@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.
@@ -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 {};