@memorysmithapp/markdown-renderer 0.1.1 → 0.2.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 +45 -2
- package/dist/Markdown.d.ts +12 -0
- package/dist/Markdown.js +57 -13
- package/dist/MermaidDiagram.d.ts +3 -3
- package/dist/MermaidDiagram.js +8 -9
- package/dist/NoteBody.js +7 -3
- package/dist/PropertiesBlock.d.ts +18 -0
- package/dist/PropertiesBlock.js +107 -0
- package/dist/Transclusion.d.ts +1 -1
- package/dist/Transclusion.js +29 -13
- 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 +5 -4
- package/dist/markdown.js +5 -4
- package/dist/properties.d.ts +50 -0
- package/dist/properties.js +144 -0
- package/dist/remark-callouts.js +5 -3
- 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/theme.d.ts +283 -0
- package/dist/theme.js +107 -0
- package/package.json +7 -3
- package/structure.css +57 -0
- package/styles.css +6 -863
- package/theme-default.css +998 -0
package/README.md
CHANGED
|
@@ -29,9 +29,52 @@ 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
|
+
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
78
|
|
|
36
79
|
## Licence
|
|
37
80
|
|
package/dist/Markdown.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type AnchorHTMLAttributes, type ReactNode } from 'react';
|
|
1
2
|
import type { PluggableList } from 'unified';
|
|
2
3
|
import { type FootnoteRef } from './footnotes.js';
|
|
3
4
|
/**
|
|
@@ -27,9 +28,20 @@ export interface MarkdownProps {
|
|
|
27
28
|
readonly refs: readonly FootnoteRef[];
|
|
28
29
|
} | 'section';
|
|
29
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* What react-markdown hands a component beside the attributes: the hast
|
|
33
|
+
* element it came from. It is not an attribute, and spread onto the element
|
|
34
|
+
* it reached the page as `node="[object Object]"` on every link, image and
|
|
35
|
+
* code span.
|
|
36
|
+
*/
|
|
37
|
+
type WithNode<T> = T & {
|
|
38
|
+
node?: unknown;
|
|
39
|
+
};
|
|
40
|
+
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;
|
|
30
41
|
/**
|
|
31
42
|
* The remark plugins of the reading surface, in their order. `planFootnotes` reads a note
|
|
32
43
|
* through the same list, so it counts the references this surface draws and no other.
|
|
33
44
|
*/
|
|
34
45
|
export declare function readingPlugins(titleOf: (kind: string) => string | undefined): PluggableList;
|
|
35
46
|
export declare function Markdown({ children, source, onToggleTask, writable, footnotes }: MarkdownProps): import("react").JSX.Element;
|
|
47
|
+
export {};
|
package/dist/Markdown.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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';
|
|
@@ -17,6 +17,7 @@ import { MermaidDiagram } from './MermaidDiagram.js';
|
|
|
17
17
|
import { remarkCallouts } from './remark-callouts.js';
|
|
18
18
|
import { remarkBlockIds, remarkComments, remarkHighlight, remarkMathDollarRule } from './remark-ring.js';
|
|
19
19
|
import { CLOBBER_PREFIX, rehypeFootnoteRun, rehypeFootnoteSection } from './footnotes.js';
|
|
20
|
+
import { drawSlot, headings, InBlock, MarkdownBlockquote, MarkdownDiv, MarkdownSection, MarkdownTable, useInBlock, useSlot } from './slots.js';
|
|
20
21
|
import { ordinalAt } from './tasklist.js';
|
|
21
22
|
/**
|
|
22
23
|
* An image, with the dimensions the specification puts in its alt text
|
|
@@ -72,8 +73,9 @@ function Attachment({ name, width, height }) {
|
|
|
72
73
|
return _jsx("span", { className: "attachment-loading", children: name });
|
|
73
74
|
return _jsx(_Fragment, { children: host.renderAttachment({ name, width, height }) });
|
|
74
75
|
}
|
|
75
|
-
function MarkdownAnchor({ href, children, node: _node, ...rest }) {
|
|
76
|
+
export function MarkdownAnchor({ href, children, node: _node, ...rest }) {
|
|
76
77
|
const host = useHost();
|
|
78
|
+
const wikilinkSlot = useSlot('wikilink');
|
|
77
79
|
if (href?.startsWith('attachment:')) {
|
|
78
80
|
/**
|
|
79
81
|
* The address carries the dimension the pipe declared, after the encoded
|
|
@@ -93,7 +95,8 @@ function MarkdownAnchor({ href, children, node: _node, ...rest }) {
|
|
|
93
95
|
return _jsx(Attachment, { name: name, width: measured?.width ?? null, height: measured?.height ?? null });
|
|
94
96
|
}
|
|
95
97
|
if (href?.startsWith('pending:')) {
|
|
96
|
-
|
|
98
|
+
const pending = (label) => (_jsx("span", { className: "wikilink-pending", title: host.strings.pendingLink, children: label }));
|
|
99
|
+
return wikilinkSlot ? drawSlot(wikilinkSlot, { pending: true, children, link: pending }) : pending(children);
|
|
97
100
|
}
|
|
98
101
|
// Refused by `followable`: the text stays on the page and stops being
|
|
99
102
|
// something to click. An author sees they got nothing, which is the same
|
|
@@ -103,11 +106,17 @@ function MarkdownAnchor({ href, children, node: _node, ...rest }) {
|
|
|
103
106
|
}
|
|
104
107
|
// A target no single note answers by name opens its choice in place.
|
|
105
108
|
const choice = choiceTargetOf(href);
|
|
106
|
-
if (choice !== null)
|
|
107
|
-
|
|
109
|
+
if (choice !== null) {
|
|
110
|
+
const link = (label) => _jsx(LinkChoice, { target: choice, children: label });
|
|
111
|
+
if (!wikilinkSlot)
|
|
112
|
+
return link(children);
|
|
113
|
+
return drawSlot(wikilinkSlot, { pending: host.reaches(choice) === 0, children, link });
|
|
114
|
+
}
|
|
108
115
|
// An address of the host's own: its router draws it.
|
|
109
|
-
if (href.startsWith('/'))
|
|
110
|
-
|
|
116
|
+
if (href.startsWith('/')) {
|
|
117
|
+
const link = (label) => _jsx(_Fragment, { children: host.renderLink({ href, className: 'wikilink', children: label }) });
|
|
118
|
+
return wikilinkSlot ? drawSlot(wikilinkSlot, { pending: false, children, link }) : link(children);
|
|
119
|
+
}
|
|
111
120
|
if (href.startsWith('#')) {
|
|
112
121
|
return (_jsx(InPageAnchor, { href: href, ...rest, children: children }));
|
|
113
122
|
}
|
|
@@ -132,10 +141,14 @@ function MarkdownAnchor({ href, children, node: _node, ...rest }) {
|
|
|
132
141
|
* highlighter would otherwise breach it.
|
|
133
142
|
*/
|
|
134
143
|
function MarkdownCode({ className, children, node: _node, ...rest }) {
|
|
135
|
-
const {
|
|
144
|
+
const { scheme } = useHost();
|
|
145
|
+
const inBlock = useInBlock();
|
|
146
|
+
const inlineSlot = useSlot('inlineCode');
|
|
136
147
|
if (className?.includes('language-mermaid')) {
|
|
137
|
-
return _jsx(MermaidDiagram, { code: String(children).trim(),
|
|
148
|
+
return _jsx(MermaidDiagram, { code: String(children).trim(), scheme: scheme });
|
|
138
149
|
}
|
|
150
|
+
if (!inBlock && inlineSlot)
|
|
151
|
+
return drawSlot(inlineSlot, { children });
|
|
139
152
|
const language = highlightLanguage(className);
|
|
140
153
|
if (language !== null) {
|
|
141
154
|
// The source is read and never rewritten: what is on the page is the
|
|
@@ -161,13 +174,31 @@ function MarkdownCode({ className, children, node: _node, ...rest }) {
|
|
|
161
174
|
*/
|
|
162
175
|
function MarkdownPre({ children, node, ...rest }) {
|
|
163
176
|
const { strings } = useHost();
|
|
164
|
-
const
|
|
177
|
+
const slot = useSlot('codeBlock');
|
|
165
178
|
const code = node?.children?.find((child) => child.tagName === 'code');
|
|
166
179
|
const classes = code?.properties?.['className'];
|
|
167
180
|
if (Array.isArray(classes) && classes.includes('language-mermaid')) {
|
|
168
181
|
return _jsx("pre", { ...rest, children: children });
|
|
169
182
|
}
|
|
170
|
-
|
|
183
|
+
// The bytes as typed: the text of the fence, less the newline before its closing line.
|
|
184
|
+
const typed = textOf(code).replace(/\n$/, '');
|
|
185
|
+
const copy = _jsx(CopyButton, { className: "code-copy", label: strings.copy, done: strings.copied, text: () => typed });
|
|
186
|
+
if (slot) {
|
|
187
|
+
const language = Array.isArray(classes)
|
|
188
|
+
? (classes.map(String).find((name) => name.startsWith('language-'))?.slice('language-'.length) ?? null)
|
|
189
|
+
: null;
|
|
190
|
+
return _jsx(InBlock.Provider, { value: true, children: drawSlot(slot, { language, code: typed, children, copy }) });
|
|
191
|
+
}
|
|
192
|
+
return (_jsxs("div", { className: "code-block", children: [_jsx(InBlock.Provider, { value: true, children: _jsx("pre", { ...rest, children: children }) }), copy] }));
|
|
193
|
+
}
|
|
194
|
+
/** The text of a hast element, which for a fence is the code as typed. */
|
|
195
|
+
function textOf(node) {
|
|
196
|
+
if (!node)
|
|
197
|
+
return '';
|
|
198
|
+
const text = node.value;
|
|
199
|
+
if (typeof text === 'string')
|
|
200
|
+
return text;
|
|
201
|
+
return (node.children ?? []).map(textOf).join('');
|
|
171
202
|
}
|
|
172
203
|
/**
|
|
173
204
|
* Whether this list item is a task box, and how it is ticked.
|
|
@@ -191,6 +222,7 @@ function taskState(node) {
|
|
|
191
222
|
* the host, never by the package.
|
|
192
223
|
*/
|
|
193
224
|
function TaskItem({ node, children, className, source, toggleTask, writable, ...rest }) {
|
|
225
|
+
const boxSlot = useSlot('taskBox');
|
|
194
226
|
const element = node;
|
|
195
227
|
const checked = taskState(element);
|
|
196
228
|
if (checked === null) {
|
|
@@ -199,10 +231,14 @@ function TaskItem({ node, children, className, source, toggleTask, writable, ...
|
|
|
199
231
|
const offset = element?.position?.start?.offset;
|
|
200
232
|
const ordinal = offset === undefined ? -1 : ordinalAt(source, offset);
|
|
201
233
|
const enabled = writable && toggleTask !== undefined && ordinal >= 0;
|
|
234
|
+
const toggle = () => {
|
|
235
|
+
if (enabled)
|
|
236
|
+
toggleTask(ordinal);
|
|
237
|
+
};
|
|
202
238
|
return (
|
|
203
239
|
// Our class comes LAST, because the one react-markdown passes in is on
|
|
204
240
|
// `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:
|
|
241
|
+
_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
242
|
}
|
|
207
243
|
/**
|
|
208
244
|
* The children of the item without the checkbox GFM put in front of them.
|
|
@@ -246,7 +282,10 @@ export function Markdown({ children, source, onToggleTask, writable = false, foo
|
|
|
246
282
|
const text = toUnixNewlines(children);
|
|
247
283
|
const { strings } = useHost();
|
|
248
284
|
const calloutTitle = (kind) => Object.hasOwn(strings.callouts, kind) ? strings.callouts[kind] : undefined;
|
|
249
|
-
return (
|
|
285
|
+
return (
|
|
286
|
+
// The pass that draws a cut note's footnotes continues the note rather than opening a run,
|
|
287
|
+
// so its section keeps the space a section takes at the end of a note.
|
|
288
|
+
_jsx("div", { className: footnotes === 'section' ? 'markdown footnotes-run' : 'markdown', children: _jsx(ReactMarkdown, { remarkPlugins: readingPlugins(calloutTitle),
|
|
250
289
|
// Raw HTML is NOT enabled, and its absence is the point: no
|
|
251
290
|
// `rehype-raw` is loaded, so a note carrying `<script>` is text. It is
|
|
252
291
|
// a security boundary rather than a rendering preference, because a
|
|
@@ -278,6 +317,11 @@ export function Markdown({ children, source, onToggleTask, writable = false, foo
|
|
|
278
317
|
code: MarkdownCode,
|
|
279
318
|
pre: MarkdownPre,
|
|
280
319
|
img: MarkdownImage,
|
|
320
|
+
div: MarkdownDiv,
|
|
321
|
+
table: MarkdownTable,
|
|
322
|
+
blockquote: MarkdownBlockquote,
|
|
323
|
+
section: MarkdownSection,
|
|
324
|
+
...headings,
|
|
281
325
|
li: (props) => (_jsx(TaskItem, { ...props, source: source ?? text, toggleTask: onToggleTask, writable: writable })),
|
|
282
326
|
}, children: text }) }));
|
|
283
327
|
}
|
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
|
@@ -4,7 +4,10 @@ import { HOST_KEYS, HostProvider, resolveHost } from './host.js';
|
|
|
4
4
|
import { planFootnotes } from './footnotes.js';
|
|
5
5
|
import { Markdown, readingPlugins } from './Markdown.js';
|
|
6
6
|
import { bodyStart, remoteImageHosts, resolveWikilinks } from './markdown.js';
|
|
7
|
+
import { readProperties } from './properties.js';
|
|
8
|
+
import { Properties } from './PropertiesBlock.js';
|
|
7
9
|
import { taskBoxes } from './tasklist.js';
|
|
10
|
+
import { rootStyle } from './theme.js';
|
|
8
11
|
import { Transclusion } from './Transclusion.js';
|
|
9
12
|
import { splitEmbeds } from './transclusion.js';
|
|
10
13
|
import { wikilinkAddress } from './wikilink-address.js';
|
|
@@ -18,9 +21,10 @@ export function NoteBody({ markdown, writable = false, onToggleTask, ...host })
|
|
|
18
21
|
const resolved = useMemo(() => resolveHost(host),
|
|
19
22
|
// Every member of the contract, so a host that passes the same functions keeps the same host.
|
|
20
23
|
HOST_KEYS.map((key) => host[key]));
|
|
21
|
-
//
|
|
22
|
-
//
|
|
24
|
+
// The frontmatter is drawn apart from the body, as the theme decides, so the
|
|
25
|
+
// runs start after it. Everything below counts task items from there.
|
|
23
26
|
const split = bodyStart(markdown);
|
|
27
|
+
const properties = useMemo(() => readProperties(markdown), [markdown]);
|
|
24
28
|
const body = markdown.slice(split);
|
|
25
29
|
// The body is cut into runs of text and the embeds between them; each run
|
|
26
30
|
// is parsed on its own, as on MemorySmith's reading page.
|
|
@@ -74,7 +78,7 @@ export function NoteBody({ markdown, writable = false, onToggleTask, ...host })
|
|
|
74
78
|
// The runs and whether an embed cut them are the whole input.
|
|
75
79
|
[runs.join('\u0000'), runs.length < segments.length]);
|
|
76
80
|
let run = 0;
|
|
77
|
-
return (_jsx(HostProvider, { value: resolved, children: _jsxs("div", { className:
|
|
81
|
+
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
82
|
if (!('rendered' in segment)) {
|
|
79
83
|
return _jsx(Transclusion, { target: segment.target, anchor: segment.anchor }, index);
|
|
80
84
|
}
|
|
@@ -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,6 +2,7 @@ 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 { drawSlot, useSlot } from './slots.js';
|
|
5
6
|
import { bodyStart, resolveWikilinks } from './markdown.js';
|
|
6
7
|
import { blockOf, demoteEmbeds, isBlockAnchor, sectionOf } from './transclusion.js';
|
|
7
8
|
import { wikilinkAddress } from './wikilink-address.js';
|
|
@@ -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,10 +41,13 @@ 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
52
|
const whole = note.markdown.slice(bodyStart(note.markdown));
|
|
48
53
|
// The caption names the note the content came from, and a note may have no
|
|
@@ -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
|
}
|
package/dist/host.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
2
|
import { type NoteStrings } from './strings.js';
|
|
3
|
+
import { type Scheme, type Theme } from './theme.js';
|
|
3
4
|
/**
|
|
4
5
|
* The host contract: everything the renderer needs from the notebook a note lives in, given by
|
|
5
6
|
* whoever draws it.
|
|
@@ -62,8 +63,17 @@ export interface NoteHost {
|
|
|
62
63
|
readonly renderLink?: (link: LinkRequest) => ReactNode;
|
|
63
64
|
/** The words the page shows, in the reader's language; each one left out is MemorySmith's English. */
|
|
64
65
|
readonly strings?: Partial<NoteStrings>;
|
|
65
|
-
/**
|
|
66
|
-
|
|
66
|
+
/**
|
|
67
|
+
* How the note looks: a theme (`defineTheme`), or `light` or `dark`, which are the default
|
|
68
|
+
* theme — MemorySmith's look — in that scheme. Defaults to the default theme.
|
|
69
|
+
*/
|
|
70
|
+
readonly theme?: Scheme | Theme;
|
|
71
|
+
/**
|
|
72
|
+
* The scheme a theme is drawn in, which also picks the theme of a Mermaid diagram. Defaults to
|
|
73
|
+
* the scheme `theme` names when it is `light` or `dark`, and to `light` otherwise; a theme of one
|
|
74
|
+
* scheme is drawn in it whatever is asked.
|
|
75
|
+
*/
|
|
76
|
+
readonly scheme?: Scheme;
|
|
67
77
|
/**
|
|
68
78
|
* Whether a note that fetched images from other sites says so, naming them, under the body
|
|
69
79
|
* (§7.10). Defaults to `true`: the specification forbids only silence. A host that discloses it
|
|
@@ -127,7 +137,10 @@ export interface ResolvedHost {
|
|
|
127
137
|
readonly renderAttachment: ((file: AttachmentRequest) => ReactNode) | null;
|
|
128
138
|
readonly renderLink: (link: LinkRequest) => ReactNode;
|
|
129
139
|
readonly strings: NoteStrings;
|
|
130
|
-
|
|
140
|
+
/** The theme the note is drawn in. */
|
|
141
|
+
readonly theme: Theme;
|
|
142
|
+
/** The scheme it is drawn in. */
|
|
143
|
+
readonly scheme: Scheme;
|
|
131
144
|
}
|
|
132
145
|
export declare function resolveHost(host: NoteHost): ResolvedHost;
|
|
133
146
|
export declare const HostProvider: import("react").Provider<ResolvedHost>;
|
package/dist/host.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { createContext, useContext } from 'react';
|
|
3
3
|
import { withDefaults } from './strings.js';
|
|
4
|
+
import { DEFAULT_THEME, schemeOf } from './theme.js';
|
|
4
5
|
/**
|
|
5
6
|
* Every member of the contract, by name. The `satisfies` is what makes it complete: a member added
|
|
6
7
|
* to `NoteHost` and not listed here does not compile.
|
|
@@ -19,11 +20,18 @@ export const HOST_KEYS = Object.keys({
|
|
|
19
20
|
renderLink: 1,
|
|
20
21
|
strings: 1,
|
|
21
22
|
theme: 1,
|
|
23
|
+
scheme: 1,
|
|
22
24
|
remoteNotice: 1,
|
|
23
25
|
});
|
|
24
26
|
const nothing = () => null;
|
|
25
27
|
const never = () => false;
|
|
26
28
|
const plainLink = ({ href, className, children }) => (_jsx("a", { className: className, href: href, children: children }));
|
|
29
|
+
function themeOf(host) {
|
|
30
|
+
const given = host.theme ?? DEFAULT_THEME;
|
|
31
|
+
const theme = typeof given === 'string' ? DEFAULT_THEME : given;
|
|
32
|
+
const asked = host.scheme ?? (typeof given === 'string' ? given : 'light');
|
|
33
|
+
return { theme, scheme: schemeOf(theme, asked) };
|
|
34
|
+
}
|
|
27
35
|
export function resolveHost(host) {
|
|
28
36
|
const resolve = host.resolve ?? nothing;
|
|
29
37
|
return {
|
|
@@ -38,7 +46,7 @@ export function resolveHost(host) {
|
|
|
38
46
|
renderAttachment: host.renderAttachment ?? null,
|
|
39
47
|
renderLink: host.renderLink ?? plainLink,
|
|
40
48
|
strings: withDefaults(host.strings),
|
|
41
|
-
|
|
49
|
+
...themeOf(host),
|
|
42
50
|
};
|
|
43
51
|
}
|
|
44
52
|
const HostContext = createContext(resolveHost({}));
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
export { NoteBody, type NoteBodyProps } from './NoteBody.js';
|
|
2
2
|
export type { AttachmentRequest, EmbeddedNote, LinkChoiceNote, LinkChoices, LinkRequest, NoteHost } from './host.js';
|
|
3
3
|
export { ENGLISH, type NoteStrings } from './strings.js';
|
|
4
|
+
export { customPropertyOf, DEFAULT_THEME, defineTheme, THEME_PROPERTIES, THEME_SLOTS, type BlockquoteSlotProps, type CalloutSlotProps, type CodeBlockSlotProps, type DiagramSlotProps, type EmbedSlotProps, type FootnotesSlotProps, type HeadingSlotProps, type InlineCodeSlotProps, type PropertiesSlotProps, type Scheme, type TableSlotProps, type TaskBoxSlotProps, type Theme, type ThemeComponents, type ThemeProperties, type WikilinkSlotProps, } from './theme.js';
|
|
4
5
|
export { followable } from './address.js';
|
|
5
6
|
export { readDimensions, readImageAlt, type ImageAlt } from './image-dimensions.js';
|
|
6
7
|
export { codeRegions, fenceRegions, headingKey, remoteImageHosts, resolveWikilinks, splitFrontmatter, tableRegions, toUnixNewlines, type SplitDocument, } from './markdown.js';
|
|
7
8
|
export { ordinalAt, taskBoxes, taskCheckedAt, toggleTaskAt } from './tasklist.js';
|
|
9
|
+
export { readProperties, type NoteProperties, type PropertyEntry, type PropertyShape } from './properties.js';
|
|
8
10
|
export { DELEGATED_TO_THE_BASE_PARSER, unclassified } from './delegated.js';
|