@memorysmithapp/markdown-renderer 0.0.0-stage → 0.1.1
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 +35 -0
- package/dist/Markdown.js +283 -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 +92 -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/footnotes.d.ts +72 -0
- package/dist/footnotes.js +137 -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 +75 -3
- package/styles.css +865 -0
package/dist/Markdown.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { toJsxRuntime } from 'hast-util-to-jsx-runtime';
|
|
3
|
+
import { isValidElement, useRef, } from 'react';
|
|
4
|
+
import { Fragment, jsx, jsxs } from 'react/jsx-runtime';
|
|
5
|
+
import ReactMarkdown from 'react-markdown';
|
|
6
|
+
import rehypeKatex from 'rehype-katex';
|
|
7
|
+
import remarkGfm from 'remark-gfm';
|
|
8
|
+
import remarkMath from 'remark-math';
|
|
9
|
+
import { followable } from './address.js';
|
|
10
|
+
import { CopyButton } from './CopyButton.js';
|
|
11
|
+
import { highlightLanguage, highlightTree } from './highlight.js';
|
|
12
|
+
import { useHost } from './host.js';
|
|
13
|
+
import { readDimensions, readImageAlt } from './image-dimensions.js';
|
|
14
|
+
import { choiceTargetOf, LinkChoice } from './LinkChoice.js';
|
|
15
|
+
import { toUnixNewlines } from './markdown.js';
|
|
16
|
+
import { MermaidDiagram } from './MermaidDiagram.js';
|
|
17
|
+
import { remarkCallouts } from './remark-callouts.js';
|
|
18
|
+
import { remarkBlockIds, remarkComments, remarkHighlight, remarkMathDollarRule } from './remark-ring.js';
|
|
19
|
+
import { CLOBBER_PREFIX, rehypeFootnoteRun, rehypeFootnoteSection } from './footnotes.js';
|
|
20
|
+
import { ordinalAt } from './tasklist.js';
|
|
21
|
+
/**
|
|
22
|
+
* An image, with the dimensions the specification puts in its alt text
|
|
23
|
+
* (§3.14). What precedes the pipe is the description and is never dropped;
|
|
24
|
+
* what follows it is width, or width and height, in CSS pixels, and never
|
|
25
|
+
* appears as text. A value that is neither stays part of the description,
|
|
26
|
+
* because deleting an accessibility label is the worse failure.
|
|
27
|
+
*/
|
|
28
|
+
function MarkdownImage({ alt, node: _node, ...rest }) {
|
|
29
|
+
const read = readImageAlt(alt ?? '');
|
|
30
|
+
return (_jsx("img", { ...rest, alt: read.description, ...(read.width === null ? {} : { width: read.width }), ...(read.height === null ? {} : { height: read.height }) }));
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A link to a place in the same note: a footnote and its way back (§7.12),
|
|
34
|
+
* or an anchor an author wrote.
|
|
35
|
+
*
|
|
36
|
+
* It must not open the whole page in another tab, and a bare hash is not
|
|
37
|
+
* enough either: a host may scroll the note inside a pane of its own rather
|
|
38
|
+
* than the window, and an embedded note renders the same identifiers a second
|
|
39
|
+
* time. So the target is looked up inside the note the link was drawn in, and
|
|
40
|
+
* brought into view in whatever scrolls it; the address of the page does not
|
|
41
|
+
* change, because nothing was navigated.
|
|
42
|
+
*/
|
|
43
|
+
function InPageAnchor({ href, children, ...rest }) {
|
|
44
|
+
const follow = (event) => {
|
|
45
|
+
const id = decodeURIComponent(href.slice(1));
|
|
46
|
+
// The note is the root `NoteBody` draws, since a footnote's way back may
|
|
47
|
+
// cross runs; inside an embed it is the embed's own run. A match inside
|
|
48
|
+
// an embed answers only a link inside that same embed.
|
|
49
|
+
const link = event.currentTarget;
|
|
50
|
+
const embed = link.closest('.embed');
|
|
51
|
+
const note = embed ? link.closest('.markdown') : (link.closest('.markdown[data-theme]') ?? link.closest('.markdown'));
|
|
52
|
+
const target = [...(note?.querySelectorAll(`[id="${CSS.escape(id)}"]`) ?? [])].find((candidate) => candidate.closest('.embed') === embed);
|
|
53
|
+
if (!(target instanceof HTMLElement))
|
|
54
|
+
return;
|
|
55
|
+
event.preventDefault();
|
|
56
|
+
target.scrollIntoView({ block: 'center' });
|
|
57
|
+
};
|
|
58
|
+
return (_jsx("a", { href: href, onClick: follow, ...rest, children: children }));
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* A file the notebook keeps, where a note embedded it. The host draws it by its
|
|
62
|
+
* type — an image, audio, video, or a card — and a name the notebook keeps
|
|
63
|
+
* nothing under is reported the way a pending link is: a notebook is read most
|
|
64
|
+
* while it is being written (§5.8).
|
|
65
|
+
*/
|
|
66
|
+
function Attachment({ name, width, height }) {
|
|
67
|
+
const host = useHost();
|
|
68
|
+
if (!host.keeps(name)) {
|
|
69
|
+
return (_jsx("span", { className: "attachment-missing", title: host.strings.attachmentMissing, children: name }));
|
|
70
|
+
}
|
|
71
|
+
if (!host.renderAttachment)
|
|
72
|
+
return _jsx("span", { className: "attachment-loading", children: name });
|
|
73
|
+
return _jsx(_Fragment, { children: host.renderAttachment({ name, width, height }) });
|
|
74
|
+
}
|
|
75
|
+
function MarkdownAnchor({ href, children, node: _node, ...rest }) {
|
|
76
|
+
const host = useHost();
|
|
77
|
+
if (href?.startsWith('attachment:')) {
|
|
78
|
+
/**
|
|
79
|
+
* The address carries the dimension the pipe declared, after the encoded
|
|
80
|
+
* name, where nothing else can be (§3.14).
|
|
81
|
+
*
|
|
82
|
+
* **Decoded before it is split**, because the parser percent-encodes what
|
|
83
|
+
* it does not have to leave alone: the separator arrives as `%7C` and
|
|
84
|
+
* looking for `|` found nothing, which sent the whole string — dimension
|
|
85
|
+
* included — to be looked up as the name of a file. A name carrying a
|
|
86
|
+
* pipe is no name anyway (§5.3), so the last one is the separator or
|
|
87
|
+
* there is none.
|
|
88
|
+
*/
|
|
89
|
+
const address = decodeURIComponent(href.slice('attachment:'.length));
|
|
90
|
+
const at = address.lastIndexOf('|');
|
|
91
|
+
const measured = at === -1 ? null : readDimensions(address.slice(at + 1));
|
|
92
|
+
const name = measured ? address.slice(0, at) : address;
|
|
93
|
+
return _jsx(Attachment, { name: name, width: measured?.width ?? null, height: measured?.height ?? null });
|
|
94
|
+
}
|
|
95
|
+
if (href?.startsWith('pending:')) {
|
|
96
|
+
return (_jsx("span", { className: "wikilink-pending", title: host.strings.pendingLink, children: children }));
|
|
97
|
+
}
|
|
98
|
+
// Refused by `followable`: the text stays on the page and stops being
|
|
99
|
+
// something to click. An author sees they got nothing, which is the same
|
|
100
|
+
// answer the specification gives for a notation it does not implement.
|
|
101
|
+
if (!href) {
|
|
102
|
+
return (_jsx("span", { className: "link-refused", title: host.strings.refusedLink, children: children }));
|
|
103
|
+
}
|
|
104
|
+
// A target no single note answers by name opens its choice in place.
|
|
105
|
+
const choice = choiceTargetOf(href);
|
|
106
|
+
if (choice !== null)
|
|
107
|
+
return _jsx(LinkChoice, { target: choice, children: children });
|
|
108
|
+
// An address of the host's own: its router draws it.
|
|
109
|
+
if (href.startsWith('/'))
|
|
110
|
+
return _jsx(_Fragment, { children: host.renderLink({ href, className: 'wikilink', children }) });
|
|
111
|
+
if (href.startsWith('#')) {
|
|
112
|
+
return (_jsx(InPageAnchor, { href: href, ...rest, children: children }));
|
|
113
|
+
}
|
|
114
|
+
return (_jsx("a", { href: href, target: "_blank", rel: "noreferrer", ...rest, children: children }));
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* A code span, a fenced block, and the one info string with a rendering rule.
|
|
118
|
+
*
|
|
119
|
+
* The order of the three branches is the contract. `mermaid` is checked first
|
|
120
|
+
* and reaches `MermaidDiagram` with its source untouched, because it is the
|
|
121
|
+
* only info string the specification attaches a rendering rule to (§7.2) and a
|
|
122
|
+
* highlighter in front of that check would swallow a diagram. A language the
|
|
123
|
+
* highlighter carries is tokenised. Everything else — a code span, a fence
|
|
124
|
+
* with no info string, a language nobody registered — renders exactly as it
|
|
125
|
+
* did before highlighting existed, which is the pattern the specification sets
|
|
126
|
+
* for the diagram it cannot draw and the formula it cannot typeset: show the
|
|
127
|
+
* source, never hide it.
|
|
128
|
+
*
|
|
129
|
+
* The tokens arrive as ELEMENTS. `refractor` produces a hast tree and
|
|
130
|
+
* `toJsxRuntime` builds React out of it, so no note-derived markup is ever
|
|
131
|
+
* parsed as HTML — the boundary §7.9 draws, held at the one place a
|
|
132
|
+
* highlighter would otherwise breach it.
|
|
133
|
+
*/
|
|
134
|
+
function MarkdownCode({ className, children, node: _node, ...rest }) {
|
|
135
|
+
const { theme } = useHost();
|
|
136
|
+
if (className?.includes('language-mermaid')) {
|
|
137
|
+
return _jsx(MermaidDiagram, { code: String(children).trim(), theme: theme });
|
|
138
|
+
}
|
|
139
|
+
const language = highlightLanguage(className);
|
|
140
|
+
if (language !== null) {
|
|
141
|
+
// The source is read and never rewritten: what is on the page is the
|
|
142
|
+
// bytes, split into spans. React-markdown hands the children of a fence as
|
|
143
|
+
// a single string ending in the newline before the closing fence.
|
|
144
|
+
const code = String(children).replace(/\n$/, '');
|
|
145
|
+
return (_jsx("code", { className: className, ...rest, children: toJsxRuntime(highlightTree(code, language), { Fragment, jsx, jsxs }) }));
|
|
146
|
+
}
|
|
147
|
+
return (_jsx("code", { className: className, ...rest, children: children }));
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* A fenced block in a frame of its own, with the verb that carries it
|
|
151
|
+
* somewhere else. The frame is not the `pre`: the `pre` scrolls and never
|
|
152
|
+
* wraps, since a wrapped line breaks indentation that carries meaning, and the
|
|
153
|
+
* button has to stay in its corner while the content scrolls under it.
|
|
154
|
+
*
|
|
155
|
+
* What is copied is the `textContent` of the code, which is the bytes as
|
|
156
|
+
* typed because the highlighter's tokens arrive as elements, less the newline
|
|
157
|
+
* before the closing fence.
|
|
158
|
+
*
|
|
159
|
+
* A diagram is not code on the page: its fence is left bare, with no frame and
|
|
160
|
+
* nothing to copy.
|
|
161
|
+
*/
|
|
162
|
+
function MarkdownPre({ children, node, ...rest }) {
|
|
163
|
+
const { strings } = useHost();
|
|
164
|
+
const ref = useRef(null);
|
|
165
|
+
const code = node?.children?.find((child) => child.tagName === 'code');
|
|
166
|
+
const classes = code?.properties?.['className'];
|
|
167
|
+
if (Array.isArray(classes) && classes.includes('language-mermaid')) {
|
|
168
|
+
return _jsx("pre", { ...rest, children: children });
|
|
169
|
+
}
|
|
170
|
+
return (_jsxs("div", { className: "code-block", children: [_jsx("pre", { ref: ref, ...rest, children: children }), _jsx(CopyButton, { className: "code-copy", label: strings.copy, done: strings.copied, text: () => (ref.current?.querySelector('code')?.textContent ?? '').replace(/\n$/, '') })] }));
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Whether this list item is a task box, and how it is ticked.
|
|
174
|
+
*
|
|
175
|
+
* The state is read from the child `input` that GFM produces, and NOT from a
|
|
176
|
+
* `checked` on the item itself: react-markdown 9 hands a component the **hast**
|
|
177
|
+
* element, which carries no such property. Reading it there returned
|
|
178
|
+
* `undefined` for every item, which once sent every task box of MemorySmith
|
|
179
|
+
* down the plain branch and onto the screen as GFM's own disabled checkbox —
|
|
180
|
+
* no handler, no click, on any note.
|
|
181
|
+
*/
|
|
182
|
+
function taskState(node) {
|
|
183
|
+
const box = node?.children?.find((child) => child.tagName === 'input');
|
|
184
|
+
const checked = box?.properties?.['checked'];
|
|
185
|
+
return typeof checked === 'boolean' ? checked : null;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* A list item that carries a task box. The ordinal comes from the position the
|
|
189
|
+
* parser reports, mapped back to the n-th task of the source, which is what
|
|
190
|
+
* lets a click become a one-character change in the original text — made by
|
|
191
|
+
* the host, never by the package.
|
|
192
|
+
*/
|
|
193
|
+
function TaskItem({ node, children, className, source, toggleTask, writable, ...rest }) {
|
|
194
|
+
const element = node;
|
|
195
|
+
const checked = taskState(element);
|
|
196
|
+
if (checked === null) {
|
|
197
|
+
return (_jsx("li", { className: className, ...rest, children: children }));
|
|
198
|
+
}
|
|
199
|
+
const offset = element?.position?.start?.offset;
|
|
200
|
+
const ordinal = offset === undefined ? -1 : ordinalAt(source, offset);
|
|
201
|
+
const enabled = writable && toggleTask !== undefined && ordinal >= 0;
|
|
202
|
+
return (
|
|
203
|
+
// Our class comes LAST, because the one react-markdown passes in is on
|
|
204
|
+
// `className` and spreading it after would silently take the item back.
|
|
205
|
+
_jsxs("li", { ...rest, className: `${className ?? ''} task-item`.trim(), children: [_jsx("input", { type: "checkbox", checked: checked, disabled: !enabled, onChange: () => enabled && toggleTask(ordinal) }), dropBox(children)] }));
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* The children of the item without the checkbox GFM put in front of them.
|
|
209
|
+
*
|
|
210
|
+
* It is matched by element type rather than by position, because a leading
|
|
211
|
+
* space follows it and neither the space nor the order is part of any contract.
|
|
212
|
+
*/
|
|
213
|
+
function dropBox(children) {
|
|
214
|
+
if (!Array.isArray(children))
|
|
215
|
+
return children;
|
|
216
|
+
return children.filter((child) => !(isValidElement(child) && child.type === 'input'));
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* The remark plugins of the reading surface, in their order. `planFootnotes` reads a note
|
|
220
|
+
* through the same list, so it counts the references this surface draws and no other.
|
|
221
|
+
*/
|
|
222
|
+
export function readingPlugins(titleOf) {
|
|
223
|
+
return [
|
|
224
|
+
/**
|
|
225
|
+
* `singleTilde: false` is a decision and not a default.
|
|
226
|
+
*
|
|
227
|
+
* GFM specifies strikethrough as `~~x~~`; GitHub also accepts a
|
|
228
|
+
* single tilde, outside its own specification. Here that extension
|
|
229
|
+
* actively misrenders a notation the specification leaves out:
|
|
230
|
+
* somebody writing `H~2~O` for a subscript would get `H` struck-out
|
|
231
|
+
* `2` `O`, which is a worse answer than nothing. With it off the
|
|
232
|
+
* characters stay on the page, and the author can see they got what
|
|
233
|
+
* the specification says they get, which is nothing.
|
|
234
|
+
*/
|
|
235
|
+
[remarkGfm, { singleTilde: false }],
|
|
236
|
+
[remarkCallouts, { titleOf }],
|
|
237
|
+
remarkHighlight,
|
|
238
|
+
remarkComments,
|
|
239
|
+
remarkBlockIds,
|
|
240
|
+
remarkMath,
|
|
241
|
+
// After remark-math, and it gives back what was never a formula.
|
|
242
|
+
remarkMathDollarRule,
|
|
243
|
+
];
|
|
244
|
+
}
|
|
245
|
+
export function Markdown({ children, source, onToggleTask, writable = false, footnotes }) {
|
|
246
|
+
const text = toUnixNewlines(children);
|
|
247
|
+
const { strings } = useHost();
|
|
248
|
+
const calloutTitle = (kind) => Object.hasOwn(strings.callouts, kind) ? strings.callouts[kind] : undefined;
|
|
249
|
+
return (_jsx("div", { className: "markdown", children: _jsx(ReactMarkdown, { remarkPlugins: readingPlugins(calloutTitle),
|
|
250
|
+
// Raw HTML is NOT enabled, and its absence is the point: no
|
|
251
|
+
// `rehype-raw` is loaded, so a note carrying `<script>` is text. It is
|
|
252
|
+
// a security boundary rather than a rendering preference, because a
|
|
253
|
+
// notebook is written by several people and by agents (§7.9).
|
|
254
|
+
// A formula KaTeX cannot typeset shows its source, never an error (§7.8).
|
|
255
|
+
rehypePlugins: [
|
|
256
|
+
[rehypeKatex, { throwOnError: false }],
|
|
257
|
+
...(footnotes === undefined
|
|
258
|
+
? []
|
|
259
|
+
: footnotes === 'section'
|
|
260
|
+
? [rehypeFootnoteSection]
|
|
261
|
+
: [[rehypeFootnoteRun, footnotes]]),
|
|
262
|
+
],
|
|
263
|
+
// Footnotes (§7.12): the words around them are the reader's, and the
|
|
264
|
+
// label heads the notes gathered at the end of the note, where a
|
|
265
|
+
// reader sees it — GFM hides it, and a stylesheet that hides nothing
|
|
266
|
+
// shows it as a visible heading, so it is in the reader's language.
|
|
267
|
+
remarkRehypeOptions: {
|
|
268
|
+
// remark-rehype's own default, stated: `footnotes.ts` renumbers by it.
|
|
269
|
+
clobberPrefix: CLOBBER_PREFIX,
|
|
270
|
+
footnoteLabel: strings.footnotes,
|
|
271
|
+
footnoteLabelProperties: { className: ['footnotes-label'] },
|
|
272
|
+
footnoteBackLabel: (referenceIndex) => strings.footnoteBack(referenceIndex + 1),
|
|
273
|
+
},
|
|
274
|
+
// Which addresses this surface will follow, and the reason the stock
|
|
275
|
+
// filter is not doing it, are in `address.ts`.
|
|
276
|
+
urlTransform: followable, components: {
|
|
277
|
+
a: MarkdownAnchor,
|
|
278
|
+
code: MarkdownCode,
|
|
279
|
+
pre: MarkdownPre,
|
|
280
|
+
img: MarkdownImage,
|
|
281
|
+
li: (props) => (_jsx(TaskItem, { ...props, source: source ?? text, toggleTask: onToggleTask, writable: writable })),
|
|
282
|
+
}, children: text }) }));
|
|
283
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
interface MermaidDiagramProps {
|
|
2
|
+
readonly code: string;
|
|
3
|
+
/** The theme the host draws in, which picks Mermaid's own. */
|
|
4
|
+
readonly theme: 'light' | 'dark';
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* A mermaid code block drawn as an SVG diagram (§7.2). The library is heavy, so
|
|
8
|
+
* it is imported lazily, out of the bundle that draws the rest of the note; on
|
|
9
|
+
* any error the code is shown instead, and the error never leaves the diagram.
|
|
10
|
+
*
|
|
11
|
+
* The SVG is the one markup this surface injects, and it is Mermaid's own
|
|
12
|
+
* output, not note-derived HTML: Mermaid renders with its default `strict`
|
|
13
|
+
* security level, which encodes the text of the diagram.
|
|
14
|
+
*/
|
|
15
|
+
export declare function MermaidDiagram({ code, theme }: MermaidDiagramProps): import("react").JSX.Element;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useId, useState } from 'react';
|
|
3
|
+
/**
|
|
4
|
+
* A mermaid code block drawn as an SVG diagram (§7.2). The library is heavy, so
|
|
5
|
+
* it is imported lazily, out of the bundle that draws the rest of the note; on
|
|
6
|
+
* any error the code is shown instead, and the error never leaves the diagram.
|
|
7
|
+
*
|
|
8
|
+
* The SVG is the one markup this surface injects, and it is Mermaid's own
|
|
9
|
+
* output, not note-derived HTML: Mermaid renders with its default `strict`
|
|
10
|
+
* security level, which encodes the text of the diagram.
|
|
11
|
+
*/
|
|
12
|
+
export function MermaidDiagram({ code, theme }) {
|
|
13
|
+
const id = useId().replace(/[^a-zA-Z0-9]/g, '');
|
|
14
|
+
const [svg, setSvg] = useState(null);
|
|
15
|
+
const [failed, setFailed] = useState(false);
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
let alive = true;
|
|
18
|
+
let renderId = null;
|
|
19
|
+
setSvg(null);
|
|
20
|
+
setFailed(false);
|
|
21
|
+
void (async () => {
|
|
22
|
+
try {
|
|
23
|
+
const { default: mermaid } = await import('mermaid');
|
|
24
|
+
// Text metrics depend on the webfont; rendering before it loads
|
|
25
|
+
// truncates node labels.
|
|
26
|
+
await document.fonts?.ready;
|
|
27
|
+
mermaid.initialize({
|
|
28
|
+
startOnLoad: false,
|
|
29
|
+
theme: theme === 'dark' ? 'dark' : 'neutral',
|
|
30
|
+
fontFamily: 'Inter, sans-serif',
|
|
31
|
+
// SVG text labels measure reliably; HTML labels clip inside nodes
|
|
32
|
+
// when the webfont metrics differ from the fallback.
|
|
33
|
+
htmlLabels: false,
|
|
34
|
+
flowchart: { htmlLabels: false },
|
|
35
|
+
});
|
|
36
|
+
/**
|
|
37
|
+
* A diagram Mermaid cannot read is shown as its code without drawing
|
|
38
|
+
* it. Drawing it anyway failed in the right place, the code, and left
|
|
39
|
+
* the scratch container Mermaid draws into — its error bomb included —
|
|
40
|
+
* at the end of <body>, under the whole page.
|
|
41
|
+
*/
|
|
42
|
+
if ((await mermaid.parse(code, { suppressErrors: true })) === false) {
|
|
43
|
+
if (alive)
|
|
44
|
+
setFailed(true);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
renderId = `mmd-${id}-${Date.now()}`;
|
|
48
|
+
const rendered = await mermaid.render(renderId, code);
|
|
49
|
+
if (alive)
|
|
50
|
+
setSvg(rendered.svg);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// And should drawing still fail, what it left behind goes with it.
|
|
54
|
+
if (renderId)
|
|
55
|
+
document.getElementById(`d${renderId}`)?.remove();
|
|
56
|
+
if (alive)
|
|
57
|
+
setFailed(true);
|
|
58
|
+
}
|
|
59
|
+
})();
|
|
60
|
+
return () => {
|
|
61
|
+
alive = false;
|
|
62
|
+
};
|
|
63
|
+
}, [code, id, theme]);
|
|
64
|
+
if (failed) {
|
|
65
|
+
return (_jsx("pre", { children: _jsx("code", { children: code }) }));
|
|
66
|
+
}
|
|
67
|
+
if (!svg)
|
|
68
|
+
return _jsx("div", { className: "mermaid-diagram mermaid-loading" });
|
|
69
|
+
return _jsx("div", { className: "mermaid-diagram", dangerouslySetInnerHTML: { __html: svg } });
|
|
70
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type NoteHost } from './host.js';
|
|
2
|
+
export interface NoteBodyProps extends NoteHost {
|
|
3
|
+
/**
|
|
4
|
+
* The note as the host keeps it, frontmatter included. It is drawn from these bytes and never
|
|
5
|
+
* rewritten; the frontmatter is not drawn, because a host shows it as properties.
|
|
6
|
+
*/
|
|
7
|
+
readonly markdown: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The body of a note, drawn as MemorySmith's reading page draws it.
|
|
11
|
+
*
|
|
12
|
+
* It renders inside `.markdown`, so the stylesheet of the package applies, and asks the host for
|
|
13
|
+
* every piece of notebook data through the props of `NoteHost`.
|
|
14
|
+
*/
|
|
15
|
+
export declare function NoteBody({ markdown, writable, onToggleTask, ...host }: NoteBodyProps): import("react").JSX.Element;
|
package/dist/NoteBody.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo } from 'react';
|
|
3
|
+
import { HOST_KEYS, HostProvider, resolveHost } from './host.js';
|
|
4
|
+
import { planFootnotes } from './footnotes.js';
|
|
5
|
+
import { Markdown, readingPlugins } from './Markdown.js';
|
|
6
|
+
import { bodyStart, remoteImageHosts, resolveWikilinks } from './markdown.js';
|
|
7
|
+
import { taskBoxes } from './tasklist.js';
|
|
8
|
+
import { Transclusion } from './Transclusion.js';
|
|
9
|
+
import { splitEmbeds } from './transclusion.js';
|
|
10
|
+
import { wikilinkAddress } from './wikilink-address.js';
|
|
11
|
+
/**
|
|
12
|
+
* The body of a note, drawn as MemorySmith's reading page draws it.
|
|
13
|
+
*
|
|
14
|
+
* It renders inside `.markdown`, so the stylesheet of the package applies, and asks the host for
|
|
15
|
+
* every piece of notebook data through the props of `NoteHost`.
|
|
16
|
+
*/
|
|
17
|
+
export function NoteBody({ markdown, writable = false, onToggleTask, ...host }) {
|
|
18
|
+
const resolved = useMemo(() => resolveHost(host),
|
|
19
|
+
// Every member of the contract, so a host that passes the same functions keeps the same host.
|
|
20
|
+
HOST_KEYS.map((key) => host[key]));
|
|
21
|
+
// Frontmatter is shown as properties, not as prose, so the reading surface
|
|
22
|
+
// starts after it. Everything below counts task items from there.
|
|
23
|
+
const split = bodyStart(markdown);
|
|
24
|
+
const body = markdown.slice(split);
|
|
25
|
+
// The body is cut into runs of text and the embeds between them; each run
|
|
26
|
+
// is parsed on its own, as on MemorySmith's reading page.
|
|
27
|
+
const segments = splitEmbeds(body, resolved.keeps).map((segment) => segment.kind === 'text'
|
|
28
|
+
? {
|
|
29
|
+
...segment,
|
|
30
|
+
// One resolver for every run: one note is a link, several are the
|
|
31
|
+
// choice, and none is pending and still asks.
|
|
32
|
+
rendered: resolveWikilinks(segment.text, (name) => wikilinkAddress(resolved, name), resolved.keeps),
|
|
33
|
+
}
|
|
34
|
+
: segment);
|
|
35
|
+
/**
|
|
36
|
+
* Whether a click can be mapped back to the bytes at all.
|
|
37
|
+
*
|
|
38
|
+
* The ordinal is counted over the text the PARSER saw, which is the one with
|
|
39
|
+
* the wikilinks already resolved, and it is applied to the text a write
|
|
40
|
+
* sends, which is the original. Those two agree on how many boxes there are
|
|
41
|
+
* and in what order, almost always — and `- [[x]]` is where they do not,
|
|
42
|
+
* because resolving it produces `- [x](/url)`, which is a task box to any
|
|
43
|
+
* GFM reader. Counting on one side and writing on the other would then
|
|
44
|
+
* toggle a different item, silently, which is the worst defect this surface
|
|
45
|
+
* can have. When the two disagree the boxes are read-only: not writing is
|
|
46
|
+
* always better than writing the wrong one.
|
|
47
|
+
*/
|
|
48
|
+
const mappable = taskBoxes(body).length ===
|
|
49
|
+
segments.reduce((total, segment) => total + ('rendered' in segment ? taskBoxes(segment.rendered).length : 0), 0);
|
|
50
|
+
// The ordinal a click carries is of the WHOLE note, and each rendered run
|
|
51
|
+
// only knows its own. The count carried across the runs is what bridges
|
|
52
|
+
// the two, and it has to include anything above the body.
|
|
53
|
+
let seen = taskBoxes(markdown.slice(0, split)).length;
|
|
54
|
+
/**
|
|
55
|
+
* Whom this note asked the browser to talk to (§7.10).
|
|
56
|
+
*
|
|
57
|
+
* The specification admits three answers about a remote image and forbids
|
|
58
|
+
* only silence: never fetching, fetching on the reader's action, or fetching
|
|
59
|
+
* and saying so. This surface fetches — the destination reaches `<img src>`,
|
|
60
|
+
* and React even preloads it — so this line is the half that says so, and
|
|
61
|
+
* the reason it has to exist is that this is the one of the three answers a
|
|
62
|
+
* person cannot work out from the page.
|
|
63
|
+
*
|
|
64
|
+
* It appears only where it is true, naming the hosts, because a notice on
|
|
65
|
+
* every note would be a thing to scroll past rather than a thing to read.
|
|
66
|
+
*/
|
|
67
|
+
const hosts = resolved.remoteNotice ? remoteImageHosts(body) : [];
|
|
68
|
+
// The footnotes of a note an embed cuts (§7.12): each run is handed the
|
|
69
|
+
// definitions of the others and the numbers of the note, and the note's one
|
|
70
|
+
// section is drawn at its end, below the last embed too. Null, and nothing
|
|
71
|
+
// changes, for a note no embed cuts or with no footnote to carry.
|
|
72
|
+
const runs = segments.flatMap((segment) => ('rendered' in segment ? [segment.rendered] : []));
|
|
73
|
+
const footnotes = useMemo(() => planFootnotes(runs, runs.length < segments.length, readingPlugins(() => undefined)),
|
|
74
|
+
// The runs and whether an embed cut them are the whole input.
|
|
75
|
+
[runs.join('\u0000'), runs.length < segments.length]);
|
|
76
|
+
let run = 0;
|
|
77
|
+
return (_jsx(HostProvider, { value: resolved, children: _jsxs("div", { className: "markdown", "data-theme": resolved.theme, children: [segments.map((segment, index) => {
|
|
78
|
+
if (!('rendered' in segment)) {
|
|
79
|
+
return _jsx(Transclusion, { target: segment.target, anchor: segment.anchor }, index);
|
|
80
|
+
}
|
|
81
|
+
const base = seen;
|
|
82
|
+
seen += taskBoxes(segment.rendered).length;
|
|
83
|
+
const planned = footnotes?.runs[run++];
|
|
84
|
+
return (_jsx(Markdown, {
|
|
85
|
+
// The text the ordinal is counted over, stated rather than left to
|
|
86
|
+
// a default: it is the one the parser reports offsets into, and
|
|
87
|
+
// `mappable` above is what makes it safe to write back through.
|
|
88
|
+
// A planned run is the same text with definitions appended after
|
|
89
|
+
// it, so every box keeps the offset it had.
|
|
90
|
+
source: planned?.text ?? segment.rendered, onToggleTask: onToggleTask && ((ordinal) => onToggleTask(base + ordinal)), writable: writable && mappable, ...(planned ? { footnotes: { refs: planned.refs } } : {}), children: planned?.text ?? segment.rendered }, index));
|
|
91
|
+
}), footnotes && _jsx(Markdown, { footnotes: "section", children: footnotes.section }), hosts.length > 0 && _jsx("p", { className: "remote-notice", children: resolved.strings.remoteImages(hosts) })] }) }));
|
|
92
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One transcluded block: the content of another note, shown in place.
|
|
3
|
+
*
|
|
4
|
+
* It always says where it came from. A passage pasted without provenance is
|
|
5
|
+
* indistinguishable from what the author wrote, and a notebook rests on
|
|
6
|
+
* knowing who asserted what.
|
|
7
|
+
*/
|
|
8
|
+
export declare function Transclusion({ target, anchor }: {
|
|
9
|
+
target: string;
|
|
10
|
+
anchor: string | null;
|
|
11
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { useHost } from './host.js';
|
|
4
|
+
import { Markdown } from './Markdown.js';
|
|
5
|
+
import { bodyStart, resolveWikilinks } from './markdown.js';
|
|
6
|
+
import { blockOf, demoteEmbeds, isBlockAnchor, sectionOf } from './transclusion.js';
|
|
7
|
+
import { wikilinkAddress } from './wikilink-address.js';
|
|
8
|
+
/** The shape of what is coming, in the position it will occupy, while the host answers. */
|
|
9
|
+
function TransclusionSkeleton({ loading }) {
|
|
10
|
+
return (_jsxs("div", { className: "skeleton-region", "aria-busy": "true", "aria-live": "polite", children: [_jsx("span", { className: "visually-hidden", children: loading }), _jsxs("figure", { className: "embed embed-skeleton", children: [_jsx("div", { className: "embed-body", children: _jsxs("span", { className: "skeleton-text", children: [_jsx("span", { className: "skeleton", style: { width: '96%', height: '1rem' } }), _jsx("span", { className: "skeleton", style: { width: '58%', height: '1rem' } })] }) }), _jsx("figcaption", { className: "embed-source", children: _jsx("span", { className: "skeleton", style: { width: '8rem', height: '0.8rem' } }) })] })] }));
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* One transcluded block: the content of another note, shown in place.
|
|
14
|
+
*
|
|
15
|
+
* It always says where it came from. A passage pasted without provenance is
|
|
16
|
+
* indistinguishable from what the author wrote, and a notebook rests on
|
|
17
|
+
* knowing who asserted what.
|
|
18
|
+
*/
|
|
19
|
+
export function Transclusion({ target, anchor }) {
|
|
20
|
+
const host = useHost();
|
|
21
|
+
const { strings } = host;
|
|
22
|
+
const name = target.normalize('NFC');
|
|
23
|
+
// A transclusion expands ONE note, so it expands what a wikilink would
|
|
24
|
+
// navigate to: an ambiguous target is a choice a reader makes and not a
|
|
25
|
+
// passage the page can inline on their behalf.
|
|
26
|
+
const one = host.loadNote !== null && host.reaches(name) === 1;
|
|
27
|
+
const [loaded, setLoaded] = useState({ state: 'loading' });
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (!one || !host.loadNote)
|
|
30
|
+
return;
|
|
31
|
+
let alive = true;
|
|
32
|
+
setLoaded({ state: 'loading' });
|
|
33
|
+
host.loadNote(name).then((note) => alive && setLoaded(note ? { state: 'ready', note } : { state: 'missing' }), () => alive && setLoaded({ state: 'missing' }));
|
|
34
|
+
return () => {
|
|
35
|
+
alive = false;
|
|
36
|
+
};
|
|
37
|
+
}, [one, host, name]);
|
|
38
|
+
// The same pending marker a wikilink uses. A notebook is read most while it
|
|
39
|
+
// is still being written, so a target that does not exist yet is an
|
|
40
|
+
// expected state and never an error that stops the page.
|
|
41
|
+
if (!one || loaded.state === 'missing') {
|
|
42
|
+
return (_jsx("p", { className: "embed-pending", children: _jsx("span", { className: "wikilink-pending", title: strings.pendingLink, children: target }) }));
|
|
43
|
+
}
|
|
44
|
+
if (loaded.state === 'loading')
|
|
45
|
+
return _jsx(TransclusionSkeleton, { loading: strings.loading });
|
|
46
|
+
const { note } = loaded;
|
|
47
|
+
const whole = note.markdown.slice(bodyStart(note.markdown));
|
|
48
|
+
// The caption names the note the content came from, and a note may have no
|
|
49
|
+
// name.
|
|
50
|
+
const sourceLabel = note.name ?? strings.unnamedNote;
|
|
51
|
+
// `#^id` addresses a BLOCK and `#Section` a heading. The two are told apart
|
|
52
|
+
// by the marker and not by trying one and falling back to the other, or a
|
|
53
|
+
// section named `^x` and a block called `x` would answer for each other.
|
|
54
|
+
const cut = !anchor ? whole : isBlockAnchor(anchor) ? blockOf(whole, anchor.slice(1)) : sectionOf(whole, anchor);
|
|
55
|
+
const caption = anchor ? `${sourceLabel} › ${anchor}` : sourceLabel;
|
|
56
|
+
return (_jsxs("figure", { className: "embed", children: [_jsx("div", { className: "embed-body", children: cut === null ? (_jsx("p", { className: "embed-missing", children: strings.embedSectionMissing(anchor ?? '') })) : (
|
|
57
|
+
/**
|
|
58
|
+
* Resolved, like every other run of the note. Without this every
|
|
59
|
+
* `[[link]]` inside transcluded content reached the page with its
|
|
60
|
+
* brackets — and the sharpest case is the one the one-level rule
|
|
61
|
+
* creates itself: `demoteEmbeds` turns an embed found in embedded
|
|
62
|
+
* content into a wikilink, and that embed is drawn as A LINK to its
|
|
63
|
+
* target. Raw text is not a link.
|
|
64
|
+
*/
|
|
65
|
+
_jsx(Markdown, { children: resolveWikilinks(demoteEmbeds(cut, host.keeps), (each) => wikilinkAddress(host, each), host.keeps) })) }), _jsx("figcaption", { className: "embed-source", children: note.address === null ? caption : host.renderLink({ href: note.address, children: caption }) })] }));
|
|
66
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which addresses the reading surface will follow.
|
|
3
|
+
*
|
|
4
|
+
* `react-markdown` normally passes every URL through a filter that keeps the
|
|
5
|
+
* safe schemes and drops the rest, and the reading page once had it **switched
|
|
6
|
+
* off** — `urlTransform={(url) => url}`. There was a reason: an unresolved
|
|
7
|
+
* wikilink is rendered as `[text](pending:target)`, and the stock filter does
|
|
8
|
+
* not know `pending:`, so it erased the pending link from the page. A whole
|
|
9
|
+
* protection was traded for one scheme.
|
|
10
|
+
*
|
|
11
|
+
* This is that trade undone. The list says what passes, never what is
|
|
12
|
+
* refused, because a notebook is written by several people and by agents, and a
|
|
13
|
+
* denylist is a promise to have thought of everything.
|
|
14
|
+
*
|
|
15
|
+
* **What passes, and why:**
|
|
16
|
+
*
|
|
17
|
+
* - `pending:` — ours, and the reason the filter was off in the first place;
|
|
18
|
+
* and `attachment:` and `choice:`, ours too.
|
|
19
|
+
* - A path of the application (`/`), a relative one (`./`, `../`) and an
|
|
20
|
+
* anchor of the page (`#`).
|
|
21
|
+
* - `http:` and `https:` — the web, which is what a note cites.
|
|
22
|
+
* - `mailto:` — a person, which is what a note cites next most often.
|
|
23
|
+
*
|
|
24
|
+
* **What does not, and why each one:**
|
|
25
|
+
*
|
|
26
|
+
* - **`obsidian://`, and any other scheme of an editor. Decided, not
|
|
27
|
+
* overlooked.** It opens the notebook of whoever has that editor installed and
|
|
28
|
+
* that notebook on that machine, so the link works for the person who wrote it
|
|
29
|
+
* and does nothing at all for everybody else who opens the note — a promise
|
|
30
|
+
* the host cannot keep on behalf of a tool it does not run. A notebook is
|
|
31
|
+
* served to whoever reads it, and an address only its author can follow is
|
|
32
|
+
* not an address.
|
|
33
|
+
* - `data:` — a whole page carried inside the address. `data:text/html` is a
|
|
34
|
+
* script running in the reader's session, offered by whoever wrote the note.
|
|
35
|
+
* - `javascript:` and `vbscript:` — script written where an address goes.
|
|
36
|
+
* React blocks the first one on its own; that it does is not a reason to
|
|
37
|
+
* hand it the chance.
|
|
38
|
+
* - `file:` — the disk of whoever is reading, addressed by somebody else.
|
|
39
|
+
*
|
|
40
|
+
* A refused address is not silently swallowed: the text stays on the page and
|
|
41
|
+
* stops being clickable, which is what the anchor of the surface does with an
|
|
42
|
+
* empty href. An author sees they got nothing, the same answer the
|
|
43
|
+
* specification gives to a notation it does not implement.
|
|
44
|
+
*/
|
|
45
|
+
/**
|
|
46
|
+
* The address as it will be written, or the empty string when this surface
|
|
47
|
+
* will not follow it.
|
|
48
|
+
*
|
|
49
|
+
* An address with no scheme is a path, an anchor or a relative target, and it
|
|
50
|
+
* cannot reach outside the page on its own: those pass untouched, which is
|
|
51
|
+
* what keeps `./note.md`, `/v/a-notebook/note/x` and `#a-heading` working.
|
|
52
|
+
*/
|
|
53
|
+
export declare function followable(url: string): string;
|