@memorysmithapp/markdown-renderer 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +37 -2
- package/dist/CopyButton.d.ts +27 -0
- package/dist/CopyButton.js +69 -0
- package/dist/LinkChoice.d.ts +28 -0
- package/dist/LinkChoice.js +181 -0
- package/dist/Markdown.d.ts +20 -0
- package/dist/Markdown.js +261 -0
- package/dist/MermaidDiagram.d.ts +16 -0
- package/dist/MermaidDiagram.js +70 -0
- package/dist/NoteBody.d.ts +15 -0
- package/dist/NoteBody.js +79 -0
- package/dist/Transclusion.d.ts +11 -0
- package/dist/Transclusion.js +66 -0
- package/dist/address.d.ts +53 -0
- package/dist/address.js +69 -0
- package/dist/delegated.d.ts +39 -0
- package/dist/delegated.js +63 -0
- package/dist/highlight.d.ts +54 -0
- package/dist/highlight.js +96 -0
- package/dist/host.d.ts +135 -0
- package/dist/host.js +49 -0
- package/dist/image-dimensions.d.ts +33 -0
- package/dist/image-dimensions.js +42 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/markdown.d.ts +68 -0
- package/dist/markdown.js +395 -0
- package/dist/remark-callouts.d.ts +40 -0
- package/dist/remark-callouts.js +108 -0
- package/dist/remark-ring.d.ts +68 -0
- package/dist/remark-ring.js +178 -0
- package/dist/strings.d.ts +55 -0
- package/dist/strings.js +64 -0
- package/dist/tasklist.d.ts +25 -0
- package/dist/tasklist.js +80 -0
- package/dist/transclusion.d.ts +76 -0
- package/dist/transclusion.js +161 -0
- package/dist/wikilink-address.d.ts +8 -0
- package/dist/wikilink-address.js +13 -0
- package/package.json +73 -3
- package/styles.css +865 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 memorysmithapp
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,38 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @memorysmithapp/markdown-renderer
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A React component that draws the body of a MemorySmith note exactly as MemorySmith's reading page does: callouts, wikilinks, embedded notes, task boxes, code with highlighting and diagrams, mathematics, footnotes — the notation of the [MemorySmith Markdown Specification](https://www.npmjs.com/package/@memorysmithapp/markdown-spec).
|
|
4
|
+
|
|
5
|
+
The package knows no API, no router and no i18n library. Everything it needs from the notebook a note lives in reaches it as props, and it never rewrites the note: ticking a task box reports which box, and the host applies it.
|
|
6
|
+
|
|
7
|
+
## Use
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install @memorysmithapp/markdown-renderer react react-dom katex
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
import { NoteBody, toggleTaskAt } from '@memorysmithapp/markdown-renderer';
|
|
15
|
+
import '@memorysmithapp/markdown-renderer/styles.css';
|
|
16
|
+
import 'katex/dist/katex.min.css';
|
|
17
|
+
|
|
18
|
+
<NoteBody
|
|
19
|
+
markdown={note} // the note as you keep it, frontmatter included
|
|
20
|
+
resolve={(name) => addressOfTheOneNoteNamed(name)} // or null: the link is pending
|
|
21
|
+
keeps={(name) => notebookKeepsAFile(name)} // only an embed reaches a file
|
|
22
|
+
renderAttachment={({ name, width, height }) => <img src={urlOf(name)} width={width ?? undefined} />}
|
|
23
|
+
renderLink={({ href, className, children }) => <RouterLink to={href} className={className}>{children}</RouterLink>}
|
|
24
|
+
loadNote={(name) => fetchTheBodyOf(name)} // for an embedded note; the package never fetches
|
|
25
|
+
writable
|
|
26
|
+
onToggleTask={(ordinal) => save(toggleTaskAt(note, ordinal))}
|
|
27
|
+
theme="dark"
|
|
28
|
+
strings={{ footnotes: 'Notas' }} // every word left out is MemorySmith's English
|
|
29
|
+
/>;
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Every prop is optional, and every default is the safe one: with no `resolve` every link is pending, with no `keeps` no embed reaches a file. The stylesheet styles nothing outside `.markdown`; theme it with the `--markdown-*` custom properties. The faces are Inter and Space Grotesk, which the page loads.
|
|
33
|
+
|
|
34
|
+
The host contract, the stylesheet's properties and the decisions behind them are in the repository's [`ARCHITECTURE.md`](https://github.com/memorysmithapp/memorysmithapp-markdown/blob/main/ARCHITECTURE.md).
|
|
35
|
+
|
|
36
|
+
## Licence
|
|
37
|
+
|
|
38
|
+
[MIT](LICENSE).
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copying to the clipboard, in the one way that works everywhere a note is
|
|
3
|
+
* opened.
|
|
4
|
+
*
|
|
5
|
+
* The async clipboard API can stay pending forever in embedded or automated
|
|
6
|
+
* contexts, so the promise is raced against a short timeout and the legacy
|
|
7
|
+
* path takes over when it does not settle.
|
|
8
|
+
*/
|
|
9
|
+
export declare function copyText(text: string): Promise<void>;
|
|
10
|
+
/**
|
|
11
|
+
* The verb beside a block of code, which carries it somewhere else. It says
|
|
12
|
+
* *Copied* with the check for two seconds and goes back.
|
|
13
|
+
*
|
|
14
|
+
* The word is dropped at phone width and the icon stays, so the label lives in
|
|
15
|
+
* `aria-label` and `title` too: whoever hears the page still hears *Copy*.
|
|
16
|
+
*
|
|
17
|
+
* The text is asked for at the moment of the click, because a block is copied
|
|
18
|
+
* from what is on the page and not from a string held since it was drawn.
|
|
19
|
+
*/
|
|
20
|
+
export declare function CopyButton({ text, label, done, className, }: {
|
|
21
|
+
text: () => string;
|
|
22
|
+
/** The verb, in the reader's language. */
|
|
23
|
+
label: string;
|
|
24
|
+
/** What it says once the text is copied. */
|
|
25
|
+
done: string;
|
|
26
|
+
className?: string;
|
|
27
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
/**
|
|
4
|
+
* Copying to the clipboard, in the one way that works everywhere a note is
|
|
5
|
+
* opened.
|
|
6
|
+
*
|
|
7
|
+
* The async clipboard API can stay pending forever in embedded or automated
|
|
8
|
+
* contexts, so the promise is raced against a short timeout and the legacy
|
|
9
|
+
* path takes over when it does not settle.
|
|
10
|
+
*/
|
|
11
|
+
export async function copyText(text) {
|
|
12
|
+
const viaApi = navigator.clipboard
|
|
13
|
+
?.writeText(text)
|
|
14
|
+
.then(() => true)
|
|
15
|
+
.catch(() => false);
|
|
16
|
+
const done = await Promise.race([
|
|
17
|
+
viaApi ?? Promise.resolve(false),
|
|
18
|
+
new Promise((resolve) => setTimeout(() => resolve(false), 350)),
|
|
19
|
+
]);
|
|
20
|
+
if (done)
|
|
21
|
+
return;
|
|
22
|
+
const scratch = document.createElement('textarea');
|
|
23
|
+
scratch.value = text;
|
|
24
|
+
scratch.style.position = 'fixed';
|
|
25
|
+
scratch.style.opacity = '0';
|
|
26
|
+
document.body.appendChild(scratch);
|
|
27
|
+
scratch.select();
|
|
28
|
+
document.execCommand('copy');
|
|
29
|
+
scratch.remove();
|
|
30
|
+
}
|
|
31
|
+
const ICON = {
|
|
32
|
+
width: 16,
|
|
33
|
+
height: 16,
|
|
34
|
+
viewBox: '0 0 24 24',
|
|
35
|
+
fill: 'none',
|
|
36
|
+
stroke: 'currentColor',
|
|
37
|
+
strokeWidth: 2,
|
|
38
|
+
strokeLinecap: 'round',
|
|
39
|
+
strokeLinejoin: 'round',
|
|
40
|
+
'aria-hidden': true,
|
|
41
|
+
};
|
|
42
|
+
function CopyIcon() {
|
|
43
|
+
return (_jsxs("svg", { ...ICON, children: [_jsx("rect", { x: "9", y: "9", width: "13", height: "13", rx: "2" }), _jsx("path", { d: "M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" })] }));
|
|
44
|
+
}
|
|
45
|
+
function CheckIcon() {
|
|
46
|
+
return (_jsx("svg", { ...ICON, children: _jsx("path", { d: "M20 6 9 17l-5-5" }) }));
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The verb beside a block of code, which carries it somewhere else. It says
|
|
50
|
+
* *Copied* with the check for two seconds and goes back.
|
|
51
|
+
*
|
|
52
|
+
* The word is dropped at phone width and the icon stays, so the label lives in
|
|
53
|
+
* `aria-label` and `title` too: whoever hears the page still hears *Copy*.
|
|
54
|
+
*
|
|
55
|
+
* The text is asked for at the moment of the click, because a block is copied
|
|
56
|
+
* from what is on the page and not from a string held since it was drawn.
|
|
57
|
+
*/
|
|
58
|
+
export function CopyButton({ text, label, done, className = '', }) {
|
|
59
|
+
const [copied, setCopied] = useState(false);
|
|
60
|
+
const timer = useRef(undefined);
|
|
61
|
+
useEffect(() => () => clearTimeout(timer.current), []);
|
|
62
|
+
async function copy() {
|
|
63
|
+
await copyText(text());
|
|
64
|
+
setCopied(true);
|
|
65
|
+
clearTimeout(timer.current);
|
|
66
|
+
timer.current = setTimeout(() => setCopied(false), 2000);
|
|
67
|
+
}
|
|
68
|
+
return (_jsxs("button", { type: "button", className: `button is-small copy-action ${className}`.trim(), onClick: () => void copy(), "aria-label": label, title: copied ? done : label, children: [copied ? _jsx(CheckIcon, {}) : _jsx(CopyIcon, {}), _jsx("span", { className: "copy-action-label", children: copied ? done : label })] }));
|
|
69
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* The address a wikilink carries when no single note answers it and the host
|
|
4
|
+
* can say which notes might: `choice:` and the encoded name. It never leaves
|
|
5
|
+
* the page — the anchor of the surface turns it into a `LinkChoice` — and it
|
|
6
|
+
* exists so the decision is made once, where the wikilink is resolved.
|
|
7
|
+
*/
|
|
8
|
+
export declare const CHOICE_SCHEME = "choice:";
|
|
9
|
+
/** The name a choice address carries, or `null`. */
|
|
10
|
+
export declare function choiceTargetOf(href: string): string | null;
|
|
11
|
+
export declare function shortened(description: string, limit?: number): string;
|
|
12
|
+
/**
|
|
13
|
+
* A wikilink that no single note answers by name, and the choice it opens.
|
|
14
|
+
*
|
|
15
|
+
* Two cases reach here. A name several notes carry is a choice, told apart by
|
|
16
|
+
* the folder each note lives in. A name no note carries is drawn as pending and
|
|
17
|
+
* still asks, because an alias may answer it with one note, which opens, or
|
|
18
|
+
* with several, which is the choice. The host answers both, through `choose`.
|
|
19
|
+
*
|
|
20
|
+
* **The choice is a DIALOG over the page, and it has to be.** A menu
|
|
21
|
+
* positioned inside whatever held the link was clipped by the scroll box of a
|
|
22
|
+
* table, which every table of a note has. `showModal()` puts the dialog in the
|
|
23
|
+
* top layer, which no ancestor can clip, and brings the focus handling with it.
|
|
24
|
+
*/
|
|
25
|
+
export declare function LinkChoice({ target, children }: {
|
|
26
|
+
target: string;
|
|
27
|
+
children: ReactNode;
|
|
28
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { useHost } from './host.js';
|
|
4
|
+
/**
|
|
5
|
+
* The address a wikilink carries when no single note answers it and the host
|
|
6
|
+
* can say which notes might: `choice:` and the encoded name. It never leaves
|
|
7
|
+
* the page — the anchor of the surface turns it into a `LinkChoice` — and it
|
|
8
|
+
* exists so the decision is made once, where the wikilink is resolved.
|
|
9
|
+
*/
|
|
10
|
+
export const CHOICE_SCHEME = 'choice:';
|
|
11
|
+
/** The name a choice address carries, or `null`. */
|
|
12
|
+
export function choiceTargetOf(href) {
|
|
13
|
+
if (!href.startsWith(CHOICE_SCHEME))
|
|
14
|
+
return null;
|
|
15
|
+
try {
|
|
16
|
+
return decodeURIComponent(href.slice(CHOICE_SCHEME.length)).normalize('NFC');
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* How long a click may resolve before the dialog opens on placeholder rows.
|
|
24
|
+
* Under it the dialog opens once, with its content; over it the click would
|
|
25
|
+
* otherwise look ignored.
|
|
26
|
+
*/
|
|
27
|
+
const PATIENCE_MS = 150;
|
|
28
|
+
/**
|
|
29
|
+
* How long a description may be on a row of a choice. It is a folder
|
|
30
|
+
* description, written to be read whole somewhere else; here it is a hint
|
|
31
|
+
* under a path, and three rows of prose would bury the paths they were
|
|
32
|
+
* supposed to tell apart. Cut on a word, never mid-word, and say that it was
|
|
33
|
+
* cut.
|
|
34
|
+
*/
|
|
35
|
+
const DESCRIPTION_LIMIT = 120;
|
|
36
|
+
export function shortened(description, limit = DESCRIPTION_LIMIT) {
|
|
37
|
+
const text = description.trim().replace(/\s+/g, ' ');
|
|
38
|
+
if (text.length <= limit)
|
|
39
|
+
return text;
|
|
40
|
+
const cut = text.slice(0, limit);
|
|
41
|
+
const lastSpace = cut.lastIndexOf(' ');
|
|
42
|
+
return `${(lastSpace > limit * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A wikilink that no single note answers by name, and the choice it opens.
|
|
46
|
+
*
|
|
47
|
+
* Two cases reach here. A name several notes carry is a choice, told apart by
|
|
48
|
+
* the folder each note lives in. A name no note carries is drawn as pending and
|
|
49
|
+
* still asks, because an alias may answer it with one note, which opens, or
|
|
50
|
+
* with several, which is the choice. The host answers both, through `choose`.
|
|
51
|
+
*
|
|
52
|
+
* **The choice is a DIALOG over the page, and it has to be.** A menu
|
|
53
|
+
* positioned inside whatever held the link was clipped by the scroll box of a
|
|
54
|
+
* table, which every table of a note has. `showModal()` puts the dialog in the
|
|
55
|
+
* top layer, which no ancestor can clip, and brings the focus handling with it.
|
|
56
|
+
*/
|
|
57
|
+
export function LinkChoice({ target, children }) {
|
|
58
|
+
const host = useHost();
|
|
59
|
+
const { strings } = host;
|
|
60
|
+
const [asked, setAsked] = useState(false);
|
|
61
|
+
const [answer, setAnswer] = useState({ state: 'loading' });
|
|
62
|
+
const [patient, setPatient] = useState(false);
|
|
63
|
+
const anchor = useRef(null);
|
|
64
|
+
const dialog = useRef(null);
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!asked || !host.choose)
|
|
67
|
+
return;
|
|
68
|
+
let alive = true;
|
|
69
|
+
setAnswer({ state: 'loading' });
|
|
70
|
+
host.choose(target).then((choices) => alive && setAnswer({ state: 'ready', choices }), () => alive && setAnswer({ state: 'error' }));
|
|
71
|
+
return () => {
|
|
72
|
+
alive = false;
|
|
73
|
+
};
|
|
74
|
+
}, [asked, host, target]);
|
|
75
|
+
const resolving = asked && answer.state === 'loading';
|
|
76
|
+
// No dialog that flashes: the click marks the link as loading, and the
|
|
77
|
+
// dialog opens either with its content or, past a moment, on placeholders.
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!resolving)
|
|
80
|
+
return;
|
|
81
|
+
const timer = setTimeout(() => setPatient(true), PATIENCE_MS);
|
|
82
|
+
return () => clearTimeout(timer);
|
|
83
|
+
}, [resolving]);
|
|
84
|
+
// One note answers, which only an alias can do here: the choice is a step
|
|
85
|
+
// nobody asked for, so the click goes straight to the note.
|
|
86
|
+
const notes = answer.state === 'ready' ? answer.choices.notes : [];
|
|
87
|
+
const only = notes.length === 1 ? notes[0] : undefined;
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (asked && only) {
|
|
90
|
+
setAsked(false);
|
|
91
|
+
setPatient(false);
|
|
92
|
+
host.navigate(only.address);
|
|
93
|
+
}
|
|
94
|
+
}, [asked, only, host]);
|
|
95
|
+
const open = asked && !only && (patient || !resolving);
|
|
96
|
+
// The dialog is modal or it is nothing: `open` as an attribute renders it
|
|
97
|
+
// inline, inside the scroll box this exists to escape.
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
const node = dialog.current;
|
|
100
|
+
if (!node || !open)
|
|
101
|
+
return;
|
|
102
|
+
if (!node.open)
|
|
103
|
+
node.showModal?.();
|
|
104
|
+
}, [open]);
|
|
105
|
+
// The first option takes the focus, and not the control that closes.
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (!open || resolving)
|
|
108
|
+
return;
|
|
109
|
+
dialog.current?.querySelector('.link-choice-option')?.focus();
|
|
110
|
+
}, [open, resolving, answer]);
|
|
111
|
+
// Pending is what reaches no note, by its name or by a spelling one of them
|
|
112
|
+
// declares.
|
|
113
|
+
const pending = host.reaches(target) === 0;
|
|
114
|
+
const address = host.targetAddress(target);
|
|
115
|
+
const ask = (event) => {
|
|
116
|
+
// A modifier or a middle click keeps the ordinary behaviour of a link,
|
|
117
|
+
// when the host gave the link an address to keep it for.
|
|
118
|
+
if (address && (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0))
|
|
119
|
+
return;
|
|
120
|
+
event.preventDefault();
|
|
121
|
+
setAsked(true);
|
|
122
|
+
};
|
|
123
|
+
const close = () => {
|
|
124
|
+
dialog.current?.close?.();
|
|
125
|
+
setAsked(false);
|
|
126
|
+
setPatient(false);
|
|
127
|
+
anchor.current?.focus();
|
|
128
|
+
};
|
|
129
|
+
return (_jsxs("span", { className: "link-choice", children: [_jsx("a", { ref: anchor, href: address ?? '#', className: pending ? 'wikilink-pending' : 'wikilink', title: pending ? strings.pendingLink : undefined, "aria-haspopup": "dialog", "aria-expanded": open, "aria-busy": resolving && !open, onClick: ask, children: children }), open && (_jsx("dialog", { ref: dialog, className: "link-choice-dialog", "aria-label": strings.linkChoice(target),
|
|
130
|
+
// Esc fires `cancel`, and a click on the backdrop lands on the
|
|
131
|
+
// dialog element itself, never on the card inside it.
|
|
132
|
+
onCancel: (event) => {
|
|
133
|
+
event.preventDefault();
|
|
134
|
+
close();
|
|
135
|
+
}, onClick: (event) => {
|
|
136
|
+
if (event.target === dialog.current)
|
|
137
|
+
close();
|
|
138
|
+
}, children: _jsxs("div", { className: "link-choice-card", children: [_jsxs("header", { className: "link-choice-header", children: [_jsx("h2", { children: pending ? strings.linkLeadsNowhere : strings.linkLeadsToSeveral }), _jsxs("button", { type: "button", className: "link-choice-close", onClick: close, children: [_jsx("span", { "aria-hidden": "true", children: "\u2715" }), _jsx("span", { className: "visually-hidden", children: strings.close })] })] }), _jsx(LinkChoiceContent, { target: target, answer: answer, onPick: (picked) => {
|
|
139
|
+
close();
|
|
140
|
+
host.navigate(picked);
|
|
141
|
+
} }), _jsx("footer", { className: "link-choice-footer", children: _jsx("button", { type: "button", className: "link-choice-cancel", onClick: close, children: strings.cancel }) })] }) }))] }));
|
|
142
|
+
}
|
|
143
|
+
/** Three rows of nothing, so a click never looks ignored while it resolves. */
|
|
144
|
+
function Placeholders() {
|
|
145
|
+
return (_jsx("ul", { className: "link-choice-options", "aria-hidden": "true", children: [0, 1].map((row) => (_jsx("li", { children: _jsx("span", { className: "link-choice-option is-placeholder" }) }, row))) }));
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* What a link to more than one note has to say.
|
|
149
|
+
*
|
|
150
|
+
* **What leads an option depends on what matched.** Matched by name, every
|
|
151
|
+
* option carries the same name, so repeating it is noise: the folder trail
|
|
152
|
+
* leads, and the name is said once, in the explanation. Matched by alias, the
|
|
153
|
+
* names differ, so each option leads with its name and carries the trail under
|
|
154
|
+
* it.
|
|
155
|
+
*/
|
|
156
|
+
function LinkChoiceContent({ target, answer, onPick, }) {
|
|
157
|
+
const { strings } = useHost();
|
|
158
|
+
if (answer.state === 'loading') {
|
|
159
|
+
return (_jsxs("div", { className: "link-choice-body", "aria-busy": "true", children: [_jsx("p", { className: "link-choice-explanation", children: strings.loading }), _jsx(Placeholders, {})] }));
|
|
160
|
+
}
|
|
161
|
+
if (answer.state === 'error') {
|
|
162
|
+
return (_jsx("div", { className: "link-choice-body", children: _jsx("p", { className: "link-choice-explanation", children: strings.failed }) }));
|
|
163
|
+
}
|
|
164
|
+
const { by, notes } = answer.choices;
|
|
165
|
+
if (notes.length === 0) {
|
|
166
|
+
return (_jsx("div", { className: "link-choice-body", children: _jsx("p", { className: "link-choice-explanation", children: strings.targetPending(target) }) }));
|
|
167
|
+
}
|
|
168
|
+
const byAlias = by === 'alias';
|
|
169
|
+
/**
|
|
170
|
+
* The path, written the way a path is written: an address of a place a note
|
|
171
|
+
* is IN, the root included, as `/`.
|
|
172
|
+
*/
|
|
173
|
+
const trailOf = (note) => (note.trail.length === 0 ? '/' : `/${note.trail.join('/')}`);
|
|
174
|
+
const pick = (address) => (event) => {
|
|
175
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0)
|
|
176
|
+
return;
|
|
177
|
+
event.preventDefault();
|
|
178
|
+
onPick(address);
|
|
179
|
+
};
|
|
180
|
+
return (_jsxs("div", { className: "link-choice-body", children: [_jsx("p", { className: "link-choice-explanation", children: byAlias ? strings.targetAliased(target, notes.length) : strings.targetNamed(target, notes.length) }), _jsx("ul", { className: "link-choice-options", children: notes.map((note) => (_jsx("li", { children: _jsxs("a", { className: "link-choice-option", href: note.address, onClick: pick(note.address), children: [_jsx("span", { className: "link-choice-lead", children: byAlias ? note.name || strings.unnamedNote : trailOf(note) }), byAlias && _jsx("span", { className: "link-choice-under", children: trailOf(note) }), note.folderDescription.trim().length > 0 && (_jsx("span", { className: "link-choice-about", children: shortened(note.folderDescription) }))] }) }, note.address))) })] }));
|
|
181
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One run of a note, parsed and drawn: the pipeline of MemorySmith's reading page.
|
|
3
|
+
*
|
|
4
|
+
* It draws the text it is given and nothing else. What a note becomes before this — its
|
|
5
|
+
* frontmatter cut, its embeds split out, its wikilinks resolved — is `NoteBody`'s.
|
|
6
|
+
*/
|
|
7
|
+
export interface MarkdownProps {
|
|
8
|
+
readonly children: string;
|
|
9
|
+
/**
|
|
10
|
+
* The text the ordinal of a task box is counted over. It has to be the text
|
|
11
|
+
* the PARSER saw, because the offsets come from it — not the bytes a write
|
|
12
|
+
* sends back, which are the original ones. Keeping the two aligned is the
|
|
13
|
+
* caller's job, and `NoteBody` does it by refusing to enable the boxes when
|
|
14
|
+
* the two disagree on how many there are.
|
|
15
|
+
*/
|
|
16
|
+
readonly source?: string;
|
|
17
|
+
readonly onToggleTask?: (ordinal: number) => void;
|
|
18
|
+
readonly writable?: boolean;
|
|
19
|
+
}
|
|
20
|
+
export declare function Markdown({ children, source, onToggleTask, writable }: MarkdownProps): import("react").JSX.Element;
|
package/dist/Markdown.js
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
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 { ordinalAt } from './tasklist.js';
|
|
20
|
+
/**
|
|
21
|
+
* An image, with the dimensions the specification puts in its alt text
|
|
22
|
+
* (§3.14). What precedes the pipe is the description and is never dropped;
|
|
23
|
+
* what follows it is width, or width and height, in CSS pixels, and never
|
|
24
|
+
* appears as text. A value that is neither stays part of the description,
|
|
25
|
+
* because deleting an accessibility label is the worse failure.
|
|
26
|
+
*/
|
|
27
|
+
function MarkdownImage({ alt, node: _node, ...rest }) {
|
|
28
|
+
const read = readImageAlt(alt ?? '');
|
|
29
|
+
return (_jsx("img", { ...rest, alt: read.description, ...(read.width === null ? {} : { width: read.width }), ...(read.height === null ? {} : { height: read.height }) }));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A link to a place in the same note: a footnote and its way back (§7.12),
|
|
33
|
+
* or an anchor an author wrote.
|
|
34
|
+
*
|
|
35
|
+
* It must not open the whole page in another tab, and a bare hash is not
|
|
36
|
+
* enough either: a host may scroll the note inside a pane of its own rather
|
|
37
|
+
* than the window, and an embedded note renders the same identifiers a second
|
|
38
|
+
* time. So the target is looked up inside the note the link was drawn in, and
|
|
39
|
+
* brought into view in whatever scrolls it; the address of the page does not
|
|
40
|
+
* change, because nothing was navigated.
|
|
41
|
+
*/
|
|
42
|
+
function InPageAnchor({ href, children, ...rest }) {
|
|
43
|
+
const follow = (event) => {
|
|
44
|
+
const id = decodeURIComponent(href.slice(1));
|
|
45
|
+
const note = event.currentTarget.closest('.markdown');
|
|
46
|
+
const target = note?.querySelector(`[id="${CSS.escape(id)}"]`);
|
|
47
|
+
if (!(target instanceof HTMLElement))
|
|
48
|
+
return;
|
|
49
|
+
event.preventDefault();
|
|
50
|
+
target.scrollIntoView({ block: 'center' });
|
|
51
|
+
};
|
|
52
|
+
return (_jsx("a", { href: href, onClick: follow, ...rest, children: children }));
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* A file the notebook keeps, where a note embedded it. The host draws it by its
|
|
56
|
+
* type — an image, audio, video, or a card — and a name the notebook keeps
|
|
57
|
+
* nothing under is reported the way a pending link is: a notebook is read most
|
|
58
|
+
* while it is being written (§5.8).
|
|
59
|
+
*/
|
|
60
|
+
function Attachment({ name, width, height }) {
|
|
61
|
+
const host = useHost();
|
|
62
|
+
if (!host.keeps(name)) {
|
|
63
|
+
return (_jsx("span", { className: "attachment-missing", title: host.strings.attachmentMissing, children: name }));
|
|
64
|
+
}
|
|
65
|
+
if (!host.renderAttachment)
|
|
66
|
+
return _jsx("span", { className: "attachment-loading", children: name });
|
|
67
|
+
return _jsx(_Fragment, { children: host.renderAttachment({ name, width, height }) });
|
|
68
|
+
}
|
|
69
|
+
function MarkdownAnchor({ href, children, node: _node, ...rest }) {
|
|
70
|
+
const host = useHost();
|
|
71
|
+
if (href?.startsWith('attachment:')) {
|
|
72
|
+
/**
|
|
73
|
+
* The address carries the dimension the pipe declared, after the encoded
|
|
74
|
+
* name, where nothing else can be (§3.14).
|
|
75
|
+
*
|
|
76
|
+
* **Decoded before it is split**, because the parser percent-encodes what
|
|
77
|
+
* it does not have to leave alone: the separator arrives as `%7C` and
|
|
78
|
+
* looking for `|` found nothing, which sent the whole string — dimension
|
|
79
|
+
* included — to be looked up as the name of a file. A name carrying a
|
|
80
|
+
* pipe is no name anyway (§5.3), so the last one is the separator or
|
|
81
|
+
* there is none.
|
|
82
|
+
*/
|
|
83
|
+
const address = decodeURIComponent(href.slice('attachment:'.length));
|
|
84
|
+
const at = address.lastIndexOf('|');
|
|
85
|
+
const measured = at === -1 ? null : readDimensions(address.slice(at + 1));
|
|
86
|
+
const name = measured ? address.slice(0, at) : address;
|
|
87
|
+
return _jsx(Attachment, { name: name, width: measured?.width ?? null, height: measured?.height ?? null });
|
|
88
|
+
}
|
|
89
|
+
if (href?.startsWith('pending:')) {
|
|
90
|
+
return (_jsx("span", { className: "wikilink-pending", title: host.strings.pendingLink, children: children }));
|
|
91
|
+
}
|
|
92
|
+
// Refused by `followable`: the text stays on the page and stops being
|
|
93
|
+
// something to click. An author sees they got nothing, which is the same
|
|
94
|
+
// answer the specification gives for a notation it does not implement.
|
|
95
|
+
if (!href) {
|
|
96
|
+
return (_jsx("span", { className: "link-refused", title: host.strings.refusedLink, children: children }));
|
|
97
|
+
}
|
|
98
|
+
// A target no single note answers by name opens its choice in place.
|
|
99
|
+
const choice = choiceTargetOf(href);
|
|
100
|
+
if (choice !== null)
|
|
101
|
+
return _jsx(LinkChoice, { target: choice, children: children });
|
|
102
|
+
// An address of the host's own: its router draws it.
|
|
103
|
+
if (href.startsWith('/'))
|
|
104
|
+
return _jsx(_Fragment, { children: host.renderLink({ href, className: 'wikilink', children }) });
|
|
105
|
+
if (href.startsWith('#')) {
|
|
106
|
+
return (_jsx(InPageAnchor, { href: href, ...rest, children: children }));
|
|
107
|
+
}
|
|
108
|
+
return (_jsx("a", { href: href, target: "_blank", rel: "noreferrer", ...rest, children: children }));
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* A code span, a fenced block, and the one info string with a rendering rule.
|
|
112
|
+
*
|
|
113
|
+
* The order of the three branches is the contract. `mermaid` is checked first
|
|
114
|
+
* and reaches `MermaidDiagram` with its source untouched, because it is the
|
|
115
|
+
* only info string the specification attaches a rendering rule to (§7.2) and a
|
|
116
|
+
* highlighter in front of that check would swallow a diagram. A language the
|
|
117
|
+
* highlighter carries is tokenised. Everything else — a code span, a fence
|
|
118
|
+
* with no info string, a language nobody registered — renders exactly as it
|
|
119
|
+
* did before highlighting existed, which is the pattern the specification sets
|
|
120
|
+
* for the diagram it cannot draw and the formula it cannot typeset: show the
|
|
121
|
+
* source, never hide it.
|
|
122
|
+
*
|
|
123
|
+
* The tokens arrive as ELEMENTS. `refractor` produces a hast tree and
|
|
124
|
+
* `toJsxRuntime` builds React out of it, so no note-derived markup is ever
|
|
125
|
+
* parsed as HTML — the boundary §7.9 draws, held at the one place a
|
|
126
|
+
* highlighter would otherwise breach it.
|
|
127
|
+
*/
|
|
128
|
+
function MarkdownCode({ className, children, node: _node, ...rest }) {
|
|
129
|
+
const { theme } = useHost();
|
|
130
|
+
if (className?.includes('language-mermaid')) {
|
|
131
|
+
return _jsx(MermaidDiagram, { code: String(children).trim(), theme: theme });
|
|
132
|
+
}
|
|
133
|
+
const language = highlightLanguage(className);
|
|
134
|
+
if (language !== null) {
|
|
135
|
+
// The source is read and never rewritten: what is on the page is the
|
|
136
|
+
// bytes, split into spans. React-markdown hands the children of a fence as
|
|
137
|
+
// a single string ending in the newline before the closing fence.
|
|
138
|
+
const code = String(children).replace(/\n$/, '');
|
|
139
|
+
return (_jsx("code", { className: className, ...rest, children: toJsxRuntime(highlightTree(code, language), { Fragment, jsx, jsxs }) }));
|
|
140
|
+
}
|
|
141
|
+
return (_jsx("code", { className: className, ...rest, children: children }));
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* A fenced block in a frame of its own, with the verb that carries it
|
|
145
|
+
* somewhere else. The frame is not the `pre`: the `pre` scrolls and never
|
|
146
|
+
* wraps, since a wrapped line breaks indentation that carries meaning, and the
|
|
147
|
+
* button has to stay in its corner while the content scrolls under it.
|
|
148
|
+
*
|
|
149
|
+
* What is copied is the `textContent` of the code, which is the bytes as
|
|
150
|
+
* typed because the highlighter's tokens arrive as elements, less the newline
|
|
151
|
+
* before the closing fence.
|
|
152
|
+
*
|
|
153
|
+
* A diagram is not code on the page: its fence is left bare, with no frame and
|
|
154
|
+
* nothing to copy.
|
|
155
|
+
*/
|
|
156
|
+
function MarkdownPre({ children, node, ...rest }) {
|
|
157
|
+
const { strings } = useHost();
|
|
158
|
+
const ref = useRef(null);
|
|
159
|
+
const code = node?.children?.find((child) => child.tagName === 'code');
|
|
160
|
+
const classes = code?.properties?.['className'];
|
|
161
|
+
if (Array.isArray(classes) && classes.includes('language-mermaid')) {
|
|
162
|
+
return _jsx("pre", { ...rest, children: children });
|
|
163
|
+
}
|
|
164
|
+
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$/, '') })] }));
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Whether this list item is a task box, and how it is ticked.
|
|
168
|
+
*
|
|
169
|
+
* The state is read from the child `input` that GFM produces, and NOT from a
|
|
170
|
+
* `checked` on the item itself: react-markdown 9 hands a component the **hast**
|
|
171
|
+
* element, which carries no such property. Reading it there returned
|
|
172
|
+
* `undefined` for every item, which once sent every task box of MemorySmith
|
|
173
|
+
* down the plain branch and onto the screen as GFM's own disabled checkbox —
|
|
174
|
+
* no handler, no click, on any note.
|
|
175
|
+
*/
|
|
176
|
+
function taskState(node) {
|
|
177
|
+
const box = node?.children?.find((child) => child.tagName === 'input');
|
|
178
|
+
const checked = box?.properties?.['checked'];
|
|
179
|
+
return typeof checked === 'boolean' ? checked : null;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* A list item that carries a task box. The ordinal comes from the position the
|
|
183
|
+
* parser reports, mapped back to the n-th task of the source, which is what
|
|
184
|
+
* lets a click become a one-character change in the original text — made by
|
|
185
|
+
* the host, never by the package.
|
|
186
|
+
*/
|
|
187
|
+
function TaskItem({ node, children, className, source, toggleTask, writable, ...rest }) {
|
|
188
|
+
const element = node;
|
|
189
|
+
const checked = taskState(element);
|
|
190
|
+
if (checked === null) {
|
|
191
|
+
return (_jsx("li", { className: className, ...rest, children: children }));
|
|
192
|
+
}
|
|
193
|
+
const offset = element?.position?.start?.offset;
|
|
194
|
+
const ordinal = offset === undefined ? -1 : ordinalAt(source, offset);
|
|
195
|
+
const enabled = writable && toggleTask !== undefined && ordinal >= 0;
|
|
196
|
+
return (
|
|
197
|
+
// Our class comes LAST, because the one react-markdown passes in is on
|
|
198
|
+
// `className` and spreading it after would silently take the item back.
|
|
199
|
+
_jsxs("li", { ...rest, className: `${className ?? ''} task-item`.trim(), children: [_jsx("input", { type: "checkbox", checked: checked, disabled: !enabled, onChange: () => enabled && toggleTask(ordinal) }), dropBox(children)] }));
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* The children of the item without the checkbox GFM put in front of them.
|
|
203
|
+
*
|
|
204
|
+
* It is matched by element type rather than by position, because a leading
|
|
205
|
+
* space follows it and neither the space nor the order is part of any contract.
|
|
206
|
+
*/
|
|
207
|
+
function dropBox(children) {
|
|
208
|
+
if (!Array.isArray(children))
|
|
209
|
+
return children;
|
|
210
|
+
return children.filter((child) => !(isValidElement(child) && child.type === 'input'));
|
|
211
|
+
}
|
|
212
|
+
export function Markdown({ children, source, onToggleTask, writable = false }) {
|
|
213
|
+
const text = toUnixNewlines(children);
|
|
214
|
+
const { strings } = useHost();
|
|
215
|
+
const calloutTitle = (kind) => Object.hasOwn(strings.callouts, kind) ? strings.callouts[kind] : undefined;
|
|
216
|
+
return (_jsx("div", { className: "markdown", children: _jsx(ReactMarkdown, { remarkPlugins: [
|
|
217
|
+
/**
|
|
218
|
+
* `singleTilde: false` is a decision and not a default.
|
|
219
|
+
*
|
|
220
|
+
* GFM specifies strikethrough as `~~x~~`; GitHub also accepts a
|
|
221
|
+
* single tilde, outside its own specification. Here that extension
|
|
222
|
+
* actively misrenders a notation the specification leaves out:
|
|
223
|
+
* somebody writing `H~2~O` for a subscript would get `H` struck-out
|
|
224
|
+
* `2` `O`, which is a worse answer than nothing. With it off the
|
|
225
|
+
* characters stay on the page, and the author can see they got what
|
|
226
|
+
* the specification says they get, which is nothing.
|
|
227
|
+
*/
|
|
228
|
+
[remarkGfm, { singleTilde: false }],
|
|
229
|
+
[remarkCallouts, { titleOf: calloutTitle }],
|
|
230
|
+
remarkHighlight,
|
|
231
|
+
remarkComments,
|
|
232
|
+
remarkBlockIds,
|
|
233
|
+
remarkMath,
|
|
234
|
+
// After remark-math, and it gives back what was never a formula.
|
|
235
|
+
remarkMathDollarRule,
|
|
236
|
+
],
|
|
237
|
+
// Raw HTML is NOT enabled, and its absence is the point: no
|
|
238
|
+
// `rehype-raw` is loaded, so a note carrying `<script>` is text. It is
|
|
239
|
+
// a security boundary rather than a rendering preference, because a
|
|
240
|
+
// notebook is written by several people and by agents (§7.9).
|
|
241
|
+
// A formula KaTeX cannot typeset shows its source, never an error (§7.8).
|
|
242
|
+
rehypePlugins: [[rehypeKatex, { throwOnError: false }]],
|
|
243
|
+
// Footnotes (§7.12): the words around them are the reader's, and the
|
|
244
|
+
// label heads the notes gathered at the end of the note, where a
|
|
245
|
+
// reader sees it — GFM hides it, and a stylesheet that hides nothing
|
|
246
|
+
// shows it as a visible heading, so it is in the reader's language.
|
|
247
|
+
remarkRehypeOptions: {
|
|
248
|
+
footnoteLabel: strings.footnotes,
|
|
249
|
+
footnoteLabelProperties: { className: ['footnotes-label'] },
|
|
250
|
+
footnoteBackLabel: (referenceIndex) => strings.footnoteBack(referenceIndex + 1),
|
|
251
|
+
},
|
|
252
|
+
// Which addresses this surface will follow, and the reason the stock
|
|
253
|
+
// filter is not doing it, are in `address.ts`.
|
|
254
|
+
urlTransform: followable, components: {
|
|
255
|
+
a: MarkdownAnchor,
|
|
256
|
+
code: MarkdownCode,
|
|
257
|
+
pre: MarkdownPre,
|
|
258
|
+
img: MarkdownImage,
|
|
259
|
+
li: (props) => (_jsx(TaskItem, { ...props, source: source ?? text, toggleTask: onToggleTask, writable: writable })),
|
|
260
|
+
}, children: text }) }));
|
|
261
|
+
}
|