- commit
- 126290ef34205bc27cc03d98df553fcd0405fbb7
- parent
- 8191f1a31d1ac53b5cf9b38f34b02991f7dafd2e
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 2 | +- |
| M | internal/ | 76 | ++++++++++++++++++++++++++++-- |
| M | internal/ | 72 | ++++++++++++++++++++++++++++- |
| M | internal/ | 17 | +++++++ |
| M | internal/ | 2 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 6 | ++- |
| M | internal/ | 1 | + |
| M | scripts/ | 26 | +++++++++- |
@@ -59,7 +59,7 @@ Room events reach device notifications and relay push callbacks the same way as
5959 6060 **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.6161 62-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.62+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.6363 6464 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.6565 @@ -616,6 +616,74 @@
616616 node.append(text.slice(last));617617 return node;618618 };619+ // chatMarkdown renders the same Markdown subset the page renders for chat620+ // messages: paragraphs with line breaks, fenced code, headings, lists,621+ // inline code, emphasis, links, and bare http(s) or nostr: references. It622+ // builds nodes rather than markup, so message text never becomes HTML.623+ const inlinePattern = /(`[^`]+`)|\[([^\]]+)\]\(([^)\s]+)\)|\*\*([^*]+)\*\*|(^|[^*\w])\*([^*]+)\*|(https?:\/\/[^\s<>"']+|(?:web\+)?nostr:[a-z0-9]+)/g;624+ const safeLink = target => /^(https?:\/\/|\/(?!\/)|\.\.?\/|#|mailto:)/.test(target) || (!target.includes(":") && !target.startsWith("//"));625+ const chatInline = (node, text) => {626+ let last = 0;627+ for (const match of String(text).matchAll(inlinePattern)) {628+ node.append(text.slice(last, match.index));629+ last = match.index + match[0].length;630+ if (match[1]) { node.append(el("code", match[1].slice(1, -1))); continue; }631+ if (match[2] !== undefined) {632+ if (!safeLink(match[3])) { node.append(match[0]); continue; }633+ const link = el("a", match[2]);634+ link.href = match[3];635+ if (match[3].startsWith("http")) link.rel = "noopener";636+ node.append(link);637+ continue;638+ }639+ if (match[4] !== undefined) { node.append(chatInline(el("strong"), match[4])); continue; }640+ if (match[6] !== undefined) { node.append(match[5], chatInline(el("em"), match[6])); continue; }641+ const trimmed = match[7].replace(/[.,;:!?)]+$/, "");642+ const link = el("a", trimmed);643+ link.href = trimmed.startsWith("http") ? trimmed : tiny.localPath("/open?target=" + encodeURIComponent(trimmed));644+ if (trimmed.startsWith("http")) link.rel = "noopener";645+ node.append(link, match[7].slice(trimmed.length));646+ }647+ node.append(text.slice(last));648+ return node;649+ };650+ const listItem = line => /^\s*(?:[-*+]|\d+[.)])\s+/.test(line);651+ const chatMarkdown = (node, text) => {652+ const lines = String(text).replace(/\r\n/g, "\n").split("\n");653+ let paragraph = [];654+ const flush = () => {655+ if (!paragraph.length) return;656+ const p = el("p");657+ paragraph.forEach((line, index) => { if (index) p.append(el("br")); chatInline(p, line); });658+ node.append(p);659+ paragraph = [];660+ };661+ for (let i = 0; i < lines.length; i++) {662+ const line = lines[i];663+ if (line.startsWith("```")) {664+ flush();665+ const code = [];666+ for (i++; i < lines.length && !lines[i].startsWith("```"); i++) code.push(lines[i]);667+ const pre = el("pre"); pre.append(el("code", code.join("\n"))); node.append(pre);668+ } else if (line.startsWith("#")) {669+ flush();670+ const level = Math.min(6, line.length - line.replace(/^#+/, "").length);671+ node.append(chatInline(el("h" + level), line.slice(level).trim()));672+ } else if (listItem(line)) {673+ flush();674+ const list = el(/^\s*\d/.test(line) ? "ol" : "ul");675+ for (; i < lines.length && listItem(lines[i]); i++) list.append(chatInline(el("li"), lines[i].replace(/^\s*(?:[-*+]|\d+[.)])\s+/, "")));676+ i--;677+ node.append(list);678+ } else if (!line.trim()) {679+ flush();680+ } else {681+ paragraph.push(line.trim());682+ }683+ }684+ flush();685+ return node;686+ };619687 const keyNode = hex => { const node = el("nostr-key", hex.slice(0, 12)); node.setAttribute("hex", hex); node.title = hex; return node; };620688 // nameNode shows a person: the vendored nostr-name element replaces the short621689 // id with the profile name published on this relay.@@ -646,7 +714,7 @@
646714 time.title = time.dateTime.slice(0, 16).replace("T", " ") + " UTC";647715 small.append(time);648716 header.append(name, member?.role ? " | " + member.role : "", small);649- const body = notice ? el("p", notice) : linkify(el("p"), event.content || "");717+ const body = notice ? el("p", notice) : chatMarkdown(el("div"), event.content || "");650718 node.append(header, body);651719 const footer = el("footer");652720 const mentions = notice ? [] : (event.tags || []).filter(tag => tag[0] === "p" && isHex64(tag[1]) && tag[1] !== pubkey).map(tag => tag[1]);@@ -705,9 +773,9 @@
705773 const targets = (event.tags || []).filter(tag => tag[0] === "e");706774 const target = targets.length && document.getElementById("msg-" + targets[targets.length - 1][1]);707775 if (!target || target.dataset.pubkey !== event.pubkey || target.dataset.notice !== undefined) return;708- const body = target.querySelector(":scope > p");776+ const body = target.querySelector(":scope > div");709777 if (!body) return;710- body.replaceWith(linkify(el("p"), event.content || ""));778+ body.replaceWith(chatMarkdown(el("div"), event.content || ""));711779 if (target.dataset.edited !== undefined) return;712780 target.dataset.edited = "";713781 let footer = target.querySelector(":scope > footer");@@ -884,7 +952,7 @@
884952 roomAppend(event, {room: this.getAttribute("room"), inThread: Boolean(root)});885953 }886954 }887- tiny.rooms = Object.freeze({keyHex, roomMentions, roomID, messageNode, roomAppend, roomReact, roomEdit, linkify});955+ tiny.rooms = Object.freeze({keyHex, roomMentions, roomID, messageNode, roomAppend, roomReact, roomEdit, linkify, chatMarkdown});888956 889957 // JsonView renders any JSON value. Arrays of objects become tables, objects890958 // become definition lists, scalar arrays become lists, and deep nesting@@ -15,6 +15,17 @@ import (
1515 // the tags this function emits reach the page, so untrusted README files and1616 // relay descriptions cannot inject markup. Anything else stays plain text.1717 func renderMarkdown(source string) template.HTML {18+ return markdownHTML(source, false)19+}20+21+// renderChatMarkdown is the same subset for chat messages, where a single22+// newline is a line break and bare http(s) and nostr: references become23+// links, as people and agents type them.24+func renderChatMarkdown(source string) template.HTML {25+ return template.HTML(autolinkOutsideTags(string(markdownHTML(source, true))))26+}27+28+func markdownHTML(source string, breaks bool) template.HTML {1829 lines := strings.Split(strings.ReplaceAll(source, "\r\n", "\n"), "\n")1930 var out strings.Builder2031 var paragraph []string@@ -22,7 +33,15 @@ func renderMarkdown(source string) template.HTML {
2233 if len(paragraph) == 0 {2334 return2435 }25- out.WriteString("<p>" + inlineMarkdown(strings.Join(paragraph, " ")) + "</p>\n")36+ joiner := " "37+ if breaks {38+ joiner = "<br>"39+ }40+ parts := make([]string, len(paragraph))41+ for i, line := range paragraph {42+ parts[i] = inlineMarkdown(line)43+ }44+ out.WriteString("<p>" + strings.Join(parts, joiner) + "</p>\n")2645 paragraph = paragraph[:0]2746 }2847 for i := 0; i < len(lines); i++ {@@ -147,3 +166,54 @@ func renderRepositoryMarkdown(source string, query url.Values) template.HTML {
147166 return `<a href="` + html.EscapeString(destination.String()) + `">`148167 }))149168 }169+170+// tagOrText splits rendered HTML into tags and the text between them.171+var tagOrText = regexp.MustCompile(`<[^>]+>|[^<]+`)172+173+// chatLinkPattern matches bare links in escaped text: http(s) URLs and174+// nostr: references, as roomLinkPattern does before escaping.175+var chatLinkPattern = regexp.MustCompile(`https?://[^\s<>"']+|(?:web\+)?nostr:[a-z0-9]+`)176+177+// autolinkOutsideTags links bare URLs and nostr: references in the text178+// between tags, leaving anything already inside a link or code alone.179+func autolinkOutsideTags(rendered string) string {180+ var out strings.Builder181+ skip := 0182+ for _, part := range tagOrText.FindAllString(rendered, -1) {183+ if strings.HasPrefix(part, "<") {184+ lower := strings.ToLower(part)185+ switch {186+ case strings.HasPrefix(lower, "<a ") || strings.HasPrefix(lower, "<code") || strings.HasPrefix(lower, "<pre"):187+ skip++188+ case strings.HasPrefix(lower, "</a") || strings.HasPrefix(lower, "</code") || strings.HasPrefix(lower, "</pre"):189+ if skip > 0 {190+ skip--191+ }192+ }193+ out.WriteString(part)194+ continue195+ }196+ if skip > 0 {197+ out.WriteString(part)198+ continue199+ }200+ last := 0201+ for _, match := range chatLinkPattern.FindAllStringIndex(part, -1) {202+ out.WriteString(part[last:match[0]])203+ link := part[match[0]:match[1]]204+ trimmed := strings.TrimRight(link, ".,;:!?)")205+ href := trimmed206+ if !strings.HasPrefix(trimmed, "http") {207+ href = "/open?target=" + template.URLQueryEscaper(html.UnescapeString(trimmed))208+ }209+ out.WriteString(`<a href="` + html.EscapeString(html.UnescapeString(href)) + `"`)210+ if strings.HasPrefix(trimmed, "http") {211+ out.WriteString(` rel="noopener"`)212+ }213+ out.WriteString(`>` + trimmed + `</a>` + link[len(trimmed):])214+ last = match[1]215+ }216+ out.WriteString(part[last:])217+ }218+ return out.String()219+}@@ -37,3 +37,20 @@ func TestRepositoryMarkdownDecodesPathsAndPreservesFragments(t *testing.T) {
3737 t.Fatalf("encoded README link lost its resource or fragment: %s", got)3838 }3939 }40+41+func TestChatMarkdownBreaksLinesAndAutolinksOutsideLinksAndCode(t *testing.T) {42+ 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```"))43+ for _, want := range []string{44+ `<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>`,45+ "<li><code>code https://not.a.link</code></li>",46+ "<li>[x](javascript:alert(1))</li>",47+ "<pre><code>https://in.code</code></pre>",48+ } {49+ if !strings.Contains(got, want) {50+ t.Errorf("missing %q in %s", want, got)51+ }52+ }53+ if strings.Count(got, "<a ") != 3 {54+ t.Fatalf("expected three links, got %s", got)55+ }56+}@@ -59,6 +59,6 @@
5959 {{/* One message. room-live builds the same markup for messages that arrive on the stream. */}}6060 {{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}}>6161 <header><b>{{template "nostrName" .PubKey}}</b>{{if .Role}} | {{.Role}}{{end}}<small><time datetime="{{datetime .CreatedAt}}" title="{{when .CreatedAt}}">{{clock .CreatedAt}}</time></small></header>62-<p>{{if .Notice}}{{.Notice}}{{else}}{{roomContent .Content}}{{end}}</p>62+{{if .Notice}}<p>{{.Notice}}</p>{{else}}<div>{{chatMarkdown .Content}}</div>{{end}}6363 {{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}}6464 </room-message>{{end}}@@ -148,7 +148,7 @@ func TestRoomPageRendersMessagesOldestFirstWithMarkers(t *testing.T) {
148148 `<script>alert(1)</script>`,149149 `<span>to <nostr-name pubkey="` + roomAgent + `"`,150150 `<span data-reaction="+1">+1 1</span>`,151- `<p>hello again, see <a href="https://example.com/docs" rel="noopener">`, `<span data-edited>edited</span>`,151+ `<div><p>hello again, see <a href="https://example.com/docs" rel="noopener">`, `<span data-edited>edited</span>`,152152 `<a href="/rooms/general/thread/` + roomThread + `">thread | 1 reply</a>`,153153 `<a href="/rooms/general/thread/` + roomThread + `">in thread</a>`,154154 `<room-live room="general"></room-live>`,@@ -258,8 +258,10 @@ room-message{display:block;padding:8px 0;border-bottom:1px solid var(--line)}
258258 room-message>header{font:12px var(--mono);color:var(--muted);margin-bottom:2px;user-select:none;-webkit-user-select:none}259259 room-message>header b{color:var(--ink);font-weight:500}room-message>header b nostr-key{cursor:copy}260260 room-message>header small{float:right}261-room-message>p{margin:0;font:14px/1.45 var(--sans);white-space:pre-wrap;overflow-wrap:anywhere}262-room-message>p a{color:var(--accent)}261+room-message>p,room-message>div{margin:0;font:14px/1.45 var(--sans);overflow-wrap:anywhere}room-message>p{white-space:pre-wrap}262+room-message>p a,room-message>div a{color:var(--accent)}263+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}264+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}263265 room-message>footer{font:12px var(--mono);color:var(--muted);margin-top:4px}264266 room-message>footer>*+*::before{content:" | "}265267 room-message[data-agent]>header b::after{content:" agent";color:var(--bark);font-size:11px}@@ -232,6 +232,7 @@ func parseTemplates() (*template.Template, error) {
232232 "datetime": datetime,233233 "when": when,234234 "markdown": renderMarkdown,235+ "chatMarkdown": renderChatMarkdown,235236 "hasPrefix": strings.HasPrefix,236237 "npub": identityNpub,237238 "wsURL": wsURL,@@ -186,7 +186,7 @@ test("room-live appends streamed messages once, summarizes reactions and reconne
186186 assert.equal(node.querySelector("header b nostr-name").getAttribute("pubkey"), message.pubkey);187187 const link = node.querySelector("p a");188188 assert.equal(link.href, "https://example.com/x");189- assert.equal(node.querySelector("p").textContent, "see https://example.com/x, ok");189+ assert.equal(node.querySelector("div p").textContent, "see https://example.com/x, ok");190190 assert.equal(node.querySelector("footer span nostr-name").getAttribute("pubkey"), "c".repeat(64));191191 source.emit("message", {data: "not json"});192192 source.emit("message", {data: JSON.stringify({...message, id: "9".repeat(64), kind: 20001})});@@ -199,13 +199,13 @@ test("room-live appends streamed messages once, summarizes reactions and reconne
199199 assert.equal(summary.textContent, "+1 2");200200 const edit = finalizeEvent({kind: 40003, created_at: 1757203750, tags: [["h", "build"], ["e", message.id]], content: "see https://example.com/y instead"}, secret);201201 source.emit("message", {data: JSON.stringify(edit)});202- assert.equal(node.querySelector("p").textContent, "see https://example.com/y instead");202+ assert.equal(node.querySelector("div p").textContent, "see https://example.com/y instead");203203 assert.equal(node.querySelector("p a").href, "https://example.com/y");204204 assert.equal(node.dataset.edited, "");205205 assert.equal(node.querySelectorAll("footer span[data-edited]").length, 1);206206 const stranger = finalizeEvent({kind: 40003, created_at: 1757203760, tags: [["h", "build"], ["e", message.id]], content: "hijacked"}, generateSecretKey());207207 source.emit("message", {data: JSON.stringify(stranger)});208- assert.equal(node.querySelector("p").textContent, "see https://example.com/y instead");208+ assert.equal(node.querySelector("div p").textContent, "see https://example.com/y instead");209209 assert.equal(s.list.children.length, 1);210210 // An agent's message carries the marker from the members list.211211 const agent = finalizeEvent({kind: 11, created_at: 1757203800, tags: [["h", "build"]], content: "draft"}, secret);@@ -242,3 +242,23 @@ test("room-live on a thread page keeps only replies to its root", () => {
242242 live.disconnectedCallback();243243 assert.equal(source.closed, true);244244 });245+246+test("chat markdown renders the shared subset without ever parsing markup", () => {247+ const s = setup();248+ 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```");249+ const [p, list, pre] = body.children;250+ assert.equal(p.localName, "p");251+ assert.equal(p.querySelector("strong a").href, "https://012.run/wiki/agents");252+ assert.equal(p.querySelectorAll("br").length, 1);253+ assert.equal(p.querySelectorAll("a").length, 2);254+ assert.equal(p.textContent, "It works: the page.see https://example.com/x, plus <b>not markup</b>");255+ assert.equal(list.localName, "ul");256+ assert.equal(list.children[0].querySelector("code").textContent, "code https://not.a.link");257+ assert.equal(list.children[0].querySelectorAll("a").length, 0);258+ assert.equal(list.children[1].textContent, "[x](javascript:alert(1))");259+ assert.equal(list.children[1].querySelectorAll("a").length, 0);260+ assert.equal(pre.localName, "pre");261+ assert.equal(pre.querySelector("code").textContent, "https://in.code");262+ assert.equal(pre.querySelectorAll("a").length, 0);263+});264+