use std::collections::HashMap;
use inker::{
Block, DocumentProvenance, DocumentTrustState, Engine, EngineDocument, EngineError, EngineInput,
};
use crate::MarkdownEngine;
pub mod djot;
mod expand;
#[cfg(test)]
mod tests;
pub use expand::{build_clip_knot, build_clip_knot_with_block_provenance};
pub const ENGINE_ID: &str = "nematic.knot";
pub struct KnotEngine {
markdown: MarkdownEngine,
}
impl KnotEngine {
pub fn new() -> Self {
Self {
markdown: MarkdownEngine::new(),
}
}
}
impl Default for KnotEngine {
fn default() -> Self {
Self::new()
}
}
impl Engine for KnotEngine {
fn engine_id(&self) -> &str {
ENGINE_ID
}
fn render(&self, input: &EngineInput) -> Result<EngineDocument, EngineError> {
let (frontmatter, body) = split_frontmatter(&input.body);
let body_input = EngineInput {
address: input.address.clone(),
body: body.to_string(),
content_type: Some("text/markdown".to_string()),
};
let mut doc = self.markdown.render(&body_input)?;
expand::expand_fenced_blocks(&mut doc.blocks);
expand::rewrite_inline_extensions(&mut doc.blocks);
apply_frontmatter(
&mut doc,
&frontmatter,
self.engine_id(),
&input.address,
input.content_type.as_deref(),
);
Ok(doc)
}
}
fn apply_frontmatter(
doc: &mut EngineDocument,
frontmatter: &HashMap<String, FrontmatterValue>,
engine_id: &str,
address: &str,
content_type: Option<&str>,
) {
if let Some(FrontmatterValue::Scalar(title)) = frontmatter.get("title") {
doc.title = Some(title.clone());
}
let mut provenance = DocumentProvenance::for_engine(engine_id, address);
if let Some(FrontmatterValue::Scalar(source)) = frontmatter.get("source") {
provenance.canonical_uri = Some(source.clone());
}
if let Some(FrontmatterValue::Scalar(captured)) = frontmatter.get("captured") {
provenance.fetched_at = Some(captured.clone());
}
if let Some(FrontmatterValue::Scalar(label)) = frontmatter.get("source_label") {
provenance.source_label = Some(label.clone());
}
doc.provenance = provenance;
if let Some(FrontmatterValue::Scalar(trust)) = frontmatter.get("trust") {
doc.trust = parse_trust(trust);
}
doc.content_type = content_type
.map(|s| s.to_string())
.unwrap_or_else(|| "text/x-knot".to_string());
let mut prefix: Vec<Block> = Vec::new();
if let Some(FrontmatterValue::Scalar(kind)) = frontmatter.get("note_kind") {
prefix.push(Block::MetadataRow {
label: "kind".to_string(),
value: kind.clone(),
});
}
if let Some(value) = frontmatter.get("tags") {
let joined = match value {
FrontmatterValue::Scalar(s) => s.clone(),
FrontmatterValue::List(items) => items.join(", "),
};
if !joined.is_empty() {
prefix.push(Block::MetadataRow {
label: "tags".to_string(),
value: joined,
});
}
}
if !prefix.is_empty() {
prefix.append(&mut doc.blocks);
doc.blocks = prefix;
}
}
#[derive(Clone, Debug)]
enum FrontmatterValue {
Scalar(String),
List(Vec<String>),
}
fn split_frontmatter(input: &str) -> (HashMap<String, FrontmatterValue>, &str) {
let opener_len = if let Some(rest) = input.strip_prefix("---\n") {
input.len() - rest.len()
} else if let Some(rest) = input.strip_prefix("---\r\n") {
input.len() - rest.len()
} else {
return (HashMap::new(), input);
};
let after_opener = &input[opener_len..];
let close_match = find_close_marker(after_opener);
let Some((close_start, close_end)) = close_match else {
return (HashMap::new(), input);
};
let yaml = &after_opener[..close_start];
let body = &after_opener[close_end..];
let map = parse_frontmatter_yaml(yaml);
(map, body)
}
fn find_close_marker(haystack: &str) -> Option<(usize, usize)> {
let mut idx = 0;
while idx < haystack.len() {
let rest = &haystack[idx..];
let line_end = rest
.find('\n')
.map(|n| idx + n + 1)
.unwrap_or(haystack.len());
let line = haystack[idx..line_end].trim_end_matches(['\n', '\r']);
if line == "---" {
return Some((idx, line_end));
}
idx = line_end;
}
None
}
fn parse_frontmatter_yaml(yaml: &str) -> HashMap<String, FrontmatterValue> {
let mut map = HashMap::new();
for line in yaml.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let Some((key, value)) = trimmed.split_once(':') else {
continue;
};
let key = key.trim();
let value = value.trim();
if key.is_empty() {
continue;
}
let parsed = if value.starts_with('[') && value.ends_with(']') && value.len() >= 2 {
let inner = &value[1..value.len() - 1];
let items: Vec<String> = inner
.split(',')
.map(|item| strip_optional_quotes(item.trim()).to_string())
.filter(|item| !item.is_empty())
.collect();
FrontmatterValue::List(items)
} else {
FrontmatterValue::Scalar(strip_optional_quotes(value).to_string())
};
map.insert(key.to_string(), parsed);
}
map
}
fn strip_optional_quotes(value: &str) -> &str {
let trimmed = value.trim();
if (trimmed.starts_with('"') && trimmed.ends_with('"') && trimmed.len() >= 2)
|| (trimmed.starts_with('\'') && trimmed.ends_with('\'') && trimmed.len() >= 2)
{
&trimmed[1..trimmed.len() - 1]
} else {
trimmed
}
}
fn parse_trust(value: &str) -> DocumentTrustState {
match value.trim().to_ascii_lowercase().as_str() {
"trusted" => DocumentTrustState::Trusted,
"tofu" => DocumentTrustState::Tofu,
"insecure" => DocumentTrustState::Insecure,
"broken" => DocumentTrustState::Broken,
_ => DocumentTrustState::Unknown,
}
}