use crate::render_native::{self, NativeBlock, NativeDoc, NativeTheme, NATIVE_DOC_SCHEMA_VERSION};
use crate::resolve;
#[derive(Debug, thiserror::Error, uniffi::Error)]
pub enum SurfDocError {
#[error("Parse error: {msg}")]
Parse { msg: String },
#[error("Frontmatter invalid: {msg}")]
FrontmatterInvalid { msg: String },
#[error("Internal error: {msg}")]
Internal { msg: String },
}
#[uniffi::export]
pub fn parse_surfdoc(source: String) -> Result<Vec<NativeBlock>, SurfDocError> {
let doc = parse_checked(&source)?;
Ok(render_native::to_native_blocks(&doc))
}
#[uniffi::export]
pub fn parse_to_native(source: String) -> Result<NativeDoc, SurfDocError> {
parse_to_native_styled(source, None, None, None)
}
#[uniffi::export]
pub fn parse_to_native_styled(
source: String,
style_pack: Option<String>,
accent: Option<String>,
font: Option<String>,
) -> Result<NativeDoc, SurfDocError> {
let doc = parse_checked(&source)?;
let (site, _pages, _loose) = crate::render_html::extract_site(&doc);
let doc_accent = site.as_ref().and_then(|s| s.accent.clone());
let doc_font = site.as_ref().and_then(|s| s.font.clone());
let doc_pack = site.as_ref().and_then(|s| {
s.properties
.iter()
.find(|p| p.key == "style_pack" || p.key == "style")
.map(|p| p.value.clone())
});
let theme = resolve::resolve_theme(
accent.or(doc_accent).as_deref(),
font.or(doc_font).as_deref(),
style_pack.or(doc_pack).as_deref(),
);
Ok(NativeDoc {
schema_version: NATIVE_DOC_SCHEMA_VERSION,
theme: NativeTheme::from(&theme),
blocks: render_native::to_native_blocks(&doc),
})
}
fn parse_checked(source: &str) -> Result<crate::types::SurfDoc, SurfDocError> {
let result = crate::parse(source);
let fatals: Vec<&crate::error::Diagnostic> = result
.diagnostics
.iter()
.filter(|d| d.severity == crate::error::Severity::Error)
.collect();
if !fatals.is_empty() {
let msg = fatals
.iter()
.map(|d| d.message.clone())
.collect::<Vec<_>>()
.join("; ");
let is_frontmatter = fatals.iter().any(|d| {
let m = d.message.to_lowercase();
m.contains("front matter") || m.contains("frontmatter") || m.contains("yaml")
});
return Err(if is_frontmatter {
SurfDocError::FrontmatterInvalid { msg }
} else {
SurfDocError::Parse { msg }
});
}
Ok(result.doc)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn happy_path_heading_paragraph_code() {
let blocks = parse_surfdoc(
"# Title\n\nSome paragraph.\n\n::code[lang=rust]\nfn main() {}\n::\n".into(),
)
.expect("parse should succeed");
assert!(blocks.len() >= 2);
assert!(blocks.iter().any(|b| matches!(b, NativeBlock::Code { .. })));
}
#[test]
fn frontmatter_only_doc() {
let blocks = parse_surfdoc("---\ntitle: Test\n---\n".into())
.expect("frontmatter-only doc should parse");
assert!(blocks.is_empty());
}
#[test]
fn plain_paragraph_emits_markdown_block() {
let blocks = parse_surfdoc("Just text.".into()).expect("parse should succeed");
assert!(matches!(&blocks[0], NativeBlock::Markdown { content } if content.contains("Just text")));
}
#[test]
fn hero_directive_roundtrip() {
let blocks = parse_surfdoc("::hero\nheadline: Welcome\nsubtitle: to SurfDoc\n::\n".into())
.expect("parse should succeed");
assert!(blocks
.iter()
.any(|b| matches!(b, NativeBlock::Hero { .. })));
}
#[test]
fn parse_to_native_defaults_to_surf_simple() {
let doc = parse_to_native("# Hello\n".into()).expect("parse");
assert_eq!(doc.schema_version, NATIVE_DOC_SCHEMA_VERSION);
assert_eq!(doc.theme.pack_id, "surf");
assert_eq!(doc.theme.accent, crate::resolve::DEFAULT_ACCENT);
assert_eq!(doc.theme.radius_card, 16.0);
assert_eq!(doc.theme.radius_btn, 10.0);
assert!(!doc.blocks.is_empty());
}
#[test]
fn parse_to_native_styled_overrides_win() {
let source = "::site\nname: Demo\naccent: #10b981\n::\n# Hi\n";
let doc = parse_to_native_styled(source.into(), Some("comic".into()), None, None)
.expect("parse");
assert_eq!(doc.theme.pack_id, "comic");
assert_eq!(doc.theme.border_w, 3.0);
assert_eq!(doc.theme.radius_card, 4.0);
assert_eq!(doc.theme.accent, "#10b981");
assert!(crate::resolve::contrast_ratio(&doc.theme.accent_ink_light, "#eef1f7") >= 4.5);
}
#[test]
fn parse_to_native_unknown_pack_falls_back() {
let doc = parse_to_native_styled("# Hi\n".into(), Some("old-school".into()), None, None)
.expect("parse");
assert_eq!(doc.theme.pack_id, "surf");
}
#[test]
fn section_preserves_children() {
let blocks = parse_surfdoc(
"::section[headline=\"S\"]\nInner text\n::\n".into(),
)
.expect("parse should succeed");
assert!(blocks.iter().any(
|b| matches!(b, NativeBlock::SectionContainer { children, .. } if !children.is_empty())
));
}
}