//! TipTap/ProseMirror JSON → HTML-konvertering. //! //! Konverterer `metadata.document` (TipTap JSON) til HTML-streng. //! Støtter vanlige nodetyper: paragraph, heading, blockquote, bullet_list, //! ordered_list, list_item, code_block, horizontal_rule, image, hard_break. //! Støtter marks: bold, italic, strike, code, link, underline. use serde_json::Value; /// Konverter et TipTap/ProseMirror-dokument (JSON) til HTML. /// Returnerer tom streng hvis dokumentet er ugyldig. pub fn document_to_html(doc: &Value) -> String { let Some(content) = doc.get("content").and_then(|c| c.as_array()) else { return String::new(); }; let mut html = String::new(); for node in content { render_node(node, &mut html); } html } fn render_node(node: &Value, out: &mut String) { let node_type = node.get("type").and_then(|t| t.as_str()).unwrap_or(""); match node_type { "paragraph" => { out.push_str("

"); render_inline_content(node, out); out.push_str("

\n"); } "heading" => { let level = node .get("attrs") .and_then(|a| a.get("level")) .and_then(|l| l.as_u64()) .unwrap_or(2) .min(6); out.push_str(&format!("")); render_inline_content(node, out); out.push_str(&format!("\n")); } "blockquote" => { out.push_str("
\n"); render_children(node, out); out.push_str("
\n"); } "bulletList" | "bullet_list" => { out.push_str("\n"); } "orderedList" | "ordered_list" => { let start = node .get("attrs") .and_then(|a| a.get("start")) .and_then(|s| s.as_u64()) .unwrap_or(1); if start == 1 { out.push_str("
    \n"); } else { out.push_str(&format!("
      \n")); } render_children(node, out); out.push_str("
    \n"); } "listItem" | "list_item" => { out.push_str("
  1. "); render_children(node, out); out.push_str("
  2. \n"); } "codeBlock" | "code_block" => { let lang = node .get("attrs") .and_then(|a| a.get("language")) .and_then(|l| l.as_str()) .unwrap_or(""); if lang.is_empty() { out.push_str("
    ");
                } else {
                    out.push_str(&format!("
    ", escape_html(lang)));
                }
                render_inline_content(node, out);
                out.push_str("
    \n"); } "horizontalRule" | "horizontal_rule" => { out.push_str("
    \n"); } "image" => { let attrs = node.get("attrs"); let src = attrs .and_then(|a| a.get("src")) .and_then(|s| s.as_str()) .unwrap_or(""); let alt = attrs .and_then(|a| a.get("alt")) .and_then(|s| s.as_str()) .unwrap_or(""); let title = attrs .and_then(|a| a.get("title")) .and_then(|s| s.as_str()); out.push_str(&format!( "\"{}\"",\n"); } "hardBreak" | "hard_break" => { out.push_str("
    "); } _ => { // Ukjent nodetype — render barn rekursivt render_children(node, out); } } } fn render_children(node: &Value, out: &mut String) { if let Some(content) = node.get("content").and_then(|c| c.as_array()) { for child in content { render_node(child, out); } } } fn render_inline_content(node: &Value, out: &mut String) { let Some(content) = node.get("content").and_then(|c| c.as_array()) else { return; }; for child in content { let child_type = child.get("type").and_then(|t| t.as_str()).unwrap_or(""); match child_type { "text" => { let text = child.get("text").and_then(|t| t.as_str()).unwrap_or(""); let marks = child.get("marks").and_then(|m| m.as_array()); render_text_with_marks(text, marks, out); } "hardBreak" | "hard_break" => { out.push_str("
    "); } "image" => { render_node(child, out); } _ => { // Ukjent inline-type — render rekursivt render_node(child, out); } } } } fn render_text_with_marks(text: &str, marks: Option<&Vec>, out: &mut String) { let Some(marks) = marks else { out.push_str(&escape_html(text)); return; }; // Åpne marks let mut close_tags: Vec<&str> = Vec::new(); for mark in marks { let mark_type = mark.get("type").and_then(|t| t.as_str()).unwrap_or(""); match mark_type { "bold" | "strong" => { out.push_str(""); close_tags.push(""); } "italic" | "em" => { out.push_str(""); close_tags.push(""); } "strike" | "strikethrough" => { out.push_str(""); close_tags.push(""); } "code" => { out.push_str(""); close_tags.push(""); } "underline" => { out.push_str(""); close_tags.push(""); } "link" => { let href = mark .get("attrs") .and_then(|a| a.get("href")) .and_then(|h| h.as_str()) .unwrap_or("#"); let target = mark .get("attrs") .and_then(|a| a.get("target")) .and_then(|t| t.as_str()); out.push_str(&format!("")); close_tags.push(""); } _ => {} // Ukjent mark — ignorer } } out.push_str(&escape_html(text)); // Lukk marks i motsatt rekkefølge for tag in close_tags.iter().rev() { out.push_str(tag); } } fn escape_html(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") } fn escape_attr(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn simple_paragraph() { let doc = json!({ "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Hello world" }] }] }); assert_eq!(document_to_html(&doc), "

    Hello world

    \n"); } #[test] fn heading_levels() { let doc = json!({ "type": "doc", "content": [{ "type": "heading", "attrs": { "level": 2 }, "content": [{ "type": "text", "text": "Title" }] }] }); assert_eq!(document_to_html(&doc), "

    Title

    \n"); } #[test] fn bold_and_italic_marks() { let doc = json!({ "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "bold text", "marks": [{ "type": "bold" }] }] }] }); assert_eq!(document_to_html(&doc), "

    bold text

    \n"); } #[test] fn link_mark() { let doc = json!({ "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "click here", "marks": [{ "type": "link", "attrs": { "href": "https://example.com" } }] }] }] }); let html = document_to_html(&doc); assert!(html.contains("href=\"https://example.com\"")); assert!(html.contains("rel=\"noopener noreferrer\"")); assert!(html.contains("click here")); } #[test] fn blockquote() { let doc = json!({ "type": "doc", "content": [{ "type": "blockquote", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "quoted" }] }] }] }); let html = document_to_html(&doc); assert!(html.contains("
    ")); assert!(html.contains("

    quoted

    ")); } #[test] fn bullet_list() { let doc = json!({ "type": "doc", "content": [{ "type": "bulletList", "content": [ { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "item 1" }] }] }, { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "item 2" }] }] } ] }] }); let html = document_to_html(&doc); assert!(html.contains("
      ")); assert!(html.contains("
    • ")); assert!(html.contains("item 1")); assert!(html.contains("item 2")); } #[test] fn html_escaping() { let doc = json!({ "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "" }] }] }); let html = document_to_html(&doc); assert!(!html.contains("