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).
43///
44/// Links inside code blocks and inline code spans are left verbatim,
45/// by the one definition every other reader uses
46/// ([`crate::markdown::mask_code_blocks_and_spans`]). An export is the
47/// surface most likely to carry a code sample *documenting* wiki-link
48/// syntax, and rewriting one silently corrupts the sample: what a
49/// renderer shows as code is what the engine treats as code, here too.
50/// The scan runs over the masked copy and every slice is taken from
51/// the original, so the output is byte-identical outside real links.
52fn resolve_wiki_links(body: &str, mem: &str, exported_ids: &[String]) -> String {
53    let masked = crate::markdown::mask_code_blocks_and_spans(body);
54    let mut out = String::with_capacity(body.len());
55    let mut cursor = 0usize;
56    while let Some(rel) = masked[cursor..].find("[[") {
57        let start = cursor + rel;
58        out.push_str(&body[cursor..start]);
59        let after_start = start + 2;
60        match masked[after_start..].find("]]") {
61            None => {
62                out.push_str(&body[start..]);
63                cursor = body.len();
64                break;
65            }
66            Some(rel_end) => {
67                let end = after_start + rel_end;
68                let target = &body[after_start..end];
69                let full_id = if target.contains("--") {
70                    target.to_string()
71                } else {
72                    format!("{mem}--{target}")
73                };
74                if exported_ids.iter().any(|id| id == &full_id) {
75                    // In-document anchor (markdown link keeps the
76                    // rendering pipeline uniform).
77                    let _ = write!(out, "[{target}](#{full_id})");
78                } else if full_id.starts_with(&format!("{mem}--")) {
79                    // Dangling in-mem reference — plain text, marked.
80                    let _ = write!(out, "{target} *(unresolved)*");
81                } else {
82                    // Cross-mem reference — labelled, never an anchor.
83                    let _ = write!(out, "{full_id} *(other mem)*");
84                }
85                cursor = end + 2;
86            }
87        }
88    }
89    out.push_str(&body[cursor..]);
90    out
91}
92
93/// Percent-decode a URL fragment for comparison against raw entity
94/// ids (pulldown-cmark percent-encodes non-ASCII hrefs; the `id`
95/// attributes we emit stay raw UTF-8 — browsers decode fragments, so
96/// equality must too).
97fn percent_decode(s: &str) -> String {
98    let bytes = s.as_bytes();
99    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
100    let mut i = 0;
101    while i < bytes.len() {
102        if bytes[i] == b'%'
103            && i + 2 < bytes.len()
104            && let (Some(h), Some(l)) = (
105                (bytes[i + 1] as char).to_digit(16),
106                (bytes[i + 2] as char).to_digit(16),
107            )
108        {
109            out.push((h * 16 + l) as u8);
110            i += 3;
111        } else {
112            out.push(bytes[i]);
113            i += 1;
114        }
115    }
116    String::from_utf8_lossy(&out).into_owned()
117}
118
119/// Whether a link destination may render as a clickable `<a href>`.
120/// Passive web links (http/https/mailto) stay clickable; a fragment
121/// link is allowed only when it resolves to an exported entity id (no
122/// dangling in-document anchors, mechanically); every other scheme —
123/// `javascript:`, `data:`, `file:`, vendor schemes — is neutralised
124/// to text: the handed-over file must never execute user-supplied
125/// script, not even on click.
126fn link_dest_allowed(dest: &str, exported_ids: &[String]) -> bool {
127    if let Some(frag) = dest.strip_prefix('#') {
128        let decoded = percent_decode(frag);
129        return exported_ids.iter().any(|id| id == &decoded);
130    }
131    let lower = dest.trim().to_ascii_lowercase();
132    lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("mailto:")
133}
134
135/// Render one markdown body to sanitised HTML: raw HTML becomes
136/// escaped text, images (external or not) degrade to plain links
137/// naming their target, link destinations outside the allowed set
138/// (http/https/mailto/resolvable fragments) neutralise to plain text,
139/// and everything else renders through the CommonMark machinery the
140/// engine already carries.
141fn markdown_to_safe_html(md: &str, exported_ids: &[String]) -> String {
142    // The engine's one reader dialect plus the extensions that are
143    // rendering-only. Derived from `parser_options()` rather than
144    // rebuilt, so a flag added to the reader reaches the renderer too
145    // — an independently constructed `Options` here is how two
146    // referees start.
147    let options = crate::markdown::parser_options() | Options::ENABLE_STRIKETHROUGH;
148    let parser = Parser::new_ext(md, options);
149    let mut events: Vec<Event> = Vec::new();
150    let mut skipping_image: Option<(String, String)> = None; // (url, alt)
151    // Depth-tracked suppression of disallowed links: the wrapper is
152    // dropped, inner text stays, the destination surfaces as text.
153    let mut suppressed_link: Option<String> = None;
154    for ev in parser {
155        if let Some((_url, alt)) = skipping_image.as_mut() {
156            match ev {
157                Event::End(TagEnd::Image) => {
158                    let (url, alt) = skipping_image.take().unwrap();
159                    let label = if alt.trim().is_empty() {
160                        format!("image: {url}")
161                    } else {
162                        format!("image: {alt} ({url})")
163                    };
164                    if link_dest_allowed(&url, exported_ids) {
165                        events.push(Event::Start(Tag::Link {
166                            link_type: pulldown_cmark::LinkType::Inline,
167                            dest_url: url.clone().into(),
168                            title: "".into(),
169                            id: "".into(),
170                        }));
171                        events.push(Event::Text(label.into()));
172                        events.push(Event::End(TagEnd::Link));
173                    } else {
174                        // Disallowed scheme on an image: text only.
175                        events.push(Event::Text(format!("[{label}]").into()));
176                    }
177                }
178                Event::Text(t) => alt.push_str(&t),
179                _ => {}
180            }
181            continue;
182        }
183        match ev {
184            // Raw HTML never passes through — escaped as visible text.
185            Event::Html(s) | Event::InlineHtml(s) => {
186                events.push(Event::Text(s));
187            }
188            // Images are resources: degrade to a labelled passive link.
189            Event::Start(Tag::Image { dest_url, .. }) => {
190                skipping_image = Some((dest_url.to_string(), String::new()));
191            }
192            Event::Start(Tag::Link { dest_url, .. }) if suppressed_link.is_none() => {
193                let dest = dest_url.to_string();
194                if link_dest_allowed(&dest, exported_ids) {
195                    events.push(Event::Start(Tag::Link {
196                        link_type: pulldown_cmark::LinkType::Inline,
197                        dest_url: dest.into(),
198                        title: "".into(),
199                        id: "".into(),
200                    }));
201                } else {
202                    suppressed_link = Some(dest);
203                }
204            }
205            Event::End(TagEnd::Link) if suppressed_link.is_some() => {
206                let dest = suppressed_link.take().unwrap();
207                events.push(Event::Text(format!(" ({dest} — link removed)").into()));
208            }
209            other => events.push(other),
210        }
211    }
212    let mut html = String::new();
213    pulldown_cmark::html::push_html(&mut html, events.into_iter());
214    html
215}
216
217impl Engine {
218    /// Render one mem as a single self-contained HTML document.
219    /// `export_date` is an ISO date stamped once in the identity
220    /// block — the only environmental input besides the store.
221    pub fn render_html_export(
222        &self,
223        mem: &str,
224        export_date: &str,
225    ) -> Result<String, crate::engine::EngineError> {
226        let mounted = self
227            .mounts
228            .iter()
229            .find(|m| m.mount.mem == mem)
230            .ok_or_else(|| crate::engine::EngineError::UnknownMem(mem.to_string()))?;
231        let third_party = mounted.mount.capability == MountCapability::ReadOnly;
232        let config = self.mem_config_for(mem);
233        // The mem's schema, for the declared section headings below. A
234        // mem whose schema did not resolve still exports — every
235        // section falls back to its key rather than the export failing.
236        let schema = self.schemas.get(mem);
237        let schema_ref = self
238            .schemas
239            .get(mem)
240            .map(|s| {
241                let (n, v) = s.id();
242                format!("{n}@{v}")
243            })
244            .unwrap_or_else(|| "(unresolved)".to_string());
245
246        // Entities of this mem, deterministic order: (type, id).
247        // Stubs are collected separately and rendered marked.
248        let mut entities: Vec<&Entity> = self
249            .store
250            .all_entities()
251            .filter(|e| e.mem == mem && !e.stub)
252            .collect();
253        entities.sort_by(|a, b| {
254            a.entity_type
255                .cmp(&b.entity_type)
256                .then_with(|| a.id.as_ref().cmp(b.id.as_ref()))
257        });
258        let mut stubs: Vec<&Entity> = self
259            .store
260            .all_entities()
261            .filter(|e| e.mem == mem && e.stub)
262            .collect();
263        stubs.sort_by(|a, b| a.id.as_ref().cmp(b.id.as_ref()));
264        let exported_ids: Vec<String> = entities.iter().map(|e| e.id.to_string()).collect();
265
266        let mut out = String::new();
267        out.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n");
268        out.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
269        let doc_title = config
270            .and_then(|c| c.title.clone())
271            .unwrap_or_else(|| mem.to_string());
272        let _ = writeln!(out, "<title>{}</title>", esc(&doc_title));
273        out.push_str(
274            "<style>\n\
275             body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;\
276             max-width:52rem;margin:0 auto;padding:2rem 1rem;line-height:1.55;color:#1a1a1a;}\n\
277             h1{border-bottom:2px solid #ddd;padding-bottom:.3rem;}\n\
278             section.entity{border-top:1px solid #ddd;margin-top:2rem;padding-top:1rem;}\n\
279             table.meta{border-collapse:collapse;font-size:.9rem;margin:.5rem 0;}\n\
280             table.meta td{border:1px solid #ddd;padding:.15rem .5rem;}\n\
281             table.meta td:first-child{color:#555;}\n\
282             nav ul{columns:2;list-style:none;padding-left:0;}\n\
283             nav li{margin:.15rem 0;}\n\
284             .identity{background:#f6f6f6;border:1px solid #ddd;padding:.75rem 1rem;\
285             border-radius:4px;font-size:.95rem;}\n\
286             .badge{display:inline-block;background:#eee;border-radius:3px;\
287             padding:0 .4rem;font-size:.8rem;color:#555;}\n\
288             .stub{color:#888;font-style:italic;}\n\
289             .reltable{font-size:.9rem;}\n\
290             @media print{nav ul{columns:1;}}\n\
291             </style>\n</head>\n<body>\n",
292        );
293
294        // Identity block.
295        let _ = write!(
296            out,
297            "<h1>{}</h1>\n<div class=\"identity\">\n",
298            esc(&doc_title)
299        );
300        let _ = writeln!(out, "<div><strong>Mem:</strong> {}</div>", esc(mem));
301        if let Some(desc) = config.and_then(|c| c.description.as_deref())
302            && !desc.is_empty()
303        {
304            let _ = writeln!(
305                out,
306                "<div><strong>Description:</strong> {}</div>",
307                esc(desc)
308            );
309        }
310        if let Some(subject) = config.and_then(|c| c.subject.as_ref()) {
311            let _ = writeln!(
312                out,
313                "<div><strong>Subject:</strong> {}</div>",
314                esc(&subject.scope)
315            );
316        }
317        let _ = writeln!(
318            out,
319            "<div><strong>Schema:</strong> {}</div>",
320            esc(&schema_ref)
321        );
322        let trust = if third_party {
323            "third-party (read-only mount — someone else's published content, quoted here)"
324        } else {
325            "first-party (writable mem of this workspace)"
326        };
327        let _ = writeln!(out, "<div><strong>Origin:</strong> {trust}</div>");
328        let _ = write!(
329            out,
330            "<div><strong>Exported:</strong> {} · {} entities</div>\n</div>\n",
331            esc(export_date),
332            entities.len()
333        );
334
335        // Type-grouped navigation index.
336        let mut by_type: BTreeMap<&str, Vec<&Entity>> = BTreeMap::new();
337        for e in &entities {
338            by_type.entry(e.entity_type.as_str()).or_default().push(e);
339        }
340        out.push_str("<nav>\n<h2>Index</h2>\n");
341        for (ty, list) in &by_type {
342            let _ = write!(out, "<h3>{} ({})</h3>\n<ul>\n", esc(ty), list.len());
343            for e in list {
344                let _ = writeln!(
345                    out,
346                    "<li><a href=\"#{}\">{}</a></li>",
347                    esc(e.id.as_ref()),
348                    esc(&e.title)
349                );
350            }
351            out.push_str("</ul>\n");
352        }
353        out.push_str("</nav>\n");
354
355        // Entities.
356        for e in &entities {
357            let _ = write!(
358                out,
359                "<section class=\"entity\" id=\"{}\">\n<h2>{}</h2>\n<span class=\"badge\">{}</span> <span class=\"badge\">{}</span>\n",
360                esc(e.id.as_ref()),
361                esc(&e.title),
362                esc(&e.entity_type),
363                esc(e.id.as_ref()),
364            );
365            if !e.metadata.is_empty() {
366                out.push_str("<table class=\"meta\">\n");
367                for (k, v) in &e.metadata {
368                    let _ = writeln!(
369                        out,
370                        "<tr><td>{}</td><td>{}</td></tr>",
371                        esc(k),
372                        esc(&v.to_frontmatter_string())
373                    );
374                }
375                out.push_str("</table>\n");
376            }
377            for (key, body) in &e.sections {
378                if body.trim().is_empty() {
379                    continue;
380                }
381                // Show the heading the schema author declared, not the
382                // engine's storage key. This export is the one artifact
383                // handed to somebody with nothing installed, and the
384                // declared heading is the only place an author gets to
385                // control how their model reads to an outsider —
386                // rendering `summary` where they wrote `Summary` was
387                // the export path reaching for the field nearest to
388                // hand, never a decision.
389                //
390                // The key still governs identity elsewhere (anchors are
391                // derived from entity ids, and stay untouched), so
392                // display and stability stay separable.
393                let heading = schema
394                    .and_then(|s| s.get_type(&e.entity_type))
395                    .and_then(|t| {
396                        t.sections
397                            .iter()
398                            .find(|s| &s.key == key)
399                            .map(|s| s.heading.clone())
400                    })
401                    .unwrap_or_else(|| key.clone());
402                let _ = writeln!(out, "<h3>{}</h3>", esc(&heading));
403                let resolved = resolve_wiki_links(body, mem, &exported_ids);
404                out.push_str(&markdown_to_safe_html(&resolved, &exported_ids));
405            }
406            if !e.relationships.is_empty() {
407                // `Relationships` as the engine writes it on disk, not the
408                // lowercase slot name. This block is auto-managed rather
409                // than schema-declared, so it has no `heading` to read —
410                // but it sits beside headings that now carry the author's
411                // words, and was the last storage-flavoured one left.
412                out.push_str("<h3>Relationships</h3>\n<ul class=\"reltable\">\n");
413                let mut rels = e.relationships.clone();
414                rels.sort_by(|a, b| {
415                    a.rel_type
416                        .cmp(&b.rel_type)
417                        .then_with(|| a.target.as_ref().cmp(b.target.as_ref()))
418                });
419                for r in &rels {
420                    let target_id = r.target.to_string();
421                    let in_doc = exported_ids.iter().any(|id| id == &target_id);
422                    let is_stub_target = self.store.get(&r.target).map(|t| t.stub).unwrap_or(false);
423                    if in_doc {
424                        let _ = writeln!(
425                            out,
426                            "<li>{} → <a href=\"#{}\">{}</a></li>",
427                            esc(&r.rel_type),
428                            esc(&target_id),
429                            esc(&target_id)
430                        );
431                    } else if is_stub_target {
432                        let _ = writeln!(
433                            out,
434                            "<li>{} → <span class=\"stub\">{} (stub — unresolved reference)</span></li>",
435                            esc(&r.rel_type),
436                            esc(&target_id)
437                        );
438                    } else {
439                        let _ = writeln!(
440                            out,
441                            "<li>{} → {} <span class=\"badge\">other mem</span></li>",
442                            esc(&r.rel_type),
443                            esc(&target_id)
444                        );
445                    }
446                }
447                out.push_str("</ul>\n");
448            }
449            out.push_str("</section>\n");
450        }
451
452        if !stubs.is_empty() {
453            out.push_str(
454                "<section class=\"entity\">\n<h2>Unresolved references (stubs)</h2>\n<ul>\n",
455            );
456            for s in &stubs {
457                let _ = writeln!(
458                    out,
459                    "<li class=\"stub\">{} — referenced but never written</li>",
460                    esc(s.id.as_ref())
461                );
462            }
463            out.push_str("</ul>\n</section>\n");
464        }
465
466        out.push_str("</body>\n</html>\n");
467        Ok(out)
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474    use tempfile::TempDir;
475
476    use crate::backend::MemBackend;
477    use crate::engine::test_helpers::{cli_actor, folder_mount};
478    use crate::storage::FilesystemMemWriter;
479    use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
480
481    /// The export scan uses the one definition of "not a link": a
482    /// `[[…]]` inside a code block or inline span is a code sample, not
483    /// a reference, and an export is the surface most likely to carry
484    /// one — a page documenting wiki-link syntax. Rewriting it
485    /// corrupts the sample it is trying to show.
486    #[test]
487    fn wiki_link_resolution_leaves_code_verbatim() {
488        let exported = vec!["m--real".to_string()];
489        for body in [
490            "```\n[[m--real]]\n```",
491            "~~~\n[[m--real]]\n~~~",
492            "    [[m--real]]",
493            "> ```\n> [[m--real]]\n> ```",
494            "An inline `[[m--real]]` sample.",
495            "A double ``[[m--real]]`` sample.",
496        ] {
497            assert_eq!(
498                resolve_wiki_links(body, "m", &exported),
499                body,
500                "code content must survive byte-identical: {body:?}"
501            );
502        }
503    }
504
505    /// …and a dangling target inside code is not marked either — the
506    /// `*(unresolved)*` suffix is just as much a corruption.
507    #[test]
508    fn wiki_link_resolution_does_not_mark_code_as_unresolved() {
509        let body = "```\n[[m--ghost]]\n```\n\n    [[m--other-ghost]]\n";
510        assert_eq!(resolve_wiki_links(body, "m", &[]), body);
511    }
512
513    /// Complement: prose links on either side of a code block still
514    /// resolve exactly as before — anchor, unresolved marker, and
515    /// cross-mem label alike.
516    #[test]
517    fn wiki_link_resolution_still_rewrites_prose() {
518        let exported = vec!["m--real".to_string()];
519        let body =
520            "See [[m--real]].\n\n```\n[[m--real]]\n```\n\nAnd [[m--ghost]] and [[other--thing]].\n";
521        let out = resolve_wiki_links(body, "m", &exported);
522        assert!(out.contains("[m--real](#m--real)"), "{out}");
523        assert!(out.contains("m--ghost *(unresolved)*"), "{out}");
524        assert!(out.contains("other--thing *(other mem)*"), "{out}");
525        assert!(
526            out.contains("```\n[[m--real]]\n```"),
527            "code untouched: {out}"
528        );
529    }
530
531    /// An unterminated `[[` still passes through verbatim rather than
532    /// truncating the body — the offset walk must not lose the tail.
533    #[test]
534    fn wiki_link_resolution_passes_through_an_unterminated_open() {
535        let body = "text [[not-closed and more text after\n";
536        assert_eq!(resolve_wiki_links(body, "m", &[]), body);
537    }
538
539    /// Pre-boot on-disk fixture: the renderer is a read surface, so
540    /// the fixture is written as existing markdown (including a
541    /// cross-mem wiki-link and hostile content that the write path
542    /// would gate) and the engine boots over it.
543    fn fixture_engine(tmp: &TempDir) -> Engine {
544        let mem_dir = tmp.path().to_path_buf();
545        std::fs::write(
546            mem_dir.join("bösenberg-söhne-rev-21.md"),
547            "---\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",
548        )
549        .unwrap();
550        std::fs::write(
551            mem_dir.join("über-ziele.md"),
552            "---\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",
553        )
554        .unwrap();
555        std::fs::write(
556            mem_dir.join("target-entity.md"),
557            "---\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",
558        )
559        .unwrap();
560        let writer = FilesystemMemWriter::new(mem_dir.clone());
561        Engine::from_mounts(vec![(
562            folder_mount("specs", mem_dir),
563            Box::new(writer) as Box<dyn MemBackend>,
564        )])
565        .unwrap()
566    }
567
568    /// Every `href="#..."` in the document must point at an existing
569    /// `id="..."` — the no-dangling-anchor complement, mechanical.
570    fn assert_no_dangling_anchors(html: &str) {
571        let mut ids: Vec<&str> = Vec::new();
572        for part in html.split("id=\"").skip(1) {
573            if let Some(end) = part.find('"') {
574                ids.push(&part[..end]);
575            }
576        }
577        for part in html.split("href=\"#").skip(1) {
578            if let Some(end) = part.find('"') {
579                let anchor = percent_decode(&part[..end]);
580                assert!(
581                    ids.iter().any(|id| *id == anchor),
582                    "dangling in-document anchor #{anchor}"
583                );
584            }
585        }
586    }
587
588    /// The export shows the heading the schema author declared, not
589    /// the engine's storage key.
590    ///
591    /// The load-bearing fixture is `out_of_scope` → `Out of Scope`: the
592    /// interior word stays lowercase, so no capitalisation rule
593    /// reconstructs it from the key. (`current_state` → `Current State`
594    /// is also asserted, but title-casing the key would produce it, so
595    /// on its own it would not have caught a renderer that guessed.)
596    /// That is the point of the finding: the declared heading is the
597    /// only place an author controls how their model reads to someone
598    /// who cannot see the markdown, and guessing is not reading.
599    ///
600    /// Anchors are unaffected: they derive from entity ids, and the
601    /// no-dangling-anchor sweep runs here too.
602    #[test]
603    fn html_export_renders_the_declared_heading_not_the_section_key() {
604        let tmp = TempDir::new().unwrap();
605        let mem_dir = tmp.path().to_path_buf();
606        std::fs::write(
607            mem_dir.join("open-question.md"),
608            "---\ntype: inquiry\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\n\
609             status: open\nurgency: medium\n---\n# Open Question\n\n## Question\n\nQ?\n\n\
610             ## Significance\n\nS.\n\n## Current State\n\nWhere things stand.\n",
611        )
612        .unwrap();
613        let writer = FilesystemMemWriter::new(mem_dir.clone());
614        let engine = Engine::from_mounts(vec![(
615            folder_mount("specs", mem_dir),
616            Box::new(writer) as Box<dyn MemBackend>,
617        )])
618        .unwrap();
619
620        let html = engine.render_html_export("specs", "2026-08-15").unwrap();
621
622        assert!(
623            html.contains("<h3>Current State</h3>"),
624            "must render the declared heading; got:\n{html}"
625        );
626        assert!(
627            !html.contains("<h3>current_state</h3>"),
628            "must not render the storage key as a heading; got:\n{html}"
629        );
630        // The capitalised-only cases come along for free.
631        assert!(html.contains("<h3>Question</h3>"), "got:\n{html}");
632        assert!(html.contains("<h3>Significance</h3>"), "got:\n{html}");
633        assert_no_dangling_anchors(&html);
634
635        // The case a capitalisation rule cannot fake: `out_of_scope` is
636        // declared `Out of Scope`, interior word lowercase. A renderer
637        // that title-cased the key would emit "Out Of Scope" and fail
638        // here — which is what makes this the load-bearing assertion.
639        let tmp2 = TempDir::new().unwrap();
640        let goal_dir = tmp2.path().to_path_buf();
641        std::fs::write(
642            goal_dir.join("second-goal.md"),
643            "---\ntype: goal\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\n\
644             priority: high\nstatus: active\n---\n# Second Goal\n\n## Statement\n\nS.\n\n\
645             ## Rationale\n\nR.\n\n## Success Criteria\n\nC.\n\n## Out of Scope\n\n\
646             Everything else.\n",
647        )
648        .unwrap();
649        let goal_writer = FilesystemMemWriter::new(goal_dir.clone());
650        let goal_mount = Mount {
651            mem: "plans".to_string(),
652            schema: Some(memstead_schema::SchemaRef::new(
653                "planning",
654                semver::Version::new(0, 4, 0),
655            )),
656            storage: MountStorage::Folder { path: goal_dir },
657            capability: MountCapability::Write,
658            lifecycle: MountLifecycle::Eager,
659            cross_linkable: true,
660            migration_target: None,
661        };
662        let goal_engine = Engine::from_mounts(vec![(
663            goal_mount,
664            Box::new(goal_writer) as Box<dyn MemBackend>,
665        )])
666        .unwrap();
667        let goal_html = goal_engine
668            .render_html_export("plans", "2026-08-15")
669            .unwrap();
670        assert!(
671            goal_html.contains("<h3>Out of Scope</h3>"),
672            "the interior word must stay lowercase, as declared; got:\n{goal_html}"
673        );
674        assert!(
675            !goal_html.contains("<h3>Out Of Scope</h3>")
676                && !goal_html.contains("<h3>out_of_scope</h3>"),
677            "neither a title-cased guess nor the storage key; got:\n{goal_html}"
678        );
679
680        // Byte-deterministic given store and export date.
681        let again = engine.render_html_export("specs", "2026-08-15").unwrap();
682        assert_eq!(html, again, "export must be byte-deterministic");
683    }
684
685    /// Criteria 1–4 over one fixture: rendering, sanitisation,
686    /// resource degradation, anchors, determinism, localized change.
687    #[test]
688    fn html_export_renders_sanitises_and_stays_self_contained() {
689        let tmp = TempDir::new().unwrap();
690        let mut engine = fixture_engine(&tmp);
691
692        let html = engine.render_html_export("specs", "2026-08-10").unwrap();
693
694        // Identity block + index + entities.
695        assert!(
696            html.contains("<strong>Mem:</strong> specs"),
697            "identity block"
698        );
699        assert!(html.contains("<strong>Exported:</strong> 2026-08-10"));
700        assert!(html.contains("<nav>"), "type-grouped index");
701        assert!(
702            html.contains("Bösenberg &amp; Söhne — Rev. 2.1"),
703            "widened title escaped: {html}"
704        );
705        assert!(html.contains("äöüß"), "umlauts verbatim");
706
707        // Sanitisation: no script tag survives; the text is escaped.
708        assert!(!html.contains("<script>"), "raw HTML must not pass through");
709        assert!(html.contains("&lt;script&gt;"), "escaped as visible text");
710
711        // External image degraded to a passive link; external links stay.
712        assert!(!html.contains("<img"), "no image element: {html}");
713        assert!(
714            html.contains("<a href=\"https://evil.example/x.png\">image: diagram (https://evil.example/x.png)</a>"),
715            "image degraded to labelled link: {html}"
716        );
717        assert!(html.contains("<a href=\"https://example.org/page\">docs</a>"));
718
719        // Wiki-links: in-mem → anchor; cross-mem → labelled, no anchor.
720        assert!(
721            html.contains("href=\"#specs--target-entity\""),
722            "in-doc anchor"
723        );
724        assert!(html.contains("other--far-away"), "cross-mem labelled");
725        assert!(
726            !html.contains("href=\"#other--far-away\""),
727            "cross-mem never an anchor"
728        );
729
730        // Umlaut-slug wiki-link: pulldown percent-encodes the href;
731        // the checker decodes, so this is the form that used to be
732        // untested.
733        assert!(html.contains("ber-ziele"), "umlaut target linked: {html}");
734
735        // User fragment link to nowhere: neutralised to text — never
736        // a dangling anchor.
737        assert!(
738            !html.contains("href=\"#bogus-frag\""),
739            "dangling fragment neutralised"
740        );
741        assert!(html.contains("(#bogus-frag — link removed)"), "{html}");
742
743        // javascript: scheme: never a clickable href — even on click,
744        // the handed-over file must not execute user script.
745        assert!(
746            !html.contains("href=\"javascript:"),
747            "javascript scheme stripped: {html}"
748        );
749        assert!(
750            html.contains("link removed"),
751            "neutralised destination surfaced"
752        );
753        assert_no_dangling_anchors(&html);
754
755        // Stub marking: the cross-mem auto-stub never lands in a
756        // folder mem without policy — but an in-mem stub does.
757        // (See the relationships list: targets that are stubs are
758        // marked; asserted in the wiki-link block above via absence.)
759
760        // Zero external resources: nothing in the markup fetches.
761        for fetching in [
762            "<img",
763            "<video",
764            "<audio",
765            "<iframe",
766            "<link ",
767            "<script src",
768            "@import",
769            "url(",
770        ] {
771            assert!(
772                !html.contains(fetching),
773                "self-containment violated by {fetching}"
774            );
775        }
776
777        // Determinism: same store + date → same bytes.
778        let again = engine.render_html_export("specs", "2026-08-10").unwrap();
779        assert_eq!(html, again, "byte-deterministic");
780
781        // Localized change: edit one entity; the untouched entity's
782        // section block stays byte-identical.
783        let untouched_block = {
784            let start = html.find("id=\"specs--bösenberg-söhne-rev-21\"").unwrap();
785            let end = html[start..].find("</section>").unwrap() + start;
786            html[start..end].to_string()
787        };
788        let (actor, client) = cli_actor();
789        let mut edit = crate::engine::UpdateEntityArgs {
790            anchors: Vec::new(),
791            id: crate::entity::EntityId::new("specs", "target-entity"),
792            expected_hash: None,
793            sections: indexmap::IndexMap::from_iter([(
794                "purpose".to_string(),
795                "Geändert.".to_string(),
796            )]),
797            append_sections: indexmap::IndexMap::new(),
798            patch_sections: indexmap::IndexMap::new(),
799            metadata: indexmap::IndexMap::new(),
800            metadata_unset: Vec::new(),
801            declare_relations: Vec::new(),
802            dry_run: false,
803            relations_unset: Vec::new(),
804            anchors_unset: Vec::new(),
805        };
806        let _ = &mut edit;
807        engine
808            .update_entity(edit, actor, Some(&client), None)
809            .expect("edit lands");
810        let after = engine.render_html_export("specs", "2026-08-10").unwrap();
811        assert_ne!(html, after, "edit changes the export");
812        assert!(
813            after.contains(&untouched_block),
814            "untouched entity's region byte-identical after the edit"
815        );
816        assert!(after.contains("Geändert."), "edited content present");
817    }
818
819    /// Criterion 5: a read-only mount exports with its trust class in
820    /// the identity block. Criterion 6's refusal parity: unknown mem
821    /// refuses UNKNOWN_MEM like the other formats.
822    #[test]
823    fn read_only_origin_stated_and_unknown_mem_refuses() {
824        let tmp = TempDir::new().unwrap();
825        let mem_dir = tmp.path().to_path_buf();
826        std::fs::write(
827            mem_dir.join("note.md"),
828            "---\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",
829        )
830        .unwrap();
831        let writer = FilesystemMemWriter::new(mem_dir.clone());
832        let mount = Mount {
833            mem: "foreign".to_string(),
834            schema: Some("default@1.0.0".parse().unwrap()),
835            storage: MountStorage::Folder { path: mem_dir },
836            capability: MountCapability::ReadOnly,
837            lifecycle: MountLifecycle::Eager,
838            cross_linkable: true,
839            migration_target: None,
840        };
841        let engine =
842            Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
843        let html = engine.render_html_export("foreign", "2026-08-10").unwrap();
844        assert!(
845            html.contains("third-party (read-only mount"),
846            "trust class stated: {html}"
847        );
848
849        let err = engine.render_html_export("nope", "2026-08-10").unwrap_err();
850        assert_eq!(err.code(), "UNKNOWN_MEM");
851    }
852}