Two things found by using this on real notes. **The paste.** Copying out of Apple Notes put most of the note on the floor. WebKit wraps a copied selection in a single span carrying the computed style of everything in it — `font-weight: 700` included — with the real blocks nested inside. The serializer read that span as inline, so every line collapsed into one paragraph and every word came out bold; switching to the Markdown view then showed what little had survived, which is what "most of the text was gone" was. And because the boldness came from a foreign span's style rather than a tag, the bold button could not remove it. The rule now is that an element holding blocks is a block whatever its tag, and that a container's style is not emphasis — only a span wrapping a single run of text is. A paste this editor cannot read at all (some engines withhold the clipboard from the event) is tidied afterwards instead, but only if something actually arrived, so an empty paste still costs nothing. **The search.** A Suche tab over the user's own notes, reading the files rather than the index: notes reach the index only on a full crawl, so a lesson written this morning would not be findable this morning, which is most of what anyone searches their own notes for. A result names the lesson it matched in, not the day, for the same reason the index indexes day notes per section. Tapping one opens that day in the editor. Driven in Firefox against the real app with a proxied session: the paste, six switches between the two views, bold and unbold on pasted text, the search, and opening a result. 386 unit tests, 114/115 smoke. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
688 lines
23 KiB
JavaScript
688 lines
23 KiB
JavaScript
/*
|
|
* Markdown in, formatted text out, and back again.
|
|
*
|
|
* The notes are Markdown files — that is what the indexer reads, what
|
|
* `subjectFromHeading` takes a lesson apart with, and what survives this
|
|
* project. The editor shows them as formatted text anyway, so this module is
|
|
* the hinge: `markdownToHtml` on the way into the editor, `markdownFromDom` on
|
|
* the way back out to the file.
|
|
*
|
|
* Three properties matter more than completeness, because what passes through
|
|
* here is the only record of what was said in a lesson:
|
|
*
|
|
* - **Round-trip stability.** `fromDom(toHtml(x))` may tidy `x` once — `*a*`
|
|
* becomes `_a_`, a ragged table lines up — but doing it again must change
|
|
* nothing. `editor.js` checks exactly that before it opens a note in
|
|
* formatted mode, and falls back to the Markdown view when it does not hold.
|
|
* - **Nothing is dropped.** An element this module does not model keeps its
|
|
* words and loses its tag. A note is better off plain than short.
|
|
* - **No HTML is trusted.** `markdownToHtml` escapes everything that is not a
|
|
* construct it produced itself, so a note containing `<script>` is text, not
|
|
* script. Pasted HTML never reaches the document either: it is converted to
|
|
* Markdown first and parsed back, which reduces it to the subset below.
|
|
*
|
|
* The subset is what these notes are made of: headings, paragraphs, bold,
|
|
* italic, strikethrough, code (inline and fenced), links, bullet / numbered /
|
|
* task lists with nesting, blockquotes, tables and rules. Underline is
|
|
* deliberately absent — Markdown has no way to write it, so the toolbar does
|
|
* not offer what the file cannot keep.
|
|
*/
|
|
|
|
/**
|
|
* Where a code span sat while the emphasis rules ran over the line.
|
|
*
|
|
* A control character, because it is the one thing a note cannot contain: the
|
|
* store strips NUL out of extracted text, and nothing types one.
|
|
*/
|
|
const PLACEHOLDER = '\u0000';
|
|
const PLACEHOLDERS = /\u0000(\d+)\u0000/g;
|
|
|
|
/** Ordered and bullet items, with their indentation and marker. */
|
|
const ITEM = /^(\s*)([-*+]|\d{1,9}[.)])\s+(.*)$/;
|
|
/** How far a continuation line must be indented to belong to the item above. */
|
|
const CONTINUATION = 2;
|
|
|
|
// --- Markdown → HTML -----------------------------------------------------
|
|
|
|
/**
|
|
* A note's body as HTML for the editor.
|
|
*
|
|
* The output is the only HTML the editor ever starts from, which is what makes
|
|
* the serializer's job finite.
|
|
*/
|
|
export function markdownToHtml(markdown) {
|
|
const lines = String(markdown ?? '')
|
|
.replace(/\r\n?/g, '\n')
|
|
.split('\n')
|
|
.map(expandLeadingTabs);
|
|
return parseBlocks(lines);
|
|
}
|
|
|
|
/**
|
|
* Tabs only in the indentation, and only there.
|
|
*
|
|
* Indentation is measured in columns to decide what nests inside what, so a
|
|
* tab has to become a known number of spaces first. Tabs inside the text are
|
|
* left alone — in a code block they are content.
|
|
*/
|
|
function expandLeadingTabs(line) {
|
|
const match = /^[ \t]+/.exec(line);
|
|
if (!match) return line;
|
|
return match[0].replace(/\t/g, ' ') + line.slice(match[0].length);
|
|
}
|
|
|
|
function parseBlocks(lines) {
|
|
const out = [];
|
|
let i = 0;
|
|
|
|
while (i < lines.length) {
|
|
const line = lines[i];
|
|
if (!line.trim()) {
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
const fence = /^ {0,3}(```+|~~~+)\s*([A-Za-z0-9_+#-]*)\s*$/.exec(line);
|
|
if (fence) {
|
|
const closing = new RegExp('^ {0,3}' + fence[1][0] + '{' + fence[1].length + ',}\\s*$');
|
|
const body = [];
|
|
i++;
|
|
while (i < lines.length && !closing.test(lines[i])) {
|
|
body.push(lines[i]);
|
|
i++;
|
|
}
|
|
// An unclosed fence still ends the block; the note is what it is.
|
|
i++;
|
|
const language = fence[2] ? ' class="language-' + escapeHtml(fence[2]) + '"' : '';
|
|
out.push('<pre><code' + language + '>' + escapeHtml(body.join('\n')) + '</code></pre>');
|
|
continue;
|
|
}
|
|
|
|
const heading = /^ {0,3}(#{1,6})\s+(.*?)\s*#*$/.exec(line);
|
|
if (heading) {
|
|
const level = heading[1].length;
|
|
out.push('<h' + level + '>' + inlineToHtml(heading[2]) + '</h' + level + '>');
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
if (isRule(line)) {
|
|
out.push('<hr>');
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
if (/^ {0,3}>/.test(line)) {
|
|
const body = [];
|
|
while (i < lines.length && lines[i].trim()) {
|
|
if (/^ {0,3}>/.test(lines[i])) body.push(lines[i].replace(/^ {0,3}> ?/, ''));
|
|
// A wrapped line with no `>` still belongs to the quote it follows.
|
|
else body.push(lines[i].trim());
|
|
i++;
|
|
}
|
|
out.push('<blockquote>' + parseBlocks(body) + '</blockquote>');
|
|
continue;
|
|
}
|
|
|
|
if (startsTable(lines, i)) {
|
|
const header = splitRow(lines[i]);
|
|
i += 2;
|
|
const rows = [];
|
|
while (i < lines.length && lines[i].trim() && lines[i].includes('|')) {
|
|
rows.push(splitRow(lines[i]));
|
|
i++;
|
|
}
|
|
out.push(tableToHtml(header, rows));
|
|
continue;
|
|
}
|
|
|
|
if (ITEM.test(line)) {
|
|
const list = parseList(lines, i);
|
|
out.push(list.html);
|
|
i = list.next;
|
|
continue;
|
|
}
|
|
|
|
// A paragraph, whose single newlines are line breaks rather than
|
|
// paragraph breaks. That is how a note reads in a plain editor and how
|
|
// Notes.app behaved, and it round-trips exactly — unlike the two
|
|
// trailing spaces CommonMark wants, which no one can see.
|
|
const paragraph = [];
|
|
while (i < lines.length && lines[i].trim() && !startsBlock(lines, i)) {
|
|
paragraph.push(lines[i].trim());
|
|
i++;
|
|
}
|
|
out.push('<p>' + paragraph.map(inlineToHtml).join('<br>') + '</p>');
|
|
}
|
|
|
|
return out.join('');
|
|
}
|
|
|
|
/** Everything that interrupts a paragraph. */
|
|
function startsBlock(lines, index) {
|
|
const line = lines[index];
|
|
return (
|
|
/^ {0,3}(```+|~~~+)/.test(line) ||
|
|
/^ {0,3}#{1,6}\s/.test(line) ||
|
|
/^ {0,3}>/.test(line) ||
|
|
isRule(line) ||
|
|
ITEM.test(line) ||
|
|
startsTable(lines, index)
|
|
);
|
|
}
|
|
|
|
function isRule(line) {
|
|
return /^ {0,3}([-*_])\s*(?:\1\s*){2,}$/.test(line);
|
|
}
|
|
|
|
function startsTable(lines, index) {
|
|
if (!lines[index].includes('|')) return false;
|
|
const next = lines[index + 1];
|
|
return Boolean(next) && /^\s*\|?(\s*:?-{1,}:?\s*\|)+\s*:?-*:?\s*\|?\s*$/.test(next) && next.includes('-');
|
|
}
|
|
|
|
function splitRow(line) {
|
|
let value = line.trim();
|
|
if (value.startsWith('|')) value = value.slice(1);
|
|
if (value.endsWith('|') && !value.endsWith('\\|')) value = value.slice(0, -1);
|
|
// Split on pipes that are not escaped, then give the cells their pipes back.
|
|
return value.split(/(?<!\\)\|/).map((cell) => cell.trim().replace(/\\\|/g, '|'));
|
|
}
|
|
|
|
function tableToHtml(header, rows) {
|
|
const width = Math.max(header.length, ...rows.map((row) => row.length), 1);
|
|
const cells = (row, tag) => {
|
|
let out = '';
|
|
for (let i = 0; i < width; i++) {
|
|
// A break in an empty cell: a `<td></td>` with nothing in it cannot be
|
|
// clicked into, so a blank cell would be uneditable. It serializes
|
|
// back to an empty cell.
|
|
out += '<' + tag + '>' + (inlineToHtml(row[i] ?? '') || '<br>') + '</' + tag + '>';
|
|
}
|
|
return out;
|
|
};
|
|
const body = rows.map((row) => '<tr>' + cells(row, 'td') + '</tr>').join('');
|
|
return '<table><thead><tr>' + cells(header, 'th') + '</tr></thead><tbody>' + body + '</tbody></table>';
|
|
}
|
|
|
|
/**
|
|
* One list, and everything nested inside it.
|
|
*
|
|
* Continuation is by indentation: a line indented at least two columns past
|
|
* the item's own marker belongs to that item, which is what makes nesting and
|
|
* multi-paragraph items work without tracking marker widths through the
|
|
* recursion. Indentation inside an item is relative, so the nested list parses
|
|
* as a list of its own.
|
|
*/
|
|
function parseList(lines, start) {
|
|
const first = ITEM.exec(lines[start]);
|
|
const base = first[1].length;
|
|
const ordered = /^\d/.test(first[2]);
|
|
const startNumber = ordered ? Number.parseInt(first[2], 10) : 1;
|
|
const items = [];
|
|
let i = start;
|
|
|
|
while (i < lines.length) {
|
|
const match = ITEM.exec(lines[i]);
|
|
if (!match) break;
|
|
// A shallower item ends this list; a deeper one is swallowed below as
|
|
// part of the item above it, so reaching one here means the list is over.
|
|
if (match[1].length !== base) break;
|
|
if (/^\d/.test(match[2]) !== ordered) break;
|
|
|
|
const body = [match[3]];
|
|
i++;
|
|
while (i < lines.length) {
|
|
const line = lines[i];
|
|
if (!line.trim()) {
|
|
// A blank line keeps the item open only if something indented
|
|
// follows it; otherwise the list ends here.
|
|
const after = lines[i + 1];
|
|
if (after && after.trim() && indentOf(after) >= base + CONTINUATION) {
|
|
body.push('');
|
|
i++;
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
if (indentOf(line) >= base + CONTINUATION) {
|
|
body.push(line.slice(base + CONTINUATION));
|
|
i++;
|
|
continue;
|
|
}
|
|
if (ITEM.test(line) || startsBlock(lines, i)) break;
|
|
// A wrapped line, typed without indentation.
|
|
body.push(line.trim());
|
|
i++;
|
|
}
|
|
items.push(body);
|
|
}
|
|
|
|
const tag = ordered ? 'ol' : 'ul';
|
|
const open = ordered && startNumber !== 1 ? '<ol start="' + startNumber + '">' : '<' + tag + '>';
|
|
return { html: open + items.map(itemToHtml).join('') + '</' + tag + '>', next: i };
|
|
}
|
|
|
|
function itemToHtml(body) {
|
|
const task = /^\[([ xX])\]\s+([\s\S]*)$/.exec(body[0] ?? '');
|
|
if (task) body = [task[2], ...body.slice(1)];
|
|
|
|
let inner = parseBlocks(body);
|
|
// A tight item: its first paragraph is the item's own text, not a paragraph
|
|
// inside it. Unwrapping only the first keeps multi-paragraph items intact.
|
|
inner = inner.replace(/^<p>([\s\S]*?)<\/p>/, '$1');
|
|
|
|
if (!task) return '<li>' + inner + '</li>';
|
|
const checked = task[1] !== ' ';
|
|
return (
|
|
'<li class="task"><input type="checkbox" contenteditable="false"' +
|
|
(checked ? ' checked' : '') +
|
|
'>' +
|
|
inner +
|
|
'</li>'
|
|
);
|
|
}
|
|
|
|
function indentOf(line) {
|
|
return /^[ ]*/.exec(line)[0].length;
|
|
}
|
|
|
|
/**
|
|
* Inline Markdown as HTML.
|
|
*
|
|
* Code spans are taken out first and put back last, so the stars and
|
|
* underscores inside `**bold**` written as code stay literal.
|
|
*/
|
|
function inlineToHtml(text) {
|
|
const literals = [];
|
|
const park = (html) => {
|
|
literals.push(html);
|
|
return PLACEHOLDER + (literals.length - 1) + PLACEHOLDER;
|
|
};
|
|
|
|
// Backslash escapes first, or `\*` would still be read as emphasis and a
|
|
// backslashed backtick would still open a code span. Parked as literal
|
|
// text, they take no further part in anything.
|
|
let value = String(text).replace(/\\([\\`*_[\]#>~|+.()-])/g, (all, character) => park(escapeHtml(character)));
|
|
|
|
value = value.replace(/(`+)([\s\S]*?)\1/g, (all, fence, body) =>
|
|
park('<code>' + escapeHtml(body.replace(/^ (.*) $/, '$1')) + '</code>'),
|
|
);
|
|
|
|
value = escapeHtml(value);
|
|
|
|
// Links before emphasis: a label may contain either, and a URL may contain
|
|
// underscores that are not emphasis. One level of balanced parentheses is
|
|
// allowed in the target, because real links have them —
|
|
// de.wikipedia.org/wiki/Erörterung_(Textsorte).
|
|
value = value.replace(/\[([^\]]*)\]\(((?:[^()\s]|\([^()\s]*\))*)\)/g, (all, label, href) => {
|
|
const safe = safeUrl(href);
|
|
if (!safe) return label;
|
|
return '<a href="' + safe + '">' + (label || safe) + '</a>';
|
|
});
|
|
|
|
value = value.replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, '<strong>$2</strong>');
|
|
value = value.replace(/~~(?=\S)([\s\S]*?\S)~~/g, '<del>$1</del>');
|
|
// A single marker, not part of a double one, and not mid-word for `_` —
|
|
// otherwise snake_case_names turn into emphasis.
|
|
value = value.replace(/(?<!\*)\*(?!\*)(?=\S)([\s\S]*?\S)\*(?!\*)/g, '<em>$1</em>');
|
|
value = value.replace(/(?<![\w_])_(?!_)(?=\S)([\s\S]*?\S)_(?![\w_])/g, '<em>$1</em>');
|
|
|
|
return value.replace(PLACEHOLDERS, (all, index) => literals[Number(index)]);
|
|
}
|
|
|
|
/**
|
|
* A link target, or nothing.
|
|
*
|
|
* The editor's content comes from the user's own notes, but a note can be
|
|
* written by anything — an import, a paste from a web page — so a `javascript:`
|
|
* url is refused rather than rendered into a document a finger will tap.
|
|
*/
|
|
function safeUrl(href) {
|
|
const value = href.trim();
|
|
if (!value) return '';
|
|
if (/^[a-z][a-z0-9+.-]*:/i.test(value) && !/^(https?|mailto|tel):/i.test(value)) return '';
|
|
return escapeHtml(value).replace(/"/g, '"');
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
// --- HTML → Markdown -----------------------------------------------------
|
|
|
|
/**
|
|
* What the editor holds, as the Markdown that will be written to the file.
|
|
*
|
|
* Deliberately tolerant: browsers put their own tags into a contenteditable
|
|
* element (`<div>` for a line, `<span style="font-weight: bold">` after a
|
|
* paste, `<font>` on older engines), and none of that may cost a word. An
|
|
* element with no meaning here serializes its children.
|
|
*
|
|
* `root` needs only the read-only parts of the DOM — `nodeType`, `nodeName`,
|
|
* `childNodes`, `textContent` and `getAttribute` — so the same function runs
|
|
* against a plain tree in the tests.
|
|
*/
|
|
export function markdownFromDom(root) {
|
|
return serializeBlocks(root).replace(/[ \t]+$/gm, '').replace(/\n{3,}/g, '\n\n').trim();
|
|
}
|
|
|
|
const BLOCK_TAGS = new Set([
|
|
'P', 'DIV', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
|
|
'UL', 'OL', 'BLOCKQUOTE', 'PRE', 'HR', 'TABLE', 'SECTION', 'ARTICLE', 'FIGURE',
|
|
]);
|
|
|
|
function serializeBlocks(node) {
|
|
const out = [];
|
|
let inline = [];
|
|
|
|
const flush = () => {
|
|
if (inline.length === 0) return;
|
|
const text = paragraph(inlineFrom(inline));
|
|
if (text) out.push(text);
|
|
inline = [];
|
|
};
|
|
|
|
for (const child of children(node)) {
|
|
// A block *inside* an inline element is still a block. WebKit wraps a
|
|
// copied selection in one span carrying the computed style of everything
|
|
// in it, so a paste from Apple Notes arrives as
|
|
// `<span style="font-weight: 700"><div>…</div><div>…</div></span>` —
|
|
// and reading that span as inline flattened a whole note into one
|
|
// paragraph and made every word of it bold.
|
|
if (child.nodeType === 1 && (BLOCK_TAGS.has(child.nodeName) || holdsBlock(child))) {
|
|
flush();
|
|
const block = serializeBlock(child);
|
|
if (block) out.push(block);
|
|
} else {
|
|
inline.push(child);
|
|
}
|
|
}
|
|
flush();
|
|
|
|
return out.join('\n\n');
|
|
}
|
|
|
|
function serializeBlock(element) {
|
|
switch (element.nodeName) {
|
|
case 'H1':
|
|
case 'H2':
|
|
case 'H3':
|
|
case 'H4':
|
|
case 'H5':
|
|
case 'H6': {
|
|
const text = inlineFrom(children(element)).replace(/\n+/g, ' ').trim();
|
|
if (!text) return '';
|
|
return '#'.repeat(Number(element.nodeName[1])) + ' ' + text;
|
|
}
|
|
case 'HR':
|
|
return '---';
|
|
case 'PRE': {
|
|
const body = element.textContent.replace(/\n$/, '');
|
|
const language = languageOf(element);
|
|
// A fence longer than any run of backticks inside, or a note about
|
|
// Markdown closes its own code block.
|
|
const longest = Math.max(2, ...[...body.matchAll(/`+/g)].map((match) => match[0].length));
|
|
const fence = '`'.repeat(longest + 1);
|
|
return fence + language + '\n' + body + '\n' + fence;
|
|
}
|
|
case 'BLOCKQUOTE': {
|
|
const inner = serializeBlocks(element).trim();
|
|
if (!inner) return '';
|
|
return inner.split('\n').map((line) => (line ? '> ' + line : '>')).join('\n');
|
|
}
|
|
case 'UL':
|
|
case 'OL':
|
|
return serializeList(element);
|
|
case 'TABLE':
|
|
return serializeTable(element);
|
|
case 'DIV':
|
|
case 'SECTION':
|
|
case 'ARTICLE':
|
|
case 'FIGURE':
|
|
// A browser's line wrapper, or a real container. Both are handled by
|
|
// asking what is inside.
|
|
return hasBlockChild(element) ? serializeBlocks(element) : paragraph(inlineFrom(children(element)));
|
|
default:
|
|
// Anything else that reached this function is here because it holds
|
|
// blocks — a paste wrapper, most often. Its own tag means nothing;
|
|
// what it contains means everything.
|
|
return holdsBlock(element) ? serializeBlocks(element) : paragraph(inlineFrom(children(element)));
|
|
}
|
|
}
|
|
|
|
function serializeList(list, depth = 0) {
|
|
const ordered = list.nodeName === 'OL';
|
|
const start = Number.parseInt(list.getAttribute('start') ?? '', 10);
|
|
let number = Number.isFinite(start) && start > 0 ? start : 1;
|
|
const out = [];
|
|
|
|
// Two columns per level, matching what the parser takes back apart.
|
|
const indent = ' '.repeat(CONTINUATION);
|
|
const shift = (block) => block.split('\n').map((line) => (line ? indent + line : '')).join('\n');
|
|
|
|
for (const item of children(list)) {
|
|
if (item.nodeType !== 1) continue;
|
|
if (item.nodeName === 'UL' || item.nodeName === 'OL') {
|
|
// A list as a *sibling* of the items rather than inside one. Several
|
|
// engines produce this when Tab indents a bullet, and skipping it
|
|
// would silently drop everything the person nested.
|
|
const nested = shift(serializeList(item, depth + 1));
|
|
if (out.length > 0) out[out.length - 1] += '\n' + nested;
|
|
else out.push(nested);
|
|
continue;
|
|
}
|
|
if (item.nodeName !== 'LI') continue;
|
|
|
|
const checkbox = firstCheckbox(item);
|
|
const marker = ordered ? number++ + '.' : '-';
|
|
const box = checkbox ? (checkbox.getAttribute('checked') === null ? '[ ] ' : '[x] ') : '';
|
|
|
|
// The item's own text, then whatever blocks hang under it.
|
|
const leading = [];
|
|
const blocks = [];
|
|
for (const child of children(item)) {
|
|
if (child === checkbox) continue;
|
|
if (child.nodeType === 1 && BLOCK_TAGS.has(child.nodeName)) blocks.push(child);
|
|
else if (blocks.length === 0) leading.push(child);
|
|
// Inline content after a nested list is rare and reads as part of it.
|
|
else blocks.push(child);
|
|
}
|
|
|
|
// An item whose text the browser wrapped in a div or a p: that is the
|
|
// item's own line, not a block underneath it.
|
|
if (leading.length === 0 && blocks.length > 0 && (blocks[0].nodeName === 'DIV' || blocks[0].nodeName === 'P')) {
|
|
if (!hasBlockChild(blocks[0])) leading.push(...children(blocks.shift()));
|
|
}
|
|
|
|
const head = paragraph(inlineFrom(leading));
|
|
const rest = blocks
|
|
.map((child) =>
|
|
child.nodeType === 1 && (child.nodeName === 'UL' || child.nodeName === 'OL')
|
|
? serializeList(child, depth + 1)
|
|
: serializeBlock(child),
|
|
)
|
|
.filter(Boolean);
|
|
|
|
const first = marker + ' ' + box + head.split('\n').join('\n' + indent);
|
|
out.push([first, ...rest.map(shift)].join('\n'));
|
|
}
|
|
|
|
return out.join('\n');
|
|
}
|
|
|
|
function serializeTable(table) {
|
|
const rows = [];
|
|
const walk = (node) => {
|
|
for (const child of children(node)) {
|
|
if (child.nodeType !== 1) continue;
|
|
if (child.nodeName === 'TR') rows.push(child);
|
|
else walk(child);
|
|
}
|
|
};
|
|
walk(table);
|
|
if (rows.length === 0) return '';
|
|
|
|
const cells = rows.map((row) =>
|
|
children(row)
|
|
.filter((cell) => cell.nodeType === 1 && (cell.nodeName === 'TD' || cell.nodeName === 'TH'))
|
|
.map((cell) => inlineFrom(children(cell)).replace(/\n+/g, ' ').replace(/\|/g, '\\|').trim()),
|
|
);
|
|
const width = Math.max(...cells.map((row) => row.length));
|
|
const line = (row) => '| ' + Array.from({ length: width }, (_, i) => row[i] ?? '').join(' | ') + ' |';
|
|
|
|
// A header row is required by the syntax: a table whose first row is data
|
|
// would otherwise lose that row entirely.
|
|
return [line(cells[0]), '|' + ' --- |'.repeat(width), ...cells.slice(1).map(line)].join('\n');
|
|
}
|
|
|
|
function inlineFrom(nodes) {
|
|
return nodes.map(inlineNode).join('');
|
|
}
|
|
|
|
function inlineNode(node) {
|
|
if (node.nodeType === 3) return escapeText(node.textContent);
|
|
if (node.nodeType !== 1) return '';
|
|
|
|
switch (node.nodeName) {
|
|
case 'BR':
|
|
return '\n';
|
|
case 'IMG':
|
|
// No note here has an image; one arriving by paste says so rather
|
|
// than vanishing.
|
|
return node.getAttribute('alt') ? '[' + escapeText(node.getAttribute('alt')) + ']' : '';
|
|
case 'INPUT':
|
|
// Only ever a task checkbox, and `serializeList` has already read it.
|
|
return '';
|
|
case 'CODE': {
|
|
const body = node.textContent;
|
|
if (!body) return '';
|
|
const longest = Math.max(0, ...[...body.matchAll(/`+/g)].map((match) => match[0].length));
|
|
const fence = '`'.repeat(longest + 1);
|
|
const pad = body.startsWith('`') || body.endsWith('`') ? ' ' : '';
|
|
return fence + pad + body + pad + fence;
|
|
}
|
|
case 'A': {
|
|
const label = inlineFrom(children(node));
|
|
const href = (node.getAttribute('href') ?? '').trim();
|
|
if (!href) return label;
|
|
if (!label.trim()) return href;
|
|
return '[' + label + '](' + href + ')';
|
|
}
|
|
case 'STRONG':
|
|
case 'B':
|
|
return emphasise(inlineFrom(children(node)), '**');
|
|
case 'EM':
|
|
case 'I':
|
|
return emphasise(inlineFrom(children(node)), '_');
|
|
case 'DEL':
|
|
case 'S':
|
|
case 'STRIKE':
|
|
return emphasise(inlineFrom(children(node)), '~~');
|
|
case 'SPAN':
|
|
case 'FONT': {
|
|
// What a paste leaves behind. The tag says nothing; the style might
|
|
// — but only for a span wrapping one run of text. A span with
|
|
// elements inside it is a container carrying inherited style, not
|
|
// emphasis: WebKit hangs the whole computed style of a copied
|
|
// selection on such a wrapper, and honouring its `font-weight: 700`
|
|
// is what made an entire pasted note bold.
|
|
const inner = inlineFrom(children(node));
|
|
if (!isTextOnly(node)) return inner;
|
|
const style = node.getAttribute('style') ?? '';
|
|
if (/font-weight:\s*(bold|[6-9]00)/i.test(style)) return emphasise(inner, '**');
|
|
if (/font-style:\s*italic/i.test(style)) return emphasise(inner, '_');
|
|
return inner;
|
|
}
|
|
default:
|
|
return inlineFrom(children(node));
|
|
}
|
|
}
|
|
|
|
/** Markers hug their text: `** bold **` is four literal stars, not emphasis. */
|
|
function emphasise(inner, marker) {
|
|
const parts = /^(\s*)([\s\S]*?)(\s*)$/.exec(inner);
|
|
if (!parts[2]) return inner;
|
|
// Already carrying the same marker (nested `<b><b>`, or a paste): once is enough.
|
|
if (parts[2].startsWith(marker) && parts[2].endsWith(marker)) return inner;
|
|
return parts[1] + marker + parts[2] + marker + parts[3];
|
|
}
|
|
|
|
/**
|
|
* A run of inline content as one paragraph.
|
|
*
|
|
* Line starts are escaped here rather than in `escapeText`, because whether a
|
|
* `-` opens a list depends on where in the line it sits.
|
|
*/
|
|
function paragraph(text) {
|
|
return text
|
|
.split('\n')
|
|
.map((line) =>
|
|
line
|
|
.replace(/^(\s*)([#>]|[-*+](?=\s))/, '$1\\$2')
|
|
// The backslash goes before the dot, never before the digit: a
|
|
// backslash in front of anything but punctuation is a literal
|
|
// backslash, and `\1.` would be written into the file as it looks.
|
|
.replace(/^(\s*\d{1,9})([.)](?=\s))/, '$1\\$2'),
|
|
)
|
|
.join('\n')
|
|
.replace(/^\n+|\n+$/g, '');
|
|
}
|
|
|
|
function escapeText(value) {
|
|
return String(value)
|
|
.replace(/\\/g, '\\\\')
|
|
.replace(/([`*[\]])/g, '\\$1')
|
|
// Only where it could be read as emphasis: `snake_case` stays readable.
|
|
.replace(/(^|[^\w_])_/g, '$1\\_')
|
|
.replace(/_($|[^\w_])/g, '\\_$1')
|
|
.replace(/~~/g, '\\~\\~')
|
|
// A lone `<` only matters when it could open a tag.
|
|
.replace(/<(?=[a-zA-Z/!])/g, '\\<');
|
|
}
|
|
|
|
function languageOf(pre) {
|
|
for (const child of children(pre)) {
|
|
if (child.nodeType === 1 && child.nodeName === 'CODE') {
|
|
const match = /language-([A-Za-z0-9_+#-]+)/.exec(child.getAttribute('class') ?? '');
|
|
if (match) return match[1];
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function firstCheckbox(item) {
|
|
for (const child of children(item)) {
|
|
if (child.nodeType === 1 && child.nodeName === 'INPUT' && child.getAttribute('type') === 'checkbox') return child;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function hasBlockChild(element) {
|
|
return children(element).some((child) => child.nodeType === 1 && BLOCK_TAGS.has(child.nodeName));
|
|
}
|
|
|
|
/**
|
|
* Whether a block hides anywhere under this element.
|
|
*
|
|
* Pastes nest wrappers several deep — `<span><span><div>` — so the answer has
|
|
* to be looked for rather than checked one level down. Bounded, because the
|
|
* tree comes from a clipboard and nothing here should be able to hang on one.
|
|
*/
|
|
function holdsBlock(element, depth = 0) {
|
|
if (depth > 6) return false;
|
|
return children(element).some(
|
|
(child) =>
|
|
child.nodeType === 1 && (BLOCK_TAGS.has(child.nodeName) || child.nodeName === 'LI' || holdsBlock(child, depth + 1)),
|
|
);
|
|
}
|
|
|
|
/** A span with nothing but text in it — the only shape whose style is emphasis. */
|
|
function isTextOnly(element) {
|
|
return children(element).every((child) => child.nodeType === 3 || child.nodeName === 'BR');
|
|
}
|
|
|
|
function children(node) {
|
|
return Array.prototype.slice.call(node.childNodes ?? []);
|
|
}
|