v0.2.87
This commit is contained in:
1 parent
acfe926066
commit
df403c0aa7
26 files changed
+1502
-144
No files matched your search
+78
-24
@@ -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]);
|
||||
|
||||
Reference in new issue
Block a user