feat(frontend): enhance link rendering and markdown processing

- Added LinkUtils for detecting and rendering NomadNet and standard links in text.
- Introduced MarkdownRenderer for converting Markdown to HTML, including support for code blocks, headers, and inline formatting.
- Implemented escapeHtml utility function to prevent XSS in rendered text.
- Updated ToastUtils to support an optional key parameter for toast notifications.
- Included Italian language support in the frontend localization.
This commit is contained in:
2026-01-07 19:13:35 -06:00
parent 37d4b317b9
commit 55f718c72b
6 changed files with 179 additions and 12 deletions

View File

@@ -348,9 +348,9 @@ app.whenReady().then(async () => {
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https://*.tile.openstreetmap.org https://tile.openstreetmap.org",
"img-src 'self' data: blob: https://*.tile.openstreetmap.org https://tile.openstreetmap.org https://*.cartocdn.com",
"font-src 'self' data:",
"connect-src 'self' http://localhost:9337 https://localhost:9337 ws://localhost:* wss://localhost:* blob: https://*.tile.openstreetmap.org https://tile.openstreetmap.org https://nominatim.openstreetmap.org https://git.quad4.io",
"connect-src 'self' http://localhost:9337 https://localhost:9337 ws://localhost:* wss://localhost:* blob: https://*.tile.openstreetmap.org https://tile.openstreetmap.org https://nominatim.openstreetmap.org https://git.quad4.io https://*.cartocdn.com",
"media-src 'self' blob:",
"worker-src 'self' blob:",
"frame-src 'self'",

View File

@@ -0,0 +1,41 @@
export default class LinkUtils {
/**
* Detects and wraps NomadNet links in HTML.
* Supports nomadnet://<hash>:/path and <hash>:/path
*/
static renderNomadNetLinks(text) {
if (!text) return "";
// Hash is 32 hex chars. Path is optional.
const hashPattern = "[a-fA-F0-9]{32}";
const nomadnetRegex = new RegExp(`(?:nomadnet://)?(${hashPattern})(?::(/[\\w\\d./?%&=-]*))?`, "g");
return text.replace(nomadnetRegex, (match, hash, path) => {
const fullPath = path || "/page/index.mu";
const url = `${hash}:${fullPath}`;
return `<a href="#" class="nomadnet-link text-blue-600 dark:text-blue-400 hover:underline font-mono" data-nomadnet-url="${url}">${match}</a>`;
});
}
/**
* Basic URL detection for standard http/https links.
*/
static renderStandardLinks(text) {
if (!text) return "";
// Simple regex for URLs
const urlRegex = /(https?:\/\/[^\s<]+)/g;
return text.replace(urlRegex, (url) => {
return `<a href="${url}" target="_blank" rel="noopener noreferrer" class="text-blue-600 dark:text-blue-400 hover:underline">${url}</a>`;
});
}
/**
* Applies all link rendering.
*/
static renderAllLinks(text) {
text = this.renderStandardLinks(text);
text = this.renderNomadNetLinks(text);
return text;
}
}

View File

@@ -0,0 +1,101 @@
import Utils from "./Utils";
import LinkUtils from "./LinkUtils";
export default class MarkdownRenderer {
/**
* A simple Markdown to HTML renderer, cause we dont need another library for this.
* Ported and simplified from meshchatx/src/backend/markdown_renderer.py
*/
static render(text) {
if (!text) {
return "";
}
// Escape HTML entities first to prevent XSS
text = Utils.escapeHtml(text);
// Fenced code blocks - process these FIRST and replace with placeholders
const code_blocks = [];
text = text.replace(/```(\w+)?\n([\s\S]*?)\n```/g, (match, lang, code) => {
const placeholder = `[[CB${code_blocks.length}]]`;
code_blocks.push(
`<pre class="bg-gray-800 dark:bg-zinc-900 text-zinc-100 dark:text-zinc-100 p-3 rounded-lg my-3 overflow-x-auto border border-gray-700 dark:border-zinc-800 font-mono text-sm"><code class="language-${lang || ""} text-inherit">${code}</code></pre>`
);
return placeholder;
});
// Headers
text = text.replace(/^# (.*)$/gm, '<h1 class="text-xl font-bold mt-4 mb-2"> $1</h1>');
text = text.replace(/^## (.*)$/gm, '<h2 class="text-lg font-bold mt-3 mb-1">$1</h2>');
text = text.replace(/^### (.*)$/gm, '<h3 class="text-base font-bold mt-2 mb-1">$1</h3>');
// Bold and Italic
text = text.replace(/\*\*\*(.*?)\*\*\*/g, "<strong><em>$1</em></strong>");
text = text.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>");
text = text.replace(/\*(.*?)\*/g, "<em>$1</em>");
text = text.replace(/___(.*?)___/g, "<strong><em>$1</em></strong>");
text = text.replace(/__(.*?)__/g, "<strong>$1</strong>");
text = text.replace(/_(.*?)_/g, "<em>$1</em>");
// Inline code
text = text.replace(
/`([^`]+)`/g,
'<code class="bg-black/10 dark:bg-white/10 px-1 rounded font-mono text-[0.9em]">$1</code>'
);
// Links
text = LinkUtils.renderAllLinks(text);
// Restore code blocks
for (let i = 0; i < code_blocks.length; i++) {
text = text.replace(`[[CB${i}]]`, code_blocks[i]);
}
// Paragraphs - double newline to p tag
const parts = text.split(/\n\n+/);
const processed_parts = [];
for (let part of parts) {
part = part.trim();
if (!part) continue;
// If it's a placeholder for code block, don't wrap in <p>
if (part.startsWith("<pre") || part.startsWith("<h")) {
processed_parts.push(part);
} else {
// Replace single newlines with <br> for line breaks within paragraphs
part = part.replace(/\n/g, "<br>");
processed_parts.push(`<p class="my-2 leading-relaxed">${part}</p>`);
}
}
return processed_parts.join("\n");
}
/**
* Strips markdown from text for previews.
*/
static strip(text) {
if (!text) {
return "";
}
// Strip fenced code blocks
text = text.replace(/```(\w+)?\n([\s\S]*?)\n```/g, "[Code Block]");
// Strip headers
text = text.replace(/^#+ (.*)$/gm, "$1");
// Strip bold and italic
text = text.replace(/\*\*\*(.*?)\*\*\*/g, "$1");
text = text.replace(/\*\*(.*?)\*\*/g, "$1");
text = text.replace(/\*(.*?)\*/g, "$1");
text = text.replace(/___(.*?)___/g, "$1");
text = text.replace(/__(.*?)__/g, "$1");
text = text.replace(/_(.*?)_/g, "$1");
// Strip inline code
text = text.replace(/`([^`]+)`/g, "$1");
return text;
}
}

View File

@@ -1,24 +1,28 @@
import GlobalEmitter from "./GlobalEmitter";
class ToastUtils {
static show(message, type = "info", duration = 5000) {
GlobalEmitter.emit("toast", { message, type, duration });
static show(message, type = "info", duration = 5000, key = null) {
GlobalEmitter.emit("toast", { message, type, duration, key });
}
static success(message, duration = 5000) {
this.show(message, "success", duration);
static success(message, duration = 5000, key = null) {
this.show(message, "success", duration, key);
}
static error(message, duration = 5000) {
this.show(message, "error", duration);
static error(message, duration = 5000, key = null) {
this.show(message, "error", duration, key);
}
static warning(message, duration = 5000) {
this.show(message, "warning", duration);
static warning(message, duration = 5000, key = null) {
this.show(message, "warning", duration, key);
}
static info(message, duration = 5000) {
this.show(message, "info", duration);
static info(message, duration = 5000, key = null) {
this.show(message, "info", duration, key);
}
static loading(message, duration = 0, key = null) {
this.show(message, "loading", duration, key);
}
}

View File

@@ -183,6 +183,20 @@ class Utils {
const value = rawValue?.toString()?.toLowerCase();
return value === "on" || value === "yes" || value === "true";
}
static escapeHtml(text) {
if (!text) return "";
const map = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
};
return text.replace(/[&<>"']/g, function (m) {
return map[m];
});
}
}
export default Utils;

View File

@@ -12,6 +12,7 @@ import App from "./components/App.vue";
import en from "./locales/en.json";
import de from "./locales/de.json";
import ru from "./locales/ru.json";
import it from "./locales/it.json";
// init i18n
const i18n = createI18n({
@@ -22,6 +23,7 @@ const i18n = createI18n({
en,
de,
ru,
it,
},
});
@@ -179,6 +181,11 @@ const router = createRouter({
path: "/rnpath",
component: defineAsyncComponent(() => import("./components/tools/RNPathPage.vue")),
},
{
name: "rnpath-trace",
path: "/rnpath-trace",
component: defineAsyncComponent(() => import("./components/tools/RNPathTracePage.vue")),
},
{
name: "rnprobe",
path: "/rnprobe",