From eee4ed1ea2e2e483d026abef150c68cd1f928be3 Mon Sep 17 00:00:00 2001 From: Ivan Date: Thu, 16 Apr 2026 21:55:38 -0500 Subject: [PATCH] feat(link-utils): update link processing with anchor protection and trailing punctuation handling --- meshchatx/src/frontend/js/LinkUtils.js | 62 ++++++++++++-- tests/frontend/LinkUtils.test.js | 34 ++++++++ tests/frontend/MarkdownRenderer.test.js | 105 ++++++++++++++++++++++++ 3 files changed, 194 insertions(+), 7 deletions(-) diff --git a/meshchatx/src/frontend/js/LinkUtils.js b/meshchatx/src/frontend/js/LinkUtils.js index 3ce2572..7836d24 100644 --- a/meshchatx/src/frontend/js/LinkUtils.js +++ b/meshchatx/src/frontend/js/LinkUtils.js @@ -6,6 +6,50 @@ function defaultNomadPagePath() { } export default class LinkUtils { + static protectAnchors(text) { + const anchors = []; + const protectedText = text.replace(/]*>[\s\S]*?<\/a>/gi, (anchor) => { + const token = `[[ANCHOR_${anchors.length}]]`; + anchors.push(anchor); + return token; + }); + return { protectedText, anchors }; + } + + static restoreAnchors(text, anchors) { + return text.replace(/\[\[ANCHOR_(\d+)\]\]/g, (match, idx) => { + const i = Number(idx); + return Number.isInteger(i) && i >= 0 && i < anchors.length ? anchors[i] : match; + }); + } + + static splitTrailingPunctuation(url) { + let core = url; + let suffix = ""; + const alwaysTrim = new Set([".", ",", "!", "?", ":", ";"]); + while (core.length > 0) { + const ch = core.at(-1); + if (alwaysTrim.has(ch)) { + suffix = ch + suffix; + core = core.slice(0, -1); + continue; + } + if (ch === ")" || ch === "]") { + const open = ch === ")" ? "(" : "["; + const close = ch; + const opens = [...core].filter((c) => c === open).length; + const closes = [...core].filter((c) => c === close).length; + if (closes > opens) { + suffix = ch + suffix; + core = core.slice(0, -1); + continue; + } + } + break; + } + return { core, suffix }; + } + /** * Detects and wraps Reticulum (NomadNet and LXMF) links in HTML. * Supports nomadnet://, nomadnet@, lxmf://, lxmf@ and bare @@ -45,10 +89,13 @@ export default class LinkUtils { static renderStandardLinks(text) { if (!text) return ""; - // Simple regex for URLs - const urlRegex = /(https?:\/\/[^\s<]+)/g; - return text.replace(urlRegex, (url) => { - return `${url}`; + const urlRegex = /(^|[^\w"'=])(https?:\/\/[^\s<]+)/g; + return text.replace(urlRegex, (match, prefix, url) => { + const { core, suffix } = this.splitTrailingPunctuation(url); + if (!core) { + return match; + } + return `${prefix}${core}${suffix}`; }); } @@ -56,8 +103,9 @@ export default class LinkUtils { * Applies all link rendering. */ static renderAllLinks(text) { - text = this.renderStandardLinks(text); - text = this.renderReticulumLinks(text); - return text; + const { protectedText, anchors } = this.protectAnchors(text); + let rendered = this.renderStandardLinks(protectedText); + rendered = this.renderReticulumLinks(rendered); + return this.restoreAnchors(rendered, anchors); } } diff --git a/tests/frontend/LinkUtils.test.js b/tests/frontend/LinkUtils.test.js index 07db1c8..75a2383 100644 --- a/tests/frontend/LinkUtils.test.js +++ b/tests/frontend/LinkUtils.test.js @@ -58,6 +58,27 @@ describe("LinkUtils.js", () => { const result = LinkUtils.renderStandardLinks(text); expect(result).toContain(' { + const result = LinkUtils.renderStandardLinks("visit https://example.com/path?x=1, now"); + expect(result).toContain('href="https://example.com/path?x=1"'); + expect(result).toContain(", now"); + }); + + it("keeps balanced parenthesis in url but trims unmatched trailing one", () => { + const withBalanced = LinkUtils.renderStandardLinks("see https://example.com/path_(v1)"); + expect(withBalanced).toContain('href="https://example.com/path_(v1)"'); + + const withTrailing = LinkUtils.renderStandardLinks("see (https://example.com/path_(v1))"); + expect(withTrailing).toContain('href="https://example.com/path_(v1)"'); + expect(withTrailing).toContain(")"); + }); + + it("keeps escaped entity query content in href", () => { + const text = "visit https://example.com/search?q=a&lang=en"; + const result = LinkUtils.renderStandardLinks(text); + expect(result).toContain('href="https://example.com/search?q=a&lang=en"'); + }); }); describe("renderAllLinks", () => { @@ -77,6 +98,19 @@ describe("LinkUtils.js", () => { expect(result).toContain("Just some words."); expect(result).not.toContain(" { + const original = 'already linked https://example.com'; + const result = LinkUtils.renderAllLinks(original); + expect(result).toBe(original); + expect((result.match(/ { + const text = "1dfeb0d794963579bd21ac8f153c77a4:/page/meshchatx_on_pi.mu"; + const result = LinkUtils.renderAllLinks(text); + expect(result).toContain('data-nomadnet-url="1dfeb0d794963579bd21ac8f153c77a4:/page/meshchatx_on_pi.mu"'); + }); }); describe("risky: no script or data URLs in href", () => { diff --git a/tests/frontend/MarkdownRenderer.test.js b/tests/frontend/MarkdownRenderer.test.js index b36cb04..038212b 100644 --- a/tests/frontend/MarkdownRenderer.test.js +++ b/tests/frontend/MarkdownRenderer.test.js @@ -88,6 +88,58 @@ describe("MarkdownRenderer.js", () => { expect(result).not.toContain("on"); expect(result).not.toContain("raspberry"); }); + + it("renders multiple urls in one line without corruption", () => { + const a = "https://example.com/docs/meshchatx_on_pi.md"; + const b = "https://example.com/plain"; + const result = MarkdownRenderer.render(`links: ${a} and ${b}`); + expect(result).toContain(`href="${a}"`); + expect(result).toContain(`href="${b}"`); + expect((result.match(/ { + const result = MarkdownRenderer.render("Check (https://example.com/path_(v1)), and continue."); + expect(result).toContain('href="https://example.com/path_(v1)"'); + expect(result).toContain("), and continue."); + }); + + it("supports encoded chars and balanced parentheses in link path", () => { + const url = "https://example.com/docs/file%5Fname_(v1).md"; + const result = MarkdownRenderer.render(`open ${url}`); + expect(result).toContain(`href="${url}"`); + }); + + it("keeps escaped entities in query string links", () => { + const url = "https://example.com/search?q=a&lang=en"; + const result = MarkdownRenderer.render(`lookup ${url}`); + expect(result).toContain('href="https://example.com/search?q=a&amp;lang=en"'); + expect(result).toContain("https://example.com/search?q=a&amp;lang=en"); + }); + + it("handles links at line boundaries with newline conversion", () => { + const url = "https://example.com/meshchatx_on_pi.md"; + const result = MarkdownRenderer.render(`${url}\nnext line`); + expect(result).toContain(`href="${url}"`); + expect(result).toContain("
next line"); + }); + + it("mixes underscore markdown with underscore urls safely", () => { + const url = "https://example.com/meshchatx_on_raspberry_pi.md"; + const result = MarkdownRenderer.render(`_label_ ${url} _tail_`); + expect(result).toContain("label"); + expect(result).toContain("tail"); + expect(result).toContain(`href="${url}"`); + expect(result).not.toContain("on"); + }); + + it("escapes pre-rendered html input safely instead of nesting raw anchors", () => { + const preRendered = '

https://example.com/path_(v1)

'; + const result = MarkdownRenderer.render(preRendered); + expect(result).toContain("<p>"); + expect(result).toContain("<a href=""); + expect(result).not.toContain(" { @@ -255,6 +307,35 @@ describe("MarkdownRenderer.js", () => { expect(() => MarkdownRenderer.render(text)).not.toThrow(); }); }); + + it("underscore-heavy fuzz input does not create unbalanced emphasis tags", () => { + const randomUnderscoreText = () => { + const parts = [ + "_", + "__", + "___", + "snake_case", + "meshchatx_on_pi", + " ", + "text", + "https://example.com/meshchatx_on_pi.md", + ]; + let out = ""; + for (let i = 0; i < 200; i++) { + out += parts[Math.floor(Math.random() * parts.length)]; + } + return out; + }; + for (let i = 0; i < 50; i++) { + const rendered = MarkdownRenderer.render(randomUnderscoreText()); + const opensEm = (rendered.match(//g) || []).length; + const closesEm = (rendered.match(/<\/em>/g) || []).length; + const opensStrong = (rendered.match(//g) || []).length; + const closesStrong = (rendered.match(/<\/strong>/g) || []).length; + expect(opensEm).toBe(closesEm); + expect(opensStrong).toBe(closesStrong); + } + }); }); describe("isSingleEmojiMessage", () => { @@ -397,5 +478,29 @@ describe("MarkdownRenderer.js", () => { const r = MarkdownRenderer.render(msg); expect(typeof r).toBe("string"); }); + + it("renders a real-world mixed message body safely", () => { + const msg = [ + "# Deploy notes", + "", + "Read https://git.quad4.io/RNS-Things/MeshChatX/src/branch/dev/docs/meshchatx_on_raspberry_pi.md, then ping", + "nomadnet://1dfeb0d794963579bd21ac8f153c77a4:/page/meshchatx_on_pi.mu", + "", + "`inline_code` and _italic_ and snake_case stay sane.", + "", + "```txt", + "https://example.com/not_linked_inside_code", + "```", + ].join("\n"); + const result = MarkdownRenderer.render(msg); + expect(result).toContain("case"); + }); }); });