438 lines
19 KiB
JavaScript
438 lines
19 KiB
JavaScript
/*
|
|
* 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]));
|
|
}
|
|
|
|
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 "#";
|
|
}
|
|
}
|
|
|
|
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)=([^,\]\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>${code}</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(`<a href="${safeUrl(file.url)}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-file-alias="file" data-file-name="${filename}">${text}</a>`);
|
|
}
|
|
if (!file.mimeType.startsWith("image/")) return match;
|
|
return stash(`<img src="${safeUrl(file.url)}" alt="${text}" loading="lazy" decoding="async" referrerpolicy="no-referrer" draggable="false" contenteditable="false" data-file-alias="image" data-file-name="${filename}">`);
|
|
});
|
|
html = html.replace(/!\[([^\]]*)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, alt, url, title) => {
|
|
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
|
|
return stash(`<img src="${safeUrl(url)}" alt="${alt}" loading="lazy" decoding="async" referrerpolicy="no-referrer" draggable="false" contenteditable="false"${titleAttr}>`);
|
|
});
|
|
html = html.replace(/\[([^\]]+)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, label, url, title) => {
|
|
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
|
|
return stash(`<a href="${safeUrl(url)}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer"${titleAttr}>${label}</a>`);
|
|
});
|
|
html = html.replace(/\[\^([^\]\s]+)\]/g, (_, id) => stash(`<sup class="footnote-ref"><a href="#fn-${escapeHtml(id)}" id="fnref-${escapeHtml(id)}">?</a></sup>`));
|
|
|
|
html = html
|
|
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
|
.replace(/~~([^~]+)~~/g, "<s>$1</s>")
|
|
.replace(/==([^=]+)==/g, "<mark>$1</mark>")
|
|
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
|
|
.replace(/(?<!~)~([^~\n]+)~(?!~)/g, "<sub>$1</sub>")
|
|
.replace(/\^([^^\n]+)\^/g, "<sup>$1</sup>")
|
|
.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(`<a href="${safeUrl(clean)}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">${clean}</a>`)}${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 += `<li class="preview-source-line list-source-line${taskClass}" data-source-line="${sourceLine}"${valueAttr} style="--list-gutter-offset:${gutterOffset}">`;
|
|
if (item.checked != null) {
|
|
body += `<input type="checkbox" class="task-checkbox" data-source-line="${sourceLine}"${item.checked ? " checked" : ""}>`;
|
|
}
|
|
body += `<span class="preview-editable list-item-content" data-source-line="${sourceLine}" data-source-prefix="${escapeHtml(prefix)}">${inline(item.text)}</span>`;
|
|
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 += `</li>`;
|
|
}
|
|
|
|
const classAttr = hasTask ? ` class="contains-task-items"` : "";
|
|
return { html: `<${type}${classAttr}>${body}</${type}>`, 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 += `<div class="mermaid preview-source-line" data-source-line="${codeStart + lineOffset + 1}">${body}</div>`;
|
|
} else if (codeLineStart !== null) {
|
|
const numbered = body.split("\n").map((line, index) => `<span class="code-line" data-line="${codeLineStart + index}">${line || " "}</span>`).join("\n");
|
|
const languageClass = lang ? ` class="language-${escapeHtml(lang)}"` : "";
|
|
html += `<pre${attrs(codeStart, false, "", "", lineOffset).replace(' class="', ' class="code-with-lines ')}><code${languageClass}>${numbered}</code></pre>`;
|
|
} else {
|
|
const languageClass = lang ? ` class="language-${escapeHtml(lang)}"` : "";
|
|
html += `<pre${attrs(codeStart, false, "", "", lineOffset)}><code${languageClass}>${body}</code></pre>`;
|
|
}
|
|
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 += `<div class="table-wrap preview-source-line" data-source-line="${index + lineOffset + 1}"><table><thead><tr>`;
|
|
headers.forEach((cell, i) => html += `<th class="preview-editable" data-source-line="${index + lineOffset + 1}" data-table-cell="${i}" style="text-align:${delimiter[i] || "left"}">${inline(cell)}</th>`);
|
|
html += `</tr></thead><tbody>`;
|
|
index += 2;
|
|
while (index < lines.length && lines[index].includes("|") && lines[index].trim()) {
|
|
const cells = splitTableRow(lines[index]);
|
|
html += `<tr>`;
|
|
headers.forEach((_, i) => html += `<td class="preview-editable" data-source-line="${index + lineOffset + 1}" data-table-cell="${i}" style="text-align:${delimiter[i] || "left"}">${inline(cells[i] || "")}</td>`);
|
|
html += `</tr>`;
|
|
index++;
|
|
}
|
|
html += `</tbody></table></div>`;
|
|
index--;
|
|
continue;
|
|
}
|
|
|
|
if (/^<details>\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(/^<summary>([\s\S]*?)<\/summary>$/i);
|
|
if (summaryMatch) { summary = summaryMatch[1].trim() || "Details"; block.shift(); }
|
|
}
|
|
while (block.length && !block[0].trim()) block.shift();
|
|
html += `<details class="markdown-details preview-source-line" data-source-line="${index + lineOffset + 1}"><summary>${inline(summary)}</summary><div class="markdown-details__content">${renderMarkdown(block.join("\n"), lineOffset + index + 1)}</div></details>`;
|
|
index = end;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (/^\s*\[TOC\]\s*$/i.test(line)) {
|
|
closeList();
|
|
if (headings.length) {
|
|
html += `<nav class="markdown-toc preview-source-line" data-source-line="${index + lineOffset + 1}" aria-label="Table of contents"><ol>`;
|
|
headings.forEach(item => html += `<li class="toc-level-${item.level}"><a href="#${escapeHtml(item.id)}">${inline(item.text)}</a></li>`);
|
|
html += `</ol></nav>`;
|
|
}
|
|
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 += `<aside class="markdown-alert markdown-alert--${type}" role="note">${renderMarkdown(body, lineOffset + index + 1)}</aside>`;
|
|
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 += `<h${n} id="${escapeHtml(id)}"${attrs(index, true, `${heading[1]} `, suffix, lineOffset)}>${inline(heading[2])}</h${n}>`;
|
|
} else {
|
|
closeList();
|
|
const definition = index + 1 < lines.length && /^:\s+/.test(lines[index + 1]);
|
|
if (line.trim() && definition) {
|
|
html += `<dl${attrs(index, false, "", "", lineOffset)}><dt>${inline(line)}</dt>`;
|
|
while (index + 1 < lines.length && /^:\s+/.test(lines[index + 1])) {
|
|
index++;
|
|
html += `<dd data-source-line="${index + lineOffset + 1}">${inline(lines[index].replace(/^:\s+/, ""))}</dd>`;
|
|
}
|
|
html += `</dl>`;
|
|
} else if (/^---+$/.test(line.trim())) html += `<hr${attrs(index, false, "", "", lineOffset)}>`;
|
|
else if (line.startsWith("> ")) html += `<blockquote${attrs(index, true, "> ", "", lineOffset)}>${inline(line.slice(2))}</blockquote>`;
|
|
else if (line.trim()) html += `<p${attrs(index, true, "", "", lineOffset)}>${inline(line)}</p>`;
|
|
else html += `<div${attrs(index, true, "", "", lineOffset)}><br></div>`;
|
|
}
|
|
}
|
|
|
|
closeList();
|
|
if (inCode) closeCode();
|
|
|
|
if (footnotes.size) {
|
|
html = html.replace(/<sup class="footnote-ref"><a href="#fn-([^"]+)" id="fnref-\1">\?<\/a><\/sup>/g, (_, id) => {
|
|
const order = [...footnotes.keys()].indexOf(id) + 1;
|
|
return `<sup class="footnote-ref"><a href="#fn-${id}" id="fnref-${id}">${order || "?"}</a></sup>`;
|
|
});
|
|
html += `<section class="footnotes"><hr><ol>`;
|
|
for (const [id, body] of footnotes) {
|
|
html += `<li id="fn-${escapeHtml(id)}">${body.split("\n").map(part => inline(part)).join("<br>")} <a class="footnote-backref" href="#fnref-${escapeHtml(id)}" aria-label="Back to reference">↩</a></li>`;
|
|
}
|
|
html += `</ol></section>`;
|
|
}
|
|
return html;
|
|
}
|