@memorysmithapp/markdown-renderer 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +37 -2
- package/dist/CopyButton.d.ts +27 -0
- package/dist/CopyButton.js +69 -0
- package/dist/LinkChoice.d.ts +28 -0
- package/dist/LinkChoice.js +181 -0
- package/dist/Markdown.d.ts +20 -0
- package/dist/Markdown.js +261 -0
- package/dist/MermaidDiagram.d.ts +16 -0
- package/dist/MermaidDiagram.js +70 -0
- package/dist/NoteBody.d.ts +15 -0
- package/dist/NoteBody.js +79 -0
- package/dist/Transclusion.d.ts +11 -0
- package/dist/Transclusion.js +66 -0
- package/dist/address.d.ts +53 -0
- package/dist/address.js +69 -0
- package/dist/delegated.d.ts +39 -0
- package/dist/delegated.js +63 -0
- package/dist/highlight.d.ts +54 -0
- package/dist/highlight.js +96 -0
- package/dist/host.d.ts +135 -0
- package/dist/host.js +49 -0
- package/dist/image-dimensions.d.ts +33 -0
- package/dist/image-dimensions.js +42 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/markdown.d.ts +68 -0
- package/dist/markdown.js +395 -0
- package/dist/remark-callouts.d.ts +40 -0
- package/dist/remark-callouts.js +108 -0
- package/dist/remark-ring.d.ts +68 -0
- package/dist/remark-ring.js +178 -0
- package/dist/strings.d.ts +55 -0
- package/dist/strings.js +64 -0
- package/dist/tasklist.d.ts +25 -0
- package/dist/tasklist.js +80 -0
- package/dist/transclusion.d.ts +76 -0
- package/dist/transclusion.js +161 -0
- package/dist/wikilink-address.d.ts +8 -0
- package/dist/wikilink-address.js +13 -0
- package/package.json +73 -3
- package/styles.css +865 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
interface MermaidDiagramProps {
|
|
2
|
+
readonly code: string;
|
|
3
|
+
/** The theme the host draws in, which picks Mermaid's own. */
|
|
4
|
+
readonly theme: 'light' | 'dark';
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* A mermaid code block drawn as an SVG diagram (§7.2). The library is heavy, so
|
|
8
|
+
* it is imported lazily, out of the bundle that draws the rest of the note; on
|
|
9
|
+
* any error the code is shown instead, and the error never leaves the diagram.
|
|
10
|
+
*
|
|
11
|
+
* The SVG is the one markup this surface injects, and it is Mermaid's own
|
|
12
|
+
* output, not note-derived HTML: Mermaid renders with its default `strict`
|
|
13
|
+
* security level, which encodes the text of the diagram.
|
|
14
|
+
*/
|
|
15
|
+
export declare function MermaidDiagram({ code, theme }: MermaidDiagramProps): import("react").JSX.Element;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useId, useState } from 'react';
|
|
3
|
+
/**
|
|
4
|
+
* A mermaid code block drawn as an SVG diagram (§7.2). The library is heavy, so
|
|
5
|
+
* it is imported lazily, out of the bundle that draws the rest of the note; on
|
|
6
|
+
* any error the code is shown instead, and the error never leaves the diagram.
|
|
7
|
+
*
|
|
8
|
+
* The SVG is the one markup this surface injects, and it is Mermaid's own
|
|
9
|
+
* output, not note-derived HTML: Mermaid renders with its default `strict`
|
|
10
|
+
* security level, which encodes the text of the diagram.
|
|
11
|
+
*/
|
|
12
|
+
export function MermaidDiagram({ code, theme }) {
|
|
13
|
+
const id = useId().replace(/[^a-zA-Z0-9]/g, '');
|
|
14
|
+
const [svg, setSvg] = useState(null);
|
|
15
|
+
const [failed, setFailed] = useState(false);
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
let alive = true;
|
|
18
|
+
let renderId = null;
|
|
19
|
+
setSvg(null);
|
|
20
|
+
setFailed(false);
|
|
21
|
+
void (async () => {
|
|
22
|
+
try {
|
|
23
|
+
const { default: mermaid } = await import('mermaid');
|
|
24
|
+
// Text metrics depend on the webfont; rendering before it loads
|
|
25
|
+
// truncates node labels.
|
|
26
|
+
await document.fonts?.ready;
|
|
27
|
+
mermaid.initialize({
|
|
28
|
+
startOnLoad: false,
|
|
29
|
+
theme: theme === 'dark' ? 'dark' : 'neutral',
|
|
30
|
+
fontFamily: 'Inter, sans-serif',
|
|
31
|
+
// SVG text labels measure reliably; HTML labels clip inside nodes
|
|
32
|
+
// when the webfont metrics differ from the fallback.
|
|
33
|
+
htmlLabels: false,
|
|
34
|
+
flowchart: { htmlLabels: false },
|
|
35
|
+
});
|
|
36
|
+
/**
|
|
37
|
+
* A diagram Mermaid cannot read is shown as its code without drawing
|
|
38
|
+
* it. Drawing it anyway failed in the right place, the code, and left
|
|
39
|
+
* the scratch container Mermaid draws into — its error bomb included —
|
|
40
|
+
* at the end of <body>, under the whole page.
|
|
41
|
+
*/
|
|
42
|
+
if ((await mermaid.parse(code, { suppressErrors: true })) === false) {
|
|
43
|
+
if (alive)
|
|
44
|
+
setFailed(true);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
renderId = `mmd-${id}-${Date.now()}`;
|
|
48
|
+
const rendered = await mermaid.render(renderId, code);
|
|
49
|
+
if (alive)
|
|
50
|
+
setSvg(rendered.svg);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// And should drawing still fail, what it left behind goes with it.
|
|
54
|
+
if (renderId)
|
|
55
|
+
document.getElementById(`d${renderId}`)?.remove();
|
|
56
|
+
if (alive)
|
|
57
|
+
setFailed(true);
|
|
58
|
+
}
|
|
59
|
+
})();
|
|
60
|
+
return () => {
|
|
61
|
+
alive = false;
|
|
62
|
+
};
|
|
63
|
+
}, [code, id, theme]);
|
|
64
|
+
if (failed) {
|
|
65
|
+
return (_jsx("pre", { children: _jsx("code", { children: code }) }));
|
|
66
|
+
}
|
|
67
|
+
if (!svg)
|
|
68
|
+
return _jsx("div", { className: "mermaid-diagram mermaid-loading" });
|
|
69
|
+
return _jsx("div", { className: "mermaid-diagram", dangerouslySetInnerHTML: { __html: svg } });
|
|
70
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type NoteHost } from './host.js';
|
|
2
|
+
export interface NoteBodyProps extends NoteHost {
|
|
3
|
+
/**
|
|
4
|
+
* The note as the host keeps it, frontmatter included. It is drawn from these bytes and never
|
|
5
|
+
* rewritten; the frontmatter is not drawn, because a host shows it as properties.
|
|
6
|
+
*/
|
|
7
|
+
readonly markdown: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The body of a note, drawn as MemorySmith's reading page draws it.
|
|
11
|
+
*
|
|
12
|
+
* It renders inside `.markdown`, so the stylesheet of the package applies, and asks the host for
|
|
13
|
+
* every piece of notebook data through the props of `NoteHost`.
|
|
14
|
+
*/
|
|
15
|
+
export declare function NoteBody({ markdown, writable, onToggleTask, ...host }: NoteBodyProps): import("react").JSX.Element;
|
package/dist/NoteBody.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo } from 'react';
|
|
3
|
+
import { HOST_KEYS, HostProvider, resolveHost } from './host.js';
|
|
4
|
+
import { Markdown } from './Markdown.js';
|
|
5
|
+
import { bodyStart, remoteImageHosts, resolveWikilinks } from './markdown.js';
|
|
6
|
+
import { taskBoxes } from './tasklist.js';
|
|
7
|
+
import { Transclusion } from './Transclusion.js';
|
|
8
|
+
import { splitEmbeds } from './transclusion.js';
|
|
9
|
+
import { wikilinkAddress } from './wikilink-address.js';
|
|
10
|
+
/**
|
|
11
|
+
* The body of a note, drawn as MemorySmith's reading page draws it.
|
|
12
|
+
*
|
|
13
|
+
* It renders inside `.markdown`, so the stylesheet of the package applies, and asks the host for
|
|
14
|
+
* every piece of notebook data through the props of `NoteHost`.
|
|
15
|
+
*/
|
|
16
|
+
export function NoteBody({ markdown, writable = false, onToggleTask, ...host }) {
|
|
17
|
+
const resolved = useMemo(() => resolveHost(host),
|
|
18
|
+
// Every member of the contract, so a host that passes the same functions keeps the same host.
|
|
19
|
+
HOST_KEYS.map((key) => host[key]));
|
|
20
|
+
// Frontmatter is shown as properties, not as prose, so the reading surface
|
|
21
|
+
// starts after it. Everything below counts task items from there.
|
|
22
|
+
const split = bodyStart(markdown);
|
|
23
|
+
const body = markdown.slice(split);
|
|
24
|
+
// The body is cut into runs of text and the embeds between them; each run
|
|
25
|
+
// is parsed on its own, as on MemorySmith's reading page.
|
|
26
|
+
const segments = splitEmbeds(body, resolved.keeps).map((segment) => segment.kind === 'text'
|
|
27
|
+
? {
|
|
28
|
+
...segment,
|
|
29
|
+
// One resolver for every run: one note is a link, several are the
|
|
30
|
+
// choice, and none is pending and still asks.
|
|
31
|
+
rendered: resolveWikilinks(segment.text, (name) => wikilinkAddress(resolved, name), resolved.keeps),
|
|
32
|
+
}
|
|
33
|
+
: segment);
|
|
34
|
+
/**
|
|
35
|
+
* Whether a click can be mapped back to the bytes at all.
|
|
36
|
+
*
|
|
37
|
+
* The ordinal is counted over the text the PARSER saw, which is the one with
|
|
38
|
+
* the wikilinks already resolved, and it is applied to the text a write
|
|
39
|
+
* sends, which is the original. Those two agree on how many boxes there are
|
|
40
|
+
* and in what order, almost always — and `- [[x]]` is where they do not,
|
|
41
|
+
* because resolving it produces `- [x](/url)`, which is a task box to any
|
|
42
|
+
* GFM reader. Counting on one side and writing on the other would then
|
|
43
|
+
* toggle a different item, silently, which is the worst defect this surface
|
|
44
|
+
* can have. When the two disagree the boxes are read-only: not writing is
|
|
45
|
+
* always better than writing the wrong one.
|
|
46
|
+
*/
|
|
47
|
+
const mappable = taskBoxes(body).length ===
|
|
48
|
+
segments.reduce((total, segment) => total + ('rendered' in segment ? taskBoxes(segment.rendered).length : 0), 0);
|
|
49
|
+
// The ordinal a click carries is of the WHOLE note, and each rendered run
|
|
50
|
+
// only knows its own. The count carried across the runs is what bridges
|
|
51
|
+
// the two, and it has to include anything above the body.
|
|
52
|
+
let seen = taskBoxes(markdown.slice(0, split)).length;
|
|
53
|
+
/**
|
|
54
|
+
* Whom this note asked the browser to talk to (§7.10).
|
|
55
|
+
*
|
|
56
|
+
* The specification admits three answers about a remote image and forbids
|
|
57
|
+
* only silence: never fetching, fetching on the reader's action, or fetching
|
|
58
|
+
* and saying so. This surface fetches — the destination reaches `<img src>`,
|
|
59
|
+
* and React even preloads it — so this line is the half that says so, and
|
|
60
|
+
* the reason it has to exist is that this is the one of the three answers a
|
|
61
|
+
* person cannot work out from the page.
|
|
62
|
+
*
|
|
63
|
+
* It appears only where it is true, naming the hosts, because a notice on
|
|
64
|
+
* every note would be a thing to scroll past rather than a thing to read.
|
|
65
|
+
*/
|
|
66
|
+
const hosts = resolved.remoteNotice ? remoteImageHosts(body) : [];
|
|
67
|
+
return (_jsx(HostProvider, { value: resolved, children: _jsxs("div", { className: "markdown", "data-theme": resolved.theme, children: [segments.map((segment, index) => {
|
|
68
|
+
if (!('rendered' in segment)) {
|
|
69
|
+
return _jsx(Transclusion, { target: segment.target, anchor: segment.anchor }, index);
|
|
70
|
+
}
|
|
71
|
+
const base = seen;
|
|
72
|
+
seen += taskBoxes(segment.rendered).length;
|
|
73
|
+
return (_jsx(Markdown, {
|
|
74
|
+
// The text the ordinal is counted over, stated rather than left to
|
|
75
|
+
// a default: it is the one the parser reports offsets into, and
|
|
76
|
+
// `mappable` above is what makes it safe to write back through.
|
|
77
|
+
source: segment.rendered, onToggleTask: onToggleTask && ((ordinal) => onToggleTask(base + ordinal)), writable: writable && mappable, children: segment.rendered }, index));
|
|
78
|
+
}), hosts.length > 0 && _jsx("p", { className: "remote-notice", children: resolved.strings.remoteImages(hosts) })] }) }));
|
|
79
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One transcluded block: the content of another note, shown in place.
|
|
3
|
+
*
|
|
4
|
+
* It always says where it came from. A passage pasted without provenance is
|
|
5
|
+
* indistinguishable from what the author wrote, and a notebook rests on
|
|
6
|
+
* knowing who asserted what.
|
|
7
|
+
*/
|
|
8
|
+
export declare function Transclusion({ target, anchor }: {
|
|
9
|
+
target: string;
|
|
10
|
+
anchor: string | null;
|
|
11
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { useHost } from './host.js';
|
|
4
|
+
import { Markdown } from './Markdown.js';
|
|
5
|
+
import { bodyStart, resolveWikilinks } from './markdown.js';
|
|
6
|
+
import { blockOf, demoteEmbeds, isBlockAnchor, sectionOf } from './transclusion.js';
|
|
7
|
+
import { wikilinkAddress } from './wikilink-address.js';
|
|
8
|
+
/** The shape of what is coming, in the position it will occupy, while the host answers. */
|
|
9
|
+
function TransclusionSkeleton({ loading }) {
|
|
10
|
+
return (_jsxs("div", { className: "skeleton-region", "aria-busy": "true", "aria-live": "polite", children: [_jsx("span", { className: "visually-hidden", children: loading }), _jsxs("figure", { className: "embed embed-skeleton", children: [_jsx("div", { className: "embed-body", children: _jsxs("span", { className: "skeleton-text", children: [_jsx("span", { className: "skeleton", style: { width: '96%', height: '1rem' } }), _jsx("span", { className: "skeleton", style: { width: '58%', height: '1rem' } })] }) }), _jsx("figcaption", { className: "embed-source", children: _jsx("span", { className: "skeleton", style: { width: '8rem', height: '0.8rem' } }) })] })] }));
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* One transcluded block: the content of another note, shown in place.
|
|
14
|
+
*
|
|
15
|
+
* It always says where it came from. A passage pasted without provenance is
|
|
16
|
+
* indistinguishable from what the author wrote, and a notebook rests on
|
|
17
|
+
* knowing who asserted what.
|
|
18
|
+
*/
|
|
19
|
+
export function Transclusion({ target, anchor }) {
|
|
20
|
+
const host = useHost();
|
|
21
|
+
const { strings } = host;
|
|
22
|
+
const name = target.normalize('NFC');
|
|
23
|
+
// A transclusion expands ONE note, so it expands what a wikilink would
|
|
24
|
+
// navigate to: an ambiguous target is a choice a reader makes and not a
|
|
25
|
+
// passage the page can inline on their behalf.
|
|
26
|
+
const one = host.loadNote !== null && host.reaches(name) === 1;
|
|
27
|
+
const [loaded, setLoaded] = useState({ state: 'loading' });
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (!one || !host.loadNote)
|
|
30
|
+
return;
|
|
31
|
+
let alive = true;
|
|
32
|
+
setLoaded({ state: 'loading' });
|
|
33
|
+
host.loadNote(name).then((note) => alive && setLoaded(note ? { state: 'ready', note } : { state: 'missing' }), () => alive && setLoaded({ state: 'missing' }));
|
|
34
|
+
return () => {
|
|
35
|
+
alive = false;
|
|
36
|
+
};
|
|
37
|
+
}, [one, host, name]);
|
|
38
|
+
// The same pending marker a wikilink uses. A notebook is read most while it
|
|
39
|
+
// is still being written, so a target that does not exist yet is an
|
|
40
|
+
// expected state and never an error that stops the page.
|
|
41
|
+
if (!one || loaded.state === 'missing') {
|
|
42
|
+
return (_jsx("p", { className: "embed-pending", children: _jsx("span", { className: "wikilink-pending", title: strings.pendingLink, children: target }) }));
|
|
43
|
+
}
|
|
44
|
+
if (loaded.state === 'loading')
|
|
45
|
+
return _jsx(TransclusionSkeleton, { loading: strings.loading });
|
|
46
|
+
const { note } = loaded;
|
|
47
|
+
const whole = note.markdown.slice(bodyStart(note.markdown));
|
|
48
|
+
// The caption names the note the content came from, and a note may have no
|
|
49
|
+
// name.
|
|
50
|
+
const sourceLabel = note.name ?? strings.unnamedNote;
|
|
51
|
+
// `#^id` addresses a BLOCK and `#Section` a heading. The two are told apart
|
|
52
|
+
// by the marker and not by trying one and falling back to the other, or a
|
|
53
|
+
// section named `^x` and a block called `x` would answer for each other.
|
|
54
|
+
const cut = !anchor ? whole : isBlockAnchor(anchor) ? blockOf(whole, anchor.slice(1)) : sectionOf(whole, anchor);
|
|
55
|
+
const caption = anchor ? `${sourceLabel} › ${anchor}` : sourceLabel;
|
|
56
|
+
return (_jsxs("figure", { className: "embed", children: [_jsx("div", { className: "embed-body", children: cut === null ? (_jsx("p", { className: "embed-missing", children: strings.embedSectionMissing(anchor ?? '') })) : (
|
|
57
|
+
/**
|
|
58
|
+
* Resolved, like every other run of the note. Without this every
|
|
59
|
+
* `[[link]]` inside transcluded content reached the page with its
|
|
60
|
+
* brackets — and the sharpest case is the one the one-level rule
|
|
61
|
+
* creates itself: `demoteEmbeds` turns an embed found in embedded
|
|
62
|
+
* content into a wikilink, and that embed is drawn as A LINK to its
|
|
63
|
+
* target. Raw text is not a link.
|
|
64
|
+
*/
|
|
65
|
+
_jsx(Markdown, { children: resolveWikilinks(demoteEmbeds(cut, host.keeps), (each) => wikilinkAddress(host, each), host.keeps) })) }), _jsx("figcaption", { className: "embed-source", children: note.address === null ? caption : host.renderLink({ href: note.address, children: caption }) })] }));
|
|
66
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which addresses the reading surface will follow.
|
|
3
|
+
*
|
|
4
|
+
* `react-markdown` normally passes every URL through a filter that keeps the
|
|
5
|
+
* safe schemes and drops the rest, and the reading page once had it **switched
|
|
6
|
+
* off** — `urlTransform={(url) => url}`. There was a reason: an unresolved
|
|
7
|
+
* wikilink is rendered as `[text](pending:target)`, and the stock filter does
|
|
8
|
+
* not know `pending:`, so it erased the pending link from the page. A whole
|
|
9
|
+
* protection was traded for one scheme.
|
|
10
|
+
*
|
|
11
|
+
* This is that trade undone. The list says what passes, never what is
|
|
12
|
+
* refused, because a notebook is written by several people and by agents, and a
|
|
13
|
+
* denylist is a promise to have thought of everything.
|
|
14
|
+
*
|
|
15
|
+
* **What passes, and why:**
|
|
16
|
+
*
|
|
17
|
+
* - `pending:` — ours, and the reason the filter was off in the first place;
|
|
18
|
+
* and `attachment:` and `choice:`, ours too.
|
|
19
|
+
* - A path of the application (`/`), a relative one (`./`, `../`) and an
|
|
20
|
+
* anchor of the page (`#`).
|
|
21
|
+
* - `http:` and `https:` — the web, which is what a note cites.
|
|
22
|
+
* - `mailto:` — a person, which is what a note cites next most often.
|
|
23
|
+
*
|
|
24
|
+
* **What does not, and why each one:**
|
|
25
|
+
*
|
|
26
|
+
* - **`obsidian://`, and any other scheme of an editor. Decided, not
|
|
27
|
+
* overlooked.** It opens the notebook of whoever has that editor installed and
|
|
28
|
+
* that notebook on that machine, so the link works for the person who wrote it
|
|
29
|
+
* and does nothing at all for everybody else who opens the note — a promise
|
|
30
|
+
* the host cannot keep on behalf of a tool it does not run. A notebook is
|
|
31
|
+
* served to whoever reads it, and an address only its author can follow is
|
|
32
|
+
* not an address.
|
|
33
|
+
* - `data:` — a whole page carried inside the address. `data:text/html` is a
|
|
34
|
+
* script running in the reader's session, offered by whoever wrote the note.
|
|
35
|
+
* - `javascript:` and `vbscript:` — script written where an address goes.
|
|
36
|
+
* React blocks the first one on its own; that it does is not a reason to
|
|
37
|
+
* hand it the chance.
|
|
38
|
+
* - `file:` — the disk of whoever is reading, addressed by somebody else.
|
|
39
|
+
*
|
|
40
|
+
* A refused address is not silently swallowed: the text stays on the page and
|
|
41
|
+
* stops being clickable, which is what the anchor of the surface does with an
|
|
42
|
+
* empty href. An author sees they got nothing, the same answer the
|
|
43
|
+
* specification gives to a notation it does not implement.
|
|
44
|
+
*/
|
|
45
|
+
/**
|
|
46
|
+
* The address as it will be written, or the empty string when this surface
|
|
47
|
+
* will not follow it.
|
|
48
|
+
*
|
|
49
|
+
* An address with no scheme is a path, an anchor or a relative target, and it
|
|
50
|
+
* cannot reach outside the page on its own: those pass untouched, which is
|
|
51
|
+
* what keeps `./note.md`, `/v/a-notebook/note/x` and `#a-heading` working.
|
|
52
|
+
*/
|
|
53
|
+
export declare function followable(url: string): string;
|
package/dist/address.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which addresses the reading surface will follow.
|
|
3
|
+
*
|
|
4
|
+
* `react-markdown` normally passes every URL through a filter that keeps the
|
|
5
|
+
* safe schemes and drops the rest, and the reading page once had it **switched
|
|
6
|
+
* off** — `urlTransform={(url) => url}`. There was a reason: an unresolved
|
|
7
|
+
* wikilink is rendered as `[text](pending:target)`, and the stock filter does
|
|
8
|
+
* not know `pending:`, so it erased the pending link from the page. A whole
|
|
9
|
+
* protection was traded for one scheme.
|
|
10
|
+
*
|
|
11
|
+
* This is that trade undone. The list says what passes, never what is
|
|
12
|
+
* refused, because a notebook is written by several people and by agents, and a
|
|
13
|
+
* denylist is a promise to have thought of everything.
|
|
14
|
+
*
|
|
15
|
+
* **What passes, and why:**
|
|
16
|
+
*
|
|
17
|
+
* - `pending:` — ours, and the reason the filter was off in the first place;
|
|
18
|
+
* and `attachment:` and `choice:`, ours too.
|
|
19
|
+
* - A path of the application (`/`), a relative one (`./`, `../`) and an
|
|
20
|
+
* anchor of the page (`#`).
|
|
21
|
+
* - `http:` and `https:` — the web, which is what a note cites.
|
|
22
|
+
* - `mailto:` — a person, which is what a note cites next most often.
|
|
23
|
+
*
|
|
24
|
+
* **What does not, and why each one:**
|
|
25
|
+
*
|
|
26
|
+
* - **`obsidian://`, and any other scheme of an editor. Decided, not
|
|
27
|
+
* overlooked.** It opens the notebook of whoever has that editor installed and
|
|
28
|
+
* that notebook on that machine, so the link works for the person who wrote it
|
|
29
|
+
* and does nothing at all for everybody else who opens the note — a promise
|
|
30
|
+
* the host cannot keep on behalf of a tool it does not run. A notebook is
|
|
31
|
+
* served to whoever reads it, and an address only its author can follow is
|
|
32
|
+
* not an address.
|
|
33
|
+
* - `data:` — a whole page carried inside the address. `data:text/html` is a
|
|
34
|
+
* script running in the reader's session, offered by whoever wrote the note.
|
|
35
|
+
* - `javascript:` and `vbscript:` — script written where an address goes.
|
|
36
|
+
* React blocks the first one on its own; that it does is not a reason to
|
|
37
|
+
* hand it the chance.
|
|
38
|
+
* - `file:` — the disk of whoever is reading, addressed by somebody else.
|
|
39
|
+
*
|
|
40
|
+
* A refused address is not silently swallowed: the text stays on the page and
|
|
41
|
+
* stops being clickable, which is what the anchor of the surface does with an
|
|
42
|
+
* empty href. An author sees they got nothing, the same answer the
|
|
43
|
+
* specification gives to a notation it does not implement.
|
|
44
|
+
*/
|
|
45
|
+
/**
|
|
46
|
+
* `pending:`, `attachment:` and `choice:` are this surface's own: a wikilink
|
|
47
|
+
* that matched no note, a reference to a file the notebook keeps beside its
|
|
48
|
+
* notes, and a wikilink several notes may answer. None of them leaves the page
|
|
49
|
+
* — the anchor of the surface turns each into what it is — and they are listed
|
|
50
|
+
* here so the filter does not empty them into the refusal case, which says
|
|
51
|
+
* something else entirely.
|
|
52
|
+
*/
|
|
53
|
+
const ALLOWED_SCHEME = /^(https?|mailto|pending|attachment|choice):/i;
|
|
54
|
+
/** Anything of the shape `word:`, which is what makes an address absolute. */
|
|
55
|
+
const HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
56
|
+
/**
|
|
57
|
+
* The address as it will be written, or the empty string when this surface
|
|
58
|
+
* will not follow it.
|
|
59
|
+
*
|
|
60
|
+
* An address with no scheme is a path, an anchor or a relative target, and it
|
|
61
|
+
* cannot reach outside the page on its own: those pass untouched, which is
|
|
62
|
+
* what keeps `./note.md`, `/v/a-notebook/note/x` and `#a-heading` working.
|
|
63
|
+
*/
|
|
64
|
+
export function followable(url) {
|
|
65
|
+
const trimmed = url.trim();
|
|
66
|
+
if (!HAS_SCHEME.test(trimmed))
|
|
67
|
+
return url;
|
|
68
|
+
return ALLOWED_SCHEME.test(trimmed) ? url : '';
|
|
69
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The entries of the specification whose rendering this renderer does NOT
|
|
3
|
+
* assert, because the base parser is what produces them.
|
|
4
|
+
*
|
|
5
|
+
* **This list is the renderer's decision, and it used to be a field.** Until
|
|
6
|
+
* specification v0.4.0 every entry carried a `ring`, and the reading-surface
|
|
7
|
+
* expectations were scoped to everything outside the `base` one. The
|
|
8
|
+
* specification dropped the tier for a good reason: an implementation is asked
|
|
9
|
+
* for the notation the document lists and not for a specification in full. But
|
|
10
|
+
* the reason the guard was scoped did not go away with the field, so the scope
|
|
11
|
+
* is written here instead of being inferred from data that no longer says it.
|
|
12
|
+
*
|
|
13
|
+
* The reason: asserting that emphasis renders as `<em>` is a claim about
|
|
14
|
+
* react-markdown and not about this surface. CommonMark is the floor every
|
|
15
|
+
* renderer already stands on; what the conformance test proves is what the
|
|
16
|
+
* specification adds on top of it.
|
|
17
|
+
*
|
|
18
|
+
* **Two things this list is not.**
|
|
19
|
+
*
|
|
20
|
+
* It is not a claim that these forms are untested: the crossings — where the
|
|
21
|
+
* specification changes what a base form MEANS, such as a link inside a code
|
|
22
|
+
* span, an embed that looks like an image, or `---` under a paragraph — are
|
|
23
|
+
* asserted by hand in the conformance test.
|
|
24
|
+
*
|
|
25
|
+
* And it is not allowed to silently absorb a new entry. The conformance test
|
|
26
|
+
* asserts that every `reading-surface` entry is either expected there or listed
|
|
27
|
+
* here, so a form the specification adds in a later version fails the build
|
|
28
|
+
* until somebody classifies it.
|
|
29
|
+
*/
|
|
30
|
+
export declare const DELEGATED_TO_THE_BASE_PARSER: ReadonlySet<string>;
|
|
31
|
+
/**
|
|
32
|
+
* The `reading-surface` entries of a specification that are neither asserted
|
|
33
|
+
* nor delegated: the ones a renderer would silently not implement. Empty is the
|
|
34
|
+
* only answer a conforming renderer gives.
|
|
35
|
+
*/
|
|
36
|
+
export declare function unclassified(notations: ReadonlyArray<{
|
|
37
|
+
readonly id: string;
|
|
38
|
+
readonly reader: string;
|
|
39
|
+
}>, asserted: ReadonlySet<string>, delegated?: ReadonlySet<string>): string[];
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The entries of the specification whose rendering this renderer does NOT
|
|
3
|
+
* assert, because the base parser is what produces them.
|
|
4
|
+
*
|
|
5
|
+
* **This list is the renderer's decision, and it used to be a field.** Until
|
|
6
|
+
* specification v0.4.0 every entry carried a `ring`, and the reading-surface
|
|
7
|
+
* expectations were scoped to everything outside the `base` one. The
|
|
8
|
+
* specification dropped the tier for a good reason: an implementation is asked
|
|
9
|
+
* for the notation the document lists and not for a specification in full. But
|
|
10
|
+
* the reason the guard was scoped did not go away with the field, so the scope
|
|
11
|
+
* is written here instead of being inferred from data that no longer says it.
|
|
12
|
+
*
|
|
13
|
+
* The reason: asserting that emphasis renders as `<em>` is a claim about
|
|
14
|
+
* react-markdown and not about this surface. CommonMark is the floor every
|
|
15
|
+
* renderer already stands on; what the conformance test proves is what the
|
|
16
|
+
* specification adds on top of it.
|
|
17
|
+
*
|
|
18
|
+
* **Two things this list is not.**
|
|
19
|
+
*
|
|
20
|
+
* It is not a claim that these forms are untested: the crossings — where the
|
|
21
|
+
* specification changes what a base form MEANS, such as a link inside a code
|
|
22
|
+
* span, an embed that looks like an image, or `---` under a paragraph — are
|
|
23
|
+
* asserted by hand in the conformance test.
|
|
24
|
+
*
|
|
25
|
+
* And it is not allowed to silently absorb a new entry. The conformance test
|
|
26
|
+
* asserts that every `reading-surface` entry is either expected there or listed
|
|
27
|
+
* here, so a form the specification adds in a later version fails the build
|
|
28
|
+
* until somebody classifies it.
|
|
29
|
+
*/
|
|
30
|
+
export const DELEGATED_TO_THE_BASE_PARSER = new Set([
|
|
31
|
+
'paragraph',
|
|
32
|
+
'backslash-escape',
|
|
33
|
+
'character-reference',
|
|
34
|
+
'heading-atx',
|
|
35
|
+
'heading-setext',
|
|
36
|
+
'thematic-break',
|
|
37
|
+
'block-quote',
|
|
38
|
+
'list-bullet',
|
|
39
|
+
'list-ordered',
|
|
40
|
+
'code-fenced',
|
|
41
|
+
'code-indented',
|
|
42
|
+
'link-reference-definition',
|
|
43
|
+
'code-span',
|
|
44
|
+
'emphasis',
|
|
45
|
+
'strong',
|
|
46
|
+
'strong-emphasis',
|
|
47
|
+
'link-inline',
|
|
48
|
+
'link-reference',
|
|
49
|
+
'image',
|
|
50
|
+
'autolink',
|
|
51
|
+
'hard-line-break',
|
|
52
|
+
]);
|
|
53
|
+
/**
|
|
54
|
+
* The `reading-surface` entries of a specification that are neither asserted
|
|
55
|
+
* nor delegated: the ones a renderer would silently not implement. Empty is the
|
|
56
|
+
* only answer a conforming renderer gives.
|
|
57
|
+
*/
|
|
58
|
+
export function unclassified(notations, asserted, delegated = DELEGATED_TO_THE_BASE_PARSER) {
|
|
59
|
+
return notations
|
|
60
|
+
.filter((entry) => entry.reader === 'reading-surface')
|
|
61
|
+
.map((entry) => entry.id)
|
|
62
|
+
.filter((id) => !asserted.has(id) && !delegated.has(id));
|
|
63
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Syntax highlighting for a fenced block that names its language.
|
|
3
|
+
*
|
|
4
|
+
* **This is presentation and nothing else.** The specification attaches a rendering
|
|
5
|
+
* rule to exactly one info string, `mermaid`, and says of every other one only
|
|
6
|
+
* that it names the language by convention. So highlighting is a product
|
|
7
|
+
* decision rather than a conformance obligation — §8 is explicit that an
|
|
8
|
+
* implementation MAY render an undescribed form however it likes — and the
|
|
9
|
+
* four rules below are the ones the specification *does* impose on anything sitting
|
|
10
|
+
* this close to the body of a note.
|
|
11
|
+
*
|
|
12
|
+
* **The bytes never change.** Highlighting is display. §7.6 sets the principle
|
|
13
|
+
* for comments — the bytes stay in the file and a tool that returns the note
|
|
14
|
+
* returns them — and §7.11 states it as behaviour, where a task toggle writes
|
|
15
|
+
* back exactly the one character that changed. Nothing here touches the
|
|
16
|
+
* source: the tokens are read from it and the string is not rewritten.
|
|
17
|
+
*
|
|
18
|
+
* **An unknown language renders as plain code, never as nothing.** The specification
|
|
19
|
+
* sets that pattern twice, for the diagram it cannot draw (§7.2) and for the
|
|
20
|
+
* mathematics it cannot typeset (§7.8): show the source, never hide it. A
|
|
21
|
+
* language `refractor` does not carry falls through to exactly what was
|
|
22
|
+
* rendered before this existed.
|
|
23
|
+
*
|
|
24
|
+
* **`mermaid` keeps its branch**, and it is checked before this is reached. It
|
|
25
|
+
* is the one info string with a rendering rule, and a highlighter installed in
|
|
26
|
+
* front of that check would swallow a diagram.
|
|
27
|
+
*
|
|
28
|
+
* **The markup arrives as elements and never as an HTML string.** §7.9 refuses
|
|
29
|
+
* raw HTML as a security boundary — a notebook is written by several people and
|
|
30
|
+
* by agents, and a page that renders arbitrary HTML out of one is a script
|
|
31
|
+
* injection whose trigger is written by whoever wrote the note. A highlighter
|
|
32
|
+
* returning HTML to be injected reopens that door with the note body as its
|
|
33
|
+
* input. `refractor` produces a hast tree, which is converted to React
|
|
34
|
+
* elements, so nothing is ever parsed as markup.
|
|
35
|
+
*
|
|
36
|
+
* **The languages are enumerated rather than bundled.** Prism carries close to
|
|
37
|
+
* three hundred grammars and a knowledge notebook writes in a handful. The list
|
|
38
|
+
* is what a notebook of MemorySmith plausibly holds — the languages of its own
|
|
39
|
+
* repository, the two query languages, the three configuration formats and the
|
|
40
|
+
* two shells — and adding one is a line, which is the point: a bundle nobody
|
|
41
|
+
* chose is a bundle nobody can defend. It is MemorySmith's list, unchanged, so
|
|
42
|
+
* a fence is highlighted here exactly when it is highlighted there.
|
|
43
|
+
*/
|
|
44
|
+
import type { Root } from 'hast';
|
|
45
|
+
/**
|
|
46
|
+
* The language of a fenced block, from the class react-markdown puts on it.
|
|
47
|
+
*
|
|
48
|
+
* `null` for a block with no info string, for one whose language is not
|
|
49
|
+
* carried, and for `mermaid` — which never reaches here, and is refused
|
|
50
|
+
* explicitly so that a future caller cannot make it.
|
|
51
|
+
*/
|
|
52
|
+
export declare function highlightLanguage(className: string | undefined): string | null;
|
|
53
|
+
/** The tokens of `code` in `language`, as a hast tree. Never an HTML string. */
|
|
54
|
+
export declare function highlightTree(code: string, language: string): Root;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Syntax highlighting for a fenced block that names its language.
|
|
3
|
+
*
|
|
4
|
+
* **This is presentation and nothing else.** The specification attaches a rendering
|
|
5
|
+
* rule to exactly one info string, `mermaid`, and says of every other one only
|
|
6
|
+
* that it names the language by convention. So highlighting is a product
|
|
7
|
+
* decision rather than a conformance obligation — §8 is explicit that an
|
|
8
|
+
* implementation MAY render an undescribed form however it likes — and the
|
|
9
|
+
* four rules below are the ones the specification *does* impose on anything sitting
|
|
10
|
+
* this close to the body of a note.
|
|
11
|
+
*
|
|
12
|
+
* **The bytes never change.** Highlighting is display. §7.6 sets the principle
|
|
13
|
+
* for comments — the bytes stay in the file and a tool that returns the note
|
|
14
|
+
* returns them — and §7.11 states it as behaviour, where a task toggle writes
|
|
15
|
+
* back exactly the one character that changed. Nothing here touches the
|
|
16
|
+
* source: the tokens are read from it and the string is not rewritten.
|
|
17
|
+
*
|
|
18
|
+
* **An unknown language renders as plain code, never as nothing.** The specification
|
|
19
|
+
* sets that pattern twice, for the diagram it cannot draw (§7.2) and for the
|
|
20
|
+
* mathematics it cannot typeset (§7.8): show the source, never hide it. A
|
|
21
|
+
* language `refractor` does not carry falls through to exactly what was
|
|
22
|
+
* rendered before this existed.
|
|
23
|
+
*
|
|
24
|
+
* **`mermaid` keeps its branch**, and it is checked before this is reached. It
|
|
25
|
+
* is the one info string with a rendering rule, and a highlighter installed in
|
|
26
|
+
* front of that check would swallow a diagram.
|
|
27
|
+
*
|
|
28
|
+
* **The markup arrives as elements and never as an HTML string.** §7.9 refuses
|
|
29
|
+
* raw HTML as a security boundary — a notebook is written by several people and
|
|
30
|
+
* by agents, and a page that renders arbitrary HTML out of one is a script
|
|
31
|
+
* injection whose trigger is written by whoever wrote the note. A highlighter
|
|
32
|
+
* returning HTML to be injected reopens that door with the note body as its
|
|
33
|
+
* input. `refractor` produces a hast tree, which is converted to React
|
|
34
|
+
* elements, so nothing is ever parsed as markup.
|
|
35
|
+
*
|
|
36
|
+
* **The languages are enumerated rather than bundled.** Prism carries close to
|
|
37
|
+
* three hundred grammars and a knowledge notebook writes in a handful. The list
|
|
38
|
+
* is what a notebook of MemorySmith plausibly holds — the languages of its own
|
|
39
|
+
* repository, the two query languages, the three configuration formats and the
|
|
40
|
+
* two shells — and adding one is a line, which is the point: a bundle nobody
|
|
41
|
+
* chose is a bundle nobody can defend. It is MemorySmith's list, unchanged, so
|
|
42
|
+
* a fence is highlighted here exactly when it is highlighted there.
|
|
43
|
+
*/
|
|
44
|
+
import { refractor } from 'refractor/core';
|
|
45
|
+
import bash from 'refractor/bash';
|
|
46
|
+
import css from 'refractor/css';
|
|
47
|
+
import diff from 'refractor/diff';
|
|
48
|
+
import graphql from 'refractor/graphql';
|
|
49
|
+
import ini from 'refractor/ini';
|
|
50
|
+
import java from 'refractor/java';
|
|
51
|
+
import javascript from 'refractor/javascript';
|
|
52
|
+
import json from 'refractor/json';
|
|
53
|
+
import markdown from 'refractor/markdown';
|
|
54
|
+
import python from 'refractor/python';
|
|
55
|
+
import sql from 'refractor/sql';
|
|
56
|
+
import toml from 'refractor/toml';
|
|
57
|
+
import tsx from 'refractor/tsx';
|
|
58
|
+
import typescript from 'refractor/typescript';
|
|
59
|
+
import yaml from 'refractor/yaml';
|
|
60
|
+
for (const language of [
|
|
61
|
+
bash,
|
|
62
|
+
css,
|
|
63
|
+
diff,
|
|
64
|
+
graphql,
|
|
65
|
+
ini,
|
|
66
|
+
java,
|
|
67
|
+
javascript,
|
|
68
|
+
json,
|
|
69
|
+
markdown,
|
|
70
|
+
python,
|
|
71
|
+
sql,
|
|
72
|
+
toml,
|
|
73
|
+
tsx,
|
|
74
|
+
typescript,
|
|
75
|
+
yaml,
|
|
76
|
+
]) {
|
|
77
|
+
refractor.register(language);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The language of a fenced block, from the class react-markdown puts on it.
|
|
81
|
+
*
|
|
82
|
+
* `null` for a block with no info string, for one whose language is not
|
|
83
|
+
* carried, and for `mermaid` — which never reaches here, and is refused
|
|
84
|
+
* explicitly so that a future caller cannot make it.
|
|
85
|
+
*/
|
|
86
|
+
export function highlightLanguage(className) {
|
|
87
|
+
const match = /(?:^|\s)language-([A-Za-z0-9#+._-]+)/.exec(className ?? '');
|
|
88
|
+
const named = match?.[1]?.toLowerCase();
|
|
89
|
+
if (!named || named === 'mermaid')
|
|
90
|
+
return null;
|
|
91
|
+
return refractor.registered(named) ? named : null;
|
|
92
|
+
}
|
|
93
|
+
/** The tokens of `code` in `language`, as a hast tree. Never an HTML string. */
|
|
94
|
+
export function highlightTree(code, language) {
|
|
95
|
+
return refractor.highlight(code, language);
|
|
96
|
+
}
|