@memorysmithapp/markdown-renderer 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +37 -2
- package/dist/CopyButton.d.ts +27 -0
- package/dist/CopyButton.js +69 -0
- package/dist/LinkChoice.d.ts +28 -0
- package/dist/LinkChoice.js +181 -0
- package/dist/Markdown.d.ts +20 -0
- package/dist/Markdown.js +261 -0
- package/dist/MermaidDiagram.d.ts +16 -0
- package/dist/MermaidDiagram.js +70 -0
- package/dist/NoteBody.d.ts +15 -0
- package/dist/NoteBody.js +79 -0
- package/dist/Transclusion.d.ts +11 -0
- package/dist/Transclusion.js +66 -0
- package/dist/address.d.ts +53 -0
- package/dist/address.js +69 -0
- package/dist/delegated.d.ts +39 -0
- package/dist/delegated.js +63 -0
- package/dist/highlight.d.ts +54 -0
- package/dist/highlight.js +96 -0
- package/dist/host.d.ts +135 -0
- package/dist/host.js +49 -0
- package/dist/image-dimensions.d.ts +33 -0
- package/dist/image-dimensions.js +42 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/markdown.d.ts +68 -0
- package/dist/markdown.js +395 -0
- package/dist/remark-callouts.d.ts +40 -0
- package/dist/remark-callouts.js +108 -0
- package/dist/remark-ring.d.ts +68 -0
- package/dist/remark-ring.js +178 -0
- package/dist/strings.d.ts +55 -0
- package/dist/strings.js +64 -0
- package/dist/tasklist.d.ts +25 -0
- package/dist/tasklist.js +80 -0
- package/dist/transclusion.d.ts +76 -0
- package/dist/transclusion.js +161 -0
- package/dist/wikilink-address.d.ts +8 -0
- package/dist/wikilink-address.js +13 -0
- package/package.json +73 -3
- package/styles.css +865 -0
package/dist/markdown.js
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
// The text helpers of the reading surface: what a note becomes before the parser reads it.
|
|
2
|
+
// Nothing here rewrites the note a host keeps; each function returns a new string for display.
|
|
3
|
+
import { readDimensions } from './image-dimensions.js';
|
|
4
|
+
/**
|
|
5
|
+
* A note is bytes the notebook wrote, and a notebook written on Windows, or in a
|
|
6
|
+
* desktop editor, or pasted from anywhere, carries CRLF. Every reader in this
|
|
7
|
+
* module starts here, because a carriage return left at the end of a line is
|
|
8
|
+
* invisible in the source and fatal to a regex: `.` does not match one, so
|
|
9
|
+
* `(.*)$` fails on exactly the lines that have a value.
|
|
10
|
+
*/
|
|
11
|
+
export function toUnixNewlines(raw) {
|
|
12
|
+
return raw.replace(/\r\n?/g, '\n');
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Where the body of a note starts: after its frontmatter, which a host shows as properties and
|
|
16
|
+
* not as prose. The cut is the one MemorySmith's reading page makes, so the ordinals of the task
|
|
17
|
+
* boxes below it are counted over the whole note, as the host stores it.
|
|
18
|
+
*/
|
|
19
|
+
export function bodyStart(text) {
|
|
20
|
+
return text.startsWith('---') ? text.indexOf('\n---', 3) + 4 : 0;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Strips one layer of matching quotes, as the facet projector does. The trim
|
|
24
|
+
* comes first: an item of an inline list arrives with the space that followed
|
|
25
|
+
* the comma, and a quote that is not at position zero is not stripped.
|
|
26
|
+
*/
|
|
27
|
+
function unquote(value) {
|
|
28
|
+
return value
|
|
29
|
+
.trim()
|
|
30
|
+
.replace(/^["']|["']$/g, '')
|
|
31
|
+
.trim();
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The frontmatter block, read the way MemorySmith's reading page reads it to show a note's
|
|
35
|
+
* properties. The package does not draw it — `NoteBody` starts after it — and exports this so a
|
|
36
|
+
* host that draws the properties reads the same values MemorySmith does.
|
|
37
|
+
*/
|
|
38
|
+
export function splitFrontmatter(input) {
|
|
39
|
+
const raw = toUnixNewlines(input);
|
|
40
|
+
const bare = { frontmatter: {}, lists: new Set(), body: raw };
|
|
41
|
+
if (!raw.startsWith('---'))
|
|
42
|
+
return bare;
|
|
43
|
+
const end = raw.indexOf('\n---', 3);
|
|
44
|
+
if (end === -1)
|
|
45
|
+
return bare;
|
|
46
|
+
const head = raw.slice(raw.indexOf('\n') + 1, end);
|
|
47
|
+
const body = raw.slice(raw.indexOf('\n', end + 1) + 1);
|
|
48
|
+
const frontmatter = {};
|
|
49
|
+
const lists = new Set();
|
|
50
|
+
let lastKey = null;
|
|
51
|
+
for (const line of head.split('\n')) {
|
|
52
|
+
const kv = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line);
|
|
53
|
+
if (kv && kv[1]) {
|
|
54
|
+
lastKey = kv[1];
|
|
55
|
+
const value = (kv[2] ?? '').trim();
|
|
56
|
+
// An inline list becomes the same comma-separated string a dash list
|
|
57
|
+
// produces, so `tags: [a, b]` and a `tags:` block read alike from here.
|
|
58
|
+
const inlineList = value.startsWith('[') && value.endsWith(']');
|
|
59
|
+
if (inlineList)
|
|
60
|
+
lists.add(lastKey);
|
|
61
|
+
frontmatter[lastKey] = inlineList
|
|
62
|
+
? value
|
|
63
|
+
.slice(1, -1)
|
|
64
|
+
.split(',')
|
|
65
|
+
.map(unquote)
|
|
66
|
+
.filter((item) => item.length > 0)
|
|
67
|
+
.join(', ')
|
|
68
|
+
: unquote(value);
|
|
69
|
+
}
|
|
70
|
+
else if (lastKey && /^\s+-\s+/.test(line)) {
|
|
71
|
+
const item = unquote(line.replace(/^\s+-\s+/, ''));
|
|
72
|
+
lists.add(lastKey);
|
|
73
|
+
frontmatter[lastKey] = frontmatter[lastKey] ? `${frontmatter[lastKey]}, ${item}` : item;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { frontmatter, lists, body };
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* A wikilink, with the `!` in front of it when it is an embed.
|
|
80
|
+
*
|
|
81
|
+
* The bang is captured and not assumed away, because it is what tells a
|
|
82
|
+
* **link** from an **embed**, and the two reach different things: a link
|
|
83
|
+
* addresses a note and nothing else (§5.2), while an embed may also name a
|
|
84
|
+
* file the notebook keeps (§5.8). By the time this pass runs, the bang of a
|
|
85
|
+
* note embed has been taken off by `demoteEmbeds` — an embed that could not be
|
|
86
|
+
* expanded is a reference — so what still carries one is an embed of a file.
|
|
87
|
+
*/
|
|
88
|
+
const WIKILINK = /(!)?\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g;
|
|
89
|
+
/**
|
|
90
|
+
* Code, where notation is characters and nothing else.
|
|
91
|
+
*
|
|
92
|
+
* A fenced block parses nothing inside it and a code span is where an author
|
|
93
|
+
* writes notation without invoking it — both stated by the specification, in the
|
|
94
|
+
* `code-fenced` and `code-span` entries of `spec.json`. The rewrites below
|
|
95
|
+
* run over the raw text BEFORE the parser sees it, so they are the one place
|
|
96
|
+
* in the renderer that has to know that on its own: every other reading of
|
|
97
|
+
* the notation is a remark plugin, working on a tree where a code node is
|
|
98
|
+
* already a code node.
|
|
99
|
+
*
|
|
100
|
+
* Code is found by the rules the specification states and never by a pattern of
|
|
101
|
+
* backticks: a fence opens on a line whose first thing is a run of
|
|
102
|
+
* three or more backticks or tildes, closes on a line holding a run of the
|
|
103
|
+
* same character, at least as long, and nothing else, and otherwise runs to
|
|
104
|
+
* the end of the note; a code span closes on a run of exactly its own length.
|
|
105
|
+
* A pattern pairing any two runs of three backticks read a span SHOWING three
|
|
106
|
+
* backticks as an opening fence, and every block after it out of phase. The
|
|
107
|
+
* link extractor of MemorySmith reads code by the same rules, and both are held
|
|
108
|
+
* to the `link-in-code` cases of the conformance suite.
|
|
109
|
+
*
|
|
110
|
+
* The indented form is deliberately NOT here, and the reason is the cost of
|
|
111
|
+
* being wrong. Telling four spaces of code from four spaces of a nested list
|
|
112
|
+
* item needs the block context only a parser has; guessing it wrong turns a
|
|
113
|
+
* real link into text, and the same guess in the link extractor would drop a
|
|
114
|
+
* real edge. A wikilink written inside an indented code block is therefore
|
|
115
|
+
* still read, in both readers, and that is declared rather than discovered
|
|
116
|
+
* in MemorySmith's architecture. For the same reason a fence is accepted
|
|
117
|
+
* after whatever its list item or block quote puts in front of it, and a code
|
|
118
|
+
* span is not followed across a line break.
|
|
119
|
+
*/
|
|
120
|
+
const CONTAINER = String.raw `(?:[ \t]*(?:>[ \t]?|(?:[-*+]|\d{1,9}[.)])[ \t]+))*[ \t]*`;
|
|
121
|
+
const OPENING_FENCE = new RegExp(`^(${CONTAINER})(\`{3,}|~{3,})(.*?)\r?$`);
|
|
122
|
+
const CLOSING_FENCE = new RegExp(`^${CONTAINER}(\`{3,}|~{3,})[ \t]*\r?$`);
|
|
123
|
+
/**
|
|
124
|
+
* The fenced blocks of `body`, as half-open ranges from the opening run to the
|
|
125
|
+
* end of the closing line, and the lines no fence holds, which are where code
|
|
126
|
+
* spans may be.
|
|
127
|
+
*/
|
|
128
|
+
function scanFences(body) {
|
|
129
|
+
const fences = [];
|
|
130
|
+
const prose = [];
|
|
131
|
+
let open = null;
|
|
132
|
+
let at = 0;
|
|
133
|
+
for (const line of body.split('\n')) {
|
|
134
|
+
const end = at + line.length;
|
|
135
|
+
if (open) {
|
|
136
|
+
const run = CLOSING_FENCE.exec(line)?.[1];
|
|
137
|
+
if (run && run[0] === open.char && run.length >= open.length) {
|
|
138
|
+
fences.push([open.start, end]);
|
|
139
|
+
open = null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
const fence = OPENING_FENCE.exec(line);
|
|
144
|
+
const run = fence?.[2] ?? '';
|
|
145
|
+
// A backtick fence may not carry a backtick in its info string: such a
|
|
146
|
+
// line is a paragraph, and what looks like a fence in it is a span.
|
|
147
|
+
if (fence && !(run[0] === '`' && (fence[3] ?? '').includes('`'))) {
|
|
148
|
+
open = { char: run[0] ?? '`', length: run.length, start: at + (fence[1] ?? '').length };
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
prose.push({ line, at });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
at = end + 1;
|
|
155
|
+
}
|
|
156
|
+
if (open)
|
|
157
|
+
fences.push([open.start, body.length]);
|
|
158
|
+
return { fences, prose };
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* The code spans of one line: a run of backticks closes on the next run of
|
|
162
|
+
* exactly its length, a run with no partner is text, and a backtick escaped by
|
|
163
|
+
* a backslash opens nothing.
|
|
164
|
+
*/
|
|
165
|
+
function codeSpans(line, offset) {
|
|
166
|
+
const runs = [...line.matchAll(/`+/g)].map((match) => ({
|
|
167
|
+
index: match.index ?? 0,
|
|
168
|
+
length: match[0].length,
|
|
169
|
+
}));
|
|
170
|
+
const spans = [];
|
|
171
|
+
for (let i = 0; i < runs.length; i++) {
|
|
172
|
+
const run = runs[i];
|
|
173
|
+
if (!run)
|
|
174
|
+
continue;
|
|
175
|
+
const escaped = /(^|[^\\])(\\\\)*\\$/.test(line.slice(0, run.index));
|
|
176
|
+
const start = escaped ? run.index + 1 : run.index;
|
|
177
|
+
const length = escaped ? run.length - 1 : run.length;
|
|
178
|
+
if (length === 0)
|
|
179
|
+
continue;
|
|
180
|
+
const close = runs.findIndex((other, j) => j > i && other.length === length);
|
|
181
|
+
const closing = runs[close];
|
|
182
|
+
if (close === -1 || !closing)
|
|
183
|
+
continue;
|
|
184
|
+
spans.push([offset + start, offset + closing.index + closing.length]);
|
|
185
|
+
i = close;
|
|
186
|
+
}
|
|
187
|
+
return spans;
|
|
188
|
+
}
|
|
189
|
+
/** The half-open ranges of `body` that are fenced blocks. */
|
|
190
|
+
export function fenceRegions(body) {
|
|
191
|
+
return scanFences(body).fences;
|
|
192
|
+
}
|
|
193
|
+
/** The half-open ranges of `body` that are code, fenced or inline, in order. */
|
|
194
|
+
export function codeRegions(body) {
|
|
195
|
+
const { fences, prose } = scanFences(body);
|
|
196
|
+
const spans = prose.flatMap(({ line, at }) => codeSpans(line, at));
|
|
197
|
+
return [...fences, ...spans].sort((a, b) => a[0] - b[0]);
|
|
198
|
+
}
|
|
199
|
+
/** Whether an offset falls inside one of them. */
|
|
200
|
+
export function insideCode(regions, at) {
|
|
201
|
+
return regions.some(([start, end]) => at >= start && at < end);
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* A GFM table, which is the third place an embed cannot expand (§7.3).
|
|
205
|
+
*
|
|
206
|
+
* A cell holds inlines and never blocks (§4.1), so an embed of a note in a
|
|
207
|
+
* cell is drawn as a link, the same answer the one-level rule and the embed
|
|
208
|
+
* ceiling already give. What made this its own case is that the expansion is
|
|
209
|
+
* decided on the raw string, by cutting the body into runs around each
|
|
210
|
+
* `![[…]]`, and a cut inside a table row does not merely fail to expand: it
|
|
211
|
+
* ends the run mid-row, so the parser that reads it has an unterminated table
|
|
212
|
+
* and the closing pipe becomes a paragraph of its own.
|
|
213
|
+
*
|
|
214
|
+
* Detected here rather than in a plugin for the reason the code regions
|
|
215
|
+
* already are: the split happens before anything is parsed, so this is the one
|
|
216
|
+
* place in the renderer that has to recognise the block on its own.
|
|
217
|
+
*
|
|
218
|
+
* A table is a line containing a pipe, followed by a delimiter row of dashes,
|
|
219
|
+
* and it runs to the first blank line. The outer pipes are optional, which is
|
|
220
|
+
* why the delimiter row is what identifies the block. A shape this misses
|
|
221
|
+
* costs nothing new — it is the behaviour of before — so the pattern is
|
|
222
|
+
* deliberately the conservative one.
|
|
223
|
+
*/
|
|
224
|
+
const DELIMITER_ROW = /^ {0,3}\|?[ \t]*:?-{1,}:?[ \t]*(\|[ \t]*:?-{1,}:?[ \t]*)*\|?[ \t]*$/;
|
|
225
|
+
export function tableRegions(body) {
|
|
226
|
+
const regions = [];
|
|
227
|
+
const code = codeRegions(body);
|
|
228
|
+
const lines = body.split('\n');
|
|
229
|
+
const offsets = [];
|
|
230
|
+
let at = 0;
|
|
231
|
+
for (const line of lines) {
|
|
232
|
+
offsets.push(at);
|
|
233
|
+
at += line.length + 1;
|
|
234
|
+
}
|
|
235
|
+
for (let index = 0; index + 1 < lines.length; index++) {
|
|
236
|
+
const header = lines[index] ?? '';
|
|
237
|
+
const delimiter = lines[index + 1] ?? '';
|
|
238
|
+
if (!header.includes('|') || !delimiter.includes('|'))
|
|
239
|
+
continue;
|
|
240
|
+
if (!DELIMITER_ROW.test(delimiter))
|
|
241
|
+
continue;
|
|
242
|
+
if (insideCode(code, offsets[index] ?? 0))
|
|
243
|
+
continue;
|
|
244
|
+
let last = index + 1;
|
|
245
|
+
while (last + 1 < lines.length && (lines[last + 1] ?? '').trim().length > 0)
|
|
246
|
+
last++;
|
|
247
|
+
regions.push([offsets[index] ?? 0, (offsets[last] ?? 0) + (lines[last] ?? '').length]);
|
|
248
|
+
index = last;
|
|
249
|
+
}
|
|
250
|
+
return regions;
|
|
251
|
+
}
|
|
252
|
+
/** Whether an offset falls inside one of them. */
|
|
253
|
+
export function insideTable(regions, at) {
|
|
254
|
+
return regions.some(([start, end]) => at >= start && at < end);
|
|
255
|
+
}
|
|
256
|
+
/** Runs a rewrite over everything except code, which is copied through. */
|
|
257
|
+
export function outsideCode(body, rewrite) {
|
|
258
|
+
let out = '';
|
|
259
|
+
let cursor = 0;
|
|
260
|
+
for (const [start, end] of codeRegions(body)) {
|
|
261
|
+
out += rewrite(body.slice(cursor, start)) + body.slice(start, end);
|
|
262
|
+
cursor = end;
|
|
263
|
+
}
|
|
264
|
+
return out + rewrite(body.slice(cursor));
|
|
265
|
+
}
|
|
266
|
+
// Replaces [[wikilinks]] with markdown links. Resolved targets point at the
|
|
267
|
+
// note route; unresolved ones become pending: links styled by the renderer.
|
|
268
|
+
export function resolveWikilinks(body, resolve,
|
|
269
|
+
/**
|
|
270
|
+
* Whether the notebook keeps a FILE under that name. The extension of a
|
|
271
|
+
* name decides nothing, so what a target is cannot be read off it: it is
|
|
272
|
+
* read off what the notebook holds, which is the host's to answer.
|
|
273
|
+
*/
|
|
274
|
+
keeps = () => false) {
|
|
275
|
+
return outsideCode(body, (text) => resolveWikilinksIn(text, resolve, keeps));
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* The target of a wikilink is a NAME, and it is literal: nothing in it is
|
|
279
|
+
* decoded, no extension is removed and no path segment is discarded
|
|
280
|
+
* (§5.2). It used to be slugified here, which is what made a link differ
|
|
281
|
+
* from its note over an accent or a capital.
|
|
282
|
+
*/
|
|
283
|
+
function resolveWikilinksIn(body, resolve, keeps) {
|
|
284
|
+
return body.replace(WIKILINK, (_all, bang, target, label) => {
|
|
285
|
+
const clean = target.split('#')[0]?.trim().replace(/\\$/, '').trim() ?? '';
|
|
286
|
+
const embed = bang !== undefined;
|
|
287
|
+
const text = displayText(clean, label, target, (name) => embed && keeps(name));
|
|
288
|
+
if (!clean)
|
|
289
|
+
return `[${text}](pending:)`;
|
|
290
|
+
// The pipe is read by WHAT THE TARGET IS: a note takes the alias, an
|
|
291
|
+
// attachment takes the dimensions, and a target that resolves to neither
|
|
292
|
+
// takes the alias — because reading it as a dimension would discard text
|
|
293
|
+
// an author wrote.
|
|
294
|
+
//
|
|
295
|
+
// **Only an embed reaches a file**. A `[[name]]` is resolved against
|
|
296
|
+
// the notes of the notebook and their aliases, and against nothing else:
|
|
297
|
+
// §5.2 lists the whole of resolution and an attachment is in none of its
|
|
298
|
+
// steps, so a name only a file carries is a pending link like any other.
|
|
299
|
+
if (embed && keeps(clean)) {
|
|
300
|
+
// The dimension travels with the address, because the name is already
|
|
301
|
+
// percent-encoded and a bare `|` after it cannot be part of it.
|
|
302
|
+
// Dropping it here is what made `![[picture|120]]` render at full size:
|
|
303
|
+
// the notation was read and then thrown away one line before it was used.
|
|
304
|
+
const measured = label === undefined ? null : readDimensions(label);
|
|
305
|
+
const sized = measured
|
|
306
|
+
? `|${measured.width}${measured.height === null ? '' : `x${measured.height}`}`
|
|
307
|
+
: '';
|
|
308
|
+
return `[${text}](attachment:${encodeURIComponent(clean)}${sized})`;
|
|
309
|
+
}
|
|
310
|
+
const url = resolve(clean.normalize('NFC'));
|
|
311
|
+
return url ? `[${text}](${url})` : `[${text}](pending:${encodeURIComponent(clean)})`;
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* What the link shows. The pipe of an attachment carries dimensions and is
|
|
316
|
+
* never rendered as text, so what is left to show is the name of the file.
|
|
317
|
+
*/
|
|
318
|
+
function displayText(clean, label, target, keeps) {
|
|
319
|
+
if (clean && keeps(clean))
|
|
320
|
+
return clean;
|
|
321
|
+
return (label ?? target).trim();
|
|
322
|
+
}
|
|
323
|
+
/** The longest a heading key may be, which is what an anchor is matched on. */
|
|
324
|
+
const MAX_KEY_LENGTH = 80;
|
|
325
|
+
/**
|
|
326
|
+
* The key a **heading** is matched by, which is how an anchor finds the
|
|
327
|
+
* section it names inside a note.
|
|
328
|
+
*
|
|
329
|
+
* **It is not the key of a note, and there is no longer one of those.** A link
|
|
330
|
+
* resolves against the name, literally and case-exact (§5.2), so the
|
|
331
|
+
* slug this file used to compute — the second implementation of a rule the
|
|
332
|
+
* kernel also implemented, and the one that drifted — is gone. What is
|
|
333
|
+
* left is this: an anchor and a heading are two spellings of the same phrase,
|
|
334
|
+
* written by the same person minutes apart, and folding case and punctuation
|
|
335
|
+
* between them is what makes `#Artigo 75` find `## Artigo 75`.
|
|
336
|
+
*
|
|
337
|
+
* Nothing outside this comparison reads it, and nothing else in the renderer
|
|
338
|
+
* computes a key from a name: the address of a note is the host's to give.
|
|
339
|
+
*/
|
|
340
|
+
export function headingKey(name) {
|
|
341
|
+
return name
|
|
342
|
+
.normalize('NFD')
|
|
343
|
+
.replace(/(\d)[.,](\d)/g, '$1$2')
|
|
344
|
+
.replace(/[̀-ͯ]/g, '')
|
|
345
|
+
.toLowerCase()
|
|
346
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
347
|
+
.replace(/^-+|-+$/g, '')
|
|
348
|
+
.slice(0, MAX_KEY_LENGTH)
|
|
349
|
+
.replace(/-+$/g, '');
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* The hosts a note asks the reader's browser to talk to (§7.10).
|
|
353
|
+
*
|
|
354
|
+
* An image whose destination names a host is a **request to that host**, made
|
|
355
|
+
* when the note is opened, by whoever opens it — and the trigger was written
|
|
356
|
+
* by whoever wrote the note, which is the same sentence §7.9 uses about raw
|
|
357
|
+
* HTML. What travels with it is the reader's address, their user-agent and the
|
|
358
|
+
* moment they read it.
|
|
359
|
+
*
|
|
360
|
+
* The specification admits three answers and forbids only silence: never fetching,
|
|
361
|
+
* fetching on the reader's action, or fetching and saying so. MemorySmith,
|
|
362
|
+
* and this renderer with it, gives the third, so this is the half that says so. It is the image of §3.14
|
|
363
|
+
* and nothing else: a diagram and a formula render locally, and a link —
|
|
364
|
+
* bracketed, bare or autolinked — is a navigation the person initiates rather
|
|
365
|
+
* than a request the page makes.
|
|
366
|
+
*
|
|
367
|
+
* Code is excluded, because an image written inside a fence is an example of
|
|
368
|
+
* the notation and fetches nothing.
|
|
369
|
+
*/
|
|
370
|
+
const IMAGE_DESTINATION = /!\[[^\]]*\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)/g;
|
|
371
|
+
export function remoteImageHosts(body) {
|
|
372
|
+
const regions = codeRegions(body);
|
|
373
|
+
const hosts = new Set();
|
|
374
|
+
for (const match of body.matchAll(IMAGE_DESTINATION)) {
|
|
375
|
+
if (insideCode(regions, match.index ?? 0))
|
|
376
|
+
continue;
|
|
377
|
+
const destination = (match[1] ?? '').trim();
|
|
378
|
+
// A path, a relative target or an anchor names no host and asks nobody
|
|
379
|
+
// for anything. Only a scheme or a leading `//` reaches outside.
|
|
380
|
+
const authority = /^[a-z][a-z0-9+.-]*:\/\//i.test(destination)
|
|
381
|
+
? destination.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '')
|
|
382
|
+
: destination.startsWith('//')
|
|
383
|
+
? destination.slice(2)
|
|
384
|
+
: '';
|
|
385
|
+
if (authority === '')
|
|
386
|
+
continue;
|
|
387
|
+
// The authority, minus any credentials, port and path: what a person needs
|
|
388
|
+
// in order to recognise who they just talked to.
|
|
389
|
+
const host = (authority.split('/')[0] ?? '').split('@').pop() ?? '';
|
|
390
|
+
const named = host.split(':')[0] ?? '';
|
|
391
|
+
if (named !== '')
|
|
392
|
+
hosts.add(named.toLowerCase());
|
|
393
|
+
}
|
|
394
|
+
return [...hosts];
|
|
395
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notebook callouts, rendered as callouts.
|
|
3
|
+
*
|
|
4
|
+
* A callout is a blockquote whose first line is a type marker:
|
|
5
|
+
*
|
|
6
|
+
* > [!warning] O que pode dar errado
|
|
7
|
+
* > O corpo do aviso.
|
|
8
|
+
*
|
|
9
|
+
* It is a notebook convention and not universal Markdown (§7.1). What
|
|
10
|
+
* MemorySmith's reading page once did was a pass over the
|
|
11
|
+
* string that swapped the marker for an emoji and left the blockquote behind,
|
|
12
|
+
* which reads as a quotation with a picture in front of it. This turns it into
|
|
13
|
+
* the element the vault editors draw, and the type travels to the DOM as an
|
|
14
|
+
* attribute so the stylesheet can colour and ice it without a class per type.
|
|
15
|
+
*
|
|
16
|
+
* It is a remark plugin rather than another pass over the string because the
|
|
17
|
+
* marker has to leave the tree the renderer walks, not the text the parser
|
|
18
|
+
* reads: a `[!note]` inside a code fence is not a callout, and only the parser
|
|
19
|
+
* can tell the difference.
|
|
20
|
+
*/
|
|
21
|
+
interface MdastNode {
|
|
22
|
+
type: string;
|
|
23
|
+
value?: string;
|
|
24
|
+
children?: MdastNode[];
|
|
25
|
+
data?: {
|
|
26
|
+
hName?: string;
|
|
27
|
+
hProperties?: Record<string, unknown>;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export interface CalloutOptions {
|
|
31
|
+
/**
|
|
32
|
+
* The title of a callout written without one, in the language of whoever
|
|
33
|
+
* reads it. It answers `undefined` for a type it has no word for,
|
|
34
|
+
* and that type titles itself, capitalised, as the editors do: the
|
|
35
|
+
* vocabulary is open (§7.1) and a word nobody translated is still a word.
|
|
36
|
+
*/
|
|
37
|
+
readonly titleOf?: (kind: string) => string | undefined;
|
|
38
|
+
}
|
|
39
|
+
export declare function remarkCallouts(options?: CalloutOptions): (tree: MdastNode) => void;
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notebook callouts, rendered as callouts.
|
|
3
|
+
*
|
|
4
|
+
* A callout is a blockquote whose first line is a type marker:
|
|
5
|
+
*
|
|
6
|
+
* > [!warning] O que pode dar errado
|
|
7
|
+
* > O corpo do aviso.
|
|
8
|
+
*
|
|
9
|
+
* It is a notebook convention and not universal Markdown (§7.1). What
|
|
10
|
+
* MemorySmith's reading page once did was a pass over the
|
|
11
|
+
* string that swapped the marker for an emoji and left the blockquote behind,
|
|
12
|
+
* which reads as a quotation with a picture in front of it. This turns it into
|
|
13
|
+
* the element the vault editors draw, and the type travels to the DOM as an
|
|
14
|
+
* attribute so the stylesheet can colour and ice it without a class per type.
|
|
15
|
+
*
|
|
16
|
+
* It is a remark plugin rather than another pass over the string because the
|
|
17
|
+
* marker has to leave the tree the renderer walks, not the text the parser
|
|
18
|
+
* reads: a `[!note]` inside a code fence is not a callout, and only the parser
|
|
19
|
+
* can tell the difference.
|
|
20
|
+
*/
|
|
21
|
+
/** `[!type]`, an optional fold marker, and whatever titles it. */
|
|
22
|
+
const MARKER = /^\[!([A-Za-z][\w-]*)\][+-]?[ \t]*(.*)$/;
|
|
23
|
+
export function remarkCallouts(options = {}) {
|
|
24
|
+
return (tree) => descend(tree, options);
|
|
25
|
+
}
|
|
26
|
+
function descend(node, options) {
|
|
27
|
+
for (const child of node.children ?? []) {
|
|
28
|
+
if (child.type === 'blockquote')
|
|
29
|
+
rewrite(child, options);
|
|
30
|
+
descend(child, options);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function rewrite(quote, options) {
|
|
34
|
+
const [head, ...blocks] = quote.children ?? [];
|
|
35
|
+
if (head?.type !== 'paragraph')
|
|
36
|
+
return;
|
|
37
|
+
const [lead, ...inline] = head.children ?? [];
|
|
38
|
+
if (lead?.type !== 'text' || typeof lead.value !== 'string')
|
|
39
|
+
return;
|
|
40
|
+
const broke = lead.value.indexOf('\n');
|
|
41
|
+
const marker = MARKER.exec(broke === -1 ? lead.value : lead.value.slice(0, broke));
|
|
42
|
+
if (!marker)
|
|
43
|
+
return;
|
|
44
|
+
const kind = (marker[1] ?? '').toLowerCase();
|
|
45
|
+
const { title, body } = split(marker[2] ?? '', broke === -1 ? null : lead.value.slice(broke + 1), inline);
|
|
46
|
+
quote.data = {
|
|
47
|
+
hName: 'div',
|
|
48
|
+
hProperties: { className: ['callout'], 'data-callout': kind },
|
|
49
|
+
};
|
|
50
|
+
quote.children = [
|
|
51
|
+
{
|
|
52
|
+
type: 'paragraph',
|
|
53
|
+
data: { hName: 'div', hProperties: { className: ['callout-title'] } },
|
|
54
|
+
// An untitled callout is titled by its own type, as the editors do, and
|
|
55
|
+
// in the locale of the reader rather than always in English.
|
|
56
|
+
children: title.length > 0
|
|
57
|
+
? title
|
|
58
|
+
: [{ type: 'text', value: options.titleOf?.(kind) ?? capitalize(kind) }],
|
|
59
|
+
},
|
|
60
|
+
...(body.length > 0 ? [{ type: 'paragraph', children: body }] : []),
|
|
61
|
+
...blocks,
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Cuts the first paragraph at its first line break: what comes before titles
|
|
66
|
+
* the callout, what comes after opens its body. The break can fall inside any
|
|
67
|
+
* of the inline nodes, because a title that carries emphasis makes the
|
|
68
|
+
* paragraph three children and not one, so the split walks them instead of
|
|
69
|
+
* assuming the title ends where the first text node does.
|
|
70
|
+
*/
|
|
71
|
+
function split(titled, after, inline) {
|
|
72
|
+
const title = [{ type: 'text', value: titled }];
|
|
73
|
+
const body = [];
|
|
74
|
+
// The lead text already broke, so everything after it belongs to the body.
|
|
75
|
+
let titling = after === null;
|
|
76
|
+
if (after !== null)
|
|
77
|
+
body.push({ type: 'text', value: after });
|
|
78
|
+
for (const node of inline) {
|
|
79
|
+
if (!titling) {
|
|
80
|
+
body.push(node);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const breakAt = node.type === 'text' ? (node.value ?? '').indexOf('\n') : -1;
|
|
84
|
+
if (breakAt === -1) {
|
|
85
|
+
title.push(node);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
title.push({ type: 'text', value: (node.value ?? '').slice(0, breakAt) });
|
|
89
|
+
body.push({ type: 'text', value: (node.value ?? '').slice(breakAt + 1) });
|
|
90
|
+
titling = false;
|
|
91
|
+
}
|
|
92
|
+
return { title: trimEdges(title), body: trimEdges(body) };
|
|
93
|
+
}
|
|
94
|
+
/** Drops the empty text nodes the cut leaves at either end. */
|
|
95
|
+
function trimEdges(nodes) {
|
|
96
|
+
const kept = [...nodes];
|
|
97
|
+
while (kept.length > 0 && isBlank(kept[0]))
|
|
98
|
+
kept.shift();
|
|
99
|
+
while (kept.length > 0 && isBlank(kept[kept.length - 1]))
|
|
100
|
+
kept.pop();
|
|
101
|
+
return kept;
|
|
102
|
+
}
|
|
103
|
+
function isBlank(node) {
|
|
104
|
+
return node?.type === 'text' && (node.value ?? '').trim().length === 0;
|
|
105
|
+
}
|
|
106
|
+
function capitalize(value) {
|
|
107
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
108
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rest of the MemorySmith notation, on the reading surface: marked text,
|
|
3
|
+
* comments, block identifiers (§7.5, §7.6, §7.7) and the dollar rule of
|
|
4
|
+
* mathematics (§7.8).
|
|
5
|
+
*
|
|
6
|
+
* All three are remark plugins rather than passes over the string, for the
|
|
7
|
+
* reason `remark-callouts.ts` already gives: a `==` inside a code fence is not
|
|
8
|
+
* a highlight, and only the parser can tell the difference. Rewriting the text
|
|
9
|
+
* before it is parsed is how a notation starts firing inside examples.
|
|
10
|
+
*
|
|
11
|
+
* None of them touches the bytes. The reading surface is a rendering of the
|
|
12
|
+
* note and never a rewriting of it, so what a host stores and returns is
|
|
13
|
+
* unaffected by everything here — which is the whole of what makes
|
|
14
|
+
* `%%comment%%` an asymmetry that has to be declared rather than a deletion.
|
|
15
|
+
*/
|
|
16
|
+
interface MdastNode {
|
|
17
|
+
type: string;
|
|
18
|
+
value?: string;
|
|
19
|
+
children?: MdastNode[];
|
|
20
|
+
data?: {
|
|
21
|
+
hName?: string;
|
|
22
|
+
hProperties?: Record<string, unknown>;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export declare function remarkHighlight(): (tree: MdastNode) => void;
|
|
26
|
+
export declare function remarkComments(): (tree: MdastNode) => void;
|
|
27
|
+
export declare function remarkBlockIds(): (tree: MdastNode) => void;
|
|
28
|
+
/**
|
|
29
|
+
* The `$` that is not math (§7.8).
|
|
30
|
+
*
|
|
31
|
+
* `remark-math` opens a formula at any `$` and closes it at the next one, so
|
|
32
|
+
* **two prices in one paragraph become a formula**: `costs $30 a month, and
|
|
33
|
+
* the plan $60` renders `30 a month, and the plan` as mathematics. That is not
|
|
34
|
+
* an exotic case, it is a sentence somebody writes on an ordinary day.
|
|
35
|
+
*
|
|
36
|
+
* The specification states four prohibitions, and **each one has an outside edge and
|
|
37
|
+
* an inside edge**. A `$` does not OPEN a formula when it is followed by
|
|
38
|
+
* whitespace, nor when it is immediately preceded by an alphanumeric; it does
|
|
39
|
+
* not CLOSE one when it is preceded by whitespace, nor when it is immediately
|
|
40
|
+
* followed by a digit.
|
|
41
|
+
*
|
|
42
|
+
* The inside edges arrived with specification v0.4.0, and they are what keeps a
|
|
43
|
+
* price whole. `R$ 100` is safe on the outside edge alone, because of the
|
|
44
|
+
* space. `R$100 e o frete R$200` is not: neither `$` sits next to a space, so
|
|
45
|
+
* with only the outside edge the sentence loses its middle — `100 e o frete R`
|
|
46
|
+
* typeset as mathematics, in italic serif, with the `R` and the `200` stranded
|
|
47
|
+
* either side of it. It is an ordinary sentence in pt-BR, where a price is
|
|
48
|
+
* written `R$`.
|
|
49
|
+
*
|
|
50
|
+
* **The cost is deliberate and the specification names it.** An inline formula
|
|
51
|
+
* written immediately after a word character no longer opens, so `2$x$` is
|
|
52
|
+
* text and `2 $x$` is the form that works. A `$` written for any other purpose
|
|
53
|
+
* belongs in a code span, which is opaque to everything — and for `$HOME` and
|
|
54
|
+
* `$PATH` in one sentence that is the only thing that protects them, because
|
|
55
|
+
* both delimiters look legal by every local rule.
|
|
56
|
+
*
|
|
57
|
+
* This runs after `remark-math` and gives back to the text any inline formula
|
|
58
|
+
* that breaks the rule, reading the source the parser read: the delimiters are
|
|
59
|
+
* gone from the node, and the position is what still knows where they were.
|
|
60
|
+
* The characters ON either side of the range are what the inside edges need,
|
|
61
|
+
* which is why the slice reaches one character past each end.
|
|
62
|
+
*
|
|
63
|
+
* Block math (`$$…$$`) is untouched: its delimiters are unambiguous.
|
|
64
|
+
*/
|
|
65
|
+
export declare function remarkMathDollarRule(): (tree: MdastNode, file: {
|
|
66
|
+
value?: unknown;
|
|
67
|
+
}) => void;
|
|
68
|
+
export {};
|