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        self.render_html_export_scoped(mem, export_date, None)
227    }
228
229    /// [`Self::render_html_export`] reduced to a chain: only the mem's
230    /// entities in `chain` are rendered (stubs in the chain listed as
231    /// such), links to entities outside it render as unresolved markers,
232    /// and the identity block names the chain. `None` is the whole mem,
233    /// byte-identical to the unscoped export.
234    pub fn render_html_export_scoped(
235        &self,
236        mem: &str,
237        export_date: &str,
238        chain: Option<&crate::graph::chain::ChainSet>,
239    ) -> Result<String, crate::engine::EngineError> {
240        let in_scope = |e: &Entity| chain.is_none_or(|c| c.contains(&e.id));
241        let mounted = self
242            .mounts
243            .iter()
244            .find(|m| m.mount.mem == mem)
245            .ok_or_else(|| crate::engine::EngineError::UnknownMem(mem.to_string()))?;
246        let third_party = mounted.mount.capability == MountCapability::ReadOnly;
247        let config = self.mem_config_for(mem);
248        // The mem's schema, for the declared section headings below. A
249        // mem whose schema did not resolve still exports — every
250        // section falls back to its key rather than the export failing.
251        let schema = self.schemas.get(mem);
252        let schema_ref = self
253            .schemas
254            .get(mem)
255            .map(|s| {
256                let (n, v) = s.id();
257                format!("{n}@{v}")
258            })
259            .unwrap_or_else(|| "(unresolved)".to_string());
260
261        // Entities of this mem, deterministic order: (type, id).
262        // Stubs are collected separately and rendered marked.
263        let mut entities: Vec<&Entity> = self
264            .store
265            .all_entities()
266            .filter(|e| e.mem == mem && !e.stub && in_scope(e))
267            .collect();
268        entities.sort_by(|a, b| {
269            a.entity_type
270                .cmp(&b.entity_type)
271                .then_with(|| a.id.as_ref().cmp(b.id.as_ref()))
272        });
273        let mut stubs: Vec<&Entity> = self
274            .store
275            .all_entities()
276            .filter(|e| e.mem == mem && e.stub && in_scope(e))
277            .collect();
278        stubs.sort_by(|a, b| a.id.as_ref().cmp(b.id.as_ref()));
279        let exported_ids: Vec<String> = entities.iter().map(|e| e.id.to_string()).collect();
280
281        let mut out = String::new();
282        out.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n");
283        out.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
284        let doc_title = config
285            .and_then(|c| c.title.clone())
286            .unwrap_or_else(|| mem.to_string());
287        let _ = writeln!(out, "<title>{}</title>", esc(&doc_title));
288        out.push_str(
289            "<style>\n\
290             body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;\
291             max-width:52rem;margin:0 auto;padding:2rem 1rem;line-height:1.55;color:#1a1a1a;}\n\
292             h1{border-bottom:2px solid #ddd;padding-bottom:.3rem;}\n\
293             section.entity{border-top:1px solid #ddd;margin-top:2rem;padding-top:1rem;}\n\
294             table.meta{border-collapse:collapse;font-size:.9rem;margin:.5rem 0;}\n\
295             table.meta td{border:1px solid #ddd;padding:.15rem .5rem;}\n\
296             table.meta td:first-child{color:#555;}\n\
297             nav ul{columns:2;list-style:none;padding-left:0;}\n\
298             nav li{margin:.15rem 0;}\n\
299             .identity{background:#f6f6f6;border:1px solid #ddd;padding:.75rem 1rem;\
300             border-radius:4px;font-size:.95rem;}\n\
301             .badge{display:inline-block;background:#eee;border-radius:3px;\
302             padding:0 .4rem;font-size:.8rem;color:#555;}\n\
303             .stub{color:#888;font-style:italic;}\n\
304             .reltable{font-size:.9rem;}\n\
305             @media print{nav ul{columns:1;}}\n\
306             </style>\n</head>\n<body>\n",
307        );
308
309        // Identity block.
310        let _ = write!(
311            out,
312            "<h1>{}</h1>\n<div class=\"identity\">\n",
313            esc(&doc_title)
314        );
315        let _ = writeln!(out, "<div><strong>Mem:</strong> {}</div>", esc(mem));
316        if let Some(desc) = config.and_then(|c| c.description.as_deref())
317            && !desc.is_empty()
318        {
319            let _ = writeln!(
320                out,
321                "<div><strong>Description:</strong> {}</div>",
322                esc(desc)
323            );
324        }
325        if let Some(subject) = config.and_then(|c| c.subject.as_ref()) {
326            let _ = writeln!(
327                out,
328                "<div><strong>Subject:</strong> {}</div>",
329                esc(&subject.scope)
330            );
331        }
332        let _ = writeln!(
333            out,
334            "<div><strong>Schema:</strong> {}</div>",
335            esc(&schema_ref)
336        );
337        let trust = if third_party {
338            "third-party (read-only mount — someone else's published content, quoted here)"
339        } else {
340            "first-party (writable mem of this workspace)"
341        };
342        let _ = writeln!(out, "<div><strong>Origin:</strong> {trust}</div>");
343        if let Some(chain) = chain {
344            let _ = writeln!(
345                out,
346                "<div><strong>Chain:</strong> {} — only the entities reachable from the root are \
347                 rendered; links to entities outside the chain are marked unresolved</div>",
348                esc(&chain.describe())
349            );
350        }
351        let _ = write!(
352            out,
353            "<div><strong>Exported:</strong> {} · {} entities</div>\n</div>\n",
354            esc(export_date),
355            entities.len()
356        );
357
358        // Type-grouped navigation index.
359        let mut by_type: BTreeMap<&str, Vec<&Entity>> = BTreeMap::new();
360        for e in &entities {
361            by_type.entry(e.entity_type.as_str()).or_default().push(e);
362        }
363        out.push_str("<nav>\n<h2>Index</h2>\n");
364        for (ty, list) in &by_type {
365            let _ = write!(out, "<h3>{} ({})</h3>\n<ul>\n", esc(ty), list.len());
366            for e in list {
367                let _ = writeln!(
368                    out,
369                    "<li><a href=\"#{}\">{}</a></li>",
370                    esc(e.id.as_ref()),
371                    esc(&e.title)
372                );
373            }
374            out.push_str("</ul>\n");
375        }
376        out.push_str("</nav>\n");
377
378        // Entities.
379        for e in &entities {
380            let _ = write!(
381                out,
382                "<section class=\"entity\" id=\"{}\">\n<h2>{}</h2>\n<span class=\"badge\">{}</span> <span class=\"badge\">{}</span>\n",
383                esc(e.id.as_ref()),
384                esc(&e.title),
385                esc(&e.entity_type),
386                esc(e.id.as_ref()),
387            );
388            if !e.metadata.is_empty() {
389                out.push_str("<table class=\"meta\">\n");
390                for (k, v) in &e.metadata {
391                    let _ = writeln!(
392                        out,
393                        "<tr><td>{}</td><td>{}</td></tr>",
394                        esc(k),
395                        esc(&v.to_frontmatter_string())
396                    );
397                }
398                out.push_str("</table>\n");
399            }
400            for (key, body) in &e.sections {
401                if body.trim().is_empty() {
402                    continue;
403                }
404                // Show the heading the schema author declared, not the
405                // engine's storage key. This export is the one artifact
406                // handed to somebody with nothing installed, and the
407                // declared heading is the only place an author gets to
408                // control how their model reads to an outsider —
409                // rendering `summary` where they wrote `Summary` was
410                // the export path reaching for the field nearest to
411                // hand, never a decision.
412                //
413                // The key still governs identity elsewhere (anchors are
414                // derived from entity ids, and stay untouched), so
415                // display and stability stay separable.
416                let heading = schema
417                    .and_then(|s| s.get_type(&e.entity_type))
418                    .and_then(|t| {
419                        t.sections
420                            .iter()
421                            .find(|s| &s.key == key)
422                            .map(|s| s.heading.clone())
423                    })
424                    .unwrap_or_else(|| key.clone());
425                let _ = writeln!(out, "<h3>{}</h3>", esc(&heading));
426                let resolved = resolve_wiki_links(body, mem, &exported_ids);
427                out.push_str(&markdown_to_safe_html(&resolved, &exported_ids));
428            }
429            if !e.relationships.is_empty() {
430                // `Relationships` as the engine writes it on disk, not the
431                // lowercase slot name. This block is auto-managed rather
432                // than schema-declared, so it has no `heading` to read —
433                // but it sits beside headings that now carry the author's
434                // words, and was the last storage-flavoured one left.
435                out.push_str("<h3>Relationships</h3>\n<ul class=\"reltable\">\n");
436                let mut rels = e.relationships.clone();
437                rels.sort_by(|a, b| {
438                    a.rel_type
439                        .cmp(&b.rel_type)
440                        .then_with(|| a.target.as_ref().cmp(b.target.as_ref()))
441                });
442                for r in &rels {
443                    let target_id = r.target.to_string();
444                    let in_doc = exported_ids.iter().any(|id| id == &target_id);
445                    let is_stub_target = self.store.get(&r.target).map(|t| t.stub).unwrap_or(false);
446                    if in_doc {
447                        let _ = writeln!(
448                            out,
449                            "<li>{} → <a href=\"#{}\">{}</a></li>",
450                            esc(&r.rel_type),
451                            esc(&target_id),
452                            esc(&target_id)
453                        );
454                    } else if is_stub_target {
455                        let _ = writeln!(
456                            out,
457                            "<li>{} → <span class=\"stub\">{} (stub — unresolved reference)</span></li>",
458                            esc(&r.rel_type),
459                            esc(&target_id)
460                        );
461                    } else {
462                        let _ = writeln!(
463                            out,
464                            "<li>{} → {} <span class=\"badge\">other mem</span></li>",
465                            esc(&r.rel_type),
466                            esc(&target_id)
467                        );
468                    }
469                }
470                out.push_str("</ul>\n");
471            }
472            out.push_str("</section>\n");
473        }
474
475        if !stubs.is_empty() {
476            out.push_str(
477                "<section class=\"entity\">\n<h2>Unresolved references (stubs)</h2>\n<ul>\n",
478            );
479            for s in &stubs {
480                let _ = writeln!(
481                    out,
482                    "<li class=\"stub\">{} — referenced but never written</li>",
483                    esc(s.id.as_ref())
484                );
485            }
486            out.push_str("</ul>\n</section>\n");
487        }
488
489        out.push_str("</body>\n</html>\n");
490        Ok(out)
491    }
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497    use tempfile::TempDir;
498
499    use crate::backend::MemBackend;
500    use crate::engine::test_helpers::{cli_actor, folder_mount};
501    use crate::storage::FilesystemMemWriter;
502    use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
503
504    /// The export scan uses the one definition of "not a link": a
505    /// `[[…]]` inside a code block or inline span is a code sample, not
506    /// a reference, and an export is the surface most likely to carry
507    /// one — a page documenting wiki-link syntax. Rewriting it
508    /// corrupts the sample it is trying to show.
509    #[test]
510    fn wiki_link_resolution_leaves_code_verbatim() {
511        let exported = vec!["m--real".to_string()];
512        for body in [
513            "```\n[[m--real]]\n```",
514            "~~~\n[[m--real]]\n~~~",
515            "    [[m--real]]",
516            "> ```\n> [[m--real]]\n> ```",
517            "An inline `[[m--real]]` sample.",
518            "A double ``[[m--real]]`` sample.",
519        ] {
520            assert_eq!(
521                resolve_wiki_links(body, "m", &exported),
522                body,
523                "code content must survive byte-identical: {body:?}"
524            );
525        }
526    }
527
528    /// …and a dangling target inside code is not marked either — the
529    /// `*(unresolved)*` suffix is just as much a corruption.
530    #[test]
531    fn wiki_link_resolution_does_not_mark_code_as_unresolved() {
532        let body = "```\n[[m--ghost]]\n```\n\n    [[m--other-ghost]]\n";
533        assert_eq!(resolve_wiki_links(body, "m", &[]), body);
534    }
535
536    /// Complement: prose links on either side of a code block still
537    /// resolve exactly as before — anchor, unresolved marker, and
538    /// cross-mem label alike.
539    #[test]
540    fn wiki_link_resolution_still_rewrites_prose() {
541        let exported = vec!["m--real".to_string()];
542        let body =
543            "See [[m--real]].\n\n```\n[[m--real]]\n```\n\nAnd [[m--ghost]] and [[other--thing]].\n";
544        let out = resolve_wiki_links(body, "m", &exported);
545        assert!(out.contains("[m--real](#m--real)"), "{out}");
546        assert!(out.contains("m--ghost *(unresolved)*"), "{out}");
547        assert!(out.contains("other--thing *(other mem)*"), "{out}");
548        assert!(
549            out.contains("```\n[[m--real]]\n```"),
550            "code untouched: {out}"
551        );
552    }
553
554    /// An unterminated `[[` still passes through verbatim rather than
555    /// truncating the body — the offset walk must not lose the tail.
556    #[test]
557    fn wiki_link_resolution_passes_through_an_unterminated_open() {
558        let body = "text [[not-closed and more text after\n";
559        assert_eq!(resolve_wiki_links(body, "m", &[]), body);
560    }
561
562    /// Pre-boot on-disk fixture: the renderer is a read surface, so
563    /// the fixture is written as existing markdown (including a
564    /// cross-mem wiki-link and hostile content that the write path
565    /// would gate) and the engine boots over it.
566    fn fixture_engine(tmp: &TempDir) -> Engine {
567        let mem_dir = tmp.path().to_path_buf();
568        std::fs::write(
569            mem_dir.join("bösenberg-söhne-rev-21.md"),
570            "---\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",
571        )
572        .unwrap();
573        std::fs::write(
574            mem_dir.join("über-ziele.md"),
575            "---\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",
576        )
577        .unwrap();
578        std::fs::write(
579            mem_dir.join("target-entity.md"),
580            "---\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",
581        )
582        .unwrap();
583        let writer = FilesystemMemWriter::new(mem_dir.clone());
584        Engine::from_mounts(vec![(
585            folder_mount("specs", mem_dir),
586            Box::new(writer) as Box<dyn MemBackend>,
587        )])
588        .unwrap()
589    }
590
591    /// Every `href="#..."` in the document must point at an existing
592    /// `id="..."` — the no-dangling-anchor complement, mechanical.
593    fn assert_no_dangling_anchors(html: &str) {
594        let mut ids: Vec<&str> = Vec::new();
595        for part in html.split("id=\"").skip(1) {
596            if let Some(end) = part.find('"') {
597                ids.push(&part[..end]);
598            }
599        }
600        for part in html.split("href=\"#").skip(1) {
601            if let Some(end) = part.find('"') {
602                let anchor = percent_decode(&part[..end]);
603                assert!(
604                    ids.iter().any(|id| *id == anchor),
605                    "dangling in-document anchor #{anchor}"
606                );
607            }
608        }
609    }
610
611    /// The export shows the heading the schema author declared, not
612    /// the engine's storage key.
613    ///
614    /// The load-bearing fixture is `out_of_scope` → `Out of Scope`: the
615    /// interior word stays lowercase, so no capitalisation rule
616    /// reconstructs it from the key. (`current_state` → `Current State`
617    /// is also asserted, but title-casing the key would produce it, so
618    /// on its own it would not have caught a renderer that guessed.)
619    /// That is the point of the finding: the declared heading is the
620    /// only place an author controls how their model reads to someone
621    /// who cannot see the markdown, and guessing is not reading.
622    ///
623    /// Anchors are unaffected: they derive from entity ids, and the
624    /// no-dangling-anchor sweep runs here too.
625    #[test]
626    fn html_export_renders_the_declared_heading_not_the_section_key() {
627        let tmp = TempDir::new().unwrap();
628        let mem_dir = tmp.path().to_path_buf();
629        std::fs::write(
630            mem_dir.join("open-question.md"),
631            "---\ntype: inquiry\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\n\
632             status: open\nurgency: medium\n---\n# Open Question\n\n## Question\n\nQ?\n\n\
633             ## Significance\n\nS.\n\n## Current State\n\nWhere things stand.\n",
634        )
635        .unwrap();
636        let writer = FilesystemMemWriter::new(mem_dir.clone());
637        let engine = Engine::from_mounts(vec![(
638            folder_mount("specs", mem_dir),
639            Box::new(writer) as Box<dyn MemBackend>,
640        )])
641        .unwrap();
642
643        let html = engine.render_html_export("specs", "2026-08-15").unwrap();
644
645        assert!(
646            html.contains("<h3>Current State</h3>"),
647            "must render the declared heading; got:\n{html}"
648        );
649        assert!(
650            !html.contains("<h3>current_state</h3>"),
651            "must not render the storage key as a heading; got:\n{html}"
652        );
653        // The capitalised-only cases come along for free.
654        assert!(html.contains("<h3>Question</h3>"), "got:\n{html}");
655        assert!(html.contains("<h3>Significance</h3>"), "got:\n{html}");
656        assert_no_dangling_anchors(&html);
657
658        // The case a capitalisation rule cannot fake: `out_of_scope` is
659        // declared `Out of Scope`, interior word lowercase. A renderer
660        // that title-cased the key would emit "Out Of Scope" and fail
661        // here — which is what makes this the load-bearing assertion.
662        let tmp2 = TempDir::new().unwrap();
663        let goal_dir = tmp2.path().to_path_buf();
664        std::fs::write(
665            goal_dir.join("second-goal.md"),
666            "---\ntype: goal\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\n\
667             priority: high\nstatus: active\n---\n# Second Goal\n\n## Statement\n\nS.\n\n\
668             ## Rationale\n\nR.\n\n## Success Criteria\n\nC.\n\n## Out of Scope\n\n\
669             Everything else.\n",
670        )
671        .unwrap();
672        let goal_writer = FilesystemMemWriter::new(goal_dir.clone());
673        let goal_mount = Mount {
674            mem: "plans".to_string(),
675            schema: Some(memstead_schema::SchemaRef::new(
676                "planning",
677                semver::Version::new(0, 4, 0),
678            )),
679            storage: MountStorage::Folder { path: goal_dir },
680            capability: MountCapability::Write,
681            lifecycle: MountLifecycle::Eager,
682            cross_linkable: true,
683            migration_target: None,
684        };
685        let goal_engine = Engine::from_mounts(vec![(
686            goal_mount,
687            Box::new(goal_writer) as Box<dyn MemBackend>,
688        )])
689        .unwrap();
690        let goal_html = goal_engine
691            .render_html_export("plans", "2026-08-15")
692            .unwrap();
693        assert!(
694            goal_html.contains("<h3>Out of Scope</h3>"),
695            "the interior word must stay lowercase, as declared; got:\n{goal_html}"
696        );
697        assert!(
698            !goal_html.contains("<h3>Out Of Scope</h3>")
699                && !goal_html.contains("<h3>out_of_scope</h3>"),
700            "neither a title-cased guess nor the storage key; got:\n{goal_html}"
701        );
702
703        // Byte-deterministic given store and export date.
704        let again = engine.render_html_export("specs", "2026-08-15").unwrap();
705        assert_eq!(html, again, "export must be byte-deterministic");
706    }
707
708    /// Criteria 1–4 over one fixture: rendering, sanitisation,
709    /// resource degradation, anchors, determinism, localized change.
710    #[test]
711    fn html_export_renders_sanitises_and_stays_self_contained() {
712        let tmp = TempDir::new().unwrap();
713        let mut engine = fixture_engine(&tmp);
714
715        let html = engine.render_html_export("specs", "2026-08-10").unwrap();
716
717        // Identity block + index + entities.
718        assert!(
719            html.contains("<strong>Mem:</strong> specs"),
720            "identity block"
721        );
722        assert!(html.contains("<strong>Exported:</strong> 2026-08-10"));
723        assert!(html.contains("<nav>"), "type-grouped index");
724        assert!(
725            html.contains("Bösenberg &amp; Söhne — Rev. 2.1"),
726            "widened title escaped: {html}"
727        );
728        assert!(html.contains("äöüß"), "umlauts verbatim");
729
730        // Sanitisation: no script tag survives; the text is escaped.
731        assert!(!html.contains("<script>"), "raw HTML must not pass through");
732        assert!(html.contains("&lt;script&gt;"), "escaped as visible text");
733
734        // External image degraded to a passive link; external links stay.
735        assert!(!html.contains("<img"), "no image element: {html}");
736        assert!(
737            html.contains("<a href=\"https://evil.example/x.png\">image: diagram (https://evil.example/x.png)</a>"),
738            "image degraded to labelled link: {html}"
739        );
740        assert!(html.contains("<a href=\"https://example.org/page\">docs</a>"));
741
742        // Wiki-links: in-mem → anchor; cross-mem → labelled, no anchor.
743        assert!(
744            html.contains("href=\"#specs--target-entity\""),
745            "in-doc anchor"
746        );
747        assert!(html.contains("other--far-away"), "cross-mem labelled");
748        assert!(
749            !html.contains("href=\"#other--far-away\""),
750            "cross-mem never an anchor"
751        );
752
753        // Umlaut-slug wiki-link: pulldown percent-encodes the href;
754        // the checker decodes, so this is the form that used to be
755        // untested.
756        assert!(html.contains("ber-ziele"), "umlaut target linked: {html}");
757
758        // User fragment link to nowhere: neutralised to text — never
759        // a dangling anchor.
760        assert!(
761            !html.contains("href=\"#bogus-frag\""),
762            "dangling fragment neutralised"
763        );
764        assert!(html.contains("(#bogus-frag — link removed)"), "{html}");
765
766        // javascript: scheme: never a clickable href — even on click,
767        // the handed-over file must not execute user script.
768        assert!(
769            !html.contains("href=\"javascript:"),
770            "javascript scheme stripped: {html}"
771        );
772        assert!(
773            html.contains("link removed"),
774            "neutralised destination surfaced"
775        );
776        assert_no_dangling_anchors(&html);
777
778        // Stub marking: the cross-mem auto-stub never lands in a
779        // folder mem without policy — but an in-mem stub does.
780        // (See the relationships list: targets that are stubs are
781        // marked; asserted in the wiki-link block above via absence.)
782
783        // Zero external resources: nothing in the markup fetches.
784        for fetching in [
785            "<img",
786            "<video",
787            "<audio",
788            "<iframe",
789            "<link ",
790            "<script src",
791            "@import",
792            "url(",
793        ] {
794            assert!(
795                !html.contains(fetching),
796                "self-containment violated by {fetching}"
797            );
798        }
799
800        // Determinism: same store + date → same bytes.
801        let again = engine.render_html_export("specs", "2026-08-10").unwrap();
802        assert_eq!(html, again, "byte-deterministic");
803
804        // Localized change: edit one entity; the untouched entity's
805        // section block stays byte-identical.
806        let untouched_block = {
807            let start = html.find("id=\"specs--bösenberg-söhne-rev-21\"").unwrap();
808            let end = html[start..].find("</section>").unwrap() + start;
809            html[start..end].to_string()
810        };
811        let (actor, client) = cli_actor();
812        let mut edit = crate::engine::UpdateEntityArgs {
813            anchors: Vec::new(),
814            id: crate::entity::EntityId::new("specs", "target-entity"),
815            expected_hash: None,
816            sections: indexmap::IndexMap::from_iter([(
817                "purpose".to_string(),
818                "Geändert.".to_string(),
819            )]),
820            append_sections: indexmap::IndexMap::new(),
821            patch_sections: indexmap::IndexMap::new(),
822            sections_unset: Vec::new(),
823            metadata: indexmap::IndexMap::new(),
824            metadata_unset: Vec::new(),
825            declare_relations: Vec::new(),
826            dry_run: false,
827            relations_unset: Vec::new(),
828            anchors_unset: Vec::new(),
829        };
830        let _ = &mut edit;
831        engine
832            .update_entity(edit, actor, Some(&client), None)
833            .expect("edit lands");
834        let after = engine.render_html_export("specs", "2026-08-10").unwrap();
835        assert_ne!(html, after, "edit changes the export");
836        assert!(
837            after.contains(&untouched_block),
838            "untouched entity's region byte-identical after the edit"
839        );
840        assert!(after.contains("Geändert."), "edited content present");
841    }
842
843    /// Criterion 5: a read-only mount exports with its trust class in
844    /// the identity block. Criterion 6's refusal parity: unknown mem
845    /// refuses UNKNOWN_MEM like the other formats.
846    #[test]
847    fn read_only_origin_stated_and_unknown_mem_refuses() {
848        let tmp = TempDir::new().unwrap();
849        let mem_dir = tmp.path().to_path_buf();
850        std::fs::write(
851            mem_dir.join("note.md"),
852            "---\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",
853        )
854        .unwrap();
855        let writer = FilesystemMemWriter::new(mem_dir.clone());
856        let mount = Mount {
857            mem: "foreign".to_string(),
858            schema: Some("default@1.0.0".parse().unwrap()),
859            storage: MountStorage::Folder { path: mem_dir },
860            capability: MountCapability::ReadOnly,
861            lifecycle: MountLifecycle::Eager,
862            cross_linkable: true,
863            migration_target: None,
864        };
865        let engine =
866            Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
867        let html = engine.render_html_export("foreign", "2026-08-10").unwrap();
868        assert!(
869            html.contains("third-party (read-only mount"),
870            "trust class stated: {html}"
871        );
872
873        let err = engine.render_html_export("nope", "2026-08-10").unwrap_err();
874        assert_eq!(err.code(), "UNKNOWN_MEM");
875    }
876}