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&lang=en"');
+ expect(result).toContain("https://example.com/search?q=a&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("