katana-markdown-engine 0.1.0

Renderer-neutral Markdown document model for the KatanA ecosystem
Documentation
use crate::{
    CodeBlockRole, DescriptionItem, DiagramKind, HeadingNode, HtmlBlockRole, KmeNodeKind, ListNode,
};

pub(crate) fn heading(line: &str) -> Option<KmeNodeKind> {
    let hashes = line.chars().take_while(|it| *it == '#').count();
    if !(1..=6).contains(&hashes) || !line[hashes..].starts_with(' ') {
        return None;
    }
    Some(KmeNodeKind::Heading(HeadingNode {
        level: hashes as u8,
        text: line[hashes + 1..].trim().to_string(),
    }))
}

pub(crate) fn code_block_role(fence: &str) -> CodeBlockRole {
    let language = fence.trim_start_matches("```").trim();
    match language.to_ascii_lowercase().as_str() {
        "mermaid" => CodeBlockRole::Diagram {
            kind: DiagramKind::Mermaid,
        },
        "drawio" | "draw.io" => CodeBlockRole::Diagram {
            kind: DiagramKind::DrawIo,
        },
        "plantuml" | "puml" => CodeBlockRole::Diagram {
            kind: DiagramKind::PlantUml,
        },
        "math" => CodeBlockRole::Math,
        "" => CodeBlockRole::Plain { language: None },
        value => CodeBlockRole::Plain {
            language: Some(value.to_string()),
        },
    }
}

pub(crate) fn html_role(raw: &str) -> HtmlBlockRole {
    if raw.contains("<img") && raw.contains("<a ") {
        return HtmlBlockRole::BadgeRow;
    }
    if raw.contains("align=\"center\"") || raw.contains("<center") {
        return HtmlBlockRole::Centered;
    }
    HtmlBlockRole::Generic
}

pub(crate) fn alert_label(lines: &[String]) -> Option<String> {
    let first = lines.first()?.trim_start_matches('>').trim();
    if let Some(label) = first.strip_prefix("[!").and_then(|it| it.strip_suffix(']')) {
        return Some(label.to_ascii_uppercase());
    }
    let legacy = first.trim_matches('*').to_ascii_uppercase();
    matches!(
        legacy.as_str(),
        "NOTE" | "TIP" | "IMPORTANT" | "WARNING" | "CAUTION"
    )
    .then_some(legacy)
}

pub(crate) fn list_node(lines: &[String]) -> ListNode {
    ListNode {
        ordered: lines.iter().any(|line| ordered_list_line(line)),
        task_markers: lines.iter().filter_map(|line| task_marker(line)).collect(),
    }
}

pub(crate) fn description_items(lines: &[String]) -> Vec<DescriptionItem> {
    lines
        .chunks(2)
        .filter_map(|chunk| {
            let term = chunk.first()?.trim().to_string();
            let description = chunk
                .get(1)?
                .trim()
                .trim_start_matches(':')
                .trim()
                .to_string();
            Some(DescriptionItem { term, description })
        })
        .collect()
}

pub(crate) fn unordered_list_line(line: &str) -> bool {
    line.trim_start().starts_with("- ")
}

pub(crate) fn ordered_list_line(line: &str) -> bool {
    let trimmed = line.trim_start();
    let Some((number, rest)) = trimmed.split_once('.') else {
        return false;
    };
    !number.is_empty() && number.chars().all(|it| it.is_ascii_digit()) && rest.starts_with(' ')
}

fn task_marker(line: &str) -> Option<String> {
    ["[x]", "[ ]", "[-]", "[/]"]
        .iter()
        .find(|marker| line.contains(**marker))
        .map(|marker| (*marker).to_string())
}