1use std::collections::BTreeMap;
22use std::fmt::Write as _;
23
24use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
25
26use crate::engine::Engine;
27use crate::entity::Entity;
28use crate::workspace::MountCapability;
29
30fn esc(s: &str) -> String {
32 s.replace('&', "&")
33 .replace('<', "<")
34 .replace('>', ">")
35 .replace('"', """)
36}
37
38fn resolve_wiki_links(body: &str, mem: &str, exported_ids: &[String]) -> String {
44 let mut out = String::with_capacity(body.len());
45 let mut rest = body;
46 while let Some(start) = rest.find("[[") {
47 out.push_str(&rest[..start]);
48 let after = &rest[start + 2..];
49 match after.find("]]") {
50 None => {
51 out.push_str(&rest[start..]);
52 rest = "";
53 break;
54 }
55 Some(end) => {
56 let target = &after[..end];
57 let full_id = if target.contains("--") {
58 target.to_string()
59 } else {
60 format!("{mem}--{target}")
61 };
62 if exported_ids.iter().any(|id| id == &full_id) {
63 let _ = write!(out, "[{target}](#{full_id})");
66 } else if full_id.starts_with(&format!("{mem}--")) {
67 let _ = write!(out, "{target} *(unresolved)*");
69 } else {
70 let _ = write!(out, "{full_id} *(other mem)*");
72 }
73 rest = &after[end + 2..];
74 }
75 }
76 }
77 out.push_str(rest);
78 out
79}
80
81fn percent_decode(s: &str) -> String {
86 let bytes = s.as_bytes();
87 let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
88 let mut i = 0;
89 while i < bytes.len() {
90 if bytes[i] == b'%'
91 && i + 2 < bytes.len()
92 && let (Some(h), Some(l)) = (
93 (bytes[i + 1] as char).to_digit(16),
94 (bytes[i + 2] as char).to_digit(16),
95 )
96 {
97 out.push((h * 16 + l) as u8);
98 i += 3;
99 } else {
100 out.push(bytes[i]);
101 i += 1;
102 }
103 }
104 String::from_utf8_lossy(&out).into_owned()
105}
106
107fn link_dest_allowed(dest: &str, exported_ids: &[String]) -> bool {
115 if let Some(frag) = dest.strip_prefix('#') {
116 let decoded = percent_decode(frag);
117 return exported_ids.iter().any(|id| id == &decoded);
118 }
119 let lower = dest.trim().to_ascii_lowercase();
120 lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("mailto:")
121}
122
123fn markdown_to_safe_html(md: &str, exported_ids: &[String]) -> String {
130 let parser = Parser::new_ext(md, Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH);
131 let mut events: Vec<Event> = Vec::new();
132 let mut skipping_image: Option<(String, String)> = None; let mut suppressed_link: Option<String> = None;
136 for ev in parser {
137 if let Some((_url, alt)) = skipping_image.as_mut() {
138 match ev {
139 Event::End(TagEnd::Image) => {
140 let (url, alt) = skipping_image.take().unwrap();
141 let label = if alt.trim().is_empty() {
142 format!("image: {url}")
143 } else {
144 format!("image: {alt} ({url})")
145 };
146 if link_dest_allowed(&url, exported_ids) {
147 events.push(Event::Start(Tag::Link {
148 link_type: pulldown_cmark::LinkType::Inline,
149 dest_url: url.clone().into(),
150 title: "".into(),
151 id: "".into(),
152 }));
153 events.push(Event::Text(label.into()));
154 events.push(Event::End(TagEnd::Link));
155 } else {
156 events.push(Event::Text(format!("[{label}]").into()));
158 }
159 }
160 Event::Text(t) => alt.push_str(&t),
161 _ => {}
162 }
163 continue;
164 }
165 match ev {
166 Event::Html(s) | Event::InlineHtml(s) => {
168 events.push(Event::Text(s));
169 }
170 Event::Start(Tag::Image { dest_url, .. }) => {
172 skipping_image = Some((dest_url.to_string(), String::new()));
173 }
174 Event::Start(Tag::Link { dest_url, .. }) if suppressed_link.is_none() => {
175 let dest = dest_url.to_string();
176 if link_dest_allowed(&dest, exported_ids) {
177 events.push(Event::Start(Tag::Link {
178 link_type: pulldown_cmark::LinkType::Inline,
179 dest_url: dest.into(),
180 title: "".into(),
181 id: "".into(),
182 }));
183 } else {
184 suppressed_link = Some(dest);
185 }
186 }
187 Event::End(TagEnd::Link) if suppressed_link.is_some() => {
188 let dest = suppressed_link.take().unwrap();
189 events.push(Event::Text(format!(" ({dest} — link removed)").into()));
190 }
191 other => events.push(other),
192 }
193 }
194 let mut html = String::new();
195 pulldown_cmark::html::push_html(&mut html, events.into_iter());
196 html
197}
198
199impl Engine {
200 pub fn render_html_export(
204 &self,
205 mem: &str,
206 export_date: &str,
207 ) -> Result<String, crate::engine::EngineError> {
208 let mounted = self
209 .mounts
210 .iter()
211 .find(|m| m.mount.mem == mem)
212 .ok_or_else(|| crate::engine::EngineError::UnknownMem(mem.to_string()))?;
213 let third_party = mounted.mount.capability == MountCapability::ReadOnly;
214 let config = self.mem_config_for(mem);
215 let schema_ref = self
216 .schemas
217 .get(mem)
218 .map(|s| {
219 let (n, v) = s.id();
220 format!("{n}@{v}")
221 })
222 .unwrap_or_else(|| "(unresolved)".to_string());
223
224 let mut entities: Vec<&Entity> = self
227 .store
228 .all_entities()
229 .filter(|e| e.mem == mem && !e.stub)
230 .collect();
231 entities.sort_by(|a, b| {
232 a.entity_type
233 .cmp(&b.entity_type)
234 .then_with(|| a.id.as_ref().cmp(b.id.as_ref()))
235 });
236 let mut stubs: Vec<&Entity> = self
237 .store
238 .all_entities()
239 .filter(|e| e.mem == mem && e.stub)
240 .collect();
241 stubs.sort_by(|a, b| a.id.as_ref().cmp(b.id.as_ref()));
242 let exported_ids: Vec<String> = entities.iter().map(|e| e.id.to_string()).collect();
243
244 let mut out = String::new();
245 out.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n");
246 out.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
247 let doc_title = config
248 .and_then(|c| c.title.clone())
249 .unwrap_or_else(|| mem.to_string());
250 let _ = writeln!(out, "<title>{}</title>", esc(&doc_title));
251 out.push_str(
252 "<style>\n\
253 body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;\
254 max-width:52rem;margin:0 auto;padding:2rem 1rem;line-height:1.55;color:#1a1a1a;}\n\
255 h1{border-bottom:2px solid #ddd;padding-bottom:.3rem;}\n\
256 section.entity{border-top:1px solid #ddd;margin-top:2rem;padding-top:1rem;}\n\
257 table.meta{border-collapse:collapse;font-size:.9rem;margin:.5rem 0;}\n\
258 table.meta td{border:1px solid #ddd;padding:.15rem .5rem;}\n\
259 table.meta td:first-child{color:#555;}\n\
260 nav ul{columns:2;list-style:none;padding-left:0;}\n\
261 nav li{margin:.15rem 0;}\n\
262 .identity{background:#f6f6f6;border:1px solid #ddd;padding:.75rem 1rem;\
263 border-radius:4px;font-size:.95rem;}\n\
264 .badge{display:inline-block;background:#eee;border-radius:3px;\
265 padding:0 .4rem;font-size:.8rem;color:#555;}\n\
266 .stub{color:#888;font-style:italic;}\n\
267 .reltable{font-size:.9rem;}\n\
268 @media print{nav ul{columns:1;}}\n\
269 </style>\n</head>\n<body>\n",
270 );
271
272 let _ = write!(
274 out,
275 "<h1>{}</h1>\n<div class=\"identity\">\n",
276 esc(&doc_title)
277 );
278 let _ = writeln!(out, "<div><strong>Mem:</strong> {}</div>", esc(mem));
279 if let Some(desc) = config.and_then(|c| c.description.as_deref())
280 && !desc.is_empty()
281 {
282 let _ = writeln!(
283 out,
284 "<div><strong>Description:</strong> {}</div>",
285 esc(desc)
286 );
287 }
288 if let Some(subject) = config.and_then(|c| c.subject.as_ref()) {
289 let _ = writeln!(
290 out,
291 "<div><strong>Subject:</strong> {}</div>",
292 esc(&subject.scope)
293 );
294 }
295 let _ = writeln!(
296 out,
297 "<div><strong>Schema:</strong> {}</div>",
298 esc(&schema_ref)
299 );
300 let trust = if third_party {
301 "third-party (read-only mount — someone else's published content, quoted here)"
302 } else {
303 "first-party (writable mem of this workspace)"
304 };
305 let _ = writeln!(out, "<div><strong>Origin:</strong> {trust}</div>");
306 let _ = write!(
307 out,
308 "<div><strong>Exported:</strong> {} · {} entities</div>\n</div>\n",
309 esc(export_date),
310 entities.len()
311 );
312
313 let mut by_type: BTreeMap<&str, Vec<&Entity>> = BTreeMap::new();
315 for e in &entities {
316 by_type.entry(e.entity_type.as_str()).or_default().push(e);
317 }
318 out.push_str("<nav>\n<h2>Index</h2>\n");
319 for (ty, list) in &by_type {
320 let _ = write!(out, "<h3>{} ({})</h3>\n<ul>\n", esc(ty), list.len());
321 for e in list {
322 let _ = writeln!(
323 out,
324 "<li><a href=\"#{}\">{}</a></li>",
325 esc(e.id.as_ref()),
326 esc(&e.title)
327 );
328 }
329 out.push_str("</ul>\n");
330 }
331 out.push_str("</nav>\n");
332
333 for e in &entities {
335 let _ = write!(
336 out,
337 "<section class=\"entity\" id=\"{}\">\n<h2>{}</h2>\n<span class=\"badge\">{}</span> <span class=\"badge\">{}</span>\n",
338 esc(e.id.as_ref()),
339 esc(&e.title),
340 esc(&e.entity_type),
341 esc(e.id.as_ref()),
342 );
343 if !e.metadata.is_empty() {
344 out.push_str("<table class=\"meta\">\n");
345 for (k, v) in &e.metadata {
346 let _ = writeln!(
347 out,
348 "<tr><td>{}</td><td>{}</td></tr>",
349 esc(k),
350 esc(&v.to_frontmatter_string())
351 );
352 }
353 out.push_str("</table>\n");
354 }
355 for (key, body) in &e.sections {
356 if body.trim().is_empty() {
357 continue;
358 }
359 let _ = writeln!(out, "<h3>{}</h3>", esc(key));
360 let resolved = resolve_wiki_links(body, mem, &exported_ids);
361 out.push_str(&markdown_to_safe_html(&resolved, &exported_ids));
362 }
363 if !e.relationships.is_empty() {
364 out.push_str("<h3>relationships</h3>\n<ul class=\"reltable\">\n");
365 let mut rels = e.relationships.clone();
366 rels.sort_by(|a, b| {
367 a.rel_type
368 .cmp(&b.rel_type)
369 .then_with(|| a.target.as_ref().cmp(b.target.as_ref()))
370 });
371 for r in &rels {
372 let target_id = r.target.to_string();
373 let in_doc = exported_ids.iter().any(|id| id == &target_id);
374 let is_stub_target = self.store.get(&r.target).map(|t| t.stub).unwrap_or(false);
375 if in_doc {
376 let _ = writeln!(
377 out,
378 "<li>{} → <a href=\"#{}\">{}</a></li>",
379 esc(&r.rel_type),
380 esc(&target_id),
381 esc(&target_id)
382 );
383 } else if is_stub_target {
384 let _ = writeln!(
385 out,
386 "<li>{} → <span class=\"stub\">{} (stub — unresolved reference)</span></li>",
387 esc(&r.rel_type),
388 esc(&target_id)
389 );
390 } else {
391 let _ = writeln!(
392 out,
393 "<li>{} → {} <span class=\"badge\">other mem</span></li>",
394 esc(&r.rel_type),
395 esc(&target_id)
396 );
397 }
398 }
399 out.push_str("</ul>\n");
400 }
401 out.push_str("</section>\n");
402 }
403
404 if !stubs.is_empty() {
405 out.push_str(
406 "<section class=\"entity\">\n<h2>Unresolved references (stubs)</h2>\n<ul>\n",
407 );
408 for s in &stubs {
409 let _ = writeln!(
410 out,
411 "<li class=\"stub\">{} — referenced but never written</li>",
412 esc(s.id.as_ref())
413 );
414 }
415 out.push_str("</ul>\n</section>\n");
416 }
417
418 out.push_str("</body>\n</html>\n");
419 Ok(out)
420 }
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426 use tempfile::TempDir;
427
428 use crate::backend::MemBackend;
429 use crate::engine::test_helpers::{cli_actor, folder_mount};
430 use crate::storage::FilesystemMemWriter;
431 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
432
433 fn fixture_engine(tmp: &TempDir) -> Engine {
438 let mem_dir = tmp.path().to_path_buf();
439 std::fs::write(
440 mem_dir.join("bösenberg-söhne-rev-21.md"),
441 "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Bösenberg & Söhne — Rev. 2.1\n\n## Identity\n\nCited in [[target-entity]] and [[über-ziele]] and cross-mem [[other--far-away]].\n\n<script>alert('x')</script>\n\n\n\nSee [docs](https://example.org/page), [broken](#bogus-frag), [evil](javascript:alert(2)).\n\n## Purpose\n\nZweck mit Umlauten: äöüß.\n",
442 )
443 .unwrap();
444 std::fs::write(
445 mem_dir.join("über-ziele.md"),
446 "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Über-Ziele\n\n## Identity\n\nUmlaut-slug anchor target.\n\n## Purpose\n\nP.\n",
447 )
448 .unwrap();
449 std::fs::write(
450 mem_dir.join("target-entity.md"),
451 "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Target Entity\n\n## Identity\n\nThe link target.\n\n## Purpose\n\nAnchors resolve here.\n",
452 )
453 .unwrap();
454 let writer = FilesystemMemWriter::new(mem_dir.clone());
455 Engine::from_mounts(vec![(
456 folder_mount("specs", mem_dir),
457 Box::new(writer) as Box<dyn MemBackend>,
458 )])
459 .unwrap()
460 }
461
462 fn assert_no_dangling_anchors(html: &str) {
465 let mut ids: Vec<&str> = Vec::new();
466 for part in html.split("id=\"").skip(1) {
467 if let Some(end) = part.find('"') {
468 ids.push(&part[..end]);
469 }
470 }
471 for part in html.split("href=\"#").skip(1) {
472 if let Some(end) = part.find('"') {
473 let anchor = percent_decode(&part[..end]);
474 assert!(
475 ids.iter().any(|id| *id == anchor),
476 "dangling in-document anchor #{anchor}"
477 );
478 }
479 }
480 }
481
482 #[test]
485 fn html_export_renders_sanitises_and_stays_self_contained() {
486 let tmp = TempDir::new().unwrap();
487 let mut engine = fixture_engine(&tmp);
488
489 let html = engine.render_html_export("specs", "2026-08-10").unwrap();
490
491 assert!(
493 html.contains("<strong>Mem:</strong> specs"),
494 "identity block"
495 );
496 assert!(html.contains("<strong>Exported:</strong> 2026-08-10"));
497 assert!(html.contains("<nav>"), "type-grouped index");
498 assert!(
499 html.contains("Bösenberg & Söhne — Rev. 2.1"),
500 "widened title escaped: {html}"
501 );
502 assert!(html.contains("äöüß"), "umlauts verbatim");
503
504 assert!(!html.contains("<script>"), "raw HTML must not pass through");
506 assert!(html.contains("<script>"), "escaped as visible text");
507
508 assert!(!html.contains("<img"), "no image element: {html}");
510 assert!(
511 html.contains("<a href=\"https://evil.example/x.png\">image: diagram (https://evil.example/x.png)</a>"),
512 "image degraded to labelled link: {html}"
513 );
514 assert!(html.contains("<a href=\"https://example.org/page\">docs</a>"));
515
516 assert!(
518 html.contains("href=\"#specs--target-entity\""),
519 "in-doc anchor"
520 );
521 assert!(html.contains("other--far-away"), "cross-mem labelled");
522 assert!(
523 !html.contains("href=\"#other--far-away\""),
524 "cross-mem never an anchor"
525 );
526
527 assert!(html.contains("ber-ziele"), "umlaut target linked: {html}");
531
532 assert!(
535 !html.contains("href=\"#bogus-frag\""),
536 "dangling fragment neutralised"
537 );
538 assert!(html.contains("(#bogus-frag — link removed)"), "{html}");
539
540 assert!(
543 !html.contains("href=\"javascript:"),
544 "javascript scheme stripped: {html}"
545 );
546 assert!(
547 html.contains("link removed"),
548 "neutralised destination surfaced"
549 );
550 assert_no_dangling_anchors(&html);
551
552 for fetching in [
559 "<img",
560 "<video",
561 "<audio",
562 "<iframe",
563 "<link ",
564 "<script src",
565 "@import",
566 "url(",
567 ] {
568 assert!(
569 !html.contains(fetching),
570 "self-containment violated by {fetching}"
571 );
572 }
573
574 let again = engine.render_html_export("specs", "2026-08-10").unwrap();
576 assert_eq!(html, again, "byte-deterministic");
577
578 let untouched_block = {
581 let start = html.find("id=\"specs--bösenberg-söhne-rev-21\"").unwrap();
582 let end = html[start..].find("</section>").unwrap() + start;
583 html[start..end].to_string()
584 };
585 let (actor, client) = cli_actor();
586 let mut edit = crate::engine::UpdateEntityArgs {
587 anchors: Vec::new(),
588 id: crate::entity::EntityId::new("specs", "target-entity"),
589 expected_hash: None,
590 sections: indexmap::IndexMap::from_iter([(
591 "purpose".to_string(),
592 "Geändert.".to_string(),
593 )]),
594 append_sections: indexmap::IndexMap::new(),
595 patch_sections: indexmap::IndexMap::new(),
596 metadata: indexmap::IndexMap::new(),
597 metadata_unset: Vec::new(),
598 declare_relations: Vec::new(),
599 dry_run: false,
600 relations_unset: Vec::new(),
601 anchors_unset: Vec::new(),
602 };
603 let _ = &mut edit;
604 engine
605 .update_entity(edit, actor, Some(&client), None)
606 .expect("edit lands");
607 let after = engine.render_html_export("specs", "2026-08-10").unwrap();
608 assert_ne!(html, after, "edit changes the export");
609 assert!(
610 after.contains(&untouched_block),
611 "untouched entity's region byte-identical after the edit"
612 );
613 assert!(after.contains("Geändert."), "edited content present");
614 }
615
616 #[test]
620 fn read_only_origin_stated_and_unknown_mem_refuses() {
621 let tmp = TempDir::new().unwrap();
622 let mem_dir = tmp.path().to_path_buf();
623 std::fs::write(
624 mem_dir.join("note.md"),
625 "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Foreign Note\n\n## Identity\n\nI.\n\n## Purpose\n\nP.\n",
626 )
627 .unwrap();
628 let writer = FilesystemMemWriter::new(mem_dir.clone());
629 let mount = Mount {
630 mem: "foreign".to_string(),
631 schema: Some("default@1.0.0".parse().unwrap()),
632 storage: MountStorage::Folder { path: mem_dir },
633 capability: MountCapability::ReadOnly,
634 lifecycle: MountLifecycle::Eager,
635 cross_linkable: true,
636 migration_target: None,
637 };
638 let engine =
639 Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
640 let html = engine.render_html_export("foreign", "2026-08-10").unwrap();
641 assert!(
642 html.contains("third-party (read-only mount"),
643 "trust class stated: {html}"
644 );
645
646 let err = engine.render_html_export("nope", "2026-08-10").unwrap_err();
647 assert_eq!(err.code(), "UNKNOWN_MEM");
648 }
649}