@memorysmithapp/markdown-renderer 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -2
- package/dist/Markdown.d.ts +10 -4
- package/dist/Markdown.js +58 -44
- package/dist/MermaidDiagram.d.ts +3 -3
- package/dist/MermaidDiagram.js +8 -9
- package/dist/NoteBody.js +12 -6
- package/dist/PropertiesBlock.d.ts +18 -0
- package/dist/PropertiesBlock.js +107 -0
- package/dist/Transclusion.d.ts +1 -1
- package/dist/Transclusion.js +31 -15
- package/dist/diagnose.d.ts +50 -0
- package/dist/diagnose.js +304 -0
- package/dist/host.d.ts +16 -3
- package/dist/host.js +9 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/markdown.d.ts +25 -4
- package/dist/markdown.js +53 -4
- package/dist/properties.d.ts +50 -0
- package/dist/properties.js +144 -0
- package/dist/reading.d.ts +6 -0
- package/dist/reading.js +32 -0
- package/dist/remark-callouts.js +5 -3
- package/dist/remark-ring.d.ts +8 -6
- package/dist/remark-ring.js +8 -28
- package/dist/slots.d.ts +36 -0
- package/dist/slots.js +124 -0
- package/dist/strings.d.ts +7 -0
- package/dist/strings.js +4 -0
- package/dist/tasklist.js +7 -3
- package/dist/theme.d.ts +283 -0
- package/dist/theme.js +107 -0
- package/package.json +11 -3
- package/structure.css +57 -0
- package/styles.css +6 -863
- package/theme-default.css +998 -0
package/README.md
CHANGED
|
@@ -29,9 +29,70 @@ import 'katex/dist/katex.min.css';
|
|
|
29
29
|
/>;
|
|
30
30
|
```
|
|
31
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
|
|
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`. The faces of the default theme are Inter and Space Grotesk, which the page loads.
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
## Themes
|
|
35
|
+
|
|
36
|
+
With no theme, a note looks as it does in MemorySmith, in `theme="light"` or `theme="dark"`. To give it your own look, write a theme in your project and pass it:
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
import { defineTheme, NoteBody } from '@memorysmithapp/markdown-renderer';
|
|
40
|
+
import '@memorysmithapp/markdown-renderer/styles.css';
|
|
41
|
+
import './acme-theme.css'; // your CSS, under .markdown-theme-acme
|
|
42
|
+
|
|
43
|
+
const acme = defineTheme({
|
|
44
|
+
name: 'acme', // the root of a note carries .markdown-theme-acme
|
|
45
|
+
schemes: ['light', 'dark'],
|
|
46
|
+
properties: { // level 1: values, no CSS needed
|
|
47
|
+
light: { bg: '#ffffff', text: '#222222', accent: '#e11d48', fontSans: "'IBM Plex Sans', sans-serif" },
|
|
48
|
+
dark: { bg: '#111111', text: '#eeeeee', accent: '#fb7185' },
|
|
49
|
+
},
|
|
50
|
+
icons: { warning: <AlertTriangle />, tip: <Lightbulb /> }, // any icon library
|
|
51
|
+
components: { // level 3: draw a block your way
|
|
52
|
+
table: ({ children }) => <table className="acme-table">{children}</table>,
|
|
53
|
+
callout: ({ kind, title, icon, children }) => (
|
|
54
|
+
<aside className={`acme-callout acme-${kind}`}>
|
|
55
|
+
<header>{icon}{title}</header>
|
|
56
|
+
{children}
|
|
57
|
+
</aside>
|
|
58
|
+
),
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
<NoteBody markdown={note} theme={acme} scheme="dark" />;
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
- **Properties** name every value the stylesheet reads — colours, faces, sizes, spacing, headings, quotations, tables, callout hues and icons — and are written on the root as `--markdown-*`, so your CSS may set them too.
|
|
66
|
+
- **Your CSS** is scoped by `.markdown-theme-<name>`, and reaches the note and nothing else.
|
|
67
|
+
- **Slots** — `callout`, `calloutIcon`, `table`, `codeBlock`, `inlineCode`, `heading`, `blockquote`, `embed`, `footnotes`, `taskBox`, `wikilink`, `diagram`, `properties` — are called with the notation already read. A slot decides how a block looks, never what it means: the links it draws go where the host resolved them, a task box reports through the `toggle` it is given, and no raw HTML ever reaches it.
|
|
68
|
+
|
|
69
|
+
The frontmatter is the note's too. The default theme draws it as MemorySmith does: `name` as the title, and the other entries in a block that folds, each key with the icon of its value's shape, lists as chips and dates in `strings.locale`, with a `[[wikilink]]`, a Markdown link or a web address in a value drawn as a link. A theme decides otherwise through the `properties` slot, which may draw nothing, as a host that shows properties elsewhere does:
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
const mine = defineTheme({ name: 'mine', components: { properties: () => null } });
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Import `structure.css` instead of `styles.css` for a theme that draws everything itself. A theme names faces; your page loads them.
|
|
76
|
+
|
|
77
|
+
## Diagnose
|
|
78
|
+
|
|
79
|
+
Every note is valid, and nothing here refuses one. What `diagnose` tells a host is where the page will not show what was most likely typed, and what the note lacks that the specification reserves a place for: the notices of [`SPEC.md` §7.13](https://github.com/memorysmithapp/memorysmithapp-markdown/blob/main/packages/markdown-spec/SPEC.md#713-notices). It imports no React and needs no DOM, so it runs in an editor, on a server, or in a tool that writes a note.
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import { diagnose } from '@memorysmithapp/markdown-renderer/diagnose';
|
|
83
|
+
|
|
84
|
+
const notices = await diagnose(note, {
|
|
85
|
+
resolve: (name) => addressOfTheOneNoteNamed(name), // or reaches; leave both out and no link is pending
|
|
86
|
+
keeps: (name) => notebookKeepsAFile(name),
|
|
87
|
+
});
|
|
88
|
+
// [{ code: 'heading-skipped-level', severity: 'notice', section: '3.3',
|
|
89
|
+
// message: 'This heading skips a level below the heading before it.',
|
|
90
|
+
// line: 12, column: 1, endLine: 12, endColumn: 14 }, …]
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
A `notice` says the page will differ from the text — a fence never closed, a formula KaTeX cannot typeset, a diagram Mermaid cannot read, raw HTML, a footnote with no definition or no reference, a single-tilde strikethrough, a frontmatter never closed, a `name` that leaves the note unnamed or none at all, `aliases` or `tags` that are not a list, a body that does not start at `#` or skips a level. A `hint` says nothing is drawn wrong: no `aliases`, no `tags`, a link or an embed no note answers yet. Lines and columns count from 1 over the whole note, frontmatter included, and the `message` is English; a host words it in its own language from the `code`, which is stable. Mermaid is loaded only for a note that holds a diagram, and `diagrams: false` skips it.
|
|
94
|
+
|
|
95
|
+
The host contract, the stylesheet's properties, the slots and the decisions behind them are in the repository's [`ARCHITECTURE.md`](https://github.com/memorysmithapp/memorysmithapp-markdown/blob/main/ARCHITECTURE.md).
|
|
35
96
|
|
|
36
97
|
## Licence
|
|
37
98
|
|
package/dist/Markdown.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type AnchorHTMLAttributes, type ReactNode } from 'react';
|
|
2
2
|
import { type FootnoteRef } from './footnotes.js';
|
|
3
3
|
/**
|
|
4
4
|
* One run of a note, parsed and drawn: the pipeline of MemorySmith's reading page.
|
|
@@ -28,8 +28,14 @@ export interface MarkdownProps {
|
|
|
28
28
|
} | 'section';
|
|
29
29
|
}
|
|
30
30
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
31
|
+
* What react-markdown hands a component beside the attributes: the hast
|
|
32
|
+
* element it came from. It is not an attribute, and spread onto the element
|
|
33
|
+
* it reached the page as `node="[object Object]"` on every link, image and
|
|
34
|
+
* code span.
|
|
33
35
|
*/
|
|
34
|
-
|
|
36
|
+
type WithNode<T> = T & {
|
|
37
|
+
node?: unknown;
|
|
38
|
+
};
|
|
39
|
+
export declare function MarkdownAnchor({ href, children, node: _node, ...rest }: WithNode<AnchorHTMLAttributes<HTMLAnchorElement>>): string | number | bigint | boolean | import("react").JSX.Element | Iterable<ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | null | undefined;
|
|
35
40
|
export declare function Markdown({ children, source, onToggleTask, writable, footnotes }: MarkdownProps): import("react").JSX.Element;
|
|
41
|
+
export {};
|
package/dist/Markdown.js
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { toJsxRuntime } from 'hast-util-to-jsx-runtime';
|
|
3
|
-
import { isValidElement,
|
|
3
|
+
import { isValidElement, } from 'react';
|
|
4
4
|
import { Fragment, jsx, jsxs } from 'react/jsx-runtime';
|
|
5
5
|
import ReactMarkdown from 'react-markdown';
|
|
6
6
|
import rehypeKatex from 'rehype-katex';
|
|
7
|
-
import remarkGfm from 'remark-gfm';
|
|
8
|
-
import remarkMath from 'remark-math';
|
|
9
7
|
import { followable } from './address.js';
|
|
10
8
|
import { CopyButton } from './CopyButton.js';
|
|
11
9
|
import { highlightLanguage, highlightTree } from './highlight.js';
|
|
@@ -14,9 +12,9 @@ import { readDimensions, readImageAlt } from './image-dimensions.js';
|
|
|
14
12
|
import { choiceTargetOf, LinkChoice } from './LinkChoice.js';
|
|
15
13
|
import { toUnixNewlines } from './markdown.js';
|
|
16
14
|
import { MermaidDiagram } from './MermaidDiagram.js';
|
|
17
|
-
import {
|
|
18
|
-
import { remarkBlockIds, remarkComments, remarkHighlight, remarkMathDollarRule } from './remark-ring.js';
|
|
15
|
+
import { readingPlugins } from './reading.js';
|
|
19
16
|
import { CLOBBER_PREFIX, rehypeFootnoteRun, rehypeFootnoteSection } from './footnotes.js';
|
|
17
|
+
import { drawSlot, headings, InBlock, MarkdownBlockquote, MarkdownDiv, MarkdownSection, MarkdownTable, useInBlock, useSlot } from './slots.js';
|
|
20
18
|
import { ordinalAt } from './tasklist.js';
|
|
21
19
|
/**
|
|
22
20
|
* An image, with the dimensions the specification puts in its alt text
|
|
@@ -72,8 +70,9 @@ function Attachment({ name, width, height }) {
|
|
|
72
70
|
return _jsx("span", { className: "attachment-loading", children: name });
|
|
73
71
|
return _jsx(_Fragment, { children: host.renderAttachment({ name, width, height }) });
|
|
74
72
|
}
|
|
75
|
-
function MarkdownAnchor({ href, children, node: _node, ...rest }) {
|
|
73
|
+
export function MarkdownAnchor({ href, children, node: _node, ...rest }) {
|
|
76
74
|
const host = useHost();
|
|
75
|
+
const wikilinkSlot = useSlot('wikilink');
|
|
77
76
|
if (href?.startsWith('attachment:')) {
|
|
78
77
|
/**
|
|
79
78
|
* The address carries the dimension the pipe declared, after the encoded
|
|
@@ -93,7 +92,8 @@ function MarkdownAnchor({ href, children, node: _node, ...rest }) {
|
|
|
93
92
|
return _jsx(Attachment, { name: name, width: measured?.width ?? null, height: measured?.height ?? null });
|
|
94
93
|
}
|
|
95
94
|
if (href?.startsWith('pending:')) {
|
|
96
|
-
|
|
95
|
+
const pending = (label) => (_jsx("span", { className: "wikilink-pending", title: host.strings.pendingLink, children: label }));
|
|
96
|
+
return wikilinkSlot ? drawSlot(wikilinkSlot, { pending: true, children, link: pending }) : pending(children);
|
|
97
97
|
}
|
|
98
98
|
// Refused by `followable`: the text stays on the page and stops being
|
|
99
99
|
// something to click. An author sees they got nothing, which is the same
|
|
@@ -103,11 +103,17 @@ function MarkdownAnchor({ href, children, node: _node, ...rest }) {
|
|
|
103
103
|
}
|
|
104
104
|
// A target no single note answers by name opens its choice in place.
|
|
105
105
|
const choice = choiceTargetOf(href);
|
|
106
|
-
if (choice !== null)
|
|
107
|
-
|
|
106
|
+
if (choice !== null) {
|
|
107
|
+
const link = (label) => _jsx(LinkChoice, { target: choice, children: label });
|
|
108
|
+
if (!wikilinkSlot)
|
|
109
|
+
return link(children);
|
|
110
|
+
return drawSlot(wikilinkSlot, { pending: host.reaches(choice) === 0, children, link });
|
|
111
|
+
}
|
|
108
112
|
// An address of the host's own: its router draws it.
|
|
109
|
-
if (href.startsWith('/'))
|
|
110
|
-
|
|
113
|
+
if (href.startsWith('/')) {
|
|
114
|
+
const link = (label) => _jsx(_Fragment, { children: host.renderLink({ href, className: 'wikilink', children: label }) });
|
|
115
|
+
return wikilinkSlot ? drawSlot(wikilinkSlot, { pending: false, children, link }) : link(children);
|
|
116
|
+
}
|
|
111
117
|
if (href.startsWith('#')) {
|
|
112
118
|
return (_jsx(InPageAnchor, { href: href, ...rest, children: children }));
|
|
113
119
|
}
|
|
@@ -132,10 +138,14 @@ function MarkdownAnchor({ href, children, node: _node, ...rest }) {
|
|
|
132
138
|
* highlighter would otherwise breach it.
|
|
133
139
|
*/
|
|
134
140
|
function MarkdownCode({ className, children, node: _node, ...rest }) {
|
|
135
|
-
const {
|
|
141
|
+
const { scheme } = useHost();
|
|
142
|
+
const inBlock = useInBlock();
|
|
143
|
+
const inlineSlot = useSlot('inlineCode');
|
|
136
144
|
if (className?.includes('language-mermaid')) {
|
|
137
|
-
return _jsx(MermaidDiagram, { code: String(children).trim(),
|
|
145
|
+
return _jsx(MermaidDiagram, { code: String(children).trim(), scheme: scheme });
|
|
138
146
|
}
|
|
147
|
+
if (!inBlock && inlineSlot)
|
|
148
|
+
return drawSlot(inlineSlot, { children });
|
|
139
149
|
const language = highlightLanguage(className);
|
|
140
150
|
if (language !== null) {
|
|
141
151
|
// The source is read and never rewritten: what is on the page is the
|
|
@@ -161,13 +171,31 @@ function MarkdownCode({ className, children, node: _node, ...rest }) {
|
|
|
161
171
|
*/
|
|
162
172
|
function MarkdownPre({ children, node, ...rest }) {
|
|
163
173
|
const { strings } = useHost();
|
|
164
|
-
const
|
|
174
|
+
const slot = useSlot('codeBlock');
|
|
165
175
|
const code = node?.children?.find((child) => child.tagName === 'code');
|
|
166
176
|
const classes = code?.properties?.['className'];
|
|
167
177
|
if (Array.isArray(classes) && classes.includes('language-mermaid')) {
|
|
168
178
|
return _jsx("pre", { ...rest, children: children });
|
|
169
179
|
}
|
|
170
|
-
|
|
180
|
+
// The bytes as typed: the text of the fence, less the newline before its closing line.
|
|
181
|
+
const typed = textOf(code).replace(/\n$/, '');
|
|
182
|
+
const copy = _jsx(CopyButton, { className: "code-copy", label: strings.copy, done: strings.copied, text: () => typed });
|
|
183
|
+
if (slot) {
|
|
184
|
+
const language = Array.isArray(classes)
|
|
185
|
+
? (classes.map(String).find((name) => name.startsWith('language-'))?.slice('language-'.length) ?? null)
|
|
186
|
+
: null;
|
|
187
|
+
return _jsx(InBlock.Provider, { value: true, children: drawSlot(slot, { language, code: typed, children, copy }) });
|
|
188
|
+
}
|
|
189
|
+
return (_jsxs("div", { className: "code-block", children: [_jsx(InBlock.Provider, { value: true, children: _jsx("pre", { ...rest, children: children }) }), copy] }));
|
|
190
|
+
}
|
|
191
|
+
/** The text of a hast element, which for a fence is the code as typed. */
|
|
192
|
+
function textOf(node) {
|
|
193
|
+
if (!node)
|
|
194
|
+
return '';
|
|
195
|
+
const text = node.value;
|
|
196
|
+
if (typeof text === 'string')
|
|
197
|
+
return text;
|
|
198
|
+
return (node.children ?? []).map(textOf).join('');
|
|
171
199
|
}
|
|
172
200
|
/**
|
|
173
201
|
* Whether this list item is a task box, and how it is ticked.
|
|
@@ -191,6 +219,7 @@ function taskState(node) {
|
|
|
191
219
|
* the host, never by the package.
|
|
192
220
|
*/
|
|
193
221
|
function TaskItem({ node, children, className, source, toggleTask, writable, ...rest }) {
|
|
222
|
+
const boxSlot = useSlot('taskBox');
|
|
194
223
|
const element = node;
|
|
195
224
|
const checked = taskState(element);
|
|
196
225
|
if (checked === null) {
|
|
@@ -199,10 +228,14 @@ function TaskItem({ node, children, className, source, toggleTask, writable, ...
|
|
|
199
228
|
const offset = element?.position?.start?.offset;
|
|
200
229
|
const ordinal = offset === undefined ? -1 : ordinalAt(source, offset);
|
|
201
230
|
const enabled = writable && toggleTask !== undefined && ordinal >= 0;
|
|
231
|
+
const toggle = () => {
|
|
232
|
+
if (enabled)
|
|
233
|
+
toggleTask(ordinal);
|
|
234
|
+
};
|
|
202
235
|
return (
|
|
203
236
|
// Our class comes LAST, because the one react-markdown passes in is on
|
|
204
237
|
// `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:
|
|
238
|
+
_jsxs("li", { ...rest, className: `${className ?? ''} task-item`.trim(), children: [boxSlot ? (drawSlot(boxSlot, { checked, enabled, toggle })) : (_jsx("input", { type: "checkbox", checked: checked, disabled: !enabled, onChange: toggle })), dropBox(children)] }));
|
|
206
239
|
}
|
|
207
240
|
/**
|
|
208
241
|
* The children of the item without the checkbox GFM put in front of them.
|
|
@@ -215,38 +248,14 @@ function dropBox(children) {
|
|
|
215
248
|
return children;
|
|
216
249
|
return children.filter((child) => !(isValidElement(child) && child.type === 'input'));
|
|
217
250
|
}
|
|
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
251
|
export function Markdown({ children, source, onToggleTask, writable = false, footnotes }) {
|
|
246
252
|
const text = toUnixNewlines(children);
|
|
247
253
|
const { strings } = useHost();
|
|
248
254
|
const calloutTitle = (kind) => Object.hasOwn(strings.callouts, kind) ? strings.callouts[kind] : undefined;
|
|
249
|
-
return (
|
|
255
|
+
return (
|
|
256
|
+
// The pass that draws a cut note's footnotes continues the note rather than opening a run,
|
|
257
|
+
// so its section keeps the space a section takes at the end of a note.
|
|
258
|
+
_jsx("div", { className: footnotes === 'section' ? 'markdown footnotes-run' : 'markdown', children: _jsx(ReactMarkdown, { remarkPlugins: readingPlugins(calloutTitle),
|
|
250
259
|
// Raw HTML is NOT enabled, and its absence is the point: no
|
|
251
260
|
// `rehype-raw` is loaded, so a note carrying `<script>` is text. It is
|
|
252
261
|
// a security boundary rather than a rendering preference, because a
|
|
@@ -278,6 +287,11 @@ export function Markdown({ children, source, onToggleTask, writable = false, foo
|
|
|
278
287
|
code: MarkdownCode,
|
|
279
288
|
pre: MarkdownPre,
|
|
280
289
|
img: MarkdownImage,
|
|
290
|
+
div: MarkdownDiv,
|
|
291
|
+
table: MarkdownTable,
|
|
292
|
+
blockquote: MarkdownBlockquote,
|
|
293
|
+
section: MarkdownSection,
|
|
294
|
+
...headings,
|
|
281
295
|
li: (props) => (_jsx(TaskItem, { ...props, source: source ?? text, toggleTask: onToggleTask, writable: writable })),
|
|
282
296
|
}, children: text }) }));
|
|
283
297
|
}
|
package/dist/MermaidDiagram.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
interface MermaidDiagramProps {
|
|
2
2
|
readonly code: string;
|
|
3
|
-
/** The
|
|
4
|
-
readonly
|
|
3
|
+
/** The scheme the note is drawn in, which picks Mermaid's own theme. */
|
|
4
|
+
readonly scheme: 'light' | 'dark';
|
|
5
5
|
}
|
|
6
6
|
/**
|
|
7
7
|
* A mermaid code block drawn as an SVG diagram (§7.2). The library is heavy, so
|
|
@@ -12,5 +12,5 @@ interface MermaidDiagramProps {
|
|
|
12
12
|
* output, not note-derived HTML: Mermaid renders with its default `strict`
|
|
13
13
|
* security level, which encodes the text of the diagram.
|
|
14
14
|
*/
|
|
15
|
-
export declare function MermaidDiagram({ code,
|
|
15
|
+
export declare function MermaidDiagram({ code, scheme }: MermaidDiagramProps): string | number | bigint | boolean | import("react").JSX.Element | Iterable<import("react").ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<import("react").ReactNode> | null | undefined> | null | undefined;
|
|
16
16
|
export {};
|
package/dist/MermaidDiagram.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useId, useState } from 'react';
|
|
3
|
+
import { drawSlot, useSlot } from './slots.js';
|
|
3
4
|
/**
|
|
4
5
|
* A mermaid code block drawn as an SVG diagram (§7.2). The library is heavy, so
|
|
5
6
|
* it is imported lazily, out of the bundle that draws the rest of the note; on
|
|
@@ -9,7 +10,7 @@ import { useEffect, useId, useState } from 'react';
|
|
|
9
10
|
* output, not note-derived HTML: Mermaid renders with its default `strict`
|
|
10
11
|
* security level, which encodes the text of the diagram.
|
|
11
12
|
*/
|
|
12
|
-
export function MermaidDiagram({ code,
|
|
13
|
+
export function MermaidDiagram({ code, scheme }) {
|
|
13
14
|
const id = useId().replace(/[^a-zA-Z0-9]/g, '');
|
|
14
15
|
const [svg, setSvg] = useState(null);
|
|
15
16
|
const [failed, setFailed] = useState(false);
|
|
@@ -26,7 +27,7 @@ export function MermaidDiagram({ code, theme }) {
|
|
|
26
27
|
await document.fonts?.ready;
|
|
27
28
|
mermaid.initialize({
|
|
28
29
|
startOnLoad: false,
|
|
29
|
-
theme:
|
|
30
|
+
theme: scheme === 'dark' ? 'dark' : 'neutral',
|
|
30
31
|
fontFamily: 'Inter, sans-serif',
|
|
31
32
|
// SVG text labels measure reliably; HTML labels clip inside nodes
|
|
32
33
|
// when the webfont metrics differ from the fallback.
|
|
@@ -60,11 +61,9 @@ export function MermaidDiagram({ code, theme }) {
|
|
|
60
61
|
return () => {
|
|
61
62
|
alive = false;
|
|
62
63
|
};
|
|
63
|
-
}, [code, id,
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
return _jsx("div", { className: "mermaid-diagram mermaid-loading" });
|
|
69
|
-
return _jsx("div", { className: "mermaid-diagram", dangerouslySetInnerHTML: { __html: svg } });
|
|
64
|
+
}, [code, id, scheme]);
|
|
65
|
+
const slot = useSlot('diagram');
|
|
66
|
+
const state = failed ? 'failed' : svg ? 'ready' : 'loading';
|
|
67
|
+
const drawn = state === 'failed' ? (_jsx("pre", { children: _jsx("code", { children: code }) })) : state === 'ready' ? (_jsx("div", { className: "mermaid-diagram", dangerouslySetInnerHTML: { __html: svg ?? '' } })) : (_jsx("div", { className: "mermaid-diagram mermaid-loading" }));
|
|
68
|
+
return slot ? drawSlot(slot, { source: code, state, children: drawn }) : drawn;
|
|
70
69
|
}
|
package/dist/NoteBody.js
CHANGED
|
@@ -2,9 +2,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { useMemo } from 'react';
|
|
3
3
|
import { HOST_KEYS, HostProvider, resolveHost } from './host.js';
|
|
4
4
|
import { planFootnotes } from './footnotes.js';
|
|
5
|
-
import { Markdown
|
|
6
|
-
import {
|
|
5
|
+
import { Markdown } from './Markdown.js';
|
|
6
|
+
import { readingPlugins } from './reading.js';
|
|
7
|
+
import { bodyStart, hideComments, remoteImageHosts, resolveWikilinks } from './markdown.js';
|
|
8
|
+
import { readProperties } from './properties.js';
|
|
9
|
+
import { Properties } from './PropertiesBlock.js';
|
|
7
10
|
import { taskBoxes } from './tasklist.js';
|
|
11
|
+
import { rootStyle } from './theme.js';
|
|
8
12
|
import { Transclusion } from './Transclusion.js';
|
|
9
13
|
import { splitEmbeds } from './transclusion.js';
|
|
10
14
|
import { wikilinkAddress } from './wikilink-address.js';
|
|
@@ -18,10 +22,12 @@ export function NoteBody({ markdown, writable = false, onToggleTask, ...host })
|
|
|
18
22
|
const resolved = useMemo(() => resolveHost(host),
|
|
19
23
|
// Every member of the contract, so a host that passes the same functions keeps the same host.
|
|
20
24
|
HOST_KEYS.map((key) => host[key]));
|
|
21
|
-
//
|
|
22
|
-
//
|
|
25
|
+
// The frontmatter is drawn apart from the body, as the theme decides, so the
|
|
26
|
+
// runs start after it. Everything below counts task items from there.
|
|
23
27
|
const split = bodyStart(markdown);
|
|
24
|
-
const
|
|
28
|
+
const properties = useMemo(() => readProperties(markdown), [markdown]);
|
|
29
|
+
// What a comment holds is never read (§7.6): it leaves before the embeds and the links are found.
|
|
30
|
+
const body = hideComments(markdown.slice(split)).text;
|
|
25
31
|
// The body is cut into runs of text and the embeds between them; each run
|
|
26
32
|
// is parsed on its own, as on MemorySmith's reading page.
|
|
27
33
|
const segments = splitEmbeds(body, resolved.keeps).map((segment) => segment.kind === 'text'
|
|
@@ -74,7 +80,7 @@ export function NoteBody({ markdown, writable = false, onToggleTask, ...host })
|
|
|
74
80
|
// The runs and whether an embed cut them are the whole input.
|
|
75
81
|
[runs.join('\u0000'), runs.length < segments.length]);
|
|
76
82
|
let run = 0;
|
|
77
|
-
return (_jsx(HostProvider, { value: resolved, children: _jsxs("div", { className:
|
|
83
|
+
return (_jsx(HostProvider, { value: resolved, children: _jsxs("div", { className: `markdown markdown-theme-${resolved.theme.name}`, "data-theme": resolved.scheme, style: rootStyle(resolved.theme, resolved.scheme), children: [_jsx(Properties, { properties: properties }), segments.map((segment, index) => {
|
|
78
84
|
if (!('rendered' in segment)) {
|
|
79
85
|
return _jsx(Transclusion, { target: segment.target, anchor: segment.anchor }, index);
|
|
80
86
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The frontmatter of a note, drawn above its body — or not — as the theme decides.
|
|
3
|
+
*
|
|
4
|
+
* The renderer reads it (`readProperties`) and hands it to the theme's
|
|
5
|
+
* `properties` slot. With none, the default theme draws what MemorySmith's note
|
|
6
|
+
* page draws above the body (*observed* at `4b67c7e`): the name as the note's
|
|
7
|
+
* title, always — *Unnamed note* when it has none — and the properties in a
|
|
8
|
+
* block that folds, under a rule and never in a card. The reserved keys come
|
|
9
|
+
* first, in the order of the specification, then the notebook's own in the
|
|
10
|
+
* order written; each key wears the icon of the shape of its value; a list is
|
|
11
|
+
* chips, a boolean the reader's *Yes* or *No*, a date in the reader's locale,
|
|
12
|
+
* and what was typed stays in the title of each.
|
|
13
|
+
*/
|
|
14
|
+
import type { ReactNode } from 'react';
|
|
15
|
+
import { type NoteProperties } from './properties.js';
|
|
16
|
+
export declare function Properties({ properties }: {
|
|
17
|
+
properties: NoteProperties;
|
|
18
|
+
}): string | number | bigint | boolean | import("react").JSX.Element | Iterable<ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | null | undefined;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useHost } from './host.js';
|
|
3
|
+
import { MarkdownAnchor } from './Markdown.js';
|
|
4
|
+
import { DRAWN_RESERVED_KEYS, isTrue, VALUE_LINK, } from './properties.js';
|
|
5
|
+
import { drawSlot, useSlot } from './slots.js';
|
|
6
|
+
import { wikilinkAddress } from './wikilink-address.js';
|
|
7
|
+
/**
|
|
8
|
+
* A value as the note means it: a wikilink a link to where the host resolved it — the note, the
|
|
9
|
+
* choice, or the pending marker — a Markdown link or a bare address to the web a link to it, each
|
|
10
|
+
* through the anchor the body's links take, so the address filter holds and a theme's `wikilink`
|
|
11
|
+
* slot draws them too; everything else as text, never as markup.
|
|
12
|
+
*/
|
|
13
|
+
function useValue() {
|
|
14
|
+
const host = useHost();
|
|
15
|
+
return (text) => {
|
|
16
|
+
const parts = [];
|
|
17
|
+
let cursor = 0;
|
|
18
|
+
for (const match of text.matchAll(VALUE_LINK)) {
|
|
19
|
+
const at = match.index ?? 0;
|
|
20
|
+
if (at > cursor)
|
|
21
|
+
parts.push(text.slice(cursor, at));
|
|
22
|
+
const [whole, wikiTarget, wikiLabel, mdLabel, mdUrl, rawUrl] = match;
|
|
23
|
+
if (wikiTarget) {
|
|
24
|
+
const target = wikiTarget.trim();
|
|
25
|
+
const name = target.split('#')[0]?.trim() ?? '';
|
|
26
|
+
const address = name ? wikilinkAddress(host, name.normalize('NFC')) : null;
|
|
27
|
+
parts.push(_jsx(MarkdownAnchor, { href: address ?? `pending:${encodeURIComponent(name)}`, children: wikiLabel?.trim() || target }, at));
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
parts.push(_jsx(MarkdownAnchor, { href: mdUrl ?? rawUrl, children: mdLabel ?? rawUrl }, at));
|
|
31
|
+
}
|
|
32
|
+
cursor = at + whole.length;
|
|
33
|
+
}
|
|
34
|
+
if (cursor < text.length)
|
|
35
|
+
parts.push(text.slice(cursor));
|
|
36
|
+
return parts.length === 1 ? parts[0] : _jsx(_Fragment, { children: parts });
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** The type that picks a key's icon, by the shape of its value and never by its key. */
|
|
40
|
+
function iconType(entry) {
|
|
41
|
+
if (entry.shape === 'list')
|
|
42
|
+
return 'list';
|
|
43
|
+
if (entry.shape === 'boolean')
|
|
44
|
+
return 'checkbox';
|
|
45
|
+
if (entry.shape === 'date')
|
|
46
|
+
return 'date';
|
|
47
|
+
return 'text';
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A date in the reader's locale, as the rest of an interface writes one. A note's date carries no
|
|
51
|
+
* zone, so it is read and written in UTC: read in the zone of the browser, `2026-09-21` is the
|
|
52
|
+
* evening of the 20th west of Greenwich. A form the reading does not take stays as typed.
|
|
53
|
+
*/
|
|
54
|
+
function readableDate(value, locale) {
|
|
55
|
+
const typed = value.trim();
|
|
56
|
+
if (!/^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2})?$/.test(typed))
|
|
57
|
+
return null;
|
|
58
|
+
const [day, time] = typed.split(/[T ]/);
|
|
59
|
+
const [year, month, date] = (day ?? '').split('-').map(Number);
|
|
60
|
+
const [hours, minutes] = (time ?? '').split(':').map(Number);
|
|
61
|
+
const when = new Date(Date.UTC(year ?? 0, (month ?? 1) - 1, date ?? 1, hours ?? 0, minutes ?? 0));
|
|
62
|
+
if (when.getUTCDate() !== date || when.getUTCMonth() !== (month ?? 1) - 1)
|
|
63
|
+
return null;
|
|
64
|
+
try {
|
|
65
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', ...(time ? { timeStyle: 'short' } : {}), timeZone: 'UTC' }).format(when);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function PropertyValue({ entry, value }) {
|
|
72
|
+
const { strings } = useHost();
|
|
73
|
+
if (entry.shape === 'list') {
|
|
74
|
+
return (_jsx("span", { className: "prop-tags", children: entry.values.map((item, index) => (_jsx("span", { className: "prop-tag", children: value(item) }, index))) }));
|
|
75
|
+
}
|
|
76
|
+
const typed = (entry.values[0] ?? '').trim();
|
|
77
|
+
if (entry.shape === 'boolean')
|
|
78
|
+
return _jsx("span", { title: typed, children: isTrue(typed) ? strings.yes : strings.no });
|
|
79
|
+
if (entry.shape === 'date') {
|
|
80
|
+
const readable = readableDate(typed, strings.locale);
|
|
81
|
+
if (readable !== null) {
|
|
82
|
+
return (_jsx("time", { dateTime: typed, title: typed, children: readable }));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return _jsx(_Fragment, { children: value(typed) });
|
|
86
|
+
}
|
|
87
|
+
/** The reserved keys first, in the order of the specification, then the rest in the order written. */
|
|
88
|
+
function ordered(entries) {
|
|
89
|
+
const rank = (key) => {
|
|
90
|
+
const at = DRAWN_RESERVED_KEYS.indexOf(key);
|
|
91
|
+
return at === -1 ? DRAWN_RESERVED_KEYS.length : at;
|
|
92
|
+
};
|
|
93
|
+
return entries
|
|
94
|
+
.filter((entry) => entry.shape !== 'empty')
|
|
95
|
+
.map((entry, index) => ({ entry, index }))
|
|
96
|
+
.sort((left, right) => rank(left.entry.key) - rank(right.entry.key) || left.index - right.index)
|
|
97
|
+
.map(({ entry }) => entry);
|
|
98
|
+
}
|
|
99
|
+
export function Properties({ properties }) {
|
|
100
|
+
const { strings } = useHost();
|
|
101
|
+
const slot = useSlot('properties');
|
|
102
|
+
const value = useValue();
|
|
103
|
+
if (slot)
|
|
104
|
+
return drawSlot(slot, { name: properties.name, entries: properties.entries, value });
|
|
105
|
+
const shown = ordered(properties.entries);
|
|
106
|
+
return (_jsxs(_Fragment, { children: [_jsx("h1", { className: properties.name === null ? 'note-title note-unnamed' : 'note-title', children: properties.name ?? strings.unnamedNote }), shown.length > 0 && (_jsxs("details", { className: "properties-box", open: true, children: [_jsx("summary", { children: strings.properties }), _jsx("div", { className: "metadata-container", children: shown.map((entry, index) => (_jsxs("div", { className: "metadata-property", "data-property-type": iconType(entry), children: [_jsx("span", { className: "metadata-property-key", children: entry.key }), _jsx("span", { className: "metadata-property-value", children: _jsx(PropertyValue, { entry: entry, value: value }) })] }, index))) })] }))] }));
|
|
107
|
+
}
|
package/dist/Transclusion.d.ts
CHANGED
|
@@ -8,4 +8,4 @@
|
|
|
8
8
|
export declare function Transclusion({ target, anchor }: {
|
|
9
9
|
target: string;
|
|
10
10
|
anchor: string | null;
|
|
11
|
-
}): import("react").JSX.Element;
|
|
11
|
+
}): string | number | bigint | boolean | import("react").JSX.Element | Iterable<import("react").ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<import("react").ReactNode> | null | undefined> | null | undefined;
|
package/dist/Transclusion.js
CHANGED
|
@@ -2,7 +2,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { useEffect, useState } from 'react';
|
|
3
3
|
import { useHost } from './host.js';
|
|
4
4
|
import { Markdown } from './Markdown.js';
|
|
5
|
-
import {
|
|
5
|
+
import { drawSlot, useSlot } from './slots.js';
|
|
6
|
+
import { bodyStart, hideComments, resolveWikilinks } from './markdown.js';
|
|
6
7
|
import { blockOf, demoteEmbeds, isBlockAnchor, sectionOf } from './transclusion.js';
|
|
7
8
|
import { wikilinkAddress } from './wikilink-address.js';
|
|
8
9
|
/** The shape of what is coming, in the position it will occupy, while the host answers. */
|
|
@@ -25,6 +26,7 @@ export function Transclusion({ target, anchor }) {
|
|
|
25
26
|
// passage the page can inline on their behalf.
|
|
26
27
|
const one = host.loadNote !== null && host.reaches(name) === 1;
|
|
27
28
|
const [loaded, setLoaded] = useState({ state: 'loading' });
|
|
29
|
+
const slot = useSlot('embed');
|
|
28
30
|
useEffect(() => {
|
|
29
31
|
if (!one || !host.loadNote)
|
|
30
32
|
return;
|
|
@@ -39,12 +41,15 @@ export function Transclusion({ target, anchor }) {
|
|
|
39
41
|
// is still being written, so a target that does not exist yet is an
|
|
40
42
|
// expected state and never an error that stops the page.
|
|
41
43
|
if (!one || loaded.state === 'missing') {
|
|
42
|
-
|
|
44
|
+
const marker = (_jsx("p", { className: "embed-pending", children: _jsx("span", { className: "wikilink-pending", title: strings.pendingLink, children: target }) }));
|
|
45
|
+
return slot ? drawSlot(slot, { state: 'pending', target, anchor, caption: null, children: marker }) : marker;
|
|
46
|
+
}
|
|
47
|
+
if (loaded.state === 'loading') {
|
|
48
|
+
const skeleton = _jsx(TransclusionSkeleton, { loading: strings.loading });
|
|
49
|
+
return slot ? drawSlot(slot, { state: 'loading', target, anchor, caption: null, children: skeleton }) : skeleton;
|
|
43
50
|
}
|
|
44
|
-
if (loaded.state === 'loading')
|
|
45
|
-
return _jsx(TransclusionSkeleton, { loading: strings.loading });
|
|
46
51
|
const { note } = loaded;
|
|
47
|
-
const whole = note.markdown.slice(bodyStart(note.markdown));
|
|
52
|
+
const whole = hideComments(note.markdown.slice(bodyStart(note.markdown))).text;
|
|
48
53
|
// The caption names the note the content came from, and a note may have no
|
|
49
54
|
// name.
|
|
50
55
|
const sourceLabel = note.name ?? strings.unnamedNote;
|
|
@@ -53,14 +58,25 @@ export function Transclusion({ target, anchor }) {
|
|
|
53
58
|
// section named `^x` and a block called `x` would answer for each other.
|
|
54
59
|
const cut = !anchor ? whole : isBlockAnchor(anchor) ? blockOf(whole, anchor.slice(1)) : sectionOf(whole, anchor);
|
|
55
60
|
const caption = anchor ? `${sourceLabel} › ${anchor}` : sourceLabel;
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
61
|
+
const named = note.address === null ? caption : host.renderLink({ href: note.address, children: caption });
|
|
62
|
+
const content = cut === null ? (_jsx("p", { className: "embed-missing", children: strings.embedSectionMissing(anchor ?? '') })) : (
|
|
63
|
+
/**
|
|
64
|
+
* Resolved, like every other run of the note. Without this every
|
|
65
|
+
* `[[link]]` inside transcluded content reached the page with its
|
|
66
|
+
* brackets — and the sharpest case is the one the one-level rule
|
|
67
|
+
* creates itself: `demoteEmbeds` turns an embed found in embedded
|
|
68
|
+
* content into a wikilink, and that embed is drawn as A LINK to its
|
|
69
|
+
* target. Raw text is not a link.
|
|
70
|
+
*/
|
|
71
|
+
_jsx(Markdown, { children: resolveWikilinks(demoteEmbeds(cut, host.keeps), (each) => wikilinkAddress(host, each), host.keeps) }));
|
|
72
|
+
if (slot) {
|
|
73
|
+
return drawSlot(slot, {
|
|
74
|
+
state: cut === null ? 'section-missing' : 'ready',
|
|
75
|
+
target,
|
|
76
|
+
anchor,
|
|
77
|
+
caption: named,
|
|
78
|
+
children: content,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return (_jsxs("figure", { className: "embed", children: [_jsx("div", { className: "embed-body", children: content }), _jsx("figcaption", { className: "embed-source", children: named })] }));
|
|
66
82
|
}
|