1//! Clover's Q+A extensions for paperclover.net
2//!
3//! Block rules:
4//! - `q: text` -> `<question>text</question>` (consecutive q: lines get <br /> between)
5//! - `@html <raw>` -> raw HTML passthrough
6//!
7//! Inline rules:
8//! - `@slug` -> `<artifactRef>slug</artifactRef>`
9//! - `#2602142011` -> `<questionRef>2602142011</questionRef>`
10//! - `##label##` -> `<labelledRedaction>label</labelledRedaction>`
11
12use crate::CloverExtensions;
13use markdown_it::parser::block::{BlockRule, BlockState};
14use markdown_it::parser::extset::MarkdownItExt;
15use markdown_it::parser::inline::{InlineRoot, InlineRule, InlineState};
16use markdown_it::{MarkdownIt, Node, NodeValue, Renderer};
17
18/// Stored in MarkdownIt.ext for runtime access
19#[derive(Debug, Clone)]
20pub struct CloverConfig(pub CloverExtensions);
21
22impl MarkdownItExt for CloverConfig {}
23
24/// Marker used to separate question lines (will become <br>)
25const QUESTION_LINE_BREAK: &str = "\x1F";
26
27/// Register clover extension rules
28pub fn add(md: &mut MarkdownIt, config: CloverExtensions) {
29 md.ext.insert(CloverConfig(config));
30 md.block.add_rule::<QuestionBlockRule>();
31 md.block.add_rule::<HtmlBlockRule>();
32 md.inline.add_rule::<QuestionLineBreakRule>();
33 md.inline.add_rule::<ArtifactRefRule>();
34 md.inline.add_rule::<QuestionRefRule>();
35 md.inline.add_rule::<LabelledRedactionRule>();
36}
37
38// =============================================================================
39// Question Block: `q: inline text`
40// =============================================================================
41
42#[derive(Debug)]
43pub struct QuestionBlock {
44 pub element: String,
45}
46
47impl NodeValue for QuestionBlock {
48 fn render(&self, node: &Node, fmt: &mut dyn Renderer) {
49 fmt.text_raw(&format!("<{}>", self.element));
50 fmt.contents(&node.children);
51 fmt.text_raw(&format!("</{}>", self.element));
52 fmt.text_raw("\n");
53 }
54}
55
56/// Node for line breaks within question blocks
57#[derive(Debug)]
58pub struct QuestionLineBreak;
59
60impl NodeValue for QuestionLineBreak {
61 fn render(&self, _node: &Node, fmt: &mut dyn Renderer) {
62 fmt.text_raw("<br>");
63 }
64}
65
66/// Inline rule to convert line break markers to <br>
67pub struct QuestionLineBreakRule;
68
69impl InlineRule for QuestionLineBreakRule {
70 const MARKER: char = '\x1F';
71
72 fn run(state: &mut InlineState) -> Option<(Node, usize)> {
73 let input = &state.src[state.pos..state.pos_max];
74 if input.starts_with(QUESTION_LINE_BREAK) {
75 Some((Node::new(QuestionLineBreak), 1))
76 } else {
77 None
78 }
79 }
80}
81
82pub struct QuestionBlockRule;
83
84impl BlockRule for QuestionBlockRule {
85 fn run(state: &mut BlockState) -> Option<(Node, usize)> {
86 let config = state.md.ext.get::<CloverConfig>()?;
87
88 if state.line >= state.line_max {
89 return None;
90 }
91
92 let first_line = state.get_line(state.line);
93 if !first_line.starts_with("q: ") {
94 return None;
95 }
96
97 // Consume all consecutive q: lines
98 let start_line = state.line;
99 let mut line_count = 0;
100 let mut contents = Vec::new();
101
102 while start_line + line_count < state.line_max {
103 let line = state.get_line(start_line + line_count);
104 if !line.starts_with("q: ") {
105 break;
106 }
107 contents.push(&line[3..]);
108 line_count += 1;
109 }
110
111 // Join lines with marker - converted to <br> by QuestionLineBreakRule
112 let combined = contents.join(QUESTION_LINE_BREAK);
113 let mapping = vec![(0, state.line_offsets[start_line].first_nonspace + 3)];
114
115 let mut node = Node::new(QuestionBlock {
116 element: config.0.question.clone(),
117 });
118 node.children
119 .push(Node::new(InlineRoot::new(combined, mapping)));
120
121 Some((node, line_count))
122 }
123}
124
125// =============================================================================
126// HTML Block: `@html <raw content>`
127// =============================================================================
128
129#[derive(Debug)]
130pub struct RawHtmlBlock {
131 pub content: String,
132}
133
134impl NodeValue for RawHtmlBlock {
135 fn render(&self, _node: &Node, fmt: &mut dyn Renderer) {
136 fmt.text_raw(&self.content);
137 fmt.text_raw("\n");
138 }
139}
140
141pub struct HtmlBlockRule;
142
143impl BlockRule for HtmlBlockRule {
144 fn run(state: &mut BlockState) -> Option<(Node, usize)> {
145 // This rule is enabled when any clover extensions are present
146 state.md.ext.get::<CloverConfig>()?;
147
148 if state.line >= state.line_max {
149 return None;
150 }
151
152 let line = state.get_line(state.line);
153 if !line.starts_with("@html ") {
154 return None;
155 }
156
157 let content = &line[6..];
158 let node = Node::new(RawHtmlBlock {
159 content: content.to_string(),
160 });
161
162 Some((node, 1))
163 }
164}
165
166// =============================================================================
167// Artifact Ref: `@slug-name`
168// =============================================================================
169
170#[derive(Debug)]
171pub struct ArtifactRef {
172 pub element: String,
173 pub slug: String,
174}
175
176impl NodeValue for ArtifactRef {
177 fn render(&self, _node: &Node, fmt: &mut dyn Renderer) {
178 fmt.text_raw(&format!("<{0}>{1}</{0}>", self.element, self.slug));
179 }
180}
181
182pub struct ArtifactRefRule;
183
184impl InlineRule for ArtifactRefRule {
185 const MARKER: char = '@';
186
187 fn run(state: &mut InlineState) -> Option<(Node, usize)> {
188 let config = state.md.ext.get::<CloverConfig>()?;
189
190 let input = &state.src[state.pos..state.pos_max];
191 if !input.starts_with('@') {
192 return None;
193 }
194
195 // Match @[a-z0-9-]+ but not @html (that's a block rule)
196 let rest = &input[1..];
197 if rest.starts_with("html ") {
198 return None;
199 }
200
201 let slug_len = rest
202 .chars()
203 .take_while(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '-')
204 .count();
205
206 if slug_len == 0 {
207 return None;
208 }
209
210 let slug = &rest[..slug_len];
211 let node = Node::new(ArtifactRef {
212 element: config.0.artifact_ref.clone(),
213 slug: slug.to_string(),
214 });
215
216 Some((node, 1 + slug_len))
217 }
218}
219
220// =============================================================================
221// Question Ref: `#2602142011` (10 or 12 digits)
222// =============================================================================
223
224#[derive(Debug)]
225pub struct QuestionRef {
226 pub element: String,
227 pub id: String,
228}
229
230impl NodeValue for QuestionRef {
231 fn render(&self, _node: &Node, fmt: &mut dyn Renderer) {
232 fmt.text_raw(&format!("<{0}>{1}</{0}>", self.element, self.id));
233 }
234}
235
236pub struct QuestionRefRule;
237
238impl InlineRule for QuestionRefRule {
239 const MARKER: char = '#';
240
241 fn run(state: &mut InlineState) -> Option<(Node, usize)> {
242 let config = state.md.ext.get::<CloverConfig>()?;
243
244 let input = &state.src[state.pos..state.pos_max];
245 if !input.starts_with('#') {
246 return None;
247 }
248
249 let rest = &input[1..];
250 let digit_count = rest.chars().take_while(|c| c.is_ascii_digit()).count();
251
252 // Must be exactly 10 or 12 digits
253 if digit_count != 10 && digit_count != 12 {
254 return None;
255 }
256
257 // Make sure the next char after digits is not also a digit
258 if rest
259 .chars()
260 .nth(digit_count)
261 .is_some_and(|c| c.is_ascii_digit())
262 {
263 return None;
264 }
265
266 let id = &rest[..digit_count];
267 let node = Node::new(QuestionRef {
268 element: config.0.question_ref.clone(),
269 id: id.to_string(),
270 });
271
272 Some((node, 1 + digit_count))
273 }
274}
275
276// =============================================================================
277// Labelled Redaction: `#label#`
278// =============================================================================
279
280#[derive(Debug)]
281pub struct LabelledRedaction {
282 pub element: String,
283 pub label: String,
284}
285
286impl NodeValue for LabelledRedaction {
287 fn render(&self, _node: &Node, fmt: &mut dyn Renderer) {
288 fmt.text_raw(&format!("<{0}>{1}</{0}>", self.element, self.label));
289 }
290}
291
292pub struct LabelledRedactionRule;
293
294impl InlineRule for LabelledRedactionRule {
295 const MARKER: char = '#';
296
297 fn run(state: &mut InlineState) -> Option<(Node, usize)> {
298 let config = state.md.ext.get::<CloverConfig>()?;
299
300 let input = &state.src[state.pos..state.pos_max];
301 if !input.starts_with("#") {
302 return None;
303 }
304
305 let rest = &input[1..];
306
307 // Find closing #
308 let label_end = rest.find("#")?;
309 if label_end == 0 {
310 return None; // Empty label
311 }
312
313 let label = &rest[..label_end];
314
315 // Label should be simple identifier-like (alphanumeric, hyphens, underscores)
316 if !label
317 .chars()
318 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
319 {
320 return None;
321 }
322
323 let node = Node::new(LabelledRedaction {
324 element: config.0.labelled_redaction.clone(),
325 label: label.to_string(),
326 });
327
328 // # + label + #
329 Some((node, label_end + 2))
330 }
331}