myst-to-react 0.1.14
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 +6 -0
- package/package.json +48 -0
- package/src/admonitions.tsx +183 -0
- package/src/basic.tsx +217 -0
- package/src/cite.tsx +94 -0
- package/src/code.tsx +119 -0
- package/src/components/ClickPopover.tsx +56 -0
- package/src/components/CopyIcon.tsx +40 -0
- package/src/components/HoverPopover.tsx +60 -0
- package/src/components/LinkCard.tsx +42 -0
- package/src/convertToReact.ts +33 -0
- package/src/crossReference.tsx +139 -0
- package/src/extensions/chemicalFormula.tsx +42 -0
- package/src/extensions/index.tsx +10 -0
- package/src/extensions/siunits.tsx +15 -0
- package/src/footnotes.tsx +30 -0
- package/src/heading.tsx +68 -0
- package/src/iframe.tsx +42 -0
- package/src/image.tsx +72 -0
- package/src/index.tsx +59 -0
- package/src/inlineError.tsx +15 -0
- package/src/links/index.tsx +132 -0
- package/src/links/rrid.tsx +81 -0
- package/src/links/wiki.tsx +119 -0
- package/src/math.tsx +81 -0
- package/src/mermaid.tsx +49 -0
- package/src/myst.tsx +185 -0
- package/src/output/components.tsx +34 -0
- package/src/output/error.tsx +20 -0
- package/src/output/hooks.ts +127 -0
- package/src/output/index.tsx +7 -0
- package/src/output/jupyter.tsx +86 -0
- package/src/output/output.tsx +79 -0
- package/src/output/outputBlock.tsx +21 -0
- package/src/output/safe.tsx +84 -0
- package/src/output/selectors.ts +15 -0
- package/src/output/stream.tsx +18 -0
- package/src/reactive.tsx +64 -0
- package/src/tabs.tsx +58 -0
- package/src/types.ts +6 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { ElementType } from 'react';
|
|
2
|
+
import React, { useState } from 'react';
|
|
3
|
+
import { Popover, Transition } from '@headlessui/react';
|
|
4
|
+
import { usePopper } from 'react-popper';
|
|
5
|
+
import classNames from 'classnames';
|
|
6
|
+
|
|
7
|
+
export function ClickPopover({
|
|
8
|
+
children,
|
|
9
|
+
card,
|
|
10
|
+
as = 'cite',
|
|
11
|
+
}: {
|
|
12
|
+
children: React.ReactNode;
|
|
13
|
+
card: React.ReactNode | ((args: { open: boolean; close: () => void }) => React.ReactNode);
|
|
14
|
+
as?: ElementType;
|
|
15
|
+
}) {
|
|
16
|
+
const [referenceElement, setReferenceElement] = useState<HTMLSpanElement | null>(null);
|
|
17
|
+
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
|
18
|
+
const { styles, attributes } = usePopper(referenceElement, popperElement);
|
|
19
|
+
return (
|
|
20
|
+
<Popover as="span">
|
|
21
|
+
{({ open, close }) => (
|
|
22
|
+
<>
|
|
23
|
+
<Popover.Button
|
|
24
|
+
ref={setReferenceElement}
|
|
25
|
+
as={as}
|
|
26
|
+
className={classNames(
|
|
27
|
+
{ 'border-dotted': !open },
|
|
28
|
+
'cursor-zoom-in border-b-2 border-b-blue-600 hover:text-blue-600 hover:border-solid',
|
|
29
|
+
{ 'text-blue-600 border-solid': open },
|
|
30
|
+
)}
|
|
31
|
+
>
|
|
32
|
+
{children}
|
|
33
|
+
</Popover.Button>
|
|
34
|
+
<Popover.Panel
|
|
35
|
+
className="exclude-from-outline absolute z-30 sm:max-w-[500px]"
|
|
36
|
+
ref={setPopperElement}
|
|
37
|
+
style={{ ...styles.popper }}
|
|
38
|
+
{...attributes.popper}
|
|
39
|
+
>
|
|
40
|
+
<Transition
|
|
41
|
+
className="my-2 p-4 shadow-xl bg-white dark:bg-zinc-900 text-sm dark:text-white rounded border-2 border-slate-200 dark:border-zinc-500 break-words"
|
|
42
|
+
enter="transition ease-out duration-200"
|
|
43
|
+
enterFrom="opacity-0 translate-y-1"
|
|
44
|
+
enterTo="opacity-100 translate-y-0"
|
|
45
|
+
leave="transition ease-in duration-150"
|
|
46
|
+
leaveFrom="opacity-100 translate-y-0"
|
|
47
|
+
leaveTo="opacity-0 translate-y-1"
|
|
48
|
+
>
|
|
49
|
+
{open && (typeof card === 'function' ? card({ open, close }) : card)}
|
|
50
|
+
</Transition>
|
|
51
|
+
</Popover.Panel>
|
|
52
|
+
</>
|
|
53
|
+
)}
|
|
54
|
+
</Popover>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import DuplicateIcon from '@heroicons/react/outline/DuplicateIcon';
|
|
2
|
+
import CheckIcon from '@heroicons/react/outline/CheckIcon';
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import classNames from 'classnames';
|
|
5
|
+
|
|
6
|
+
export function CopyIcon({ text }: { text: string }) {
|
|
7
|
+
const [copied, setCopied] = useState(false);
|
|
8
|
+
const onClick = () => {
|
|
9
|
+
if (copied) return;
|
|
10
|
+
navigator.clipboard.writeText(text).then(() => {
|
|
11
|
+
setCopied(true);
|
|
12
|
+
setTimeout(() => setCopied(false), 3000);
|
|
13
|
+
});
|
|
14
|
+
};
|
|
15
|
+
return (
|
|
16
|
+
<button
|
|
17
|
+
title={copied ? 'Copied!!' : 'Copy to Clipboard'}
|
|
18
|
+
suppressHydrationWarning
|
|
19
|
+
className={classNames(
|
|
20
|
+
'inline-flex items-center opacity-60 hover:opacity-100 active:opacity-40 cursor-pointer ml-2',
|
|
21
|
+
'transition-color duration-200 ease-in-out',
|
|
22
|
+
{
|
|
23
|
+
// This is hidden if on the server or no clipboard access
|
|
24
|
+
'sr-only hidden': typeof document === 'undefined' || !navigator.clipboard,
|
|
25
|
+
'text-primary-500 border-primary-500': !copied,
|
|
26
|
+
'text-success border-success ': copied,
|
|
27
|
+
},
|
|
28
|
+
)}
|
|
29
|
+
onClick={onClick}
|
|
30
|
+
aria-pressed={copied ? 'true' : 'false'}
|
|
31
|
+
aria-label="Copy code to clipboard"
|
|
32
|
+
>
|
|
33
|
+
{copied ? (
|
|
34
|
+
<CheckIcon className="w-[24px] h-[24px] text-success" />
|
|
35
|
+
) : (
|
|
36
|
+
<DuplicateIcon className="w-[24px] h-[24px]" />
|
|
37
|
+
)}
|
|
38
|
+
</button>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import React, { useRef, useState } from 'react';
|
|
2
|
+
import { Transition } from '@headlessui/react';
|
|
3
|
+
import { usePopper } from 'react-popper';
|
|
4
|
+
|
|
5
|
+
export function HoverPopover({
|
|
6
|
+
children,
|
|
7
|
+
card,
|
|
8
|
+
}: {
|
|
9
|
+
children: React.ReactNode;
|
|
10
|
+
card: React.ReactNode | ((args: { open: boolean; close?: () => void }) => React.ReactNode);
|
|
11
|
+
}) {
|
|
12
|
+
const buttonRef = useRef<HTMLSpanElement | null>(null);
|
|
13
|
+
const timeoutDuration = 300;
|
|
14
|
+
const [open, setOpen] = useState(false);
|
|
15
|
+
let openTimeout: NodeJS.Timeout | undefined;
|
|
16
|
+
let closeTimeout: NodeJS.Timeout | undefined;
|
|
17
|
+
|
|
18
|
+
const onMouseEnter = () => {
|
|
19
|
+
clearTimeout(closeTimeout);
|
|
20
|
+
if (open) return;
|
|
21
|
+
setOpen(true);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const onMouseLeave = () => {
|
|
25
|
+
clearTimeout(openTimeout);
|
|
26
|
+
if (!open) return;
|
|
27
|
+
closeTimeout = setTimeout(() => setOpen(false), timeoutDuration);
|
|
28
|
+
};
|
|
29
|
+
const [popperElement, setPopperElement] = useState<HTMLSpanElement | null>(null);
|
|
30
|
+
const { styles, attributes } = usePopper(buttonRef.current, popperElement, {
|
|
31
|
+
placement: 'bottom-start',
|
|
32
|
+
});
|
|
33
|
+
return (
|
|
34
|
+
<span onMouseLeave={onMouseLeave}>
|
|
35
|
+
<span ref={buttonRef} onMouseMove={onMouseEnter} onMouseEnter={onMouseEnter}>
|
|
36
|
+
{children}
|
|
37
|
+
</span>
|
|
38
|
+
<span
|
|
39
|
+
className="exclude-from-outline absolute z-30 sm:max-w-[500px]"
|
|
40
|
+
ref={setPopperElement}
|
|
41
|
+
style={{ ...styles.popper }}
|
|
42
|
+
{...attributes.popper}
|
|
43
|
+
>
|
|
44
|
+
<Transition
|
|
45
|
+
className="my-2 p-4 shadow-xl bg-white dark:bg-zinc-900 text-sm dark:text-white rounded border border-slate-100 dark:border-zinc-500 break-words"
|
|
46
|
+
enter="transition ease-out duration-200"
|
|
47
|
+
enterFrom="opacity-0 translate-y-1"
|
|
48
|
+
enterTo="opacity-100 translate-y-0"
|
|
49
|
+
leave="transition ease-in duration-150"
|
|
50
|
+
leaveFrom="opacity-100 translate-y-0"
|
|
51
|
+
leaveTo="opacity-0 translate-y-1"
|
|
52
|
+
onMouseEnter={onMouseEnter}
|
|
53
|
+
show={open}
|
|
54
|
+
>
|
|
55
|
+
{typeof card === 'function' ? card({ open, close: () => setOpen(false) }) : card}
|
|
56
|
+
</Transition>
|
|
57
|
+
</span>
|
|
58
|
+
</span>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Link as RemixLink } from '@remix-run/react';
|
|
2
|
+
import { ExternalLinkIcon } from '@heroicons/react/outline';
|
|
3
|
+
import classNames from 'classnames';
|
|
4
|
+
|
|
5
|
+
export function LinkCard({
|
|
6
|
+
url,
|
|
7
|
+
title,
|
|
8
|
+
internal = false,
|
|
9
|
+
loading = false,
|
|
10
|
+
description,
|
|
11
|
+
thumbnail,
|
|
12
|
+
}: {
|
|
13
|
+
url: string;
|
|
14
|
+
internal?: boolean;
|
|
15
|
+
loading?: boolean;
|
|
16
|
+
title: React.ReactNode;
|
|
17
|
+
description?: React.ReactNode;
|
|
18
|
+
thumbnail?: string;
|
|
19
|
+
}) {
|
|
20
|
+
return (
|
|
21
|
+
<div className={classNames('w-[300px]', { 'animate-pulse': loading })}>
|
|
22
|
+
{internal && (
|
|
23
|
+
<RemixLink to={url} className="block" prefetch="intent">
|
|
24
|
+
{title}
|
|
25
|
+
</RemixLink>
|
|
26
|
+
)}
|
|
27
|
+
{!internal && (
|
|
28
|
+
<a href={url} className="block" target="_blank" rel="noreferrer">
|
|
29
|
+
<ExternalLinkIcon className="w-4 h-4 float-right" />
|
|
30
|
+
{title}
|
|
31
|
+
</a>
|
|
32
|
+
)}
|
|
33
|
+
{!loading && thumbnail && (
|
|
34
|
+
<img src={thumbnail} className="w-full max-h-[200px] object-cover object-top" />
|
|
35
|
+
)}
|
|
36
|
+
{loading && (
|
|
37
|
+
<div className="animate-pulse bg-slate-100 dark:bg-slate-800 w-full h-[150px] mt-4" />
|
|
38
|
+
)}
|
|
39
|
+
{!loading && description && <div className="mt-2">{description}</div>}
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
import { createElement as e } from 'react';
|
|
3
|
+
import type { NodeRenderer } from './types';
|
|
4
|
+
import type { GenericNode } from 'mystjs';
|
|
5
|
+
|
|
6
|
+
export function toReact(
|
|
7
|
+
fragment: GenericNode[],
|
|
8
|
+
replacements: Record<string, NodeRenderer>,
|
|
9
|
+
): React.ReactNode {
|
|
10
|
+
if (fragment.length === 0) return undefined;
|
|
11
|
+
return fragment.map((node) => {
|
|
12
|
+
if (node.type === 'text') return node.value;
|
|
13
|
+
const custom = replacements[node.type] as NodeRenderer | undefined;
|
|
14
|
+
if (node.children) {
|
|
15
|
+
const children = toReact(node.children, replacements);
|
|
16
|
+
if (custom) {
|
|
17
|
+
return custom(node, children);
|
|
18
|
+
}
|
|
19
|
+
return e('div', { key: node.key }, children);
|
|
20
|
+
}
|
|
21
|
+
if (custom) {
|
|
22
|
+
return custom(node, node.value);
|
|
23
|
+
}
|
|
24
|
+
return e('span', { children: node.value, key: node.key });
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function mystToReact(
|
|
29
|
+
content: GenericNode,
|
|
30
|
+
replacements: Record<string, NodeRenderer>,
|
|
31
|
+
): React.ReactNode {
|
|
32
|
+
return toReact(content.children ?? [], replacements);
|
|
33
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { selectAll } from 'unist-util-select';
|
|
2
|
+
import { EXIT, SKIP, visit } from 'unist-util-visit';
|
|
3
|
+
import type { Root } from 'mdast';
|
|
4
|
+
import type { CrossReference } from 'myst-spec';
|
|
5
|
+
import LinkIcon from '@heroicons/react/outline/LinkIcon';
|
|
6
|
+
import ExternalLinkIcon from '@heroicons/react/outline/ExternalLinkIcon';
|
|
7
|
+
import { useReferences, useXRefState, XRefProvider } from '@curvenote/ui-providers';
|
|
8
|
+
import { useParse } from '.';
|
|
9
|
+
import { InlineError } from './inlineError';
|
|
10
|
+
import type { NodeRenderer } from './types';
|
|
11
|
+
import { ClickPopover } from './components/ClickPopover';
|
|
12
|
+
import useSWR from 'swr';
|
|
13
|
+
import { Link } from '@remix-run/react';
|
|
14
|
+
|
|
15
|
+
const MAX_NODES = 3; // Max nodes to show after a header
|
|
16
|
+
|
|
17
|
+
function selectMdastNodes(mdast: Root, identifier: string) {
|
|
18
|
+
const identifiers = selectAll(`[identifier=${identifier}],[key=${identifier}]`, mdast);
|
|
19
|
+
const container = identifiers.filter(({ type }) => type === 'container' || type === 'math')[0];
|
|
20
|
+
const nodes = container ? [container] : [];
|
|
21
|
+
if (nodes.length === 0 && identifiers.length > 0 && mdast) {
|
|
22
|
+
let begin = false;
|
|
23
|
+
visit(mdast, (node) => {
|
|
24
|
+
if ((begin && node.type === 'heading') || nodes.length >= MAX_NODES) {
|
|
25
|
+
return EXIT;
|
|
26
|
+
}
|
|
27
|
+
if ((node as any).identifier === identifier && node.type === 'heading') begin = true;
|
|
28
|
+
if (begin) {
|
|
29
|
+
nodes.push(node);
|
|
30
|
+
return SKIP; // Don't traverse the children
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
if (nodes.length === 0 && identifiers.length > 0) {
|
|
35
|
+
// If we haven't found anything, push the first identifier that isn't a cite or crossReference
|
|
36
|
+
const resolved = identifiers.filter(
|
|
37
|
+
(node) => node.type !== 'crossReference' && node.type !== 'cite',
|
|
38
|
+
)[0];
|
|
39
|
+
nodes.push(resolved ?? identifiers[0]);
|
|
40
|
+
}
|
|
41
|
+
return nodes;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const fetcher = (...args: Parameters<typeof fetch>) =>
|
|
45
|
+
fetch(...args).then((res) => {
|
|
46
|
+
if (res.status === 200) return res.json();
|
|
47
|
+
throw new Error(`Content returned with status ${res.status}.`);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export function ReferencedContent({
|
|
51
|
+
identifier,
|
|
52
|
+
close,
|
|
53
|
+
}: {
|
|
54
|
+
identifier: string;
|
|
55
|
+
close: () => void;
|
|
56
|
+
}) {
|
|
57
|
+
const { remote, url } = useXRefState();
|
|
58
|
+
const external = url?.startsWith('http') ?? false;
|
|
59
|
+
const lookupUrl = external ? `/api/lookup?url=${url}.json` : `${url}.json`;
|
|
60
|
+
const { data, error } = useSWR(remote ? lookupUrl : null, fetcher);
|
|
61
|
+
const references = useReferences();
|
|
62
|
+
const mdast = data?.mdast ?? references?.article;
|
|
63
|
+
const nodes = selectMdastNodes(mdast, identifier);
|
|
64
|
+
const htmlId = (nodes[0] as any)?.html_id || (nodes[0] as any)?.identifier;
|
|
65
|
+
const link = `${url}${htmlId ? `#${htmlId}` : ''}`;
|
|
66
|
+
const onClose = () => {
|
|
67
|
+
// Need to close it first because the ID is on the page twice ...
|
|
68
|
+
close();
|
|
69
|
+
setTimeout(() => {
|
|
70
|
+
const el = document.getElementById(htmlId);
|
|
71
|
+
el?.scrollIntoView({ behavior: 'smooth' });
|
|
72
|
+
}, 10);
|
|
73
|
+
};
|
|
74
|
+
const children = useParse({ type: 'block', children: nodes });
|
|
75
|
+
if (remote && !data) {
|
|
76
|
+
return <>Loading...</>;
|
|
77
|
+
}
|
|
78
|
+
if (remote && error) {
|
|
79
|
+
return <>Error loading remote page.</>;
|
|
80
|
+
}
|
|
81
|
+
if (!nodes || nodes.length === 0) {
|
|
82
|
+
return (
|
|
83
|
+
<>
|
|
84
|
+
<InlineError value={identifier || 'No Label'} message="Cross Reference Not Found" />
|
|
85
|
+
</>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return (
|
|
89
|
+
<div className="exclude-from-outline">
|
|
90
|
+
{remote && external && (
|
|
91
|
+
<a href={link} className="absolute top-4 right-1" target="_blank">
|
|
92
|
+
<ExternalLinkIcon className="w-4 h-4" />
|
|
93
|
+
</a>
|
|
94
|
+
)}
|
|
95
|
+
{remote && !external && (
|
|
96
|
+
<Link to={link} className="absolute top-4 right-1" prefetch="intent">
|
|
97
|
+
<ExternalLinkIcon className="w-4 h-4" />
|
|
98
|
+
</Link>
|
|
99
|
+
)}
|
|
100
|
+
{!remote && (
|
|
101
|
+
<button onClick={onClose} className="absolute top-4 right-1">
|
|
102
|
+
<LinkIcon className="w-4 h-4" />
|
|
103
|
+
</button>
|
|
104
|
+
)}
|
|
105
|
+
<div className="popout">{children}</div>
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const CrossReferenceNode: NodeRenderer<CrossReference> = (node, children) => {
|
|
111
|
+
if (!children) {
|
|
112
|
+
return (
|
|
113
|
+
<InlineError
|
|
114
|
+
key={node.key}
|
|
115
|
+
value={node.label || node.identifier || 'No Label'}
|
|
116
|
+
message="Cross Reference Not Found"
|
|
117
|
+
/>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
return (
|
|
121
|
+
<ClickPopover
|
|
122
|
+
key={node.key}
|
|
123
|
+
card={({ close }) => (
|
|
124
|
+
<XRefProvider remote={(node as any).remote} url={(node as any).url}>
|
|
125
|
+
<ReferencedContent identifier={node.identifier as string} close={close} />
|
|
126
|
+
</XRefProvider>
|
|
127
|
+
)}
|
|
128
|
+
as="span"
|
|
129
|
+
>
|
|
130
|
+
{children}
|
|
131
|
+
</ClickPopover>
|
|
132
|
+
);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const CROSS_REFERENCE_RENDERERS = {
|
|
136
|
+
crossReference: CrossReferenceNode,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export default CROSS_REFERENCE_RENDERERS;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { NodeRenderer } from '../types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Separate numbers and letters so that numbers can be <sub>2</sub>
|
|
5
|
+
* @param formula a string H2O
|
|
6
|
+
* @returns ['H', '2', '0']
|
|
7
|
+
*/
|
|
8
|
+
function parseFormula(formula?: string) {
|
|
9
|
+
return [...(formula ?? '')].reduce((acc, letter) => {
|
|
10
|
+
const last = acc.pop();
|
|
11
|
+
const isNumber = letter.match(/[0-9]/);
|
|
12
|
+
const lastIsNumber = last?.match(/[0-9]/);
|
|
13
|
+
if (isNumber) {
|
|
14
|
+
if (lastIsNumber) {
|
|
15
|
+
return [...acc, `${last ?? ''}${letter}`];
|
|
16
|
+
}
|
|
17
|
+
return [...acc, last, letter].filter((v) => !!v) as string[];
|
|
18
|
+
}
|
|
19
|
+
if (lastIsNumber) {
|
|
20
|
+
return [...acc, last, letter].filter((v) => !!v) as string[];
|
|
21
|
+
}
|
|
22
|
+
return [...acc, `${last ?? ''}${letter}`];
|
|
23
|
+
}, [] as string[]);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const ChemicalFormula: NodeRenderer = (node) => {
|
|
27
|
+
const parts = parseFormula(node.value);
|
|
28
|
+
return (
|
|
29
|
+
<code key={node.key} className="text-inherit">
|
|
30
|
+
{parts.map((letter, index) => {
|
|
31
|
+
if (letter.match(/[0-9]/)) return <sub key={index}>{letter}</sub>;
|
|
32
|
+
return <span key={index}>{letter}</span>;
|
|
33
|
+
})}
|
|
34
|
+
</code>
|
|
35
|
+
);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const CHEM_RENDERERS = {
|
|
39
|
+
chemicalFormula: ChemicalFormula,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export default CHEM_RENDERERS;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { NodeRenderer } from '../types';
|
|
2
|
+
import CHEM_RENDERERS from './chemicalFormula';
|
|
3
|
+
import SI_RENDERERS from './siunits';
|
|
4
|
+
|
|
5
|
+
const EXT_RENDERERS: Record<string, NodeRenderer> = {
|
|
6
|
+
...CHEM_RENDERERS,
|
|
7
|
+
...SI_RENDERERS,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export default EXT_RENDERERS;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { NodeRenderer } from '../types';
|
|
2
|
+
|
|
3
|
+
export const SIUnits: NodeRenderer = (node) => {
|
|
4
|
+
return (
|
|
5
|
+
<code key={node.key} className="text-inherit" title={`${node.num} ${node.units}`}>
|
|
6
|
+
{node.value}
|
|
7
|
+
</code>
|
|
8
|
+
);
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const SI_RENDERERS = {
|
|
12
|
+
si: SIUnits,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export default SI_RENDERERS;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { GenericParent } from 'mystjs';
|
|
2
|
+
import { useReferences } from '@curvenote/ui-providers';
|
|
3
|
+
import type { NodeRenderer } from '.';
|
|
4
|
+
import { useParse } from '.';
|
|
5
|
+
import { ClickPopover } from './components/ClickPopover';
|
|
6
|
+
|
|
7
|
+
export function FootnoteDefinition({ identifier }: { identifier: string }) {
|
|
8
|
+
const references = useReferences();
|
|
9
|
+
const node = references?.footnotes?.[identifier];
|
|
10
|
+
const children = useParse(node as GenericParent);
|
|
11
|
+
return <>{children}</>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const FootnoteReference: NodeRenderer = (node) => {
|
|
15
|
+
return (
|
|
16
|
+
<ClickPopover
|
|
17
|
+
key={node.key}
|
|
18
|
+
card={<FootnoteDefinition identifier={node.identifier as string} />}
|
|
19
|
+
as="span"
|
|
20
|
+
>
|
|
21
|
+
<sup>[{node.identifier}]</sup>
|
|
22
|
+
</ClickPopover>
|
|
23
|
+
);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const FOOTNOTE_RENDERERS = {
|
|
27
|
+
footnoteReference: FootnoteReference,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export default FOOTNOTE_RENDERERS;
|
package/src/heading.tsx
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Heading } from 'myst-spec';
|
|
2
|
+
import type { NodeRenderer } from './types';
|
|
3
|
+
import { createElement as e } from 'react';
|
|
4
|
+
import classNames from 'classnames';
|
|
5
|
+
import { useXRefState } from '@curvenote/ui-providers';
|
|
6
|
+
|
|
7
|
+
function getHelpHashText(kind: string) {
|
|
8
|
+
return `Link to this ${kind}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function HashLink({
|
|
12
|
+
id,
|
|
13
|
+
kind,
|
|
14
|
+
align = 'left',
|
|
15
|
+
}: {
|
|
16
|
+
id: string;
|
|
17
|
+
kind: string;
|
|
18
|
+
align?: 'left' | 'right';
|
|
19
|
+
}) {
|
|
20
|
+
const { inCrossRef } = useXRefState();
|
|
21
|
+
// If we are in a cross-reference popout, hide the hash links
|
|
22
|
+
if (inCrossRef) return null;
|
|
23
|
+
const helpText = getHelpHashText(kind);
|
|
24
|
+
return (
|
|
25
|
+
<a
|
|
26
|
+
className={classNames(
|
|
27
|
+
'select-none absolute top-0 font-normal no-underline transition-opacity opacity-0 group-hover:opacity-70',
|
|
28
|
+
{
|
|
29
|
+
'left-0 -translate-x-[100%] pr-3': align === 'left',
|
|
30
|
+
'right-0 translate-x-[100%] pl-3': align === 'right',
|
|
31
|
+
},
|
|
32
|
+
)}
|
|
33
|
+
href={`#${id}`}
|
|
34
|
+
title={helpText}
|
|
35
|
+
aria-label={helpText}
|
|
36
|
+
>
|
|
37
|
+
#
|
|
38
|
+
</a>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const Heading: NodeRenderer<Heading> = (node, children) => {
|
|
43
|
+
const { enumerator, depth, key, identifier, html_id } = node;
|
|
44
|
+
const id = html_id || identifier || key;
|
|
45
|
+
const textContent = (
|
|
46
|
+
<>
|
|
47
|
+
<HashLink id={id} align="left" kind="Section" />
|
|
48
|
+
{enumerator && <span className="select-none mr-3">{enumerator}</span>}
|
|
49
|
+
<span className="heading-text">{children}</span>
|
|
50
|
+
</>
|
|
51
|
+
);
|
|
52
|
+
// The `heading-text` class is picked up in the Outline to select without the enumerator and "#" link
|
|
53
|
+
return e(
|
|
54
|
+
`h${depth}`,
|
|
55
|
+
{
|
|
56
|
+
key: node.key,
|
|
57
|
+
id,
|
|
58
|
+
className: 'relative group',
|
|
59
|
+
},
|
|
60
|
+
textContent,
|
|
61
|
+
);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const HEADING_RENDERERS = {
|
|
65
|
+
heading: Heading,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export default HEADING_RENDERERS;
|
package/src/iframe.tsx
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { NodeRenderer } from './types';
|
|
2
|
+
|
|
3
|
+
export const IFrame: NodeRenderer = (node) => {
|
|
4
|
+
return (
|
|
5
|
+
<figure
|
|
6
|
+
key={node.key}
|
|
7
|
+
id={node.label || undefined}
|
|
8
|
+
style={{ textAlign: node.align || 'center' }}
|
|
9
|
+
>
|
|
10
|
+
<div
|
|
11
|
+
style={{
|
|
12
|
+
position: 'relative',
|
|
13
|
+
display: 'inline-block',
|
|
14
|
+
paddingBottom: '60%',
|
|
15
|
+
width: `min(max(${node.width || 70}%, 500px), 100%)`,
|
|
16
|
+
}}
|
|
17
|
+
>
|
|
18
|
+
<iframe
|
|
19
|
+
width="100%"
|
|
20
|
+
height="100%"
|
|
21
|
+
src={node.src}
|
|
22
|
+
allowFullScreen
|
|
23
|
+
allow="autoplay"
|
|
24
|
+
style={{
|
|
25
|
+
width: '100%',
|
|
26
|
+
height: '100%',
|
|
27
|
+
position: 'absolute',
|
|
28
|
+
top: 0,
|
|
29
|
+
left: 0,
|
|
30
|
+
border: 'none',
|
|
31
|
+
}}
|
|
32
|
+
></iframe>
|
|
33
|
+
</div>
|
|
34
|
+
</figure>
|
|
35
|
+
);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const IFRAME_RENDERERS = {
|
|
39
|
+
iframe: IFrame,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export default IFRAME_RENDERERS;
|
package/src/image.tsx
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Alignment } from '@curvenote/blocks';
|
|
2
|
+
import type { Image as ImageNode } from 'myst-spec';
|
|
3
|
+
import type { NodeRenderer } from './types';
|
|
4
|
+
|
|
5
|
+
function alignToMargin(align: string) {
|
|
6
|
+
switch (align) {
|
|
7
|
+
case 'left':
|
|
8
|
+
return { marginRight: 'auto' };
|
|
9
|
+
case 'right':
|
|
10
|
+
return { marginLeft: 'auto' };
|
|
11
|
+
case 'center':
|
|
12
|
+
return { margin: '0 auto' };
|
|
13
|
+
default:
|
|
14
|
+
return {};
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function Picture({
|
|
19
|
+
src,
|
|
20
|
+
srcOptimized,
|
|
21
|
+
urlSource,
|
|
22
|
+
align = 'center',
|
|
23
|
+
alt,
|
|
24
|
+
width,
|
|
25
|
+
}: {
|
|
26
|
+
src: string;
|
|
27
|
+
srcOptimized?: string;
|
|
28
|
+
urlSource?: string;
|
|
29
|
+
alt?: string;
|
|
30
|
+
width?: string;
|
|
31
|
+
align?: Alignment;
|
|
32
|
+
}) {
|
|
33
|
+
const image = (
|
|
34
|
+
<img
|
|
35
|
+
style={{
|
|
36
|
+
width: width || undefined,
|
|
37
|
+
...alignToMargin(align),
|
|
38
|
+
}}
|
|
39
|
+
src={src}
|
|
40
|
+
alt={alt}
|
|
41
|
+
data-canonical-url={urlSource}
|
|
42
|
+
/>
|
|
43
|
+
);
|
|
44
|
+
if (!srcOptimized) return image;
|
|
45
|
+
return (
|
|
46
|
+
<picture>
|
|
47
|
+
<source srcSet={srcOptimized} type="image/webp" />
|
|
48
|
+
{image}
|
|
49
|
+
</picture>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const Image: NodeRenderer<ImageNode> = (node) => {
|
|
54
|
+
return (
|
|
55
|
+
<Picture
|
|
56
|
+
key={node.key}
|
|
57
|
+
src={node.url}
|
|
58
|
+
srcOptimized={(node as any).urlOptimized}
|
|
59
|
+
alt={node.alt || node.title}
|
|
60
|
+
width={node.width || undefined}
|
|
61
|
+
align={node.align}
|
|
62
|
+
// Note that sourceUrl is for backwards compatibility
|
|
63
|
+
urlSource={(node as any).urlSource || (node as any).sourceUrl}
|
|
64
|
+
/>
|
|
65
|
+
);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const IMAGE_RENDERERS = {
|
|
69
|
+
image: Image,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export default IMAGE_RENDERERS;
|