textlane_html/actions/hast/
toc.rs1use 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
9pub 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
64fn 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 } 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
92fn 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
105fn 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
113fn 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
141fn read_attribute(name: &str) -> String {
143 find_property(&HTML, name).attribute.to_lowercase()
144}