use std::collections::HashMap;
use wasm_bindgen::prelude::*;
use ox_content_allocator::Allocator;
use ox_content_ast::{Document, Heading, Node};
use ox_content_parser::{Parser, ParserOptions};
use ox_content_renderer::HtmlRenderer;
#[derive(serde::Serialize)]
pub struct TocEntry {
pub depth: u8,
pub text: String,
pub slug: String,
}
#[derive(serde::Serialize)]
pub struct TransformResult {
pub html: String,
pub frontmatter: HashMap<String, serde_json::Value>,
pub toc: Vec<TocEntry>,
pub errors: Vec<String>,
}
#[wasm_bindgen]
#[derive(Default)]
pub struct WasmParserOptions {
gfm: bool,
footnotes: bool,
task_lists: bool,
tables: bool,
strikethrough: bool,
autolinks: bool,
toc_max_depth: u8,
}
#[wasm_bindgen]
impl WasmParserOptions {
#[wasm_bindgen(constructor)]
pub fn new() -> Self {
Self {
gfm: false,
footnotes: false,
task_lists: false,
tables: false,
strikethrough: false,
autolinks: false,
toc_max_depth: 3,
}
}
#[wasm_bindgen(setter)]
pub fn set_gfm(&mut self, value: bool) {
self.gfm = value;
}
#[wasm_bindgen(setter)]
pub fn set_footnotes(&mut self, value: bool) {
self.footnotes = value;
}
#[wasm_bindgen(setter = taskLists)]
pub fn set_task_lists(&mut self, value: bool) {
self.task_lists = value;
}
#[wasm_bindgen(setter)]
pub fn set_tables(&mut self, value: bool) {
self.tables = value;
}
#[wasm_bindgen(setter)]
pub fn set_strikethrough(&mut self, value: bool) {
self.strikethrough = value;
}
#[wasm_bindgen(setter)]
pub fn set_autolinks(&mut self, value: bool) {
self.autolinks = value;
}
#[wasm_bindgen(setter = tocMaxDepth)]
pub fn set_toc_max_depth(&mut self, value: u8) {
self.toc_max_depth = value;
}
}
impl From<&WasmParserOptions> for ParserOptions {
fn from(opts: &WasmParserOptions) -> Self {
let mut options = if opts.gfm { ParserOptions::gfm() } else { ParserOptions::default() };
options.footnotes = opts.footnotes;
options.task_lists = opts.task_lists;
options.tables = opts.tables;
options.strikethrough = opts.strikethrough;
options.autolinks = opts.autolinks;
options
}
}
#[wasm_bindgen(js_name = parseAndRender)]
pub fn parse_and_render(source: &str, options: Option<WasmParserOptions>) -> JsValue {
let opts = options.unwrap_or_default();
let allocator = Allocator::new();
let parser_options = ParserOptions::from(&opts);
let parser = Parser::with_options(&allocator, source, parser_options);
let result = parser.parse();
match result {
Ok(doc) => {
let mut renderer = HtmlRenderer::new();
let html = renderer.render(&doc);
serde_wasm_bindgen::to_value(&serde_json::json!({
"html": html,
"errors": Vec::<String>::new()
}))
.unwrap_or(JsValue::NULL)
}
Err(e) => serde_wasm_bindgen::to_value(&serde_json::json!({
"html": "",
"errors": [e.to_string()]
}))
.unwrap_or(JsValue::NULL),
}
}
#[wasm_bindgen]
pub fn transform(source: &str, options: Option<WasmParserOptions>) -> JsValue {
let opts = options.unwrap_or_default();
let toc_max_depth = opts.toc_max_depth;
let (content, frontmatter) = parse_frontmatter(source);
let allocator = Allocator::new();
let parser_options = ParserOptions::from(&opts);
let parser = Parser::with_options(&allocator, &content, parser_options);
let result = parser.parse();
match result {
Ok(doc) => {
let toc = extract_toc(&doc, toc_max_depth);
let mut renderer = HtmlRenderer::new();
let html = renderer.render(&doc);
let transform_result = TransformResult { html, frontmatter, toc, errors: vec![] };
serde_wasm_bindgen::to_value(&transform_result).unwrap_or(JsValue::NULL)
}
Err(e) => {
let transform_result = TransformResult {
html: String::new(),
frontmatter: HashMap::new(),
toc: vec![],
errors: vec![e.to_string()],
};
serde_wasm_bindgen::to_value(&transform_result).unwrap_or(JsValue::NULL)
}
}
}
#[wasm_bindgen]
pub fn version() -> String {
env!("CARGO_PKG_VERSION").to_string()
}
fn parse_frontmatter(source: &str) -> (String, HashMap<String, serde_json::Value>) {
let mut frontmatter = HashMap::new();
if !source.starts_with("---") {
return (source.to_string(), frontmatter);
}
let rest = &source[3..];
let Some(end_pos) = rest.find("\n---") else {
return (source.to_string(), frontmatter);
};
let frontmatter_str = rest[..end_pos].trim_start_matches('\n');
let content = rest[end_pos + 4..].trim_start_matches('\n');
for line in frontmatter_str.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(colon_pos) = line.find(':') {
let key = line[..colon_pos].trim().to_string();
let value_str = line[colon_pos + 1..].trim();
let value = if value_str == "true" {
serde_json::Value::Bool(true)
} else if value_str == "false" {
serde_json::Value::Bool(false)
} else if let Ok(n) = value_str.parse::<i64>() {
serde_json::Value::Number(n.into())
} else if let Ok(n) = value_str.parse::<f64>() {
serde_json::Number::from_f64(n).map_or_else(
|| serde_json::Value::String(value_str.to_string()),
serde_json::Value::Number,
)
} else {
let s = value_str.trim_matches('"').trim_matches('\'');
serde_json::Value::String(s.to_string())
};
frontmatter.insert(key, value);
}
}
(content.to_string(), frontmatter)
}
fn extract_toc(doc: &Document, max_depth: u8) -> Vec<TocEntry> {
let mut entries = Vec::new();
for node in &doc.children {
if let Node::Heading(heading) = node {
if heading.depth <= max_depth {
let text = extract_heading_text(heading);
let slug = slugify(&text);
entries.push(TocEntry { depth: heading.depth, text, slug });
}
}
}
entries
}
fn extract_heading_text(heading: &Heading) -> String {
let mut text = String::new();
for child in &heading.children {
collect_text(child, &mut text);
}
text
}
fn collect_text(node: &Node, text: &mut String) {
match node {
Node::Text(t) => text.push_str(t.value),
Node::Emphasis(e) => {
for child in &e.children {
collect_text(child, text);
}
}
Node::Strong(s) => {
for child in &s.children {
collect_text(child, text);
}
}
Node::InlineCode(c) => text.push_str(c.value),
Node::Delete(d) => {
for child in &d.children {
collect_text(child, text);
}
}
Node::Link(l) => {
for child in &l.children {
collect_text(child, text);
}
}
_ => {}
}
}
fn slugify(text: &str) -> String {
text.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' { c } else { ' ' })
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join("-")
}