@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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +37 -2
  3. package/dist/CopyButton.d.ts +27 -0
  4. package/dist/CopyButton.js +69 -0
  5. package/dist/LinkChoice.d.ts +28 -0
  6. package/dist/LinkChoice.js +181 -0
  7. package/dist/Markdown.d.ts +35 -0
  8. package/dist/Markdown.js +283 -0
  9. package/dist/MermaidDiagram.d.ts +16 -0
  10. package/dist/MermaidDiagram.js +70 -0
  11. package/dist/NoteBody.d.ts +15 -0
  12. package/dist/NoteBody.js +92 -0
  13. package/dist/Transclusion.d.ts +11 -0
  14. package/dist/Transclusion.js +66 -0
  15. package/dist/address.d.ts +53 -0
  16. package/dist/address.js +69 -0
  17. package/dist/delegated.d.ts +39 -0
  18. package/dist/delegated.js +63 -0
  19. package/dist/footnotes.d.ts +72 -0
  20. package/dist/footnotes.js +137 -0
  21. package/dist/highlight.d.ts +54 -0
  22. package/dist/highlight.js +96 -0
  23. package/dist/host.d.ts +135 -0
  24. package/dist/host.js +49 -0
  25. package/dist/image-dimensions.d.ts +33 -0
  26. package/dist/image-dimensions.js +42 -0
  27. package/dist/index.d.ts +8 -0
  28. package/dist/index.js +8 -0
  29. package/dist/markdown.d.ts +68 -0
  30. package/dist/markdown.js +395 -0
  31. package/dist/remark-callouts.d.ts +40 -0
  32. package/dist/remark-callouts.js +108 -0
  33. package/dist/remark-ring.d.ts +68 -0
  34. package/dist/remark-ring.js +178 -0
  35. package/dist/strings.d.ts +55 -0
  36. package/dist/strings.js +64 -0
  37. package/dist/tasklist.d.ts +25 -0
  38. package/dist/tasklist.js +80 -0
  39. package/dist/transclusion.d.ts +76 -0
  40. package/dist/transclusion.js +161 -0
  41. package/dist/wikilink-address.d.ts +8 -0
  42. package/dist/wikilink-address.js +13 -0
  43. package/package.json +75 -3
  44. 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
- # Temporary Holding Version
1
+ # @memorysmithapp/markdown-renderer
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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,35 @@
1
+ import type { PluggableList } from 'unified';
2
+ import { type FootnoteRef } from './footnotes.js';
3
+ /**
4
+ * One run of a note, parsed and drawn: the pipeline of MemorySmith's reading page.
5
+ *
6
+ * It draws the text it is given and nothing else. What a note becomes before this — its
7
+ * frontmatter cut, its embeds split out, its wikilinks resolved — is `NoteBody`'s.
8
+ */
9
+ export interface MarkdownProps {
10
+ readonly children: string;
11
+ /**
12
+ * The text the ordinal of a task box is counted over. It has to be the text
13
+ * the PARSER saw, because the offsets come from it — not the bytes a write
14
+ * sends back, which are the original ones. Keeping the two aligned is the
15
+ * caller's job, and `NoteBody` does it by refusing to enable the boxes when
16
+ * the two disagree on how many there are.
17
+ */
18
+ readonly source?: string;
19
+ readonly onToggleTask?: (ordinal: number) => void;
20
+ readonly writable?: boolean;
21
+ /**
22
+ * How this run takes part in the footnotes of a note drawn in several runs
23
+ * (`footnotes.ts`): the number and id of each of its references, or
24
+ * `'section'` for the pass that draws the note's one section.
25
+ */
26
+ readonly footnotes?: {
27
+ readonly refs: readonly FootnoteRef[];
28
+ } | 'section';
29
+ }
30
+ /**
31
+ * The remark plugins of the reading surface, in their order. `planFootnotes` reads a note
32
+ * through the same list, so it counts the references this surface draws and no other.
33
+ */
34
+ export declare function readingPlugins(titleOf: (kind: string) => string | undefined): PluggableList;
35
+ export declare function Markdown({ children, source, onToggleTask, writable, footnotes }: MarkdownProps): import("react").JSX.Element;