benjivers/notes Public Public: anyone can read this; nobody can change it.

Files 1060c95de59213030a45f268d791921201715cde
234 lines · 9.6 KB · ~2.8k tok Raw History
1
// Markdown, the vault's flavour of it, in one file that runs in two places:
2
// the server imports it to render the first paint, and the browser loads
3
// it to render the live preview while you type. One renderer, so what you
4
// see while writing is what the page shows afterwards.
5
//
6
// Safe by construction: the text is escaped first, and every tag comes
7
// from a pattern matched on the escaped text — nothing a note says can
8
// open a tag the renderer did not write. Links go only to http(s), mailto
9
// and this site's own paths.
10
//
11
// Beyond CommonMark's everyday core: [[wikilinks]] (with |alias and
12
// #heading), #tags, ==highlights==, ~~strikes~~, - [ ] tasks, tables,
13
// and Obsidian's callouts (> [!note] Title).
14
15
const ESC = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
16
const UNESC = { "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": '"', "&#39;": "'" };
17
18
/** @param {string} s */
19
export const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ESC[c] ?? c);
20
/** @param {string} s */
21
const unescapeHtml = (s) => s.replace(/&(?:amp|lt|gt|quot|#39);/g, (e) => UNESC[e] ?? e);
22
23
/** A heading's anchor: lower case, words joined by dashes. @param {string} s */
24
export const slug = (s) =>
25
  s
26
    .toLowerCase()
27
    .replace(/[^\p{L}\p{N}\s-]/gu, "")
28
    .trim()
29
    .replace(/\s+/g, "-");
30
31
/** The text with fenced code and inline code removed, for finding links and tags. @param {string} md */
32
const prose = (md) => md.replace(/^(```|~~~)[^\n]*\n[\s\S]*?(?:^\1[^\n]*$|(?![\s\S]))/gm, "").replace(/`[^`\n]*`/g, "");
33
34
const WIKI = /(!?)\[\[([^\[\]\n|]+?)(?:\|([^\[\]\n]+?))?\]\]/g;
35
const TAG = /(^|[\s(])#([\p{L}\p{N}_/-]*[\p{L}_/-][\p{L}\p{N}_/-]*)/gu;
36
37
/** The note titles a note links to, in order, once each (no #heading). @param {string} md */
38
export function links(md) {
39
  const seen = new Set();
40
  const out = [];
41
  for (const m of prose(md).matchAll(WIKI)) {
42
    const target = m[2].split("#")[0].trim();
43
    const key = target.toLowerCase();
44
    if (target !== "" && !seen.has(key)) {
45
      seen.add(key);
46
      out.push(target);
47
    }
48
  }
49
  return out;
50
}
51
52
/** The tags a note carries, lower-cased, once each, without the #. @param {string} md */
53
export function tags(md) {
54
  const out = new Set();
55
  for (const m of prose(md).matchAll(TAG)) out.add(m[2].toLowerCase());
56
  return [...out];
57
}
58
59
/** The note's headings, for an outline. @param {string} md */
60
export function headings(md) {
61
  const out = [];
62
  for (const m of prose(md).matchAll(/^(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$/gm)) {
63
    const level = m[1].length;
64
    const text = m[2].trim();
65
    if (text === "") continue;
66
    out.push({ level, text, id: slug(text) });
67
  }
68
  return out;
69
}
70
71
const SAFE_URL = /^(?:https?:\/\/|mailto:|\/(?!\/)|#)/i;
72
73
/**
74
 * One line's inline marks, on text that is already escaped.
75
 * @param {string} text escaped
76
 * @param {Options} o
77
 */
78
function inline(text, o) {
79
  // Code spans first: what is inside them is no mark.
80
  const parts = text.split(/(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/);
81
  let out = "";
82
  for (let i = 0; i < parts.length; i++) {
83
    if (i % 3 === 0) out += marks(parts[i], o);
84
    else if (i % 3 === 2) out += `<code>${parts[i]}</code>`;
85
  }
86
  return out;
87
}
88
89
/** @param {string} s escaped @param {Options} o */
90
function marks(s, o) {
91
  const held = [];
92
  const hold = (html) => `\u0000${held.push(html) - 1}\u0000`;
93
  s = s
94
    .replace(WIKI, (_, embed, target, alias) => {
95
      const [title, heading] = unescapeHtml(target).split("#");
96
      const name = title.trim();
97
      const shown = alias ?? target;
98
      const href = o.resolve(name);
99
      const anchor = heading ? `#${slug(heading)}` : "";
100
      return hold(
101
        href === null
102
          ? `<a class="wikilink unresolved" href="/open?title=${encodeURIComponent(name)}" data-title="${escapeHtml(name)}">${shown}</a>`
103
          : `<a class="wikilink${embed ? " embed" : ""}" href="${escapeHtml(href)}${anchor}" data-title="${escapeHtml(name)}">${shown}</a>`,
104
      );
105
    })
106
    .replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (whole, label, url) =>
107
      SAFE_URL.test(unescapeHtml(url))
108
        ? hold(`<a href="${url}"${/^https?:/i.test(url) ? ' rel="noopener noreferrer" target="_blank"' : ""}>${label}</a>`)
109
        : whole,
110
    )
111
    .replace(/(^|[\s(])(https?:\/\/(?:(?!&(?:quot|#39|lt|gt);)[^\s<])*[^\s<.,;:!?)'"&])/g, (_, lead, url) =>
112
      lead + hold(`<a href="${url}" rel="noopener noreferrer" target="_blank">${url}</a>`),
113
    )
114
    .replace(TAG, (_, lead, tag) =>
115
      lead + hold(`<a class="tag" href="/search?q=${encodeURIComponent(`#${unescapeHtml(tag)}`)}">#${tag}</a>`),
116
    );
117
  s = s
118
    .replace(/\*\*(?=\S)([^*]*?\S)\*\*/g, "<strong>$1</strong>")
119
    .replace(/__(?=\S)([^_]*?\S)__/g, "<strong>$1</strong>")
120
    .replace(/(^|[^*\w])\*(?=\S)([^*]*?\S)\*(?!\w)/g, "$1<em>$2</em>")
121
    .replace(/(^|[^_\w])_(?=\S)([^_]*?\S)_(?!\w)/g, "$1<em>$2</em>")
122
    .replace(/~~(?=\S)([^~]*?\S)~~/g, "<del>$1</del>")
123
    .replace(/==(?=\S)([^=]*?\S)==/g, "<mark>$1</mark>");
124
  return s.replace(/\u0000(\d+)\u0000/g, (_, i) => held[Number(i)]);
125
}
126
127
/**
128
 * @typedef {{ resolve: (title: string) => string | null }} Options
129
 * `resolve` turns a linked note's title into its address, or null when no
130
 * such note exists yet (the link is drawn as one to create).
131
 */
132
133
const CALLOUTS = new Set(["note", "info", "tip", "success", "question", "warning", "danger", "bug", "example", "quote", "abstract", "todo", "failure"]);
134
135
/**
136
 * A whole note, as HTML.
137
 * @param {string} md
138
 * @param {Partial<Options>} [options]
139
 */
140
export function render(md, options = {}) {
141
  const o = { resolve: options.resolve ?? (() => null) };
142
  return blocks(md.replace(/\r\n?/g, "\n").split("\n"), o);
143
}
144
145
/** @param {string[]} lines @param {Options} o */
146
function blocks(lines, o) {
147
  let out = "";
148
  let i = 0;
149
  const para = [];
150
  const flush = () => {
151
    if (para.length > 0) out += `<p>${para.map((l) => inline(escapeHtml(l), o)).join("<br>")}</p>\n`;
152
    para.length = 0;
153
  };
154
  while (i < lines.length) {
155
    const line = lines[i];
156
    let m;
157
    if (/^\s*$/.test(line)) {
158
      flush();
159
      i++;
160
    } else if ((m = /^(```|~~~)\s*([\w+-]*)/.exec(line))) {
161
      flush();
162
      const fence = m[1];
163
      const code = [];
164
      i++;
165
      while (i < lines.length && !lines[i].startsWith(fence)) code.push(lines[i++]);
166
      i++;
167
      out += `<pre><code${m[2] ? ` class="lang-${escapeHtml(m[2])}"` : ""}>${escapeHtml(code.join("\n"))}</code></pre>\n`;
168
    } else if ((m = /^(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$/.exec(line))) {
169
      flush();
170
      const n = m[1].length;
171
      out += `<h${n} id="${escapeHtml(slug(m[2]))}">${inline(escapeHtml(m[2]), o)}</h${n}>\n`;
172
      i++;
173
    } else if (/^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/.test(line)) {
174
      flush();
175
      out += "<hr>\n";
176
      i++;
177
    } else if (/^ {0,3}>/.test(line)) {
178
      flush();
179
      const quoted = [];
180
      while (i < lines.length && /^ {0,3}>/.test(lines[i])) quoted.push(lines[i++].replace(/^ {0,3}> ?/, ""));
181
      const callout = /^\[!(\w+)\]([+-]?)[ \t]*(.*)$/.exec(quoted[0] ?? "");
182
      if (callout) {
183
        const kind = callout[1].toLowerCase();
184
        const cls = CALLOUTS.has(kind) ? kind : "note";
185
        const heading = callout[3] || kind.charAt(0).toUpperCase() + kind.slice(1);
186
        out += `<div class="callout callout-${cls}"><p class="callout-title">${inline(escapeHtml(heading), o)}</p>${blocks(quoted.slice(1), o).trimEnd()}</div>\n`;
187
      } else out += `<blockquote>${blocks(quoted, o).trimEnd()}</blockquote>\n`;
188
    } else if (/^\s*([-*+]|\d+[.)])\s/.test(line)) {
189
      flush();
190
      const items = [];
191
      while (i < lines.length && (/^\s*([-*+]|\d+[.)])\s/.test(lines[i]) || (/^\s{2,}\S/.test(lines[i]) && items.length > 0))) items.push(lines[i++]);
192
      out += list(items, o);
193
    } else if (/^\s*\|.*\|\s*$/.test(line) && /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/.test(lines[i + 1] ?? "")) {
194
      flush();
195
      const cells = (l) => l.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim());
196
      const head = cells(line);
197
      const align = cells(lines[i + 1]).map((c) => (c.startsWith(":") && c.endsWith(":") ? "center" : c.endsWith(":") ? "right" : ""));
198
      const style = (k) => (align[k] ? ` style="text-align:${align[k]}"` : "");
199
      i += 2;
200
      let body = "";
201
      while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) {
202
        body += `<tr>${cells(lines[i++]).map((c, k) => `<td${style(k)}>${inline(escapeHtml(c), o)}</td>`).join("")}</tr>`;
203
      }
204
      out += `<div class="table"><table><thead><tr>${head.map((c, k) => `<th${style(k)}>${inline(escapeHtml(c), o)}</th>`).join("")}</tr></thead><tbody>${body}</tbody></table></div>\n`;
205
    } else {
206
      para.push(line);
207
      i++;
208
    }
209
  }
210
  flush();
211
  return out;
212
}
213
214
/** Lines of a list, nested by indentation. @param {string[]} lines @param {Options} o */
215
function list(lines, o) {
216
  const indent = (l) => l.match(/^\s*/)[0].replace(/\t/g, "  ").length;
217
  const base = indent(lines[0]);
218
  const ordered = /^\s*\d+[.)]\s/.test(lines[0]);
219
  let html = "";
220
  let i = 0;
221
  while (i < lines.length) {
222
    const m = /^\s*(?:[-*+]|\d+[.)])\s+(.*)$/.exec(lines[i]);
223
    const text = m ? m[1] : lines[i].trim();
224
    i++;
225
    const child = [];
226
    while (i < lines.length && indent(lines[i]) > base) child.push(lines[i++]);
227
    const task = /^\[([ xX])\]\s+(.*)$/.exec(text);
228
    const body = task
229
      ? `<button type="button" class="check" role="checkbox" aria-checked="${task[1] !== " "}" aria-label="Done"></button><span${task[1] !== " " ? ' class="done"' : ""}>${inline(escapeHtml(task[2]), o)}</span>`
230
      : inline(escapeHtml(text), o);
231
    html += `<li${task ? ' class="task"' : ""}>${body}${child.length > 0 ? list(child, o).trimEnd() : ""}</li>`;
232
  }
233
  return ordered ? `<ol>${html}</ol>\n` : `<ul>${html}</ul>\n`;
234
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts