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(`${html}
`, "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);
}