pub struct Preprocessed {
pub text: String,
pub title: Option<String>,
pub flowchart_curve: Option<String>,
}
pub fn preprocess(src: &str) -> Preprocessed {
let text = normalize_newlines(src);
let text = single_quote_html_attributes(&text);
let (text, title) = strip_front_matter(&text);
let (text, flowchart_curve) = strip_directives(&text);
let text = strip_comments(&text);
Preprocessed {
text,
title,
flowchart_curve,
}
}
fn normalize_newlines(src: &str) -> String {
let mut out = String::with_capacity(src.len());
let mut chars = src.chars().peekable();
while let Some(c) = chars.next() {
if c == '\r' {
if chars.peek() == Some(&'\n') {
chars.next();
}
out.push('\n');
} else {
out.push(c);
}
}
out
}
fn single_quote_html_attributes(src: &str) -> String {
let chars: Vec<char> = src.chars().collect();
let mut out = String::with_capacity(src.len());
let mut i = 0;
while i < chars.len() {
if chars[i] != '<' {
out.push(chars[i]);
i += 1;
continue;
}
let mut j = i + 1;
while j < chars.len() && (chars[j].is_alphanumeric() || chars[j] == '_') {
j += 1;
}
if j == i + 1 {
out.push('<');
i += 1;
continue;
}
let mut k = j;
while k < chars.len() && chars[k] != '>' {
k += 1;
}
if k >= chars.len() {
out.push('<');
i += 1;
continue;
}
out.push('<');
out.extend(&chars[i + 1..j]);
out.push_str(&requote_attributes(&chars[j..k]));
out.push('>');
i = k + 1;
}
out
}
fn requote_attributes(attrs: &[char]) -> String {
let mut out = String::new();
let mut i = 0;
while i < attrs.len() {
if attrs[i] == '=' && i + 1 < attrs.len() && attrs[i + 1] == '"' {
if let Some(end) = (i + 2..attrs.len()).find(|&p| attrs[p] == '"') {
out.push('=');
out.push('\'');
out.extend(&attrs[i + 2..end]);
out.push('\'');
i = end + 1;
continue;
}
}
out.push(attrs[i]);
i += 1;
}
out
}
fn strip_front_matter(src: &str) -> (String, Option<String>) {
let lines: Vec<&str> = src.split('\n').collect();
if lines.is_empty() || lines[0].trim_end() != "---" {
return (src.to_string(), None);
}
let close = lines
.iter()
.enumerate()
.skip(1)
.find(|(_, l)| l.trim_end() == "---")
.map(|(i, _)| i);
let Some(close) = close else {
return (src.to_string(), None);
};
let title = front_matter_title(&lines[1..close]);
let mut out = String::with_capacity(src.len());
for _ in 0..=close {
out.push('\n');
}
out.push_str(&lines[close + 1..].join("\n"));
(out, title)
}
fn front_matter_title(body: &[&str]) -> Option<String> {
for line in body {
let Some(rest) = line.strip_prefix("title:") else {
continue;
};
let v = rest.trim();
let v = v
.strip_prefix('"')
.and_then(|v| v.strip_suffix('"'))
.or_else(|| v.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
.unwrap_or(v);
if v.is_empty() {
return None;
}
return Some(v.to_string());
}
None
}
fn strip_directives(src: &str) -> (String, Option<String>) {
let chars: Vec<char> = src.chars().collect();
let mut out = String::with_capacity(src.len());
let mut curve = None;
let mut i = 0;
while i < chars.len() {
if chars[i] == '%' && chars.get(i + 1) == Some(&'%') && chars.get(i + 2) == Some(&'{') {
let end = find_seq(&chars, i + 3, &['}', '%', '%']);
let stop = match end {
Some(e) => e + 3,
None => (i..chars.len())
.find(|&p| chars[p] == '\n')
.unwrap_or(chars.len()),
};
let body_end = end.map_or(stop, |e| e + 1);
let body: String = chars[i + 3..body_end].iter().collect();
if let Some(found) = init_flowchart_curve(&body) {
curve = Some(found);
}
for c in &chars[i..stop] {
if *c == '\n' {
out.push('\n');
}
}
i = stop;
continue;
}
out.push(chars[i]);
i += 1;
}
(out, curve)
}
fn find_seq(chars: &[char], from: usize, needle: &[char]) -> Option<usize> {
if needle.is_empty() || chars.len() < needle.len() {
return None;
}
(from..=chars.len() - needle.len()).find(|&p| chars[p..p + needle.len()] == *needle)
}
fn is_word_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_'
}
fn key_value_start<'a>(text: &'a str, key: &str) -> Option<&'a str> {
let chars: Vec<char> = text.chars().collect();
let key_chars: Vec<char> = key.chars().collect();
if key_chars.is_empty() || chars.len() < key_chars.len() {
return None;
}
let mut i = 0;
while i + key_chars.len() <= chars.len() {
if chars[i..i + key_chars.len()] == key_chars[..] {
let before_ok = i == 0 || !is_word_char(chars[i - 1]);
let after_idx = i + key_chars.len();
let after_ok = after_idx >= chars.len() || !is_word_char(chars[after_idx]);
if before_ok && after_ok {
let quote_before = i > 0 && matches!(chars[i - 1], '\'' | '"');
let quote_after = after_idx < chars.len() && matches!(chars[after_idx], '\'' | '"');
if quote_before == quote_after {
let mut j = after_idx + usize::from(quote_after);
while j < chars.len() && chars[j].is_whitespace() {
j += 1;
}
if j < chars.len() && chars[j] == ':' {
j += 1;
while j < chars.len() && chars[j].is_whitespace() {
j += 1;
}
let byte_start: usize = chars[..j].iter().map(|c| c.len_utf8()).sum();
return Some(&text[byte_start..]);
}
}
}
}
i += 1;
}
None
}
fn balanced_object(text: &str) -> Option<&str> {
let mut iter = text.char_indices();
match iter.next() {
Some((_, '{')) => {}
_ => return None,
}
let mut depth = 1i32;
for (idx, c) in iter {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
return Some(&text[1..idx]);
}
}
_ => {}
}
}
None
}
fn leading_string_value(text: &str) -> Option<String> {
let mut chars = text.chars();
match chars.next() {
Some(q @ ('\'' | '"')) => {
let rest = &text[q.len_utf8()..];
let end = rest.find(q)?;
Some(rest[..end].to_string())
}
Some(c) if c.is_ascii_alphabetic() || c == '_' => {
let end = text
.char_indices()
.find(|&(_, c)| !is_word_char(c))
.map_or(text.len(), |(i, _)| i);
Some(text[..end].to_string())
}
_ => None,
}
}
fn init_flowchart_curve(directive_body: &str) -> Option<String> {
let directive = key_value_start(directive_body, "init")
.or_else(|| key_value_start(directive_body, "initialize"))
.or_else(|| key_value_start(directive_body, "config"))?;
let config = balanced_object(directive)?;
let flowchart = balanced_object(key_value_start(config, "flowchart")?)?;
leading_string_value(key_value_start(flowchart, "curve")?)
}
fn strip_comments(src: &str) -> String {
let mut out = String::with_capacity(src.len());
for (i, line) in src.split('\n').enumerate() {
if i > 0 {
out.push('\n');
}
let trimmed = line.trim_start();
let is_comment = trimmed.starts_with("%%") && !trimmed.starts_with("%%{");
if !is_comment {
out.push_str(line);
}
}
out
}