1
0
mirror of https://github.com/coalaura/whiskr.git synced 2025-12-02 20:22:52 +00:00
Files
whiskr/static/js/markdown.js

264 lines
5.9 KiB
JavaScript
Raw Normal View History

2025-08-09 21:16:24 +02:00
(() => {
2025-09-04 17:15:09 +02:00
const timeouts = new WeakMap(),
scrollState = {
el: null,
startX: 0,
scrollLeft: 0,
pointerId: null,
moved: false,
};
2025-08-09 21:16:24 +02:00
marked.use({
async: false,
breaks: false,
gfm: true,
pedantic: false,
2025-09-04 17:15:09 +02:00
walkTokens: token => {
2025-08-23 16:17:01 +02:00
const { type, text } = token;
2025-08-09 21:16:24 +02:00
2025-08-15 03:38:24 +02:00
if (type === "html") {
2025-11-04 00:23:29 +01:00
token.text = escapeHtml(token.text);
2025-08-15 03:38:24 +02:00
return;
2025-11-04 21:08:59 +01:00
}
2025-08-09 21:16:24 +02:00
2025-11-17 23:25:05 +01:00
if (type === "code") {
if (text.trim().match(/^§\|FILE\|\d+\|§$/gm)) {
token.type = "text";
2025-08-23 16:17:01 +02:00
2025-11-17 23:25:05 +01:00
return;
}
2025-08-09 21:16:24 +02:00
2025-11-17 23:25:05 +01:00
const lang = token.lang || "plaintext";
2025-08-09 21:16:24 +02:00
2025-11-17 23:25:05 +01:00
let code;
if (lang && hljs.getLanguage(lang)) {
code = hljs.highlight(text.trim(), {
language: lang,
});
} else {
code = hljs.highlightAuto(text.trim());
}
token.escaped = true;
token.lang = code.language || "plaintext";
token.text = code.value;
}
2025-08-09 21:16:24 +02:00
},
renderer: {
2025-11-04 21:08:59 +01:00
code: code => {
2025-11-30 22:52:16 +01:00
const header = `<div class="pre-header">${escapeHtml(code.lang)}</div>`;
const button = `<button class="pre-copy" title="Copy code contents"></button>`;
2025-08-09 21:16:24 +02:00
2025-11-30 22:52:16 +01:00
return `<pre class="l-${escapeHtml(code.lang)}">${header}${button}<code>${code.text}</code></pre>`;
2025-08-09 21:16:24 +02:00
},
2025-08-11 01:38:16 +02:00
2025-11-04 21:08:59 +01:00
link: link => `<a href="${link.href}" target="_blank">${escapeHtml(link.text || link.href)}</a>`,
2025-08-09 21:16:24 +02:00
},
2025-09-04 17:15:09 +02:00
hooks: {
postprocess: html => {
html = html.replace(/<table>/g, `<div class="table-wrapper"><table>`);
html = html.replace(/<\/ ?table>/g, `</table></div>`);
return html;
},
},
2025-08-09 21:16:24 +02:00
});
2025-11-04 00:23:29 +01:00
function generateID() {
return `${Math.random().toString(36).slice(2)}${"0".repeat(8)}`.slice(0, 8);
}
function escapeHtml(text) {
return text.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function formatBytes(bytes) {
if (!+bytes) {
return "0B";
}
const sizes = ["B", "kB", "MB", "GB", "TB"],
i = Math.floor(Math.log(bytes) / Math.log(1000));
const val = bytes / Math.pow(1000, i),
dec = i === 0 ? 0 : val < 10 ? 2 : 1;
return `${val.toFixed(dec)}${sizes[i]}`;
}
function parse(markdown) {
const starts = (markdown.match(/^FILE\s+"([^"]+)"(?:\s+LINES\s+\d+)?\s*\r?\n<<CONTENT>>\s*$/gm) || []).length,
ends = (markdown.match(/^<<END(?:ING)?>>$/gm) || []).length;
if (starts !== ends) {
markdown += "\n<<ENDING>>";
}
const files = [],
table = {};
markdown = markdown.replace(/^FILE\s+"([^"]+)"(?:\s+LINES\s+(\d+))?\s*\r?\n<<CONTENT>>\s*\r?\n([\s\S]*?)\r?\n<<END(ING)?>>$/gm, (_a, name, _b, content, ending) => {
const index = files.length,
id = generateID();
files.push({
id: id,
name: name,
size: content.length,
busy: !!ending,
});
table[id] = {
name: name,
content: content,
};
return `§|FILE|${index}`;
});
2025-11-04 21:12:27 +01:00
const html = marked.parse(markdown).replace(/(?:<p>\s*)?§\|FILE\|(\d+)\|§(?:<\/p>\s*)?/g, (match, index) => {
2025-11-04 00:23:29 +01:00
index = parseInt(index, 10);
if (index < files.length) {
const file = files[index],
name = escapeHtml(file.name);
return `<div class="inline-file ${file.busy ? "busy" : ""}" data-id="${file.id}"><div class="name" title="${name}">${name}</div><div class="size"><sup>${formatBytes(file.size)}</sup></div><button class="download" title="Download file"></button></div>`;
}
return match;
});
return {
html: html,
files: table,
};
}
2025-09-04 17:15:09 +02:00
addEventListener("click", event => {
2025-11-30 22:52:16 +01:00
const button = event.target;
if (!button.classList.contains("pre-copy")) {
return;
}
const pre = button.closest("pre"),
2025-08-09 21:16:24 +02:00
code = pre?.querySelector("code");
if (!code) {
return;
}
clearTimeout(timeouts.get(pre));
navigator.clipboard.writeText(code.textContent.trim());
button.classList.add("copied");
timeouts.set(
pre,
setTimeout(() => {
button.classList.remove("copied");
2025-09-04 17:15:09 +02:00
}, 1000)
2025-08-09 21:16:24 +02:00
);
});
2025-09-04 17:15:09 +02:00
addEventListener("pointerover", event => {
if (event.pointerType !== "mouse") {
return;
}
const el = event.target.closest(".table-wrapper");
if (!el) {
return;
}
el.classList.toggle("overflowing", el.scrollWidth - el.clientWidth > 1);
});
addEventListener("pointerdown", event => {
if (event.button !== 0) {
return;
}
const el = event.target.closest(".table-wrapper");
2025-09-04 17:15:38 +02:00
if (!el || !el.classList.contains("overflowing")) {
2025-09-04 17:15:09 +02:00
return;
}
scrollState.el = el;
scrollState.pointerId = event.pointerId;
scrollState.startX = event.clientX;
scrollState.scrollLeft = el.scrollLeft;
scrollState.moved = false;
el.classList.add("dragging");
el.setPointerCapture?.(event.pointerId);
event.preventDefault();
});
addEventListener("pointermove", event => {
if (!scrollState.el || event.pointerId !== scrollState.pointerId) {
return;
}
const dx = event.clientX - scrollState.startX;
if (Math.abs(dx) > 3) {
scrollState.moved = true;
}
scrollState.el.scrollLeft = scrollState.scrollLeft - dx;
});
function endScroll(event) {
if (!scrollState.el || (event && event.pointerId !== scrollState.pointerId)) {
return;
}
scrollState.el.classList.remove("dragging");
scrollState.el.releasePointerCapture?.(scrollState.pointerId);
scrollState.el = null;
scrollState.pointerId = null;
}
addEventListener("pointerup", endScroll);
addEventListener("pointercancel", endScroll);
window.render = markdown => {
2025-11-04 00:23:29 +01:00
return parse(markdown);
2025-08-09 21:16:24 +02:00
};
2025-09-12 20:52:10 +02:00
window.renderInline = markdown => {
return marked.parseInline(markdown.trim());
};
2025-09-30 22:25:01 +02:00
window.stripMarkdown = markdown => {
return (
markdown
// Remove headings
.replace(/^#+\s*/gm, "")
// Remove links, keeping the link text
2025-11-04 21:08:59 +01:00
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
2025-09-30 22:25:01 +02:00
// Remove bold/italics, keeping the text
.replace(/(\*\*|__|\*|_|~~|`)(.*?)\1/g, "$2")
// Remove images
.replace(/!\[[^\]]*\]\([^)]*\)/g, "")
// Remove horizontal rules
.replace(/^-{3,}\s*$/gm, "")
// Remove list markers
.replace(/^\s*([*-]|\d+\.)\s+/gm, "")
// Collapse multiple newlines into one
.replace(/\n{2,}/g, "\n")
.trim()
);
};
2025-11-30 22:52:16 +01:00
})();