//! TipTap/ProseMirror JSON → HTML-konvertering. //! //! Konverterer `metadata.document` (TipTap JSON) til HTML-streng. //! Identisk med maskinrommet/src/tiptap.rs — delt logikk. //! Ref: oppgave 21.16 (synops-common) vil samle dette i felles crate. 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("
    "); } _ => { 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); } _ => { 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; }; 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(""); } _ => {} } } out.push_str(&escape_html(text)); 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_mark() { let doc = json!({ "type": "doc", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "bold", "marks": [{ "type": "bold" }] }] }] }); assert_eq!(document_to_html(&doc), "

    bold

    \n"); } #[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("