/* * 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"; function escapeHtml(value) { return String(value).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } function safeUrl(value) { const raw = String(value || "").trim(); if (!raw || raw.startsWith("//")) return "#"; if (raw.startsWith("#")) return escapeHtml(raw); 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 "#"; } } const emoji = EMOJI_SHORTCODES; let markdownFiles = new Map(); export function setMarkdownFiles(files) { markdownFiles = new Map((Array.isArray(files) ? files : []) .filter(file => file && file.filename && file.url) .map(file => [String(file.filename), { url: String(file.url), mimeType: String(file.mime_type || ""), }])); } export function unresolvedMarkdownFileAliases(value) { const missing = new Set(); const source = String(value || "").replace(/`[^`]*`/g, ""); for (const match of source.matchAll(/\[(?:file|image|img)=([^,\]\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)=([^,\]\s]+)(?:,([^\]]*))?\]/gi, (match, kind, filename, label) => { const file = markdownFiles.get(filename); if (!file) return match; const text = String(label || filename).trim() || filename; if (kind.toLowerCase() === "file") { return stash(`${text}`); } if (!file.mimeType.startsWith("image/")) return match; return stash(`${text}`); }); html = html.replace(/!\[([^\]]*)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, alt, url, title) => { const titleAttr = title ? ` title="${escapeHtml(title)}"` : ""; return stash(`${alt}`); }); 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 listLine(line) { const match = line.match(/^(\s*)([-*+]|(\d+)\.)\s+(?:\[([ xX])\]\s+)?(.+)$/); if (!match) return null; const indent = match[1].replace(/\t/g, " ").length; return { indent, whitespace: match[1], type: match[3] ? "ol" : "ul", number: match[3] ? Number(match[3]) : null, checked: match[4] == null ? null : match[4].toLowerCase() === "x", text: match[5], marker: match[3] ? `${match[3]}. ` : `${match[2]} ` }; } function renderList(lines, start, lineOffset = 0, baseIndent = null, forcedType = null, depth = 0) { const first = listLine(lines[start]); if (!first) return null; const indent = baseIndent == null ? first.indent : baseIndent; const type = forcedType || first.type; let index = start; let body = ""; let hasTask = false; while (index < lines.length) { const item = listLine(lines[index]); if (!item || item.indent < indent || item.indent !== indent || item.type !== type) break; const sourceLine = index + lineOffset + 1; const prefix = `${item.whitespace}${item.marker}${item.checked == null ? "" : `[${item.checked ? "x" : " "}] `}`; const valueAttr = ""; const taskClass = item.checked == null ? "" : " task-list-item"; hasTask ||= item.checked != null; const gutterOffset = `${(depth + 1) * 1.75}em`; body += `
  • `; if (item.checked != null) { body += ``; } body += `${inline(item.text)}`; index++; while (index < lines.length) { const nested = listLine(lines[index]); if (!nested || nested.indent <= indent) break; const rendered = renderList(lines, index, lineOffset, nested.indent, nested.type, depth + 1); if (!rendered) break; body += rendered.html; index = rendered.end; } body += `
  • `; } const classAttr = hasTask ? ` class="contains-task-items"` : ""; return { html: `<${type}${classAttr}>${body}`, end: index }; } function splitTableRow(line) { let value = line.trim(); if (value.startsWith("|")) value = value.slice(1); if (value.endsWith("|")) value = value.slice(0, -1); return value.split("|").map(cell => cell.trim().replace(/|/g, "|")); } function tableDelimiter(line) { const cells = splitTableRow(line); if (!cells.length || !cells.every(cell => /^:?-{3,}:?$/.test(cell))) return null; return cells.map(cell => cell.startsWith(":") && cell.endsWith(":") ? "center" : cell.endsWith(":") ? "right" : "left"); } function headingSlug(value) { return String(value) .replace(/\{#[A-Za-z][\w:.-]*\}\s*$/, "") .replace(/[`*_~^=<>]/g, "") .replace(/:([a-z0-9_+-]+):/gi, "$1") .toLowerCase().trim() .replace(/[^a-z0-9\u00c0-\u024f\u1e00-\u1eff]+/g, "-") .replace(/^-+|-+$/g, "") || "section"; } function collectHeadings(lines) { const used = new Map(); const headings = []; let fence = null; lines.forEach((line, index) => { const fenceMatch = line.match(/^\s*(```+|~~~+)/); if (fenceMatch) { if (!fence) fence = fenceMatch[1]; else if (fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length) fence = null; return; } if (fence) return; const match = line.match(/^\s{0,4}(#{1,6})\s+(.+?)(?:\s+\{#([A-Za-z][\w:.-]*)\})?\s*$/); if (!match) return; const base = match[3] || headingSlug(match[2]); const count = used.get(base) || 0; used.set(base, count + 1); const id = count ? `${base}-${count + 1}` : base; headings.push({ level: match[1].length, text: match[2], id, index }); }); return headings; } export function alignPreviewLineNumbers(root) { if (!root) return; const styles = getComputedStyle(root); const targetLeft = parseFloat(styles.paddingLeft || "0") - 50; const rootLeft = root.getBoundingClientRect().left; root.querySelectorAll(".preview-source-line").forEach(line => { const lineLeft = line.getBoundingClientRect().left - rootLeft; line.style.setProperty("--preview-line-left", `${targetLeft - lineLeft}px`); }); } export function renderMarkdown(source, lineOffset = 0) { let html = "", inCode = false, fence = "", language = "", codeLineStart = null, code = [], codeStart = 0; const lines = String(source).split("\n"); const headings = collectHeadings(lines); const headingByLine = new Map(headings.map(item => [item.index, item])); const footnotes = new Map(); for (let i = 0; i < lines.length; i++) { const match = lines[i].match(/^\[\^([^\]\s]+)\]:\s*(.*)$/); if (!match) continue; const body = [match[2]]; let j = i + 1; while (j < lines.length && /^(?: {4}|\t)/.test(lines[j])) { body.push(lines[j].replace(/^(?: {4}|\t)/, "")); lines[j] = ""; j++; } footnotes.set(match[1], body.join("\n")); lines[i] = ""; } const closeList = () => { }; const closeCode = () => { const body = escapeHtml(code.join("\n")); const lang = normalizeLanguage(language); if (lang === "mermaid") { html += `
    ${body}
    `; } else if (codeLineStart !== null) { const numbered = body.split("\n").map((line, index) => `${line || " "}`).join("\n"); const languageClass = lang ? ` class="language-${escapeHtml(lang)}"` : ""; html += `${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 delimiter = index + 1 < lines.length ? tableDelimiter(lines[index + 1]) : null; if (line.includes("|") && delimiter) { closeList(); const headers = splitTableRow(line); html += `
    `; headers.forEach((cell, i) => html += ``); html += ``; index += 2; while (index < lines.length && lines[index].includes("|") && lines[index].trim()) { const cells = splitTableRow(lines[index]); html += ``; headers.forEach((_, i) => html += ``); html += ``; index++; } html += `
    ${inline(cell)}
    ${inline(cells[i] || "")}
    `; index--; continue; } if (/^
    \s*$/i.test(line.trim())) { closeList(); let end = index + 1; while (end < lines.length && !/^<\/details>\s*$/i.test(lines[end].trim())) end++; if (end < lines.length) { const block = lines.slice(index + 1, end); let summary = "Details"; while (block.length && !block[0].trim()) block.shift(); if (block.length) { const summaryMatch = block[0].trim().match(/^([\s\S]*?)<\/summary>$/i); if (summaryMatch) { summary = summaryMatch[1].trim() || "Details"; block.shift(); } } while (block.length && !block[0].trim()) block.shift(); html += `
    ${inline(summary)}
    ${renderMarkdown(block.join("\n"), lineOffset + index + 1)}
    `; index = end; continue; } } if (/^\s*\[TOC\]\s*$/i.test(line)) { closeList(); if (headings.length) { html += ``; } continue; } const alertStart = line.match(/^\s*:::(success|info|warning|danger)\s*$/i); if (alertStart) { closeList(); let end = index + 1; while (end < lines.length && !/^\s*:::\s*$/.test(lines[end])) end++; if (end < lines.length) { const type = alertStart[1].toLowerCase(); const body = lines.slice(index + 1, end).join("\n"); html += ``; index = end; continue; } } const heading = line.match(/^\s{0,4}(#{1,6})\s+(.+?)(?:\s+\{#([A-Za-z][\w:.-]*)\})?\s*$/); const listItem = listLine(line); if (listItem) { const rendered = renderList(lines, index, lineOffset, listItem.indent, listItem.type); html += rendered.html; index = rendered.end - 1; } else if (heading) { closeList(); const n = heading[1].length; const resolved = headingByLine.get(index); const id = resolved?.id || heading[3] || headingSlug(heading[2]); const suffix = heading[3] ? ` {#${heading[3]}}` : ""; html += `${inline(heading[2])}`; } else { closeList(); const definition = index + 1 < lines.length && /^:\s+/.test(lines[index + 1]); if (line.trim() && definition) { html += `
    ${inline(line)}
    `; while (index + 1 < lines.length && /^:\s+/.test(lines[index + 1])) { index++; html += `
    ${inline(lines[index].replace(/^:\s+/, ""))}
    `; } html += ``; } else if (/^---+$/.test(line.trim())) html += ``; else if (line.startsWith("> ")) html += ` ", "", lineOffset)}>${inline(line.slice(2))}`; else if (line.trim()) html += `${inline(line)}

    `; else html += `
    `; } } closeList(); if (inCode) closeCode(); if (footnotes.size) { html = html.replace(/\?<\/a><\/sup>/g, (_, id) => { const order = [...footnotes.keys()].indexOf(id) + 1; return `${order || "?"}`; }); html += `

      `; for (const [id, body] of footnotes) { html += `
    1. ${body.split("\n").map(part => inline(part)).join("
      ")}
    2. `; } html += `
    `; } return html; }