Skip to content

Commit

Permalink
πŸ§‘πŸΌβ€πŸ« Add sphinx exercise initial implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
rowanc1 committed May 28, 2023
1 parent 116cb0a commit 0b91560
Show file tree
Hide file tree
Showing 3 changed files with 231 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .changeset/curvy-students-march.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'myst-to-react': patch
---

Add sphinx exercise clone
224 changes: 224 additions & 0 deletions packages/myst-to-react/src/exercise.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import type { Admonition as AdmonitionSpec } from 'myst-spec';
import React from 'react';
import type { NodeRenderer } from '@myst-theme/providers';
import ChevronRightIcon from '@heroicons/react/24/solid/ChevronRightIcon';
import classNames from 'classnames';
import { HashLink } from './heading';

type Color = 'gray' | 'blue' | 'green' | 'yellow' | 'orange' | 'red' | 'purple';

function getClasses(className?: string) {
const classes =
className
?.split(' ')
.map((s) => s.trim().toLowerCase())
.filter((s) => !!s) ?? [];
return [...new Set(classes)];
}

function getColor(
{ classes = [] }: { classes?: string[] },
defaultColor: Color = 'blue',
): {
color: Color;
} {
if (classes.includes('gray')) return { color: 'gray' };
if (classes.includes('purple')) return { color: 'purple' };
if (classes.includes('yellow')) return { color: 'yellow' };
if (classes.includes('orange')) return { color: 'orange' };
if (classes.includes('green')) return { color: 'green' };
if (classes.includes('red')) return { color: 'red' };
if (classes.includes('blue')) return { color: 'blue' };
return { color: defaultColor };
}

const WrapperElement = ({
id,
dropdown,
className,
children,
}: {
id?: string;
className?: string;
children: React.ReactNode;
dropdown?: boolean;
}) => {
if (dropdown)
return (
<details id={id} className={className}>
{children}
</details>
);
return (
<aside id={id} className={className}>
{children}
</aside>
);
};

const HeaderElement = ({
dropdown,
className,
children,
}: {
className?: string;
children: React.ReactNode;
dropdown?: boolean;
}) => {
if (dropdown) return <summary className={className}>{children}</summary>;
return <div className={className}>{children}</div>;
};

const iconClass = 'h-8 w-8 inline-block pl-2 mr-2 self-center flex-none';

export function Callout({
title,
color,
dropdown,
children,
identifier,
}: {
title?: React.ReactNode;
color?: Color;
children: React.ReactNode;
dropdown?: boolean;
identifier?: string;
}) {
return (
<WrapperElement
id={identifier}
dropdown={dropdown}
className={classNames(
'my-5 shadow dark:bg-stone-800 overflow-hidden',
'dark:border-l-4 border-slate-400',
{
'dark:border-gray-500/60': !color || color === 'gray',
'dark:border-blue-500/60': color === 'blue',
'dark:border-green-500/60': color === 'green',
'dark:border-amber-500/70': color === 'yellow',
'dark:border-orange-500/60': color === 'orange',
'dark:border-red-500/60': color === 'red',
'dark:border-purple-500/60': color === 'purple',
},
)}
>
<HeaderElement
dropdown={dropdown}
className={classNames(
'm-0 font-medium py-2 flex min-w-0',
'text-md',
'border-y dark:border-y-0',
{
'bg-gray-50/80 dark:bg-slate-900': !color || color === 'gray',
'bg-blue-50/80 dark:bg-slate-900': color === 'blue',
'bg-green-50/80 dark:bg-slate-900': color === 'green',
'bg-amber-50/80 dark:bg-slate-900': color === 'yellow',
'bg-orange-50/80 dark:bg-slate-900': color === 'orange',
'bg-red-50/80 dark:bg-slate-900': color === 'red',
'bg-purple-50/80 dark:bg-slate-900': color === 'purple',
'cursor-pointer hover:shadow-[inset_0_0_0px_30px_#00000003] dark:hover:shadow-[inset_0_0_0px_30px_#FFFFFF03]':
dropdown,
},
)}
>
<div
className={classNames(
'text-neutral-900 dark:text-white grow self-center overflow-hidden break-words',
'ml-4', // No icon!
'group', // For nested cross-reference links
)}
>
{title}
</div>
{dropdown && (
<div className="font-thin text-sm text-neutral-700 dark:text-neutral-200 self-center flex-none">
<ChevronRightIcon
className={classNames(iconClass, 'transition-transform details-toggle')}
/>
</div>
)}
</HeaderElement>
<div className={classNames('px-4', { 'details-body': dropdown })}>{children}</div>
</WrapperElement>
);
}

export const ExerciseRenderer: NodeRenderer<AdmonitionSpec> = (node, children) => {
if ((node as any).hidden) return null;
const [title, ...rest] = (children as any[]) ?? [];
const classes = getClasses(node.class);
const { color } = getColor({ classes });
const isDropdown = classes.includes('dropdown');

const useTitle = node.children?.[0]?.type === 'admonitionTitle';

const identifier = node.html_id;
const enumerator = (node as any).enumerator;

const titleNode = (
<>
<HashLink id={identifier} kind="Exercise">
{(node as any).gate === 'start' && 'Start of '}
{(node as any).gate === 'end' && 'End of '}
Exercise{enumerator != null && <> {enumerator}</>}
</HashLink>
{useTitle && <> ({title})</>}
</>
);

return (
<Callout
key={node.key}
identifier={identifier}
title={titleNode}
color={color}
dropdown={isDropdown}
>
{!useTitle && title}
{rest}
</Callout>
);
};

export const SolutionRenderer: NodeRenderer<AdmonitionSpec> = (node, children) => {
if ((node as any).hidden) return null;
const [title, ...rest] = (children as any[]) ?? [];
const classes = getClasses(node.class);
const { color } = getColor({ classes }, 'gray');
const isDropdown = classes.includes('dropdown');

const useTitle = node.children?.[0]?.type === 'admonitionTitle';

const identifier = node.html_id;

const titleNode = (
<>
{(node as any).gate === 'start' && 'Start of '}
{(node as any).gate === 'end' && 'End of '}
{title}
<HashLink id={identifier} kind="Solution" hover hideInPopup>
{' #'}
</HashLink>
</>
);

return (
<Callout
key={node.key}
identifier={identifier}
title={useTitle ? titleNode : undefined}
color={color}
dropdown={isDropdown}
>
{!useTitle && title}
{rest}
</Callout>
);
};

const EXERCISE_RENDERERS = {
exercise: ExerciseRenderer,
solution: SolutionRenderer,
};

export default EXERCISE_RENDERERS;
2 changes: 2 additions & 0 deletions packages/myst-to-react/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import TAB_RENDERERS from './tabs';
import EXT_RENDERERS from './extensions';
import INLINE_EXPRESSION_RENDERERS from './inlineExpression';
import PROOF_RENDERERS from './proof';
import EXERCISE_RENDERERS from './exercise';

export { CopyIcon } from './components/CopyIcon';
export { CodeBlock } from './code';
Expand Down Expand Up @@ -47,6 +48,7 @@ export const DEFAULT_RENDERERS: Record<string, NodeRenderer> = {
...INLINE_EXPRESSION_RENDERERS,
...EXT_RENDERERS,
...PROOF_RENDERERS,
...EXERCISE_RENDERERS,
};

export function useParse(
Expand Down

0 comments on commit 0b91560

Please sign in to comment.