| 1 | diff --git a/docs/rooms.md b/docs/rooms.md |
| 2 | index 8a5cce6..1095a68 100644 |
| 3 | --- a/docs/rooms.md |
| 4 | +++ b/docs/rooms.md |
| 5 | @@ -59,7 +59,7 @@ Room events reach device notifications and relay push callbacks the same way as |
| 6 | |
| 7 | **Rooms** in the relay navigation lists the rooms you can see with their access rule, member count and last message. Signed-in members create a room at the bottom of the list: the name becomes the room id, lowercased with punctuation replaced by hyphens, unless the **Id** field names one, and the room opens once the relay accepts it. Give the id yourself when a client expects a particular shape, such as a Buzz gateway that wants a UUID. |
| 8 | |
| 9 | -Open a room to read its messages, oldest first. Each message shows the author's key, their room role and the time; messages from agents carry an agent marker. Links open in place and `nostr:` links resolve through the relay. A thread shows how many replies it has and opens on its own page, where replies read in order. Reactions appear under the message they answer, and an edited message shows its newest text with an edited marker. Choose **load earlier** for older messages. |
| 10 | +Open a room to read its messages, oldest first. Each message shows the author's name, their room role and the time; messages from agents carry an agent marker. Message text renders the common Markdown subset that people and agents type: paragraphs with line breaks, bold and italic, inline code and fenced code, lists, headings and links, and bare `https://` or `nostr:` references become links. Nothing else in a message is treated as markup. Links open in place and `nostr:` links resolve through the relay. A thread shows how many replies it has and opens on its own page, where replies read in order. Reactions appear under the message they answer, and an edited message shows its newest text with an edited marker. Choose **load earlier** for older messages. |
| 11 | |
| 12 | The compose bar sits at the bottom of the column. Enter sends and Shift+Enter starts a new line. Mention a person with `@npub...` or `@<hex key>`; the relay notifies them. Sending, creating a room and every room action need JavaScript and a connected signer; without JavaScript the page still shows the newest messages. |
| 13 | |
| 14 | diff --git a/internal/webui/components.js b/internal/webui/components.js |
| 15 | index 55c806c..49d7d74 100644 |
| 16 | --- a/internal/webui/components.js |
| 17 | +++ b/internal/webui/components.js |
| 18 | @@ -616,6 +616,74 @@ |
| 19 | node.append(text.slice(last)); |
| 20 | return node; |
| 21 | }; |
| 22 | + // chatMarkdown renders the same Markdown subset the page renders for chat |
| 23 | + // messages: paragraphs with line breaks, fenced code, headings, lists, |
| 24 | + // inline code, emphasis, links, and bare http(s) or nostr: references. It |
| 25 | + // builds nodes rather than markup, so message text never becomes HTML. |
| 26 | + const inlinePattern = /(`[^`]+`)|\[([^\]]+)\]\(([^)\s]+)\)|\*\*([^*]+)\*\*|(^|[^*\w])\*([^*]+)\*|(https?:\/\/[^\s<>"']+|(?:web\+)?nostr:[a-z0-9]+)/g; |
| 27 | + const safeLink = target => /^(https?:\/\/|\/(?!\/)|\.\.?\/|#|mailto:)/.test(target) || (!target.includes(":") && !target.startsWith("//")); |
| 28 | + const chatInline = (node, text) => { |
| 29 | + let last = 0; |
| 30 | + for (const match of String(text).matchAll(inlinePattern)) { |
| 31 | + node.append(text.slice(last, match.index)); |
| 32 | + last = match.index + match[0].length; |
| 33 | + if (match[1]) { node.append(el("code", match[1].slice(1, -1))); continue; } |
| 34 | + if (match[2] !== undefined) { |
| 35 | + if (!safeLink(match[3])) { node.append(match[0]); continue; } |
| 36 | + const link = el("a", match[2]); |
| 37 | + link.href = match[3]; |
| 38 | + if (match[3].startsWith("http")) link.rel = "noopener"; |
| 39 | + node.append(link); |
| 40 | + continue; |
| 41 | + } |
| 42 | + if (match[4] !== undefined) { node.append(chatInline(el("strong"), match[4])); continue; } |
| 43 | + if (match[6] !== undefined) { node.append(match[5], chatInline(el("em"), match[6])); continue; } |
| 44 | + const trimmed = match[7].replace(/[.,;:!?)]+$/, ""); |
| 45 | + const link = el("a", trimmed); |
| 46 | + link.href = trimmed.startsWith("http") ? trimmed : tiny.localPath("/open?target=" + encodeURIComponent(trimmed)); |
| 47 | + if (trimmed.startsWith("http")) link.rel = "noopener"; |
| 48 | + node.append(link, match[7].slice(trimmed.length)); |
| 49 | + } |
| 50 | + node.append(text.slice(last)); |
| 51 | + return node; |
| 52 | + }; |
| 53 | + const listItem = line => /^\s*(?:[-*+]|\d+[.)])\s+/.test(line); |
| 54 | + const chatMarkdown = (node, text) => { |
| 55 | + const lines = String(text).replace(/\r\n/g, "\n").split("\n"); |
| 56 | + let paragraph = []; |
| 57 | + const flush = () => { |
| 58 | + if (!paragraph.length) return; |
| 59 | + const p = el("p"); |
| 60 | + paragraph.forEach((line, index) => { if (index) p.append(el("br")); chatInline(p, line); }); |
| 61 | + node.append(p); |
| 62 | + paragraph = []; |
| 63 | + }; |
| 64 | + for (let i = 0; i < lines.length; i++) { |
| 65 | + const line = lines[i]; |
| 66 | + if (line.startsWith("```")) { |
| 67 | + flush(); |
| 68 | + const code = []; |
| 69 | + for (i++; i < lines.length && !lines[i].startsWith("```"); i++) code.push(lines[i]); |
| 70 | + const pre = el("pre"); pre.append(el("code", code.join("\n"))); node.append(pre); |
| 71 | + } else if (line.startsWith("#")) { |
| 72 | + flush(); |
| 73 | + const level = Math.min(6, line.length - line.replace(/^#+/, "").length); |
| 74 | + node.append(chatInline(el("h" + level), line.slice(level).trim())); |
| 75 | + } else if (listItem(line)) { |
| 76 | + flush(); |
| 77 | + const list = el(/^\s*\d/.test(line) ? "ol" : "ul"); |
| 78 | + for (; i < lines.length && listItem(lines[i]); i++) list.append(chatInline(el("li"), lines[i].replace(/^\s*(?:[-*+]|\d+[.)])\s+/, ""))); |
| 79 | + i--; |
| 80 | + node.append(list); |
| 81 | + } else if (!line.trim()) { |
| 82 | + flush(); |
| 83 | + } else { |
| 84 | + paragraph.push(line.trim()); |
| 85 | + } |
| 86 | + } |
| 87 | + flush(); |
| 88 | + return node; |
| 89 | + }; |
| 90 | const keyNode = hex => { const node = el("nostr-key", hex.slice(0, 12)); node.setAttribute("hex", hex); node.title = hex; return node; }; |
| 91 | // nameNode shows a person: the vendored nostr-name element replaces the short |
| 92 | // id with the profile name published on this relay. |
| 93 | @@ -646,7 +714,7 @@ |
| 94 | time.title = time.dateTime.slice(0, 16).replace("T", " ") + " UTC"; |
| 95 | small.append(time); |
| 96 | header.append(name, member?.role ? " | " + member.role : "", small); |
| 97 | - const body = notice ? el("p", notice) : linkify(el("p"), event.content || ""); |
| 98 | + const body = notice ? el("p", notice) : chatMarkdown(el("div"), event.content || ""); |
| 99 | node.append(header, body); |
| 100 | const footer = el("footer"); |
| 101 | const mentions = notice ? [] : (event.tags || []).filter(tag => tag[0] === "p" && isHex64(tag[1]) && tag[1] !== pubkey).map(tag => tag[1]); |
| 102 | @@ -705,9 +773,9 @@ |
| 103 | const targets = (event.tags || []).filter(tag => tag[0] === "e"); |
| 104 | const target = targets.length && document.getElementById("msg-" + targets[targets.length - 1][1]); |
| 105 | if (!target || target.dataset.pubkey !== event.pubkey || target.dataset.notice !== undefined) return; |
| 106 | - const body = target.querySelector(":scope > p"); |
| 107 | + const body = target.querySelector(":scope > div"); |
| 108 | if (!body) return; |
| 109 | - body.replaceWith(linkify(el("p"), event.content || "")); |
| 110 | + body.replaceWith(chatMarkdown(el("div"), event.content || "")); |
| 111 | if (target.dataset.edited !== undefined) return; |
| 112 | target.dataset.edited = ""; |
| 113 | let footer = target.querySelector(":scope > footer"); |
| 114 | @@ -884,7 +952,7 @@ |
| 115 | roomAppend(event, {room: this.getAttribute("room"), inThread: Boolean(root)}); |
| 116 | } |
| 117 | } |
| 118 | - tiny.rooms = Object.freeze({keyHex, roomMentions, roomID, messageNode, roomAppend, roomReact, roomEdit, linkify}); |
| 119 | + tiny.rooms = Object.freeze({keyHex, roomMentions, roomID, messageNode, roomAppend, roomReact, roomEdit, linkify, chatMarkdown}); |
| 120 | |
| 121 | // JsonView renders any JSON value. Arrays of objects become tables, objects |
| 122 | // become definition lists, scalar arrays become lists, and deep nesting |
| 123 | diff --git a/internal/webui/markdown.go b/internal/webui/markdown.go |
| 124 | index c2f3046..0d928f9 100644 |
| 125 | --- a/internal/webui/markdown.go |
| 126 | +++ b/internal/webui/markdown.go |
| 127 | @@ -15,6 +15,17 @@ import ( |
| 128 | // the tags this function emits reach the page, so untrusted README files and |
| 129 | // relay descriptions cannot inject markup. Anything else stays plain text. |
| 130 | func renderMarkdown(source string) template.HTML { |
| 131 | + return markdownHTML(source, false) |
| 132 | +} |
| 133 | + |
| 134 | +// renderChatMarkdown is the same subset for chat messages, where a single |
| 135 | +// newline is a line break and bare http(s) and nostr: references become |
| 136 | +// links, as people and agents type them. |
| 137 | +func renderChatMarkdown(source string) template.HTML { |
| 138 | + return template.HTML(autolinkOutsideTags(string(markdownHTML(source, true)))) |
| 139 | +} |
| 140 | + |
| 141 | +func markdownHTML(source string, breaks bool) template.HTML { |
| 142 | lines := strings.Split(strings.ReplaceAll(source, "\r\n", "\n"), "\n") |
| 143 | var out strings.Builder |
| 144 | var paragraph []string |
| 145 | @@ -22,7 +33,15 @@ func renderMarkdown(source string) template.HTML { |
| 146 | if len(paragraph) == 0 { |
| 147 | return |
| 148 | } |
| 149 | - out.WriteString("<p>" + inlineMarkdown(strings.Join(paragraph, " ")) + "</p>\n") |
| 150 | + joiner := " " |
| 151 | + if breaks { |
| 152 | + joiner = "<br>" |
| 153 | + } |
| 154 | + parts := make([]string, len(paragraph)) |
| 155 | + for i, line := range paragraph { |
| 156 | + parts[i] = inlineMarkdown(line) |
| 157 | + } |
| 158 | + out.WriteString("<p>" + strings.Join(parts, joiner) + "</p>\n") |
| 159 | paragraph = paragraph[:0] |
| 160 | } |
| 161 | for i := 0; i < len(lines); i++ { |
| 162 | @@ -147,3 +166,54 @@ func renderRepositoryMarkdown(source string, query url.Values) template.HTML { |
| 163 | return `<a href="` + html.EscapeString(destination.String()) + `">` |
| 164 | })) |
| 165 | } |
| 166 | + |
| 167 | +// tagOrText splits rendered HTML into tags and the text between them. |
| 168 | +var tagOrText = regexp.MustCompile(`<[^>]+>|[^<]+`) |
| 169 | + |
| 170 | +// chatLinkPattern matches bare links in escaped text: http(s) URLs and |
| 171 | +// nostr: references, as roomLinkPattern does before escaping. |
| 172 | +var chatLinkPattern = regexp.MustCompile(`https?://[^\s<>"']+|(?:web\+)?nostr:[a-z0-9]+`) |
| 173 | + |
| 174 | +// autolinkOutsideTags links bare URLs and nostr: references in the text |
| 175 | +// between tags, leaving anything already inside a link or code alone. |
| 176 | +func autolinkOutsideTags(rendered string) string { |
| 177 | + var out strings.Builder |
| 178 | + skip := 0 |
| 179 | + for _, part := range tagOrText.FindAllString(rendered, -1) { |
| 180 | + if strings.HasPrefix(part, "<") { |
| 181 | + lower := strings.ToLower(part) |
| 182 | + switch { |
| 183 | + case strings.HasPrefix(lower, "<a ") || strings.HasPrefix(lower, "<code") || strings.HasPrefix(lower, "<pre"): |
| 184 | + skip++ |
| 185 | + case strings.HasPrefix(lower, "</a") || strings.HasPrefix(lower, "</code") || strings.HasPrefix(lower, "</pre"): |
| 186 | + if skip > 0 { |
| 187 | + skip-- |
| 188 | + } |
| 189 | + } |
| 190 | + out.WriteString(part) |
| 191 | + continue |
| 192 | + } |
| 193 | + if skip > 0 { |
| 194 | + out.WriteString(part) |
| 195 | + continue |
| 196 | + } |
| 197 | + last := 0 |
| 198 | + for _, match := range chatLinkPattern.FindAllStringIndex(part, -1) { |
| 199 | + out.WriteString(part[last:match[0]]) |
| 200 | + link := part[match[0]:match[1]] |
| 201 | + trimmed := strings.TrimRight(link, ".,;:!?)") |
| 202 | + href := trimmed |
| 203 | + if !strings.HasPrefix(trimmed, "http") { |
| 204 | + href = "/open?target=" + template.URLQueryEscaper(html.UnescapeString(trimmed)) |
| 205 | + } |
| 206 | + out.WriteString(`<a href="` + html.EscapeString(html.UnescapeString(href)) + `"`) |
| 207 | + if strings.HasPrefix(trimmed, "http") { |
| 208 | + out.WriteString(` rel="noopener"`) |
| 209 | + } |
| 210 | + out.WriteString(`>` + trimmed + `</a>` + link[len(trimmed):]) |
| 211 | + last = match[1] |
| 212 | + } |
| 213 | + out.WriteString(part[last:]) |
| 214 | + } |
| 215 | + return out.String() |
| 216 | +} |
| 217 | diff --git a/internal/webui/markdown_test.go b/internal/webui/markdown_test.go |
| 218 | index 7268e81..8b57591 100644 |
| 219 | --- a/internal/webui/markdown_test.go |
| 220 | +++ b/internal/webui/markdown_test.go |
| 221 | @@ -37,3 +37,20 @@ func TestRepositoryMarkdownDecodesPathsAndPreservesFragments(t *testing.T) { |
| 222 | t.Fatalf("encoded README link lost its resource or fragment: %s", got) |
| 223 | } |
| 224 | } |
| 225 | + |
| 226 | +func TestChatMarkdownBreaksLinesAndAutolinksOutsideLinksAndCode(t *testing.T) { |
| 227 | + got := string(renderChatMarkdown("It works: **[the page](https://012.run/wiki/agents)**.\nsee https://example.com/x, and nostr:npub1ttrypewl3au52wqux86r22yt506c077k3maj02a0jste97wrvd5sjfutc2\n\n- `code https://not.a.link`\n- [x](javascript:alert(1))\n\n```\nhttps://in.code\n```")) |
| 228 | + for _, want := range []string{ |
| 229 | + `<strong><a href="https://012.run/wiki/agents">the page</a></strong>.<br>see <a href="https://example.com/x" rel="noopener">https://example.com/x</a>, and <a href="/open?target=nostr%3Anpub1ttrypewl3au52wqux86r22yt506c077k3maj02a0jste97wrvd5sjfutc2">nostr:npub1ttrypewl3au52wqux86r22yt506c077k3maj02a0jste97wrvd5sjfutc2</a></p>`, |
| 230 | + "<li><code>code https://not.a.link</code></li>", |
| 231 | + "<li>[x](javascript:alert(1))</li>", |
| 232 | + "<pre><code>https://in.code</code></pre>", |
| 233 | + } { |
| 234 | + if !strings.Contains(got, want) { |
| 235 | + t.Errorf("missing %q in %s", want, got) |
| 236 | + } |
| 237 | + } |
| 238 | + if strings.Count(got, "<a ") != 3 { |
| 239 | + t.Fatalf("expected three links, got %s", got) |
| 240 | + } |
| 241 | +} |
| 242 | diff --git a/internal/webui/rooms.html b/internal/webui/rooms.html |
| 243 | index 494615a..e8bd029 100644 |
| 244 | --- a/internal/webui/rooms.html |
| 245 | +++ b/internal/webui/rooms.html |
| 246 | @@ -59,6 +59,6 @@ |
| 247 | {{/* One message. room-live builds the same markup for messages that arrive on the stream. */}} |
| 248 | {{define "roomMessage"}}<room-message id="msg-{{.ID}}" data-id="{{.ID}}" data-kind="{{.Kind}}" data-pubkey="{{.PubKey}}"{{if .Agent}} data-agent{{end}}{{if .Notice}} data-notice{{end}}{{if .Edited}} data-edited{{end}}> |
| 249 | <header><b>{{template "nostrName" .PubKey}}</b>{{if .Role}} | {{.Role}}{{end}}<small><time datetime="{{datetime .CreatedAt}}" title="{{when .CreatedAt}}">{{clock .CreatedAt}}</time></small></header> |
| 250 | -<p>{{if .Notice}}{{.Notice}}{{else}}{{roomContent .Content}}{{end}}</p> |
| 251 | +{{if .Notice}}<p>{{.Notice}}</p>{{else}}<div>{{chatMarkdown .Content}}</div>{{end}} |
| 252 | {{if or .Mentions (and (not .InThread) (or (eq .Kind "11") .Root)) .Reactions .Edited}}<footer>{{if .Mentions}}<span>to {{range .Mentions}}{{template "nostrName" .}} {{end}}</span>{{end}}{{if not .InThread}}{{if eq .Kind "11"}}<a href="/rooms/{{.Room}}/thread/{{.ID}}">thread{{if .Replies}} | {{.Replies}} {{if eq .Replies 1}}reply{{else}}replies{{end}}{{end}}</a>{{else if .Root}}<a href="/rooms/{{.Room}}/thread/{{.Root}}">in thread</a>{{end}}{{end}}{{range .Reactions}}<span data-reaction="{{.Content}}">{{.Content}} {{.Count}}</span>{{end}}{{if .Edited}}<span data-edited>edited</span>{{end}}</footer>{{end}} |
| 253 | </room-message>{{end}} |
| 254 | diff --git a/internal/webui/rooms_test.go b/internal/webui/rooms_test.go |
| 255 | index d0662f0..61430d7 100644 |
| 256 | --- a/internal/webui/rooms_test.go |
| 257 | +++ b/internal/webui/rooms_test.go |
| 258 | @@ -148,7 +148,7 @@ func TestRoomPageRendersMessagesOldestFirstWithMarkers(t *testing.T) { |
| 259 | `<script>alert(1)</script>`, |
| 260 | `<span>to <nostr-name pubkey="` + roomAgent + `"`, |
| 261 | `<span data-reaction="+1">+1 1</span>`, |
| 262 | - `<p>hello again, see <a href="https://example.com/docs" rel="noopener">`, `<span data-edited>edited</span>`, |
| 263 | + `<div><p>hello again, see <a href="https://example.com/docs" rel="noopener">`, `<span data-edited>edited</span>`, |
| 264 | `<a href="/rooms/general/thread/` + roomThread + `">thread | 1 reply</a>`, |
| 265 | `<a href="/rooms/general/thread/` + roomThread + `">in thread</a>`, |
| 266 | `<room-live room="general"></room-live>`, |
| 267 | diff --git a/internal/webui/style.css b/internal/webui/style.css |
| 268 | index 5e78673..c570e4b 100644 |
| 269 | --- a/internal/webui/style.css |
| 270 | +++ b/internal/webui/style.css |
| 271 | @@ -258,8 +258,10 @@ room-message{display:block;padding:8px 0;border-bottom:1px solid var(--line)} |
| 272 | room-message>header{font:12px var(--mono);color:var(--muted);margin-bottom:2px;user-select:none;-webkit-user-select:none} |
| 273 | room-message>header b{color:var(--ink);font-weight:500}room-message>header b nostr-key{cursor:copy} |
| 274 | room-message>header small{float:right} |
| 275 | -room-message>p{margin:0;font:14px/1.45 var(--sans);white-space:pre-wrap;overflow-wrap:anywhere} |
| 276 | -room-message>p a{color:var(--accent)} |
| 277 | +room-message>p,room-message>div{margin:0;font:14px/1.45 var(--sans);overflow-wrap:anywhere}room-message>p{white-space:pre-wrap} |
| 278 | +room-message>p a,room-message>div a{color:var(--accent)} |
| 279 | +room-message>div p{margin:0 0 6px}room-message>div>:last-child{margin-bottom:0}room-message>div ul,room-message>div ol{margin:0 0 6px 20px;padding:0} |
| 280 | +room-message>div pre{margin:0 0 6px;padding:6px 8px;background:var(--rail);border:1px solid var(--line);overflow:auto;font:12px/1.4 var(--mono);white-space:pre}room-message>div code{font:12px var(--mono)}room-message>div h1,room-message>div h2,room-message>div h3,room-message>div h4{margin:4px 0 6px;font-size:14px;border:0} |
| 281 | room-message>footer{font:12px var(--mono);color:var(--muted);margin-top:4px} |
| 282 | room-message>footer>*+*::before{content:" | "} |
| 283 | room-message[data-agent]>header b::after{content:" agent";color:var(--bark);font-size:11px} |
| 284 | diff --git a/internal/webui/templates.go b/internal/webui/templates.go |
| 285 | index 7ed457e..c984b56 100644 |
| 286 | --- a/internal/webui/templates.go |
| 287 | +++ b/internal/webui/templates.go |
| 288 | @@ -232,6 +232,7 @@ func parseTemplates() (*template.Template, error) { |
| 289 | "datetime": datetime, |
| 290 | "when": when, |
| 291 | "markdown": renderMarkdown, |
| 292 | + "chatMarkdown": renderChatMarkdown, |
| 293 | "hasPrefix": strings.HasPrefix, |
| 294 | "npub": identityNpub, |
| 295 | "wsURL": wsURL, |
| 296 | diff --git a/scripts/rooms.test.mjs b/scripts/rooms.test.mjs |
| 297 | index 472fdb4..8f2f5bc 100644 |
| 298 | --- a/scripts/rooms.test.mjs |
| 299 | +++ b/scripts/rooms.test.mjs |
| 300 | @@ -186,7 +186,7 @@ test("room-live appends streamed messages once, summarizes reactions and reconne |
| 301 | assert.equal(node.querySelector("header b nostr-name").getAttribute("pubkey"), message.pubkey); |
| 302 | const link = node.querySelector("p a"); |
| 303 | assert.equal(link.href, "https://example.com/x"); |
| 304 | - assert.equal(node.querySelector("p").textContent, "see https://example.com/x, ok"); |
| 305 | + assert.equal(node.querySelector("div p").textContent, "see https://example.com/x, ok"); |
| 306 | assert.equal(node.querySelector("footer span nostr-name").getAttribute("pubkey"), "c".repeat(64)); |
| 307 | source.emit("message", {data: "not json"}); |
| 308 | source.emit("message", {data: JSON.stringify({...message, id: "9".repeat(64), kind: 20001})}); |
| 309 | @@ -199,13 +199,13 @@ test("room-live appends streamed messages once, summarizes reactions and reconne |
| 310 | assert.equal(summary.textContent, "+1 2"); |
| 311 | const edit = finalizeEvent({kind: 40003, created_at: 1757203750, tags: [["h", "build"], ["e", message.id]], content: "see https://example.com/y instead"}, secret); |
| 312 | source.emit("message", {data: JSON.stringify(edit)}); |
| 313 | - assert.equal(node.querySelector("p").textContent, "see https://example.com/y instead"); |
| 314 | + assert.equal(node.querySelector("div p").textContent, "see https://example.com/y instead"); |
| 315 | assert.equal(node.querySelector("p a").href, "https://example.com/y"); |
| 316 | assert.equal(node.dataset.edited, ""); |
| 317 | assert.equal(node.querySelectorAll("footer span[data-edited]").length, 1); |
| 318 | const stranger = finalizeEvent({kind: 40003, created_at: 1757203760, tags: [["h", "build"], ["e", message.id]], content: "hijacked"}, generateSecretKey()); |
| 319 | source.emit("message", {data: JSON.stringify(stranger)}); |
| 320 | - assert.equal(node.querySelector("p").textContent, "see https://example.com/y instead"); |
| 321 | + assert.equal(node.querySelector("div p").textContent, "see https://example.com/y instead"); |
| 322 | assert.equal(s.list.children.length, 1); |
| 323 | // An agent's message carries the marker from the members list. |
| 324 | const agent = finalizeEvent({kind: 11, created_at: 1757203800, tags: [["h", "build"]], content: "draft"}, secret); |
| 325 | @@ -242,3 +242,23 @@ test("room-live on a thread page keeps only replies to its root", () => { |
| 326 | live.disconnectedCallback(); |
| 327 | assert.equal(source.closed, true); |
| 328 | }); |
| 329 | + |
| 330 | +test("chat markdown renders the shared subset without ever parsing markup", () => { |
| 331 | + const s = setup(); |
| 332 | + const body = s.rooms.chatMarkdown(new FakeNode("div"), "It works: **[the page](https://012.run/wiki/agents)**.\nsee https://example.com/x, plus <b>not markup</b>\n\n- `code https://not.a.link`\n- [x](javascript:alert(1))\n\n```\nhttps://in.code\n```"); |
| 333 | + const [p, list, pre] = body.children; |
| 334 | + assert.equal(p.localName, "p"); |
| 335 | + assert.equal(p.querySelector("strong a").href, "https://012.run/wiki/agents"); |
| 336 | + assert.equal(p.querySelectorAll("br").length, 1); |
| 337 | + assert.equal(p.querySelectorAll("a").length, 2); |
| 338 | + assert.equal(p.textContent, "It works: the page.see https://example.com/x, plus <b>not markup</b>"); |
| 339 | + assert.equal(list.localName, "ul"); |
| 340 | + assert.equal(list.children[0].querySelector("code").textContent, "code https://not.a.link"); |
| 341 | + assert.equal(list.children[0].querySelectorAll("a").length, 0); |
| 342 | + assert.equal(list.children[1].textContent, "[x](javascript:alert(1))"); |
| 343 | + assert.equal(list.children[1].querySelectorAll("a").length, 0); |
| 344 | + assert.equal(pre.localName, "pre"); |
| 345 | + assert.equal(pre.querySelector("code").textContent, "https://in.code"); |
| 346 | + assert.equal(pre.querySelectorAll("a").length, 0); |
| 347 | +}); |
| 348 | + |