//! 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!("\n"); render_children(node, out); out.push_str("\n"); } "bulletList" | "bullet_list" => { 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("