Skip to main content

max / docengine

Pin the chat preset against mention resolution Mention highlighting in a room is client-side and in-room only, so render_chat must never turn @name into a profile link. That already held structurally, since resolve_mentions is a consumer step over markdown and not a stage of Renderer, but nothing tested it. Add the tripwire and say so in the docs.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-03 18:45 UTC
Signed with PGP, not checked
Commit: adb44e83950c1d20b3f41d8050263ec8957dc824
Parent: 324a392
4 files changed, +51 insertions, -0 deletions
M README.md +1
@@ -124,6 +124,7 @@
124 124 | Multithreaded | `mentions`, `quotes` | Strict (forum posts) |
125 125 | GoingsOn | core only | Standard (notes, descriptions) |
126 126 | Balanced Breakfast | core only | Sanitize-only (RSS feed content) |
127 + | livechat | core only | Chat (messages). Not `mentions`: highlighting is client-side, so `@name` stays plain text |
127 128
128 129 ## Security
129 130
@@ -87,6 +87,7 @@
87 87 | GoingsOn | core | Task/event descriptions (standard) |
88 88 | Balanced Breakfast | core | RSS feed content (sanitize_only) |
89 89 | audiofiles | core | Sample descriptions (standard) |
90 + | livechat | core | Chat messages (chat preset). Deliberately not mentions: highlighting a mention is client-side and in-room, so nothing resolves `@name` at render time |
90 91
91 92 ## Key Paths
92 93
M src/lib.rs +4
@@ -73,6 +73,10 @@
73 73
74 74 /// Render a chat message: inline only, no images, no raw HTML, links carry
75 75 /// `nofollow noopener`, and nothing is fetched.
76 + ///
77 + /// `@mentions` pass through as plain text. Resolving them to profile links is a
78 + /// consumer step (`resolve_mentions`, behind the `mentions` feature) and chat
79 + /// does not take it: a mention in a room is a client-side highlight, not a link.
76 80 pub fn render_chat(markdown: &str) -> String {
77 81 Renderer::chat().render(markdown)
78 82 }
@@ -147,6 +147,13 @@
147 147 /// make the server fetch an attacker-chosen address. That is the security
148 148 /// boundary the preset exists to hold, and `chat_render_path_cannot_fetch`
149 149 /// in this module pins it.
150 + ///
151 + /// One thing the preset deliberately does not do: resolve `@mentions`. In a
152 + /// room a mention is a client-side highlight for whoever is present, not a
153 + /// link, so `@name` comes out of here as the plain text it went in as.
154 + /// `resolve_mentions` (the `mentions` feature) stays a step a consumer
155 + /// applies to markdown by choice, never a stage of this renderer, and chat
156 + /// consumers do not apply it. `chat_leaves_mentions_as_plain_text` pins that.
150 157 pub fn chat() -> Self {
151 158 Self {
152 159 tables: false,
@@ -693,6 +700,44 @@
693 700 }
694 701 }
695 702
703 + #[test]
704 + fn chat_leaves_mentions_as_plain_text() {
705 + // Mention highlighting in a room is client-side and in-room only, so the
706 + // chat render path must never turn `@name` into a profile link. A link
707 + // is the wrong artifact twice over: it outlives the room, surviving into
708 + // every archive and quote that reuses the HTML, and it implies a
709 + // "which usernames exist" lookup at render time, on a hot path, for a
710 + // cosmetic effect the client already has the roster to produce.
711 + //
712 + // The invariant is structural today. `resolve_mentions` is a free
713 + // function a consumer runs over markdown before rendering, not a stage
714 + // of `Renderer`, so `chat()` has no path to it. This is the tripwire for
715 + // the day someone wires one into the other.
716 + let r = Renderer::chat();
717 +
718 + let html = r.render("hi @alice, see [docs](https://example.com)");
719 + assert!(
720 + html.contains("@alice"),
721 + "the mention text survives verbatim: {html}"
722 + );
723 + assert!(
724 + !html.contains(">@alice</a>"),
725 + "the mention must not become an anchor: {html}"
726 + );
727 + assert_eq!(
728 + html.matches("<a ").count(),
729 + 1,
730 + "exactly one anchor, the one the author wrote: {html}"
731 + );
732 +
733 + // A message that is nothing but a mention renders to no markup at all
734 + // beyond the text, which is the case a resolution step would break most
735 + // visibly.
736 + let html = r.render("@bob");
737 + assert!(!html.contains("<a"), "no anchor: {html}");
738 + assert!(html.contains("@bob"), "text survives: {html}");
739 + }
740 +
696 741 #[test]
697 742 fn chat_flattens_block_structure() {
698 743 let r = Renderer::chat();