Files
harbour-quill/qml/components/Markdown.js
T

221 lines
7.9 KiB
JavaScript

// Markdown -> HTML subset (Qt rich text) renderer. Pure ES5, no deps.
// IMPORTANT (QML): functions are TOP-LEVEL so a QML namespace import exposes
// them directly: import "../components/Markdown.js" as Markdown
// ... Markdown.render(src) ...
// (an IIFE + var would hide them under Markdown.Markdown and QML would throw
// "Property 'render' of object [object Object] is not a function").
// The module.exports guard at the bottom is for node tests only.
//
// render(src, baseUrl, opts):
// baseUrl - resolves relative links/images against (the note's WebDAV folder)
// opts.imageMap - { url: localCachePath } -> inline <img> instead of placeholder
// opts.imageLabel - localized word for the image placeholder ("image")
function escapeHtml(s) {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function resolveUrl(u, baseUrl) {
if (baseUrl && !/^[a-z]+:/i.test(u) && u.charAt(0) !== "#" && u.charAt(0) !== "/") {
return String(baseUrl).replace(/\/+$/, "") + "/" + u;
}
return u;
}
function inline(s, baseUrl, opts) {
s = escapeHtml(s);
s = s.replace(/`([^`]+)`/g, "<tt>$1</tt>");
s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, function (m, alt, u) {
var key = resolveUrl(u, baseUrl);
var p = opts && opts.imageMap ? opts.imageMap[key] : "";
if (p && p !== "error") {
return '<img src="file://' + p + '" width="350" height="-1" alt="' + alt + '"/>';
}
var label = opts && opts.imageLabel ? opts.imageLabel : "image";
return "<i>[" + label + ": " + alt + "]</i>";
});
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, function (m, t, u) {
return '<a href="' + resolveUrl(u, baseUrl) + '">' + t + "</a>";
});
// Wiki links [[Nota]] -> quill://<url-encoded name.md>; the app intercepts
// the scheme and opens the note in the editor.
s = s.replace(/\[\[([^\]]+)\]\]/g, function (m, name) {
var full = name;
var lower = name.toLowerCase();
if (lower.indexOf(".md") !== lower.length - 3) full = name + ".md";
var label = full;
if (label.toLowerCase().indexOf(".md") === label.length - 3) {
label = label.substring(0, label.length - 3);
}
return '<a href="quill://' + encodeURIComponent(full) + '">' + label + "</a>";
});
s = s.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>");
s = s.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, "$1<i>$2</i>");
s = s.replace(/~~([^~]+)~~/g, "<s>$1</s>");
return s;
}
// Split a table row on unescaped pipes; leading/trailing pipe is optional.
function splitRow(s) {
var t = s.trim();
if (t.charAt(0) === "|") t = t.substring(1);
if (t.charAt(t.length - 1) === "|") t = t.substring(0, t.length - 1);
var parts = t.split("|");
var cells = [];
for (var i = 0; i < parts.length; i++) cells.push(parts[i].trim());
return cells;
}
// GFM alignment from a delimiter cell: ":---" left, "---:" right, ":---:" center.
function alignmentFromSep(sep, n) {
var parts = splitRow(sep);
var aligns = [];
for (var i = 0; i < n; i++) {
var p = i < parts.length ? parts[i] : "";
if (p.charAt(0) === ":" && p.charAt(p.length - 1) === ":") aligns.push("center");
else if (p.charAt(0) === ":") aligns.push("left");
else if (p.charAt(p.length - 1) === ":") aligns.push("right");
else aligns.push("");
}
return aligns;
}
function alignAttr(a) {
return a ? ' align="' + a + '"' : "";
}
function render(src, baseUrl, opts) {
if (!src) return "";
src = src.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
// Strip UTF-8 BOM: a leading \uFEFF breaks the first heading/block
// regex (line no longer starts with #) and the symbol stays visible.
src = src.replace(/^\uFEFF/, "");
var lines = src.split("\n");
var out = [];
var i = 0;
var n = lines.length;
var headingRe = /^\s*(#{1,6})\s+(.*)$/;
var hrRe = /^\s*(-{3,}|\*{3,}|_{3,})\s*$/;
var quoteRe = /^>\s?(.*)$/;
var listRe = /^\s*([-*+]|\d+[.)])\s+(.*)$/;
var fenceRe = /^```/;
var tableHeaderRe = /^\s*\|.*\|/;
var tableSepRe = /^\s*\|?[\s:|-]+\|?\s*$/;
while (i < n) {
var line = lines[i];
var m;
if (fenceRe.test(line)) {
var buf = [];
i++;
while (i < n && !fenceRe.test(lines[i])) {
buf.push(lines[i]);
i++;
}
i++; // closing fence
out.push('<pre style="color:#9a9a9a">' + escapeHtml(buf.join("\n")) + "</pre>");
continue;
}
// GFM table: header row + delimiter row (must contain a pipe and a dash).
if (tableHeaderRe.test(line) && i + 1 < n) {
var sepLine = lines[i + 1];
if (tableSepRe.test(sepLine) && sepLine.indexOf("|") >= 0 && sepLine.indexOf("-") >= 0) {
var headerCells = splitRow(line);
var aligns = alignmentFromSep(sepLine, headerCells.length);
var tbody = [];
i += 2;
while (i < n) {
var dl = lines[i];
if (dl.trim() === "" || dl.indexOf("|") < 0) break;
tbody.push(splitRow(dl));
i++;
}
var t = '<table border="1" cellspacing="0" cellpadding="4">';
t += "<tr>";
for (var h = 0; h < headerCells.length; h++) {
t += "<th" + alignAttr(aligns[h]) + "><b>" + inline(headerCells[h], baseUrl, opts) + "</b></th>";
}
t += "</tr>";
for (var r = 0; r < tbody.length; r++) {
t += "<tr>";
var row = tbody[r];
for (var c = 0; c < headerCells.length; c++) {
var cell = c < row.length ? row[c] : "";
t += "<td" + alignAttr(aligns[c]) + ">" + inline(cell, baseUrl, opts) + "</td>";
}
t += "</tr>";
}
t += "</table>";
out.push(t);
continue;
}
}
m = headingRe.exec(line);
if (m) {
var level = m[1].length;
out.push("<h" + level + ">" + inline(m[2], baseUrl, opts) + "</h" + level + ">");
i++;
continue;
}
if (hrRe.test(line)) {
out.push("<hr/>");
i++;
continue;
}
m = quoteRe.exec(line);
if (m) {
var q = [];
while (i < n) {
var qm = quoteRe.exec(lines[i]);
if (!qm) break;
q.push(inline(qm[1], baseUrl, opts));
i++;
}
out.push('<p style="color:#8f8f8f"><i>' + q.join("<br/>") + "</i></p>");
continue;
}
m = listRe.exec(line);
if (m) {
var ordered = /^\s*\d/.test(line);
var items = [];
while (i < n) {
var lm = listRe.exec(lines[i]);
if (!lm) break;
items.push("<li>" + inline(lm[2], baseUrl, opts) + "</li>");
i++;
}
out.push(ordered ? "<ol>" + items.join("") + "</ol>" : "<ul>" + items.join("") + "</ul>");
continue;
}
if (line.trim() === "") {
i++;
continue;
}
var para = [];
while (i < n) {
var pl = lines[i];
if (pl.trim() === "" || headingRe.test(pl) || hrRe.test(pl) ||
quoteRe.test(pl) || listRe.test(pl) || fenceRe.test(pl)) {
break;
}
para.push(inline(pl, baseUrl, opts));
i++;
}
out.push("<p>" + para.join("<br/>") + "</p>");
}
return out.join("\n");
}
if (typeof module !== "undefined" && module.exports) {
module.exports = { render: render };
}