@memorysmithapp/markdown-renderer 0.1.2 → 0.3.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/README.md +63 -2
- package/dist/Markdown.d.ts +10 -4
- package/dist/Markdown.js +58 -44
- package/dist/MermaidDiagram.d.ts +3 -3
- package/dist/MermaidDiagram.js +8 -9
- package/dist/NoteBody.js +12 -6
- package/dist/PropertiesBlock.d.ts +18 -0
- package/dist/PropertiesBlock.js +107 -0
- package/dist/Transclusion.d.ts +1 -1
- package/dist/Transclusion.js +31 -15
- package/dist/diagnose.d.ts +50 -0
- package/dist/diagnose.js +304 -0
- package/dist/host.d.ts +16 -3
- package/dist/host.js +9 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/markdown.d.ts +25 -4
- package/dist/markdown.js +53 -4
- package/dist/properties.d.ts +50 -0
- package/dist/properties.js +144 -0
- package/dist/reading.d.ts +6 -0
- package/dist/reading.js +32 -0
- package/dist/remark-callouts.js +5 -3
- package/dist/remark-ring.d.ts +8 -6
- package/dist/remark-ring.js +8 -28
- package/dist/slots.d.ts +36 -0
- package/dist/slots.js +124 -0
- package/dist/strings.d.ts +7 -0
- package/dist/strings.js +4 -0
- package/dist/tasklist.js +7 -3
- package/dist/theme.d.ts +283 -0
- package/dist/theme.js +107 -0
- package/package.json +11 -3
- package/structure.css +57 -0
- package/styles.css +6 -863
- package/theme-default.css +998 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The notices of a note (§7.13): where what is drawn will not be what was most likely typed.
|
|
3
|
+
*
|
|
4
|
+
* There is no invalid note (§6.4), so nothing here accepts or refuses: it reads a note the way the
|
|
5
|
+
* reading surface reads it — the same frontmatter cut, its comments taken out (`hideComments`), the
|
|
6
|
+
* same parser and the same remark plugins (`reading.ts`) — and reports, by a stable code, the places where the page will differ from the
|
|
7
|
+
* text, or where the note lacks something the specification reserves a place for. A notice never
|
|
8
|
+
* blocks a save and never changes a byte.
|
|
9
|
+
*
|
|
10
|
+
* It runs with no DOM and no React, so a host can call it on a server, from a tool that writes a
|
|
11
|
+
* note, or beside an editor. KaTeX checks a formula as `rehype-katex` would typeset it, and Mermaid
|
|
12
|
+
* reads a diagram as the page would draw it, loaded only when a note carries one.
|
|
13
|
+
*/
|
|
14
|
+
import type { NoteHost } from './host.js';
|
|
15
|
+
/** The codes of §7.13, which `spec.json` declares as `notices`. */
|
|
16
|
+
export type NoticeCode = 'fence-unclosed' | 'math-unclosed' | 'formula-invalid' | 'diagram-invalid' | 'frontmatter-unclosed' | 'name-missing' | 'name-unusable' | 'reserved-not-a-list' | 'aliases-missing' | 'tags-missing' | 'heading-first-level' | 'heading-skipped-level' | 'footnote-undefined' | 'footnote-unreferenced' | 'raw-html' | 'strikethrough-single-tilde' | 'link-pending' | 'embed-pending';
|
|
17
|
+
/** `notice`: the page will differ from what was typed. `hint`: nothing is drawn wrong. */
|
|
18
|
+
export type NoticeSeverity = 'notice' | 'hint';
|
|
19
|
+
/** One place in a note, by a code of §7.13. Lines and columns count from 1 over the whole note. */
|
|
20
|
+
export interface Notice {
|
|
21
|
+
readonly code: NoticeCode;
|
|
22
|
+
readonly severity: NoticeSeverity;
|
|
23
|
+
/** The section of the specification that decides what happens there. */
|
|
24
|
+
readonly section: string;
|
|
25
|
+
/** What a reader gets, in English; a host words it in its own language from `code`. */
|
|
26
|
+
readonly message: string;
|
|
27
|
+
readonly line: number;
|
|
28
|
+
readonly column: number;
|
|
29
|
+
readonly endLine: number;
|
|
30
|
+
readonly endColumn: number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* What the notebook answers, as `NoteBody` asks it. With neither `resolve` nor `reaches`, nothing
|
|
34
|
+
* is known about the notebook and no link is reported as pending.
|
|
35
|
+
*/
|
|
36
|
+
export interface DiagnoseOptions extends Pick<NoteHost, 'resolve' | 'reaches' | 'keeps'> {
|
|
37
|
+
/** Whether to read each `mermaid` block with Mermaid, which is loaded for it. Defaults to true. */
|
|
38
|
+
readonly diagrams?: boolean;
|
|
39
|
+
}
|
|
40
|
+
/** The table of §7.13, as `spec.json` carries it: a test holds the two equal. */
|
|
41
|
+
export declare const NOTICES: Readonly<Record<NoticeCode, {
|
|
42
|
+
readonly severity: NoticeSeverity;
|
|
43
|
+
readonly section: string;
|
|
44
|
+
readonly message: string;
|
|
45
|
+
}>>;
|
|
46
|
+
/**
|
|
47
|
+
* The notices of a note, in the order of the note. `markdown` is the note as a host keeps it,
|
|
48
|
+
* frontmatter included; the positions are into it.
|
|
49
|
+
*/
|
|
50
|
+
export declare function diagnose(markdown: string, options?: DiagnoseOptions): Promise<Notice[]>;
|
package/dist/diagnose.js
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The notices of a note (§7.13): where what is drawn will not be what was most likely typed.
|
|
3
|
+
*
|
|
4
|
+
* There is no invalid note (§6.4), so nothing here accepts or refuses: it reads a note the way the
|
|
5
|
+
* reading surface reads it — the same frontmatter cut, its comments taken out (`hideComments`), the
|
|
6
|
+
* same parser and the same remark plugins (`reading.ts`) — and reports, by a stable code, the places where the page will differ from the
|
|
7
|
+
* text, or where the note lacks something the specification reserves a place for. A notice never
|
|
8
|
+
* blocks a save and never changes a byte.
|
|
9
|
+
*
|
|
10
|
+
* It runs with no DOM and no React, so a host can call it on a server, from a tool that writes a
|
|
11
|
+
* note, or beside an editor. KaTeX checks a formula as `rehype-katex` would typeset it, and Mermaid
|
|
12
|
+
* reads a diagram as the page would draw it, loaded only when a note carries one.
|
|
13
|
+
*/
|
|
14
|
+
import katex from 'katex';
|
|
15
|
+
import remarkParse from 'remark-parse';
|
|
16
|
+
import { unified } from 'unified';
|
|
17
|
+
import { bodyStart, codeRegions, hideComments, insideCode, toUnixNewlines } from './markdown.js';
|
|
18
|
+
import { readProperties } from './properties.js';
|
|
19
|
+
import { readingPlugins } from './reading.js';
|
|
20
|
+
/** The table of §7.13, as `spec.json` carries it: a test holds the two equal. */
|
|
21
|
+
export const NOTICES = {
|
|
22
|
+
'fence-unclosed': { severity: 'notice', section: '3.7', message: 'This fence is never closed: it runs to the end of its block, and everything after it is code.' },
|
|
23
|
+
'math-unclosed': { severity: 'notice', section: '7.8', message: 'This $$ block is never closed: it runs to the end of its block, and everything after it is a formula.' },
|
|
24
|
+
'formula-invalid': { severity: 'notice', section: '7.8', message: 'This formula cannot be typeset, and is shown as its source.' },
|
|
25
|
+
'diagram-invalid': { severity: 'notice', section: '7.2', message: 'This diagram cannot be read, and is shown as its source or its error.' },
|
|
26
|
+
'frontmatter-unclosed': { severity: 'notice', section: '6.1', message: 'This frontmatter is never closed by a ---: the note has no properties, and its lines are body text.' },
|
|
27
|
+
'name-missing': { severity: 'notice', section: '6.5', message: 'The note has no name: a link reaches it only through an alias.' },
|
|
28
|
+
'name-unusable': { severity: 'notice', section: '6.5', message: 'This name is not a single text value, so the note is unnamed.' },
|
|
29
|
+
'reserved-not-a-list': { severity: 'notice', section: '6.4', message: 'This value is not a list, so it is an ordinary attribute.' },
|
|
30
|
+
'aliases-missing': { severity: 'hint', section: '6.4', message: 'The note has no aliases: only its name catches a link.' },
|
|
31
|
+
'tags-missing': { severity: 'hint', section: '6.4', message: 'The note has no tags: it has no subjects to be filtered by.' },
|
|
32
|
+
'heading-first-level': { severity: 'notice', section: '3.3', message: 'The first heading of the body is not level one.' },
|
|
33
|
+
'heading-skipped-level': { severity: 'notice', section: '3.3', message: 'This heading skips a level below the heading before it.' },
|
|
34
|
+
'footnote-undefined': { severity: 'notice', section: '7.12', message: 'This footnote has no definition, and is drawn as its characters.' },
|
|
35
|
+
'footnote-unreferenced': { severity: 'notice', section: '7.12', message: 'Nothing references this footnote, so it is not drawn.' },
|
|
36
|
+
'raw-html': { severity: 'notice', section: '7.9', message: 'Raw HTML is drawn as text, tags and all.' },
|
|
37
|
+
'strikethrough-single-tilde': { severity: 'notice', section: '4.3', message: 'One tilde strikes nothing: the tildes stay on the page. Strikethrough is ~~two~~.' },
|
|
38
|
+
'link-pending': { severity: 'hint', section: '7.4', message: 'No note answers this link yet, so it is drawn as pending.' },
|
|
39
|
+
'embed-pending': { severity: 'hint', section: '7.3', message: 'No note and no kept file answers this embed, so it is drawn as a pending link.' },
|
|
40
|
+
};
|
|
41
|
+
/** A footnote reference left as text, which is one with no definition (§7.12). */
|
|
42
|
+
const UNDEFINED_REFERENCE = /\[\^[^\]\s]+\]/g;
|
|
43
|
+
/** A run between single tildes, which GFM as read here leaves as characters (§4.3). */
|
|
44
|
+
const SINGLE_TILDE = /(?<![~\\])~(?!~)(?=\S)[^~\n]*?\S~(?!~)/g;
|
|
45
|
+
/** A wikilink, with the bang of an embed: the shape `resolveWikilinks` reads. */
|
|
46
|
+
const WIKILINK = /(!)?\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g;
|
|
47
|
+
/** A closing fence or `$$`, once the markers of its container are taken off. */
|
|
48
|
+
const CONTAINER_PREFIX = /^[ \t>]*/;
|
|
49
|
+
const KEY = /^([A-Za-z0-9_-]+)\s*:/;
|
|
50
|
+
/**
|
|
51
|
+
* The notices of a note, in the order of the note. `markdown` is the note as a host keeps it,
|
|
52
|
+
* frontmatter included; the positions are into it.
|
|
53
|
+
*/
|
|
54
|
+
export async function diagnose(markdown, options = {}) {
|
|
55
|
+
const text = toUnixNewlines(markdown);
|
|
56
|
+
const lineStarts = [0];
|
|
57
|
+
for (let index = 0; index < text.length; index++)
|
|
58
|
+
if (text[index] === '\n')
|
|
59
|
+
lineStarts.push(index + 1);
|
|
60
|
+
const pointOf = (offset) => {
|
|
61
|
+
let low = 0;
|
|
62
|
+
let high = lineStarts.length - 1;
|
|
63
|
+
while (low < high) {
|
|
64
|
+
const middle = (low + high + 1) >> 1;
|
|
65
|
+
if ((lineStarts[middle] ?? 0) <= offset)
|
|
66
|
+
low = middle;
|
|
67
|
+
else
|
|
68
|
+
high = middle - 1;
|
|
69
|
+
}
|
|
70
|
+
return { line: low + 1, column: offset - (lineStarts[low] ?? 0) + 1 };
|
|
71
|
+
};
|
|
72
|
+
const found = [];
|
|
73
|
+
const report = (code, start, end = start, detail) => {
|
|
74
|
+
const from = pointOf(start);
|
|
75
|
+
const to = pointOf(end);
|
|
76
|
+
const { severity, section, message } = NOTICES[code];
|
|
77
|
+
found.push({
|
|
78
|
+
at: start,
|
|
79
|
+
notice: {
|
|
80
|
+
code,
|
|
81
|
+
severity,
|
|
82
|
+
section,
|
|
83
|
+
message: detail ? `${message} (${detail})` : message,
|
|
84
|
+
line: from.line,
|
|
85
|
+
column: from.column,
|
|
86
|
+
endLine: to.line,
|
|
87
|
+
endColumn: to.column,
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
const split = bodyStart(text);
|
|
92
|
+
frontmatter(text, split, report);
|
|
93
|
+
// A comment is never drawn, so nothing it holds raises a notice; positions are mapped back over it.
|
|
94
|
+
const hidden = hideComments(text.slice(split));
|
|
95
|
+
const body = hidden.text;
|
|
96
|
+
const processor = unified().use(remarkParse).use(readingPlugins(() => undefined));
|
|
97
|
+
const parsed = processor.parse(body);
|
|
98
|
+
// The text as the parser found it, before a plugin cut a run of it and dropped its position.
|
|
99
|
+
const read = structuredClone(parsed);
|
|
100
|
+
const tree = processor.runSync(parsed, body);
|
|
101
|
+
const span = (node) => {
|
|
102
|
+
const start = node.position?.start.offset;
|
|
103
|
+
const end = node.position?.end.offset;
|
|
104
|
+
return start === undefined || end === undefined ? null : [split + hidden.original(start), split + hidden.original(end, true)];
|
|
105
|
+
};
|
|
106
|
+
const diagrams = [];
|
|
107
|
+
const referenced = new Set();
|
|
108
|
+
const definitions = [];
|
|
109
|
+
walk(tree, (node) => {
|
|
110
|
+
const at = span(node);
|
|
111
|
+
if (!at)
|
|
112
|
+
return;
|
|
113
|
+
const source = text.slice(at[0], at[1]);
|
|
114
|
+
if (node.type === 'code' && /^\s*(`{3,}|~{3,})/.test(source)) {
|
|
115
|
+
if (!closes(source, /^(`{3,}|~{3,})/))
|
|
116
|
+
report('fence-unclosed', at[0], at[1]);
|
|
117
|
+
else if (node.lang === 'mermaid')
|
|
118
|
+
diagrams.push({ source: node.value ?? '', at });
|
|
119
|
+
}
|
|
120
|
+
else if (node.type === 'math') {
|
|
121
|
+
if (!closes(source, /^(\${2,})/))
|
|
122
|
+
report('math-unclosed', at[0], at[1]);
|
|
123
|
+
else if (!typesets(node.value ?? '', true))
|
|
124
|
+
report('formula-invalid', at[0], at[1]);
|
|
125
|
+
}
|
|
126
|
+
else if (node.type === 'inlineMath') {
|
|
127
|
+
if (!typesets(node.value ?? '', false))
|
|
128
|
+
report('formula-invalid', at[0], at[1]);
|
|
129
|
+
}
|
|
130
|
+
else if (node.type === 'html') {
|
|
131
|
+
report('raw-html', at[0], at[1]);
|
|
132
|
+
}
|
|
133
|
+
else if (node.type === 'footnoteReference') {
|
|
134
|
+
referenced.add(String(node.identifier ?? '').toUpperCase());
|
|
135
|
+
}
|
|
136
|
+
else if (node.type === 'footnoteDefinition') {
|
|
137
|
+
definitions.push(node);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
for (const definition of definitions) {
|
|
141
|
+
const at = span(definition);
|
|
142
|
+
if (at && !referenced.has(String(definition.identifier ?? '').toUpperCase())) {
|
|
143
|
+
report('footnote-unreferenced', at[0], at[1]);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
outline(tree, span, report);
|
|
147
|
+
// What stays characters on the page is found in the text the parser read.
|
|
148
|
+
walk(read, (node) => {
|
|
149
|
+
if (node.type !== 'text')
|
|
150
|
+
return;
|
|
151
|
+
const start = node.position?.start.offset;
|
|
152
|
+
const end = node.position?.end.offset;
|
|
153
|
+
if (start === undefined || end === undefined)
|
|
154
|
+
return;
|
|
155
|
+
const source = body.slice(start, end);
|
|
156
|
+
for (const [pattern, code] of [
|
|
157
|
+
[UNDEFINED_REFERENCE, 'footnote-undefined'],
|
|
158
|
+
[SINGLE_TILDE, 'strikethrough-single-tilde'],
|
|
159
|
+
]) {
|
|
160
|
+
for (const match of source.matchAll(pattern)) {
|
|
161
|
+
const from = start + match.index;
|
|
162
|
+
report(code, split + hidden.original(from), split + hidden.original(from + match[0].length, true));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
pending(hidden, split, options, report);
|
|
167
|
+
if (diagrams.length > 0 && options.diagrams !== false) {
|
|
168
|
+
const mermaid = (await import('mermaid')).default;
|
|
169
|
+
for (const diagram of diagrams) {
|
|
170
|
+
try {
|
|
171
|
+
await mermaid.parse(diagram.source);
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
if (unreadable(error))
|
|
175
|
+
report('diagram-invalid', diagram.at[0], diagram.at[1]);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return found.sort((a, b) => a.at - b.at).map(({ notice }) => notice);
|
|
180
|
+
}
|
|
181
|
+
function walk(node, visit) {
|
|
182
|
+
visit(node);
|
|
183
|
+
for (const child of node.children ?? [])
|
|
184
|
+
walk(child, visit);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Whether a fenced block or a `$$` block was closed: its last line, once its container's markers
|
|
188
|
+
* are off, is a run of the opening character at least as long as the opening one, and nothing else.
|
|
189
|
+
*/
|
|
190
|
+
function closes(source, opening) {
|
|
191
|
+
const lines = source.split('\n');
|
|
192
|
+
const run = opening.exec((lines[0] ?? '').replace(CONTAINER_PREFIX, ''))?.[1] ?? '';
|
|
193
|
+
if (lines.length < 2 || run.length === 0)
|
|
194
|
+
return false;
|
|
195
|
+
const last = (lines[lines.length - 1] ?? '').replace(CONTAINER_PREFIX, '').trimEnd();
|
|
196
|
+
const closing = new RegExp(`^\\${run[0]}{${run.length},}$`);
|
|
197
|
+
return closing.test(last);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Whether Mermaid refused a diagram for what it says, rather than for where it runs. Its parsers
|
|
201
|
+
* answer with a parse error, and an unknown diagram type with its own error; with no DOM, reading a
|
|
202
|
+
* label of some diagrams fails on the sanitiser instead, and that failure is no notice: a diagram
|
|
203
|
+
* Mermaid could not finish reading here may well be drawn on the page.
|
|
204
|
+
*/
|
|
205
|
+
function unreadable(error) {
|
|
206
|
+
if (typeof error !== 'object' || error === null)
|
|
207
|
+
return false;
|
|
208
|
+
const failure = error;
|
|
209
|
+
return (typeof failure.hash === 'object' ||
|
|
210
|
+
failure.name === 'UnknownDiagramError' ||
|
|
211
|
+
failure.constructor?.name === 'MermaidParseError');
|
|
212
|
+
}
|
|
213
|
+
/** Whether KaTeX typesets a formula, as `rehype-katex` would. */
|
|
214
|
+
function typesets(formula, display) {
|
|
215
|
+
try {
|
|
216
|
+
katex.renderToString(formula, { displayMode: display, throwOnError: true, strict: 'ignore' });
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/** The reserved keys of the frontmatter (§6.4, §6.5), and a block that never closes (§6.1). */
|
|
224
|
+
function frontmatter(text, split, report) {
|
|
225
|
+
const opened = text.startsWith('---');
|
|
226
|
+
if (opened && text.indexOf('\n---', 3) === -1)
|
|
227
|
+
report('frontmatter-unclosed', 0, text.indexOf('\n') === -1 ? 3 : text.indexOf('\n'));
|
|
228
|
+
// Where each key was written, to place a notice on it.
|
|
229
|
+
const keys = new Map();
|
|
230
|
+
if (opened && split > 0) {
|
|
231
|
+
let at = text.indexOf('\n') + 1;
|
|
232
|
+
for (const line of text.slice(at, split).split('\n')) {
|
|
233
|
+
const key = KEY.exec(line)?.[1];
|
|
234
|
+
if (key && !keys.has(key))
|
|
235
|
+
keys.set(key, at);
|
|
236
|
+
at += line.length + 1;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const lineEnd = (at) => {
|
|
240
|
+
const end = text.indexOf('\n', at);
|
|
241
|
+
return end === -1 ? text.length : end;
|
|
242
|
+
};
|
|
243
|
+
const properties = readProperties(text);
|
|
244
|
+
const name = keys.get('name');
|
|
245
|
+
if (name === undefined)
|
|
246
|
+
report('name-missing', 0);
|
|
247
|
+
else if (properties.name === null)
|
|
248
|
+
report('name-unusable', name, lineEnd(name));
|
|
249
|
+
for (const [key, code] of [
|
|
250
|
+
['aliases', 'aliases-missing'],
|
|
251
|
+
['tags', 'tags-missing'],
|
|
252
|
+
]) {
|
|
253
|
+
const at = keys.get(key);
|
|
254
|
+
const entry = properties.entries.find((candidate) => candidate.key === key);
|
|
255
|
+
if (at === undefined || !entry)
|
|
256
|
+
report(code, 0);
|
|
257
|
+
else if (entry.shape === 'empty')
|
|
258
|
+
report(code, at, lineEnd(at));
|
|
259
|
+
else if (entry.shape !== 'list')
|
|
260
|
+
report('reserved-not-a-list', at, lineEnd(at), key);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/** The outline of the body (§3.3): the headings at its top level, outside code. */
|
|
264
|
+
function outline(tree, span, report) {
|
|
265
|
+
let previous = null;
|
|
266
|
+
for (const node of tree.children ?? []) {
|
|
267
|
+
if (node.type !== 'heading' || node.depth === undefined)
|
|
268
|
+
continue;
|
|
269
|
+
const at = span(node);
|
|
270
|
+
if (at) {
|
|
271
|
+
if (previous === null && node.depth > 1)
|
|
272
|
+
report('heading-first-level', at[0], at[1]);
|
|
273
|
+
else if (previous !== null && node.depth > previous + 1)
|
|
274
|
+
report('heading-skipped-level', at[0], at[1]);
|
|
275
|
+
}
|
|
276
|
+
previous = node.depth;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* The wikilinks and embeds no note answers, when the host says which notes exist: a link reaches
|
|
281
|
+
* a note, and an embed a note or a kept file (§5.2, §5.8). Read on the body outside code, as
|
|
282
|
+
* `resolveWikilinks` reads it.
|
|
283
|
+
*/
|
|
284
|
+
function pending(hidden, split, options, report) {
|
|
285
|
+
const body = hidden.text;
|
|
286
|
+
const { resolve, reaches, keeps = () => false } = options;
|
|
287
|
+
if (!resolve && !reaches)
|
|
288
|
+
return;
|
|
289
|
+
const answered = (name) => reaches ? reaches(name.normalize('NFC')) > 0 : resolve(name.normalize('NFC')) !== null;
|
|
290
|
+
const code = codeRegions(body);
|
|
291
|
+
for (const match of body.matchAll(WIKILINK)) {
|
|
292
|
+
if (insideCode(code, match.index))
|
|
293
|
+
continue;
|
|
294
|
+
const name = (match[2] ?? '').split('#')[0]?.trim().replace(/\\$/, '').trim() ?? '';
|
|
295
|
+
// `[[#Section]]` is a link within the note itself.
|
|
296
|
+
if (!name)
|
|
297
|
+
continue;
|
|
298
|
+
const embed = match[1] !== undefined;
|
|
299
|
+
if (answered(name) || (embed && keeps(name)))
|
|
300
|
+
continue;
|
|
301
|
+
const end = match.index + match[0].length;
|
|
302
|
+
report(embed ? 'embed-pending' : 'link-pending', split + hidden.original(match.index), split + hidden.original(end, true), name);
|
|
303
|
+
}
|
|
304
|
+
}
|
package/dist/host.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
2
|
import { type NoteStrings } from './strings.js';
|
|
3
|
+
import { type Scheme, type Theme } from './theme.js';
|
|
3
4
|
/**
|
|
4
5
|
* The host contract: everything the renderer needs from the notebook a note lives in, given by
|
|
5
6
|
* whoever draws it.
|
|
@@ -62,8 +63,17 @@ export interface NoteHost {
|
|
|
62
63
|
readonly renderLink?: (link: LinkRequest) => ReactNode;
|
|
63
64
|
/** The words the page shows, in the reader's language; each one left out is MemorySmith's English. */
|
|
64
65
|
readonly strings?: Partial<NoteStrings>;
|
|
65
|
-
/**
|
|
66
|
-
|
|
66
|
+
/**
|
|
67
|
+
* How the note looks: a theme (`defineTheme`), or `light` or `dark`, which are the default
|
|
68
|
+
* theme — MemorySmith's look — in that scheme. Defaults to the default theme.
|
|
69
|
+
*/
|
|
70
|
+
readonly theme?: Scheme | Theme;
|
|
71
|
+
/**
|
|
72
|
+
* The scheme a theme is drawn in, which also picks the theme of a Mermaid diagram. Defaults to
|
|
73
|
+
* the scheme `theme` names when it is `light` or `dark`, and to `light` otherwise; a theme of one
|
|
74
|
+
* scheme is drawn in it whatever is asked.
|
|
75
|
+
*/
|
|
76
|
+
readonly scheme?: Scheme;
|
|
67
77
|
/**
|
|
68
78
|
* Whether a note that fetched images from other sites says so, naming them, under the body
|
|
69
79
|
* (§7.10). Defaults to `true`: the specification forbids only silence. A host that discloses it
|
|
@@ -127,7 +137,10 @@ export interface ResolvedHost {
|
|
|
127
137
|
readonly renderAttachment: ((file: AttachmentRequest) => ReactNode) | null;
|
|
128
138
|
readonly renderLink: (link: LinkRequest) => ReactNode;
|
|
129
139
|
readonly strings: NoteStrings;
|
|
130
|
-
|
|
140
|
+
/** The theme the note is drawn in. */
|
|
141
|
+
readonly theme: Theme;
|
|
142
|
+
/** The scheme it is drawn in. */
|
|
143
|
+
readonly scheme: Scheme;
|
|
131
144
|
}
|
|
132
145
|
export declare function resolveHost(host: NoteHost): ResolvedHost;
|
|
133
146
|
export declare const HostProvider: import("react").Provider<ResolvedHost>;
|
package/dist/host.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { createContext, useContext } from 'react';
|
|
3
3
|
import { withDefaults } from './strings.js';
|
|
4
|
+
import { DEFAULT_THEME, schemeOf } from './theme.js';
|
|
4
5
|
/**
|
|
5
6
|
* Every member of the contract, by name. The `satisfies` is what makes it complete: a member added
|
|
6
7
|
* to `NoteHost` and not listed here does not compile.
|
|
@@ -19,11 +20,18 @@ export const HOST_KEYS = Object.keys({
|
|
|
19
20
|
renderLink: 1,
|
|
20
21
|
strings: 1,
|
|
21
22
|
theme: 1,
|
|
23
|
+
scheme: 1,
|
|
22
24
|
remoteNotice: 1,
|
|
23
25
|
});
|
|
24
26
|
const nothing = () => null;
|
|
25
27
|
const never = () => false;
|
|
26
28
|
const plainLink = ({ href, className, children }) => (_jsx("a", { className: className, href: href, children: children }));
|
|
29
|
+
function themeOf(host) {
|
|
30
|
+
const given = host.theme ?? DEFAULT_THEME;
|
|
31
|
+
const theme = typeof given === 'string' ? DEFAULT_THEME : given;
|
|
32
|
+
const asked = host.scheme ?? (typeof given === 'string' ? given : 'light');
|
|
33
|
+
return { theme, scheme: schemeOf(theme, asked) };
|
|
34
|
+
}
|
|
27
35
|
export function resolveHost(host) {
|
|
28
36
|
const resolve = host.resolve ?? nothing;
|
|
29
37
|
return {
|
|
@@ -38,7 +46,7 @@ export function resolveHost(host) {
|
|
|
38
46
|
renderAttachment: host.renderAttachment ?? null,
|
|
39
47
|
renderLink: host.renderLink ?? plainLink,
|
|
40
48
|
strings: withDefaults(host.strings),
|
|
41
|
-
|
|
49
|
+
...themeOf(host),
|
|
42
50
|
};
|
|
43
51
|
}
|
|
44
52
|
const HostContext = createContext(resolveHost({}));
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
export { NoteBody, type NoteBodyProps } from './NoteBody.js';
|
|
2
2
|
export type { AttachmentRequest, EmbeddedNote, LinkChoiceNote, LinkChoices, LinkRequest, NoteHost } from './host.js';
|
|
3
3
|
export { ENGLISH, type NoteStrings } from './strings.js';
|
|
4
|
+
export { customPropertyOf, DEFAULT_THEME, defineTheme, THEME_PROPERTIES, THEME_SLOTS, type BlockquoteSlotProps, type CalloutSlotProps, type CodeBlockSlotProps, type DiagramSlotProps, type EmbedSlotProps, type FootnotesSlotProps, type HeadingSlotProps, type InlineCodeSlotProps, type PropertiesSlotProps, type Scheme, type TableSlotProps, type TaskBoxSlotProps, type Theme, type ThemeComponents, type ThemeProperties, type WikilinkSlotProps, } from './theme.js';
|
|
4
5
|
export { followable } from './address.js';
|
|
5
6
|
export { readDimensions, readImageAlt, type ImageAlt } from './image-dimensions.js';
|
|
6
7
|
export { codeRegions, fenceRegions, headingKey, remoteImageHosts, resolveWikilinks, splitFrontmatter, tableRegions, toUnixNewlines, type SplitDocument, } from './markdown.js';
|
|
7
8
|
export { ordinalAt, taskBoxes, taskCheckedAt, toggleTaskAt } from './tasklist.js';
|
|
9
|
+
export { readProperties, type NoteProperties, type PropertyEntry, type PropertyShape } from './properties.js';
|
|
8
10
|
export { DELEGATED_TO_THE_BASE_PARSER, unclassified } from './delegated.js';
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// The entry point of @memorysmithapp/markdown-renderer.
|
|
2
2
|
export { NoteBody } from './NoteBody.js';
|
|
3
3
|
export { ENGLISH } from './strings.js';
|
|
4
|
+
export { customPropertyOf, DEFAULT_THEME, defineTheme, THEME_PROPERTIES, THEME_SLOTS, } from './theme.js';
|
|
4
5
|
export { followable } from './address.js';
|
|
5
6
|
export { readDimensions, readImageAlt } from './image-dimensions.js';
|
|
6
7
|
export { codeRegions, fenceRegions, headingKey, remoteImageHosts, resolveWikilinks, splitFrontmatter, tableRegions, toUnixNewlines, } from './markdown.js';
|
|
7
8
|
export { ordinalAt, taskBoxes, taskCheckedAt, toggleTaskAt } from './tasklist.js';
|
|
9
|
+
export { readProperties } from './properties.js';
|
|
8
10
|
export { DELEGATED_TO_THE_BASE_PARSER, unclassified } from './delegated.js';
|
package/dist/markdown.d.ts
CHANGED
|
@@ -20,15 +20,16 @@ export interface SplitDocument {
|
|
|
20
20
|
*/
|
|
21
21
|
export declare function toUnixNewlines(raw: string): string;
|
|
22
22
|
/**
|
|
23
|
-
* Where the body of a note starts: after its frontmatter, which
|
|
24
|
-
*
|
|
23
|
+
* Where the body of a note starts: after its frontmatter, which the theme draws apart from the
|
|
24
|
+
* body (`readProperties`). The cut is the one MemorySmith's reading page makes, so the ordinals of the task
|
|
25
25
|
* boxes below it are counted over the whole note, as the host stores it.
|
|
26
26
|
*/
|
|
27
27
|
export declare function bodyStart(text: string): number;
|
|
28
28
|
/**
|
|
29
29
|
* The frontmatter block, read the way MemorySmith's reading page reads it to show a note's
|
|
30
|
-
* properties
|
|
31
|
-
*
|
|
30
|
+
* properties, its lists joined by commas. The renderer draws the frontmatter from
|
|
31
|
+
* `readProperties`, which keeps each item and each shape apart; this stays for a host that reads
|
|
32
|
+
* the values MemorySmith does.
|
|
32
33
|
*/
|
|
33
34
|
export declare function splitFrontmatter(input: string): SplitDocument;
|
|
34
35
|
/** The half-open ranges of `body` that are fenced blocks. */
|
|
@@ -40,6 +41,26 @@ export declare function insideCode(regions: Array<[number, number]>, at: number)
|
|
|
40
41
|
export declare function tableRegions(body: string): Array<[number, number]>;
|
|
41
42
|
/** Whether an offset falls inside one of them. */
|
|
42
43
|
export declare function insideTable(regions: Array<[number, number]>, at: number): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* The comments of a text (§7.6), as half-open ranges: from a `%%` outside code to the next `%%`
|
|
46
|
+
* outside code, across inline markup and blank lines alike. A `%%` with no partner opens nothing.
|
|
47
|
+
*
|
|
48
|
+
* A comment that is all of its lines takes the line break after it too, so a comment written on
|
|
49
|
+
* lines of its own leaves no empty line behind to loosen a list or split a paragraph.
|
|
50
|
+
*
|
|
51
|
+
* MemorySmith's reading page hides a comment inside one run of text only, so one that holds a
|
|
52
|
+
* link, a tag, emphasis or a blank line reaches the page whole; this is found on the text, before
|
|
53
|
+
* anything is parsed, so nothing a comment holds is ever read.
|
|
54
|
+
*/
|
|
55
|
+
export declare function commentRegions(text: string): Array<[number, number]>;
|
|
56
|
+
/** A text with its comments taken out, and where an offset into it falls in the text it came from. */
|
|
57
|
+
export interface HiddenComments {
|
|
58
|
+
readonly text: string;
|
|
59
|
+
/** The offset in the original text of an offset into `text`; an `end` stays before a comment it meets. */
|
|
60
|
+
readonly original: (offset: number, end?: boolean) => number;
|
|
61
|
+
}
|
|
62
|
+
/** The text a reader is shown: its comments taken out (§7.6). The note itself is never changed. */
|
|
63
|
+
export declare function hideComments(text: string): HiddenComments;
|
|
43
64
|
/** Runs a rewrite over everything except code, which is copied through. */
|
|
44
65
|
export declare function outsideCode(body: string, rewrite: (text: string) => string): string;
|
|
45
66
|
export declare function resolveWikilinks(body: string, resolve: (name: string) => string | null,
|
package/dist/markdown.js
CHANGED
|
@@ -12,8 +12,8 @@ export function toUnixNewlines(raw) {
|
|
|
12
12
|
return raw.replace(/\r\n?/g, '\n');
|
|
13
13
|
}
|
|
14
14
|
/**
|
|
15
|
-
* Where the body of a note starts: after its frontmatter, which
|
|
16
|
-
*
|
|
15
|
+
* Where the body of a note starts: after its frontmatter, which the theme draws apart from the
|
|
16
|
+
* body (`readProperties`). The cut is the one MemorySmith's reading page makes, so the ordinals of the task
|
|
17
17
|
* boxes below it are counted over the whole note, as the host stores it.
|
|
18
18
|
*/
|
|
19
19
|
export function bodyStart(text) {
|
|
@@ -32,8 +32,9 @@ function unquote(value) {
|
|
|
32
32
|
}
|
|
33
33
|
/**
|
|
34
34
|
* The frontmatter block, read the way MemorySmith's reading page reads it to show a note's
|
|
35
|
-
* properties
|
|
36
|
-
*
|
|
35
|
+
* properties, its lists joined by commas. The renderer draws the frontmatter from
|
|
36
|
+
* `readProperties`, which keeps each item and each shape apart; this stays for a host that reads
|
|
37
|
+
* the values MemorySmith does.
|
|
37
38
|
*/
|
|
38
39
|
export function splitFrontmatter(input) {
|
|
39
40
|
const raw = toUnixNewlines(input);
|
|
@@ -253,6 +254,54 @@ export function tableRegions(body) {
|
|
|
253
254
|
export function insideTable(regions, at) {
|
|
254
255
|
return regions.some(([start, end]) => at >= start && at < end);
|
|
255
256
|
}
|
|
257
|
+
/**
|
|
258
|
+
* The comments of a text (§7.6), as half-open ranges: from a `%%` outside code to the next `%%`
|
|
259
|
+
* outside code, across inline markup and blank lines alike. A `%%` with no partner opens nothing.
|
|
260
|
+
*
|
|
261
|
+
* A comment that is all of its lines takes the line break after it too, so a comment written on
|
|
262
|
+
* lines of its own leaves no empty line behind to loosen a list or split a paragraph.
|
|
263
|
+
*
|
|
264
|
+
* MemorySmith's reading page hides a comment inside one run of text only, so one that holds a
|
|
265
|
+
* link, a tag, emphasis or a blank line reaches the page whole; this is found on the text, before
|
|
266
|
+
* anything is parsed, so nothing a comment holds is ever read.
|
|
267
|
+
*/
|
|
268
|
+
export function commentRegions(text) {
|
|
269
|
+
const code = codeRegions(text);
|
|
270
|
+
const markers = [...text.matchAll(/%%/g)].map((match) => match.index).filter((at) => !insideCode(code, at));
|
|
271
|
+
const regions = [];
|
|
272
|
+
for (let index = 0; index + 1 < markers.length; index += 2) {
|
|
273
|
+
let start = markers[index];
|
|
274
|
+
let end = markers[index + 1] + 2;
|
|
275
|
+
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
|
|
276
|
+
const lineEnd = text.indexOf('\n', end) === -1 ? text.length : text.indexOf('\n', end);
|
|
277
|
+
if (/^[ \t]*$/.test(text.slice(lineStart, start)) && /^[ \t]*$/.test(text.slice(end, lineEnd))) {
|
|
278
|
+
start = lineStart;
|
|
279
|
+
end = lineEnd === text.length ? lineEnd : lineEnd + 1;
|
|
280
|
+
}
|
|
281
|
+
regions.push([start, end]);
|
|
282
|
+
}
|
|
283
|
+
return regions;
|
|
284
|
+
}
|
|
285
|
+
/** The text a reader is shown: its comments taken out (§7.6). The note itself is never changed. */
|
|
286
|
+
export function hideComments(text) {
|
|
287
|
+
const regions = commentRegions(text);
|
|
288
|
+
if (regions.length === 0)
|
|
289
|
+
return { text, original: (offset) => offset };
|
|
290
|
+
let shown = '';
|
|
291
|
+
let cursor = 0;
|
|
292
|
+
// Where each comment was, in the shown text, and how much of the original it took.
|
|
293
|
+
const cuts = [];
|
|
294
|
+
for (const [start, end] of regions) {
|
|
295
|
+
shown += text.slice(cursor, start);
|
|
296
|
+
cuts.push({ at: shown.length, length: end - start });
|
|
297
|
+
cursor = end;
|
|
298
|
+
}
|
|
299
|
+
shown += text.slice(cursor);
|
|
300
|
+
return {
|
|
301
|
+
text: shown,
|
|
302
|
+
original: (offset, end = false) => offset + cuts.filter((cut) => (end ? cut.at < offset : cut.at <= offset)).reduce((total, cut) => total + cut.length, 0),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
256
305
|
/** Runs a rewrite over everything except code, which is copied through. */
|
|
257
306
|
export function outsideCode(body, rewrite) {
|
|
258
307
|
let out = '';
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The frontmatter of a note, read for drawing (§6).
|
|
3
|
+
*
|
|
4
|
+
* The frontmatter is content: what the author wrote about the note. The
|
|
5
|
+
* renderer reads it the way the specification reads it and hands it to the
|
|
6
|
+
* theme, which decides how — and whether — it is drawn. The default theme
|
|
7
|
+
* draws `name` as the note's title and every other entry as a property, by
|
|
8
|
+
* the shape of its value.
|
|
9
|
+
*
|
|
10
|
+
* It reads the subset of YAML §6.2 names — scalars, inline lists and dash
|
|
11
|
+
* lists, with one layer of matching quotes stripped — and nothing else; a line
|
|
12
|
+
* it does not read is left out, never guessed at. A value written as a
|
|
13
|
+
* wikilink, `"[[Note]]"`, is kept as written: the theme draws it as a link,
|
|
14
|
+
* resolved by the host as a link of the body is. That is a decision of the
|
|
15
|
+
* reading surface; it adds no edge, and what an Indexer reads is unchanged.
|
|
16
|
+
*/
|
|
17
|
+
/** The shape of a value, which decides how it is drawn (§6.3). */
|
|
18
|
+
export type PropertyShape = 'list' | 'date' | 'boolean' | 'text' | 'prose' | 'empty';
|
|
19
|
+
/** One entry of the frontmatter, other than `name`. */
|
|
20
|
+
export interface PropertyEntry {
|
|
21
|
+
/** The key as the author wrote it, in their language. */
|
|
22
|
+
readonly key: string;
|
|
23
|
+
readonly shape: PropertyShape;
|
|
24
|
+
/**
|
|
25
|
+
* The value as written, one layer of quotes stripped: one item, or each item of a list. A
|
|
26
|
+
* theme reads a boolean or a date from it, and shows what was typed where it shows the bytes.
|
|
27
|
+
*/
|
|
28
|
+
readonly values: readonly string[];
|
|
29
|
+
}
|
|
30
|
+
/** The frontmatter, read. */
|
|
31
|
+
export interface NoteProperties {
|
|
32
|
+
/** The name of the note: `name:` when it is a single text value (§6.5), else `null`. */
|
|
33
|
+
readonly name: string | null;
|
|
34
|
+
/** Every other entry, in the order written. */
|
|
35
|
+
readonly entries: readonly PropertyEntry[];
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The frontmatter of a note, or no name and no entries when it has none. The block begins on the
|
|
39
|
+
* first line with `---` and ends at the next line that is `---` (§6.1).
|
|
40
|
+
*/
|
|
41
|
+
export declare function readProperties(markdown: string): NoteProperties;
|
|
42
|
+
/**
|
|
43
|
+
* What a value may carry that is a link: a wikilink, a Markdown link to the web, or a bare web
|
|
44
|
+
* address — as MemorySmith's properties read it, and nothing else of Markdown.
|
|
45
|
+
*/
|
|
46
|
+
export declare const VALUE_LINK: RegExp;
|
|
47
|
+
/** Whether a boolean value reads true: `true` or `yes`, in any case. */
|
|
48
|
+
export declare function isTrue(value: string): boolean;
|
|
49
|
+
/** The reserved keys a theme draws as properties (§6.4): `name` is the title, not a property. */
|
|
50
|
+
export declare const DRAWN_RESERVED_KEYS: readonly string[];
|