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.
@@ -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;
@@ -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;