pub struct Preprocessed {
pub text: String,
pub title: 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 = strip_directives(&text);
let text = strip_comments(&text);
Preprocessed { text, title }
}
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 {
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] == '%' && 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()),
};
for c in &chars[i..stop] {
if *c == '\n' {
out.push('\n');
}
}
i = stop;
continue;
}
out.push(chars[i]);
i += 1;
}
out
}
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 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
}