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

Files main
279 lines · 11.7 KB · ~3.4k 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
/**
53
 * A note's properties: the YAML-ish block between `---` lines at the very
54
 * top, the way Obsidian (and every static-site generator) writes it. Only
55
 * what a person reads is understood — `key: value`, `key: [a, b]` and a
56
 * key followed by `- item` lines — and the rest of the note is its body.
57
 * @param {string} md
58
 * @returns {{ props: [string, string[]][], body: string } | null}
59
 */
60
export function frontmatter(md) {
61
  const m = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(md);
62
  if (!m) return null;
63
  /** @type {[string, string[]][]} */
64
  const props = [];
65
  for (const line of m[1].split(/\r?\n/)) {
66
    const item = /^\s+-\s+(.*)$/.exec(line);
67
    if (item && props.length > 0) {
68
      props[props.length - 1][1].push(unquote(item[1]));
69
      continue;
70
    }
71
    const kv = /^([\w][\w .-]*?)\s*:\s*(.*)$/.exec(line);
72
    if (!kv) continue;
73
    const value = kv[2].trim();
74
    const listed = /^\[(.*)\]$/.exec(value);
75
    props.push([kv[1], listed ? listed[1].split(",").map(unquote).filter((v) => v !== "") : value === "" ? [] : [unquote(value)]]);
76
  }
77
  return { props, body: md.slice(m[0].length) };
78
}
79
/** @param {string} s */
80
const unquote = (s) => s.trim().replace(/^(["'])(.*)\1$/, "$2");
81
82
/** The tags a note carries, lower-cased, once each, without the #: in its words and in its `tags` property. @param {string} md */
83
export function tags(md) {
84
  const out = new Set();
85
  const fm = frontmatter(md);
86
  for (const [key, values] of fm?.props ?? [])
87
    if (/^tags?$/i.test(key))
88
      for (const v of values.flatMap((v) => v.split(/[\s,]+/))) {
89
        const tag = v.replace(/^#/, "").toLowerCase();
90
        if (/^[\p{L}\p{N}_/-]*[\p{L}_/-][\p{L}\p{N}_/-]*$/u.test(tag)) out.add(tag);
91
      }
92
  for (const m of prose(fm?.body ?? md).matchAll(TAG)) out.add(m[2].toLowerCase());
93
  return [...out];
94
}
95
96
/** The note's headings, for an outline. @param {string} md */
97
export function headings(md) {
98
  const out = [];
99
  for (const m of prose(md).matchAll(/^(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$/gm)) {
100
    const level = m[1].length;
101
    const text = m[2].trim();
102
    if (text === "") continue;
103
    out.push({ level, text, id: slug(text) });
104
  }
105
  return out;
106
}
107
108
const SAFE_URL = /^(?:https?:\/\/|mailto:|\/(?!\/)|#)/i;
109
110
/**
111
 * One line's inline marks, on text that is already escaped.
112
 * @param {string} text escaped
113
 * @param {Options} o
114
 */
115
function inline(text, o) {
116
  // Code spans first: what is inside them is no mark.
117
  const parts = text.split(/(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/);
118
  let out = "";
119
  for (let i = 0; i < parts.length; i++) {
120
    if (i % 3 === 0) out += marks(parts[i], o);
121
    else if (i % 3 === 2) out += `<code>${parts[i]}</code>`;
122
  }
123
  return out;
124
}
125
126
/** @param {string} s escaped @param {Options} o */
127
function marks(s, o) {
128
  const held = [];
129
  const hold = (html) => `\u0000${held.push(html) - 1}\u0000`;
130
  s = s
131
    .replace(WIKI, (_, embed, target, alias) => {
132
      const [title, heading] = unescapeHtml(target).split("#");
133
      const name = title.trim();
134
      const shown = alias ?? target;
135
      const href = o.resolve(name);
136
      const anchor = heading ? `#${slug(heading)}` : "";
137
      return hold(
138
        href === null
139
          ? `<a class="wikilink unresolved" href="${o.base}/open?title=${encodeURIComponent(name)}" data-title="${escapeHtml(name)}">${shown}</a>`
140
          : `<a class="wikilink${embed ? " embed" : ""}" href="${escapeHtml(href)}${anchor}" data-title="${escapeHtml(name)}">${shown}</a>`,
141
      );
142
    })
143
    .replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (whole, label, url) =>
144
      SAFE_URL.test(unescapeHtml(url))
145
        ? hold(`<a href="${url}"${/^https?:/i.test(url) ? ' rel="noopener noreferrer" target="_blank"' : ""}>${label}</a>`)
146
        : whole,
147
    )
148
    .replace(/(^|[\s(])(https?:\/\/(?:(?!&(?:quot|#39|lt|gt);)[^\s<])*[^\s<.,;:!?)'"&])/g, (_, lead, url) =>
149
      lead + hold(`<a href="${url}" rel="noopener noreferrer" target="_blank">${url}</a>`),
150
    )
151
    .replace(TAG, (_, lead, tag) =>
152
      lead + hold(`<a class="tag" href="${o.base}/search?q=${encodeURIComponent(`#${unescapeHtml(tag)}`)}">#${tag}</a>`),
153
    );
154
  s = s
155
    .replace(/\*\*(?=\S)([^*]*?\S)\*\*/g, "<strong>$1</strong>")
156
    .replace(/__(?=\S)([^_]*?\S)__/g, "<strong>$1</strong>")
157
    .replace(/(^|[^*\w])\*(?=\S)([^*]*?\S)\*(?!\w)/g, "$1<em>$2</em>")
158
    .replace(/(^|[^_\w])_(?=\S)([^_]*?\S)_(?!\w)/g, "$1<em>$2</em>")
159
    .replace(/~~(?=\S)([^~]*?\S)~~/g, "<del>$1</del>")
160
    .replace(/==(?=\S)([^=]*?\S)==/g, "<mark>$1</mark>");
161
  return s.replace(/\u0000(\d+)\u0000/g, (_, i) => held[Number(i)]);
162
}
163
164
/**
165
 * @typedef {{ resolve: (title: string) => string | null, base: string }} Options
166
 * `resolve` turns a linked note's title into its address, or null when no
167
 * such note exists yet (the link is drawn as one to create). `base` is the
168
 * vault's own address (`""` for yours, `/s/<id>` for a shared one), where a
169
 * link to a note not written yet, or a tag, goes looking.
170
 */
171
172
const CALLOUTS = new Set(["note", "info", "tip", "success", "question", "warning", "danger", "bug", "example", "quote", "abstract", "todo", "failure"]);
173
174
/**
175
 * A whole note, as HTML.
176
 * @param {string} md
177
 * @param {Partial<Options>} [options]
178
 */
179
export function render(md, options = {}) {
180
  const o = { resolve: options.resolve ?? (() => null), base: options.base ?? "" };
181
  const text = md.replace(/\r\n?/g, "\n");
182
  const fm = frontmatter(text);
183
  if (fm === null) return blocks(text.split("\n"), o);
184
  const props = fm.props
185
    .map(([k, v]) => `<dt>${escapeHtml(k)}</dt><dd>${v.map((x) => inline(escapeHtml(x), o)).join(", ") || "—"}</dd>`)
186
    .join("");
187
  return `${props === "" ? "" : `<dl class="properties">${props}</dl>\n`}${blocks(fm.body.split("\n"), o)}`;
188
}
189
190
/** @param {string[]} lines @param {Options} o */
191
function blocks(lines, o) {
192
  let out = "";
193
  let i = 0;
194
  const para = [];
195
  const flush = () => {
196
    if (para.length > 0) out += `<p>${para.map((l) => inline(escapeHtml(l), o)).join("<br>")}</p>\n`;
197
    para.length = 0;
198
  };
199
  while (i < lines.length) {
200
    const line = lines[i];
201
    let m;
202
    if (/^\s*$/.test(line)) {
203
      flush();
204
      i++;
205
    } else if ((m = /^(```|~~~)\s*([\w+-]*)/.exec(line))) {
206
      flush();
207
      const fence = m[1];
208
      const code = [];
209
      i++;
210
      while (i < lines.length && !lines[i].startsWith(fence)) code.push(lines[i++]);
211
      i++;
212
      out += `<pre><code${m[2] ? ` class="lang-${escapeHtml(m[2])}"` : ""}>${escapeHtml(code.join("\n"))}</code></pre>\n`;
213
    } else if ((m = /^(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$/.exec(line))) {
214
      flush();
215
      const n = m[1].length;
216
      out += `<h${n} id="${escapeHtml(slug(m[2]))}">${inline(escapeHtml(m[2]), o)}</h${n}>\n`;
217
      i++;
218
    } else if (/^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/.test(line)) {
219
      flush();
220
      out += "<hr>\n";
221
      i++;
222
    } else if (/^ {0,3}>/.test(line)) {
223
      flush();
224
      const quoted = [];
225
      while (i < lines.length && /^ {0,3}>/.test(lines[i])) quoted.push(lines[i++].replace(/^ {0,3}> ?/, ""));
226
      const callout = /^\[!(\w+)\]([+-]?)[ \t]*(.*)$/.exec(quoted[0] ?? "");
227
      if (callout) {
228
        const kind = callout[1].toLowerCase();
229
        const cls = CALLOUTS.has(kind) ? kind : "note";
230
        const heading = callout[3] || kind.charAt(0).toUpperCase() + kind.slice(1);
231
        out += `<div class="callout callout-${cls}"><p class="callout-title">${inline(escapeHtml(heading), o)}</p>${blocks(quoted.slice(1), o).trimEnd()}</div>\n`;
232
      } else out += `<blockquote>${blocks(quoted, o).trimEnd()}</blockquote>\n`;
233
    } else if (/^\s*([-*+]|\d+[.)])\s/.test(line)) {
234
      flush();
235
      const items = [];
236
      while (i < lines.length && (/^\s*([-*+]|\d+[.)])\s/.test(lines[i]) || (/^\s{2,}\S/.test(lines[i]) && items.length > 0))) items.push(lines[i++]);
237
      out += list(items, o);
238
    } else if (/^\s*\|.*\|\s*$/.test(line) && /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/.test(lines[i + 1] ?? "")) {
239
      flush();
240
      const cells = (l) => l.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim());
241
      const head = cells(line);
242
      const align = cells(lines[i + 1]).map((c) => (c.startsWith(":") && c.endsWith(":") ? "center" : c.endsWith(":") ? "right" : ""));
243
      const style = (k) => (align[k] ? ` style="text-align:${align[k]}"` : "");
244
      i += 2;
245
      let body = "";
246
      while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) {
247
        body += `<tr>${cells(lines[i++]).map((c, k) => `<td${style(k)}>${inline(escapeHtml(c), o)}</td>`).join("")}</tr>`;
248
      }
249
      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`;
250
    } else {
251
      para.push(line);
252
      i++;
253
    }
254
  }
255
  flush();
256
  return out;
257
}
258
259
/** Lines of a list, nested by indentation. @param {string[]} lines @param {Options} o */
260
function list(lines, o) {
261
  const indent = (l) => l.match(/^\s*/)[0].replace(/\t/g, "  ").length;
262
  const base = indent(lines[0]);
263
  const ordered = /^\s*\d+[.)]\s/.test(lines[0]);
264
  let html = "";
265
  let i = 0;
266
  while (i < lines.length) {
267
    const m = /^\s*(?:[-*+]|\d+[.)])\s+(.*)$/.exec(lines[i]);
268
    const text = m ? m[1] : lines[i].trim();
269
    i++;
270
    const child = [];
271
    while (i < lines.length && indent(lines[i]) > base) child.push(lines[i++]);
272
    const task = /^\[([ xX])\]\s+(.*)$/.exec(text);
273
    const body = task
274
      ? `<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>`
275
      : inline(escapeHtml(text), o);
276
    html += `<li${task ? ' class="task"' : ""}>${body}${child.length > 0 ? list(child, o).trimEnd() : ""}</li>`;
277
  }
278
  return ordered ? `<ol>${html}</ol>\n` : `<ul>${html}</ul>\n`;
279
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts