From 39c5ae751b741591e8894e2e9f6ea899775b458a Mon Sep 17 00:00:00 2001 From: Ivan Date: Mon, 13 Apr 2026 22:04:19 -0500 Subject: [PATCH] feat(nomad): improve Nomad page rendering with new link isolation and markdown options, and update global state for rendering configurations --- meshchatx/src/frontend/js/GlobalState.js | 4 + meshchatx/src/frontend/js/LinkUtils.js | 9 +- meshchatx/src/frontend/js/MicronParser.js | 2 +- .../src/frontend/js/NomadPageRenderer.js | 131 ++++++++++++++++-- 4 files changed, 130 insertions(+), 16 deletions(-) diff --git a/meshchatx/src/frontend/js/GlobalState.js b/meshchatx/src/frontend/js/GlobalState.js index 40b6e90..8c4c82e 100644 --- a/meshchatx/src/frontend/js/GlobalState.js +++ b/meshchatx/src/frontend/js/GlobalState.js @@ -20,6 +20,10 @@ const globalState = reactive({ message_inbound_bubble_color: null, message_failed_bubble_color: "#ef4444", message_waiting_bubble_color: "#e5e7eb", + nomad_render_markdown_enabled: true, + nomad_render_html_enabled: true, + nomad_render_plaintext_enabled: true, + nomad_default_page_path: "/page/index.mu", }, }); diff --git a/meshchatx/src/frontend/js/LinkUtils.js b/meshchatx/src/frontend/js/LinkUtils.js index b9a1faa..3ce2572 100644 --- a/meshchatx/src/frontend/js/LinkUtils.js +++ b/meshchatx/src/frontend/js/LinkUtils.js @@ -1,3 +1,10 @@ +import GlobalState from "./GlobalState.js"; + +function defaultNomadPagePath() { + const p = GlobalState.config?.nomad_default_page_path; + return typeof p === "string" && p.startsWith("/page/") ? p : "/page/index.mu"; +} + export default class LinkUtils { /** * Detects and wraps Reticulum (NomadNet and LXMF) links in HTML. @@ -22,7 +29,7 @@ export default class LinkUtils { (prefix && (prefix.startsWith("nomadnet://") || prefix.startsWith("nomadnet@"))) || !!path; if (isNomadNet) { - const fullPath = path || "/page/index.mu"; + const fullPath = path || defaultNomadPagePath(); const url = `${hash}:${fullPath}`; return `${match}`; } else { diff --git a/meshchatx/src/frontend/js/MicronParser.js b/meshchatx/src/frontend/js/MicronParser.js index 9327d4f..1611b23 100644 --- a/meshchatx/src/frontend/js/MicronParser.js +++ b/meshchatx/src/frontend/js/MicronParser.js @@ -146,7 +146,7 @@ export default class MicronParser extends BaseMicronParser { } .Mu-mnt-group { display: inline; - font-family: ui-monospace, monospace, "Courier New", monospace; + font-family: inherit; white-space: pre; text-decoration: inherit; vertical-align: baseline; diff --git a/meshchatx/src/frontend/js/NomadPageRenderer.js b/meshchatx/src/frontend/js/NomadPageRenderer.js index 1f8e736..8c282e0 100644 --- a/meshchatx/src/frontend/js/NomadPageRenderer.js +++ b/meshchatx/src/frontend/js/NomadPageRenderer.js @@ -37,7 +37,7 @@ function escapeHtmlText(text) { } export function escapeNomadPlainText(content) { - return `
${escapeHtmlText(content)}
`; + return `
${escapeHtmlText(content)}
`; } const NOMAD_HTML_ROOT_CLASS = "nomad-html-root"; @@ -49,11 +49,11 @@ function normalizeMarkdownInput(md) { let s = String(md); s = s.replace(/\r\n/g, "\n"); // CommonMark requires a space after # for ATX headings; "#Title" is not a heading - s = s.replace(/^(\s{0,3})(\#{1,6})([^\s#])/gm, "$1$2 $3"); + s = s.replace(/^(\s{0,3})(#{1,6})([^\s#])/gm, "$1$2 $3"); return s; } -function rewriteCssBodyHtmlSelectors(css) { +export function rewriteCssBodyHtmlSelectors(css) { if (!css) { return ""; } @@ -66,7 +66,7 @@ function rewriteCssBodyHtmlSelectors(css) { return s; } -function stripExternalFromCss(css) { +export function stripExternalFromCss(css) { if (!css) { return ""; } @@ -85,10 +85,24 @@ function isAllowedNomadHref(href) { return false; } const h = href.trim(); + const lower = h.toLowerCase(); + if ( + lower.startsWith("http:") || + lower.startsWith("https:") || + lower.startsWith("//") || + lower.startsWith("javascript:") || + lower.startsWith("vbscript:") || + lower.startsWith("data:") || + lower.startsWith("mailto:") || + lower.startsWith("ftp:") || + lower.startsWith("file:") + ) { + return false; + } if (h.startsWith("#")) { return true; } - if (h.startsWith(":")) { + if (h.startsWith(":") && !h.startsWith("://")) { return true; } if (/^[a-f0-9]{32}:/i.test(h)) { @@ -100,6 +114,62 @@ function isAllowedNomadHref(href) { return false; } +/** + * Rewrites in-renderer links so the browser does not follow relative or mesh paths as normal navigation. + * Produces `href="#"` plus `data-nomadnet-url` for use with NomadNetworkPage `onElementClick`. + */ +export function isolateNomadLinksInHtml(html, destinationHash) { + if (!html || !destinationHash || typeof destinationHash !== "string") { + return html; + } + const dh = destinationHash.trim(); + if (!/^[a-f0-9]{32}$/i.test(dh)) { + return html; + } + try { + const parser = new DOMParser(); + const doc = parser.parseFromString(``, "text/html"); + const root = doc.body.firstElementChild; + if (!root) { + return html; + } + const anchors = root.querySelectorAll("a[href]"); + anchors.forEach((a) => { + const href = a.getAttribute("href"); + if (href == null) { + return; + } + const h = href.trim(); + if (h.startsWith("#")) { + if (h === "" || h === "#") { + a.setAttribute("href", "#"); + } + return; + } + let full = null; + if (/^[a-f0-9]{32}:/i.test(h)) { + full = h; + } else if (h.startsWith("/page/") || h.startsWith("/file/")) { + full = `${dh}:${h}`; + } else if (h.startsWith(":") && !h.startsWith("://")) { + full = `${dh}:${h.slice(1)}`; + } + if (full) { + a.setAttribute("href", "#"); + a.setAttribute("data-nomadnet-url", full); + a.classList.add("nomadnet-link", "text-blue-600", "dark:text-blue-400", "hover:underline"); + } else { + a.setAttribute("href", "#"); + } + a.removeAttribute("target"); + a.removeAttribute("rel"); + }); + return root.innerHTML; + } catch { + return html; + } +} + function isAllowedImgSrc(src) { if (src == null || src === "") { return false; @@ -185,30 +255,63 @@ export function sanitizeNomadHtmlDocument(html) { }); } -export function renderNomadMarkdown(markdown) { +export function renderNomadMarkdown(markdown, options = {}) { + const { destinationHash } = options; const raw = marked.parse(normalizeMarkdownInput(markdown ?? "")); - const inner = sanitizeNomadHtmlFragment(raw); + let inner = sanitizeNomadHtmlFragment(raw); + if (destinationHash) { + inner = isolateNomadLinksInHtml(inner, destinationHash); + } return `
${inner}
`; } -export function renderNomadHtmlPage(html) { - return sanitizeNomadHtmlDocument(html); +export function renderNomadHtmlPage(html, options = {}) { + const { destinationHash } = options; + let out = sanitizeNomadHtmlDocument(html); + if (destinationHash) { + out = isolateNomadLinksInHtml(out, destinationHash); + } + return out; } -export function renderNomadPageByPath(pagePathWithoutData, content, pagePartials, MicronParserClass) { +export function renderNomadPageByPath( + pagePathWithoutData, + content, + pagePartials, + MicronParserClass, + renderOptions = {} +) { + const renderMarkdown = renderOptions.renderMarkdown !== false; + const renderHtml = renderOptions.renderHtml !== false; + const renderPlaintext = renderOptions.renderPlaintext !== false; + const nomadDestinationHash = renderOptions.nomadDestinationHash || null; + const linkOpts = { destinationHash: nomadDestinationHash }; const p = (pagePathWithoutData || "").toLowerCase(); if (p.endsWith(".mu")) { const muParser = new MicronParserClass(); - return muParser.convertMicronToHtml(content, pagePartials); + let out = muParser.convertMicronToHtml(content, pagePartials); + if (nomadDestinationHash) { + out = isolateNomadLinksInHtml(out, nomadDestinationHash); + } + return out; } if (p.endsWith(".md")) { - return renderNomadMarkdown(content); + if (renderMarkdown) { + return renderNomadMarkdown(content, linkOpts); + } + return escapeNomadPlainText(content); } if (p.endsWith(".html")) { - return renderNomadHtmlPage(content); + if (renderHtml) { + return renderNomadHtmlPage(content, linkOpts); + } + return escapeNomadPlainText(content); } if (p.endsWith(".txt")) { - return escapeNomadPlainText(content); + if (renderPlaintext) { + return escapeNomadPlainText(content); + } + return `
${escapeHtmlText(content)}
`; } return escapeHtmlText(content); }