dmc/engine/
accumulator.rs1use dmc_codegen::{NodeSink, WalkCtx};
2use dmc_parser::ast::Node;
3
4use crate::engine::compile::{CompileConfig, CompileOutput, Metadata, TocItem};
5
6#[derive(Debug)]
7pub struct Accumulator {
8 pub frontmatter: serde_json::Value,
9 pub frontmatter_raw: String,
10 pub imports: Vec<String>,
11 pub exports: Vec<String>,
12 pub plain: String,
14 pub toc_flat: Vec<(u8, String, String)>,
16
17 in_heading: Option<(u8, String)>,
18 heading_text: String,
19}
20
21impl NodeSink for Accumulator {
22 fn enter(&mut self, node: &Node, _ctx: &WalkCtx) {
23 match node {
24 Node::Frontmatter(f) => {
25 self.frontmatter_raw = f.raw.clone();
26 self.frontmatter = serde_yaml::from_str(&f.raw).unwrap_or(serde_json::Value::Null);
27 },
28 Node::Import(i) => self.imports.push(i.raw.clone()),
29 Node::Export(x) => self.exports.push(x.raw.clone()),
30 Node::Heading(h) => {
31 self.in_heading = Some((h.level, h.slug()));
32 self.heading_text.clear();
33 },
34 Node::Text(t) => {
35 if self.in_heading.is_some() {
36 self.heading_text.push_str(&t.value);
37 }
38 self.plain.push_str(&t.value)
39 },
40 Node::InlineCode(c) => {
41 if self.in_heading.is_some() {
42 self.heading_text.push_str(&c.value);
43 }
44 self.plain.push_str(&c.value);
45 },
46 Node::CodeBlock(cb) => {
47 if self.in_heading.is_some() {
48 self.heading_text.push_str(&cb.value);
49 }
50 self.plain.push_str(&cb.value);
51 },
52 Node::Image(i) => self.plain.push_str(&i.alt),
53 _ => {},
54 }
55 }
56 fn leave(&mut self, node: &Node, _ctx: &WalkCtx) {
57 match node {
58 Node::Heading(_) => {
59 if let Some((level, slug)) = self.in_heading.take() {
60 self.toc_flat.push((level, std::mem::take(&mut self.heading_text).trim().to_string(), slug));
61 }
62 },
63 Node::Paragraph(_) => self.plain.push('\n'),
64 _ => {},
65 }
66 }
67}
68
69impl Default for Accumulator {
70 fn default() -> Self {
71 Self::new()
72 }
73}
74
75impl Accumulator {
76 pub fn new() -> Self {
77 Self {
78 frontmatter: serde_json::Value::Null,
79 frontmatter_raw: String::new(),
80 imports: Vec::new(),
81 exports: Vec::new(),
82 plain: String::new(),
83 toc_flat: Vec::new(),
84 in_heading: None,
85 heading_text: String::new(),
86 }
87 }
88
89 pub fn into_compile_output(self, source: &str, html: String, body: String, _cfg: &CompileConfig) -> CompileOutput {
91 let content = Self::frontmatter(source).to_string();
92 let excerpt = Self::excerpt(&self.plain, 260);
93 let metadata = Self::metadata(&content, &self.plain);
97 let toc = Self::toc(&self.toc_flat);
98
99 CompileOutput {
100 frontmatter: self.frontmatter,
101 frontmatter_raw: self.frontmatter_raw,
102 content,
103 html,
104 body,
105 excerpt,
106 metadata,
107 toc,
108 imports: self.imports,
109 exports: self.exports,
110 }
111 }
112
113 fn frontmatter(source: &str) -> &str {
115 let s = source.trim_start_matches('\u{feff}');
116 if !s.starts_with("---") {
117 return source;
118 }
119 let after = &s[3..];
120 if let Some(end) = after.find("\n---") {
121 let rest_start = 3 + end + 4;
122 let rest = &s[rest_start..];
123 let rest = rest.trim_start_matches('\n');
124 return rest;
125 }
126 source
127 }
128
129 fn excerpt(plain: &str, max: usize) -> String {
131 let s: String = plain.split_whitespace().collect::<Vec<_>>().join(" ");
132 if s.chars().count() <= max {
133 return s;
134 }
135 let truncated: String = s.chars().take(max).collect();
136 format!("{}...", truncated.trim_end())
137 }
138
139 fn metadata(source: &str, plain: &str) -> Metadata {
143 let mut filtered = String::with_capacity(source.len());
144 let mut in_fence = false;
145 for line in source.lines() {
146 if line.trim_start().starts_with("```") {
147 in_fence = !in_fence;
148 continue;
149 }
150 if in_fence {
151 continue;
152 }
153 let trimmed = line.trim_start();
155 if let Some(rest) = trimmed.strip_prefix(|c: char| c == '#') {
156 let mut after_hashes = rest;
157 while let Some(r) = after_hashes.strip_prefix('#') {
158 after_hashes = r;
159 }
160 if after_hashes.starts_with(' ') || after_hashes.starts_with('\t') || after_hashes.is_empty() {
161 filtered.push_str(after_hashes);
162 filtered.push('\n');
163 continue;
164 }
165 }
166 filtered.push_str(line);
167 filtered.push('\n');
168 }
169 let words = filtered.split_whitespace().count() as u32;
170 let plain_words = plain.split_whitespace().count() as u32;
171 let reading = ((plain_words as f32) / 200.0).round() as u32;
173 Metadata { word_count: words, reading_time: reading.max(1) }
174 }
175
176 fn toc(items: &[(u8, String, String)]) -> Vec<TocItem> {
178 let mut roots: Vec<TocItem> = Vec::new();
179 let mut path: Vec<usize> = Vec::new();
180 let mut levels: Vec<u8> = Vec::new();
181 for (level, title, id) in items {
182 let item = TocItem { title: title.clone(), url: format!("#{}", id), items: Vec::new() };
183 while let Some(top) = levels.last() {
184 if *top >= *level {
185 levels.pop();
186 path.pop();
187 } else {
188 break;
189 }
190 }
191 let parent_list: &mut Vec<TocItem> = if path.is_empty() {
192 &mut roots
193 } else {
194 let mut node = &mut roots[path[0]];
195 for idx in &path[1..] {
196 node = &mut node.items[*idx];
197 }
198 &mut node.items
199 };
200 parent_list.push(item);
201 let new_idx = parent_list.len() - 1;
202 path.push(new_idx);
203 levels.push(*level);
204 }
205 roots
206 }
207}