/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
import { EMOJI_SHORTCODES } from "@rustpad/emoji-data";
import { parseImageAlias } from "@rustpad/image-alias";
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
}
const emoji = EMOJI_SHORTCODES;
let markdownFiles = new Map();
let markdownFileRoutes = new Map();
function attachmentRoute(value) {
try {
const path = new URL(String(value || ""), location.origin).pathname;
return /^\/f\/[^/]+\/[^/]+$/.test(path) ? path : null;
} catch {
return null;
}
}
function safeUrl(value) {
let raw = String(value || "").trim();
if (!raw || raw.startsWith("//")) return "#";
if (raw.startsWith("#")) return escapeHtml(raw);
const route = attachmentRoute(raw);
if (route && markdownFileRoutes.has(route)) raw = markdownFileRoutes.get(route);
try {
const url = new URL(raw, location.origin);
if (url.protocol === "mailto:") return escapeHtml(url.href);
if (url.protocol !== "http:" && url.protocol !== "https:") return "#";
return escapeHtml(url.href);
} catch {
return "#";
}
}
function safeAttachmentUrl(file, { download = false } = {}) {
let raw = String(file?.url || "").trim();
const route = attachmentRoute(raw);
if (route && markdownFileRoutes.has(route)) raw = markdownFileRoutes.get(route);
try {
const url = new URL(raw, location.origin);
if (url.protocol !== "http:" && url.protocol !== "https:") return "#";
if (download && route) url.searchParams.set("download", "1");
return escapeHtml(url.href);
} catch {
return "#";
}
}
function safeAttachmentPlaybackUrl(file) {
return safeAttachmentUrl(file);
}
function safeAttachmentDownloadUrl(file) {
return safeAttachmentUrl(file, { download: true });
}
function youtubeVideo(value) {
const raw = String(value || "").trim();
if (!raw || raw.startsWith("//")) return null;
try {
const url = new URL(raw, location.origin);
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
const host = url.hostname.toLowerCase();
let id = "";
if (host === "youtu.be") {
id = url.pathname.split("/").filter(Boolean)[0] || "";
} else if (host === "youtube.com" || host.endsWith(".youtube.com") || host === "youtube-nocookie.com" || host.endsWith(".youtube-nocookie.com")) {
if (url.pathname === "/watch") id = url.searchParams.get("v") || "";
else {
const match = url.pathname.match(/^\/(?:shorts|embed|live)\/([A-Za-z0-9_-]+)/);
id = match?.[1] || "";
}
}
return /^[A-Za-z0-9_-]{6,20}$/.test(id) ? { id, url: url.href } : null;
} catch {
return null;
}
}
export function setMarkdownFiles(files) {
const normalized = (Array.isArray(files) ? files : [])
.filter(file => file && file.filename && file.url)
.map(file => ({
filename: String(file.filename),
url: String(file.url),
mimeType: String(file.mime_type || ""),
}));
markdownFiles = new Map(normalized.map(file => [file.filename, file]));
markdownFileRoutes = new Map(normalized
.map(file => [attachmentRoute(file.url), file.url])
.filter(([route]) => route));
}
export function unresolvedMarkdownFileAliases(value) {
const missing = new Set();
const source = String(value || "").replace(/`[^`]*`/g, "");
for (const match of source.matchAll(/\[(?:file|image|img|video)=([^,\]\s]+)(?:,[^\]]*)?\]/gi)) {
if (!markdownFiles.has(match[1])) missing.add(match[1]);
}
return [...missing];
}
function inline(value) {
const tokens = [];
const stash = html => {
const token = `\u0000T${tokens.length}\u0000`;
tokens.push(html);
return token;
};
let html = escapeHtml(value);
html = html.replace(/`([^`]+)`/g, (_, code) => stash(`${code}`));
html = html.replace(/\[(file|image|img|video)=([^,\]\s]+)(?:,([^\]]*))?\]/gi, (match, kind, filename, label) => {
const normalizedKind = kind.toLowerCase();
const file = markdownFiles.get(filename);
if (!file) return match;
if (normalizedKind === "file") {
const text = String(label || filename).trim() || filename;
return stash(`${text}`);
}
if (normalizedKind === "video") {
if (!file.mimeType.startsWith("video/")) return match;
const text = String(label || filename).trim() || filename;
return stash(`${text}`);
}
if (!file.mimeType.startsWith("image/")) return match;
const alias = parseImageAlias(match);
if (!alias) return match;
const alignment = alias.align || "left";
const sized = alias.width && alias.height;
const sizeClass = sized ? " markdown-alias-image--sized" : "";
const style = sized
? ` style="--image-width:${alias.width}px;--image-height:${alias.height}px;--image-aspect:${alias.width} / ${alias.height}"`
: "";
return stash(``);
});
html = html.replace(/!\[([^\]]*)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, alt, url, title) => {
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
return stash(`
`);
});
html = html.replace(/\[([^\]]+)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, label, url, title) => {
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
return stash(`${label}`);
});
html = html.replace(/\[\^([^\]\s]+)\]/g, (_, id) => stash(`?`));
html = html
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/~~([^~]+)~~/g, "
$1")
.replace(/==([^=]+)==/g, "$1")
.replace(/\*([^*]+)\*/g, "$1")
.replace(/(?$1")
.replace(/\^([^^\n]+)\^/g, "$1")
.replace(/:([a-z0-9_+-]+):/gi, (match, name) => emoji[name] || match);
html = html.replace(/(^|[\s(])((?:https?:\/\/|mailto:)[^\s<]+)/gi, (match, prefix, url) => {
const clean = url.replace(/[.,!?;:]+$/, "");
const suffix = url.slice(clean.length);
return `${prefix}${stash(`${clean}`)}${suffix}`;
});
return html.replace(/\u0000T(\d+)\u0000/g, (_, index) => tokens[Number(index)] || "");
}
const languageAliases = {
js: "javascript", javascript: "javascript", jsx: "javascript",
ts: "typescript", typescript: "typescript", tsx: "typescript",
py: "python", python: "python",
rb: "ruby", ruby: "ruby",
rs: "rust", rust: "rust",
php: "php",
sh: "bash", shell: "bash", bash: "bash", zsh: "bash",
c: "c", h: "c",
cpp: "cpp", "c++": "cpp", cxx: "cpp", hpp: "cpp",
cs: "csharp", "c#": "csharp", csharp: "csharp",
java: "java", kotlin: "kotlin", kt: "kotlin",
go: "go", golang: "go",
swift: "swift", dart: "dart", scala: "scala",
html: "html", htm: "html", xml: "xml", svg: "xml",
css: "css", scss: "scss", sass: "scss", less: "less",
json: "json", jsonc: "json", yaml: "yaml", yml: "yaml", toml: "ini", ini: "ini",
sql: "sql", graphql: "graphql", gql: "graphql",
md: "markdown", markdown: "markdown",
dockerfile: "dockerfile", docker: "dockerfile",
makefile: "makefile", make: "makefile",
powershell: "powershell", ps1: "powershell",
lua: "lua", perl: "perl", pl: "perl", r: "r", matlab: "matlab",
nginx: "nginx", apache: "apache", diff: "diff", patch: "diff",
text: "plaintext", txt: "plaintext", plaintext: "plaintext", none: "plaintext",
mermaid: "mermaid"
};
function normalizeLanguage(value) {
const language = String(value || "").trim().toLowerCase();
if (!language) return "";
return languageAliases[language] || language.replace(/[^a-z0-9_-]/g, "");
}
const attrs = (line, editable = false, prefix = "", suffix = "", lineOffset = 0) => ` class="preview-source-line${editable ? " preview-editable" : ""}" data-source-line="${line + lineOffset + 1}"${editable ? ` data-source-prefix="${escapeHtml(prefix)}" data-source-suffix="${escapeHtml(suffix)}"` : ""}`;
const isPlainText = line => !/[`*_~^=\[\]<>|:#]/.test(line) && !/^\s*(?:[-+*>]|\d+\.)\s/.test(line);
function renderStandaloneMedia(line, sourceLine) {
const videoAlias = String(line).trim().match(/^\[video=([^,\]\s]+)(?:,([^\]]*))?\]$/i);
if (videoAlias) {
const filename = videoAlias[1];
const file = markdownFiles.get(filename);
if (!file || !file.mimeType.startsWith("video/")) return null;
const label = String(videoAlias[2] || filename).trim() || filename;
const playbackUrl = safeAttachmentPlaybackUrl(file);
const downloadUrl = safeAttachmentDownloadUrl(file);
return `
Playback is unavailable. Download ${escapeHtml(label)}.
${numbered}`;
} else {
const languageClass = lang ? ` class="language-${escapeHtml(lang)}"` : "";
html += `${body}`;
}
code = []; language = ""; codeLineStart = null; fence = "";
};
for (let index = 0; index < lines.length; index++) {
const line = lines[index];
const fenceMatch = line.match(/^\s*(```+|~~~+)\s*([^\s]*)\s*$/);
if (fenceMatch) {
closeList();
if (inCode && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length) closeCode();
else if (!inCode) {
fence = fenceMatch[1];
const info = fenceMatch[2] || "";
// Generic syntax for every fenced code block:
// ```rust=, ```python=101, ```= or ```=101.
const numbered = info.match(/^(.*?)=(\d*)$/);
language = numbered ? numbered[1] : info;
codeLineStart = numbered ? Number(numbered[2] || 1) : null;
codeStart = index;
}
inCode = !inCode;
continue;
}
if (inCode) { code.push(line); continue; }
const standaloneMedia = renderStandaloneMedia(line, index + lineOffset + 1);
if (standaloneMedia) {
closeList();
html += standaloneMedia;
continue;
}
const delimiter = index + 1 < lines.length ? tableDelimiter(lines[index + 1]) : null;
if (line.includes("|") && delimiter) {
closeList();
const headers = splitTableRow(line);
html += `| ${inline(cell)} | `); html += `
|---|
| ${inline(cells[i] || "")} | `); html += `
", "", lineOffset)}>${inline(line.slice(2))}`; else if (line.trim()) html += `
${inline(line)}
`; else html += `