221 lines
7.9 KiB
JavaScript
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
}
|
|
|
|
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 };
|
|
}
|