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:
@@ -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'",
|
||||
|
||||
41
meshchatx/src/frontend/js/LinkUtils.js
Normal file
41
meshchatx/src/frontend/js/LinkUtils.js
Normal 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;
|
||||
}
|
||||
}
|
||||
101
meshchatx/src/frontend/js/MarkdownRenderer.js
Normal file
101
meshchatx/src/frontend/js/MarkdownRenderer.js
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
return text.replace(/[&<>"']/g, function (m) {
|
||||
return map[m];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default Utils;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user