This commit is contained in:
Mateusz Gruszczyński committed 2026-10-12 12:19:33 +02:00
1 parent acfe926066
commit df403c0aa7
26 files changed
+1502 -144

No files matched your search

+78 -24
View File
@@ -18,6 +18,42 @@ function escapeHtml(value) {
const emoji = EMOJI_SHORTCODES;
let markdownFiles = new Map();
let markdownFileRoutes = new Map();
let markdownMentions = [];
let markdownMentionMatcher = null;
let markdownMentionLookup = new Map();
export function setMarkdownMentions(users) {
const names = new Set();
markdownMentions = (Array.isArray(users) ? users : []).flatMap(user => {
const nickname = String(user?.nickname || "").trim();
const key = nickname.toLocaleLowerCase();
if (!nickname || names.has(key)) return [];
names.add(key);
const color = /^#[0-9a-f]{6}$/i.test(user?.color || "") ? user.color : defaultMentionColor(nickname);
return [{ nickname, color }];
}).sort((a, b) => b.nickname.length - a.nickname.length);
markdownMentionLookup = new Map(markdownMentions.map(user => [escapeHtml(user.nickname).toLocaleLowerCase(), user]));
markdownMentionMatcher = markdownMentions.length
? new RegExp(`(^|[^\\p{L}\\p{N}_@])@(${markdownMentions.map(user => escapeRegex(escapeHtml(user.nickname))).join("|")})(?![\\p{L}\\p{N}_-]|\\.[\\p{L}\\p{N}_-])`, "giu")
: null;
}
function defaultMentionColor(name) {
let hash = 0;
for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) % 360;
return `hsl(${hash} 70% 62%)`;
}
function escapeRegex(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
function highlightMentions(html) {
if (!markdownMentionMatcher) return html;
// Process text nodes only, not markup, links or attributes.
return html.split(/(<[^>]*>)/g).map(part => part.startsWith("<") ? part : part.replace(markdownMentionMatcher, (match, before, name) => {
const user = markdownMentionLookup.get(name.toLocaleLowerCase());
return user ? `${before}<span class="markdown-mention" style="--owner:${user.color}" title="${escapeHtml(user.nickname)}">@${escapeHtml(user.nickname)}</span>` : match;
})).join("");
}
function attachmentRoute(value) {
try {
@@ -175,7 +211,7 @@ function inline(value) {
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)] || "");
return highlightMentions(html).replace(/\u0000T(\d+)\u0000/g, (_, index) => tokens[Number(index)] || "");
}
@@ -400,11 +436,21 @@ export function alignPreviewLineNumbers(root) {
});
}
export function renderMarkdown(source, lineOffset = 0) {
let html = "", inCode = false, fence = "", language = "", codeLineStart = null, code = [], codeStart = 0;
function stripFenceIndent(line, columns) {
let removed = 0, index = 0;
while (index < line.length && removed < columns) {
if (line[index] === " ") { removed++; index++; }
else if (line[index] === "\t" && removed + 4 <= columns) { removed += 4; index++; }
else break;
}
return line.slice(index);
}
export function renderMarkdown(source, lineOffset = 0, documentHeadings = null) {
let html = "", inCode = false, fence = "", fenceIndent = 0, 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 headings = documentHeadings || collectHeadings(lines).map(item => ({ ...item, index: item.index + lineOffset }));
const headingByLine = new Map(headings.map(item => [item.index - lineOffset, item]));
const footnotes = new Map();
for (let i = 0; i < lines.length; i++) {
@@ -425,39 +471,45 @@ export function renderMarkdown(source, lineOffset = 0) {
const closeCode = () => {
const body = escapeHtml(code.join("\n"));
const lang = normalizeLanguage(language);
const indentStyle = fenceIndent > 0 ? ` style="margin-left:${fenceIndent}ch"` : "";
if (lang === "mermaid") {
html += `<div class="mermaid preview-source-line" data-source-line="${codeStart + lineOffset + 1}">${body}</div>`;
html += `<div class="mermaid preview-source-line" data-source-line="${codeStart + lineOffset + 1}"${indentStyle}>${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("");
const languageClass = lang ? ` class="language-${escapeHtml(lang)}"` : "";
html += `<pre${attrs(codeStart, false, "", "", lineOffset).replace(' class="', ' class="code-with-lines ')}><code${languageClass}>${numbered}</code></pre>`;
html += `<pre${attrs(codeStart, false, "", "", lineOffset).replace(' class="', ' class="code-with-lines ')}${indentStyle}><code${languageClass}>${numbered}</code></pre>`;
} else {
const languageClass = lang ? ` class="language-${escapeHtml(lang)}"` : "";
html += `<pre${attrs(codeStart, false, "", "", lineOffset)}><code${languageClass}>${body}</code></pre>`;
html += `<pre${attrs(codeStart, false, "", "", lineOffset)}${indentStyle}><code${languageClass}>${body}</code></pre>`;
}
code = []; language = ""; codeLineStart = null; fence = "";
code = []; language = ""; codeLineStart = null; fence = ""; fenceIndent = 0;
};
for (let index = 0; index < lines.length; index++) {
const line = lines[index];
const fenceMatch = line.match(/^\s*(```+|~~~+)\s*([^\s]*)\s*$/);
const fenceMatch = line.match(/^([ \t]*)(`{3,}|~{3,})[ \t]*([^\s]*)[ \t]*$/);
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 marker = fenceMatch[2];
if (inCode && marker[0] === fence[0] && marker.length >= fence.length && !fenceMatch[3]) {
closeCode();
inCode = false;
continue;
}
if (!inCode) {
closeList();
fence = marker;
fenceIndent = fenceMatch[1].replace(/\t/g, " ").length;
const info = fenceMatch[3] || "";
// ```rust=, ```python=101, ```= and ```=101 enable line numbers.
const numbered = info.match(/^(.*?)=(\d*)$/);
language = numbered ? numbered[1] : info;
codeLineStart = numbered ? Number(numbered[2] || 1) : null;
codeStart = index;
inCode = true;
continue;
}
inCode = !inCode;
continue;
}
if (inCode) { code.push(line); continue; }
if (inCode) { code.push(stripFenceIndent(line, fenceIndent)); continue; }
const standaloneMedia = renderStandaloneMedia(line, index + lineOffset + 1);
if (standaloneMedia) {
@@ -500,7 +552,7 @@ export function renderMarkdown(source, lineOffset = 0) {
}
while (bodyStart < end && !lines[bodyStart].trim()) bodyStart++;
const body = lines.slice(bodyStart, end).join("\n");
html += `<details class="markdown-details preview-source-line" data-source-line="${index + lineOffset + 1}" data-source-end-line="${end + lineOffset + 1}"><summary>${inline(summary)}</summary><div class="markdown-details__content">${renderMarkdown(body, lineOffset + bodyStart)}</div></details>`;
html += `<details class="markdown-details preview-source-line" data-source-line="${index + lineOffset + 1}" data-source-end-line="${end + lineOffset + 1}"><summary>${inline(summary)}</summary><div class="markdown-details__content">${renderMarkdown(body, lineOffset + bodyStart, headings)}</div></details>`;
index = end;
continue;
}
@@ -508,7 +560,7 @@ export function renderMarkdown(source, lineOffset = 0) {
if (/^\s*\[TOC\]\s*$/i.test(line)) {
closeList();
const tocHeadings = headings.filter(item => item.index > index);
const tocHeadings = headings.filter(item => item.index > index + lineOffset);
if (tocHeadings.length) {
html += `<nav class="markdown-toc preview-source-line" data-source-line="${index + lineOffset + 1}" aria-label="${escapeHtml(t("markdown.toc", {}, "Table of contents"))}">${renderTableOfContentsList(tableOfContentsTree(tocHeadings))}</nav>`;
}
@@ -523,7 +575,7 @@ export function renderMarkdown(source, lineOffset = 0) {
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>`;
html += `<aside class="markdown-alert markdown-alert--${type}" role="note">${renderMarkdown(body, lineOffset + index + 1, headings)}</aside>`;
index = end;
continue;
}
@@ -558,7 +610,9 @@ export function renderMarkdown(source, lineOffset = 0) {
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}>`;
const permalinkLabel = t("markdown.headingPermalink", {}, "Link to heading");
const permalink = `<a class="heading-permalink" href="#${encodeURIComponent(id)}" aria-label="${escapeHtml(permalinkLabel)}" title="${escapeHtml(permalinkLabel)}" contenteditable="false">#</a>`;
html += `<h${n} id="${escapeHtml(id)}"${attrs(index, true, `${heading[1]} `, suffix, lineOffset)}>${inline(heading[2])}${permalink}</h${n}>`;
} else {
closeList();
const definition = index + 1 < lines.length && /^:\s+/.test(lines[index + 1]);