//! 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!("\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("
");
}
_ => {
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("