Skip to main content

docgen_core/
tablepass.rs

1//! AST pass that wraps every GFM table in a horizontal-scroll container.
2//!
3//! Runs after the math/mermaid passes and before `format_ast`. Each
4//! `NodeValue::Table` gets a `<div class="docgen-table-scroll">` opening block
5//! inserted immediately before it and a matching `</div>` immediately after, so
6//! a table wider than the reading column scrolls inside its own region instead
7//! of squishing its columns (or forcing whole-page horizontal overflow). The CSS
8//! side switches tables to `table-layout: auto` with per-cell min/max widths, so
9//! columns size to their content — the wrapper is what makes the overflow
10//! scrollable rather than clipped.
11//!
12//! Raw HTML is allowed through because `comrak_options().render.unsafe = true`
13//! (same mechanism `mermaidpass` relies on). Wrapping uses sibling insertion
14//! rather than node replacement because a table must keep rendering as a real
15//! `<table>` — only its surroundings change.
16
17use comrak::nodes::{AstNode, NodeHtmlBlock, NodeValue};
18use comrak::Arena;
19
20/// Wrap every table node in a `.docgen-table-scroll` div. Returns the number of
21/// tables wrapped (0 when the document has none).
22pub fn transform_tables<'a>(root: &'a AstNode<'a>, arena: &'a Arena<'a>) -> usize {
23    // Collect first, then mutate: inserting siblings while walking the live tree
24    // would perturb the sibling iterator (mirrors the collect-then-mutate shape
25    // in wikilink.rs).
26    let mut tables: Vec<&'a AstNode<'a>> = Vec::new();
27    collect_tables(root, &mut tables);
28
29    for table in &tables {
30        let open = arena.alloc(AstNode::from(NodeValue::HtmlBlock(NodeHtmlBlock {
31            block_type: 0,
32            literal: "<div class=\"docgen-table-scroll\">\n".to_string(),
33        })));
34        let close = arena.alloc(AstNode::from(NodeValue::HtmlBlock(NodeHtmlBlock {
35            block_type: 0,
36            literal: "</div>\n".to_string(),
37        })));
38        table.insert_before(open);
39        table.insert_after(close);
40    }
41
42    tables.len()
43}
44
45fn collect_tables<'a>(node: &'a AstNode<'a>, out: &mut Vec<&'a AstNode<'a>>) {
46    if matches!(node.data.borrow().value, NodeValue::Table(_)) {
47        out.push(node);
48        // A table's children are rows/cells, never nested tables — no need to
49        // recurse into it.
50        return;
51    }
52    for child in node.children() {
53        collect_tables(child, out);
54    }
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60    use crate::markdown::{comrak_options, format_ast};
61    use comrak::parse_document;
62
63    fn render(md: &str) -> (String, usize) {
64        let opts = comrak_options();
65        let arena = Arena::new();
66        let root = parse_document(&arena, md, &opts);
67        let count = transform_tables(root, &arena);
68        (format_ast(root, &opts), count)
69    }
70
71    const TABLE: &str = "| a | b |\n|---|---|\n| 1 | 2 |\n";
72
73    #[test]
74    fn wraps_a_single_table_exactly_once() {
75        let (html, count) = render(TABLE);
76        assert_eq!(count, 1);
77        assert_eq!(html.matches("docgen-table-scroll").count(), 1);
78        assert!(html.contains("<div class=\"docgen-table-scroll\">"));
79        assert!(html.contains("<table>"));
80        // The wrapper opens before the table and closes after it.
81        let open = html.find("docgen-table-scroll").unwrap();
82        let table = html.find("<table>").unwrap();
83        let close = html.rfind("</div>").unwrap();
84        let table_end = html.find("</table>").unwrap();
85        assert!(open < table, "wrapper must open before the table");
86        assert!(close > table_end, "wrapper must close after the table");
87    }
88
89    #[test]
90    fn wraps_each_of_several_tables() {
91        let md = format!("{TABLE}\ntext between\n\n{TABLE}");
92        let (html, count) = render(&md);
93        assert_eq!(count, 2);
94        assert_eq!(html.matches("docgen-table-scroll").count(), 2);
95    }
96
97    #[test]
98    fn document_without_a_table_is_unchanged() {
99        let (html, count) = render("# Heading\n\nJust a paragraph.\n");
100        assert_eq!(count, 0);
101        assert!(!html.contains("docgen-table-scroll"));
102    }
103}