Skip to main content

textlane_html/actions/hast/
toc.rs

1use serde_json::{Map, Value, json};
2use textlane_core::hast::{Node, PropertyValue};
3
4use crate::helpers::property::find_property;
5use crate::helpers::schema::HTML;
6use crate::helpers::value::write_value;
7use crate::models::options::ExtractToc;
8
9/// The headings of a tree as @stefanprobst/rehype-extract-toc nests them: each
10/// `{depth, value, id?, children?}`, under the closest heading of a lower rank before it.
11pub fn extract_toc(node: &Node<'_>, options: &ExtractToc) -> Value {
12    let skipped: Vec<(String, Option<&str>)> = (options.skip.iter())
13        .map(|rule| match rule.split_once('=') {
14            Some((name, value)) => (read_attribute(name), Some(value)),
15            None => (read_attribute(rule), None),
16        })
17        .collect();
18    let min = options.min_depth.unwrap_or(1);
19    let max = options.max_depth.unwrap_or(6);
20    let mut headings = Vec::new();
21    let mut stack: Vec<(&Node<'_>, bool)> = vec![(node, false)];
22    while let Some((node, inside)) = stack.pop() {
23        let (children, inside) = match node {
24            Node::Root(root) => (&root.children, inside),
25            Node::Element(element) => {
26                let left_out = inside
27                    || element.properties.iter().any(|property| {
28                        let attribute = read_attribute(property.name);
29                        skipped.iter().any(|(name, value)| {
30                            *name == attribute
31                                && match value {
32                                    Some(value) => write_value(&property.value) == *value,
33                                    None => !matches!(
34                                        property.value,
35                                        PropertyValue::Null
36                                            | PropertyValue::Boolean(false)
37                                    ),
38                                }
39                        })
40                    });
41                let depth = read_rank(element.tag_name)
42                    .filter(|depth| !left_out && (min..=max).contains(depth));
43                if let Some(depth) = depth {
44                    let mut heading = Map::new();
45                    heading.insert("depth".into(), json!(depth));
46                    heading.insert("value".into(), json!(read_text(node)));
47                    if let Some(id) = read_id(element) {
48                        heading.insert("id".into(), id);
49                    }
50                    headings.push((depth, heading));
51                }
52                let inside = inside
53                    || (options.skip_inside.iter())
54                        .any(|name| name.eq_ignore_ascii_case(element.tag_name));
55                (&element.children, inside)
56            }
57            _ => continue,
58        };
59        stack.extend(children.iter().rev().map(|child| (child, inside)));
60    }
61    nest_headings(headings)
62}
63
64// `createTree`: a heading deeper than the one before opens it as a parent; a shallower one
65// closes parents down to one shallower than itself
66fn nest_headings(headings: Vec<(u8, Map<String, Value>)>) -> Value {
67    let mut parents: Vec<(u8, Map<String, Value>, Vec<Value>)> =
68        vec![(0, Map::new(), vec![])];
69    let mut previous_depth = 0;
70    for (depth, heading) in headings {
71        if depth > previous_depth {
72            // the previous heading becomes a parent once something nests in it
73        } else {
74            while parents.len() > 1 && parents.last().is_some_and(|(d, _, _)| *d >= depth)
75            {
76                close_parent(&mut parents);
77            }
78        }
79        parents.push((depth, heading, vec![]));
80        previous_depth = depth;
81    }
82    while parents.len() > 1 {
83        close_parent(&mut parents);
84    }
85    let root = parents
86        .pop()
87        .map(|(_, _, children)| children)
88        .unwrap_or_default();
89    Value::Array(root)
90}
91
92// The innermost open heading joins its parent's children, with its own when it has any
93fn close_parent(parents: &mut Vec<(u8, Map<String, Value>, Vec<Value>)>) {
94    let Some((_, mut heading, children)) = parents.pop() else {
95        return;
96    };
97    if !children.is_empty() {
98        heading.insert("children".into(), Value::Array(children));
99    }
100    if let Some((_, _, siblings)) = parents.last_mut() {
101        siblings.push(Value::Object(heading));
102    }
103}
104
105// hast-util-heading-rank
106fn read_rank(tag_name: &str) -> Option<u8> {
107    match tag_name.to_ascii_lowercase().as_bytes() {
108        [b'h', digit @ b'1'..=b'6'] => Some(digit - b'0'),
109        _ => None,
110    }
111}
112
113// hast-util-to-string: the text of every descendant, in order
114fn read_text(node: &Node<'_>) -> String {
115    let mut text = String::new();
116    let mut stack = vec![node];
117    while let Some(node) = stack.pop() {
118        match node {
119            Node::Text(node) => text.push_str(node.value),
120            Node::Element(element) => stack.extend(element.children.iter().rev()),
121            Node::Root(root) => stack.extend(root.children.iter().rev()),
122            _ => {}
123        }
124    }
125    text
126}
127
128fn read_id(element: &textlane_core::hast::Element<'_>) -> Option<Value> {
129    let property = element
130        .properties
131        .iter()
132        .find(|property| property.name == "id")?;
133    match &property.value {
134        PropertyValue::String(id) => Some(json!(id)),
135        PropertyValue::Number(id) => Some(json!(id)),
136        PropertyValue::Boolean(id) => Some(json!(id)),
137        PropertyValue::List(_) | PropertyValue::Null => None,
138    }
139}
140
141// The HTML attribute a property or attribute name stands for, `dataStep` and `data-step` alike
142fn read_attribute(name: &str) -> String {
143    find_property(&HTML, name).attribute.to_lowercase()
144}