use anyhow::Result;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Default)]
pub struct Glossary {
pub entries: HashMap<String, String>,
pub sorted_terms: Vec<String>,
}
impl Glossary {
pub fn load(book_dir: &Path) -> Result<Self> {
let glossary_path = book_dir.join("GLOSSARY.md");
if !glossary_path.exists() {
return Ok(Self::default());
}
let content = fs::read_to_string(&glossary_path)?;
Self::parse(&content)
}
pub fn parse(content: &str) -> Result<Self> {
let mut entries = HashMap::new();
let mut current_term: Option<String> = None;
let mut current_definition = String::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("# ") {
continue;
}
if trimmed.starts_with("## ") {
if let Some(term) = current_term.take() {
let definition = current_definition.trim().to_string();
if !definition.is_empty() {
entries.insert(term, definition);
}
}
current_term = Some(trimmed[3..].trim().to_string());
current_definition.clear();
continue;
}
if current_term.is_some() && !trimmed.is_empty() {
if !current_definition.is_empty() {
current_definition.push(' ');
}
current_definition.push_str(trimmed);
}
}
if let Some(term) = current_term {
let definition = current_definition.trim().to_string();
if !definition.is_empty() {
entries.insert(term, definition);
}
}
let mut sorted_terms: Vec<String> = entries.keys().cloned().collect();
sorted_terms.sort_by(|a, b| b.len().cmp(&a.len()));
Ok(Self {
entries,
sorted_terms,
})
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn get(&self, term: &str) -> Option<&String> {
self.entries.get(term)
}
}
pub fn apply_glossary(html: &str, glossary: &Glossary) -> String {
if glossary.is_empty() {
return html.to_string();
}
let mut result = html.to_string();
for term in &glossary.sorted_terms {
if let Some(definition) = glossary.get(term) {
result = replace_term_in_html(&result, term, definition);
}
}
result
}
fn replace_term_in_html(html: &str, term: &str, definition: &str) -> String {
let mut result = String::new();
let mut chars = html.char_indices().peekable();
let mut in_tag = false;
let mut in_code = false;
let mut in_glossary_span = false;
let mut tag_content = String::new();
while let Some((i, c)) = chars.next() {
if c == '<' {
in_tag = true;
tag_content.clear();
result.push(c);
continue;
}
if c == '>' && in_tag {
in_tag = false;
result.push(c);
let tag_lower = tag_content.to_lowercase();
if tag_lower.starts_with("code") || tag_lower.starts_with("pre") {
in_code = true;
} else if tag_lower.starts_with("/code") || tag_lower.starts_with("/pre") {
in_code = false;
} else if tag_lower.starts_with("span") && tag_lower.contains("glossary-term") {
in_glossary_span = true;
} else if tag_lower.starts_with("/span") && in_glossary_span {
in_glossary_span = false;
}
continue;
}
if in_tag {
tag_content.push(c);
result.push(c);
continue;
}
if in_code || in_glossary_span {
result.push(c);
continue;
}
if html[i..].starts_with(term) {
let before_ok = i == 0 || !is_word_char(result.chars().last().unwrap_or(' '));
let after_idx = i + term.len();
let after_ok = after_idx >= html.len()
|| !is_word_char(html[after_idx..].chars().next().unwrap_or(' '));
if before_ok && after_ok {
let escaped_def = html_escape_attribute(definition);
result.push_str(&format!(
r#"<span class="glossary-term" data-definition="{}">{}</span>"#,
escaped_def, term
));
for _ in 0..term.len() - 1 {
chars.next();
}
continue;
}
}
result.push(c);
}
result
}
fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || c > '\x7F' }
fn html_escape_attribute(s: &str) -> String {
s.replace('&', "&")
.replace('"', """)
.replace('<', "<")
.replace('>', ">")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_glossary() {
let content = r#"# GLOSSARY
## API
Application Programming Interface の略
## SDK
Software Development Kit の略
"#;
let glossary = Glossary::parse(content).unwrap();
assert_eq!(glossary.entries.len(), 2);
assert_eq!(
glossary.get("API"),
Some(&"Application Programming Interface の略".to_string())
);
assert_eq!(
glossary.get("SDK"),
Some(&"Software Development Kit の略".to_string())
);
}
#[test]
fn test_parse_multiline_definition() {
let content = r#"# GLOSSARY
## REST
Representational State Transfer の略。
Web APIの設計スタイルの一つ。
"#;
let glossary = Glossary::parse(content).unwrap();
assert_eq!(
glossary.get("REST"),
Some(&"Representational State Transfer の略。 Web APIの設計スタイルの一つ。".to_string())
);
}
#[test]
fn test_apply_glossary() {
let glossary = Glossary::parse("## API\nInterface").unwrap();
let html = "<p>This is an API example.</p>";
let result = apply_glossary(html, &glossary);
assert!(result.contains(r#"<span class="glossary-term" data-definition="Interface">API</span>"#));
}
#[test]
fn test_apply_glossary_in_code() {
let glossary = Glossary::parse("## API\nInterface").unwrap();
let html = "<p>Use the <code>API</code> endpoint.</p>";
let result = apply_glossary(html, &glossary);
assert!(result.contains("<code>API</code>"));
assert!(!result.contains("glossary-term"));
}
#[test]
fn test_apply_glossary_word_boundary() {
let glossary = Glossary::parse("## API\nInterface").unwrap();
let html = "<p>The APIARY tool is different from API.</p>";
let result = apply_glossary(html, &glossary);
assert!(result.contains("APIARY"));
assert!(result.contains("glossary-term"));
}
#[test]
fn test_empty_glossary() {
let glossary = Glossary::default();
assert!(glossary.is_empty());
}
#[test]
fn test_sorted_terms_longest_first() {
let content = r#"# GLOSSARY
## API
Application Programming Interface
## REST API
RESTful API
## REST
Representational State Transfer
"#;
let glossary = Glossary::parse(content).unwrap();
assert_eq!(glossary.sorted_terms[0], "REST API");
}
}