diff --git a/.changeset/tidy-candles-develop.md b/.changeset/tidy-candles-develop.md new file mode 100644 index 000000000..4986108fe --- /dev/null +++ b/.changeset/tidy-candles-develop.md @@ -0,0 +1,8 @@ +--- +'myst-to-react': patch +'@myst-theme/providers': patch +'@myst-theme/jupyter': patch +'@myst-theme/site': patch +--- + +Allow for specific renderers diff --git a/packages/jupyter/src/figure.tsx b/packages/jupyter/src/figure.tsx index e523e2742..e9dfe1621 100644 --- a/packages/jupyter/src/figure.tsx +++ b/packages/jupyter/src/figure.tsx @@ -5,7 +5,7 @@ import classNames from 'classnames'; import { OutputDecoration } from './decoration.js'; export function Figure({ node }: { node: GenericNode }) { - const { container: Container } = DEFAULT_RENDERERS; + const { base: Container } = DEFAULT_RENDERERS['container']; const isFromJupyer = node.source?.kind === SourceFileKind.Notebook; const output = node.children?.find((child) => child.type === 'output'); if (isFromJupyer && !!output) { diff --git a/packages/myst-to-react/src/MyST.tsx b/packages/myst-to-react/src/MyST.tsx index 5041a57aa..4a2d5554b 100644 --- a/packages/myst-to-react/src/MyST.tsx +++ b/packages/myst-to-react/src/MyST.tsx @@ -1,3 +1,5 @@ +import { matches } from 'unist-util-select'; +import type { NodeRenderersValidated } from '@myst-theme/providers'; import { useNodeRenderers } from '@myst-theme/providers'; import type { GenericNode } from 'myst-common'; @@ -10,17 +12,25 @@ function DefaultComponent({ node }: { node: GenericNode }) { ); } +export function selectRenderer(renderers: NodeRenderersValidated, node: GenericNode) { + const componentRenderers = renderers[node.type] ?? renderers['DefaultComponent']; + const SpecificComponent = Object.entries(componentRenderers) + .reverse() + .find(([selector]) => selector !== 'base' && matches(selector, node))?.[1]; + return SpecificComponent ?? componentRenderers.base ?? DefaultComponent; +} + export function MyST({ ast }: { ast?: GenericNode | GenericNode[] }) { const renderers = useNodeRenderers(); if (!ast || ast.length === 0) return null; if (!Array.isArray(ast)) { - const Component = renderers[ast.type] ?? renderers['DefaultComponent'] ?? DefaultComponent; + const Component = selectRenderer(renderers, ast); return ; } return ( <> {ast?.map((node) => { - const Component = renderers[node.type] ?? DefaultComponent; + const Component = selectRenderer(renderers, node); return ; })} diff --git a/packages/myst-to-react/src/index.tsx b/packages/myst-to-react/src/index.tsx index 765a6eac3..71e88fcd8 100644 --- a/packages/myst-to-react/src/index.tsx +++ b/packages/myst-to-react/src/index.tsx @@ -1,4 +1,4 @@ -import type { NodeRenderer } from '@myst-theme/providers'; +import { mergeRenderers } from '@myst-theme/providers'; import BASIC_RENDERERS from './basic.js'; import ADMONITION_RENDERERS from './admonitions.js'; import DROPDOWN_RENDERERS from './dropdown.js'; @@ -30,29 +30,32 @@ export { Details } from './dropdown.js'; export { TabSet, TabItem } from './tabs.js'; export { useFetchMdast } from './crossReference.js'; -export const DEFAULT_RENDERERS: Record = { - ...BASIC_RENDERERS, - ...UNKNOWN_MYST_RENDERERS, - ...IMAGE_RENDERERS, - ...LINK_RENDERERS, - ...CODE_RENDERERS, - ...MATH_RENDERERS, - ...CITE_RENDERERS, - ...TAB_RENDERERS, - ...IFRAME_RENDERERS, - ...FOOTNOTE_RENDERERS, - ...ADMONITION_RENDERERS, - ...REACTIVE_RENDERERS, - ...HEADING_RENDERERS, - ...CROSS_REFERENCE_RENDERERS, - ...DROPDOWN_RENDERERS, - ...CARD_RENDERERS, - ...GRID_RENDERERS, - ...INLINE_EXPRESSION_RENDERERS, - ...EXT_RENDERERS, - ...PROOF_RENDERERS, - ...EXERCISE_RENDERERS, - ...ASIDE_RENDERERS, -}; +export const DEFAULT_RENDERERS = mergeRenderers( + [ + BASIC_RENDERERS, + UNKNOWN_MYST_RENDERERS, + IMAGE_RENDERERS, + LINK_RENDERERS, + CODE_RENDERERS, + MATH_RENDERERS, + CITE_RENDERERS, + TAB_RENDERERS, + IFRAME_RENDERERS, + FOOTNOTE_RENDERERS, + ADMONITION_RENDERERS, + REACTIVE_RENDERERS, + HEADING_RENDERERS, + CROSS_REFERENCE_RENDERERS, + DROPDOWN_RENDERERS, + CARD_RENDERERS, + GRID_RENDERERS, + INLINE_EXPRESSION_RENDERERS, + EXT_RENDERERS, + PROOF_RENDERERS, + EXERCISE_RENDERERS, + ASIDE_RENDERERS, + ], + true, +); -export { MyST } from './MyST.js'; +export { MyST, selectRenderer } from './MyST.js'; diff --git a/packages/myst-to-react/src/links/index.tsx b/packages/myst-to-react/src/links/index.tsx index e6c7a370e..f4d039ce8 100644 --- a/packages/myst-to-react/src/links/index.tsx +++ b/packages/myst-to-react/src/links/index.tsx @@ -5,7 +5,7 @@ import { } from '@heroicons/react/24/outline'; import { useLinkProvider, useSiteManifest, useBaseurl, withBaseurl } from '@myst-theme/providers'; import type { SiteManifest } from 'myst-config'; -import type { NodeRenderer } from '@myst-theme/providers'; +import type { NodeRenderer, NodeRenderers } from '@myst-theme/providers'; import { HoverPopover, LinkCard } from '../components/index.js'; import { WikiLink } from './wiki.js'; import { RRIDLink } from './rrid.js'; @@ -55,51 +55,54 @@ function InternalLink({ url, children }: { url: string; children: React.ReactNod ); } -export const link: NodeRenderer = ({ node }) => { - const internal = node.internal ?? false; - const protocol = node.protocol; +export const WikiLinkRenderer: NodeRenderer = ({ node }) => { + return ( + + + + ); +}; - switch (protocol) { - case 'wiki': - return ( - - - - ); - case 'github': - return ( - - - - ); - case 'rrid': - return ; - case 'ror': - return ; - default: - if (internal) { - return ( - - - - ); - } - return ( - - - - ); +export const GithubLinkRenderer: NodeRenderer = ({ node }) => { + return ( + + + + ); +}; + +export const RRIDLinkRenderer: NodeRenderer = ({ node }) => ( + +); + +export const RORLinkRenderer: NodeRenderer = ({ node }) => ( + +); + +export const SimpleLink: NodeRenderer = ({ node }) => { + const internal = node.internal ?? false; + if (internal) { + return ( + + + + ); } + return ( + + + + ); }; export const linkBlock: NodeRenderer = ({ node }) => { @@ -134,8 +137,20 @@ export const linkBlock: NodeRenderer = ({ node }) => { ); }; -const LINK_RENDERERS = { - link, +const LINK_RENDERERS: NodeRenderers = { + link: { + base: SimpleLink, + // Then duplicate the renderers for protocols + 'link[protocol=github]': GithubLinkRenderer, + 'link[protocol=wiki]': WikiLinkRenderer, + 'link[protocol=rrid]': RRIDLinkRenderer, + 'link[protocol=ror]': RORLinkRenderer, + // Put the kinds last as they will match first in the future + 'link[kind=github]': GithubLinkRenderer, + 'link[kind=wiki]': WikiLinkRenderer, + 'link[kind=rrid]': RRIDLinkRenderer, + 'link[kind=ror]': RORLinkRenderer, + }, linkBlock, }; diff --git a/packages/providers/package.json b/packages/providers/package.json index 089bdd577..43365b0a0 100644 --- a/packages/providers/package.json +++ b/packages/providers/package.json @@ -13,7 +13,9 @@ "license": "MIT", "scripts": { "clean": "rimraf dist", - "lint": "eslint src/**/*.ts*", + "test": "vitest run", + "test:watch": "vitest watch", + "lint": "eslint \"src/**/!(*.spec).ts\" -c ./.eslintrc.cjs", "lint:format": "prettier --check \"src/**/*.{ts,tsx,md}\"", "dev": "npm-run-all --parallel \"build:* -- --watch\"", "build:esm": "tsc", diff --git a/packages/providers/src/index.tsx b/packages/providers/src/index.tsx index 6058c5b34..8c68d21e6 100644 --- a/packages/providers/src/index.tsx +++ b/packages/providers/src/index.tsx @@ -7,5 +7,5 @@ export * from './ui.js'; export * from './site.js'; export * from './tabs.js'; export * from './xref.js'; -export * from './types.js'; +export * from './renderers.js'; export * from './project.js'; diff --git a/packages/providers/src/renderers.spec.tsx b/packages/providers/src/renderers.spec.tsx new file mode 100644 index 000000000..09e6cc9fd --- /dev/null +++ b/packages/providers/src/renderers.spec.tsx @@ -0,0 +1,178 @@ +import { describe, it, expect } from 'vitest'; +import type { NodeRenderer, NodeRenderers, NodeRenderersValidated } from './renderers.js'; // Update with your actual file path +import { validateRenderers, mergeRenderers } from './renderers.js'; // Update with your actual file path + +// Example NodeRenderer components +const ParagraphRenderer: NodeRenderer = (props) =>

{props.node.children}

; +const HeadingRenderer: NodeRenderer = (props) =>

{props.node.children}

; +const HeadingLevel2Renderer: NodeRenderer = (props) =>

{props.node.children}

; +const LinkRenderer: NodeRenderer = (props) => {props.node.children}; +const CustomHeadingRenderer: NodeRenderer = (props) => ( +

{props.node.children}

+); + +describe('validateRenderers', () => { + it('should validate and normalize a renderer that is a single function', () => { + const renderers: NodeRenderers = { + paragraph: ParagraphRenderer, + }; + + const validated = validateRenderers(renderers); + + expect(validated).toEqual({ + paragraph: { base: ParagraphRenderer }, + }); + }); + + it('should validate and normalize a renderer that contains a base function', () => { + const renderers: NodeRenderers = { + heading: { + base: HeadingRenderer, + level2: HeadingLevel2Renderer, + }, + }; + + const validated = validateRenderers(renderers); + + expect(validated).toEqual({ + heading: { + base: HeadingRenderer, + level2: HeadingLevel2Renderer, + }, + }); + }); + + it('should throw an error if a renderer is missing a base function', () => { + const renderers: NodeRenderers = { + heading: { + level2: HeadingLevel2Renderer, + }, + }; + + expect(() => validateRenderers(renderers)).toThrowError( + 'Renderer for "heading" must be either a function or an object containing a "base" renderer.', + ); + }); +}); + +describe('mergeRenderers', () => { + it('should merge two renderers, with the second one overriding the first', () => { + const renderers1: NodeRenderers = { + paragraph: ParagraphRenderer, + heading: HeadingRenderer, + }; + + const renderers2: NodeRenderers = { + heading: HeadingLevel2Renderer, // This should override HeadingRenderer + link: LinkRenderer, + }; + + const merged = mergeRenderers([renderers1, renderers2]); + + expect(merged).toEqual({ + paragraph: { base: ParagraphRenderer }, + heading: { base: HeadingLevel2Renderer }, + link: { base: LinkRenderer }, + }); + }); + + it('should handle merging multiple renderers, with the last one taking precedence', () => { + const renderers1: NodeRenderers = { + heading: { + base: HeadingRenderer, + level2: HeadingLevel2Renderer, + }, + }; + + const renderers2: NodeRenderers = { + heading: CustomHeadingRenderer, // This should override HeadingRenderer + }; + + const merged = mergeRenderers([renderers1, renderers2]); + + expect(merged).toEqual({ + heading: { + base: CustomHeadingRenderer, + level2: HeadingLevel2Renderer, + }, + }); + }); + + it('should correctly merge when there is no overlap between renderers', () => { + const renderers1: NodeRenderers = { + paragraph: ParagraphRenderer, + }; + + const renderers2: NodeRenderers = { + link: LinkRenderer, + }; + + const merged = mergeRenderers([renderers1, renderers2]); + + expect(merged).toEqual({ + paragraph: { base: ParagraphRenderer }, + link: { base: LinkRenderer }, + }); + }); + + it('should deeply merge objects if both renderers have objects for the same key', () => { + const renderers1: NodeRenderers = { + heading: { + base: HeadingRenderer, + }, + }; + + const renderers2: NodeRenderers = { + heading: { + level2: HeadingLevel2Renderer, + }, + }; + + const merged = mergeRenderers([renderers1, renderers2]); + + expect(merged).toEqual({ + heading: { + base: HeadingRenderer, + level2: HeadingLevel2Renderer, + }, + }); + }); + + it('should override scalar values with objects if there is a conflict', () => { + const renderers1: NodeRenderers = { + heading: HeadingRenderer, // Scalar value + }; + + const renderers2: NodeRenderers = { + heading: { + base: CustomHeadingRenderer, // Object + }, + }; + + const merged = mergeRenderers([renderers1, renderers2]); + + expect(merged).toEqual({ + heading: { + base: CustomHeadingRenderer, + }, + }); + }); + + it('should override objects with scalar values if there is a conflict', () => { + const renderers1: NodeRenderers = { + heading: { + base: HeadingRenderer, // Object + }, + }; + + const renderers2: NodeRenderers = { + heading: CustomHeadingRenderer, // Scalar value + }; + + const merged = mergeRenderers([renderers1, renderers2]); + + expect(merged).toEqual({ + heading: { base: CustomHeadingRenderer }, + }); + }); +}); diff --git a/packages/providers/src/renderers.tsx b/packages/providers/src/renderers.tsx new file mode 100644 index 000000000..ac04f411e --- /dev/null +++ b/packages/providers/src/renderers.tsx @@ -0,0 +1,52 @@ +import type React from 'react'; +import type { GenericNode } from 'myst-common'; + +export type NodeRenderer = React.FC<{ node: GenericNode & T }>; +export type NodeRenderers = Record>; +export type NodeRenderersValidated = Record< + string, + { base: NodeRenderer } & Record +>; + +export function validateRenderers(renderers?: NodeRenderers): NodeRenderersValidated { + if (!renderers) return {}; + const validatedRenderers: NodeRenderersValidated = {}; + + for (const key in renderers) { + const renderer = renderers[key]; + + if (typeof renderer === 'function') { + // If the renderer is a function, it's treated as the base renderer + validatedRenderers[key] = { base: renderer }; + } else if (typeof renderer === 'object' && 'base' in renderer) { + // If it's an object with a base renderer, validate it + validatedRenderers[key] = renderer as NodeRenderersValidated['']; + } else { + throw new Error( + `Renderer for "${key}" must be either a function or an object containing a "base" renderer.`, + ); + } + } + + return validatedRenderers; +} + +export function mergeRenderers(renderers: NodeRenderers[], validate: true): NodeRenderersValidated; +export function mergeRenderers(renderers: NodeRenderers[], validate?: false): NodeRenderers; +export function mergeRenderers(renderers: NodeRenderers[], validate?: boolean): NodeRenderers { + const mergedRenderers: NodeRenderersValidated = {}; // May not actually have base! + + for (const renderersObj of renderers) { + for (const key in renderersObj) { + const next = + typeof renderersObj[key] === 'function' ? { base: renderersObj[key] } : renderersObj[key]; + mergedRenderers[key] = { + ...(mergedRenderers[key] as any), // Not sure why we need the any here... + ...next, + }; + } + } + + if (validate) return validateRenderers(mergedRenderers); + return mergedRenderers as NodeRenderers; +} diff --git a/packages/providers/src/theme.tsx b/packages/providers/src/theme.tsx index 3d77555aa..e5196cf60 100644 --- a/packages/providers/src/theme.tsx +++ b/packages/providers/src/theme.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import type { NodeRenderer } from './types.js'; +import { validateRenderers, type NodeRenderers, type NodeRenderersValidated } from './renderers.js'; import { Theme } from '@myst-theme/common'; export { Theme }; @@ -47,7 +47,7 @@ export function isTheme(value: unknown): value is Theme { type ThemeContextType = { theme: Theme | null; setTheme: (theme: Theme) => void; - renderers?: Record; + renderers?: NodeRenderersValidated; top?: number; Link?: Link; NavLink?: NavLink; @@ -68,7 +68,7 @@ export function ThemeProvider({ }: { children: React.ReactNode; theme?: Theme; - renderers?: Record; + renderers?: NodeRenderers; Link?: Link; top?: number; NavLink?: NavLink; @@ -96,8 +96,11 @@ export function ThemeProvider({ }, [theme], ); + const validatedRenderers = validateRenderers(renderers); return ( - + {children} ); @@ -130,7 +133,7 @@ export function useTheme() { return { theme, isLight, isDark, setTheme, nextTheme }; } -export function useNodeRenderers(): Record { +export function useNodeRenderers(): NodeRenderersValidated { const context = React.useContext(ThemeContext); const { renderers } = context ?? {}; return renderers ?? {}; diff --git a/packages/providers/src/types.ts b/packages/providers/src/types.ts deleted file mode 100644 index 232f7401e..000000000 --- a/packages/providers/src/types.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { GenericNode } from 'myst-common'; -import type React from 'react'; - -export type NodeRenderer = React.FC<{ node: GenericNode & T }>; diff --git a/packages/site/src/components/renderers.ts b/packages/site/src/components/renderers.ts index fd2575069..ef49c3b63 100644 --- a/packages/site/src/components/renderers.ts +++ b/packages/site/src/components/renderers.ts @@ -1,10 +1,10 @@ -import type { NodeRenderer } from '@myst-theme/providers'; +import type { NodeRenderers } from '@myst-theme/providers'; import { DEFAULT_RENDERERS } from 'myst-to-react'; import { MystDemoRenderer } from 'myst-demo'; import { MermaidNodeRenderer } from '@myst-theme/diagrams'; import OUTPUT_RENDERERS from '@myst-theme/jupyter'; -export const renderers: Record = { +export const renderers: NodeRenderers = { ...DEFAULT_RENDERERS, myst: MystDemoRenderer, mermaid: MermaidNodeRenderer, diff --git a/packages/site/src/pages/Root.tsx b/packages/site/src/pages/Root.tsx index 04cef5d9f..067cd8080 100644 --- a/packages/site/src/pages/Root.tsx +++ b/packages/site/src/pages/Root.tsx @@ -1,6 +1,6 @@ import type { SiteManifest } from 'myst-config'; import type { SiteLoader } from '@myst-theme/common'; -import type { NodeRenderer } from '@myst-theme/providers'; +import type { NodeRenderers } from '@myst-theme/providers'; import { BaseUrlProvider, SiteProvider, Theme, ThemeProvider } from '@myst-theme/providers'; import { Links, @@ -40,7 +40,7 @@ export function Document({ staticBuild?: boolean; baseurl?: string; top?: number; - renderers?: Record; + renderers?: NodeRenderers; }) { const links = staticBuild ? {