use std::ffi::{CStr, CString, c_char};
use std::os::raw::c_uchar;
use pathland_core_transport::decode_frame;
use crate::{DocumentMeta, css, HtmlRenderer};
fn render_bytes_with_css(
batch: *const c_uchar,
len: u32,
root: u32,
fragment: bool,
debug: bool,
css_base: Option<&str>,
) -> *const c_char {
if batch.is_null() {
return std::ptr::null();
}
let bytes = unsafe { std::slice::from_raw_parts(batch, len as usize) };
let (opcodes, strings) = match decode_frame(bytes) {
Ok(decoded) => decoded,
Err(_) => return std::ptr::null(),
};
let renderer = HtmlRenderer::new().with_debug_comments(debug);
let html = if fragment {
renderer.render_fragment(&opcodes, &strings, root)
} else if let Some(base) = css_base {
renderer.render_document_linked(&opcodes, &strings, root, base)
} else {
renderer.render_document(&opcodes, &strings, root)
};
match CString::new(html) {
Ok(cs) => cs.into_raw(),
Err(_) => std::ptr::null(),
}
}
#[repr(C)]
pub struct PathlandHtmlMeta {
pub title: *const c_char,
pub description: *const c_char,
pub canonical_url: *const c_char,
pub site_name: *const c_char,
pub image_url: *const c_char,
pub og_type: *const c_char,
pub twitter_card: *const c_char,
pub twitter_site: *const c_char,
pub open_graph: u8,
pub twitter: u8,
pub structured_data: u8,
}
unsafe fn meta_string(value: *const c_char) -> Option<String> {
if value.is_null() {
None
} else {
unsafe { CStr::from_ptr(value) }
.to_str()
.ok()
.map(str::to_string)
}
}
unsafe fn document_meta(meta: *const PathlandHtmlMeta) -> DocumentMeta {
if meta.is_null() {
return DocumentMeta::none();
}
let meta = unsafe { &*meta };
DocumentMeta {
title: unsafe { meta_string(meta.title) },
description: unsafe { meta_string(meta.description) },
canonical_url: unsafe { meta_string(meta.canonical_url) },
site_name: unsafe { meta_string(meta.site_name) },
image_url: unsafe { meta_string(meta.image_url) },
og_type: unsafe { meta_string(meta.og_type) },
twitter_card: unsafe { meta_string(meta.twitter_card) },
twitter_site: unsafe { meta_string(meta.twitter_site) },
open_graph: meta.open_graph != 0,
twitter: meta.twitter != 0,
structured_data: meta.structured_data != 0,
}
}
fn render_bytes(
batch: *const c_uchar,
len: u32,
root: u32,
fragment: bool,
debug: bool,
) -> *const c_char {
render_bytes_with_css(batch, len, root, fragment, debug, None)
}
#[no_mangle]
pub unsafe extern "C" fn pathland_html_render(batch: *const c_uchar, len: u32, root: u32) -> *const c_char {
render_bytes(batch, len, root, false, false)
}
#[no_mangle]
pub unsafe extern "C" fn pathland_html_render_fragment(
batch: *const c_uchar,
len: u32,
root: u32,
) -> *const c_char {
render_bytes(batch, len, root, true, false)
}
#[no_mangle]
pub unsafe extern "C" fn pathland_html_render_debug(
batch: *const c_uchar,
len: u32,
root: u32,
debug: u8,
) -> *const c_char {
render_bytes(batch, len, root, false, debug != 0)
}
#[no_mangle]
pub unsafe extern "C" fn pathland_html_render_fragment_debug(
batch: *const c_uchar,
len: u32,
root: u32,
debug: u8,
) -> *const c_char {
render_bytes(batch, len, root, true, debug != 0)
}
#[no_mangle]
pub unsafe extern "C" fn pathland_html_free(ptr: *const c_char) {
if !ptr.is_null() {
drop(CString::from_raw(ptr as *mut c_char));
}
}
#[no_mangle]
pub unsafe extern "C" fn pathland_html_render_linked(
batch: *const c_uchar,
len: u32,
root: u32,
css_base: *const c_char,
debug: u8,
) -> *const c_char {
let css_base = if css_base.is_null() {
None
} else {
match unsafe { CStr::from_ptr(css_base) }.to_str() {
Ok(base) => Some(base),
Err(_) => return std::ptr::null(),
}
};
render_bytes_with_css(batch, len, root, false, debug != 0, css_base)
}
#[no_mangle]
pub unsafe extern "C" fn pathland_html_render_with_meta(
batch: *const c_uchar,
len: u32,
root: u32,
css_base: *const c_char,
meta: *const PathlandHtmlMeta,
debug: u8,
) -> *const c_char {
if batch.is_null() {
return std::ptr::null();
}
let bytes = unsafe { std::slice::from_raw_parts(batch, len as usize) };
let (opcodes, strings) = match decode_frame(bytes) {
Ok(decoded) => decoded,
Err(_) => return std::ptr::null(),
};
let renderer = HtmlRenderer::new().with_debug_comments(debug != 0);
let meta = unsafe { document_meta(meta) };
let html = if css_base.is_null() {
renderer.render_document_with_meta(&opcodes, &strings, root, &meta)
} else {
let base = match unsafe { CStr::from_ptr(css_base) }.to_str() {
Ok(base) => base,
Err(_) => return std::ptr::null(),
};
renderer.render_document_linked_with_meta(&opcodes, &strings, root, base, &meta)
};
match CString::new(html) {
Ok(cs) => cs.into_raw(),
Err(_) => std::ptr::null(),
}
}
#[no_mangle]
pub extern "C" fn pathland_html_base_css() -> *const c_char {
match CString::new(css::builtin_css()) {
Ok(cs) => cs.into_raw(),
Err(_) => std::ptr::null(),
}
}
#[no_mangle]
pub extern "C" fn pathland_html_base_css_name() -> *const c_char {
match CString::new(css::builtin_css_name()) {
Ok(cs) => cs.into_raw(),
Err(_) => std::ptr::null(),
}
}
#[no_mangle]
pub unsafe extern "C" fn pathland_html_icon_svg(name: *const c_char) -> *const c_char {
if name.is_null() {
return std::ptr::null();
}
let name = unsafe { CStr::from_ptr(name) }.to_string_lossy().into_owned();
let open = crate::icons::WEB_SVG_OPEN;
let inner = crate::icons::web_inner(&name).unwrap_or_else(crate::icons::web_fallback_svg);
let svg = format!("{open}{inner}</svg>");
match CString::new(svg) {
Ok(cs) => cs.into_raw(),
Err(_) => std::ptr::null(),
}
}
#[cfg(test)]
mod tests {
use std::ffi::CStr;
use pathland_core::{Opcode, category, component_type, property_id, parameter, tree};
use pathland_core_transport::encode_frame;
use super::*;
#[test]
fn capi_renders_a_full_document_and_frees() {
let mut opcodes = Vec::new();
let mut strings = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::TEXT as u32, 0));
strings.extend_from_slice(&(2u32).to_le_bytes());
strings.extend_from_slice(b"Hi");
opcodes.push(Opcode::new(category::PARAMETER, parameter::SET_TEXT, 0, 1, 0, 0));
let bytes = encode_frame(&opcodes, &strings);
let ptr = unsafe { pathland_html_render(bytes.as_ptr(), bytes.len() as u32, 1) };
assert!(!ptr.is_null());
let html = unsafe { CStr::from_ptr(ptr) }.to_string_lossy().into_owned();
assert!(html.contains("<!DOCTYPE html>"));
assert!(html.contains("<span data-pathland-id=\"1\">Hi</span>"));
unsafe { pathland_html_free(ptr) };
let frag = unsafe { pathland_html_render_fragment(bytes.as_ptr(), bytes.len() as u32, 1) };
let html = unsafe { CStr::from_ptr(frag) }.to_string_lossy().into_owned();
assert!(!html.contains("<!DOCTYPE html>"));
assert!(html.contains("data-pathland-id=\"1\""));
unsafe { pathland_html_free(frag) };
}
#[test]
fn capi_exposes_the_base_css_and_renders_a_linked_document() {
let css_ptr = pathland_html_base_css();
assert!(!css_ptr.is_null());
let css = unsafe { CStr::from_ptr(css_ptr) }.to_string_lossy().into_owned();
assert!(css.contains("--pl-color-primary"), "base design bytes");
unsafe { pathland_html_free(css_ptr) };
let name_ptr = pathland_html_base_css_name();
assert!(!name_ptr.is_null());
let name = unsafe { CStr::from_ptr(name_ptr) }.to_string_lossy().into_owned();
assert!(name.starts_with("base-") && name.ends_with(".css"), "{name}");
unsafe { pathland_html_free(name_ptr) };
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(
category::TREE,
tree::CREATE_NODE,
0,
1,
component_type::TEXT as u32,
0,
));
let bytes = encode_frame(&opcodes, &[]);
let base = std::ffi::CString::new("/_pathland").unwrap();
let ptr = unsafe {
pathland_html_render_linked(bytes.as_ptr(), bytes.len() as u32, 1, base.as_ptr(), 0)
};
assert!(!ptr.is_null());
let html = unsafe { CStr::from_ptr(ptr) }.to_string_lossy().into_owned();
assert!(
html.contains(&format!(
"<link rel=\"stylesheet\" href=\"/_pathland/{name}\">"
)),
"{html}"
);
assert!(!html.contains("<style>"), "linked document omits inline base CSS: {html}");
unsafe { pathland_html_free(ptr) };
}
#[test]
fn capi_renders_slot_route_and_transition_attrs() {
let mut opcodes = Vec::new();
let mut strings = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
strings.extend_from_slice(&(9u32).to_le_bytes());
strings.extend_from_slice(b"/users/42");
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
(0x05u32 << 16) | property_id::ROUTE as u32,
0,
));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
(0x04u32 << 16) | property_id::TRANSITION as u32,
3.0f32.to_bits(),
));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
(0x04u32 << 16) | property_id::NAV_CHROME as u32,
1.0f32.to_bits(),
));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
(0x02u32 << 16) | property_id::NAV_DEPTH as u32,
2,
));
let bytes = encode_frame(&opcodes, &strings);
let frag = unsafe { pathland_html_render_fragment(bytes.as_ptr(), bytes.len() as u32, 1) };
let html = unsafe { CStr::from_ptr(frag) }.to_string_lossy().into_owned();
assert!(html.contains("data-pathland-route=\"/users/42\""), "{html}");
assert!(html.contains("data-pathland-transition=\"slide\""), "{html}");
assert!(html.contains("data-pathland-nav-chrome=\"custom\""), "{html}");
assert!(html.contains("data-pathland-depth=\"2\""), "{html}");
unsafe { pathland_html_free(frag) };
}
#[test]
fn capi_debug_render_emits_node_comments() {
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(category::TREE, tree::CREATE_NODE, 0, 1, component_type::VSTACK as u32, 0));
opcodes.push(Opcode::new(
category::PARAMETER,
parameter::SET_PROPERTY,
0,
1,
(0x04u32 << 16) | property_id::SPACING as u32,
4.0f32.to_bits(),
));
let bytes = encode_frame(&opcodes, &[]);
let ptr = unsafe { pathland_html_render_debug(bytes.as_ptr(), bytes.len() as u32, 1, 1) };
let html = unsafe { CStr::from_ptr(ptr) }.to_string_lossy().into_owned();
assert!(html.contains("<!-- #1 VStack: spacing=4 -->"), "{html}");
unsafe { pathland_html_free(ptr) };
let ptr = unsafe { pathland_html_render_debug(bytes.as_ptr(), bytes.len() as u32, 1, 0) };
let html = unsafe { CStr::from_ptr(ptr) }.to_string_lossy().into_owned();
assert!(!html.contains("<!--"), "{html}");
unsafe { pathland_html_free(ptr) };
}
#[test]
fn capi_rejects_a_truncated_batch() {
let ptr = unsafe { pathland_html_render([0u8, 1, 2].as_ptr(), 3, 1) };
assert!(ptr.is_null());
}
#[test]
fn capi_serves_icon_svgs_from_the_shared_renderer() {
let cname = std::ffi::CString::new(pathland_core::icon::HOME).unwrap();
let ptr = unsafe { pathland_html_icon_svg(cname.as_ptr()) };
assert!(!ptr.is_null());
let svg = unsafe { CStr::from_ptr(ptr) }.to_string_lossy().into_owned();
assert!(svg.starts_with("<svg class=\"pathland-icon\""), "{svg}");
assert!(svg.ends_with("</svg>"), "{svg}");
assert!(svg.len() > 40, "a real glyph path is inlined: {svg}");
unsafe { pathland_html_free(ptr) };
let unknown = std::ffi::CString::new("definitely-not-an-icon").unwrap();
let ptr = unsafe { pathland_html_icon_svg(unknown.as_ptr()) };
assert!(!ptr.is_null());
let svg = unsafe { CStr::from_ptr(ptr) }.to_string_lossy().into_owned();
assert!(svg.contains("<circle"), "{svg}");
unsafe { pathland_html_free(ptr) };
assert!(unsafe { pathland_html_icon_svg(std::ptr::null()) }.is_null());
}
#[test]
fn capi_renders_document_meta() {
let mut opcodes = Vec::new();
opcodes.push(Opcode::new(
category::TREE,
tree::CREATE_NODE,
0,
1,
component_type::TEXT as u32,
0,
));
let bytes = encode_frame(&opcodes, &[]);
let canonical = std::ffi::CString::new("https://example.com/library").unwrap();
let title = std::ffi::CString::new("Fallback title").unwrap();
let site = std::ffi::CString::new("Pathland Music").unwrap();
let meta = PathlandHtmlMeta {
title: title.as_ptr(),
description: std::ptr::null(),
canonical_url: canonical.as_ptr(),
site_name: site.as_ptr(),
image_url: std::ptr::null(),
og_type: std::ptr::null(),
twitter_card: std::ptr::null(),
twitter_site: std::ptr::null(),
open_graph: 1,
twitter: 1,
structured_data: 1,
};
let ptr = unsafe {
pathland_html_render_with_meta(
bytes.as_ptr(),
bytes.len() as u32,
1,
std::ptr::null(),
&meta,
0,
)
};
assert!(!ptr.is_null());
let html = unsafe { CStr::from_ptr(ptr) }
.to_string_lossy()
.into_owned();
assert!(html.contains("<title>Fallback title</title>"), "{html}");
assert!(
html.contains("<link rel=\"canonical\" href=\"https://example.com/library\">"),
"{html}"
);
assert!(html.contains("\"@type\":\"WebPage\""), "{html}");
unsafe { pathland_html_free(ptr) };
let plain = unsafe {
pathland_html_render_with_meta(
bytes.as_ptr(),
bytes.len() as u32,
1,
std::ptr::null(),
std::ptr::null(),
0,
)
};
let html = unsafe { CStr::from_ptr(plain) }
.to_string_lossy()
.into_owned();
assert!(!html.contains("rel=\"canonical\""), "{html}");
assert!(!html.contains("application/ld+json"), "{html}");
unsafe { pathland_html_free(plain) };
}
}