Skip to main content

memstead_base/engine/
export_html.rs

1//! `export --format html` (first-author-path plan 11): one
2//! self-contained HTML file per mem — the read surface for
3//! non-operators. A file you hand to a person: no server, no account,
4//! no installed anything.
5//!
6//! Hard lines:
7//! - **Self-contained**: zero network requests on open. External
8//!   *links* (`<a href>`) are passive and stay clickable; external
9//!   *resources* (images, media) are degraded to plain links naming
10//!   their target — user markdown citing a web image must not make
11//!   the export dial home. Inline styling only, one file, no asset
12//!   directories.
13//! - **Sanitised**: raw HTML in user markdown is escaped as text —
14//!   the export never embeds or executes user-supplied markup.
15//! - **Deterministic** given (store, export date): entities ordered
16//!   by (type, id); one date stamp; re-exports diff cleanly.
17//! - **Read-only projection of one mem**: cross-mem edges render as
18//!   labelled references, stubs render marked, and a read-only mount
19//!   exports with its trust class stated in the identity block.
20
21use 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
30/// Minimal HTML escaping for text and attribute positions.
31fn esc(s: &str) -> String {
32    s.replace('&', "&amp;")
33        .replace('<', "&lt;")
34        .replace('>', "&gt;")
35        .replace('"', "&quot;")
36}
37
38/// Resolve `[[target]]` wiki-links in a section body BEFORE markdown
39/// parsing: an in-mem target that exists becomes a markdown link to
40/// its in-document anchor; a cross-mem target becomes a labelled
41/// plain-text reference; a dangling target stays plain text (no
42/// dangling anchors, mechanically guaranteed).
43fn 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                    // In-document anchor (markdown link keeps the
64                    // rendering pipeline uniform).
65                    let _ = write!(out, "[{target}](#{full_id})");
66                } else if full_id.starts_with(&format!("{mem}--")) {
67                    // Dangling in-mem reference — plain text, marked.
68                    let _ = write!(out, "{target} *(unresolved)*");
69                } else {
70                    // Cross-mem reference — labelled, never an anchor.
71                    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
81/// Percent-decode a URL fragment for comparison against raw entity
82/// ids (pulldown-cmark percent-encodes non-ASCII hrefs; the `id`
83/// attributes we emit stay raw UTF-8 — browsers decode fragments, so
84/// equality must too).
85fn 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
107/// Whether a link destination may render as a clickable `<a href>`.
108/// Passive web links (http/https/mailto) stay clickable; a fragment
109/// link is allowed only when it resolves to an exported entity id (no
110/// dangling in-document anchors, mechanically); every other scheme —
111/// `javascript:`, `data:`, `file:`, vendor schemes — is neutralised
112/// to text: the handed-over file must never execute user-supplied
113/// script, not even on click.
114fn 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
123/// Render one markdown body to sanitised HTML: raw HTML becomes
124/// escaped text, images (external or not) degrade to plain links
125/// naming their target, link destinations outside the allowed set
126/// (http/https/mailto/resolvable fragments) neutralise to plain text,
127/// and everything else renders through the CommonMark machinery the
128/// engine already carries.
129fn 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; // (url, alt)
133    // Depth-tracked suppression of disallowed links: the wrapper is
134    // dropped, inner text stays, the destination surfaces as text.
135    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                        // Disallowed scheme on an image: text only.
157                        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            // Raw HTML never passes through — escaped as visible text.
167            Event::Html(s) | Event::InlineHtml(s) => {
168                events.push(Event::Text(s));
169            }
170            // Images are resources: degrade to a labelled passive link.
171            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    /// Render one mem as a single self-contained HTML document.
201    /// `export_date` is an ISO date stamped once in the identity
202    /// block — the only environmental input besides the store.
203    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        // The mem's schema, for the declared section headings below. A
216        // mem whose schema did not resolve still exports — every
217        // section falls back to its key rather than the export failing.
218        let schema = self.schemas.get(mem);
219        let schema_ref = self
220            .schemas
221            .get(mem)
222            .map(|s| {
223                let (n, v) = s.id();
224                format!("{n}@{v}")
225            })
226            .unwrap_or_else(|| "(unresolved)".to_string());
227
228        // Entities of this mem, deterministic order: (type, id).
229        // Stubs are collected separately and rendered marked.
230        let mut entities: Vec<&Entity> = self
231            .store
232            .all_entities()
233            .filter(|e| e.mem == mem && !e.stub)
234            .collect();
235        entities.sort_by(|a, b| {
236            a.entity_type
237                .cmp(&b.entity_type)
238                .then_with(|| a.id.as_ref().cmp(b.id.as_ref()))
239        });
240        let mut stubs: Vec<&Entity> = self
241            .store
242            .all_entities()
243            .filter(|e| e.mem == mem && e.stub)
244            .collect();
245        stubs.sort_by(|a, b| a.id.as_ref().cmp(b.id.as_ref()));
246        let exported_ids: Vec<String> = entities.iter().map(|e| e.id.to_string()).collect();
247
248        let mut out = String::new();
249        out.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n");
250        out.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
251        let doc_title = config
252            .and_then(|c| c.title.clone())
253            .unwrap_or_else(|| mem.to_string());
254        let _ = writeln!(out, "<title>{}</title>", esc(&doc_title));
255        out.push_str(
256            "<style>\n\
257             body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;\
258             max-width:52rem;margin:0 auto;padding:2rem 1rem;line-height:1.55;color:#1a1a1a;}\n\
259             h1{border-bottom:2px solid #ddd;padding-bottom:.3rem;}\n\
260             section.entity{border-top:1px solid #ddd;margin-top:2rem;padding-top:1rem;}\n\
261             table.meta{border-collapse:collapse;font-size:.9rem;margin:.5rem 0;}\n\
262             table.meta td{border:1px solid #ddd;padding:.15rem .5rem;}\n\
263             table.meta td:first-child{color:#555;}\n\
264             nav ul{columns:2;list-style:none;padding-left:0;}\n\
265             nav li{margin:.15rem 0;}\n\
266             .identity{background:#f6f6f6;border:1px solid #ddd;padding:.75rem 1rem;\
267             border-radius:4px;font-size:.95rem;}\n\
268             .badge{display:inline-block;background:#eee;border-radius:3px;\
269             padding:0 .4rem;font-size:.8rem;color:#555;}\n\
270             .stub{color:#888;font-style:italic;}\n\
271             .reltable{font-size:.9rem;}\n\
272             @media print{nav ul{columns:1;}}\n\
273             </style>\n</head>\n<body>\n",
274        );
275
276        // Identity block.
277        let _ = write!(
278            out,
279            "<h1>{}</h1>\n<div class=\"identity\">\n",
280            esc(&doc_title)
281        );
282        let _ = writeln!(out, "<div><strong>Mem:</strong> {}</div>", esc(mem));
283        if let Some(desc) = config.and_then(|c| c.description.as_deref())
284            && !desc.is_empty()
285        {
286            let _ = writeln!(
287                out,
288                "<div><strong>Description:</strong> {}</div>",
289                esc(desc)
290            );
291        }
292        if let Some(subject) = config.and_then(|c| c.subject.as_ref()) {
293            let _ = writeln!(
294                out,
295                "<div><strong>Subject:</strong> {}</div>",
296                esc(&subject.scope)
297            );
298        }
299        let _ = writeln!(
300            out,
301            "<div><strong>Schema:</strong> {}</div>",
302            esc(&schema_ref)
303        );
304        let trust = if third_party {
305            "third-party (read-only mount — someone else's published content, quoted here)"
306        } else {
307            "first-party (writable mem of this workspace)"
308        };
309        let _ = writeln!(out, "<div><strong>Origin:</strong> {trust}</div>");
310        let _ = write!(
311            out,
312            "<div><strong>Exported:</strong> {} · {} entities</div>\n</div>\n",
313            esc(export_date),
314            entities.len()
315        );
316
317        // Type-grouped navigation index.
318        let mut by_type: BTreeMap<&str, Vec<&Entity>> = BTreeMap::new();
319        for e in &entities {
320            by_type.entry(e.entity_type.as_str()).or_default().push(e);
321        }
322        out.push_str("<nav>\n<h2>Index</h2>\n");
323        for (ty, list) in &by_type {
324            let _ = write!(out, "<h3>{} ({})</h3>\n<ul>\n", esc(ty), list.len());
325            for e in list {
326                let _ = writeln!(
327                    out,
328                    "<li><a href=\"#{}\">{}</a></li>",
329                    esc(e.id.as_ref()),
330                    esc(&e.title)
331                );
332            }
333            out.push_str("</ul>\n");
334        }
335        out.push_str("</nav>\n");
336
337        // Entities.
338        for e in &entities {
339            let _ = write!(
340                out,
341                "<section class=\"entity\" id=\"{}\">\n<h2>{}</h2>\n<span class=\"badge\">{}</span> <span class=\"badge\">{}</span>\n",
342                esc(e.id.as_ref()),
343                esc(&e.title),
344                esc(&e.entity_type),
345                esc(e.id.as_ref()),
346            );
347            if !e.metadata.is_empty() {
348                out.push_str("<table class=\"meta\">\n");
349                for (k, v) in &e.metadata {
350                    let _ = writeln!(
351                        out,
352                        "<tr><td>{}</td><td>{}</td></tr>",
353                        esc(k),
354                        esc(&v.to_frontmatter_string())
355                    );
356                }
357                out.push_str("</table>\n");
358            }
359            for (key, body) in &e.sections {
360                if body.trim().is_empty() {
361                    continue;
362                }
363                // Show the heading the schema author declared, not the
364                // engine's storage key. This export is the one artifact
365                // handed to somebody with nothing installed, and the
366                // declared heading is the only place an author gets to
367                // control how their model reads to an outsider —
368                // rendering `summary` where they wrote `Summary` was
369                // the export path reaching for the field nearest to
370                // hand, never a decision.
371                //
372                // The key still governs identity elsewhere (anchors are
373                // derived from entity ids, and stay untouched), so
374                // display and stability stay separable.
375                let heading = schema
376                    .and_then(|s| s.get_type(&e.entity_type))
377                    .and_then(|t| {
378                        t.sections
379                            .iter()
380                            .find(|s| &s.key == key)
381                            .map(|s| s.heading.clone())
382                    })
383                    .unwrap_or_else(|| key.clone());
384                let _ = writeln!(out, "<h3>{}</h3>", esc(&heading));
385                let resolved = resolve_wiki_links(body, mem, &exported_ids);
386                out.push_str(&markdown_to_safe_html(&resolved, &exported_ids));
387            }
388            if !e.relationships.is_empty() {
389                // `Relationships` as the engine writes it on disk, not the
390                // lowercase slot name. This block is auto-managed rather
391                // than schema-declared, so it has no `heading` to read —
392                // but it sits beside headings that now carry the author's
393                // words, and was the last storage-flavoured one left.
394                out.push_str("<h3>Relationships</h3>\n<ul class=\"reltable\">\n");
395                let mut rels = e.relationships.clone();
396                rels.sort_by(|a, b| {
397                    a.rel_type
398                        .cmp(&b.rel_type)
399                        .then_with(|| a.target.as_ref().cmp(b.target.as_ref()))
400                });
401                for r in &rels {
402                    let target_id = r.target.to_string();
403                    let in_doc = exported_ids.iter().any(|id| id == &target_id);
404                    let is_stub_target = self.store.get(&r.target).map(|t| t.stub).unwrap_or(false);
405                    if in_doc {
406                        let _ = writeln!(
407                            out,
408                            "<li>{} → <a href=\"#{}\">{}</a></li>",
409                            esc(&r.rel_type),
410                            esc(&target_id),
411                            esc(&target_id)
412                        );
413                    } else if is_stub_target {
414                        let _ = writeln!(
415                            out,
416                            "<li>{} → <span class=\"stub\">{} (stub — unresolved reference)</span></li>",
417                            esc(&r.rel_type),
418                            esc(&target_id)
419                        );
420                    } else {
421                        let _ = writeln!(
422                            out,
423                            "<li>{} → {} <span class=\"badge\">other mem</span></li>",
424                            esc(&r.rel_type),
425                            esc(&target_id)
426                        );
427                    }
428                }
429                out.push_str("</ul>\n");
430            }
431            out.push_str("</section>\n");
432        }
433
434        if !stubs.is_empty() {
435            out.push_str(
436                "<section class=\"entity\">\n<h2>Unresolved references (stubs)</h2>\n<ul>\n",
437            );
438            for s in &stubs {
439                let _ = writeln!(
440                    out,
441                    "<li class=\"stub\">{} — referenced but never written</li>",
442                    esc(s.id.as_ref())
443                );
444            }
445            out.push_str("</ul>\n</section>\n");
446        }
447
448        out.push_str("</body>\n</html>\n");
449        Ok(out)
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456    use tempfile::TempDir;
457
458    use crate::backend::MemBackend;
459    use crate::engine::test_helpers::{cli_actor, folder_mount};
460    use crate::storage::FilesystemMemWriter;
461    use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
462
463    /// Pre-boot on-disk fixture: the renderer is a read surface, so
464    /// the fixture is written as existing markdown (including a
465    /// cross-mem wiki-link and hostile content that the write path
466    /// would gate) and the engine boots over it.
467    fn fixture_engine(tmp: &TempDir) -> Engine {
468        let mem_dir = tmp.path().to_path_buf();
469        std::fs::write(
470            mem_dir.join("bösenberg-söhne-rev-21.md"),
471            "---\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![diagram](https://evil.example/x.png)\n\nSee [docs](https://example.org/page), [broken](#bogus-frag), [evil](javascript:alert(2)).\n\n## Purpose\n\nZweck mit Umlauten: äöüß.\n",
472        )
473        .unwrap();
474        std::fs::write(
475            mem_dir.join("über-ziele.md"),
476            "---\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",
477        )
478        .unwrap();
479        std::fs::write(
480            mem_dir.join("target-entity.md"),
481            "---\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",
482        )
483        .unwrap();
484        let writer = FilesystemMemWriter::new(mem_dir.clone());
485        Engine::from_mounts(vec![(
486            folder_mount("specs", mem_dir),
487            Box::new(writer) as Box<dyn MemBackend>,
488        )])
489        .unwrap()
490    }
491
492    /// Every `href="#..."` in the document must point at an existing
493    /// `id="..."` — the no-dangling-anchor complement, mechanical.
494    fn assert_no_dangling_anchors(html: &str) {
495        let mut ids: Vec<&str> = Vec::new();
496        for part in html.split("id=\"").skip(1) {
497            if let Some(end) = part.find('"') {
498                ids.push(&part[..end]);
499            }
500        }
501        for part in html.split("href=\"#").skip(1) {
502            if let Some(end) = part.find('"') {
503                let anchor = percent_decode(&part[..end]);
504                assert!(
505                    ids.iter().any(|id| *id == anchor),
506                    "dangling in-document anchor #{anchor}"
507                );
508            }
509        }
510    }
511
512    /// The export shows the heading the schema author declared, not
513    /// the engine's storage key.
514    ///
515    /// The load-bearing fixture is `out_of_scope` → `Out of Scope`: the
516    /// interior word stays lowercase, so no capitalisation rule
517    /// reconstructs it from the key. (`current_state` → `Current State`
518    /// is also asserted, but title-casing the key would produce it, so
519    /// on its own it would not have caught a renderer that guessed.)
520    /// That is the point of the finding: the declared heading is the
521    /// only place an author controls how their model reads to someone
522    /// who cannot see the markdown, and guessing is not reading.
523    ///
524    /// Anchors are unaffected: they derive from entity ids, and the
525    /// no-dangling-anchor sweep runs here too.
526    #[test]
527    fn html_export_renders_the_declared_heading_not_the_section_key() {
528        let tmp = TempDir::new().unwrap();
529        let mem_dir = tmp.path().to_path_buf();
530        std::fs::write(
531            mem_dir.join("open-question.md"),
532            "---\ntype: inquiry\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\n\
533             status: open\nurgency: medium\n---\n# Open Question\n\n## Question\n\nQ?\n\n\
534             ## Significance\n\nS.\n\n## Current State\n\nWhere things stand.\n",
535        )
536        .unwrap();
537        let writer = FilesystemMemWriter::new(mem_dir.clone());
538        let engine = Engine::from_mounts(vec![(
539            folder_mount("specs", mem_dir),
540            Box::new(writer) as Box<dyn MemBackend>,
541        )])
542        .unwrap();
543
544        let html = engine.render_html_export("specs", "2026-08-15").unwrap();
545
546        assert!(
547            html.contains("<h3>Current State</h3>"),
548            "must render the declared heading; got:\n{html}"
549        );
550        assert!(
551            !html.contains("<h3>current_state</h3>"),
552            "must not render the storage key as a heading; got:\n{html}"
553        );
554        // The capitalised-only cases come along for free.
555        assert!(html.contains("<h3>Question</h3>"), "got:\n{html}");
556        assert!(html.contains("<h3>Significance</h3>"), "got:\n{html}");
557        assert_no_dangling_anchors(&html);
558
559        // The case a capitalisation rule cannot fake: `out_of_scope` is
560        // declared `Out of Scope`, interior word lowercase. A renderer
561        // that title-cased the key would emit "Out Of Scope" and fail
562        // here — which is what makes this the load-bearing assertion.
563        let tmp2 = TempDir::new().unwrap();
564        let goal_dir = tmp2.path().to_path_buf();
565        std::fs::write(
566            goal_dir.join("second-goal.md"),
567            "---\ntype: goal\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\n\
568             priority: high\nstatus: active\n---\n# Second Goal\n\n## Statement\n\nS.\n\n\
569             ## Rationale\n\nR.\n\n## Success Criteria\n\nC.\n\n## Out of Scope\n\n\
570             Everything else.\n",
571        )
572        .unwrap();
573        let goal_writer = FilesystemMemWriter::new(goal_dir.clone());
574        let goal_mount = Mount {
575            mem: "plans".to_string(),
576            schema: Some(memstead_schema::SchemaRef::new(
577                "planning",
578                semver::Version::new(0, 4, 0),
579            )),
580            storage: MountStorage::Folder { path: goal_dir },
581            capability: MountCapability::Write,
582            lifecycle: MountLifecycle::Eager,
583            cross_linkable: true,
584            migration_target: None,
585        };
586        let goal_engine = Engine::from_mounts(vec![(
587            goal_mount,
588            Box::new(goal_writer) as Box<dyn MemBackend>,
589        )])
590        .unwrap();
591        let goal_html = goal_engine
592            .render_html_export("plans", "2026-08-15")
593            .unwrap();
594        assert!(
595            goal_html.contains("<h3>Out of Scope</h3>"),
596            "the interior word must stay lowercase, as declared; got:\n{goal_html}"
597        );
598        assert!(
599            !goal_html.contains("<h3>Out Of Scope</h3>")
600                && !goal_html.contains("<h3>out_of_scope</h3>"),
601            "neither a title-cased guess nor the storage key; got:\n{goal_html}"
602        );
603
604        // Byte-deterministic given store and export date.
605        let again = engine.render_html_export("specs", "2026-08-15").unwrap();
606        assert_eq!(html, again, "export must be byte-deterministic");
607    }
608
609    /// Criteria 1–4 over one fixture: rendering, sanitisation,
610    /// resource degradation, anchors, determinism, localized change.
611    #[test]
612    fn html_export_renders_sanitises_and_stays_self_contained() {
613        let tmp = TempDir::new().unwrap();
614        let mut engine = fixture_engine(&tmp);
615
616        let html = engine.render_html_export("specs", "2026-08-10").unwrap();
617
618        // Identity block + index + entities.
619        assert!(
620            html.contains("<strong>Mem:</strong> specs"),
621            "identity block"
622        );
623        assert!(html.contains("<strong>Exported:</strong> 2026-08-10"));
624        assert!(html.contains("<nav>"), "type-grouped index");
625        assert!(
626            html.contains("Bösenberg &amp; Söhne — Rev. 2.1"),
627            "widened title escaped: {html}"
628        );
629        assert!(html.contains("äöüß"), "umlauts verbatim");
630
631        // Sanitisation: no script tag survives; the text is escaped.
632        assert!(!html.contains("<script>"), "raw HTML must not pass through");
633        assert!(html.contains("&lt;script&gt;"), "escaped as visible text");
634
635        // External image degraded to a passive link; external links stay.
636        assert!(!html.contains("<img"), "no image element: {html}");
637        assert!(
638            html.contains("<a href=\"https://evil.example/x.png\">image: diagram (https://evil.example/x.png)</a>"),
639            "image degraded to labelled link: {html}"
640        );
641        assert!(html.contains("<a href=\"https://example.org/page\">docs</a>"));
642
643        // Wiki-links: in-mem → anchor; cross-mem → labelled, no anchor.
644        assert!(
645            html.contains("href=\"#specs--target-entity\""),
646            "in-doc anchor"
647        );
648        assert!(html.contains("other--far-away"), "cross-mem labelled");
649        assert!(
650            !html.contains("href=\"#other--far-away\""),
651            "cross-mem never an anchor"
652        );
653
654        // Umlaut-slug wiki-link: pulldown percent-encodes the href;
655        // the checker decodes, so this is the form that used to be
656        // untested.
657        assert!(html.contains("ber-ziele"), "umlaut target linked: {html}");
658
659        // User fragment link to nowhere: neutralised to text — never
660        // a dangling anchor.
661        assert!(
662            !html.contains("href=\"#bogus-frag\""),
663            "dangling fragment neutralised"
664        );
665        assert!(html.contains("(#bogus-frag — link removed)"), "{html}");
666
667        // javascript: scheme: never a clickable href — even on click,
668        // the handed-over file must not execute user script.
669        assert!(
670            !html.contains("href=\"javascript:"),
671            "javascript scheme stripped: {html}"
672        );
673        assert!(
674            html.contains("link removed"),
675            "neutralised destination surfaced"
676        );
677        assert_no_dangling_anchors(&html);
678
679        // Stub marking: the cross-mem auto-stub never lands in a
680        // folder mem without policy — but an in-mem stub does.
681        // (See the relationships list: targets that are stubs are
682        // marked; asserted in the wiki-link block above via absence.)
683
684        // Zero external resources: nothing in the markup fetches.
685        for fetching in [
686            "<img",
687            "<video",
688            "<audio",
689            "<iframe",
690            "<link ",
691            "<script src",
692            "@import",
693            "url(",
694        ] {
695            assert!(
696                !html.contains(fetching),
697                "self-containment violated by {fetching}"
698            );
699        }
700
701        // Determinism: same store + date → same bytes.
702        let again = engine.render_html_export("specs", "2026-08-10").unwrap();
703        assert_eq!(html, again, "byte-deterministic");
704
705        // Localized change: edit one entity; the untouched entity's
706        // section block stays byte-identical.
707        let untouched_block = {
708            let start = html.find("id=\"specs--bösenberg-söhne-rev-21\"").unwrap();
709            let end = html[start..].find("</section>").unwrap() + start;
710            html[start..end].to_string()
711        };
712        let (actor, client) = cli_actor();
713        let mut edit = crate::engine::UpdateEntityArgs {
714            anchors: Vec::new(),
715            id: crate::entity::EntityId::new("specs", "target-entity"),
716            expected_hash: None,
717            sections: indexmap::IndexMap::from_iter([(
718                "purpose".to_string(),
719                "Geändert.".to_string(),
720            )]),
721            append_sections: indexmap::IndexMap::new(),
722            patch_sections: indexmap::IndexMap::new(),
723            metadata: indexmap::IndexMap::new(),
724            metadata_unset: Vec::new(),
725            declare_relations: Vec::new(),
726            dry_run: false,
727            relations_unset: Vec::new(),
728            anchors_unset: Vec::new(),
729        };
730        let _ = &mut edit;
731        engine
732            .update_entity(edit, actor, Some(&client), None)
733            .expect("edit lands");
734        let after = engine.render_html_export("specs", "2026-08-10").unwrap();
735        assert_ne!(html, after, "edit changes the export");
736        assert!(
737            after.contains(&untouched_block),
738            "untouched entity's region byte-identical after the edit"
739        );
740        assert!(after.contains("Geändert."), "edited content present");
741    }
742
743    /// Criterion 5: a read-only mount exports with its trust class in
744    /// the identity block. Criterion 6's refusal parity: unknown mem
745    /// refuses UNKNOWN_MEM like the other formats.
746    #[test]
747    fn read_only_origin_stated_and_unknown_mem_refuses() {
748        let tmp = TempDir::new().unwrap();
749        let mem_dir = tmp.path().to_path_buf();
750        std::fs::write(
751            mem_dir.join("note.md"),
752            "---\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",
753        )
754        .unwrap();
755        let writer = FilesystemMemWriter::new(mem_dir.clone());
756        let mount = Mount {
757            mem: "foreign".to_string(),
758            schema: Some("default@1.0.0".parse().unwrap()),
759            storage: MountStorage::Folder { path: mem_dir },
760            capability: MountCapability::ReadOnly,
761            lifecycle: MountLifecycle::Eager,
762            cross_linkable: true,
763            migration_target: None,
764        };
765        let engine =
766            Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
767        let html = engine.render_html_export("foreign", "2026-08-10").unwrap();
768        assert!(
769            html.contains("third-party (read-only mount"),
770            "trust class stated: {html}"
771        );
772
773        let err = engine.render_html_export("nope", "2026-08-10").unwrap_err();
774        assert_eq!(err.code(), "UNKNOWN_MEM");
775    }
776}