lib/zen/src/markdown.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2 const code = @import("code.zig");
3 const diagnostic = @import("diagnostic.zig");
4 const diagram = @import("diagram/root.zig");
5 const document = @import("document/root.zig");
6 const footnote = @import("footnote/root.zig");
7 const html = @import("html.zig");
8 const markdown_model = @import("markdown");
9 const math = @import("math/root.zig");
10 const quiz = @import("quiz/root.zig");
11 const slides = @import("slides/root.zig");
12
13 const Allocator = std.mem.Allocator;
14
15 pub const Diagnostic = diagnostic.Diagnostic;
16
17 const RenderError = error{
18 InvalidColor,
19 InvalidContentsDirective,
20 InvalidCharacter,
21 InvalidData,
22 InvalidField,
23 InvalidFrame,
24 InlineDelimiterCapacityExceeded,
25 InvalidInlineLink,
26 InvalidJsonl,
27 InvalidRecord,
28 InvalidScale,
29 InvalidSemanticList,
30 InvalidTransform,
31 InlineNestingCapacityExceeded,
32 MissingField,
33 MissingKind,
34 UnknownData,
35 UnknownMark,
36 UnknownRecordKind,
37 UnknownTransform,
38 };
39
40 pub const Error = Allocator.Error || diagram.AsciiRenderError ||
41 diagram.DocumentError || diagram.ego.svg.FragmentError || document.Error ||
42 footnote.Error || math.Error || quiz.Error || RenderError;
43
44 const InlineError = math.Error || error{
45 InlineDelimiterCapacityExceeded,
46 InvalidInlineLink,
47 InlineNestingCapacityExceeded,
48 };
49
50 const CodeDelimiter = struct {
51 start: usize,
52 end: usize,
53 length: usize,
54 closer_end: ?usize = null,
55 };
56
57 /// Maximum backtick runs in one rendered inline fragment.
58 ///
59 /// Exceeding it returns `InlineDelimiterCapacityExceeded`; split the paragraph
60 /// or code-bearing label into smaller block-level elements. The delimiter index
61 /// occupies at most 2.5 MiB on the supported 64-bit target and is reused by
62 /// nested inline markup.
63 pub const max_inline_delimiters: usize = 65_536;
64 /// Maximum body rows in one semantic pipe table admitted by the block parser.
65 pub const max_table_body_rows: usize = markdown_model.max_table_rows - 1;
66 const code_delimiter_bytes: usize = 40;
67 const max_inline_delimiter_bytes: usize = max_inline_delimiters * code_delimiter_bytes;
68 const delimiter_capacity_reason = std.fmt.comptimePrint(
69 "inline source exceeds {d} backtick runs",
70 .{max_inline_delimiters},
71 );
72
73 const CodeDelimiters = struct {
74 items: []CodeDelimiter,
75
76 fn deinit(self: *CodeDelimiters, allocator: Allocator) void {
77 allocator.free(self.items);
78 self.* = undefined;
79 }
80
81 fn closingEnd(self: CodeDelimiters, start: usize, range_end: usize) ?usize {
82 var low: usize = 0;
83 var high = self.items.len;
84 while (low < high) {
85 const middle = low + (high - low) / 2;
86 if (self.items[middle].start < start) {
87 low = middle + 1;
88 } else {
89 high = middle;
90 }
91 }
92 if (low == self.items.len or self.items[low].start != start) return null;
93 const closer_end = self.items[low].closer_end orelse return null;
94 return if (closer_end <= range_end) closer_end else null;
95 }
96
97 fn span(
98 self: CodeDelimiters,
99 source: []const u8,
100 start: usize,
101 range_end: usize,
102 ) ?CodeSpan {
103 const closing_end = self.closingEnd(start, range_end) orelse return null;
104 const opener_end = backtickRunEnd(source, start);
105 const delimiter_length = opener_end - start;
106 return .{
107 .content = source[opener_end .. closing_end - delimiter_length],
108 .end = closing_end,
109 };
110 }
111 };
112
113 /// Maximum recursive link/emphasis levels, label brackets, or target parentheses.
114 ///
115 /// Exceeding it returns `InlineNestingCapacityExceeded`; split or simplify the
116 /// nested inline markup.
117 pub const max_inline_nesting: usize = 32;
118 const inline_nesting_reason = std.fmt.comptimePrint(
119 "inline markup nesting exceeds {d} levels",
120 .{max_inline_nesting},
121 );
122 const label_nesting_reason = std.fmt.comptimePrint(
123 "link label nesting exceeds {d} brackets",
124 .{max_inline_nesting},
125 );
126 const target_nesting_reason = std.fmt.comptimePrint(
127 "link target nesting exceeds {d} parentheses",
128 .{max_inline_nesting},
129 );
130
131 /// Selects ordinary document HTML or slide-deck HTML.
132 pub const Mode = enum {
133 /// Renders a continuous Markdown document.
134 document,
135 /// Renders explicit slides and optional speaker notes.
136 slides,
137 };
138
139 /// Bounds Markdown features and optionally captures a source-local diagnostic.
140 pub const Options = struct {
141 /// Chooses document or slide rendering.
142 mode: Mode = .document,
143 /// Emits heading IDs when true; IDs are still inspected when false.
144 heading_anchors: bool = true,
145 /// Renders `zen-diagram` fences when true and leaves them as code when false.
146 ascii_diagrams: bool = true,
147 /// Keeps one exact call-map stylesheet per rendered page when true.
148 one_call_map_style_per_page: bool = false,
149 /// Requested ASCII width, clamped to 24...200 columns.
150 diagram_width: usize = 72,
151 /// Requested ASCII height, clamped to 8...80 rows.
152 diagram_height: usize = 22,
153 /// Controls syntax-highlighted code blocks.
154 code_blocks: code.Options = .{},
155 /// Rejects Markdown whose measured heading demand exceeds these limits.
156 heading_limits: document.Limits = document.default_limits,
157 /// Rejects Markdown whose measured footnote demand exceeds these limits.
158 footnote_limits: footnote.Limits = footnote.default_limits,
159 /// Rejects one quiz fence whose measured demand exceeds these limits.
160 quiz_limits: quiz.Limits = quiz.default_limits,
161 /// Controls slide layout when `mode` is `slides`.
162 slides: slides.Options = .{},
163 /// Receives source-local repair detail after a supported parse failure.
164 diagnostic: ?*diagnostic.Diagnostic = null,
165 /// Receives one record per rendered link and image when set.
166 link_report: ?*LinkReport = null,
167 };
168
169 /// Owns the rendered HTML and the storage borrowed by its document view.
170 /// Release it with `deinit` and the allocator passed to `render`.
171 pub const Rendered = struct {
172 html: []u8,
173 document: document.Document,
174 document_storage: document.Storage,
175
176 pub fn deinit(self: *Rendered, allocator: Allocator) void {
177 allocator.free(self.html);
178 self.document_storage.reset();
179 self.document_storage.deinit(allocator);
180 self.* = undefined;
181 }
182 };
183
184 /// One link or image the renderer emitted, located in its rendered source.
185 ///
186 /// Offsets are valid only under the flag that admits them: `start` and `end`
187 /// under `located`, the target pair under `located` and a locatable target.
188 pub const RenderedLink = struct {
189 start: u32 = 0,
190 end: u32 = 0,
191 target_start: u32 = 0,
192 target_end: u32 = 0,
193 located: bool = false,
194 contiguous: bool = false,
195 image: bool = false,
196 };
197
198 /// Caller-owned, fixed-capacity sink for rendered link records.
199 ///
200 /// Records arrive in emission order, which is not document order: an
201 /// enclosing link is recorded before the label it contains.
202 pub const LinkReport = struct {
203 storage: []RenderedLink,
204 count: usize = 0,
205 overflowed: bool = false,
206
207 /// Drops every record and clears the overflow flag.
208 pub fn reset(self: *LinkReport) void {
209 self.count = 0;
210 self.overflowed = false;
211 }
212
213 /// Returns the records, borrowing `storage`.
214 pub fn links(self: *const LinkReport) []const RenderedLink {
215 return self.storage[0..self.count];
216 }
217
218 fn record(self: *LinkReport, link: RenderedLink) void {
219 if (self.count == self.storage.len) {
220 self.overflowed = true;
221 return;
222 }
223 self.storage[self.count] = link;
224 self.count += 1;
225 }
226 };
227
228 const Fence = struct {
229 marker: u8,
230 count: usize,
231 info: []const u8,
232 source_offset: usize = 0,
233 };
234
235 const EgoStylesheet = struct {
236 output_start: usize,
237 length: usize,
238 };
239
240 const QuizOwner = struct {
241 storage: ?quiz.Storage = null,
242
243 fn deinit(self: *QuizOwner, allocator: Allocator) void {
244 if (self.storage) |*storage| storage.deinit(allocator);
245 self.* = .{};
246 }
247
248 fn parse(
249 self: *QuizOwner,
250 allocator: Allocator,
251 source: []const u8,
252 limits: quiz.Limits,
253 ) Error!quiz.Quiz {
254 if (self.storage == null) {
255 var storage = try quiz.Storage.init(allocator, limits);
256 storage.activate();
257 self.storage = storage;
258 }
259 const storage = if (self.storage) |*active| active else unreachable;
260 std.debug.assert(std.meta.eql(storage.capacity.limits, limits));
261 return quiz.parse(storage, source);
262 }
263
264 fn reset(self: *QuizOwner) void {
265 const storage = if (self.storage) |*active| active else unreachable;
266 storage.reset();
267 }
268 };
269
270 const ListKind = enum {
271 unordered,
272 ordered,
273 };
274
275 const SemanticList = enum {
276 family_index,
277 module_index,
278 };
279
280 /// Computes exact heading, text, and ID demand without allocating.
281 pub fn plan(source: []const u8) document.Error!document.Plan {
282 return planDiagnostic(source, null);
283 }
284
285 fn planDiagnostic(
286 source: []const u8,
287 diag: ?*diagnostic.Diagnostic,
288 ) document.Error!document.Plan {
289 try validateHeadingAnchors(source, diag);
290 var result = document.Plan.init(source.len);
291 var iterator = HeadingIterator.init(source, diag);
292 while (try iterator.next()) |heading| {
293 const id = try resolveHeadingId(source, heading);
294 try result.includeHeading(heading.text, try id.length());
295 }
296 return result;
297 }
298
299 /// Fills active caller-owned storage; the returned document borrows it until reset.
300 pub fn inspect(storage: *document.Storage, source: []const u8) document.Error!document.Document {
301 return inspectPlanned(storage, source, try plan(source));
302 }
303
304 fn inspectPlanned(
305 storage: *document.Storage,
306 source: []const u8,
307 heading_plan: document.Plan,
308 ) document.Error!document.Document {
309 const regions = try storage.acquire(heading_plan);
310 var heading_index: usize = 0;
311 var text_index: usize = 0;
312 var id_index: usize = 0;
313 var iterator = HeadingIterator.init(source, null);
314 while (try iterator.next()) |heading| {
315 const text = regions.text[text_index..][0..heading.text.len];
316 @memcpy(text, heading.text);
317 text_index += text.len;
318 const resolved = try resolveHeadingId(source, heading);
319 const id_length = try resolved.length();
320 const id = resolved.write(regions.ids[id_index..][0..id_length]);
321 id_index += id.len;
322 regions.headings[heading_index] = .{
323 .level = heading.level,
324 .text = text,
325 .id = id,
326 .source_offset = heading.source_offset,
327 };
328 heading_index += 1;
329 }
330 std.debug.assert(heading_index == regions.plan.headings);
331 std.debug.assert(text_index == regions.plan.text_bytes);
332 std.debug.assert(id_index == regions.plan.id_bytes);
333 return .{ .headings = regions.headings };
334 }
335
336 /// Renders Markdown and returns an owner that must be deinitialized with the same allocator.
337 ///
338 /// A heading may end in `{#exact.id}`. Explicit IDs are exact, case-sensitive,
339 /// page-wide unique, and omitted from the visible heading and contents label.
340 pub fn render(allocator: Allocator, source: []const u8, options: Options) Error!Rendered {
341 const heading_plan = try planDiagnostic(source, options.diagnostic);
342 try heading_plan.require(options.heading_limits);
343 var document_storage = try document.Storage.init(allocator, heading_plan.exactLimits());
344 errdefer document_storage.deinit(allocator);
345 document_storage.activate();
346 const doc = try inspectPlanned(&document_storage, source, heading_plan);
347 errdefer document_storage.reset();
348 const footnote_plan = try footnote.Plan.inspect(source, options.footnote_limits);
349 var footnote_storage = try footnote.Storage.init(allocator, footnote_plan.exactLimits());
350 defer footnote_storage.deinit(allocator);
351 footnote_storage.activate();
352 var footnotes = try footnote.parse(&footnote_storage, source);
353 defer footnote_storage.reset();
354 var out: std.ArrayList(u8) = .empty;
355 errdefer out.deinit(allocator);
356 const track_origins = options.link_report != null;
357 var paragraph: InlineBuffer = .{ .track = track_origins };
358 defer paragraph.deinit(allocator);
359 var list_item: InlineBuffer = .{ .track = track_origins };
360 defer list_item.deinit(allocator);
361 var blockquote: InlineBuffer = .{ .track = track_origins };
362 defer blockquote.deinit(allocator);
363 var code_block: std.ArrayList(u8) = .empty;
364 defer code_block.deinit(allocator);
365 var math_block: std.ArrayList(u8) = .empty;
366 defer math_block.deinit(allocator);
367 var quiz_owner: QuizOwner = .{};
368 defer quiz_owner.deinit(allocator);
369 var table_storage: markdown_model.TableStorage = .{};
370 var math_open = false;
371 var cursor: usize = 0;
372 var code_fence: ?Fence = null;
373 var list_kind: ?ListKind = null;
374 var semantic_list: ?SemanticList = null;
375 var blockquote_open = false;
376 var footnote_definition = false;
377 var rendered_heading_index: usize = 0;
378 var ego_stylesheet: ?EgoStylesheet = null;
379 var deck = slides.Deck{ .options = options.slides };
380 if (options.mode == .slides) try deck.begin(&out, allocator);
381
382 while (true) {
383 const line_start = cursor;
384 const raw_line = nextLine(source, &cursor) orelse break;
385 const line = std.mem.trim(u8, raw_line, "\r");
386
387 if (code_fence) |fence| {
388 if (closingFence(line, fence)) {
389 try flushFence(
390 allocator,
391 &out,
392 fence,
393 code_block.items,
394 options,
395 doc,
396 &quiz_owner,
397 &ego_stylesheet,
398 );
399 code_block.clearRetainingCapacity();
400 code_fence = null;
401 } else {
402 try code_block.appendSlice(allocator, line);
403 try code_block.append(allocator, '\n');
404 }
405 continue;
406 }
407
408 if (math_open) {
409 const inner = std.mem.trim(u8, line, " \t");
410 if (std.mem.eql(u8, inner, "$$")) {
411 try flushDisplayMath(allocator, &out, &math_block, options.diagnostic);
412 math_open = false;
413 } else if (std.mem.endsWith(u8, inner, "$$")) {
414 if (math_block.items.len != 0) try math_block.append(allocator, ' ');
415 try math_block.appendSlice(allocator, std.mem.trimEnd(u8, inner[0 .. inner.len - 2], " \t"));
416 try flushDisplayMath(allocator, &out, &math_block, options.diagnostic);
417 math_open = false;
418 } else if (inner.len != 0) {
419 if (math_block.items.len != 0) try math_block.append(allocator, ' ');
420 try math_block.appendSlice(allocator, inner);
421 }
422 continue;
423 }
424
425 if (parseFence(line)) |fence| {
426 if (semantic_list != null) return error.InvalidSemanticList;
427 try flushParagraph(allocator, &out, ¶graph, &footnotes, options);
428 try closeList(allocator, &out, &list_kind, &list_item, &footnotes, options);
429 try closeBlockquote(allocator, &out, &blockquote_open, &blockquote, &footnotes, options);
430 code_block.clearRetainingCapacity();
431 var opened = fence;
432 opened.source_offset = line_start;
433 code_fence = opened;
434 footnote_definition = false;
435 continue;
436 }
437
438 const trimmed = std.mem.trim(u8, line, " \t");
439 if (parseSemanticList(trimmed)) |role| {
440 try flushParagraph(allocator, &out, ¶graph, &footnotes, options);
441 try closeList(
442 allocator,
443 &out,
444 &list_kind,
445 &list_item,
446 &footnotes,
447 options,
448 );
449 try closeBlockquote(
450 allocator,
451 &out,
452 &blockquote_open,
453 &blockquote,
454 &footnotes,
455 options,
456 );
457 if (semantic_list != null) return error.InvalidSemanticList;
458 semantic_list = role;
459 continue;
460 }
461 if (std.mem.startsWith(u8, trimmed, "$$")) {
462 if (semantic_list != null) return error.InvalidSemanticList;
463 try flushParagraph(allocator, &out, ¶graph, &footnotes, options);
464 try closeList(allocator, &out, &list_kind, &list_item, &footnotes, options);
465 try closeBlockquote(allocator, &out, &blockquote_open, &blockquote, &footnotes, options);
466 footnote_definition = false;
467 const rest = std.mem.trim(u8, trimmed[2..], " \t");
468 if (rest.len >= 2 and std.mem.endsWith(u8, rest, "$$")) {
469 try math_block.appendSlice(allocator, std.mem.trimEnd(u8, rest[0 .. rest.len - 2], " \t"));
470 try flushDisplayMath(allocator, &out, &math_block, options.diagnostic);
471 } else {
472 math_open = true;
473 try math_block.appendSlice(allocator, rest);
474 }
475 continue;
476 }
477 if (trimmed.len == 0) {
478 try flushParagraph(allocator, &out, ¶graph, &footnotes, options);
479 try closeList(allocator, &out, &list_kind, &list_item, &footnotes, options);
480 try closeBlockquote(allocator, &out, &blockquote_open, &blockquote, &footnotes, options);
481 footnote_definition = false;
482 continue;
483 }
484
485 const parsed_list_item = parseListItem(line);
486 if (semantic_list != null and parsed_list_item == null) {
487 return error.InvalidSemanticList;
488 }
489
490 if (footnote.definition(line) != null) {
491 try flushParagraph(allocator, &out, ¶graph, &footnotes, options);
492 try closeList(allocator, &out, &list_kind, &list_item, &footnotes, options);
493 try closeBlockquote(allocator, &out, &blockquote_open, &blockquote, &footnotes, options);
494 footnote_definition = true;
495 continue;
496 }
497 if (footnote_definition and footnote.continuation(line) != null) continue;
498 footnote_definition = false;
499
500 const table_source = source[line_start..];
501 switch (markdown_model.parseTable(&table_storage, table_source)) {
502 .table => |table| {
503 try flushParagraph(allocator, &out, ¶graph, &footnotes, options);
504 try closeList(
505 allocator,
506 &out,
507 &list_kind,
508 &list_item,
509 &footnotes,
510 options,
511 );
512 try closeBlockquote(
513 allocator,
514 &out,
515 &blockquote_open,
516 &blockquote,
517 &footnotes,
518 options,
519 );
520 try appendTable(
521 allocator,
522 &out,
523 source,
524 table_source,
525 table,
526 &footnotes,
527 options,
528 );
529 cursor = line_start + table.source.end;
530 continue;
531 },
532 .paragraph, .rejected => {},
533 }
534
535 if (documentationArtifactBlock(trimmed)) {
536 try flushParagraph(allocator, &out, ¶graph, &footnotes, options);
537 try closeList(
538 allocator,
539 &out,
540 &list_kind,
541 &list_item,
542 &footnotes,
543 options,
544 );
545 try closeBlockquote(
546 allocator,
547 &out,
548 &blockquote_open,
549 &blockquote,
550 &footnotes,
551 options,
552 );
553 try appendInline(
554 &out,
555 allocator,
556 trimmed,
557 &footnotes,
558 options.diagnostic,
559 originWithin(source, trimmed),
560 options.link_report,
561 );
562 try out.append(allocator, '\n');
563 continue;
564 }
565
566 if (parseBlockquote(line)) |quote| {
567 try flushParagraph(allocator, &out, ¶graph, &footnotes, options);
568 try closeList(allocator, &out, &list_kind, &list_item, &footnotes, options);
569 if (!blockquote_open) blockquote_open = true;
570 try blockquote.appendLine(allocator, source, quote);
571 continue;
572 }
573 try closeBlockquote(allocator, &out, &blockquote_open, &blockquote, &footnotes, options);
574
575 if (parsed_list_item) |item| {
576 try flushParagraph(allocator, &out, ¶graph, &footnotes, options);
577 if (list_kind == null or list_kind.? != item.kind) {
578 try closeList(allocator, &out, &list_kind, &list_item, &footnotes, options);
579 try openList(allocator, &out, item.kind, semantic_list);
580 semantic_list = null;
581 list_kind = item.kind;
582 }
583 try flushListItem(allocator, &out, &list_item, &footnotes, options);
584 try list_item.appendLine(allocator, source, item.text);
585 continue;
586 }
587 if (list_kind != null) {
588 if (listContinuation(line)) |continuation| {
589 try list_item.appendLine(allocator, source, continuation);
590 continue;
591 }
592 }
593 try closeList(allocator, &out, &list_kind, &list_item, &footnotes, options);
594
595 if (try parseHeading(line, line_start, options.diagnostic)) |heading| {
596 try flushParagraph(allocator, &out, ¶graph, &footnotes, options);
597 std.debug.assert(rendered_heading_index < doc.headings.len);
598 const inspected = doc.headings[rendered_heading_index];
599 std.debug.assert(inspected.source_offset == line_start);
600 std.debug.assert(std.mem.eql(u8, inspected.text, heading.text));
601 rendered_heading_index += 1;
602 try out.appendSlice(allocator, "<h");
603 try appendDecimal(&out, allocator, heading.level);
604 if (options.heading_anchors) {
605 try out.appendSlice(allocator, " id=\"");
606 try html.appendAttributeEscaped(&out, allocator, inspected.id);
607 try out.append(allocator, '"');
608 }
609 try out.append(allocator, '>');
610 try appendInline(
611 &out,
612 allocator,
613 heading.text,
614 &footnotes,
615 options.diagnostic,
616 originWithin(source, heading.text),
617 options.link_report,
618 );
619 try out.appendSlice(allocator, "</h");
620 try appendDecimal(&out, allocator, heading.level);
621 try out.appendSlice(allocator, ">\n");
622 continue;
623 }
624
625 if (isThematicBreak(trimmed)) {
626 try flushParagraph(allocator, &out, ¶graph, &footnotes, options);
627 try closeList(allocator, &out, &list_kind, &list_item, &footnotes, options);
628 try closeBlockquote(allocator, &out, &blockquote_open, &blockquote, &footnotes, options);
629 if (options.mode == .slides) {
630 try deck.split(&out, allocator);
631 continue;
632 }
633 try out.appendSlice(allocator, "<hr>\n");
634 continue;
635 }
636
637 try paragraph.appendLine(allocator, source, trimmed);
638 }
639
640 if (math_open) {
641 if (options.diagnostic) |d| d.setEquation(math_block.items, .{
642 .reason = "missing closing '$$'",
643 .offset = math_block.items.len,
644 });
645 return error.InvalidEquation;
646 }
647 if (code_fence) |fence| try flushFence(
648 allocator,
649 &out,
650 fence,
651 code_block.items,
652 options,
653 doc,
654 &quiz_owner,
655 &ego_stylesheet,
656 );
657 if (semantic_list != null) return error.InvalidSemanticList;
658 try flushParagraph(allocator, &out, ¶graph, &footnotes, options);
659 try closeList(allocator, &out, &list_kind, &list_item, &footnotes, options);
660 try closeBlockquote(allocator, &out, &blockquote_open, &blockquote, &footnotes, options);
661 try appendFootnotes(allocator, &out, source, &footnotes, options);
662 std.debug.assert(rendered_heading_index == doc.headings.len);
663 if (options.mode == .slides) try deck.finish(&out, allocator);
664
665 return .{
666 .html = try out.toOwnedSlice(allocator),
667 .document = doc,
668 .document_storage = document_storage,
669 };
670 }
671
672 fn flushFence(
673 allocator: Allocator,
674 out: *std.ArrayList(u8),
675 fence: Fence,
676 source: []const u8,
677 options: Options,
678 doc: document.Document,
679 quiz_owner: *QuizOwner,
680 ego_stylesheet: *?EgoStylesheet,
681 ) Error!void {
682 if (options.mode == .slides and notesFence(fence.info)) {
683 try appendSpeakerNotes(allocator, out, source, options);
684 return;
685 }
686
687 if (contentsFence(fence.info)) {
688 try appendContents(allocator, out, doc, fence, source, options.diagnostic);
689 return;
690 }
691
692 if (quizFence(fence.info)) {
693 try appendQuiz(
694 allocator,
695 out,
696 source,
697 options.diagnostic,
698 quiz_owner,
699 options.quiz_limits,
700 );
701 return;
702 }
703
704 if (egoFence(fence.info)) {
705 try diagram.ego.svg.validateDocumentFragment(source);
706 try appendEgoFragment(
707 allocator,
708 out,
709 source,
710 options.one_call_map_style_per_page,
711 ego_stylesheet,
712 );
713 return;
714 }
715
716 if (options.ascii_diagrams and diagramFence(fence.info)) {
717 var rendered = try diagram.renderAscii(allocator, source, .{
718 .width = options.diagram_width,
719 .height = options.diagram_height,
720 });
721 defer rendered.deinit(allocator);
722 try out.appendSlice(
723 allocator,
724 "<pre class=\"zen-diagram zen-role-wide\"><code>",
725 );
726 try html.appendEscaped(out, allocator, rendered.output);
727 try out.appendSlice(allocator, "</code></pre>\n");
728 return;
729 }
730
731 try code.render(allocator, out, fence.info, source, options.code_blocks);
732 }
733
734 fn appendEgoFragment(
735 allocator: Allocator,
736 out: *std.ArrayList(u8),
737 source: []const u8,
738 one_stylesheet_per_page: bool,
739 stylesheet: *?EgoStylesheet,
740 ) Allocator.Error!void {
741 const fragment = std.mem.trim(u8, source, " \t\r\n");
742 if (!one_stylesheet_per_page) {
743 try out.appendSlice(allocator, fragment);
744 try out.append(allocator, '\n');
745 return;
746 }
747 const range = egoStylesheetRange(fragment) orelse {
748 try out.appendSlice(allocator, fragment);
749 try out.append(allocator, '\n');
750 return;
751 };
752 const candidate = fragment[range.start..range.end];
753 if (stylesheet.*) |first| {
754 std.debug.assert(first.output_start <= out.items.len);
755 std.debug.assert(first.length <= out.items.len - first.output_start);
756 const retained = out.items[first.output_start..][0..first.length];
757 if (std.mem.eql(u8, retained, candidate)) {
758 try out.appendSlice(allocator, fragment[0..range.start]);
759 try out.appendSlice(allocator, fragment[range.end..]);
760 try out.append(allocator, '\n');
761 return;
762 }
763 } else {
764 stylesheet.* = .{
765 .output_start = out.items.len + range.start,
766 .length = candidate.len,
767 };
768 }
769 try out.appendSlice(allocator, fragment);
770 try out.append(allocator, '\n');
771 }
772
773 const EgoStylesheetRange = struct {
774 start: usize,
775 end: usize,
776 };
777
778 fn egoStylesheetRange(fragment: []const u8) ?EgoStylesheetRange {
779 const open = "<style>";
780 const close = "</style>";
781 const start = std.mem.indexOf(u8, fragment, open) orelse return null;
782 const close_start = std.mem.indexOfPos(u8, fragment, start + open.len, close) orelse
783 return null;
784 return .{ .start = start, .end = close_start + close.len };
785 }
786
787 fn appendSpeakerNotes(
788 allocator: Allocator,
789 out: *std.ArrayList(u8),
790 source: []const u8,
791 options: Options,
792 ) Error!void {
793 var note_options = options;
794 note_options.mode = .document;
795 note_options.slides.runtime = false;
796 var rendered = try render(allocator, source, note_options);
797 defer rendered.deinit(allocator);
798 if (std.mem.trim(u8, rendered.html, " \t\r\n").len == 0) return;
799 try out.appendSlice(allocator, "<aside class=\"zen-speaker-notes\" data-zen-speaker-notes hidden>\n");
800 try out.appendSlice(allocator, rendered.html);
801 try out.appendSlice(allocator, "</aside>\n");
802 }
803
804 const ContentsScope = enum {
805 document,
806 following,
807 };
808
809 const ContentsOptions = struct {
810 scope: ContentsScope = .following,
811 min_level: u8 = 1,
812 max_level: u8 = 6,
813 title: []const u8 = "Contents:",
814 };
815
816 fn contentsFence(info: []const u8) bool {
817 const language = firstFenceWord(info);
818 return std.mem.eql(u8, language, "zen-toc") or
819 std.mem.eql(u8, language, "zen-contents") or
820 std.mem.eql(u8, language, "toc");
821 }
822
823 fn egoFence(info: []const u8) bool {
824 return std.mem.eql(u8, firstFenceWord(info), "zen-ego");
825 }
826
827 fn appendContents(
828 allocator: Allocator,
829 out: *std.ArrayList(u8),
830 doc: document.Document,
831 fence: Fence,
832 source: []const u8,
833 diag: ?*diagnostic.Diagnostic,
834 ) !void {
835 const options = try parseContentsOptions(source);
836 var count: usize = 0;
837 for (doc.headings) |heading| {
838 if (!contentsIncludes(options, fence, heading)) continue;
839 count += 1;
840 }
841 if (count == 0) return;
842
843 try out.appendSlice(
844 allocator,
845 "<nav class=\"zen-contents zen-role-wide\" aria-label=\"Contents\">\n<p>",
846 );
847 try html.appendEscaped(out, allocator, options.title);
848 try out.appendSlice(allocator, "</p>\n<ul>\n");
849 for (doc.headings) |heading| {
850 if (!contentsIncludes(options, fence, heading)) continue;
851 try appendContentsItem(allocator, out, heading, options, diag);
852 try out.appendSlice(allocator, "</li>\n");
853 }
854 try out.appendSlice(allocator, "</ul>\n</nav>\n");
855 }
856
857 fn appendContentsItem(
858 allocator: Allocator,
859 out: *std.ArrayList(u8),
860 heading: document.Heading,
861 options: ContentsOptions,
862 diag: ?*diagnostic.Diagnostic,
863 ) !void {
864 try out.appendSlice(allocator, "<li class=\"zen-contents-level-");
865 try appendDecimal(out, allocator, heading.level);
866 if (heading.level > options.min_level) {
867 try out.appendSlice(allocator, "\" style=\"margin-left:calc(");
868 try appendDecimal(out, allocator, heading.level - options.min_level);
869 try out.appendSlice(allocator, " * 1.5rem)\"><a href=\"#");
870 } else {
871 try out.appendSlice(allocator, "\"><a href=\"#");
872 }
873 try html.appendAttributeEscaped(out, allocator, heading.id);
874 try out.appendSlice(allocator, "\">");
875 try appendInline(out, allocator, heading.text, null, diag, .untracked, null);
876 try out.appendSlice(allocator, "</a>");
877 }
878
879 fn contentsIncludes(options: ContentsOptions, fence: Fence, heading: document.Heading) bool {
880 if (heading.level < options.min_level or heading.level > options.max_level) return false;
881 return switch (options.scope) {
882 .document => true,
883 .following => heading.source_offset > fence.source_offset,
884 };
885 }
886
887 fn parseContentsOptions(source: []const u8) !ContentsOptions {
888 var options: ContentsOptions = .{};
889 var cursor: usize = 0;
890 while (nextLine(source, &cursor)) |raw_line| {
891 const line = std.mem.trim(u8, std.mem.trim(u8, raw_line, "\r"), " \t");
892 if (line.len == 0) continue;
893 const split = std.mem.indexOfScalar(u8, line, ':') orelse return error.InvalidContentsDirective;
894 const key = std.mem.trim(u8, line[0..split], " \t");
895 const value = std.mem.trim(u8, line[split + 1 ..], " \t");
896 if (std.mem.eql(u8, key, "scope")) {
897 options.scope = if (std.mem.eql(u8, value, "document") or std.mem.eql(u8, value, "page"))
898 .document
899 else if (std.mem.eql(u8, value, "following"))
900 .following
901 else
902 return error.InvalidContentsDirective;
903 } else if (std.mem.eql(u8, key, "min-level")) {
904 options.min_level = try parseHeadingLevel(value);
905 } else if (std.mem.eql(u8, key, "max-level")) {
906 options.max_level = try parseHeadingLevel(value);
907 } else if (std.mem.eql(u8, key, "title")) {
908 options.title = value;
909 } else {
910 return error.InvalidContentsDirective;
911 }
912 }
913 if (options.min_level > options.max_level) return error.InvalidContentsDirective;
914 return options;
915 }
916
917 fn parseHeadingLevel(value: []const u8) !u8 {
918 const level = std.fmt.parseUnsigned(u8, value, 10) catch return error.InvalidContentsDirective;
919 if (level < 1 or level > 6) return error.InvalidContentsDirective;
920 return level;
921 }
922
923 fn diagramFence(info: []const u8) bool {
924 const language = firstFenceWord(info);
925 return std.mem.eql(u8, language, "diagram") or
926 std.mem.eql(u8, language, "zen-diagram") or
927 std.mem.eql(u8, language, "zen-ascii");
928 }
929
930 fn quizFence(info: []const u8) bool {
931 const language = firstFenceWord(info);
932 return std.mem.eql(u8, language, "zen-quiz") or
933 std.mem.eql(u8, language, "quiz");
934 }
935
936 fn appendQuiz(
937 allocator: Allocator,
938 out: *std.ArrayList(u8),
939 source: []const u8,
940 diag: ?*diagnostic.Diagnostic,
941 owner: *QuizOwner,
942 limits: quiz.Limits,
943 ) Error!void {
944 const parsed = try owner.parse(allocator, source, limits);
945 defer owner.reset();
946
947 try out.appendSlice(allocator, "<section class=\"zen-quiz\" aria-label=\"Quiz\">\n<ol class=\"zen-quiz-questions\">\n");
948 for (parsed.questions) |question| {
949 try out.appendSlice(allocator, "<li class=\"zen-quiz-question\">\n<p class=\"zen-quiz-prompt\">");
950 try appendInline(out, allocator, question.prompt, null, diag, .untracked, null);
951 try out.appendSlice(allocator, "</p>\n");
952 if (question.options.len != 0) {
953 try out.appendSlice(allocator, "<ul class=\"zen-quiz-options\">\n");
954 for (question.options) |option| {
955 try out.appendSlice(allocator, "<li>");
956 try appendInline(out, allocator, option.text, null, diag, .untracked, null);
957 try out.appendSlice(allocator, "</li>\n");
958 }
959 try out.appendSlice(allocator, "</ul>\n");
960 }
961 try out.appendSlice(allocator, "<details class=\"zen-quiz-answer\">\n<summary>Reveal answer</summary>\n");
962 for (question.options) |option| {
963 if (!option.correct) continue;
964 try out.appendSlice(allocator, "<p class=\"zen-quiz-correct\"><strong>");
965 try appendInline(out, allocator, option.text, null, diag, .untracked, null);
966 try out.appendSlice(allocator, "</strong></p>\n");
967 }
968 if (question.explanation.len != 0) {
969 try out.appendSlice(allocator, "<p class=\"zen-quiz-explanation\">");
970 try appendInline(out, allocator, question.explanation, null, diag, .untracked, null);
971 try out.appendSlice(allocator, "</p>\n");
972 }
973 try out.appendSlice(allocator, "</details>\n</li>\n");
974 }
975 try out.appendSlice(allocator, "</ol>\n</section>\n");
976 }
977
978 fn notesFence(info: []const u8) bool {
979 const language = firstFenceWord(info);
980 return std.mem.eql(u8, language, "notes") or
981 std.mem.eql(u8, language, "speaker-notes") or
982 std.mem.eql(u8, language, "zen-notes");
983 }
984
985 fn firstFenceWord(info: []const u8) []const u8 {
986 const trimmed = std.mem.trim(u8, info, " \t");
987 var end: usize = 0;
988 while (end < trimmed.len and trimmed[end] != ' ' and trimmed[end] != '\t') : (end += 1) {}
989 return trimmed[0..end];
990 }
991
992 const MathMode = enum { inline_math, display };
993
994 fn renderMath(
995 out: *std.ArrayList(u8),
996 allocator: Allocator,
997 source: []const u8,
998 mode: MathMode,
999 diag: ?*diagnostic.Diagnostic,
1000 ) math.Error!void {
1001 var detail: math.Diagnostic = .{};
1002 (switch (mode) {
1003 .inline_math => math.renderInline(out, allocator, source, &detail),
1004 .display => math.renderDisplay(out, allocator, source, &detail),
1005 }) catch |err| {
1006 switch (err) {
1007 error.InvalidEquation => if (diag) |d| d.setEquation(source, detail),
1008 error.OutOfMemory => {},
1009 }
1010 return err;
1011 };
1012 }
1013
1014 fn flushDisplayMath(
1015 allocator: Allocator,
1016 out: *std.ArrayList(u8),
1017 block: *std.ArrayList(u8),
1018 diag: ?*diagnostic.Diagnostic,
1019 ) math.Error!void {
1020 try renderMath(out, allocator, block.items, .display, diag);
1021 try out.append(allocator, '\n');
1022 block.clearRetainingCapacity();
1023 }
1024
1025 fn flushParagraph(
1026 allocator: Allocator,
1027 out: *std.ArrayList(u8),
1028 paragraph: *InlineBuffer,
1029 footnotes: *footnote.Set,
1030 options: Options,
1031 ) InlineError!void {
1032 if (paragraph.items().len == 0) return;
1033 const container = try paragraphContainer(
1034 allocator,
1035 paragraph.items(),
1036 options.mode,
1037 options.diagnostic,
1038 );
1039 if (container) |value| {
1040 try out.appendSlice(allocator, "<p class=\"");
1041 try out.appendSlice(allocator, value.classes);
1042 try out.appendSlice(allocator, "\">");
1043 } else {
1044 try out.appendSlice(allocator, "<p>");
1045 }
1046 const full_size_image = if (container) |value| value.full_size_image else null;
1047 if (full_size_image) |image| {
1048 try out.appendSlice(allocator, "<a href=\"");
1049 try appendMarkdownEscaped(out, allocator, image.target);
1050 try out.appendSlice(
1051 allocator,
1052 "\" target=\"_blank\" rel=\"noopener\" aria-label=\"Open ",
1053 );
1054 try appendMarkdownEscaped(out, allocator, image.label);
1055 try out.appendSlice(allocator, " at full size\">");
1056 }
1057 try appendInline(
1058 out,
1059 allocator,
1060 paragraph.items(),
1061 footnotes,
1062 options.diagnostic,
1063 paragraph.origin(),
1064 options.link_report,
1065 );
1066 if (full_size_image != null) try out.appendSlice(allocator, "</a>");
1067 try out.appendSlice(allocator, "</p>\n");
1068 paragraph.clearRetainingCapacity();
1069 }
1070
1071 fn appendTable(
1072 allocator: Allocator,
1073 out: *std.ArrayList(u8),
1074 source: []const u8,
1075 table_source: []const u8,
1076 table: markdown_model.Table,
1077 footnotes: *footnote.Set,
1078 options: Options,
1079 ) InlineError!void {
1080 const alignments = table.delimiter.alignments;
1081 std.debug.assert(table.header.cells.len == alignments.len);
1082 try out.appendSlice(
1083 allocator,
1084 "<div class=\"content-table zen-role-wide\" tabindex=\"0\">\n",
1085 );
1086 try out.appendSlice(allocator, "<table>\n<thead>\n");
1087 try appendTableRow(
1088 allocator,
1089 out,
1090 source,
1091 table_source,
1092 table.header,
1093 alignments,
1094 true,
1095 footnotes,
1096 options,
1097 );
1098 try out.appendSlice(allocator, "</thead>\n<tbody>\n");
1099 for (table.body) |row| {
1100 std.debug.assert(row.cells.len == alignments.len);
1101 try appendTableRow(
1102 allocator,
1103 out,
1104 source,
1105 table_source,
1106 row,
1107 alignments,
1108 false,
1109 footnotes,
1110 options,
1111 );
1112 }
1113 try out.appendSlice(allocator, "</tbody>\n</table>\n</div>\n");
1114 }
1115
1116 fn appendTableRow(
1117 allocator: Allocator,
1118 out: *std.ArrayList(u8),
1119 source: []const u8,
1120 table_source: []const u8,
1121 row: markdown_model.TableRow,
1122 alignments: []const markdown_model.TableAlignment,
1123 header: bool,
1124 footnotes: *footnote.Set,
1125 options: Options,
1126 ) InlineError!void {
1127 std.debug.assert(row.cells.len == alignments.len);
1128 try out.appendSlice(allocator, "<tr>\n");
1129 for (row.cells, alignments) |cell, alignment| {
1130 if (header) {
1131 try out.appendSlice(allocator, "<th scope=\"col\"");
1132 } else {
1133 try out.appendSlice(allocator, "<td");
1134 }
1135 if (tableAlignmentClass(alignment)) |class| {
1136 try out.appendSlice(allocator, " class=\"");
1137 try out.appendSlice(allocator, class);
1138 try out.append(allocator, '"');
1139 }
1140 try out.append(allocator, '>');
1141 const cell_text = cell.source.bytes(table_source);
1142 try appendInline(
1143 out,
1144 allocator,
1145 cell_text,
1146 footnotes,
1147 options.diagnostic,
1148 originWithin(source, cell_text),
1149 options.link_report,
1150 );
1151 try out.appendSlice(allocator, if (header) "</th>\n" else "</td>\n");
1152 }
1153 try out.appendSlice(allocator, "</tr>\n");
1154 }
1155
1156 fn tableAlignmentClass(alignment: markdown_model.TableAlignment) ?[]const u8 {
1157 return switch (alignment) {
1158 .none => null,
1159 .left => "zen-table-align-left",
1160 .center => "zen-table-align-center",
1161 .right => "zen-table-align-right",
1162 };
1163 }
1164
1165 fn flushListItem(
1166 allocator: Allocator,
1167 out: *std.ArrayList(u8),
1168 list_item: *InlineBuffer,
1169 footnotes: *footnote.Set,
1170 options: Options,
1171 ) InlineError!void {
1172 if (list_item.items().len == 0) return;
1173 try out.appendSlice(allocator, "<li>");
1174 try appendInline(
1175 out,
1176 allocator,
1177 list_item.items(),
1178 footnotes,
1179 options.diagnostic,
1180 list_item.origin(),
1181 options.link_report,
1182 );
1183 try out.appendSlice(allocator, "</li>\n");
1184 list_item.clearRetainingCapacity();
1185 }
1186
1187 fn openList(
1188 allocator: Allocator,
1189 out: *std.ArrayList(u8),
1190 kind: ListKind,
1191 semantic: ?SemanticList,
1192 ) Allocator.Error!void {
1193 try out.appendSlice(allocator, switch (kind) {
1194 .unordered => "<ul",
1195 .ordered => "<ol",
1196 });
1197 if (semantic) |role| {
1198 try out.appendSlice(allocator, " class=\"");
1199 try out.appendSlice(allocator, semanticListClasses(role));
1200 try out.append(allocator, '"');
1201 }
1202 try out.appendSlice(allocator, ">\n");
1203 }
1204
1205 fn closeList(
1206 allocator: Allocator,
1207 out: *std.ArrayList(u8),
1208 list_kind: *?ListKind,
1209 list_item: *InlineBuffer,
1210 footnotes: *footnote.Set,
1211 options: Options,
1212 ) InlineError!void {
1213 if (list_kind.*) |kind| {
1214 try flushListItem(allocator, out, list_item, footnotes, options);
1215 switch (kind) {
1216 .unordered => try out.appendSlice(allocator, "</ul>\n"),
1217 .ordered => try out.appendSlice(allocator, "</ol>\n"),
1218 }
1219 list_kind.* = null;
1220 }
1221 }
1222
1223 fn closeBlockquote(
1224 allocator: Allocator,
1225 out: *std.ArrayList(u8),
1226 open: *bool,
1227 blockquote: *InlineBuffer,
1228 footnotes: *footnote.Set,
1229 options: Options,
1230 ) InlineError!void {
1231 if (!open.*) return;
1232 try out.appendSlice(allocator, "<blockquote><p>");
1233 try appendInline(
1234 out,
1235 allocator,
1236 blockquote.items(),
1237 footnotes,
1238 options.diagnostic,
1239 blockquote.origin(),
1240 options.link_report,
1241 );
1242 try out.appendSlice(allocator, "</p></blockquote>\n");
1243 open.* = false;
1244 blockquote.clearRetainingCapacity();
1245 }
1246
1247 fn appendDecimal(out: *std.ArrayList(u8), allocator: Allocator, value: u8) Allocator.Error!void {
1248 var buffer: [3]u8 = undefined;
1249 const text = std.fmt.bufPrint(&buffer, "{d}", .{value}) catch unreachable;
1250 try out.appendSlice(allocator, text);
1251 }
1252
1253 fn appendUsize(out: *std.ArrayList(u8), allocator: Allocator, value: usize) Allocator.Error!void {
1254 var buffer: [20]u8 = undefined;
1255 const text = std.fmt.bufPrint(&buffer, "{d}", .{value}) catch unreachable;
1256 try out.appendSlice(allocator, text);
1257 }
1258
1259 fn nextLine(source: []const u8, cursor: *usize) ?[]const u8 {
1260 if (cursor.* >= source.len) return null;
1261 const start = cursor.*;
1262 if (std.mem.indexOfScalarPos(u8, source, start, '\n')) |end| {
1263 cursor.* = end + 1;
1264 return source[start..end];
1265 }
1266 cursor.* = source.len;
1267 return source[start..];
1268 }
1269
1270 fn parseFence(line: []const u8) ?Fence {
1271 const trimmed = trimLeft(line);
1272 if (trimmed.len < 3) return null;
1273 const marker = trimmed[0];
1274 if (marker != '`' and marker != '~') return null;
1275 var count: usize = 0;
1276 while (count < trimmed.len and trimmed[count] == marker) : (count += 1) {}
1277 if (count < 3) return null;
1278 return .{
1279 .marker = marker,
1280 .count = count,
1281 .info = std.mem.trim(u8, trimmed[count..], " \t"),
1282 };
1283 }
1284
1285 fn closingFence(line: []const u8, fence: Fence) bool {
1286 const trimmed = std.mem.trim(u8, line, " \t");
1287 var count: usize = 0;
1288 while (count < trimmed.len and trimmed[count] == fence.marker) : (count += 1) {}
1289 return count >= fence.count and std.mem.trim(u8, trimmed[count..], " \t").len == 0;
1290 }
1291
1292 const Heading = struct {
1293 level: u8,
1294 text: []const u8,
1295 explicit_id: ?[]const u8 = null,
1296 anchor_offset: ?usize = null,
1297 };
1298
1299 fn parseHeading(
1300 line: []const u8,
1301 source_offset: usize,
1302 diag: ?*diagnostic.Diagnostic,
1303 ) document.Error!?Heading {
1304 const trimmed = trimLeft(line);
1305 var level: u8 = 0;
1306 while (level < trimmed.len and level < 6 and trimmed[level] == '#') : (level += 1) {}
1307 if (level == 0) return null;
1308 if (level < trimmed.len and trimmed[level] != ' ' and trimmed[level] != '\t') return null;
1309 const text = std.mem.trim(u8, trimmed[level..], " \t#");
1310 const anchor_start = headingAnchorStart(text) orelse return .{
1311 .level = level,
1312 .text = text,
1313 };
1314 const anchor_offset = source_offset +
1315 (@intFromPtr(text.ptr) - @intFromPtr(line.ptr)) + anchor_start;
1316 const anchor = text[anchor_start..];
1317 if (anchor.len < 4 or anchor[anchor.len - 1] != '}') {
1318 setHeadingDiagnostic(diag, "invalid heading anchor", anchor_offset);
1319 return error.InvalidHeadingAnchor;
1320 }
1321 const explicit_id = anchor[2 .. anchor.len - 1];
1322 if (!validHeadingId(explicit_id)) {
1323 setHeadingDiagnostic(diag, "invalid heading anchor", anchor_offset);
1324 return error.InvalidHeadingAnchor;
1325 }
1326 return .{
1327 .level = level,
1328 .text = std.mem.trimEnd(u8, text[0..anchor_start], " \t"),
1329 .explicit_id = explicit_id,
1330 .anchor_offset = anchor_offset,
1331 };
1332 }
1333
1334 fn headingAnchorStart(text: []const u8) ?usize {
1335 var index = text.len;
1336 while (index > 1) : (index -= 1) {
1337 const start = index - 2;
1338 if (text[start] != '{' or text[start + 1] != '#') continue;
1339 if (start == 0 or text[start - 1] == ' ' or text[start - 1] == '\t') {
1340 return start;
1341 }
1342 }
1343 return null;
1344 }
1345
1346 fn validHeadingId(id: []const u8) bool {
1347 if (id.len == 0) return false;
1348 for (id) |byte| {
1349 if (asciiAlphanumeric(byte)) continue;
1350 if (byte != '-' and byte != '_' and byte != '.' and byte != ':' and
1351 byte != '/' and byte != '~') return false;
1352 }
1353 return true;
1354 }
1355
1356 fn setHeadingDiagnostic(
1357 diag: ?*diagnostic.Diagnostic,
1358 reason: []const u8,
1359 offset: usize,
1360 ) void {
1361 if (diag) |active| active.setHeading(reason, offset);
1362 }
1363
1364 const LocatedHeading = struct {
1365 level: u8,
1366 text: []const u8,
1367 explicit_id: ?[]const u8,
1368 anchor_offset: ?usize,
1369 source_offset: usize,
1370 };
1371
1372 const HeadingIterator = struct {
1373 source: []const u8,
1374 diagnostic: ?*diagnostic.Diagnostic,
1375 cursor: usize = 0,
1376 code_fence: ?Fence = null,
1377 math_open: bool = false,
1378 footnote_definition: bool = false,
1379 list_open: bool = false,
1380
1381 fn init(source: []const u8, diag: ?*diagnostic.Diagnostic) HeadingIterator {
1382 return .{ .source = source, .diagnostic = diag };
1383 }
1384
1385 fn next(self: *HeadingIterator) document.Error!?LocatedHeading {
1386 while (true) {
1387 const line_start = self.cursor;
1388 const raw_line = nextLine(self.source, &self.cursor) orelse return null;
1389 const line = std.mem.trim(u8, raw_line, "\r");
1390
1391 if (self.code_fence) |fence| {
1392 if (closingFence(line, fence)) self.code_fence = null;
1393 continue;
1394 }
1395
1396 if (self.math_open) {
1397 const inner = std.mem.trim(u8, line, " \t");
1398 if (std.mem.eql(u8, inner, "$$") or
1399 std.mem.endsWith(u8, inner, "$$")) self.math_open = false;
1400 continue;
1401 }
1402
1403 if (parseFence(line)) |parsed| {
1404 self.code_fence = parsed;
1405 self.footnote_definition = false;
1406 self.list_open = false;
1407 continue;
1408 }
1409
1410 const trimmed = std.mem.trim(u8, line, " \t");
1411 if (std.mem.startsWith(u8, trimmed, "$$")) {
1412 const rest = std.mem.trim(u8, trimmed[2..], " \t");
1413 self.math_open = !(rest.len >= 2 and std.mem.endsWith(u8, rest, "$$"));
1414 self.footnote_definition = false;
1415 self.list_open = false;
1416 continue;
1417 }
1418 if (trimmed.len == 0) {
1419 self.footnote_definition = false;
1420 self.list_open = false;
1421 continue;
1422 }
1423
1424 if (footnote.definition(line) != null) {
1425 self.footnote_definition = true;
1426 self.list_open = false;
1427 continue;
1428 }
1429 if (self.footnote_definition and footnote.continuation(line) != null) continue;
1430 self.footnote_definition = false;
1431
1432 if (parseBlockquote(line) != null) {
1433 self.list_open = false;
1434 continue;
1435 }
1436
1437 if (parseListItem(line) != null) {
1438 self.list_open = true;
1439 continue;
1440 }
1441 if (self.list_open and listContinuation(line) != null) continue;
1442 self.list_open = false;
1443
1444 if (try parseHeading(line, line_start, self.diagnostic)) |heading| return .{
1445 .level = heading.level,
1446 .text = heading.text,
1447 .explicit_id = heading.explicit_id,
1448 .anchor_offset = heading.anchor_offset,
1449 .source_offset = line_start,
1450 };
1451 }
1452 }
1453 };
1454
1455 const ResolvedHeadingId = union(enum) {
1456 explicit: []const u8,
1457 generated: struct {
1458 text: []const u8,
1459 suffix: ?usize,
1460 },
1461
1462 fn length(self: ResolvedHeadingId) error{CapacityOverflow}!usize {
1463 return switch (self) {
1464 .explicit => |id| id.len,
1465 .generated => |generated| if (generated.suffix) |suffix|
1466 std.math.add(
1467 usize,
1468 html.slugLength(generated.text),
1469 1 + decimalLength(suffix),
1470 ) catch error.CapacityOverflow
1471 else
1472 html.slugLength(generated.text),
1473 };
1474 }
1475
1476 fn write(self: ResolvedHeadingId, out: []u8) []u8 {
1477 std.debug.assert(out.len == (self.length() catch unreachable));
1478 switch (self) {
1479 .explicit => |id| @memcpy(out, id),
1480 .generated => |generated| {
1481 const base_length = html.slugLength(generated.text);
1482 _ = html.writeSlug(out[0..base_length], generated.text);
1483 if (generated.suffix) |suffix| {
1484 out[base_length] = '~';
1485 _ = std.fmt.bufPrint(out[base_length + 1 ..], "{d}", .{suffix}) catch
1486 unreachable;
1487 }
1488 },
1489 }
1490 return out;
1491 }
1492 };
1493
1494 fn validateHeadingAnchors(
1495 source: []const u8,
1496 diag: ?*diagnostic.Diagnostic,
1497 ) document.Error!void {
1498 var iterator = HeadingIterator.init(source, diag);
1499 while (try iterator.next()) |heading| {
1500 const id = heading.explicit_id orelse continue;
1501 var prior = HeadingIterator.init(source, null);
1502 while (try prior.next()) |candidate| {
1503 if (candidate.source_offset >= heading.source_offset) break;
1504 const candidate_id = candidate.explicit_id orelse continue;
1505 if (!std.mem.eql(u8, candidate_id, id)) continue;
1506 setHeadingDiagnostic(diag, "duplicate heading anchor", heading.anchor_offset.?);
1507 return error.DuplicateHeadingAnchor;
1508 }
1509 }
1510 }
1511
1512 fn resolveHeadingId(
1513 source: []const u8,
1514 target: LocatedHeading,
1515 ) document.Error!ResolvedHeadingId {
1516 if (target.explicit_id) |id| return .{ .explicit = id };
1517 var occurrence: usize = 0;
1518 var explicit_reserved = false;
1519 var iterator = HeadingIterator.init(source, null);
1520 while (try iterator.next()) |heading| {
1521 if (heading.explicit_id) |id| {
1522 explicit_reserved = explicit_reserved or slugMatches(target.text, id);
1523 continue;
1524 }
1525 if (heading.source_offset > target.source_offset) continue;
1526 if (!slugsEqual(target.text, heading.text)) continue;
1527 occurrence = std.math.add(usize, occurrence, 1) catch
1528 return error.CapacityOverflow;
1529 }
1530 std.debug.assert(occurrence > 0);
1531 const suffix = if (explicit_reserved or occurrence > 1) occurrence else null;
1532 return .{ .generated = .{ .text = target.text, .suffix = suffix } };
1533 }
1534
1535 const SlugIterator = struct {
1536 source: []const u8,
1537 index: usize = 0,
1538 emitted: usize = 0,
1539 pending_separator: bool = false,
1540 delayed: ?u8 = null,
1541 fallback_index: usize = 0,
1542
1543 fn next(self: *SlugIterator) ?u8 {
1544 if (self.delayed) |byte| {
1545 self.delayed = null;
1546 self.emitted += 1;
1547 return byte;
1548 }
1549 while (self.index < self.source.len) {
1550 const byte = self.source[self.index];
1551 self.index += 1;
1552 if (asciiAlphanumeric(byte)) {
1553 const normalized = std.ascii.toLower(byte);
1554 if (self.pending_separator and self.emitted != 0) {
1555 self.pending_separator = false;
1556 self.delayed = normalized;
1557 self.emitted += 1;
1558 return '-';
1559 }
1560 self.pending_separator = false;
1561 self.emitted += 1;
1562 return normalized;
1563 }
1564 if (byte == ' ' or byte == '\t' or byte == '-' or byte == '_') {
1565 if (self.emitted != 0) self.pending_separator = true;
1566 }
1567 }
1568 if (self.emitted != 0 or self.fallback_index == "section".len) return null;
1569 const byte = "section"[self.fallback_index];
1570 self.fallback_index += 1;
1571 return byte;
1572 }
1573 };
1574
1575 fn slugsEqual(left: []const u8, right: []const u8) bool {
1576 var left_iterator = SlugIterator{ .source = left };
1577 var right_iterator = SlugIterator{ .source = right };
1578 while (true) {
1579 const left_byte = left_iterator.next();
1580 const right_byte = right_iterator.next();
1581 if (left_byte == null or right_byte == null) return left_byte == right_byte;
1582 if (left_byte.? != right_byte.?) return false;
1583 }
1584 }
1585
1586 fn slugMatches(text: []const u8, id: []const u8) bool {
1587 var iterator = SlugIterator{ .source = text };
1588 var index: usize = 0;
1589 while (iterator.next()) |byte| {
1590 if (index == id.len or id[index] != byte) return false;
1591 index += 1;
1592 }
1593 return index == id.len;
1594 }
1595
1596 fn asciiAlphanumeric(byte: u8) bool {
1597 return (byte >= 'a' and byte <= 'z') or
1598 (byte >= 'A' and byte <= 'Z') or
1599 (byte >= '0' and byte <= '9');
1600 }
1601
1602 fn decimalLength(value: usize) usize {
1603 var buffer: [20]u8 = undefined;
1604 return (std.fmt.bufPrint(&buffer, "{d}", .{value}) catch unreachable).len;
1605 }
1606
1607 const ListItem = struct {
1608 kind: ListKind,
1609 text: []const u8,
1610 };
1611
1612 fn parseListItem(line: []const u8) ?ListItem {
1613 const trimmed = trimLeft(line);
1614 if (trimmed.len >= 2 and (trimmed[0] == '-' or trimmed[0] == '*' or trimmed[0] == '+') and (trimmed[1] == ' ' or trimmed[1] == '\t')) {
1615 return .{ .kind = .unordered, .text = std.mem.trim(u8, trimmed[2..], " \t") };
1616 }
1617 var index: usize = 0;
1618 while (index < trimmed.len and trimmed[index] >= '0' and trimmed[index] <= '9') : (index += 1) {}
1619 if (index == 0 or index + 1 >= trimmed.len or trimmed[index] != '.') return null;
1620 if (trimmed[index + 1] != ' ' and trimmed[index + 1] != '\t') return null;
1621 return .{ .kind = .ordered, .text = std.mem.trim(u8, trimmed[index + 2 ..], " \t") };
1622 }
1623
1624 fn parseSemanticList(line: []const u8) ?SemanticList {
1625 if (std.mem.eql(
1626 u8,
1627 line,
1628 "{.docs-index-family .zen-role-wide}",
1629 )) return .family_index;
1630 if (std.mem.eql(
1631 u8,
1632 line,
1633 "{.docs-index-module .zen-role-wide}",
1634 )) return .module_index;
1635 return null;
1636 }
1637
1638 fn semanticListClasses(role: SemanticList) []const u8 {
1639 return switch (role) {
1640 .family_index => "docs-index-family zen-role-wide",
1641 .module_index => "docs-index-module zen-role-wide",
1642 };
1643 }
1644
1645 fn listContinuation(line: []const u8) ?[]const u8 {
1646 var index: usize = 0;
1647 while (index < line.len and (line[index] == ' ' or line[index] == '\t')) : (index += 1) {}
1648 if (index == 0) return null;
1649 const continuation = std.mem.trim(u8, line[index..], " \t");
1650 if (continuation.len == 0) return null;
1651 return continuation;
1652 }
1653
1654 fn parseBlockquote(line: []const u8) ?[]const u8 {
1655 const trimmed = trimLeft(line);
1656 if (trimmed.len == 0 or trimmed[0] != '>') return null;
1657 return std.mem.trim(u8, trimmed[1..], " \t");
1658 }
1659
1660 fn isThematicBreak(line: []const u8) bool {
1661 if (line.len < 3) return false;
1662 const marker = line[0];
1663 if (marker != '-' and marker != '*' and marker != '_') return false;
1664 for (line) |byte| {
1665 if (byte != marker) return false;
1666 }
1667 return true;
1668 }
1669
1670 fn trimLeft(line: []const u8) []const u8 {
1671 var index: usize = 0;
1672 while (index < line.len and (line[index] == ' ' or line[index] == '\t')) : (index += 1) {}
1673 return line[index..];
1674 }
1675
1676 fn appendFootnotes(
1677 allocator: Allocator,
1678 out: *std.ArrayList(u8),
1679 source: []const u8,
1680 footnotes: *footnote.Set,
1681 options: Options,
1682 ) InlineError!void {
1683 if (!footnotes.hasReferences()) return;
1684 try out.appendSlice(allocator, "<section class=\"footnotes\" aria-label=\"References\">\n<ol>\n");
1685 var number: usize = 1;
1686 while (number < footnotes.next_number) : (number += 1) {
1687 for (footnotes.definitions) |definition| {
1688 if (definition.number != number) continue;
1689 try out.appendSlice(allocator, "<li id=\"fn-");
1690 try html.appendAttributeEscaped(out, allocator, definition.key);
1691 try out.appendSlice(allocator, "\">");
1692 try appendInline(
1693 out,
1694 allocator,
1695 definition.text,
1696 null,
1697 options.diagnostic,
1698 originWithin(source, definition.text),
1699 options.link_report,
1700 );
1701 try out.appendSlice(allocator, " <a class=\"footnote-backref\" href=\"#fnref-");
1702 try html.appendAttributeEscaped(out, allocator, definition.key);
1703 try out.appendSlice(allocator, "\" aria-label=\"Back to reference\">↩</a></li>\n");
1704 }
1705 }
1706 try out.appendSlice(allocator, "</ol>\n</section>\n");
1707 }
1708
1709 fn appendFootnoteReference(
1710 out: *std.ArrayList(u8),
1711 allocator: Allocator,
1712 footnotes: ?*footnote.Set,
1713 key: []const u8,
1714 ) Allocator.Error!bool {
1715 const notes = footnotes orelse return false;
1716 const mark = notes.markFor(key) orelse return false;
1717 try out.appendSlice(allocator, "<sup id=\"fnref-");
1718 try html.appendAttributeEscaped(out, allocator, key);
1719 if (mark.reference > 1) {
1720 try out.append(allocator, '-');
1721 try appendUsize(out, allocator, mark.reference);
1722 }
1723 try out.appendSlice(allocator, "\"><a class=\"footnote-ref\" href=\"#fn-");
1724 try html.appendAttributeEscaped(out, allocator, key);
1725 try out.appendSlice(allocator, "\">[");
1726 try appendUsize(out, allocator, mark.number);
1727 try out.appendSlice(allocator, "]</a></sup>");
1728 return true;
1729 }
1730
1731 fn appendInline(
1732 out: *std.ArrayList(u8),
1733 allocator: Allocator,
1734 value: []const u8,
1735 footnotes: ?*footnote.Set,
1736 diag: ?*diagnostic.Diagnostic,
1737 origin: InlineOrigin,
1738 report: ?*LinkReport,
1739 ) InlineError!void {
1740 var delimiters = indexCodeDelimiters(allocator, value, null) catch |err| {
1741 if (err == error.InlineDelimiterCapacityExceeded) {
1742 if (diag) |detail| detail.setInline(value, delimiter_capacity_reason, 0);
1743 }
1744 return err;
1745 };
1746 defer delimiters.deinit(allocator);
1747 try appendInlineIndexed(
1748 out,
1749 allocator,
1750 value,
1751 value,
1752 footnotes,
1753 diag,
1754 0,
1755 false,
1756 delimiters,
1757 origin,
1758 report,
1759 );
1760 }
1761
1762 fn appendInlineIndexed(
1763 out: *std.ArrayList(u8),
1764 allocator: Allocator,
1765 source: []const u8,
1766 value: []const u8,
1767 footnotes: ?*footnote.Set,
1768 diag: ?*diagnostic.Diagnostic,
1769 depth: usize,
1770 inside_link: bool,
1771 delimiters: CodeDelimiters,
1772 origin: InlineOrigin,
1773 report: ?*LinkReport,
1774 ) InlineError!void {
1775 std.debug.assert(depth <= max_inline_nesting);
1776 std.debug.assert(@intFromPtr(value.ptr) >= @intFromPtr(source.ptr));
1777 const base = @intFromPtr(value.ptr) - @intFromPtr(source.ptr);
1778 std.debug.assert(base <= source.len);
1779 std.debug.assert(value.len <= source.len - base);
1780 var index: usize = 0;
1781 while (index < value.len) {
1782 if (documentationHtmlTagEnd(value, index)) |end| {
1783 try out.appendSlice(allocator, value[index..end]);
1784 index = end;
1785 continue;
1786 }
1787 if (value[index] == '`') {
1788 const opener_end = backtickRunEnd(value, index);
1789 if (delimiters.span(source, base + index, base + value.len)) |span| {
1790 try out.appendSlice(allocator, "<code>");
1791 try html.appendEscaped(out, allocator, span.content);
1792 try out.appendSlice(allocator, "</code>");
1793 index = span.end - base;
1794 continue;
1795 }
1796 try html.appendEscaped(out, allocator, value[index..opener_end]);
1797 index = opener_end;
1798 continue;
1799 }
1800
1801 if (value[index] == '\\' and index + 1 < value.len and
1802 isAsciiPunctuation(value[index + 1]))
1803 {
1804 try html.appendEscapedByte(out, allocator, value[index + 1]);
1805 index += 2;
1806 continue;
1807 }
1808
1809 if (value[index] == '$') {
1810 if (inlineMathEnd(value, index)) |end| {
1811 try renderMath(out, allocator, value[index + 1 .. end], .inline_math, diag);
1812 index = end + 1;
1813 continue;
1814 }
1815 }
1816
1817 if (index + 1 < value.len and value[index] == '!' and value[index + 1] == '[') {
1818 if (try parseInlineLink(
1819 source,
1820 value,
1821 base,
1822 index,
1823 true,
1824 diag,
1825 delimiters,
1826 )) |link| {
1827 recordRenderedLink(report, origin, base, index, value, link, true);
1828 const video = std.mem.endsWith(u8, link.target, ".webm");
1829 const attributes = parseImageAttributes(value, link.end);
1830 var motion_base: ?usize = null;
1831 if (!video) {
1832 if (attributes) |image_attributes| {
1833 if (hasAttributeClass(
1834 image_attributes.content,
1835 "zen-slide-motion",
1836 )) {
1837 motion_base = motionPosterBase(link.target);
1838 }
1839 }
1840 }
1841 if (video) {
1842 try out.appendSlice(allocator, "<video src=\"");
1843 try appendMarkdownEscaped(out, allocator, link.target);
1844 try out.appendSlice(allocator, "\" poster=\"");
1845 try appendMarkdownEscaped(
1846 out,
1847 allocator,
1848 link.target[0 .. link.target.len - ".webm".len],
1849 );
1850 try out.appendSlice(allocator, ".webp\" aria-label=\"");
1851 } else {
1852 try out.appendSlice(allocator, "<img src=\"");
1853 if (motion_base) |motion_offset| {
1854 try appendMarkdownEscaped(out, allocator, link.target[0..motion_offset]);
1855 try out.appendSlice(allocator, ".poster.svg");
1856 try appendMarkdownEscaped(
1857 out,
1858 allocator,
1859 link.target[motion_offset + ".svg".len ..],
1860 );
1861 try out.appendSlice(allocator, "\" data-zen-motion-src=\"");
1862 try appendMarkdownEscaped(out, allocator, link.target);
1863 try out.appendSlice(allocator, "\" alt=\"");
1864 } else {
1865 try appendMarkdownEscaped(out, allocator, link.target);
1866 try out.appendSlice(allocator, "\" alt=\"");
1867 }
1868 }
1869 try appendMarkdownEscaped(out, allocator, link.label);
1870 try out.append(allocator, '"');
1871 if (video) try out.appendSlice(allocator, " autoplay loop muted playsinline");
1872 if (motion_base != null) try out.appendSlice(allocator, " decoding=\"async\"");
1873 var end = link.end;
1874 if (attributes) |image_attributes| {
1875 try appendImageAttributes(out, allocator, image_attributes.content);
1876 end = image_attributes.end;
1877 }
1878 try out.append(allocator, '>');
1879 if (video) try out.appendSlice(allocator, "</video>");
1880 index = end;
1881 continue;
1882 }
1883 }
1884
1885 if (value[index] == '[') {
1886 if (!inside_link) {
1887 if (parseFootnoteReference(value, index)) |reference| {
1888 if (try appendFootnoteReference(out, allocator, footnotes, reference.key)) {
1889 index = reference.end;
1890 continue;
1891 }
1892 }
1893 }
1894 if (!inside_link) {
1895 if (try parseInlineLink(
1896 source,
1897 value,
1898 base,
1899 index,
1900 false,
1901 diag,
1902 delimiters,
1903 )) |link| {
1904 recordRenderedLink(report, origin, base, index, value, link, false);
1905 try out.appendSlice(allocator, "<a href=\"");
1906 try appendMarkdownEscaped(out, allocator, link.target);
1907 try out.appendSlice(allocator, "\">");
1908 try appendNestedInline(
1909 out,
1910 allocator,
1911 source,
1912 link.label,
1913 footnotes,
1914 diag,
1915 depth,
1916 true,
1917 delimiters,
1918 origin,
1919 report,
1920 );
1921 try out.appendSlice(allocator, "</a>");
1922 index = link.end;
1923 continue;
1924 }
1925 }
1926 }
1927
1928 if (index + 1 < value.len and ((value[index] == '*' and value[index + 1] == '*') or (value[index] == '_' and value[index + 1] == '_'))) {
1929 const token = value[index .. index + 2];
1930 if (std.mem.indexOfPos(u8, value, index + 2, token)) |end| {
1931 try out.appendSlice(allocator, "<strong>");
1932 try appendNestedInline(
1933 out,
1934 allocator,
1935 source,
1936 value[index + 2 .. end],
1937 footnotes,
1938 diag,
1939 depth,
1940 inside_link,
1941 delimiters,
1942 origin,
1943 report,
1944 );
1945 try out.appendSlice(allocator, "</strong>");
1946 index = end + 2;
1947 continue;
1948 }
1949 }
1950
1951 if (value[index] == '*' or value[index] == '_') {
1952 if (std.mem.indexOfScalarPos(u8, value, index + 1, value[index])) |end| {
1953 try out.appendSlice(allocator, "<em>");
1954 try appendNestedInline(
1955 out,
1956 allocator,
1957 source,
1958 value[index + 1 .. end],
1959 footnotes,
1960 diag,
1961 depth,
1962 inside_link,
1963 delimiters,
1964 origin,
1965 report,
1966 );
1967 try out.appendSlice(allocator, "</em>");
1968 index = end + 1;
1969 continue;
1970 }
1971 }
1972
1973 try html.appendEscapedByte(out, allocator, value[index]);
1974 index += 1;
1975 }
1976 }
1977
1978 fn documentationHtmlTagEnd(value: []const u8, start: usize) ?usize {
1979 const tags = [_][]const u8{
1980 "<span class=\"docs-breadcrumb\" role=\"navigation\" aria-label=\"Declaration hierarchy\">",
1981 "<span class=\"docs-breadcrumb\" role=\"navigation\" aria-label=\"Verification hierarchy\">",
1982 "<span aria-hidden=\"true\">",
1983 "<span aria-current=\"page\">",
1984 "<span class=\"docs-sidenote zen-role-margin\">",
1985 "<span class=\"docs-verification-source\">",
1986 "<span class=\"docs-verification-freshness-content\">",
1987 "<span class=\"docs-verification-freshness-content docs-verification-freshness-content-local\">",
1988 "<span class=\"docs-margin-figure zen-role-margin\">",
1989 "<span class=\"zen-role-reading\">",
1990 "<span class=\"zen-role-wide\">",
1991 "<span class=\"zen-role-margin\">",
1992 "<span class=\"zen-role-full\">",
1993 "<table class=\"docs-audit-table zen-role-wide\">",
1994 "</span>",
1995 "</table>",
1996 "<thead>",
1997 "</thead>",
1998 "<tbody>",
1999 "</tbody>",
2000 "<tr>",
2001 "</tr>",
2002 "<th>",
2003 "</th>",
2004 "<td>",
2005 "</td>",
2006 "<strong>",
2007 "</strong>",
2008 "<code>",
2009 "</code>",
2010 "<br>",
2011 };
2012 if (start >= value.len or value[start] != '<') return null;
2013 for (tags) |tag| {
2014 if (std.mem.startsWith(u8, value[start..], tag)) return start + tag.len;
2015 }
2016 return null;
2017 }
2018
2019 fn documentationArtifactBlock(value: []const u8) bool {
2020 return std.mem.startsWith(
2021 u8,
2022 value,
2023 "<table class=\"docs-audit-table zen-role-wide\">",
2024 ) and std.mem.endsWith(u8, value, "</table>");
2025 }
2026
2027 const ParagraphContainer = struct {
2028 classes: []const u8,
2029 full_size_image: ?InlineLink = null,
2030 };
2031
2032 const ParagraphRole = struct {
2033 open: []const u8,
2034 classes: []const u8,
2035 };
2036
2037 const paragraph_roles = [_]ParagraphRole{
2038 .{
2039 .open = "<span class=\"docs-sidenote zen-role-margin\">",
2040 .classes = "docs-sidenote-container zen-role-margin",
2041 },
2042 .{
2043 .open = "<span class=\"docs-verification-freshness-content\">",
2044 .classes = "docs-verification-freshness",
2045 },
2046 .{
2047 .open = "<span class=\"docs-verification-freshness-content " ++
2048 "docs-verification-freshness-content-local\">",
2049 .classes = "docs-verification-freshness docs-verification-freshness-local",
2050 },
2051 .{
2052 .open = "<span class=\"docs-margin-figure zen-role-margin\">",
2053 .classes = "docs-margin-figure-container zen-role-margin",
2054 },
2055 .{
2056 .open = "<span class=\"zen-role-reading\">",
2057 .classes = "zen-role-container zen-role-reading",
2058 },
2059 .{
2060 .open = "<span class=\"zen-role-wide\">",
2061 .classes = "zen-role-container zen-role-wide",
2062 },
2063 .{
2064 .open = "<span class=\"zen-role-margin\">",
2065 .classes = "zen-role-container zen-role-margin",
2066 },
2067 .{
2068 .open = "<span class=\"zen-role-full\">",
2069 .classes = "zen-role-container zen-role-full",
2070 },
2071 };
2072
2073 fn paragraphContainer(
2074 allocator: Allocator,
2075 value: []const u8,
2076 mode: Mode,
2077 diag: ?*diagnostic.Diagnostic,
2078 ) InlineError!?ParagraphContainer {
2079 if (std.mem.endsWith(u8, value, "</span>")) {
2080 for (paragraph_roles) |role| {
2081 if (std.mem.startsWith(u8, value, role.open)) {
2082 return .{ .classes = role.classes };
2083 }
2084 }
2085 }
2086 if (!std.mem.startsWith(u8, value, "![")) return null;
2087 var delimiters = try indexCodeDelimiters(allocator, value, null);
2088 defer delimiters.deinit(allocator);
2089 const link = try parseInlineLink(
2090 value,
2091 value,
2092 0,
2093 0,
2094 true,
2095 diag,
2096 delimiters,
2097 ) orelse return null;
2098 var end = link.end;
2099 const attributes = parseImageAttributes(value, end);
2100 var wide_figure = false;
2101 const role = if (attributes) |image_attributes| role: {
2102 end = image_attributes.end;
2103 wide_figure = hasAttributeClass(
2104 image_attributes.content,
2105 "zen-slide-figure-wide",
2106 );
2107 if (mode == .document and wide_figure) {
2108 break :role "zen-media-block zen-role-wide";
2109 }
2110 if (hasAttributeClass(image_attributes.content, "zen-role-wide")) {
2111 break :role "zen-media-block zen-role-wide";
2112 }
2113 if (hasAttributeClass(image_attributes.content, "zen-role-margin")) {
2114 break :role "zen-media-block zen-role-margin";
2115 }
2116 if (hasAttributeClass(image_attributes.content, "zen-role-full")) {
2117 break :role "zen-media-block zen-role-full";
2118 }
2119 break :role "zen-media-block zen-role-reading";
2120 } else "zen-media-block zen-role-reading";
2121 if (end != value.len) return null;
2122 return .{
2123 .classes = role,
2124 .full_size_image = if (mode == .document and wide_figure and
2125 !std.mem.endsWith(u8, link.target, ".webm")) link else null,
2126 };
2127 }
2128
2129 fn recordRenderedLink(
2130 report: ?*LinkReport,
2131 origin: InlineOrigin,
2132 base: usize,
2133 start: usize,
2134 value: []const u8,
2135 link: InlineLink,
2136 image: bool,
2137 ) void {
2138 const sink = report orelse return;
2139 var entry: RenderedLink = .{ .image = image };
2140 if (origin.locate(base + start, base + link.end)) |span| {
2141 entry.start = span.start;
2142 entry.end = span.end;
2143 entry.located = true;
2144 }
2145 if (entry.located) {
2146 const offset = @intFromPtr(link.target.ptr) - @intFromPtr(value.ptr);
2147 if (origin.locate(base + offset, base + offset + link.target.len)) |span| {
2148 entry.target_start = span.start;
2149 entry.target_end = span.end;
2150 entry.contiguous = span.contiguous;
2151 }
2152 }
2153 sink.record(entry);
2154 }
2155
2156 fn appendNestedInline(
2157 out: *std.ArrayList(u8),
2158 allocator: Allocator,
2159 source: []const u8,
2160 value: []const u8,
2161 footnotes: ?*footnote.Set,
2162 diag: ?*diagnostic.Diagnostic,
2163 depth: usize,
2164 inside_link: bool,
2165 delimiters: CodeDelimiters,
2166 origin: InlineOrigin,
2167 report: ?*LinkReport,
2168 ) InlineError!void {
2169 if (depth == max_inline_nesting) {
2170 if (diag) |detail| detail.setInline(value, inline_nesting_reason, 0);
2171 return error.InlineNestingCapacityExceeded;
2172 }
2173 try appendInlineIndexed(
2174 out,
2175 allocator,
2176 source,
2177 value,
2178 footnotes,
2179 diag,
2180 depth + 1,
2181 inside_link,
2182 delimiters,
2183 origin,
2184 report,
2185 );
2186 }
2187
2188 /// One run of an inline value, and where that run started in the source.
2189 const SourceSegment = struct {
2190 joined_start: u32,
2191 source_start: u32,
2192 len: u32,
2193 };
2194
2195 /// A located span of the rendered source, and whether it is one run.
2196 const LocatedSpan = struct {
2197 start: u32,
2198 end: u32,
2199 contiguous: bool,
2200 };
2201
2202 /// How an inline value's own offsets map back onto the rendered source.
2203 const InlineOrigin = union(enum) {
2204 direct: u32,
2205 joined: []const SourceSegment,
2206 untracked,
2207
2208 fn locate(self: InlineOrigin, start: usize, end: usize) ?LocatedSpan {
2209 std.debug.assert(start <= end);
2210 switch (self) {
2211 .untracked => return null,
2212 .direct => |base| {
2213 const located_start = std.math.cast(u32, base + start) orelse return null;
2214 const located_end = std.math.cast(u32, base + end) orelse return null;
2215 return .{ .start = located_start, .end = located_end, .contiguous = true };
2216 },
2217 .joined => |segments| {
2218 const first = segmentContaining(segments, start) orelse return null;
2219 const located_start = std.math.cast(
2220 u32,
2221 first.source_start + (start - first.joined_start),
2222 ) orelse return null;
2223 const last_index = if (end > start) end - 1 else start;
2224 const last = segmentContaining(segments, last_index) orelse return null;
2225 const same = last.joined_start == first.joined_start;
2226 const located_end = if (same)
2227 std.math.cast(u32, first.source_start + (end - first.joined_start)) orelse
2228 return null
2229 else
2230 std.math.cast(u32, first.source_start + first.len) orelse return null;
2231 if (located_end < located_start) return null;
2232 return .{ .start = located_start, .end = located_end, .contiguous = same };
2233 },
2234 }
2235 }
2236 };
2237
2238 fn segmentContaining(segments: []const SourceSegment, offset: usize) ?SourceSegment {
2239 for (segments) |segment| {
2240 if (offset < segment.joined_start) continue;
2241 if (offset - segment.joined_start < segment.len) return segment;
2242 }
2243 return null;
2244 }
2245
2246 /// Reports where `value` starts inside `source`, or null when it is a copy.
2247 fn sourceOffset(source: []const u8, value: []const u8) ?u32 {
2248 const base = @intFromPtr(source.ptr);
2249 const start = @intFromPtr(value.ptr);
2250 if (start < base) return null;
2251 const offset = start - base;
2252 if (offset > source.len or source.len - offset < value.len) return null;
2253 return std.math.cast(u32, offset);
2254 }
2255
2256 fn originWithin(source: []const u8, value: []const u8) InlineOrigin {
2257 return if (sourceOffset(source, value)) |offset| .{ .direct = offset } else .untracked;
2258 }
2259
2260 /// Accumulates one inline value from trimmed source lines, recording the
2261 /// origin of each line only while a link report is attached.
2262 const InlineBuffer = struct {
2263 bytes: std.ArrayList(u8) = .empty,
2264 segments: std.ArrayList(SourceSegment) = .empty,
2265 track: bool = false,
2266
2267 fn deinit(self: *InlineBuffer, allocator: Allocator) void {
2268 self.bytes.deinit(allocator);
2269 self.segments.deinit(allocator);
2270 }
2271
2272 fn items(self: *const InlineBuffer) []const u8 {
2273 return self.bytes.items;
2274 }
2275
2276 fn appendLine(
2277 self: *InlineBuffer,
2278 allocator: Allocator,
2279 source: []const u8,
2280 line: []const u8,
2281 ) Allocator.Error!void {
2282 if (self.bytes.items.len != 0) try self.bytes.append(allocator, ' ');
2283 if (self.track) try self.appendSegment(allocator, source, line);
2284 try self.bytes.appendSlice(allocator, line);
2285 }
2286
2287 fn appendSegment(
2288 self: *InlineBuffer,
2289 allocator: Allocator,
2290 source: []const u8,
2291 line: []const u8,
2292 ) Allocator.Error!void {
2293 const source_start = sourceOffset(source, line) orelse return;
2294 const joined_start = std.math.cast(u32, self.bytes.items.len) orelse return;
2295 const len = std.math.cast(u32, line.len) orelse return;
2296 try self.segments.append(allocator, .{
2297 .joined_start = joined_start,
2298 .source_start = source_start,
2299 .len = len,
2300 });
2301 }
2302
2303 fn origin(self: *const InlineBuffer) InlineOrigin {
2304 if (!self.track) return .untracked;
2305 return .{ .joined = self.segments.items };
2306 }
2307
2308 fn clearRetainingCapacity(self: *InlineBuffer) void {
2309 self.bytes.clearRetainingCapacity();
2310 self.segments.clearRetainingCapacity();
2311 }
2312 };
2313
2314 const CodeSpan = struct {
2315 content: []const u8,
2316 end: usize,
2317 };
2318
2319 fn indexCodeDelimiters(
2320 allocator: Allocator,
2321 value: []const u8,
2322 visits: ?*usize,
2323 ) (Allocator.Error || error{InlineDelimiterCapacityExceeded})!CodeDelimiters {
2324 var count: usize = 0;
2325 var index: usize = 0;
2326 while (index < value.len) {
2327 if (visits) |measured| measured.* += 1;
2328 if (value[index] != '`') {
2329 index += 1;
2330 continue;
2331 }
2332 if (count == max_inline_delimiters) return error.InlineDelimiterCapacityExceeded;
2333 count += 1;
2334 index = measuredBacktickRunEnd(value, index, visits);
2335 }
2336
2337 const items = try allocator.alloc(CodeDelimiter, count);
2338 errdefer allocator.free(items);
2339 index = 0;
2340 var cursor: usize = 0;
2341 while (index < value.len) {
2342 if (visits) |measured| measured.* += 1;
2343 if (value[index] != '`') {
2344 index += 1;
2345 continue;
2346 }
2347 const end = measuredBacktickRunEnd(value, index, visits);
2348 items[cursor] = .{ .start = index, .end = end, .length = end - index };
2349 cursor += 1;
2350 index = end;
2351 }
2352 std.debug.assert(cursor == items.len);
2353 pairCodeDelimiters(items);
2354 return .{ .items = items };
2355 }
2356
2357 fn measuredBacktickRunEnd(
2358 value: []const u8,
2359 start: usize,
2360 visits: ?*usize,
2361 ) usize {
2362 std.debug.assert(start < value.len);
2363 std.debug.assert(value[start] == '`');
2364 var end = start + 1;
2365 while (end < value.len and value[end] == '`') : (end += 1) {
2366 if (visits) |measured| measured.* += 1;
2367 }
2368 return end;
2369 }
2370
2371 fn pairCodeDelimiters(items: []CodeDelimiter) void {
2372 if (items.len < 2) return;
2373 std.mem.sort(CodeDelimiter, items, {}, codeDelimiterLessThan);
2374 var run_start: usize = 0;
2375 while (run_start < items.len) {
2376 var run_end = run_start + 1;
2377 while (run_end < items.len and items[run_end].length == items[run_start].length) {
2378 run_end += 1;
2379 }
2380 var index = run_start;
2381 while (index + 1 < run_end) : (index += 1) {
2382 items[index].closer_end = items[index + 1].end;
2383 }
2384 run_start = run_end;
2385 }
2386 std.mem.sort(CodeDelimiter, items, {}, codeDelimiterByStart);
2387 }
2388
2389 fn codeDelimiterLessThan(_: void, left: CodeDelimiter, right: CodeDelimiter) bool {
2390 if (left.length != right.length) return left.length < right.length;
2391 return left.start < right.start;
2392 }
2393
2394 fn codeDelimiterByStart(_: void, left: CodeDelimiter, right: CodeDelimiter) bool {
2395 return left.start < right.start;
2396 }
2397
2398 fn backtickRunEnd(value: []const u8, start: usize) usize {
2399 std.debug.assert(start < value.len);
2400 std.debug.assert(value[start] == '`');
2401 var end = start + 1;
2402 while (end < value.len and value[end] == '`') : (end += 1) {}
2403 return end;
2404 }
2405
2406 fn adversarialDelimiterSource(out: []u8) []u8 {
2407 var used: usize = 0;
2408 var length: usize = 1;
2409 while (used + length + 1 <= out.len and length <= max_inline_delimiters) : (length += 1) {
2410 @memset(out[used..][0..length], '`');
2411 used += length;
2412 out[used] = 'x';
2413 used += 1;
2414 }
2415 return out[0..used];
2416 }
2417
2418 fn exerciseNestedIndex(
2419 source: []const u8,
2420 delimiters: CodeDelimiters,
2421 depth: usize,
2422 max_depth: *usize,
2423 ) void {
2424 std.debug.assert(depth <= max_inline_nesting);
2425 max_depth.* = @max(max_depth.*, depth);
2426 _ = delimiters.span(source, 0, source.len);
2427 if (depth == max_inline_nesting) return;
2428 exerciseNestedIndex(source, delimiters, depth + 1, max_depth);
2429 }
2430
2431 fn appendMarkdownEscaped(
2432 out: *std.ArrayList(u8),
2433 allocator: Allocator,
2434 value: []const u8,
2435 ) Allocator.Error!void {
2436 var index: usize = 0;
2437 while (index < value.len) {
2438 if (value[index] == '\\' and index + 1 < value.len and
2439 isAsciiPunctuation(value[index + 1]))
2440 {
2441 index += 1;
2442 }
2443 try html.appendEscapedByte(out, allocator, value[index]);
2444 index += 1;
2445 }
2446 }
2447
2448 fn isAsciiPunctuation(byte: u8) bool {
2449 return (byte >= '!' and byte <= '/') or
2450 (byte >= ':' and byte <= '@') or
2451 (byte >= '[' and byte <= '`') or
2452 (byte >= '{' and byte <= '~');
2453 }
2454
2455 fn inlineMathEnd(value: []const u8, start: usize) ?usize {
2456 if (start + 1 >= value.len) return null;
2457 const first = value[start + 1];
2458 if (first == '$' or first == ' ' or first == '\t') return null;
2459 var index = start + 1;
2460 while (index < value.len) : (index += 1) {
2461 if (value[index] == '\\') {
2462 index += 1;
2463 continue;
2464 }
2465 if (value[index] != '$') continue;
2466 if (index == start + 1) return null;
2467 const previous = value[index - 1];
2468 if (previous == ' ' or previous == '\t') return null;
2469 if (index + 1 < value.len and std.ascii.isDigit(value[index + 1])) return null;
2470 return index;
2471 }
2472 return null;
2473 }
2474
2475 const InlineLink = struct {
2476 label: []const u8,
2477 target: []const u8,
2478 end: usize,
2479 };
2480
2481 const InlineParseError = error{
2482 InvalidInlineLink,
2483 InlineNestingCapacityExceeded,
2484 };
2485
2486 const ImageAttributes = struct {
2487 content: []const u8,
2488 end: usize,
2489 };
2490
2491 const FootnoteReference = struct {
2492 key: []const u8,
2493 end: usize,
2494 };
2495
2496 fn parseFootnoteReference(value: []const u8, start: usize) ?FootnoteReference {
2497 if (start + 3 > value.len or value[start] != '[' or value[start + 1] != '^') return null;
2498 const key_end = std.mem.indexOfScalarPos(u8, value, start + 2, ']') orelse return null;
2499 const key = std.mem.trim(u8, value[start + 2 .. key_end], " \t");
2500 if (key.len == 0) return null;
2501 return .{ .key = key, .end = key_end + 1 };
2502 }
2503
2504 fn parseInlineLink(
2505 source: []const u8,
2506 value: []const u8,
2507 base: usize,
2508 start: usize,
2509 image: bool,
2510 diag: ?*diagnostic.Diagnostic,
2511 delimiters: CodeDelimiters,
2512 ) InlineParseError!?InlineLink {
2513 const label_start = start + if (image) @as(usize, 2) else @as(usize, 1);
2514 const label_end = try scanLinkLabelEnd(
2515 value,
2516 label_start,
2517 diag,
2518 delimiters,
2519 source,
2520 base,
2521 ) orelse return null;
2522 if (label_end + 1 >= value.len or value[label_end + 1] != '(') return null;
2523 const target_start = label_end + 2;
2524 return try scanLinkTarget(value, label_start, label_end, target_start, diag);
2525 }
2526
2527 fn scanLinkLabelEnd(
2528 value: []const u8,
2529 start: usize,
2530 diag: ?*diagnostic.Diagnostic,
2531 delimiters: CodeDelimiters,
2532 source: []const u8,
2533 base: usize,
2534 ) InlineParseError!?usize {
2535 var depth: usize = 0;
2536 var index = start;
2537 while (index < value.len) {
2538 if (value[index] == '\\' and index + 1 < value.len) {
2539 index += 2;
2540 continue;
2541 }
2542 if (value[index] == '`') {
2543 const run_end = backtickRunEnd(value, index);
2544 if (delimiters.span(source, base + index, base + value.len)) |span| {
2545 index = span.end - base;
2546 } else {
2547 index = run_end;
2548 }
2549 continue;
2550 }
2551 if (value[index] == '[') {
2552 if (depth == max_inline_nesting) {
2553 if (diag) |detail| detail.setInline(value, label_nesting_reason, index);
2554 return error.InlineNestingCapacityExceeded;
2555 }
2556 depth += 1;
2557 } else if (value[index] == ']') {
2558 if (depth == 0) return index;
2559 depth -= 1;
2560 }
2561 index += 1;
2562 }
2563 return null;
2564 }
2565
2566 fn scanLinkTarget(
2567 value: []const u8,
2568 label_start: usize,
2569 label_end: usize,
2570 target_start: usize,
2571 diag: ?*diagnostic.Diagnostic,
2572 ) InlineParseError!InlineLink {
2573 var depth: usize = 1;
2574 var index = target_start;
2575 while (index < value.len) {
2576 if (value[index] == '\\' and index + 1 < value.len) {
2577 index += 2;
2578 continue;
2579 }
2580 if (value[index] == '(') {
2581 if (depth == max_inline_nesting) {
2582 if (diag) |detail| detail.setInline(value, target_nesting_reason, index);
2583 return error.InlineNestingCapacityExceeded;
2584 }
2585 depth += 1;
2586 } else if (value[index] == ')') {
2587 depth -= 1;
2588 if (depth == 0) return .{
2589 .label = value[label_start..label_end],
2590 .target = std.mem.trim(u8, value[target_start..index], " \t"),
2591 .end = index + 1,
2592 };
2593 }
2594 index += 1;
2595 }
2596 if (diag) |detail| {
2597 detail.setInline(value, "link target is missing closing ')'", target_start - 1);
2598 }
2599 return error.InvalidInlineLink;
2600 }
2601
2602 fn parseImageAttributes(value: []const u8, start: usize) ?ImageAttributes {
2603 if (start >= value.len or value[start] != '{') return null;
2604 const end = std.mem.indexOfScalarPos(u8, value, start + 1, '}') orelse return null;
2605 const content = std.mem.trim(u8, value[start + 1 .. end], " \t\r\n");
2606 if (content.len == 0) return null;
2607 var widget_count: u8 = 0;
2608 var tokens = std.mem.tokenizeAny(u8, content, " \t\r\n");
2609 while (tokens.next()) |token| {
2610 if (token.len >= 2 and token[0] == '.') {
2611 for (token[1..]) |byte| {
2612 if (!isClassByte(byte)) return null;
2613 }
2614 continue;
2615 }
2616 if (widgetAttributeValue(token) == null or widget_count != 0) return null;
2617 widget_count += 1;
2618 }
2619 return .{ .content = content, .end = end + 1 };
2620 }
2621
2622 fn appendImageAttributes(
2623 out: *std.ArrayList(u8),
2624 allocator: Allocator,
2625 content: []const u8,
2626 ) Allocator.Error!void {
2627 var class_count: u8 = 0;
2628 var tokens = std.mem.tokenizeAny(u8, content, " \t\r\n");
2629 while (tokens.next()) |token| {
2630 if (token[0] == '.') class_count += 1;
2631 }
2632 if (class_count != 0) try out.appendSlice(allocator, " class=\"");
2633 var first = true;
2634 tokens = std.mem.tokenizeAny(u8, content, " \t\r\n");
2635 while (tokens.next()) |token| {
2636 if (token[0] != '.') continue;
2637 if (!first) try out.append(allocator, ' ');
2638 try html.appendAttributeEscaped(out, allocator, token[1..]);
2639 first = false;
2640 }
2641 if (class_count != 0) try out.append(allocator, '"');
2642 tokens = std.mem.tokenizeAny(u8, content, " \t\r\n");
2643 while (tokens.next()) |token| {
2644 const widget = widgetAttributeValue(token) orelse continue;
2645 try out.appendSlice(allocator, " data-widget=\"");
2646 try html.appendAttributeEscaped(out, allocator, widget);
2647 try out.append(allocator, '"');
2648 }
2649 }
2650
2651 fn hasAttributeClass(content: []const u8, expected: []const u8) bool {
2652 var tokens = std.mem.tokenizeAny(u8, content, " \t\r\n");
2653 while (tokens.next()) |token| {
2654 if (token[0] != '.') continue;
2655 if (std.mem.eql(u8, token[1..], expected)) return true;
2656 }
2657 return false;
2658 }
2659
2660 fn motionPosterBase(target: []const u8) ?usize {
2661 const path_end = std.mem.indexOfAny(u8, target, "?#") orelse target.len;
2662 const path = target[0..path_end];
2663 if (!std.mem.endsWith(u8, path, ".svg")) return null;
2664 return path.len - ".svg".len;
2665 }
2666
2667 fn isClassByte(byte: u8) bool {
2668 return std.ascii.isAlphanumeric(byte) or byte == '-' or byte == '_';
2669 }
2670
2671 fn widgetAttributeValue(token: []const u8) ?[]const u8 {
2672 const prefix = "data-widget=\"";
2673 if (!std.mem.startsWith(u8, token, prefix) or
2674 token.len <= prefix.len or token[token.len - 1] != '"')
2675 {
2676 return null;
2677 }
2678 const name = token[prefix.len .. token.len - 1];
2679 if (!validWidgetName(name)) return null;
2680 return name;
2681 }
2682
2683 fn validWidgetName(name: []const u8) bool {
2684 if (name.len == 0 or name[0] == '-' or name[name.len - 1] == '-') return false;
2685 var previous_hyphen = false;
2686 for (name) |byte| {
2687 const hyphen = byte == '-';
2688 if (!std.ascii.isLower(byte) and !std.ascii.isDigit(byte) and !hyphen) {
2689 return false;
2690 }
2691 if (hyphen and previous_hyphen) return false;
2692 previous_hyphen = hyphen;
2693 }
2694 return true;
2695 }
2696
2697 const TestInspection = struct {
2698 storage: document.Storage,
2699 value: document.Document,
2700
2701 fn init(allocator: Allocator, source: []const u8) !TestInspection {
2702 const heading_plan = try plan(source);
2703 var storage = try document.Storage.init(allocator, heading_plan.exactLimits());
2704 errdefer storage.deinit(allocator);
2705 storage.activate();
2706 const value = try inspect(&storage, source);
2707 return .{ .storage = storage, .value = value };
2708 }
2709
2710 fn deinit(self: *TestInspection, allocator: Allocator) void {
2711 self.storage.reset();
2712 self.storage.deinit(allocator);
2713 self.* = undefined;
2714 }
2715 };
2716
2717 const heading_witness =
2718 "# Page\n\n" ++
2719 "```zig\n" ++
2720 "# Not a heading\n" ++
2721 "```\n\n" ++
2722 "> # Quote\n\n" ++
2723 "- item\n" ++
2724 " # Continuation\n\n" ++
2725 "## Section\n";
2726
2727 test "markdown renders headings paragraphs emphasis links and escaping" {
2728 var rendered = try render(
2729 std.testing.allocator,
2730 "# Hello <Zen>\n\n" ++
2731 "A **fast** [site](/docs) with `code` & text.\n",
2732 .{},
2733 );
2734 defer rendered.deinit(std.testing.allocator);
2735 try std.testing.expectEqualStrings(
2736 "Hello <Zen>",
2737 rendered.document.firstHeading().?.text,
2738 );
2739 try std.testing.expectEqual(@as(usize, 1), rendered.document.headings.len);
2740 try std.testing.expectEqual(@as(u8, 1), rendered.document.headings[0].level);
2741 try std.testing.expectEqualStrings("hello-zen", rendered.document.headings[0].id);
2742 try std.testing.expectEqualStrings(
2743 "<h1 id=\"hello-zen\">Hello <Zen></h1>\n" ++
2744 "<p>A <strong>fast</strong> <a href=\"/docs\">site</a> with <code>code</code> & text.</p>\n",
2745 rendered.html,
2746 );
2747 }
2748
2749 test "markdown renders semantic pipe tables with inline cell spans" {
2750 var rendered = try render(
2751 std.testing.allocator,
2752 "| Name | Detail | Time |\n" ++
2753 "| :--- | :---: | ---: |\n" ++
2754 "| **fast** | [docs](/docs) and `code` | 12 ms |\n",
2755 .{},
2756 );
2757 defer rendered.deinit(std.testing.allocator);
2758 try std.testing.expectEqualStrings(
2759 "<div class=\"content-table zen-role-wide\" tabindex=\"0\">\n" ++
2760 "<table>\n<thead>\n<tr>\n" ++
2761 "<th scope=\"col\" class=\"zen-table-align-left\">Name</th>\n" ++
2762 "<th scope=\"col\" class=\"zen-table-align-center\">Detail</th>\n" ++
2763 "<th scope=\"col\" class=\"zen-table-align-right\">Time</th>\n" ++
2764 "</tr>\n</thead>\n<tbody>\n<tr>\n" ++
2765 "<td class=\"zen-table-align-left\"><strong>fast</strong></td>\n" ++
2766 "<td class=\"zen-table-align-center\">" ++
2767 "<a href=\"/docs\">docs</a> and <code>code</code></td>\n" ++
2768 "<td class=\"zen-table-align-right\">12 ms</td>\n" ++
2769 "</tr>\n</tbody>\n</table>\n</div>\n",
2770 rendered.html,
2771 );
2772 }
2773
2774 test "markdown applies an explicit semantic role to a generated index list" {
2775 var rendered = try render(
2776 std.testing.allocator,
2777 "{.docs-index-family .zen-role-wide}\n" ++
2778 "- [one](/one)\n" ++
2779 "- [two](/two)\n",
2780 .{},
2781 );
2782 defer rendered.deinit(std.testing.allocator);
2783 try std.testing.expectEqualStrings(
2784 "<ul class=\"docs-index-family zen-role-wide\">\n" ++
2785 "<li><a href=\"/one\">one</a></li>\n" ++
2786 "<li><a href=\"/two\">two</a></li>\n" ++
2787 "</ul>\n",
2788 rendered.html,
2789 );
2790 }
2791
2792 test "markdown rejects a semantic list role without its artifact" {
2793 try std.testing.expectError(
2794 error.InvalidSemanticList,
2795 render(
2796 std.testing.allocator,
2797 "{.docs-index-module .zen-role-wide}\nNot a list.\n",
2798 .{},
2799 ),
2800 );
2801 }
2802
2803 test "markdown promotes explicit roles to artifact paragraph containers" {
2804 var rendered = try render(
2805 std.testing.allocator,
2806 "<span class=\"zen-role-wide\">Dense comparison.</span>\n\n" ++
2807 "{.zen-role-margin}\n",
2808 .{},
2809 );
2810 defer rendered.deinit(std.testing.allocator);
2811 try std.testing.expectEqualStrings(
2812 "<p class=\"zen-role-container zen-role-wide\">" ++
2813 "<span class=\"zen-role-wide\">Dense comparison.</span></p>\n" ++
2814 "<p class=\"zen-media-block zen-role-margin\">" ++
2815 "<img src=\"/plot.svg\" alt=\"Plot\" " ++
2816 "class=\"zen-role-margin\"></p>\n",
2817 rendered.html,
2818 );
2819 }
2820
2821 test "markdown admits documentation presentation tags only" {
2822 var rendered = try render(
2823 std.testing.allocator,
2824 "<span class=\"docs-breadcrumb\" role=\"navigation\" " ++
2825 "aria-label=\"Declaration hierarchy\">[Reference](/reference/) " ++
2826 "<span aria-hidden=\"true\">›</span> " ++
2827 "<span aria-current=\"page\">`tiny.pretty`</span></span>\n\n" ++
2828 "<span class=\"docs-breadcrumb\" role=\"navigation\" " ++
2829 "aria-label=\"Verification hierarchy\">[Reference](/reference/) " ++
2830 "<span aria-hidden=\"true\">›</span> " ++
2831 "<span aria-current=\"page\">Verification</span></span>\n\n" ++
2832 "<span class=\"docs-sidenote zen-role-margin\">" ++
2833 "Evidence **matters**.</span>\n\n" ++
2834 "<span class=\"docs-verification-freshness-content\">" ++
2835 "**Publication-qualified.** " ++
2836 "<span class=\"docs-verification-source\">" ++
2837 "[`0123`](/source)</span>.</span>\n\n" ++
2838 "<table class=\"docs-audit-table zen-role-wide\">" ++
2839 "<tbody><tr><th>Source</th>" ++
2840 "<td>`root.zig`</td></tr></tbody></table>\n\n" ++
2841 "<span class=\"unsafe\">No.</span><script>No.</script>\n",
2842 .{},
2843 );
2844 defer rendered.deinit(std.testing.allocator);
2845 try std.testing.expect(std.mem.indexOf(
2846 u8,
2847 rendered.html,
2848 "<span class=\"docs-breadcrumb\" role=\"navigation\" " ++
2849 "aria-label=\"Declaration hierarchy\"><a href=\"/reference/\">Reference</a> " ++
2850 "<span aria-hidden=\"true\">›</span> " ++
2851 "<span aria-current=\"page\"><code>tiny.pretty</code></span></span>",
2852 ) != null);
2853 try std.testing.expect(std.mem.indexOf(
2854 u8,
2855 rendered.html,
2856 "<span class=\"docs-breadcrumb\" role=\"navigation\" " ++
2857 "aria-label=\"Verification hierarchy\"><a href=\"/reference/\">Reference</a> " ++
2858 "<span aria-hidden=\"true\">›</span> " ++
2859 "<span aria-current=\"page\">Verification</span></span>",
2860 ) != null);
2861 try std.testing.expect(std.mem.indexOf(
2862 u8,
2863 rendered.html,
2864 "<p class=\"docs-verification-freshness\">" ++
2865 "<span class=\"docs-verification-freshness-content\">" ++
2866 "<strong>Publication-qualified.</strong> " ++
2867 "<span class=\"docs-verification-source\">" ++
2868 "<a href=\"/source\"><code>0123</code></a></span>.</span></p>",
2869 ) != null);
2870 try std.testing.expect(std.mem.indexOf(
2871 u8,
2872 rendered.html,
2873 "<p class=\"docs-sidenote-container zen-role-margin\">" ++
2874 "<span class=\"docs-sidenote zen-role-margin\">" ++
2875 "Evidence <strong>matters</strong>.</span></p>",
2876 ) != null);
2877 try std.testing.expect(std.mem.indexOf(
2878 u8,
2879 rendered.html,
2880 "<table class=\"docs-audit-table zen-role-wide\">" ++
2881 "<tbody><tr><th>Source</th><td><code>root.zig</code>",
2882 ) != null);
2883 try std.testing.expect(std.mem.indexOf(u8, rendered.html, "class=\"unsafe\"") == null);
2884 try std.testing.expect(std.mem.indexOf(u8, rendered.html, "<script>") == null);
2885 }
2886
2887 test "markdown admits validated static call map fences" {
2888 const fragment =
2889 "<figure class=\"docs-call-map docs-call-map-primary zen-role-wide\">" ++
2890 "<svg role=\"group\"><a class=\"zen-ego-node\" href=\"./\">" ++
2891 "<rect x=\"0\"/><text class=\"zen-ego-label\">run</text></a></svg>" ++
2892 "<figcaption>Static calls.</figcaption></figure>";
2893 var rendered = try render(
2894 std.testing.allocator,
2895 "```zen-ego\n" ++ fragment ++ "\n```\n",
2896 .{},
2897 );
2898 defer rendered.deinit(std.testing.allocator);
2899 try std.testing.expectEqualStrings(fragment ++ "\n", rendered.html);
2900 try std.testing.expectError(
2901 error.UnsafeDocumentFragment,
2902 render(
2903 std.testing.allocator,
2904 "```zen-ego\n" ++
2905 "<figure class=\"docs-call-map docs-call-map-primary zen-role-wide\">" ++
2906 "<svg onload=\"x\"></svg>" ++
2907 "<figcaption>Static calls.</figcaption></figure>\n```\n",
2908 .{},
2909 ),
2910 );
2911 }
2912
2913 test "markdown emits one stylesheet for repeated static call maps" {
2914 var svg_output: [diagram.ego.svg.output_bytes_max]u8 = undefined;
2915 const call_map = try diagram.ego.render(&svg_output, .{
2916 .subject = .{
2917 .label = "run",
2918 .context = "tiny.chic",
2919 .href = "#run",
2920 },
2921 }, .wide);
2922 const style_start = std.mem.indexOf(u8, call_map, "<style>") orelse
2923 return error.TestUnexpectedResult;
2924 const style_close = std.mem.indexOfPos(u8, call_map, style_start, "</style>") orelse
2925 return error.TestUnexpectedResult;
2926 const style_end = style_close + "</style>".len;
2927 const figure_open =
2928 "<figure class=\"docs-call-map docs-call-map-primary zen-role-wide\">";
2929 const figure_close = "<figcaption>Static calls.</figcaption></figure>";
2930 var source: [diagram.ego.svg.document_fragment_bytes_max * 2]u8 = undefined;
2931 var writer = std.Io.Writer.fixed(&source);
2932 for (0..2) |_| {
2933 try writer.writeAll("```zen-ego\n");
2934 try writer.writeAll(figure_open);
2935 try writer.writeAll(call_map);
2936 try writer.writeAll(figure_close);
2937 try writer.writeAll("\n```\n");
2938 }
2939 var preserved = try render(std.testing.allocator, writer.buffered(), .{});
2940 defer preserved.deinit(std.testing.allocator);
2941 var rendered = try render(std.testing.allocator, writer.buffered(), .{
2942 .one_call_map_style_per_page = true,
2943 });
2944 defer rendered.deinit(std.testing.allocator);
2945 var preserved_expected_storage: [diagram.ego.svg.document_fragment_bytes_max * 2]u8 = undefined;
2946 var preserved_expected = std.Io.Writer.fixed(&preserved_expected_storage);
2947 for (0..2) |_| {
2948 try preserved_expected.writeAll(figure_open);
2949 try preserved_expected.writeAll(call_map);
2950 try preserved_expected.writeAll(figure_close);
2951 try preserved_expected.writeByte('\n');
2952 }
2953 try std.testing.expectEqualStrings(preserved_expected.buffered(), preserved.html);
2954 var expected_storage: [diagram.ego.svg.document_fragment_bytes_max * 2]u8 = undefined;
2955 var expected = std.Io.Writer.fixed(&expected_storage);
2956 try expected.writeAll(figure_open);
2957 try expected.writeAll(call_map);
2958 try expected.writeAll(figure_close);
2959 try expected.writeByte('\n');
2960 try expected.writeAll(figure_open);
2961 try expected.writeAll(call_map[0..style_start]);
2962 try expected.writeAll(call_map[style_end..]);
2963 try expected.writeAll(figure_close);
2964 try expected.writeByte('\n');
2965 try std.testing.expectEqualStrings(expected.buffered(), rendered.html);
2966 try std.testing.expectEqual(@as(usize, 1), std.mem.count(
2967 u8,
2968 rendered.html,
2969 "<style>",
2970 ));
2971 for ([_][]const u8{ "<svg ", "class=\"zen-ego-node", " href=\"" }) |marker| {
2972 try std.testing.expectEqual(
2973 std.mem.count(u8, call_map, marker) * 2,
2974 std.mem.count(u8, rendered.html, marker),
2975 );
2976 }
2977 try std.testing.expectEqual(
2978 @as(usize, 2),
2979 std.mem.count(u8, rendered.html, figure_close),
2980 );
2981 }
2982
2983 test "markdown leaves a page without call maps style-free" {
2984 var rendered = try render(std.testing.allocator, "# Page\n\nNo call maps.\n", .{
2985 .one_call_map_style_per_page = true,
2986 });
2987 defer rendered.deinit(std.testing.allocator);
2988 try std.testing.expectEqual(@as(usize, 0), std.mem.count(
2989 u8,
2990 rendered.html,
2991 "<style>",
2992 ));
2993 }
2994
2995 test "markdown preserves distinct static call-map styles" {
2996 const opening =
2997 "<figure class=\"docs-call-map docs-call-map-primary zen-role-wide\">" ++
2998 "<svg role=\"group\"><style>";
2999 const closing =
3000 "</style><text class=\"node\">run</text></svg>" ++
3001 "<figcaption>Static calls.</figcaption></figure>";
3002 var source: [2048]u8 = undefined;
3003 var writer = std.Io.Writer.fixed(&source);
3004 for ([_][]const u8{ ".node{fill:red}", ".node{fill:blue}" }) |style| {
3005 try writer.writeAll("```zen-ego\n");
3006 try writer.writeAll(opening);
3007 try writer.writeAll(style);
3008 try writer.writeAll(closing);
3009 try writer.writeAll("\n```\n");
3010 }
3011 var rendered = try render(std.testing.allocator, writer.buffered(), .{
3012 .one_call_map_style_per_page = true,
3013 });
3014 defer rendered.deinit(std.testing.allocator);
3015 try std.testing.expectEqual(@as(usize, 2), std.mem.count(
3016 u8,
3017 rendered.html,
3018 "<style>",
3019 ));
3020 try std.testing.expect(
3021 std.mem.indexOf(u8, rendered.html, ".node{fill:red}") != null,
3022 );
3023 try std.testing.expect(
3024 std.mem.indexOf(u8, rendered.html, ".node{fill:blue}") != null,
3025 );
3026 }
3027
3028 test "markdown preserves variable backtick spans inside link labels" {
3029 var rendered = try render(
3030 std.testing.allocator,
3031 "[``@\"tick`](/wrong)\"``](safe/#anchor)\n",
3032 .{},
3033 );
3034 defer rendered.deinit(std.testing.allocator);
3035 try std.testing.expectEqualStrings(
3036 "<p><a href=\"safe/#anchor\"><code>@"tick`](/wrong)"</code></a></p>\n",
3037 rendered.html,
3038 );
3039 }
3040
3041 test "markdown pairs each code opener after spans consumed by other lengths" {
3042 var rendered = try render(
3043 std.testing.allocator,
3044 "``a ` b`` c `d`\n",
3045 .{},
3046 );
3047 defer rendered.deinit(std.testing.allocator);
3048 try std.testing.expectEqualStrings(
3049 "<p><code>a ` b</code> c <code>d</code></p>\n",
3050 rendered.html,
3051 );
3052
3053 var linked = try render(
3054 std.testing.allocator,
3055 "[``a ` ](/wrong) b`` c `d](/still-wrong)`](safe)\n",
3056 .{},
3057 );
3058 defer linked.deinit(std.testing.allocator);
3059 try std.testing.expectEqualStrings(
3060 "<p><a href=\"safe\"><code>a ` ](/wrong) b</code> c " ++
3061 "<code>d](/still-wrong)</code></a></p>\n",
3062 linked.html,
3063 );
3064 }
3065
3066 test "markdown scans escaped labels and balanced link targets" {
3067 var rendered = try render(
3068 std.testing.allocator,
3069 "[Zig \\[](/types/@\"name\"(u8\\))) and `plain ] )` plus $x$.\n",
3070 .{},
3071 );
3072 defer rendered.deinit(std.testing.allocator);
3073 try std.testing.expectEqualStrings(
3074 "<p><a href=\"/types/@"name"(u8))\">Zig [</a> and " ++
3075 "<code>plain ] )</code> plus <math><mi>x</mi></math>.</p>\n",
3076 rendered.html,
3077 );
3078 }
3079
3080 test "markdown never emits anchors inside link labels" {
3081 var rendered = try render(
3082 std.testing.allocator,
3083 "[outer [inner](x) and [^note]](y)\n\n[^note]: Note.\n",
3084 .{},
3085 );
3086 defer rendered.deinit(std.testing.allocator);
3087 try std.testing.expectEqualStrings(
3088 "<p><a href=\"y\">outer [inner](x) and [^note]</a></p>\n",
3089 rendered.html,
3090 );
3091 const first_anchor = std.mem.indexOf(u8, rendered.html, "<a href=").?;
3092 try std.testing.expect(std.mem.indexOfPos(
3093 u8,
3094 rendered.html,
3095 first_anchor + "<a href=".len,
3096 "<a href=",
3097 ) == null);
3098 }
3099
3100 test "markdown names malformed inline links and fixed nesting excess" {
3101 var diag: Diagnostic = .{};
3102 const malformed = "Read [the source](/missing.\n";
3103 try std.testing.expectError(error.InvalidInlineLink, render(
3104 std.testing.allocator,
3105 malformed,
3106 .{ .diagnostic = &diag },
3107 ));
3108 try std.testing.expectEqualStrings(malformed[0 .. malformed.len - 1], diag.inlineSource());
3109 try std.testing.expectEqualStrings("link target is missing closing ')'", diag.reason);
3110 try std.testing.expectEqual(@as(usize, 17), diag.offset);
3111
3112 diag.reset();
3113 const nested: [max_inline_nesting + 1]u8 = @splat('[');
3114 const excessive = "[" ++ nested ++ "x](safe)\n";
3115 try std.testing.expectError(error.InlineNestingCapacityExceeded, render(
3116 std.testing.allocator,
3117 excessive,
3118 .{ .diagnostic = &diag },
3119 ));
3120 try std.testing.expectEqualStrings(label_nesting_reason, diag.reason);
3121 try std.testing.expectEqual(@as(usize, max_inline_nesting + 1), diag.offset);
3122 }
3123
3124 test "markdown accepts exact inline scanner boundaries" {
3125 const labels: [max_inline_nesting]u8 = @splat('[');
3126 const label_closes: [max_inline_nesting]u8 = @splat(']');
3127 const target_opens: [max_inline_nesting - 1]u8 = @splat('(');
3128 const target_closes: [max_inline_nesting - 1]u8 = @splat(')');
3129 const source = "[" ++ labels ++ "x" ++ label_closes ++ "](safe) [x](" ++
3130 target_opens ++ "safe" ++ target_closes ++ ")\n";
3131 var rendered = try render(std.testing.allocator, source, .{});
3132 defer rendered.deinit(std.testing.allocator);
3133 try std.testing.expect(std.mem.indexOf(u8, rendered.html, "href=\"safe\"") != null);
3134
3135 var diag: Diagnostic = .{};
3136 const excessive: [max_inline_nesting]u8 = @splat('(');
3137 try std.testing.expectError(error.InlineNestingCapacityExceeded, render(
3138 std.testing.allocator,
3139 "[x](" ++ excessive ++ "safe\n",
3140 .{ .diagnostic = &diag },
3141 ));
3142 try std.testing.expectEqualStrings(target_nesting_reason, diag.reason);
3143 try std.testing.expectEqual(@as(usize, max_inline_nesting + 3), diag.offset);
3144 }
3145
3146 test "markdown keeps incomplete ordinary inline delimiters literal" {
3147 var rendered = try render(
3148 std.testing.allocator,
3149 "Use ``unfinished `code and [plain label without a target.\n",
3150 .{},
3151 );
3152 defer rendered.deinit(std.testing.allocator);
3153 try std.testing.expectEqualStrings(
3154 "<p>Use ``unfinished `code and [plain label without a target.</p>\n",
3155 rendered.html,
3156 );
3157 }
3158
3159 test "markdown delimiter indexing has a linear byte-visit bound" {
3160 const source_capacity = 8 * 1024 * 1024;
3161 const source = try std.testing.allocator.alloc(u8, source_capacity);
3162 defer std.testing.allocator.free(source);
3163 const adversarial = adversarialDelimiterSource(source);
3164 try std.testing.expect(adversarial.len > 8 * 1024 * 1024 - 8 * 1024);
3165 var visits: usize = 0;
3166 var delimiters = try indexCodeDelimiters(std.testing.allocator, adversarial, &visits);
3167 defer delimiters.deinit(std.testing.allocator);
3168 try std.testing.expect(visits <= 4 * adversarial.len);
3169 }
3170
3171 test "markdown rejects delimiter run capacity before allocation" {
3172 try std.testing.expectEqual(code_delimiter_bytes, @sizeOf(CodeDelimiter));
3173 try std.testing.expectEqual(@as(usize, 2_621_440), max_inline_delimiter_bytes);
3174 const exact_bytes = max_inline_delimiters * 2;
3175 const source = try std.testing.allocator.alloc(u8, exact_bytes + 2);
3176 defer std.testing.allocator.free(source);
3177 var index: usize = 0;
3178 while (index < source.len) : (index += 2) {
3179 source[index] = '`';
3180 source[index + 1] = 'x';
3181 }
3182 var counting = std.testing.FailingAllocator.init(std.testing.allocator, .{});
3183 var exact = try indexCodeDelimiters(counting.allocator(), source[0..exact_bytes], null);
3184 defer exact.deinit(counting.allocator());
3185 const allocations = counting.alloc_index;
3186 try std.testing.expectEqual(@as(usize, 1), allocations);
3187 try std.testing.expectEqual(
3188 max_inline_delimiter_bytes,
3189 counting.allocated_bytes,
3190 );
3191 try std.testing.expectError(
3192 error.InlineDelimiterCapacityExceeded,
3193 indexCodeDelimiters(counting.allocator(), source, null),
3194 );
3195 try std.testing.expectEqual(allocations, counting.alloc_index);
3196 }
3197
3198 test "markdown code spans never close outside their recursive subrange" {
3199 const source = "_`inside_ outside`";
3200 var delimiters = try indexCodeDelimiters(std.testing.allocator, source, null);
3201 defer delimiters.deinit(std.testing.allocator);
3202 var out: std.ArrayList(u8) = .empty;
3203 defer out.deinit(std.testing.allocator);
3204 try appendInlineIndexed(
3205 &out,
3206 std.testing.allocator,
3207 source,
3208 source[1..8],
3209 null,
3210 null,
3211 1,
3212 false,
3213 delimiters,
3214 .untracked,
3215 null,
3216 );
3217 try std.testing.expectEqualStrings("`inside", out.items);
3218 }
3219
3220 test "markdown reuses one delimiter index through maximum recursion" {
3221 const source = "`code`";
3222 var counting = std.testing.FailingAllocator.init(std.testing.allocator, .{});
3223 var delimiters = try indexCodeDelimiters(counting.allocator(), source, null);
3224 defer delimiters.deinit(counting.allocator());
3225 const index_allocations = counting.alloc_index;
3226 var max_depth: usize = 0;
3227 exerciseNestedIndex(source, delimiters, 0, &max_depth);
3228 try std.testing.expectEqual(max_inline_nesting, max_depth);
3229 try std.testing.expectEqual(index_allocations, counting.alloc_index);
3230
3231 var out: std.ArrayList(u8) = .empty;
3232 defer out.deinit(std.testing.allocator);
3233 try out.ensureTotalCapacity(std.testing.allocator, 64);
3234 try appendInlineIndexed(
3235 &out,
3236 counting.allocator(),
3237 source,
3238 source,
3239 null,
3240 null,
3241 0,
3242 false,
3243 delimiters,
3244 .untracked,
3245 null,
3246 );
3247 try std.testing.expectEqualStrings("<code>code</code>", out.items);
3248 try std.testing.expectEqual(index_allocations, counting.alloc_index);
3249 }
3250
3251 test "markdown variable fences preserve embedded structural spellings" {
3252 const source =
3253 "````````text\n" ++
3254 "```\n" ++
3255 "```````\n" ++
3256 "</code><a href=\"/wrong\">injection</a>\n" ++
3257 "````````\n";
3258 var rendered = try render(
3259 std.testing.allocator,
3260 source,
3261 .{ .code_blocks = .{ .highlighting = false, .line_numbers = false } },
3262 );
3263 defer rendered.deinit(std.testing.allocator);
3264 try std.testing.expect(std.mem.indexOf(
3265 u8,
3266 rendered.html,
3267 "```</span></span><span class=\"zen-code-line\">",
3268 ) != null);
3269 try std.testing.expect(std.mem.indexOf(u8, rendered.html, "```````") != null);
3270 try std.testing.expect(std.mem.indexOf(
3271 u8,
3272 rendered.html,
3273 "</code><a href="/wrong">injection</a>",
3274 ) != null);
3275 try std.testing.expect(std.mem.indexOf(u8, rendered.html, "<a href=\"/wrong\">") == null);
3276 }
3277
3278 test "markdown reserves explicit anchors and resolves generated collisions" {
3279 const source =
3280 "```zen-toc\n" ++
3281 "scope: document\n" ++
3282 "```\n\n" ++
3283 "# Generated\n\n" ++
3284 "## Generated\n\n" ++
3285 "## Stable API {#generated}\n\n" ++
3286 "### Render {#tiny.zen.render}\n";
3287 var rendered = try render(std.testing.allocator, source, .{});
3288 defer rendered.deinit(std.testing.allocator);
3289 try std.testing.expectEqualStrings("generated~1", rendered.document.headings[0].id);
3290 try std.testing.expectEqualStrings("generated~2", rendered.document.headings[1].id);
3291 try std.testing.expectEqualStrings("generated", rendered.document.headings[2].id);
3292 try std.testing.expectEqualStrings("tiny.zen.render", rendered.document.headings[3].id);
3293 try std.testing.expect(std.mem.indexOf(
3294 u8,
3295 rendered.html,
3296 "<a href=\"#generated\">Stable API</a>",
3297 ) != null);
3298 try std.testing.expect(std.mem.indexOf(
3299 u8,
3300 rendered.html,
3301 "<h3 id=\"tiny.zen.render\">Render</h3>",
3302 ) != null);
3303 try std.testing.expect(std.mem.indexOf(u8, rendered.html, "{#") == null);
3304 }
3305
3306 test "markdown preserves typed path anchors" {
3307 var rendered = try render(
3308 std.testing.allocator,
3309 "## Run {#method/Options/run}\n",
3310 .{},
3311 );
3312 defer rendered.deinit(std.testing.allocator);
3313 try std.testing.expectEqualStrings(
3314 "method/Options/run",
3315 rendered.document.headings[0].id,
3316 );
3317 try std.testing.expectEqualStrings(
3318 "<h2 id=\"method/Options/run\">Run</h2>\n",
3319 rendered.html,
3320 );
3321 }
3322
3323 test "markdown suffixes generated heading collisions in source order" {
3324 var rendered = try render(
3325 std.testing.allocator,
3326 "# Repeat\n\n# Repeat\n\n# Repeat\n",
3327 .{},
3328 );
3329 defer rendered.deinit(std.testing.allocator);
3330 try std.testing.expectEqualStrings("repeat", rendered.document.headings[0].id);
3331 try std.testing.expectEqualStrings("repeat~2", rendered.document.headings[1].id);
3332 try std.testing.expectEqualStrings("repeat~3", rendered.document.headings[2].id);
3333 }
3334
3335 test "explicit anchor syntax preserves unanchored heading output" {
3336 var rendered = try render(
3337 std.testing.allocator,
3338 "# Render {#tiny.zen.render}\n",
3339 .{ .heading_anchors = false },
3340 );
3341 defer rendered.deinit(std.testing.allocator);
3342 try std.testing.expectEqualStrings("tiny.zen.render", rendered.document.headings[0].id);
3343 try std.testing.expectEqualStrings("<h1>Render</h1>\n", rendered.html);
3344 }
3345
3346 test "explicit anchor syntax accepts case markers" {
3347 var rendered = try render(
3348 std.testing.allocator,
3349 "# Diagnostic {#decl/~c0~Diagnostic}\n",
3350 .{},
3351 );
3352 defer rendered.deinit(std.testing.allocator);
3353 try std.testing.expectEqualStrings(
3354 "decl/~c0~Diagnostic",
3355 rendered.document.headings[0].id,
3356 );
3357 }
3358
3359 test "markdown reports invalid and duplicate explicit heading anchors" {
3360 var diag: diagnostic.Diagnostic = .{};
3361 const duplicate = "# First {#same}\n\n## Second {#same}\n";
3362 try std.testing.expectError(error.DuplicateHeadingAnchor, render(
3363 std.testing.allocator,
3364 duplicate,
3365 .{ .diagnostic = &diag },
3366 ));
3367 try std.testing.expectEqualStrings("duplicate heading anchor", diag.reason);
3368 try std.testing.expectEqual(
3369 std.mem.lastIndexOf(u8, duplicate, "{#same}").?,
3370 diag.offset,
3371 );
3372 const invalid = "# Symbol {#not safe}\n";
3373 try std.testing.expectError(error.InvalidHeadingAnchor, render(
3374 std.testing.allocator,
3375 invalid,
3376 .{ .diagnostic = &diag },
3377 ));
3378 try std.testing.expectEqualStrings("invalid heading anchor", diag.reason);
3379 try std.testing.expectEqual(std.mem.indexOf(u8, invalid, "{#").?, diag.offset);
3380 }
3381
3382 test "markdown inspects headings outside inert blocks" {
3383 var inspected = try TestInspection.init(
3384 std.testing.allocator,
3385 heading_witness,
3386 );
3387 defer inspected.deinit(std.testing.allocator);
3388 const doc = inspected.value;
3389 try std.testing.expectEqual(@as(usize, 2), doc.headings.len);
3390 try std.testing.expectEqualStrings("Page", doc.headings[0].text);
3391 try std.testing.expectEqualStrings("page", doc.headings[0].id);
3392 try std.testing.expectEqualStrings("Section", doc.headings[1].text);
3393 try std.testing.expectEqualStrings("section", doc.headings[1].id);
3394 }
3395
3396 test "heading storage rejects every limit before backing mutation" {
3397 comptime {
3398 @stardustClaim(
3399 @import("alloc_phase").capacity.witness(@import("./document/root.zig").Storage, "zen_heading_boundaries"),
3400 null,
3401 null,
3402 null,
3403 null,
3404 null,
3405 null,
3406 );
3407 }
3408
3409 var storage = try document.Storage.init(std.testing.allocator, .{
3410 .max_headings = 2,
3411 .max_text_bytes = 10,
3412 .max_id_bytes = 7,
3413 });
3414 defer storage.deinit(std.testing.allocator);
3415 storage.activate();
3416 @memset(storage.bytes, 42);
3417
3418 try std.testing.expectError(
3419 error.HeadingCapacityExceeded,
3420 inspect(&storage, "# A\n## B\n### C\n"),
3421 );
3422 for (storage.bytes) |byte| try std.testing.expectEqual(@as(u8, 42), byte);
3423 try std.testing.expectError(
3424 error.HeadingTextByteCapacityExceeded,
3425 inspect(&storage, "# abcdef\n## ghijkl\n"),
3426 );
3427 for (storage.bytes) |byte| try std.testing.expectEqual(@as(u8, 42), byte);
3428 try std.testing.expectError(
3429 error.HeadingIdByteCapacityExceeded,
3430 inspect(&storage, "# A\n## ?\n"),
3431 );
3432 for (storage.bytes) |byte| try std.testing.expectEqual(@as(u8, 42), byte);
3433 try std.testing.expectEqual(@as(u64, 3), storage.status().rejected_source_count);
3434 try std.testing.expect(!storage.status().in_use);
3435 }
3436
3437 test "heading storage reuses one region after success and rejection" {
3438 comptime {
3439 @stardustClaim(
3440 @import("alloc_phase").capacity.witness(@import("./document/root.zig").Storage, "zen_heading_reuse"),
3441 null,
3442 null,
3443 null,
3444 null,
3445 null,
3446 null,
3447 );
3448 }
3449
3450 var storage = try document.Storage.init(std.testing.allocator, .{
3451 .max_headings = 2,
3452 .max_text_bytes = 16,
3453 .max_id_bytes = 16,
3454 });
3455 defer storage.deinit(std.testing.allocator);
3456 storage.activate();
3457 const base = @intFromPtr(storage.bytes.ptr);
3458
3459 const first = try inspect(&storage, "# First\n## Second\n");
3460 try std.testing.expectEqualStrings("First", first.headings[0].text);
3461 try std.testing.expectError(
3462 error.DocumentStorageInUse,
3463 inspect(&storage, "# Other\n"),
3464 );
3465 try std.testing.expectEqualStrings("First", first.headings[0].text);
3466 storage.reset();
3467
3468 try std.testing.expectError(
3469 error.HeadingCapacityExceeded,
3470 inspect(&storage, "# A\n## B\n### C\n"),
3471 );
3472 const second = try inspect(&storage, "# Other\n");
3473 try std.testing.expectEqualStrings("Other", second.headings[0].text);
3474 storage.reset();
3475 try std.testing.expectEqual(base, @intFromPtr(storage.bytes.ptr));
3476 try std.testing.expectEqual(@as(usize, 2), storage.status().high_water_headings);
3477 try std.testing.expectEqual(@as(u64, 2), storage.status().rejected_source_count);
3478 }
3479
3480 test "activated heading inspection performs no backing allocation" {
3481 comptime {
3482 @stardustClaim(
3483 @import("alloc_phase").capacity.witness(@import("./document/root.zig").Storage, "zen_heading_sealed"),
3484 null,
3485 null,
3486 null,
3487 null,
3488 null,
3489 null,
3490 );
3491 }
3492
3493 const heading_plan = try plan(heading_witness);
3494 const capacity = try document.Capacity.derive(heading_plan.exactLimits());
3495 var counting = std.testing.FailingAllocator.init(std.testing.allocator, .{});
3496 var storage = try document.Storage.init(counting.allocator(), heading_plan.exactLimits());
3497 defer storage.deinit(counting.allocator());
3498 storage.activate();
3499 const allocations = counting.alloc_index;
3500 const resizes = counting.resize_index;
3501 const bytes = counting.allocated_bytes;
3502 counting.fail_index = allocations;
3503 counting.resize_fail_index = resizes;
3504
3505 const doc = try inspect(&storage, heading_witness);
3506 defer storage.reset();
3507 try std.testing.expectEqual(@as(usize, 2), doc.headings.len);
3508 try std.testing.expectEqual(@as(usize, 1), allocations);
3509 try std.testing.expectEqual(@as(usize, 0), resizes);
3510 try std.testing.expectEqual(capacity.storage_bytes, bytes);
3511 try std.testing.expectEqual(allocations, counting.alloc_index);
3512 try std.testing.expectEqual(resizes, counting.resize_index);
3513 try std.testing.expectEqual(bytes, counting.allocated_bytes);
3514 }
3515
3516 test "heading storage owns text independently from the Markdown source" {
3517 var source = [_]u8{ '#', ' ', 'P', 'a', 'g', 'e', '\n' };
3518 const heading_plan = try plan(&source);
3519 var storage = try document.Storage.init(std.testing.allocator, heading_plan.exactLimits());
3520 defer storage.deinit(std.testing.allocator);
3521 storage.activate();
3522 const doc = try inspect(&storage, &source);
3523 defer storage.reset();
3524 @memset(&source, 'x');
3525 try std.testing.expectEqualStrings("Page", doc.headings[0].text);
3526 try std.testing.expectEqualStrings("page", doc.headings[0].id);
3527 }
3528
3529 test "Markdown rendering owns exact heading storage" {
3530 comptime {
3531 @stardustClaim(
3532 @import("alloc_phase").capacity.witness(@import("./document/root.zig").Storage, "zen_heading_consumer"),
3533 null,
3534 null,
3535 null,
3536 null,
3537 null,
3538 null,
3539 );
3540 }
3541
3542 const source = "# Page\n\n## Section\n";
3543 const heading_plan = try plan(source);
3544 const capacity = try document.Capacity.derive(heading_plan.exactLimits());
3545 var rendered = try render(std.testing.allocator, source, .{});
3546 defer rendered.deinit(std.testing.allocator);
3547 try std.testing.expectEqual(capacity.storage_bytes, rendered.document_storage.bytes.len);
3548 try std.testing.expectEqualStrings("Page", rendered.document.firstHeading().?.text);
3549 }
3550
3551 test "markdown renders generated contents from following headings" {
3552 var rendered = try render(
3553 std.testing.allocator,
3554 "# Page\n\n" ++
3555 "```zen-toc\n" ++
3556 "scope: following\n" ++
3557 "min-level: 2\n" ++
3558 "max-level: 3\n" ++
3559 "```\n\n" ++
3560 "## First\n\n" ++
3561 "### Detail\n\n" ++
3562 "# Next page root\n",
3563 .{},
3564 );
3565 defer rendered.deinit(std.testing.allocator);
3566 try std.testing.expectEqualStrings(
3567 "<h1 id=\"page\">Page</h1>\n" ++
3568 "<nav class=\"zen-contents zen-role-wide\" aria-label=\"Contents\">\n" ++
3569 "<p>Contents:</p>\n" ++
3570 "<ul>\n" ++
3571 "<li class=\"zen-contents-level-2\"><a href=\"#first\">First</a></li>\n" ++
3572 "<li class=\"zen-contents-level-3\" style=\"margin-left:calc(1 * 1.5rem)\"><a href=\"#detail\">Detail</a></li>\n" ++
3573 "</ul>\n" ++
3574 "</nav>\n" ++
3575 "<h2 id=\"first\">First</h2>\n" ++
3576 "<h3 id=\"detail\">Detail</h3>\n" ++
3577 "<h1 id=\"next-page-root\">Next page root</h1>\n",
3578 rendered.html,
3579 );
3580 }
3581
3582 test "markdown rejects invalid contents directives" {
3583 try std.testing.expectError(error.InvalidContentsDirective, render(
3584 std.testing.allocator,
3585 "```zen-toc\n" ++
3586 "scope: nowhere\n" ++
3587 "```\n",
3588 .{},
3589 ));
3590 }
3591
3592 test "markdown renders quiz fences with hidden answers" {
3593 var rendered = try render(
3594 std.testing.allocator,
3595 "```zen-quiz\n" ++
3596 "? Which module owns the grammar?\n" ++
3597 "- `markdown.zig`\n" ++
3598 "* `quiz.zig`\n" ++
3599 "! The typed data lives beside the renderer.\n" ++
3600 "```\n",
3601 .{},
3602 );
3603 defer rendered.deinit(std.testing.allocator);
3604 try std.testing.expectEqualStrings(
3605 "<section class=\"zen-quiz\" aria-label=\"Quiz\">\n" ++
3606 "<ol class=\"zen-quiz-questions\">\n" ++
3607 "<li class=\"zen-quiz-question\">\n" ++
3608 "<p class=\"zen-quiz-prompt\">Which module owns the grammar?</p>\n" ++
3609 "<ul class=\"zen-quiz-options\">\n" ++
3610 "<li><code>markdown.zig</code></li>\n" ++
3611 "<li><code>quiz.zig</code></li>\n" ++
3612 "</ul>\n" ++
3613 "<details class=\"zen-quiz-answer\">\n" ++
3614 "<summary>Reveal answer</summary>\n" ++
3615 "<p class=\"zen-quiz-correct\"><strong><code>quiz.zig</code></strong></p>\n" ++
3616 "<p class=\"zen-quiz-explanation\">The typed data lives beside the renderer.</p>\n" ++
3617 "</details>\n" ++
3618 "</li>\n" ++
3619 "</ol>\n" ++
3620 "</section>\n",
3621 rendered.html,
3622 );
3623 }
3624
3625 test "markdown renders recall quiz questions without options" {
3626 var rendered = try render(
3627 std.testing.allocator,
3628 "```quiz\n" ++
3629 "? What does the reveal block hold?\n" ++
3630 "! The explanation.\n" ++
3631 "```\n",
3632 .{},
3633 );
3634 defer rendered.deinit(std.testing.allocator);
3635 try std.testing.expectEqualStrings(
3636 "<section class=\"zen-quiz\" aria-label=\"Quiz\">\n" ++
3637 "<ol class=\"zen-quiz-questions\">\n" ++
3638 "<li class=\"zen-quiz-question\">\n" ++
3639 "<p class=\"zen-quiz-prompt\">What does the reveal block hold?</p>\n" ++
3640 "<details class=\"zen-quiz-answer\">\n" ++
3641 "<summary>Reveal answer</summary>\n" ++
3642 "<p class=\"zen-quiz-explanation\">The explanation.</p>\n" ++
3643 "</details>\n" ++
3644 "</li>\n" ++
3645 "</ol>\n" ++
3646 "</section>\n",
3647 rendered.html,
3648 );
3649 }
3650
3651 test "markdown rejects invalid quiz fences" {
3652 try std.testing.expectError(error.InvalidQuizDirective, render(
3653 std.testing.allocator,
3654 "```zen-quiz\n" ++
3655 "? A prompt with one lonely option\n" ++
3656 "* only\n" ++
3657 "```\n",
3658 .{},
3659 ));
3660 }
3661
3662 test "Markdown reuses one quiz region across fences" {
3663 comptime {
3664 @stardustClaim(
3665 @import("alloc_phase").capacity.witness(@import("./quiz/root.zig").Storage, "zen_quiz_consumer"),
3666 null,
3667 null,
3668 null,
3669 null,
3670 null,
3671 null,
3672 );
3673 }
3674
3675 const limits = quiz.Limits{
3676 .max_questions = 1,
3677 .max_options = 2,
3678 .max_joined_text_bytes = "First answer continued".len,
3679 };
3680 const capacity = try quiz.Capacity.derive(limits);
3681 var counting = std.testing.FailingAllocator.init(std.testing.allocator, .{});
3682 var owner: QuizOwner = .{};
3683 defer owner.deinit(counting.allocator());
3684
3685 const first = try owner.parse(
3686 counting.allocator(),
3687 "? First\n! First answer\ncontinued\n",
3688 limits,
3689 );
3690 try std.testing.expectEqualStrings("First answer continued", first.questions[0].explanation);
3691 const base = @intFromPtr(owner.storage.?.bytes.ptr);
3692 owner.reset();
3693 try std.testing.expectEqual(@as(usize, 1), counting.alloc_index);
3694 try std.testing.expectEqual(capacity.storage_bytes, counting.allocated_bytes);
3695 counting.fail_index = counting.alloc_index;
3696 counting.resize_fail_index = counting.resize_index;
3697
3698 const second = try owner.parse(
3699 counting.allocator(),
3700 "? Second\n* Correct\n- Other\n",
3701 limits,
3702 );
3703 try std.testing.expectEqualStrings("Second", second.questions[0].prompt);
3704 try std.testing.expectEqual(@as(usize, 2), second.questions[0].options.len);
3705 owner.reset();
3706 try std.testing.expectEqual(base, @intFromPtr(owner.storage.?.bytes.ptr));
3707 try std.testing.expectEqual(@as(usize, 1), counting.alloc_index);
3708 try std.testing.expectEqual(capacity.storage_bytes, counting.allocated_bytes);
3709 }
3710
3711 test "Markdown exposes quiz limits at the render boundary" {
3712 try std.testing.expectError(error.QuestionCapacityExceeded, render(
3713 std.testing.allocator,
3714 "```quiz\n? Question\n! Answer\n```\n",
3715 .{ .quiz_limits = .{
3716 .max_questions = 0,
3717 .max_options = 0,
3718 .max_joined_text_bytes = 0,
3719 } },
3720 ));
3721 }
3722
3723 test "Markdown exposes heading limits at the render boundary" {
3724 try std.testing.expectError(error.HeadingCapacityExceeded, render(
3725 std.testing.allocator,
3726 "# Heading\n",
3727 .{ .heading_limits = .{
3728 .max_headings = 0,
3729 .max_text_bytes = 0,
3730 .max_id_bytes = 0,
3731 } },
3732 ));
3733 }
3734
3735 test "Markdown exposes footnote limits at the render boundary" {
3736 const source = "[^one]: First\n continued\n";
3737 try std.testing.expectError(error.FootnoteDefinitionCapacityExceeded, render(
3738 std.testing.allocator,
3739 source,
3740 .{ .footnote_limits = .{
3741 .max_definitions = 0,
3742 .max_joined_text_bytes = 0,
3743 } },
3744 ));
3745 try std.testing.expectError(error.FootnoteJoinedTextByteCapacityExceeded, render(
3746 std.testing.allocator,
3747 source,
3748 .{ .footnote_limits = .{
3749 .max_definitions = 1,
3750 .max_joined_text_bytes = "First continued".len - 1,
3751 } },
3752 ));
3753 }
3754
3755 test "markdown renders explicit slide decks" {
3756 var rendered = try render(
3757 std.testing.allocator,
3758 "# Title\n\n" ++
3759 "---\n\n" ++
3760 "## Second\n\n" ++
3761 "---\n",
3762 .{ .mode = .slides, .slides = .{ .runtime = false } },
3763 );
3764 defer rendered.deinit(std.testing.allocator);
3765 try std.testing.expectEqualStrings(
3766 "<div class=\"zen-slides\" data-zen-slides>\n" ++
3767 "<section class=\"zen-slide\" id=\"slide-1\" aria-label=\"Slide 1\">\n" ++
3768 "<div class=\"zen-slide-frame\">\n" ++
3769 "<h1 id=\"title\">Title</h1>\n" ++
3770 "</div>\n" ++
3771 "</section>\n" ++
3772 "<section class=\"zen-slide\" id=\"slide-2\" aria-label=\"Slide 2\">\n" ++
3773 "<div class=\"zen-slide-frame\">\n" ++
3774 "<h2 id=\"second\">Second</h2>\n" ++
3775 "</div>\n" ++
3776 "</section>\n" ++
3777 "</div>\n" ++
3778 slides.print.stylesheet,
3779 rendered.html,
3780 );
3781 }
3782
3783 test "markdown renders speaker notes in slide decks" {
3784 var rendered = try render(
3785 std.testing.allocator,
3786 "# Title\n\n" ++
3787 "```notes\n" ++
3788 "Remember the concrete example.\n" ++
3789 "```\n",
3790 .{ .mode = .slides, .slides = .{ .runtime = false } },
3791 );
3792 defer rendered.deinit(std.testing.allocator);
3793 try std.testing.expect(std.mem.indexOf(u8, rendered.html, "<aside class=\"zen-speaker-notes\" data-zen-speaker-notes hidden>") != null);
3794 try std.testing.expect(std.mem.indexOf(u8, rendered.html, "<p>Remember the concrete example.</p>") != null);
3795 try std.testing.expect(std.mem.indexOf(u8, rendered.html, "zen-code-block") == null);
3796 }
3797
3798 test "markdown drops empty speaker notes in slide decks" {
3799 var rendered = try render(
3800 std.testing.allocator,
3801 "# Title\n\n" ++
3802 "```notes\n" ++
3803 "\n" ++
3804 "```\n",
3805 .{ .mode = .slides, .slides = .{ .runtime = false } },
3806 );
3807 defer rendered.deinit(std.testing.allocator);
3808 try std.testing.expect(std.mem.indexOf(u8, rendered.html, "<aside class=\"zen-speaker-notes\"") == null);
3809 }
3810
3811 test "markdown leaves notes fences as code outside slide decks" {
3812 var rendered = try render(
3813 std.testing.allocator,
3814 "```notes\n" ++
3815 "ordinary note code\n" ++
3816 "```\n",
3817 .{},
3818 );
3819 defer rendered.deinit(std.testing.allocator);
3820 try std.testing.expect(std.mem.indexOf(u8, rendered.html, "zen-speaker-notes") == null);
3821 try std.testing.expect(std.mem.indexOf(u8, rendered.html, "ordinary note code") != null);
3822 }
3823
3824 test "markdown opens wide document figures at full size" {
3825 var rendered = try render(
3826 std.testing.allocator,
3827 "{.zen-slide-figure-wide}\n",
3828 .{},
3829 );
3830 defer rendered.deinit(std.testing.allocator);
3831 try std.testing.expectEqualStrings(
3832 "<p class=\"zen-media-block zen-role-wide\">" ++
3833 "<a href=\"/plot.svg\" target=\"_blank\" rel=\"noopener\" " ++
3834 "aria-label=\"Open Plot at full size\">" ++
3835 "<img src=\"/plot.svg\" alt=\"Plot\" " ++
3836 "class=\"zen-slide-figure-wide\"></a></p>\n",
3837 rendered.html,
3838 );
3839 }
3840
3841 test "markdown slide decks preserve media math code and diagrams" {
3842 var rendered = try render(
3843 std.testing.allocator,
3844 "# Media\n\n" ++
3845 "{.zen-slide-figure-wide}\n\n" ++
3846 "---\n\n" ++
3847 "## Math and Code\n\n" ++
3848 "$$ E = mc^2 $$\n\n" ++
3849 "```zig\n" ++
3850 "const x = 1 < 2;\n" ++
3851 "```\n\n" ++
3852 "---\n\n" ++
3853 "```zen-diagram\n" ++
3854 "{\"kind\":\"frame\",\"title\":\"Coin\",\"y_min\":0,\"y_max\":1}\n" ++
3855 "{\"kind\":\"bar\",\"x\":\"heads\",\"y\":0.62,\"label\":\"heads\"}\n" ++
3856 "```\n",
3857 .{ .mode = .slides, .diagram_width = 36, .diagram_height = 10 },
3858 );
3859 defer rendered.deinit(std.testing.allocator);
3860 try std.testing.expect(std.mem.indexOf(u8, rendered.html, "<section class=\"zen-slide\" id=\"slide-3\"") != null);
3861 try std.testing.expect(std.mem.indexOf(
3862 u8,
3863 rendered.html,
3864 "<p class=\"zen-media-block zen-role-reading\">" ++
3865 "<img src=\"/plot.svg\" alt=\"Plot\" " ++
3866 "class=\"zen-slide-figure-wide\"></p>",
3867 ) != null);
3868 try std.testing.expect(std.mem.indexOf(
3869 u8,
3870 rendered.html,
3871 "<a href=\"/plot.svg\"",
3872 ) == null);
3873 try std.testing.expect(std.mem.indexOf(u8, rendered.html, "<math display=\"block\">") != null);
3874 try std.testing.expect(std.mem.indexOf(u8, rendered.html, "zen-code-block") != null);
3875 try std.testing.expect(std.mem.indexOf(
3876 u8,
3877 rendered.html,
3878 "<pre class=\"zen-diagram zen-role-wide\"><code>",
3879 ) != null);
3880 try std.testing.expect(std.mem.indexOf(u8, rendered.html, "<hr>") == null);
3881 }
3882
3883 test "markdown renders webm figures as looping videos with poster fallback" {
3884 var rendered = try render(
3885 std.testing.allocator,
3886 "{.zen-slide-figure-wide}\n",
3887 .{},
3888 );
3889 defer rendered.deinit(std.testing.allocator);
3890 try std.testing.expectEqualStrings(
3891 "<p class=\"zen-media-block zen-role-wide\">" ++
3892 "<video src=\"/assets/sph.webm\" poster=\"/assets/sph.webp\" " ++
3893 "aria-label=\"Dam break\" autoplay loop muted playsinline " ++
3894 "class=\"zen-slide-figure-wide\"></video></p>\n",
3895 rendered.html,
3896 );
3897 }
3898
3899 test "markdown renders motion figures as static posters" {
3900 var rendered = try render(
3901 std.testing.allocator,
3902 "{.zen-slide-motion\n.zen-slide-figure-wide}\n",
3903 .{ .mode = .slides, .slides = .{ .runtime = false } },
3904 );
3905 defer rendered.deinit(std.testing.allocator);
3906 try std.testing.expect(std.mem.indexOf(
3907 u8,
3908 rendered.html,
3909 "<img src=\"/assets/loop.poster.svg?revision=1\" data-zen-motion-src=\"/assets/loop.svg?revision=1\" alt=\"Agent loop\" decoding=\"async\" class=\"zen-slide-motion zen-slide-figure-wide\">",
3910 ) != null);
3911 }
3912
3913 test "markdown renders a bounded widget selector on an image" {
3914 var rendered = try render(
3915 std.testing.allocator,
3916 "{.demo data-widget=\"accy-wos\"}\n",
3917 .{},
3918 );
3919 defer rendered.deinit(std.testing.allocator);
3920 try std.testing.expectEqualStrings(
3921 "<p class=\"zen-media-block zen-role-reading\">" ++
3922 "<img src=\"/assets/poster.svg\" alt=\"Accy demo\" " ++
3923 "class=\"demo\" data-widget=\"accy-wos\"></p>\n",
3924 rendered.html,
3925 );
3926 }
3927
3928 test "markdown renders lists blockquotes code fences and images" {
3929 var rendered = try render(
3930 std.testing.allocator,
3931 "- one\n" ++
3932 "- two\n\n" ++
3933 "> quoted\n" ++
3934 "> text\n\n" ++
3935 "```zig\n" ++
3936 "const x = 1 < 2;\n" ++
3937 "```\n\n" ++
3938 "\n",
3939 .{},
3940 );
3941 defer rendered.deinit(std.testing.allocator);
3942 try std.testing.expectEqualStrings(
3943 "<ul>\n" ++
3944 "<li>one</li>\n" ++
3945 "<li>two</li>\n" ++
3946 "</ul>\n" ++
3947 "<blockquote><p>quoted text</p></blockquote>\n" ++
3948 "<div class=\"zen-code-block zen-code-wrap zen-code-example " ++
3949 "zen-role-reading\" data-language=\"zig\"><div class=\"zen-code-header\">" ++
3950 "<span class=\"zen-code-language\">zig</span></div>" ++
3951 "<pre class=\"zen-code\"><code class=\"language-zig\">" ++
3952 "<span class=\"zen-code-line\"><span class=\"zen-code-line-number\" " ++
3953 "aria-hidden=\"true\">1</span><span class=\"zen-code-line-source\">" ++
3954 "<span class=\"zen-code-keyword\">const</span> x = " ++
3955 "<span class=\"zen-code-number\">1</span> < " ++
3956 "<span class=\"zen-code-number\">2</span>;</span></span>" ++
3957 "</code></pre></div>\n" ++
3958 "<p class=\"zen-media-block zen-role-reading\">" ++
3959 "<img src=\"/logo.svg\" alt=\"Logo\"></p>\n",
3960 rendered.html,
3961 );
3962 }
3963
3964 test "markdown renders continued list items and blockquote inline markup" {
3965 var rendered = try render(
3966 std.testing.allocator,
3967 "- first item wraps\n" ++
3968 " onto the next line\n" ++
3969 "- second _Item_\n\n" ++
3970 "> **John R. Levine, _Linkers &\n" ++
3971 "> Loaders_**\n",
3972 .{},
3973 );
3974 defer rendered.deinit(std.testing.allocator);
3975 try std.testing.expectEqualStrings(
3976 "<ul>\n" ++
3977 "<li>first item wraps onto the next line</li>\n" ++
3978 "<li>second <em>Item</em></li>\n" ++
3979 "</ul>\n" ++
3980 "<blockquote><p><strong>John R. Levine, <em>Linkers & Loaders</em></strong></p></blockquote>\n",
3981 rendered.html,
3982 );
3983 }
3984
3985 test "markdown renders footnote references and definitions" {
3986 comptime {
3987 @stardustClaim(
3988 @import("alloc_phase").capacity.witness(@import("./footnote/root.zig").Storage, "zen_footnote_consumer"),
3989 null,
3990 null,
3991 null,
3992 null,
3993 null,
3994 null,
3995 );
3996 }
3997
3998 var rendered = try render(
3999 std.testing.allocator,
4000 "Alpha[^paper] beta[^paper] gamma[^block] missing[^none].\n\n" ++
4001 "[^paper]: A [paper](/paper)\n" ++
4002 " with `code`.\n" ++
4003 "[^block]:\n" ++
4004 " Block style.\n" ++
4005 "[^unused]: Hidden.\n",
4006 .{},
4007 );
4008 defer rendered.deinit(std.testing.allocator);
4009 try std.testing.expectEqualStrings(
4010 "<p>Alpha<sup id=\"fnref-paper\"><a class=\"footnote-ref\" href=\"#fn-paper\">[1]</a></sup> beta<sup id=\"fnref-paper-2\"><a class=\"footnote-ref\" href=\"#fn-paper\">[1]</a></sup> gamma<sup id=\"fnref-block\"><a class=\"footnote-ref\" href=\"#fn-block\">[2]</a></sup> missing[^none].</p>\n" ++
4011 "<section class=\"footnotes\" aria-label=\"References\">\n" ++
4012 "<ol>\n" ++
4013 "<li id=\"fn-paper\">A <a href=\"/paper\">paper</a> with <code>code</code>. <a class=\"footnote-backref\" href=\"#fnref-paper\" aria-label=\"Back to reference\">↩</a></li>\n" ++
4014 "<li id=\"fn-block\">Block style. <a class=\"footnote-backref\" href=\"#fnref-block\" aria-label=\"Back to reference\">↩</a></li>\n" ++
4015 "</ol>\n" ++
4016 "</section>\n",
4017 rendered.html,
4018 );
4019 }
4020
4021 test "markdown keeps the first duplicate footnote definition" {
4022 var rendered = try render(
4023 std.testing.allocator,
4024 "Use[^same].\n\n" ++
4025 "[^same]: First.\n" ++
4026 "[^same]: Second\n" ++
4027 " ignored continuation.\n",
4028 .{},
4029 );
4030 defer rendered.deinit(std.testing.allocator);
4031 try std.testing.expect(std.mem.indexOf(u8, rendered.html, "First.") != null);
4032 try std.testing.expect(std.mem.indexOf(u8, rendered.html, "Second") == null);
4033 }
4034
4035 test "markdown renders inline and display math" {
4036 var rendered = try render(
4037 std.testing.allocator,
4038 "Euler saw $e^{i\\pi} + 1 = 0$ here.\n\n" ++
4039 "$$\n" ++
4040 "u(x) = \\mathbb{E}[u(x + R\\omega)]\n" ++
4041 "$$\n",
4042 .{},
4043 );
4044 defer rendered.deinit(std.testing.allocator);
4045 try std.testing.expectEqualStrings(
4046 "<p>Euler saw <math><mrow><msup><mi>e</mi><mrow><mi>i</mi><mi>π</mi></mrow></msup>" ++
4047 "<mo>+</mo><mn>1</mn><mo>=</mo><mn>0</mn></mrow></math> here.</p>\n" ++
4048 "<math display=\"block\"><mrow><mi>u</mi><mo stretchy=\"false\">(</mo><mi>x</mi><mo stretchy=\"false\">)</mo><mo>=</mo>" ++
4049 "<mi>𝔼</mi><mo stretchy=\"false\">[</mo><mi>u</mi><mo stretchy=\"false\">(</mo><mi>x</mi><mo>+</mo><mi>R</mi><mi>ω</mi>" ++
4050 "<mo stretchy=\"false\">)</mo><mo stretchy=\"false\">]</mo></mrow></math>\n",
4051 rendered.html,
4052 );
4053 }
4054
4055 test "markdown renders single line display math" {
4056 var rendered = try render(std.testing.allocator, "$$ E = mc^2 $$\n", .{});
4057 defer rendered.deinit(std.testing.allocator);
4058 try std.testing.expectEqualStrings(
4059 "<math display=\"block\"><mrow><mi>E</mi><mo>=</mo><mi>m</mi>" ++
4060 "<msup><mi>c</mi><mn>2</mn></msup></mrow></math>\n",
4061 rendered.html,
4062 );
4063 }
4064
4065 test "markdown renders display environments across lines" {
4066 var rendered = try render(
4067 std.testing.allocator,
4068 "$$\n" ++
4069 "\\begin{aligned}\n" ++
4070 "a &= b \\\\\n" ++
4071 "&= c\n" ++
4072 "\\end{aligned}\n" ++
4073 "$$\n",
4074 .{},
4075 );
4076 defer rendered.deinit(std.testing.allocator);
4077 try std.testing.expectEqualStrings(
4078 "<math display=\"block\"><mtable displaystyle=\"true\" class=\"zen-aligned\">" ++
4079 "<mtr><mtd><mi>a</mi></mtd><mtd><mo>=</mo><mi>b</mi></mtd></mtr>" ++
4080 "<mtr><mtd></mtd><mtd><mo>=</mo><mi>c</mi></mtd></mtr>" ++
4081 "</mtable></math>\n",
4082 rendered.html,
4083 );
4084 }
4085
4086 test "markdown guards prose dollars and honors escapes" {
4087 var rendered = try render(
4088 std.testing.allocator,
4089 "It costs $5 and $10 to run, but \\$x\\$ stays literal.\n",
4090 .{},
4091 );
4092 defer rendered.deinit(std.testing.allocator);
4093 try std.testing.expectEqualStrings(
4094 "<p>It costs $5 and $10 to run, but $x$ stays literal.</p>\n",
4095 rendered.html,
4096 );
4097 }
4098
4099 test "markdown fails loudly on invalid math" {
4100 try std.testing.expectError(error.InvalidEquation, render(
4101 std.testing.allocator,
4102 "Broken $\\nonesuch$ command.\n",
4103 .{},
4104 ));
4105 try std.testing.expectError(error.InvalidEquation, render(
4106 std.testing.allocator,
4107 "$$\nx = 1\n",
4108 .{},
4109 ));
4110 }
4111
4112 test "markdown names the offending equation" {
4113 var diag: Diagnostic = .{};
4114 try std.testing.expectError(error.InvalidEquation, render(
4115 std.testing.allocator,
4116 "Broken $\\nonesuch$ command.\n",
4117 .{ .diagnostic = &diag },
4118 ));
4119 try std.testing.expectEqualStrings("\\nonesuch", diag.equation());
4120 try std.testing.expectEqualStrings("unknown command", diag.reason);
4121 try std.testing.expectEqual(@as(usize, 0), diag.offset);
4122
4123 diag.reset();
4124 try std.testing.expectError(error.InvalidEquation, render(
4125 std.testing.allocator,
4126 "$$\nu(x) = \\frac{1}\n$$\n",
4127 .{ .diagnostic = &diag },
4128 ));
4129 try std.testing.expectEqualStrings("u(x) = \\frac{1}", diag.equation());
4130 try std.testing.expectEqualStrings("missing argument", diag.reason);
4131 try std.testing.expectEqual(@as(usize, 15), diag.offset);
4132
4133 diag.reset();
4134 try std.testing.expectError(error.InvalidEquation, render(
4135 std.testing.allocator,
4136 "$$\nx = 1\n",
4137 .{ .diagnostic = &diag },
4138 ));
4139 try std.testing.expectEqualStrings("x = 1", diag.equation());
4140 try std.testing.expectEqualStrings("missing closing '$$'", diag.reason);
4141 }
4142
4143 test "markdown leaves math inside code spans alone" {
4144 var rendered = try render(std.testing.allocator, "Use `$x$` verbatim.\n", .{});
4145 defer rendered.deinit(std.testing.allocator);
4146 try std.testing.expectEqualStrings(
4147 "<p>Use <code>$x$</code> verbatim.</p>\n",
4148 rendered.html,
4149 );
4150 }
4151
4152 test "markdown renders diagram fences as ASCII" {
4153 comptime {
4154 @stardustClaim(
4155 @import("alloc_phase").capacity.witness(@import("./diagram/ascii/root.zig").RenderStorage, "zen_ascii_render_consumer"),
4156 null,
4157 null,
4158 null,
4159 null,
4160 null,
4161 null,
4162 );
4163 }
4164
4165 var rendered = try render(
4166 std.testing.allocator,
4167 "```zen-diagram\n" ++
4168 "{\"kind\":\"frame\",\"title\":\"Coin\",\"y_min\":0,\"y_max\":1}\n" ++
4169 "{\"kind\":\"bar\",\"x\":\"heads\",\"y\":0.62,\"label\":\"heads\"}\n" ++
4170 "```\n",
4171 .{ .diagram_width = 36, .diagram_height = 10 },
4172 );
4173 defer rendered.deinit(std.testing.allocator);
4174 try std.testing.expect(std.mem.indexOf(
4175 u8,
4176 rendered.html,
4177 "<pre class=\"zen-diagram zen-role-wide\"><code>",
4178 ) != null);
4179 try std.testing.expect(std.mem.indexOf(u8, rendered.html, "Coin") != null);
4180 try std.testing.expect(std.mem.indexOf(u8, rendered.html, "#") != null);
4181 try std.testing.expect(std.mem.indexOf(u8, rendered.html, "{\"kind\"") == null);
4182 }
4183
4184 test "link reports locate a direct paragraph target" {
4185 const source = "See [the allocator](allocator.zig) for detail.\n";
4186 var storage: [4]RenderedLink = undefined;
4187 var report: LinkReport = .{ .storage = &storage };
4188 var rendered = try render(
4189 std.testing.allocator,
4190 source,
4191 .{ .link_report = &report },
4192 );
4193 defer rendered.deinit(std.testing.allocator);
4194 const links = report.links();
4195 try std.testing.expectEqual(@as(usize, 1), links.len);
4196 try std.testing.expect(links[0].located);
4197 try std.testing.expect(links[0].contiguous);
4198 try std.testing.expect(!links[0].image);
4199 try std.testing.expectEqualStrings(
4200 "allocator.zig",
4201 source[links[0].target_start..links[0].target_end],
4202 );
4203 try std.testing.expectEqualStrings(
4204 "[the allocator](allocator.zig)",
4205 source[links[0].start..links[0].end],
4206 );
4207 }
4208
4209 test "link reports locate a target carried across joined source lines" {
4210 const source = "See [the\nallocator](allocator.zig) for detail.\n";
4211 var storage: [4]RenderedLink = undefined;
4212 var report: LinkReport = .{ .storage = &storage };
4213 var rendered = try render(
4214 std.testing.allocator,
4215 source,
4216 .{ .link_report = &report },
4217 );
4218 defer rendered.deinit(std.testing.allocator);
4219 const links = report.links();
4220 try std.testing.expectEqual(@as(usize, 1), links.len);
4221 try std.testing.expect(links[0].located);
4222 try std.testing.expect(links[0].contiguous);
4223 try std.testing.expectEqualStrings(
4224 "allocator.zig",
4225 source[links[0].target_start..links[0].target_end],
4226 );
4227 }
4228
4229 test "link reports emit a nested image after its enclosing link" {
4230 const source = "[](capacity/root.zig)\n";
4231 var storage: [4]RenderedLink = undefined;
4232 var report: LinkReport = .{ .storage = &storage };
4233 var rendered = try render(
4234 std.testing.allocator,
4235 source,
4236 .{ .link_report = &report },
4237 );
4238 defer rendered.deinit(std.testing.allocator);
4239 const links = report.links();
4240 try std.testing.expectEqual(@as(usize, 2), links.len);
4241 try std.testing.expect(!links[0].image);
4242 try std.testing.expect(links[1].image);
4243 try std.testing.expectEqualStrings(
4244 "capacity/root.zig",
4245 source[links[0].target_start..links[0].target_end],
4246 );
4247 try std.testing.expectEqualStrings(
4248 "badge.svg",
4249 source[links[1].target_start..links[1].target_end],
4250 );
4251 try std.testing.expect(links[1].target_start < links[0].target_start);
4252 }
4253
4254 test "link reports name the exhausted report capacity" {
4255 var storage: [1]RenderedLink = undefined;
4256 var report: LinkReport = .{ .storage = &storage };
4257 var rendered = try render(
4258 std.testing.allocator,
4259 "[a](a.zig) and [b](b.zig)\n",
4260 .{ .link_report = &report },
4261 );
4262 defer rendered.deinit(std.testing.allocator);
4263 try std.testing.expect(report.overflowed);
4264 try std.testing.expectEqual(@as(usize, 1), report.count);
4265 }
4266
4267 test "link reports ignore targets inside fenced and inline code" {
4268 const source = "```\n[a](a.zig)\n```\n\nText `[b](b.zig)` text.\n";
4269 var storage: [4]RenderedLink = undefined;
4270 var report: LinkReport = .{ .storage = &storage };
4271 var rendered = try render(
4272 std.testing.allocator,
4273 source,
4274 .{ .link_report = &report },
4275 );
4276 defer rendered.deinit(std.testing.allocator);
4277 try std.testing.expectEqual(@as(usize, 0), report.links().len);
4278 }
4279
4280 test "link reporting leaves ordinary rendered output unchanged" {
4281 const source =
4282 "# Title\n\nSee [one](one.zig) and .\n\n" ++
4283 "- item [three](three.zig)\n- item two\n\n" ++
4284 "> quoted [four](four.zig)\n\n" ++
4285 "| head | head |\n| :--- | :--- |\n| [five](five.zig) | cell |\n";
4286 var plain = try render(std.testing.allocator, source, .{});
4287 defer plain.deinit(std.testing.allocator);
4288 var storage: [16]RenderedLink = undefined;
4289 var report: LinkReport = .{ .storage = &storage };
4290 var reported = try render(
4291 std.testing.allocator,
4292 source,
4293 .{ .link_report = &report },
4294 );
4295 defer reported.deinit(std.testing.allocator);
4296 try std.testing.expectEqualStrings(plain.html, reported.html);
4297 try std.testing.expectEqual(@as(usize, 5), report.links().len);
4298 }
4299
4300 test "link reports cover list, blockquote and table blocks" {
4301 const source =
4302 "- item [one](one.zig)\n\n" ++
4303 "> quoted [two](two.zig)\n\n" ++
4304 "| head |\n| :--- |\n| [three](three.zig) |\n";
4305 var storage: [8]RenderedLink = undefined;
4306 var report: LinkReport = .{ .storage = &storage };
4307 var rendered = try render(
4308 std.testing.allocator,
4309 source,
4310 .{ .link_report = &report },
4311 );
4312 defer rendered.deinit(std.testing.allocator);
4313 const links = report.links();
4314 try std.testing.expectEqual(@as(usize, 3), links.len);
4315 for (links, [_][]const u8{ "one.zig", "two.zig", "three.zig" }) |link, expected| {
4316 try std.testing.expect(link.located);
4317 try std.testing.expect(link.contiguous);
4318 try std.testing.expectEqualStrings(
4319 expected,
4320 source[link.target_start..link.target_end],
4321 );
4322 }
4323 }