@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
|
@@ -0,0 +1,178 @@
|
|
|
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
|
+
/** Walks every node, letting the visitor rewrite a parent's children. */
|
|
17
|
+
function walk(node, visit) {
|
|
18
|
+
if (!node.children)
|
|
19
|
+
return;
|
|
20
|
+
visit(node);
|
|
21
|
+
for (const child of node.children)
|
|
22
|
+
walk(child, visit);
|
|
23
|
+
}
|
|
24
|
+
/** Text nodes carry the raw characters; nothing else is rewritten. */
|
|
25
|
+
function isText(node) {
|
|
26
|
+
return node.type === 'text';
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* `==highlight==` becomes marked text (§7.5).
|
|
30
|
+
*
|
|
31
|
+
* It carries no meaning beyond emphasis: no edge, no attribute, no index
|
|
32
|
+
* entry. The `==` never reaches the screen.
|
|
33
|
+
*/
|
|
34
|
+
const HIGHLIGHT = /==([^=]+)==/g;
|
|
35
|
+
export function remarkHighlight() {
|
|
36
|
+
return (tree) => walk(tree, (parent) => {
|
|
37
|
+
parent.children = (parent.children ?? []).flatMap((child) => {
|
|
38
|
+
if (!isText(child) || !child.value?.includes('=='))
|
|
39
|
+
return [child];
|
|
40
|
+
return split(child.value, HIGHLIGHT, (inner) => ({
|
|
41
|
+
type: 'emphasis',
|
|
42
|
+
data: { hName: 'mark' },
|
|
43
|
+
children: [{ type: 'text', value: inner }],
|
|
44
|
+
}));
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* `%%comment%%` leaves the page and stays in the file (§7.6).
|
|
50
|
+
*
|
|
51
|
+
* The asymmetry is the decision and it is declared, not discovered: the bytes
|
|
52
|
+
* are untouched, so whatever returns the note returns the comment, and an
|
|
53
|
+
* agent therefore sees what a person on the page does not. Text the
|
|
54
|
+
* author did not want *read on the page* is still text the author wrote, and
|
|
55
|
+
* deleting bytes to make a page tidier is not something this product does.
|
|
56
|
+
*/
|
|
57
|
+
const COMMENT = /%%([\s\S]*?)%%/g;
|
|
58
|
+
export function remarkComments() {
|
|
59
|
+
return (tree) => walk(tree, (parent) => {
|
|
60
|
+
parent.children = (parent.children ?? []).flatMap((child) => {
|
|
61
|
+
if (!isText(child) || !child.value?.includes('%%'))
|
|
62
|
+
return [child];
|
|
63
|
+
const stripped = child.value.replace(COMMENT, '');
|
|
64
|
+
// A text that was nothing but a comment leaves no empty text node
|
|
65
|
+
// behind. The paragraph that held it stays, empty, as it does on
|
|
66
|
+
// MemorySmith's reading page.
|
|
67
|
+
return stripped.length === 0 ? [] : [{ ...child, value: stripped }];
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* `^identifier` at the end of a block names that block (§7.7), and is never
|
|
73
|
+
* rendered as text. What it names is resolved by the embed, not here: this
|
|
74
|
+
* plugin only keeps the marker off the page.
|
|
75
|
+
*/
|
|
76
|
+
const BLOCK_ID = /[ \t]*\^([A-Za-z0-9-]+)[ \t]*$/;
|
|
77
|
+
export function remarkBlockIds() {
|
|
78
|
+
return (tree) => walk(tree, (parent) => {
|
|
79
|
+
const children = parent.children ?? [];
|
|
80
|
+
const last = children[children.length - 1];
|
|
81
|
+
if (!last || !isText(last) || !last.value?.includes('^'))
|
|
82
|
+
return;
|
|
83
|
+
const stripped = last.value.replace(BLOCK_ID, '');
|
|
84
|
+
if (stripped === last.value)
|
|
85
|
+
return;
|
|
86
|
+
if (stripped.length === 0)
|
|
87
|
+
children.pop();
|
|
88
|
+
else
|
|
89
|
+
last.value = stripped;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The `$` that is not math (§7.8).
|
|
94
|
+
*
|
|
95
|
+
* `remark-math` opens a formula at any `$` and closes it at the next one, so
|
|
96
|
+
* **two prices in one paragraph become a formula**: `costs $30 a month, and
|
|
97
|
+
* the plan $60` renders `30 a month, and the plan` as mathematics. That is not
|
|
98
|
+
* an exotic case, it is a sentence somebody writes on an ordinary day.
|
|
99
|
+
*
|
|
100
|
+
* The specification states four prohibitions, and **each one has an outside edge and
|
|
101
|
+
* an inside edge**. A `$` does not OPEN a formula when it is followed by
|
|
102
|
+
* whitespace, nor when it is immediately preceded by an alphanumeric; it does
|
|
103
|
+
* not CLOSE one when it is preceded by whitespace, nor when it is immediately
|
|
104
|
+
* followed by a digit.
|
|
105
|
+
*
|
|
106
|
+
* The inside edges arrived with specification v0.4.0, and they are what keeps a
|
|
107
|
+
* price whole. `R$ 100` is safe on the outside edge alone, because of the
|
|
108
|
+
* space. `R$100 e o frete R$200` is not: neither `$` sits next to a space, so
|
|
109
|
+
* with only the outside edge the sentence loses its middle — `100 e o frete R`
|
|
110
|
+
* typeset as mathematics, in italic serif, with the `R` and the `200` stranded
|
|
111
|
+
* either side of it. It is an ordinary sentence in pt-BR, where a price is
|
|
112
|
+
* written `R$`.
|
|
113
|
+
*
|
|
114
|
+
* **The cost is deliberate and the specification names it.** An inline formula
|
|
115
|
+
* written immediately after a word character no longer opens, so `2$x$` is
|
|
116
|
+
* text and `2 $x$` is the form that works. A `$` written for any other purpose
|
|
117
|
+
* belongs in a code span, which is opaque to everything — and for `$HOME` and
|
|
118
|
+
* `$PATH` in one sentence that is the only thing that protects them, because
|
|
119
|
+
* both delimiters look legal by every local rule.
|
|
120
|
+
*
|
|
121
|
+
* This runs after `remark-math` and gives back to the text any inline formula
|
|
122
|
+
* that breaks the rule, reading the source the parser read: the delimiters are
|
|
123
|
+
* gone from the node, and the position is what still knows where they were.
|
|
124
|
+
* The characters ON either side of the range are what the inside edges need,
|
|
125
|
+
* which is why the slice reaches one character past each end.
|
|
126
|
+
*
|
|
127
|
+
* Block math (`$$…$$`) is untouched: its delimiters are unambiguous.
|
|
128
|
+
*/
|
|
129
|
+
export function remarkMathDollarRule() {
|
|
130
|
+
return (tree, file) => {
|
|
131
|
+
const source = typeof file.value === 'string' ? file.value : String(file.value ?? '');
|
|
132
|
+
if (source.length === 0)
|
|
133
|
+
return;
|
|
134
|
+
walk(tree, (parent) => {
|
|
135
|
+
parent.children = (parent.children ?? []).map((child) => {
|
|
136
|
+
if (child.type !== 'inlineMath')
|
|
137
|
+
return child;
|
|
138
|
+
const { start, end } = child.position ?? {};
|
|
139
|
+
if (start?.offset === undefined || end?.offset === undefined)
|
|
140
|
+
return child;
|
|
141
|
+
const raw = source.slice(start.offset, end.offset);
|
|
142
|
+
const inner = raw.replace(/^\$+/, '').replace(/\$+$/, '');
|
|
143
|
+
if (inner.length === 0)
|
|
144
|
+
return { type: 'text', value: raw };
|
|
145
|
+
// The outside edges: what sits just inside each delimiter.
|
|
146
|
+
if (/^\s/.test(inner) || /\s$/.test(inner))
|
|
147
|
+
return { type: 'text', value: raw };
|
|
148
|
+
// The inside edges: what sits just OUTSIDE each delimiter, which is
|
|
149
|
+
// the half `R$100 e o frete R$200` is decided by.
|
|
150
|
+
const before = start.offset > 0 ? (source[start.offset - 1] ?? '') : '';
|
|
151
|
+
const after = source[end.offset] ?? '';
|
|
152
|
+
if (before !== '' && /[0-9A-Za-z]/.test(before))
|
|
153
|
+
return { type: 'text', value: raw };
|
|
154
|
+
if (after !== '' && /[0-9]/.test(after))
|
|
155
|
+
return { type: 'text', value: raw };
|
|
156
|
+
return child;
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/** Cuts a text node on a pattern, keeping the parts between the matches. */
|
|
162
|
+
function split(value, pattern, make) {
|
|
163
|
+
const parts = [];
|
|
164
|
+
let cursor = 0;
|
|
165
|
+
pattern.lastIndex = 0;
|
|
166
|
+
for (const match of value.matchAll(pattern)) {
|
|
167
|
+
const at = match.index ?? 0;
|
|
168
|
+
if (at > cursor)
|
|
169
|
+
parts.push({ type: 'text', value: value.slice(cursor, at) });
|
|
170
|
+
parts.push(make(match[1] ?? ''));
|
|
171
|
+
cursor = at + match[0].length;
|
|
172
|
+
}
|
|
173
|
+
if (parts.length === 0)
|
|
174
|
+
return [{ type: 'text', value }];
|
|
175
|
+
if (cursor < value.length)
|
|
176
|
+
parts.push({ type: 'text', value: value.slice(cursor) });
|
|
177
|
+
return parts;
|
|
178
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The words the reading surface shows of its own, around what a note says.
|
|
3
|
+
*
|
|
4
|
+
* The package carries no i18n library: the host gives the words in the language of whoever
|
|
5
|
+
* reads, and every word it leaves out falls back to the English MemorySmith ships. A callout
|
|
6
|
+
* kind with no title in `callouts` titles itself, capitalised, as the vault editors do.
|
|
7
|
+
*/
|
|
8
|
+
export interface NoteStrings {
|
|
9
|
+
/** The title of an untitled callout, by kind (`note`, `warning`, …). */
|
|
10
|
+
readonly callouts: Readonly<Record<string, string>>;
|
|
11
|
+
/** The heading of the footnotes gathered at the end of a note. */
|
|
12
|
+
readonly footnotes: string;
|
|
13
|
+
/** The label of the way back from a footnote to its reference, counted from 1. */
|
|
14
|
+
readonly footnoteBack: (number: number) => string;
|
|
15
|
+
/** The title of a link no note answers yet. */
|
|
16
|
+
readonly pendingLink: string;
|
|
17
|
+
/** The title of an address the surface does not follow. */
|
|
18
|
+
readonly refusedLink: string;
|
|
19
|
+
/** The title of an embed naming a file the notebook does not keep. */
|
|
20
|
+
readonly attachmentMissing: string;
|
|
21
|
+
/** The verb of the button that copies a block of code. */
|
|
22
|
+
readonly copy: string;
|
|
23
|
+
/** What that button says once the block is copied. */
|
|
24
|
+
readonly copied: string;
|
|
25
|
+
/** The notice under a note that fetched images from other sites, naming them. */
|
|
26
|
+
readonly remoteImages: (hosts: readonly string[]) => string;
|
|
27
|
+
/** The accessible name of the choice a link opens. */
|
|
28
|
+
readonly linkChoice: (target: string) => string;
|
|
29
|
+
/** The heading of the choice when no note answers the target yet. */
|
|
30
|
+
readonly linkLeadsNowhere: string;
|
|
31
|
+
/** The heading of the choice when several notes answer it. */
|
|
32
|
+
readonly linkLeadsToSeveral: string;
|
|
33
|
+
/** What the choice says when no note answers the target. */
|
|
34
|
+
readonly targetPending: (target: string) => string;
|
|
35
|
+
/** What it says when notes are named the target. */
|
|
36
|
+
readonly targetNamed: (target: string, count: number) => string;
|
|
37
|
+
/** What it says when notes carry the target as an alias. */
|
|
38
|
+
readonly targetAliased: (target: string, count: number) => string;
|
|
39
|
+
/** What an embed says when the section or block it names is not in the note. */
|
|
40
|
+
readonly embedSectionMissing: (section: string) => string;
|
|
41
|
+
/** The caption of a note that has no name. */
|
|
42
|
+
readonly unnamedNote: string;
|
|
43
|
+
/** What a region says while it waits for the host. */
|
|
44
|
+
readonly loading: string;
|
|
45
|
+
/** What a region says when the host failed to answer. */
|
|
46
|
+
readonly failed: string;
|
|
47
|
+
/** The control that closes the choice. */
|
|
48
|
+
readonly close: string;
|
|
49
|
+
/** The button that dismisses the choice. */
|
|
50
|
+
readonly cancel: string;
|
|
51
|
+
}
|
|
52
|
+
/** The words MemorySmith shows in English, used for every word a host does not give. */
|
|
53
|
+
export declare const ENGLISH: NoteStrings;
|
|
54
|
+
/** The host's words over the English ones, key by key. */
|
|
55
|
+
export declare function withDefaults(strings: Partial<NoteStrings> | undefined): NoteStrings;
|
package/dist/strings.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** The callout titles of MemorySmith, in English. */
|
|
2
|
+
const CALLOUTS = {
|
|
3
|
+
note: 'Note',
|
|
4
|
+
info: 'Info',
|
|
5
|
+
todo: 'To do',
|
|
6
|
+
abstract: 'Abstract',
|
|
7
|
+
summary: 'Summary',
|
|
8
|
+
tldr: 'TL;DR',
|
|
9
|
+
tip: 'Tip',
|
|
10
|
+
hint: 'Hint',
|
|
11
|
+
important: 'Important',
|
|
12
|
+
success: 'Success',
|
|
13
|
+
check: 'Check',
|
|
14
|
+
done: 'Done',
|
|
15
|
+
question: 'Question',
|
|
16
|
+
help: 'Help',
|
|
17
|
+
faq: 'FAQ',
|
|
18
|
+
warning: 'Warning',
|
|
19
|
+
caution: 'Caution',
|
|
20
|
+
attention: 'Attention',
|
|
21
|
+
failure: 'Failure',
|
|
22
|
+
fail: 'Fail',
|
|
23
|
+
missing: 'Missing',
|
|
24
|
+
danger: 'Danger',
|
|
25
|
+
error: 'Error',
|
|
26
|
+
bug: 'Bug',
|
|
27
|
+
example: 'Example',
|
|
28
|
+
quote: 'Quote',
|
|
29
|
+
cite: 'Cite',
|
|
30
|
+
};
|
|
31
|
+
/** The words MemorySmith shows in English, used for every word a host does not give. */
|
|
32
|
+
export const ENGLISH = {
|
|
33
|
+
callouts: CALLOUTS,
|
|
34
|
+
footnotes: 'Notes',
|
|
35
|
+
footnoteBack: (number) => `Back to reference ${number}`,
|
|
36
|
+
pendingLink: 'Pending link: the target note does not exist yet.',
|
|
37
|
+
refusedLink: 'This address is not one the reading surface follows, so the text stays and the link does not.',
|
|
38
|
+
attachmentMissing: 'This notebook keeps no file under that name.',
|
|
39
|
+
copy: 'Copy',
|
|
40
|
+
copied: 'Copied',
|
|
41
|
+
remoteImages: (hosts) => hosts.length === 1
|
|
42
|
+
? `Opening this note fetched an image from ${hosts.join(', ')}, so that site was told your address and the moment you read this.`
|
|
43
|
+
: `Opening this note fetched images from ${hosts.length} other sites — ${hosts.join(', ')} — so each of them was told your address and the moment you read this.`,
|
|
44
|
+
linkChoice: (target) => `The notes the link “${target}” may lead to`,
|
|
45
|
+
linkLeadsNowhere: 'This link leads nowhere yet',
|
|
46
|
+
linkLeadsToSeveral: 'This link leads to more than one note',
|
|
47
|
+
targetPending: (target) => `No note carries the name “${target}” yet. The link is kept and resolves on its own when one does.`,
|
|
48
|
+
targetNamed: (target, count) => count === 1
|
|
49
|
+
? `“${target}” is the name of one note.`
|
|
50
|
+
: `“${target}” is the name of ${count} notes, each in its own folder. Choose which one to open.`,
|
|
51
|
+
targetAliased: (target, count) => count === 1
|
|
52
|
+
? `No note is named “${target}”. One note carries it as an alias.`
|
|
53
|
+
: `No note is named “${target}”, so it matched ${count} notes that carry it as an alias — and one of them gives the link up the day somebody writes a note under this name.`,
|
|
54
|
+
embedSectionMissing: (section) => `The section "${section}" is not in this note.`,
|
|
55
|
+
unnamedNote: 'Unnamed note',
|
|
56
|
+
loading: 'Loading…',
|
|
57
|
+
failed: 'Something went wrong.',
|
|
58
|
+
close: 'Close',
|
|
59
|
+
cancel: 'Cancel',
|
|
60
|
+
};
|
|
61
|
+
/** The host's words over the English ones, key by key. */
|
|
62
|
+
export function withDefaults(strings) {
|
|
63
|
+
return { ...ENGLISH, ...strings };
|
|
64
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clicking a task box, on the reading surface.
|
|
3
|
+
*
|
|
4
|
+
* The package never writes the note: a click reports the ordinal of the box,
|
|
5
|
+
* and the host applies it with `toggleTaskAt`, which these functions are
|
|
6
|
+
* exported for. The toggle is POSITIONAL over the original text, never a
|
|
7
|
+
* re-serialisation. Exactly one character between brackets changes, and
|
|
8
|
+
* everything else in the document reaches the host's store as the author
|
|
9
|
+
* wrote it: frontmatter, spacing, line breaks and the rest of the item line
|
|
10
|
+
* included. That is a requirement, not elegance: `splitFrontmatter` is lossy
|
|
11
|
+
* by design, because it flattens lists for display, and rebuilding the
|
|
12
|
+
* document from it would hand the notebook a file nobody typed.
|
|
13
|
+
*/
|
|
14
|
+
/** Offset of the `[` of every real task box, in document order. */
|
|
15
|
+
export declare function taskBoxes(text: string): number[];
|
|
16
|
+
/** How many task items start before this offset: the ordinal of that item. */
|
|
17
|
+
export declare function ordinalAt(text: string, offset: number): number;
|
|
18
|
+
/**
|
|
19
|
+
* Flips the n-th task box. Returns null when there is no such item, which is
|
|
20
|
+
* what a stale render looks like, and null is the honest answer: writing the
|
|
21
|
+
* wrong box would be worse than not writing.
|
|
22
|
+
*/
|
|
23
|
+
export declare function toggleTaskAt(raw: string, ordinal: number): string | null;
|
|
24
|
+
/** Whether the n-th box is checked, for the optimistic state on the screen. */
|
|
25
|
+
export declare function taskCheckedAt(raw: string, ordinal: number): boolean;
|
package/dist/tasklist.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clicking a task box, on the reading surface.
|
|
3
|
+
*
|
|
4
|
+
* The package never writes the note: a click reports the ordinal of the box,
|
|
5
|
+
* and the host applies it with `toggleTaskAt`, which these functions are
|
|
6
|
+
* exported for. The toggle is POSITIONAL over the original text, never a
|
|
7
|
+
* re-serialisation. Exactly one character between brackets changes, and
|
|
8
|
+
* everything else in the document reaches the host's store as the author
|
|
9
|
+
* wrote it: frontmatter, spacing, line breaks and the rest of the item line
|
|
10
|
+
* included. That is a requirement, not elegance: `splitFrontmatter` is lossy
|
|
11
|
+
* by design, because it flattens lists for display, and rebuilding the
|
|
12
|
+
* document from it would hand the notebook a file nobody typed.
|
|
13
|
+
*/
|
|
14
|
+
import { fenceRegions } from './markdown.js';
|
|
15
|
+
/**
|
|
16
|
+
* A task item, as GFM defines it, at the start of a list item — after any
|
|
17
|
+
* block quote markers in front of it, because a callout is a block quote and a
|
|
18
|
+
* checklist is what a `[!todo]` callout holds.
|
|
19
|
+
*
|
|
20
|
+
* MemorySmith's reading page counts without them, so a box inside a callout
|
|
21
|
+
* reported the ordinal of the next box outside it, and ticking it ticked that
|
|
22
|
+
* other box. Counting the quoted boxes on both sides — the text the parser saw
|
|
23
|
+
* and the text the host writes — is what keeps the ordinal of every box its
|
|
24
|
+
* own.
|
|
25
|
+
*/
|
|
26
|
+
const TASK = /^(?:[ \t]*>[ \t]?)*[ \t]*(?:[-*+]|\d+[.)])[ \t]+\[([ xX])\]/gm;
|
|
27
|
+
/**
|
|
28
|
+
* Blanks out fenced blocks while keeping every offset intact. A task item
|
|
29
|
+
* inside a fence is an example, not a box, and counting it would shift the
|
|
30
|
+
* index and toggle a different item: the worst possible defect here, because
|
|
31
|
+
* it is silent and lands in the wrong place.
|
|
32
|
+
*
|
|
33
|
+
* The fences are the ones the reading surface finds, by the rules of the
|
|
34
|
+
* specification: a longer outer fence holds a shorter inner one, so a box
|
|
35
|
+
* written after the inner fence closes is still an example.
|
|
36
|
+
*/
|
|
37
|
+
function maskFences(text) {
|
|
38
|
+
let masked = '';
|
|
39
|
+
let cursor = 0;
|
|
40
|
+
for (const [start, end] of fenceRegions(text)) {
|
|
41
|
+
masked += text.slice(cursor, start) + text.slice(start, end).replace(/[^\n]/g, ' ');
|
|
42
|
+
cursor = end;
|
|
43
|
+
}
|
|
44
|
+
return masked + text.slice(cursor);
|
|
45
|
+
}
|
|
46
|
+
/** Offset of the `[` of every real task box, in document order. */
|
|
47
|
+
export function taskBoxes(text) {
|
|
48
|
+
const masked = maskFences(text);
|
|
49
|
+
const found = [];
|
|
50
|
+
for (const match of masked.matchAll(TASK)) {
|
|
51
|
+
const at = match.index ?? 0;
|
|
52
|
+
found.push(at + match[0].length - 3);
|
|
53
|
+
}
|
|
54
|
+
return found;
|
|
55
|
+
}
|
|
56
|
+
/** How many task items start before this offset: the ordinal of that item. */
|
|
57
|
+
export function ordinalAt(text, offset) {
|
|
58
|
+
return taskBoxes(text).filter((box) => box < offset).length;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Flips the n-th task box. Returns null when there is no such item, which is
|
|
62
|
+
* what a stale render looks like, and null is the honest answer: writing the
|
|
63
|
+
* wrong box would be worse than not writing.
|
|
64
|
+
*/
|
|
65
|
+
export function toggleTaskAt(raw, ordinal) {
|
|
66
|
+
const boxes = taskBoxes(raw);
|
|
67
|
+
const at = boxes[ordinal];
|
|
68
|
+
if (at === undefined)
|
|
69
|
+
return null;
|
|
70
|
+
// `[x]` lowercase on the way in. GFM reads both cases, and picking one keeps
|
|
71
|
+
// the diff of a revision about what changed, not about how it was typed.
|
|
72
|
+
const current = raw[at + 1];
|
|
73
|
+
const next = current === ' ' ? 'x' : ' ';
|
|
74
|
+
return `${raw.slice(0, at + 1)}${next}${raw.slice(at + 2)}`;
|
|
75
|
+
}
|
|
76
|
+
/** Whether the n-th box is checked, for the optimistic state on the screen. */
|
|
77
|
+
export function taskCheckedAt(raw, ordinal) {
|
|
78
|
+
const at = taskBoxes(raw)[ordinal];
|
|
79
|
+
return at !== undefined && raw[at + 1] !== ' ';
|
|
80
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transclusion: `![[note]]` and `![[note#section]]`, expanded for reading.
|
|
3
|
+
*
|
|
4
|
+
* The expansion belongs to the reading surface. It is the same class of work
|
|
5
|
+
* the wikilink resolution already does here: interpreting the notation to
|
|
6
|
+
* display it. Nothing changes in what the host stores: the body of the
|
|
7
|
+
* embedded note comes from the host, and the package never fetches.
|
|
8
|
+
*
|
|
9
|
+
* ONE LEVEL, AND ONLY ONE. An embed found inside transcluded content is drawn
|
|
10
|
+
* as a link to its target, which is what keeps a pair of notes that embed each
|
|
11
|
+
* other from hanging the page.
|
|
12
|
+
*
|
|
13
|
+
* **Three places an embed cannot expand, and one answer for all of them.**
|
|
14
|
+
* Inside embedded content, past the ceiling this file sets, and — since
|
|
15
|
+
* specification v0.4.0 named it — inside a table cell, because a cell holds inlines
|
|
16
|
+
* and never blocks (§4.1). Wherever expansion cannot happen the embed becomes
|
|
17
|
+
* a link and is never dropped, which is what `demoteEmbeds` does.
|
|
18
|
+
*/
|
|
19
|
+
export type BodySegment = {
|
|
20
|
+
kind: 'text';
|
|
21
|
+
text: string;
|
|
22
|
+
} | {
|
|
23
|
+
kind: 'embed';
|
|
24
|
+
target: string;
|
|
25
|
+
anchor: string | null;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* How many embeds one page expands. Past it the rest stay as links: the cost
|
|
29
|
+
* of a page has to stay predictable, and a note with forty embeds is a note
|
|
30
|
+
* that wanted a different structure.
|
|
31
|
+
*/
|
|
32
|
+
export declare const EMBED_LIMIT = 10;
|
|
33
|
+
/**
|
|
34
|
+
* Splits a body into what to render inline and what to transclude. Embeds past
|
|
35
|
+
* the ceiling are demoted to plain wikilinks instead of being dropped, because
|
|
36
|
+
* losing the reference would be worse than not expanding it.
|
|
37
|
+
*/
|
|
38
|
+
export declare function splitEmbeds(body: string,
|
|
39
|
+
/**
|
|
40
|
+
* Whether the notebook keeps a FILE under that name. An embed of one
|
|
41
|
+
* expands nothing here: it stays in the text run, where the wikilink pass
|
|
42
|
+
* turns it into the attachment the page draws by its type.
|
|
43
|
+
*/
|
|
44
|
+
keeps?: (name: string) => boolean, limit?: number): BodySegment[];
|
|
45
|
+
/**
|
|
46
|
+
* `![[x]]` becomes `[[x]]`: a reference instead of an expansion.
|
|
47
|
+
*
|
|
48
|
+
* **An embed of a file keeps its `!`**. Demoting exists for an embed
|
|
49
|
+
* that cannot be expanded here — one inside a table cell, one inside embedded
|
|
50
|
+
* content — and an attachment is not one of those: the page draws it. Taking
|
|
51
|
+
* the bang off it was what made `[[picture]]` and `![[picture]]` arrive at the
|
|
52
|
+
* wikilink pass as the same three characters, so a **link** to a file drew the
|
|
53
|
+
* file. A link addresses a note (§5.2), and only the embed reaches a file.
|
|
54
|
+
*/
|
|
55
|
+
export declare function demoteEmbeds(body: string, keeps?: (name: string) => boolean): string;
|
|
56
|
+
/**
|
|
57
|
+
* The block a `^identifier` names, or null when nothing names it (§7.7).
|
|
58
|
+
*
|
|
59
|
+
* A block is what the parser would call one: a run of lines up to a blank
|
|
60
|
+
* line. The identifier sits at the END of it, and the marker is left in the
|
|
61
|
+
* text on purpose — `remarkBlockIds` takes it off the page, and cutting it
|
|
62
|
+
* here would mean this function decides what the reader shows.
|
|
63
|
+
*
|
|
64
|
+
* An identifier that names nothing answers null, and the caller renders it the
|
|
65
|
+
* way it renders a pending link. It is never an error: a notebook is read most
|
|
66
|
+
* while it is being written.
|
|
67
|
+
*/
|
|
68
|
+
export declare function blockOf(markdown: string, identifier: string): string | null;
|
|
69
|
+
/** An anchor written `^id` addresses a block; anything else, a section. */
|
|
70
|
+
export declare function isBlockAnchor(anchor: string): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* The section of a document, cut syntactically: from the heading whose slug
|
|
73
|
+
* matches the anchor to the next heading of equal or higher level. No notebook
|
|
74
|
+
* convention takes part in this: the cut is syntactic.
|
|
75
|
+
*/
|
|
76
|
+
export declare function sectionOf(markdown: string, anchor: string): string | null;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transclusion: `![[note]]` and `![[note#section]]`, expanded for reading.
|
|
3
|
+
*
|
|
4
|
+
* The expansion belongs to the reading surface. It is the same class of work
|
|
5
|
+
* the wikilink resolution already does here: interpreting the notation to
|
|
6
|
+
* display it. Nothing changes in what the host stores: the body of the
|
|
7
|
+
* embedded note comes from the host, and the package never fetches.
|
|
8
|
+
*
|
|
9
|
+
* ONE LEVEL, AND ONLY ONE. An embed found inside transcluded content is drawn
|
|
10
|
+
* as a link to its target, which is what keeps a pair of notes that embed each
|
|
11
|
+
* other from hanging the page.
|
|
12
|
+
*
|
|
13
|
+
* **Three places an embed cannot expand, and one answer for all of them.**
|
|
14
|
+
* Inside embedded content, past the ceiling this file sets, and — since
|
|
15
|
+
* specification v0.4.0 named it — inside a table cell, because a cell holds inlines
|
|
16
|
+
* and never blocks (§4.1). Wherever expansion cannot happen the embed becomes
|
|
17
|
+
* a link and is never dropped, which is what `demoteEmbeds` does.
|
|
18
|
+
*/
|
|
19
|
+
import { codeRegions, headingKey, insideCode, insideTable, outsideCode, tableRegions } from './markdown.js';
|
|
20
|
+
/** Same shape the link extractor matches, plus the leading `!`. */
|
|
21
|
+
const EMBED = /!\[\[([^\]|#]+?)(?:#([^\]|]+?))?(?:\|[^\]]*?)?\]\]/g;
|
|
22
|
+
/**
|
|
23
|
+
* How many embeds one page expands. Past it the rest stay as links: the cost
|
|
24
|
+
* of a page has to stay predictable, and a note with forty embeds is a note
|
|
25
|
+
* that wanted a different structure.
|
|
26
|
+
*/
|
|
27
|
+
export const EMBED_LIMIT = 10;
|
|
28
|
+
/**
|
|
29
|
+
* Splits a body into what to render inline and what to transclude. Embeds past
|
|
30
|
+
* the ceiling are demoted to plain wikilinks instead of being dropped, because
|
|
31
|
+
* losing the reference would be worse than not expanding it.
|
|
32
|
+
*/
|
|
33
|
+
export function splitEmbeds(body,
|
|
34
|
+
/**
|
|
35
|
+
* Whether the notebook keeps a FILE under that name. An embed of one
|
|
36
|
+
* expands nothing here: it stays in the text run, where the wikilink pass
|
|
37
|
+
* turns it into the attachment the page draws by its type.
|
|
38
|
+
*/
|
|
39
|
+
keeps = () => false, limit = EMBED_LIMIT) {
|
|
40
|
+
const segments = [];
|
|
41
|
+
const code = codeRegions(body);
|
|
42
|
+
const tables = tableRegions(body);
|
|
43
|
+
let cursor = 0;
|
|
44
|
+
let expanded = 0;
|
|
45
|
+
for (const match of body.matchAll(EMBED)) {
|
|
46
|
+
const at = match.index ?? 0;
|
|
47
|
+
const target = (match[1] ?? '').trim();
|
|
48
|
+
if (!target)
|
|
49
|
+
continue;
|
|
50
|
+
// A file is not a note, so there is nothing to transclude: it is left in
|
|
51
|
+
// the text run, where the wikilink pass turns it into the attachment the
|
|
52
|
+
// page draws by its type.
|
|
53
|
+
if (keeps(target))
|
|
54
|
+
continue;
|
|
55
|
+
// An embed written inside code is an example of the notation, not a use of
|
|
56
|
+
// it: expanding it would replace the very text somebody was showing.
|
|
57
|
+
if (insideCode(code, at))
|
|
58
|
+
continue;
|
|
59
|
+
// An embed inside a table cell is drawn as a link (§7.3): no block
|
|
60
|
+
// fits in a cell. Skipping it here leaves it in the surrounding text run,
|
|
61
|
+
// where `demoteEmbeds` turns it into the wikilink the specification asks for —
|
|
62
|
+
// and, just as importantly, keeps the run from being cut mid-row, which
|
|
63
|
+
// used to empty the cell and leave the closing pipe as a paragraph.
|
|
64
|
+
if (insideTable(tables, at))
|
|
65
|
+
continue;
|
|
66
|
+
if (expanded >= limit)
|
|
67
|
+
break;
|
|
68
|
+
// Demoted like the tail is, and for the embeds this loop SKIPPED: one in
|
|
69
|
+
// a table cell lands in a run before an expanded one, and without this it
|
|
70
|
+
// would reach the page as the literal `![[…]]` — dropped notation, which
|
|
71
|
+
// is the one thing §7.3 forbids in every place expansion cannot happen.
|
|
72
|
+
// Code is untouched, because `demoteEmbeds` rewrites outside it.
|
|
73
|
+
const before = demoteEmbeds(body.slice(cursor, at), keeps);
|
|
74
|
+
if (before.length > 0)
|
|
75
|
+
segments.push({ kind: 'text', text: before });
|
|
76
|
+
segments.push({ kind: 'embed', target, anchor: match[2]?.trim() ?? null });
|
|
77
|
+
cursor = at + match[0].length;
|
|
78
|
+
expanded += 1;
|
|
79
|
+
}
|
|
80
|
+
const rest = body.slice(cursor);
|
|
81
|
+
if (rest.length > 0)
|
|
82
|
+
segments.push({ kind: 'text', text: demoteEmbeds(rest, keeps) });
|
|
83
|
+
return segments;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* `![[x]]` becomes `[[x]]`: a reference instead of an expansion.
|
|
87
|
+
*
|
|
88
|
+
* **An embed of a file keeps its `!`**. Demoting exists for an embed
|
|
89
|
+
* that cannot be expanded here — one inside a table cell, one inside embedded
|
|
90
|
+
* content — and an attachment is not one of those: the page draws it. Taking
|
|
91
|
+
* the bang off it was what made `[[picture]]` and `![[picture]]` arrive at the
|
|
92
|
+
* wikilink pass as the same three characters, so a **link** to a file drew the
|
|
93
|
+
* file. A link addresses a note (§5.2), and only the embed reaches a file.
|
|
94
|
+
*/
|
|
95
|
+
export function demoteEmbeds(body, keeps = () => false) {
|
|
96
|
+
return outsideCode(body, (text) => text.replace(EMBED, (all, target) => (keeps(target.trim()) ? all : all.slice(1))));
|
|
97
|
+
}
|
|
98
|
+
const HEADING = /^(#{1,6})\s+(.+?)\s*$/;
|
|
99
|
+
/**
|
|
100
|
+
* The block a `^identifier` names, or null when nothing names it (§7.7).
|
|
101
|
+
*
|
|
102
|
+
* A block is what the parser would call one: a run of lines up to a blank
|
|
103
|
+
* line. The identifier sits at the END of it, and the marker is left in the
|
|
104
|
+
* text on purpose — `remarkBlockIds` takes it off the page, and cutting it
|
|
105
|
+
* here would mean this function decides what the reader shows.
|
|
106
|
+
*
|
|
107
|
+
* An identifier that names nothing answers null, and the caller renders it the
|
|
108
|
+
* way it renders a pending link. It is never an error: a notebook is read most
|
|
109
|
+
* while it is being written.
|
|
110
|
+
*/
|
|
111
|
+
export function blockOf(markdown, identifier) {
|
|
112
|
+
const marker = new RegExp(`[ \\t]\\^${escapeRegExp(identifier)}[ \\t]*$`);
|
|
113
|
+
const lines = markdown.split('\n');
|
|
114
|
+
for (let index = 0; index < lines.length; index++) {
|
|
115
|
+
if (!marker.test(lines[index] ?? ''))
|
|
116
|
+
continue;
|
|
117
|
+
// Walk back to the top of the block: the first line after a blank one.
|
|
118
|
+
let start = index;
|
|
119
|
+
while (start > 0 && (lines[start - 1] ?? '').trim().length > 0)
|
|
120
|
+
start--;
|
|
121
|
+
return lines
|
|
122
|
+
.slice(start, index + 1)
|
|
123
|
+
.join('\n')
|
|
124
|
+
.trimEnd();
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
/** An anchor written `^id` addresses a block; anything else, a section. */
|
|
129
|
+
export function isBlockAnchor(anchor) {
|
|
130
|
+
return anchor.startsWith('^');
|
|
131
|
+
}
|
|
132
|
+
function escapeRegExp(value) {
|
|
133
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The section of a document, cut syntactically: from the heading whose slug
|
|
137
|
+
* matches the anchor to the next heading of equal or higher level. No notebook
|
|
138
|
+
* convention takes part in this: the cut is syntactic.
|
|
139
|
+
*/
|
|
140
|
+
export function sectionOf(markdown, anchor) {
|
|
141
|
+
const wanted = headingKey(anchor);
|
|
142
|
+
const lines = markdown.split('\n');
|
|
143
|
+
let start = -1;
|
|
144
|
+
let level = 0;
|
|
145
|
+
for (let index = 0; index < lines.length; index++) {
|
|
146
|
+
const heading = HEADING.exec(lines[index] ?? '');
|
|
147
|
+
if (!heading)
|
|
148
|
+
continue;
|
|
149
|
+
if (start === -1) {
|
|
150
|
+
if (headingKey(heading[2] ?? '') === wanted) {
|
|
151
|
+
start = index;
|
|
152
|
+
level = (heading[1] ?? '').length;
|
|
153
|
+
}
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if ((heading[1] ?? '').length <= level) {
|
|
157
|
+
return lines.slice(start, index).join('\n').trimEnd();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return start === -1 ? null : lines.slice(start).join('\n').trimEnd();
|
|
161
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ResolvedHost } from './host.js';
|
|
2
|
+
/**
|
|
3
|
+
* Where a wikilink goes: the note when exactly one answers it, and the choice
|
|
4
|
+
* otherwise — several notes, or none, which is pending and still asks — when
|
|
5
|
+
* the host can say which notes a target may lead to. With no such host, a
|
|
6
|
+
* target no single note answers is pending.
|
|
7
|
+
*/
|
|
8
|
+
export declare function wikilinkAddress(host: ResolvedHost, name: string): string | null;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { CHOICE_SCHEME } from './LinkChoice.js';
|
|
2
|
+
/**
|
|
3
|
+
* Where a wikilink goes: the note when exactly one answers it, and the choice
|
|
4
|
+
* otherwise — several notes, or none, which is pending and still asks — when
|
|
5
|
+
* the host can say which notes a target may lead to. With no such host, a
|
|
6
|
+
* target no single note answers is pending.
|
|
7
|
+
*/
|
|
8
|
+
export function wikilinkAddress(host, name) {
|
|
9
|
+
const address = host.resolve(name);
|
|
10
|
+
if (address !== null)
|
|
11
|
+
return address;
|
|
12
|
+
return host.choose ? `${CHOICE_SCHEME}${encodeURIComponent(name)}` : null;
|
|
13
|
+
}
|