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/src/myst.tsx ADDED
@@ -0,0 +1,185 @@
1
+ import { useParse } from '.';
2
+ import { VFile } from 'vfile';
3
+ import type { LatexResult } from 'myst-to-tex'; // Only import the type!!
4
+ import type { VFileMessage } from 'vfile-message';
5
+ import yaml from 'js-yaml';
6
+ import type { References } from '@curvenote/site-common';
7
+ import type { PageFrontmatter } from 'myst-frontmatter';
8
+ import type { NodeRenderer } from './types';
9
+ import React, { useEffect, useRef, useState } from 'react';
10
+ import classnames from 'classnames';
11
+ import ExclamationIcon from '@heroicons/react/outline/ExclamationIcon';
12
+ import ExclamationCircleIcon from '@heroicons/react/outline/ExclamationCircleIcon';
13
+ import InformationCircleIcon from '@heroicons/react/outline/InformationCircleIcon';
14
+ import { CopyIcon } from './components/CopyIcon';
15
+ import { CodeBlock } from './code';
16
+ import { ReferencesProvider } from '@curvenote/ui-providers';
17
+
18
+ async function parse(text: string, defaultFrontmatter?: PageFrontmatter) {
19
+ // Ensure that any imports from myst are async and scoped to this function
20
+ const { MyST, unified, visit } = await import('mystjs');
21
+ const {
22
+ mathPlugin,
23
+ footnotesPlugin,
24
+ keysPlugin,
25
+ basicTransformationsPlugin,
26
+ enumerateTargetsPlugin,
27
+ resolveReferencesPlugin,
28
+ WikiTransformer,
29
+ DOITransformer,
30
+ RRIDTransformer,
31
+ linksPlugin,
32
+ ReferenceState,
33
+ getFrontmatter,
34
+ } = await import('myst-transforms');
35
+ const { default: mystToTex } = await import('myst-to-tex');
36
+ const myst = new MyST();
37
+ const mdast = myst.parse(text);
38
+ const linkTransforms = [new WikiTransformer(), new DOITransformer(), new RRIDTransformer()];
39
+ // For the mdast that we show, duplicate, strip positions and dump to yaml
40
+ // Also run some of the transforms, like the links
41
+ const mdastPre = JSON.parse(JSON.stringify(mdast));
42
+ unified().use(linksPlugin, { transformers: linkTransforms }).runSync(mdastPre);
43
+ visit(mdastPre, (n) => delete n.position);
44
+ const mdastString = yaml.dump(mdastPre);
45
+ const htmlString = myst.renderMdast(mdastPre);
46
+ const file = new VFile();
47
+ const references = {
48
+ cite: { order: [], data: {} },
49
+ footnotes: {},
50
+ };
51
+ const { frontmatter } = getFrontmatter(mdast, { removeYaml: true, removeHeading: false });
52
+ const state = new ReferenceState({
53
+ numbering: frontmatter.numbering ?? defaultFrontmatter?.numbering,
54
+ file,
55
+ });
56
+ unified()
57
+ .use(basicTransformationsPlugin)
58
+ .use(mathPlugin, { macros: frontmatter?.math ?? {} }) // This must happen before enumeration, as it can add labels
59
+ .use(enumerateTargetsPlugin, { state })
60
+ .use(linksPlugin, { transformers: linkTransforms })
61
+ .use(footnotesPlugin, { references })
62
+ .use(resolveReferencesPlugin, { state })
63
+ .use(keysPlugin)
64
+ .runSync(mdast as any, file);
65
+ const tex = unified()
66
+ .use(mystToTex)
67
+ .stringify(mdast as any).result as LatexResult;
68
+ const content = useParse(mdast as any);
69
+ return {
70
+ yaml: mdastString,
71
+ references: { ...references, article: mdast } as References,
72
+ html: htmlString,
73
+ tex: tex.value,
74
+ content,
75
+ warnings: file.messages,
76
+ };
77
+ }
78
+
79
+ export function MySTRenderer({ value, numbering }: { value: string; numbering: any }) {
80
+ const area = useRef<HTMLTextAreaElement | null>(null);
81
+ const [text, setText] = useState<string>(value.trim());
82
+ const [references, setReferences] = useState<References>({});
83
+ const [mdastYaml, setYaml] = useState<string>('Loading...');
84
+ const [html, setHtml] = useState<string>('Loading...');
85
+ const [tex, setTex] = useState<string>('Loading...');
86
+ const [warnings, setWarnings] = useState<VFileMessage[]>([]);
87
+ const [content, setContent] = useState<React.ReactNode>(<p>{value}</p>);
88
+ const [previewType, setPreviewType] = useState('DEMO');
89
+
90
+ useEffect(() => {
91
+ const ref = { current: true };
92
+ parse(text, { numbering }).then((result) => {
93
+ if (!ref.current) return;
94
+ setYaml(result.yaml);
95
+ setReferences(result.references);
96
+ setHtml(result.html);
97
+ setTex(result.tex);
98
+ setContent(result.content);
99
+ setWarnings(result.warnings);
100
+ });
101
+ return () => {
102
+ ref.current = false;
103
+ };
104
+ }, [text]);
105
+
106
+ useEffect(() => {
107
+ if (!area.current) return;
108
+ area.current.style.height = 'auto'; // for the scroll area in the next step!
109
+ area.current.style.height = `${area.current.scrollHeight}px`;
110
+ }, [text]);
111
+
112
+ return (
113
+ <figure className="relative shadow-lg rounded overflow-hidden">
114
+ <div className="absolute right-0 p-1">
115
+ <CopyIcon text={text} />
116
+ </div>
117
+ <div className="myst">
118
+ <label>
119
+ <span className="sr-only">Edit the MyST text</span>
120
+ <textarea
121
+ ref={area}
122
+ value={text}
123
+ className="block p-6 shadow-inner resize-none w-full font-mono bg-slate-50 dark:bg-slate-800 outline-none"
124
+ onChange={(e) => setText(e.target.value)}
125
+ ></textarea>
126
+ </label>
127
+ </div>
128
+ {/* The `exclude-from-outline` class is excluded from the document outline */}
129
+ <div className="exclude-from-outline relative min-h-1 pt-[50px] px-6 pb-6 dark:bg-slate-900">
130
+ <div className="absolute cursor-pointer top-0 left-0 border dark:border-slate-600">
131
+ {['DEMO', 'AST', 'HTML', 'LaTeX'].map((show) => (
132
+ <button
133
+ key={show}
134
+ className={classnames('px-2', {
135
+ 'bg-white hover:bg-slate-200 dark:bg-slate-500 dark:hover:bg-slate-700':
136
+ previewType !== show,
137
+ 'bg-blue-800 text-white': previewType === show,
138
+ })}
139
+ title={`Show the ${show}`}
140
+ aria-label={`Show the ${show}`}
141
+ aria-pressed={previewType === show ? 'true' : 'false'}
142
+ onClick={() => setPreviewType(show)}
143
+ >
144
+ {show}
145
+ </button>
146
+ ))}
147
+ </div>
148
+ {previewType === 'DEMO' && (
149
+ <ReferencesProvider references={references}>{content}</ReferencesProvider>
150
+ )}
151
+ {previewType === 'AST' && <CodeBlock lang="yaml" value={mdastYaml} showCopy={false} />}
152
+ {previewType === 'HTML' && <CodeBlock lang="xml" value={html} showCopy={false} />}
153
+ {previewType === 'LaTeX' && <CodeBlock lang="latex" value={tex} showCopy={false} />}
154
+ </div>
155
+ {previewType === 'DEMO' && warnings.length > 0 && (
156
+ <div>
157
+ {warnings.map((m) => (
158
+ <div
159
+ className={classnames('p-1 shadow-inner text-white not-prose', {
160
+ 'bg-red-500 dark:bg-red-800': m.fatal === true,
161
+ 'bg-orange-500 dark:bg-orange-700': m.fatal === false,
162
+ 'bg-slate-500 dark:bg-slate-800': m.fatal === null,
163
+ })}
164
+ >
165
+ {m.fatal === true && <ExclamationCircleIcon className="inline h-[1.3em] mr-1" />}
166
+ {m.fatal === false && <ExclamationIcon className="inline h-[1.3em] mr-1" />}
167
+ {m.fatal === null && <InformationCircleIcon className="inline h-[1.3em] mr-1" />}
168
+ <code>{m.ruleId || m.source}</code>: {m.message}
169
+ </div>
170
+ ))}
171
+ </div>
172
+ )}
173
+ </figure>
174
+ );
175
+ }
176
+
177
+ const MystNodeRenderer: NodeRenderer = (node) => {
178
+ return <MySTRenderer key={node.key} value={node.value} numbering={node.numbering} />;
179
+ };
180
+
181
+ const MYST_RENDERERS = {
182
+ myst: MystNodeRenderer,
183
+ };
184
+
185
+ export default MYST_RENDERERS;
@@ -0,0 +1,34 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { useLongContent } from './hooks';
3
+
4
+ export const MaybeLongContent = ({
5
+ content,
6
+ path,
7
+ render,
8
+ }: {
9
+ content?: string;
10
+ path?: string;
11
+ render: (content: string) => JSX.Element;
12
+ }) => {
13
+ const { error, data } = useLongContent(content, path);
14
+ if (error) {
15
+ return <div className="text-red-500">Error loading content: {error.message}</div>;
16
+ }
17
+ if (!data) {
18
+ return <div>Fetching long content....</div>;
19
+ }
20
+ return <div>{render(data.content)}</div>;
21
+ };
22
+
23
+ export const DangerousHTML = ({ content, ...rest }: { content: string }) => {
24
+ const ref = useRef<HTMLDivElement | null>(null);
25
+
26
+ useEffect(() => {
27
+ if (!content || !ref.current) return;
28
+ const el = document.createRange().createContextualFragment(content);
29
+ ref.current.innerHTML = '';
30
+ ref.current.appendChild(el);
31
+ }, [content, ref]);
32
+
33
+ return <div {...rest} ref={ref} />;
34
+ };
@@ -0,0 +1,20 @@
1
+ import Ansi from 'ansi-to-react';
2
+ import { ensureString } from '@curvenote/blocks';
3
+ import type { MinifiedErrorOutput } from '@curvenote/nbtx';
4
+ import { MaybeLongContent } from './components';
5
+
6
+ export default function Error({ output }: { output: MinifiedErrorOutput }) {
7
+ return (
8
+ <MaybeLongContent
9
+ content={ensureString(output.traceback)}
10
+ path={output.path}
11
+ render={(content?: string) => {
12
+ return (
13
+ <pre className="text-sm font-thin font-system jupyter-error">
14
+ <Ansi>{content ?? ''}</Ansi>
15
+ </pre>
16
+ );
17
+ }}
18
+ />
19
+ );
20
+ }
@@ -0,0 +1,127 @@
1
+ import useSWRImmutable from 'swr/immutable';
2
+ import type {
3
+ MinifiedErrorOutput,
4
+ MinifiedMimeBundle,
5
+ MinifiedMimePayload,
6
+ MinifiedOutput,
7
+ MinifiedStreamOutput,
8
+ } from '@curvenote/nbtx/dist/minify/types';
9
+ import { walkPaths } from '@curvenote/nbtx/dist/minify/utils';
10
+ import { useState, useLayoutEffect } from 'react';
11
+
12
+ /**
13
+ * Truncation vs Summarization
14
+ *
15
+ * In Curvespace, we're decided to change our data structure for outputs to align it as
16
+ * closely as possible with Jupyters nbformat.IOutput[] type, but in a way that still allows
17
+ * us to truncate output content and push that to storage.
18
+ *
19
+ * This will be used only in the CLI and Curvespace initially but should be ported back to
20
+ * the rest of the code base. This will mean
21
+ *
22
+ * - changing the DB schema
23
+ * - migration
24
+ * - changing API response
25
+ * - changing the frontend
26
+ * - changing the extension
27
+ *
28
+ */
29
+
30
+ interface LongContent {
31
+ content_type?: string;
32
+ content: string;
33
+ }
34
+
35
+ const fetcher = (...args: Parameters<typeof fetch>) =>
36
+ fetch(...args).then((res) => {
37
+ if (res.status === 200) return res.json();
38
+ throw new Error(`Content returned with status ${res.status}.`);
39
+ });
40
+
41
+ export function useLongContent(
42
+ content?: string,
43
+ url?: string,
44
+ ): { data?: LongContent; error?: Error } {
45
+ if (typeof document === 'undefined') {
46
+ // This is ONLY called on the server
47
+ return url ? {} : { data: { content: content ?? '' } };
48
+ }
49
+ const { data, error } = useSWRImmutable<LongContent>(url || null, fetcher);
50
+ if (!url) return { data: { content: content ?? '' } };
51
+ return { data, error };
52
+ }
53
+
54
+ const arrayFetcher = (...urls: string[]) => {
55
+ return Promise.all(urls.map((url) => fetcher(url)));
56
+ };
57
+
58
+ type ObjectWithPath = MinifiedErrorOutput | MinifiedStreamOutput | MinifiedMimePayload;
59
+
60
+ function shallowCloneOutputs(outputs: MinifiedOutput[]) {
61
+ return outputs.map((output) => {
62
+ if ('data' in output && output.data) {
63
+ const data = output.data as MinifiedMimeBundle;
64
+ return {
65
+ ...output,
66
+ data: Object.entries(data).reduce((acc, [mimetype, payload]) => {
67
+ return { ...acc, [mimetype]: { ...payload } };
68
+ }, {}),
69
+ };
70
+ }
71
+ return { ...output };
72
+ });
73
+ }
74
+
75
+ export function useFetchAnyTruncatedContent(outputs: MinifiedOutput[]) {
76
+ const itemsWithPaths: ObjectWithPath[] = [];
77
+ const updated = shallowCloneOutputs(outputs);
78
+
79
+ walkPaths(updated, (path, obj) => {
80
+ // images have paths, but we don't need to fetch them
81
+ if ('content_type' in obj && (obj as MinifiedMimePayload).content_type.startsWith('image/'))
82
+ return;
83
+ obj.path = path;
84
+ itemsWithPaths.push(obj);
85
+ });
86
+
87
+ const { data, error } = useSWRImmutable<LongContent[]>(
88
+ itemsWithPaths.map(({ path }) => path),
89
+ arrayFetcher,
90
+ );
91
+
92
+ data?.forEach(({ content }, idx) => {
93
+ const obj = itemsWithPaths[idx];
94
+ if ('text' in obj) obj.text = content; // stream
95
+ if ('traceback' in obj) obj.traceback = content; // error
96
+ if ('content' in obj) obj.content = content; // mimeoutput
97
+ obj.path = undefined;
98
+ });
99
+
100
+ return {
101
+ data: itemsWithPaths.length === 0 || data ? updated : undefined,
102
+ error,
103
+ };
104
+ }
105
+
106
+ function getWindowSize() {
107
+ const { innerWidth: width, innerHeight: height } = window;
108
+ return {
109
+ width,
110
+ height,
111
+ };
112
+ }
113
+
114
+ export default function useWindowSize() {
115
+ const [windowSize, setWindowSize] = useState(getWindowSize());
116
+
117
+ useLayoutEffect(() => {
118
+ function handleResize() {
119
+ setWindowSize(getWindowSize());
120
+ }
121
+
122
+ window.addEventListener('resize', handleResize);
123
+ return () => window.removeEventListener('resize', handleResize);
124
+ }, []);
125
+
126
+ return windowSize;
127
+ }
@@ -0,0 +1,7 @@
1
+ import { Output } from './output';
2
+
3
+ const OUTPUT_RENDERERS = {
4
+ output: Output,
5
+ };
6
+
7
+ export default OUTPUT_RENDERERS;
@@ -0,0 +1,86 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import useWindowSize, { useFetchAnyTruncatedContent } from './hooks';
3
+ import { nanoid } from 'nanoid';
4
+ import { useSelector } from 'react-redux';
5
+ import { host, actions } from '@curvenote/connect';
6
+ import type { MinifiedOutput } from '@curvenote/nbtx';
7
+ import { convertToIOutputs, fetchAndEncodeOutputImages } from '@curvenote/nbtx';
8
+ import { ChevronDoubleDownIcon } from '@heroicons/react/outline';
9
+ import type { State } from './selectors';
10
+ import { selectIFrameHeight, selectIFrameReady } from './selectors';
11
+
12
+ const PERCENT_OF_WINDOW = 0.9;
13
+
14
+ export const NativeJupyterOutputs = ({
15
+ id,
16
+ outputs,
17
+ }: {
18
+ id: string;
19
+ outputs: MinifiedOutput[];
20
+ }) => {
21
+ const windowSize = useWindowSize();
22
+
23
+ const { data, error } = useFetchAnyTruncatedContent(outputs);
24
+
25
+ const [loading, setLoading] = useState(true);
26
+ const [frameHeight, setFrameHeight] = useState(0);
27
+ const [clamped, setClamped] = useState(false);
28
+
29
+ const uid = useMemo(nanoid, []);
30
+
31
+ const height = useSelector((state: State) => selectIFrameHeight(state, uid));
32
+ const rendererReady = useSelector((state: State) => selectIFrameReady(state, uid));
33
+
34
+ const iframeRef = useRef<HTMLIFrameElement | null>(null);
35
+
36
+ useEffect(() => {
37
+ if (iframeRef.current == null || !rendererReady || !data) return;
38
+ fetchAndEncodeOutputImages(convertToIOutputs(data)).then((out) => {
39
+ host.commsDispatch(iframeRef.current, actions.connectHostSendContent(uid, out));
40
+ });
41
+ }, [id, iframeRef.current, rendererReady]);
42
+
43
+ useEffect(() => {
44
+ if (height == null) return;
45
+ if (height > PERCENT_OF_WINDOW * windowSize.height) {
46
+ setFrameHeight(PERCENT_OF_WINDOW * windowSize.height);
47
+ setClamped(true);
48
+ } else {
49
+ setFrameHeight(height + 25);
50
+ setClamped(false);
51
+ }
52
+ setLoading(false);
53
+ }, [height]);
54
+
55
+ if (error) {
56
+ return <div className="text-red-500">Error rendering output: {error.message}</div>;
57
+ }
58
+
59
+ return (
60
+ <div>
61
+ {loading && <div className="p-2.5">Loading...</div>}
62
+ <iframe
63
+ ref={iframeRef}
64
+ id={uid}
65
+ name={uid}
66
+ title={uid}
67
+ src="https://next.curvenote.run"
68
+ width={'100%'}
69
+ height={frameHeight}
70
+ sandbox="allow-scripts"
71
+ ></iframe>
72
+ {clamped && (
73
+ <div
74
+ className="cursor-pointer p-1 pb-2 hover:bg-gray-50 dark:hover:bg-gray-700 text-center text-gray-500 hover:text-gray-600 dark:text-gray-200 dark:hover:text-gray-50"
75
+ title="Expand"
76
+ onClick={() => {
77
+ setFrameHeight(height ?? 0);
78
+ setClamped(false);
79
+ }}
80
+ >
81
+ <ChevronDoubleDownIcon className="w-5 h-5 inline"></ChevronDoubleDownIcon>
82
+ </div>
83
+ )}
84
+ </div>
85
+ );
86
+ };
@@ -0,0 +1,79 @@
1
+ import type { GenericNode } from 'mystjs';
2
+ import { KnownCellOutputMimeTypes } from '@curvenote/blocks';
3
+ import type { MinifiedMimeOutput, MinifiedOutput } from '@curvenote/nbtx/dist/minify/types';
4
+ import classNames from 'classnames';
5
+ import { SafeOutputs } from './safe';
6
+ import { NativeJupyterOutputs as JupyterOutputs } from './jupyter';
7
+ import { OutputBlock } from './outputBlock';
8
+
9
+ const DIRECT_OUTPUT_TYPES = new Set(['stream', 'error']);
10
+
11
+ const DIRECT_MIME_TYPES = new Set([
12
+ KnownCellOutputMimeTypes.TextPlain,
13
+ KnownCellOutputMimeTypes.ImagePng,
14
+ KnownCellOutputMimeTypes.ImageGif,
15
+ KnownCellOutputMimeTypes.ImageJpeg,
16
+ KnownCellOutputMimeTypes.ImageBmp,
17
+ ]) as Set<string>;
18
+
19
+ export function allOutputsAreSafe(
20
+ outputs: MinifiedOutput[],
21
+ directOutputTypes: Set<string>,
22
+ directMimeTypes: Set<string>,
23
+ ) {
24
+ return outputs.reduce((flag, output) => {
25
+ if (directOutputTypes.has(output.output_type)) return true;
26
+ const data = (output as MinifiedMimeOutput).data;
27
+ const mimetypes = data ? Object.keys(data) : [];
28
+ const safe =
29
+ 'data' in output &&
30
+ Boolean(output.data) &&
31
+ mimetypes.every((mimetype) => directMimeTypes.has(mimetype));
32
+ return flag && safe;
33
+ }, true);
34
+ }
35
+
36
+ function listMimetypes(outputs: MinifiedOutput[], directOutputTypes: Set<string>) {
37
+ return outputs.map((output) => {
38
+ if (directOutputTypes.has(output.output_type)) return [output.output_type];
39
+ const data = (output as MinifiedMimeOutput).data;
40
+ const mimetypes = data ? Object.keys(data) : [];
41
+ return [output.output_type, mimetypes];
42
+ });
43
+ }
44
+
45
+ export function anyErrors(outputs: MinifiedOutput[]) {
46
+ return outputs.reduce((flag, output) => flag || output.output_type === 'error', false);
47
+ }
48
+
49
+ export function Output(node: GenericNode) {
50
+ const outputs: MinifiedOutput[] = node.data;
51
+ const allSafe = allOutputsAreSafe(outputs, DIRECT_OUTPUT_TYPES, DIRECT_MIME_TYPES);
52
+ const hasError = anyErrors(outputs);
53
+
54
+ let component;
55
+ if (allSafe) {
56
+ component = <SafeOutputs keyStub={node.key} outputs={outputs} />;
57
+ } else {
58
+ // Hide the iframe if rendering on the server
59
+ component =
60
+ typeof document === 'undefined' ? null : <JupyterOutputs id={node.key} outputs={outputs} />;
61
+ }
62
+
63
+ return (
64
+ <figure
65
+ suppressHydrationWarning={!allSafe}
66
+ key={node.key}
67
+ id={node.identifier || undefined}
68
+ className={classNames('max-w-full overflow-auto m-0', {
69
+ 'text-left': !node.align || node.align === 'left',
70
+ 'text-center': node.align === 'center',
71
+ 'text-right': node.align === 'right',
72
+ })}
73
+ >
74
+ <OutputBlock allSafe={allSafe} hasError={hasError}>
75
+ {component}
76
+ </OutputBlock>
77
+ </figure>
78
+ );
79
+ }
@@ -0,0 +1,21 @@
1
+ import classNames from 'classnames';
2
+
3
+ type Props = {
4
+ children?: React.ReactNode;
5
+ allSafe?: boolean;
6
+ hasError?: boolean;
7
+ className?: string;
8
+ };
9
+
10
+ export function OutputBlock(props: Props) {
11
+ const { children, allSafe, className } = props;
12
+
13
+ return (
14
+ <div
15
+ suppressHydrationWarning={!allSafe}
16
+ className={classNames('relative group not-prose overflow-auto mb-4 pl-0.5', className)}
17
+ >
18
+ {children}
19
+ </div>
20
+ );
21
+ }
@@ -0,0 +1,84 @@
1
+ import { KnownCellOutputMimeTypes } from '@curvenote/blocks/dist/blocks/types/jupyter';
2
+ import type {
3
+ MinifiedMimeBundle,
4
+ MinifiedMimePayload,
5
+ MinifiedOutput,
6
+ } from '@curvenote/nbtx/dist/minify/types';
7
+ import Stream from './stream';
8
+ import Error from './error';
9
+ import Ansi from 'ansi-to-react';
10
+
11
+ /**
12
+ * https://jupyterbook.org/content/code-outputs.html#render-priority
13
+ *
14
+ * nb_render_priority:
15
+ html:
16
+ - "application/vnd.jupyter.widget-view+json"
17
+ - "application/javascript"
18
+ - "text/html"
19
+ - "image/svg+xml"
20
+ - "image/png"
21
+ - "image/jpeg"
22
+ - "text/markdown"
23
+ - "text/latex"
24
+ - "text/plain"
25
+ */
26
+
27
+ const RENDER_PRIORITY = [
28
+ KnownCellOutputMimeTypes.ImagePng,
29
+ KnownCellOutputMimeTypes.ImageJpeg,
30
+ KnownCellOutputMimeTypes.ImageGif,
31
+ KnownCellOutputMimeTypes.ImageBmp,
32
+ ];
33
+
34
+ function findSafeMimeOutputs(output: MinifiedOutput): {
35
+ image?: MinifiedMimePayload;
36
+ text?: MinifiedMimePayload;
37
+ } {
38
+ const data: MinifiedMimeBundle = output.data as MinifiedMimeBundle;
39
+ const image = RENDER_PRIORITY.reduce((acc: MinifiedMimePayload | undefined, mimetype) => {
40
+ if (acc) return acc;
41
+ if (data && data[mimetype]) return data[mimetype];
42
+ return undefined;
43
+ }, undefined);
44
+ const text = data && data['text/plain'];
45
+ return { image, text };
46
+ }
47
+
48
+ function OutputImage({ image, text }: { image: MinifiedMimePayload; text?: MinifiedMimePayload }) {
49
+ return <img src={image?.path} alt={text?.content ?? 'Image produced in Jupyter'} />;
50
+ }
51
+
52
+ function SafeOutput({ output }: { output: MinifiedOutput }) {
53
+ switch (output.output_type) {
54
+ case 'stream':
55
+ return <Stream output={output} />;
56
+ case 'error':
57
+ return <Error output={output} />;
58
+ case 'display_data':
59
+ case 'execute_result':
60
+ case 'update_display_data': {
61
+ const { image, text } = findSafeMimeOutputs(output);
62
+ if (!image && !text) return null;
63
+ if (image) return <OutputImage image={image} text={text} />;
64
+ if (text)
65
+ return (
66
+ <div>
67
+ <Ansi>{text.content}</Ansi>
68
+ </div>
69
+ );
70
+ return null;
71
+ }
72
+ default:
73
+ console.warn(`Unknown output_type ${output['output_type']}`);
74
+ return null;
75
+ }
76
+ }
77
+
78
+ export function SafeOutputs({ keyStub, outputs }: { keyStub: string; outputs: MinifiedOutput[] }) {
79
+ // TODO better key - add keys during content creation?
80
+ const components = outputs.map((output, idx) => (
81
+ <SafeOutput key={`${keyStub}-${idx}`} output={output} />
82
+ ));
83
+ return <>{components}</>;
84
+ }
@@ -0,0 +1,15 @@
1
+ import type { HostState } from '@curvenote/connect';
2
+ import { host } from '@curvenote/connect';
3
+
4
+ export interface State {
5
+ app: HostState;
6
+ }
7
+
8
+ export const selectIFrameHeight = (state: State, id: string) =>
9
+ host.selectors.selectIFrameSize(state.app, id);
10
+
11
+ export const selectIFrameReady = (state: State, id: string) =>
12
+ host.selectors.selectIFrameReady(state.app, id);
13
+
14
+ export const selectIFrameSendFailed = (state: State, id: string) =>
15
+ host.selectors.selectIFrameFailed(state.app, id);
@@ -0,0 +1,18 @@
1
+ import Ansi from 'ansi-to-react';
2
+ import { ensureString } from '@curvenote/blocks';
3
+ import type { MinifiedStreamOutput } from '@curvenote/nbtx';
4
+ import { MaybeLongContent } from './components';
5
+
6
+ export default function Stream({ output }: { output: MinifiedStreamOutput }) {
7
+ return (
8
+ <MaybeLongContent
9
+ content={ensureString(output.text)}
10
+ path={output.path}
11
+ render={(content?: string) => (
12
+ <pre className="text-sm font-thin font-system">
13
+ <Ansi>{content ?? ''}</Ansi>
14
+ </pre>
15
+ )}
16
+ />
17
+ );
18
+ }