diff --git a/package.json b/package.json index a4a5d48ae..0b0685e51 100644 --- a/package.json +++ b/package.json @@ -50,12 +50,12 @@ "@hedgedoc/markdown-it-task-lists": "1.0.5", "@hedgedoc/realtime": "0.3.0", "@hpcc-js/wasm": "1.16.1", - "@matejmazur/react-katex": "3.1.3", "@mrdrogdrog/optional": "0.2.0", "@react-hook/resize-observer": "1.2.6", "@redux-devtools/core": "3.13.1", "@reduxjs/toolkit": "1.8.5", "@svgr/webpack": "6.3.1", + "@types/katex": "0.14.0", "@uiw/react-codemirror": "4.12.3", "abcjs": "6.1.3", "bootstrap": "4.6.2", diff --git a/src/components/markdown-renderer/markdown-extension/katex/__snapshots__/katex-frame.test.tsx.snap b/src/components/markdown-renderer/markdown-extension/katex/__snapshots__/katex-frame.test.tsx.snap new file mode 100644 index 000000000..e762c8cd5 --- /dev/null +++ b/src/components/markdown-renderer/markdown-extension/katex/__snapshots__/katex-frame.test.tsx.snap @@ -0,0 +1,130 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`katex frame renders a valid latex expression as explicit block 1`] = ` +
+
+ + This is a mock for lib katex with this parameters: + + + + +
+
+`; + +exports[`katex frame renders a valid latex expression as explicit inline 1`] = ` +
+ + + This is a mock for lib katex with this parameters: + + + + + +
+`; + +exports[`katex frame renders a valid latex expression as implicit inline 1`] = ` +
+ + + This is a mock for lib katex with this parameters: + + + + + +
+`; + +exports[`katex frame renders an error for an invalid latex expression as explicit block 1`] = ` +
+
+ +
+
+`; + +exports[`katex frame renders an error for an invalid latex expression as explicit inline 1`] = ` +
+ + + +
+`; + +exports[`katex frame renders an error for an invalid latex expression as implicit inline 1`] = ` +
+ + + +
+`; diff --git a/src/components/markdown-renderer/markdown-extension/katex/__snapshots__/katex-markdown-extension.test.tsx.snap b/src/components/markdown-renderer/markdown-extension/katex/__snapshots__/katex-markdown-extension.test.tsx.snap new file mode 100644 index 000000000..074f782bd --- /dev/null +++ b/src/components/markdown-renderer/markdown-extension/katex/__snapshots__/katex-markdown-extension.test.tsx.snap @@ -0,0 +1,114 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`KaTeX markdown extensions renders a valid block LaTeX expression in a single line 1`] = ` +
+
+ + This is a mock for lib katex with this parameters: + + + + +
+ + +
+`; + +exports[`KaTeX markdown extensions renders a valid block LaTeX expression in multi line 1`] = ` +
+
+ + This is a mock for lib katex with this parameters: + + + + +
+ + +
+`; + +exports[`KaTeX markdown extensions renders a valid inline LaTeX expression 1`] = ` +
+

+ + + This is a mock for lib katex with this parameters: + + + +

+ +

+ + +
+`; + +exports[`KaTeX markdown extensions renders an error message for an invalid LaTeX expression 1`] = ` +
+

+ +

+ +

+ + +
+`; diff --git a/src/components/markdown-renderer/markdown-extension/katex/katex-frame.test.tsx b/src/components/markdown-renderer/markdown-extension/katex/katex-frame.test.tsx new file mode 100644 index 000000000..4d22acb63 --- /dev/null +++ b/src/components/markdown-renderer/markdown-extension/katex/katex-frame.test.tsx @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { render } from '@testing-library/react' +import KatexFrame from './katex-frame' +import type { KatexOptions } from 'katex' +import { default as KatexDefault } from 'katex' + +jest.mock('katex') + +describe('katex frame', () => { + afterAll(() => { + jest.resetAllMocks() + jest.resetModules() + }) + + beforeEach(() => { + jest.spyOn(KatexDefault, 'renderToString').mockImplementation( + (tex: string, options?: KatexOptions) => `This is a mock for lib katex with this parameters: +` + ) + }) + + describe('renders a valid latex expression', () => { + it('as implicit inline', () => { + const view = render() + expect(view.container).toMatchSnapshot() + }) + it('as explicit inline', () => { + const view = render() + expect(view.container).toMatchSnapshot() + }) + it('as explicit block', () => { + const view = render() + expect(view.container).toMatchSnapshot() + }) + }) + + describe('renders an error for an invalid latex expression', () => { + beforeEach(() => { + jest.spyOn(KatexDefault, 'renderToString').mockImplementation(() => { + throw new Error('mocked parseerror') + }) + }) + + it('as implicit inline', () => { + const view = render() + expect(view.container).toMatchSnapshot() + }) + it('as explicit inline', () => { + const view = render() + expect(view.container).toMatchSnapshot() + }) + it('as explicit block', () => { + const view = render() + expect(view.container).toMatchSnapshot() + }) + }) +}) diff --git a/src/components/markdown-renderer/markdown-extension/katex/katex-frame.tsx b/src/components/markdown-renderer/markdown-extension/katex/katex-frame.tsx new file mode 100644 index 000000000..c7ae3c0cf --- /dev/null +++ b/src/components/markdown-renderer/markdown-extension/katex/katex-frame.tsx @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { useMemo } from 'react' +import KaTeX from 'katex' +import convertHtmlToReact from '@hedgedoc/html-to-react' +import 'katex/dist/katex.min.css' +import { Alert } from 'react-bootstrap' +import { testId } from '../../../../utils/test-id' +import { sanitize } from 'dompurify' + +interface KatexFrameProps { + expression: string + block?: boolean +} + +/** + * Renders a LaTeX expression. + * + * @param expression The mathematical expression to render + * @param block Defines if the output should be a block or inline. + */ +export const KatexFrame: React.FC = ({ expression, block = false }) => { + const dom = useMemo(() => { + try { + const katexHtml = KaTeX.renderToString(expression, { + displayMode: block === true, + throwOnError: true + }) + return convertHtmlToReact(sanitize(katexHtml, { ADD_TAGS: ['semantics', 'annotation'] })) + } catch (error) { + return ( + + {(error as Error).message} + + ) + } + }, [block, expression]) + + return block ?
{dom}
: {dom} +} + +export default KatexFrame diff --git a/src/components/markdown-renderer/markdown-extension/katex/katex-markdown-extension.test.tsx b/src/components/markdown-renderer/markdown-extension/katex/katex-markdown-extension.test.tsx new file mode 100644 index 000000000..1294d3fe2 --- /dev/null +++ b/src/components/markdown-renderer/markdown-extension/katex/katex-markdown-extension.test.tsx @@ -0,0 +1,75 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { render, screen } from '@testing-library/react' +import { KatexMarkdownExtension } from './katex-markdown-extension' +import { TestMarkdownRenderer } from '../../test-utils/test-markdown-renderer' +import { Suspense } from 'react' +import type { KatexOptions } from 'katex' +import { default as KatexDefault } from 'katex' + +jest.mock('katex') + +describe('KaTeX markdown extensions', () => { + afterAll(() => { + jest.resetAllMocks() + jest.resetModules() + }) + + beforeEach(() => { + jest.spyOn(KatexDefault, 'renderToString').mockImplementation( + (tex: string, options?: KatexOptions) => `This is a mock for lib katex with this parameters: +` + ) + }) + + it('renders a valid inline LaTeX expression', async () => { + const view = render( + + + + ) + expect(await screen.findByTestId('katex-inline')).toBeInTheDocument() + expect(view.container).toMatchSnapshot() + }) + + it('renders a valid block LaTeX expression in a single line', async () => { + const view = render( + + + + ) + expect(await screen.findByTestId('katex-block')).toBeInTheDocument() + expect(view.container).toMatchSnapshot() + }) + + it('renders a valid block LaTeX expression in multi line', async () => { + const view = render( + + + + ) + expect(await screen.findByTestId('katex-block')).toBeInTheDocument() + expect(view.container).toMatchSnapshot() + }) + + it('renders an error message for an invalid LaTeX expression', async () => { + jest.spyOn(KatexDefault, 'renderToString').mockImplementation(() => { + throw new Error('mocked parseerror') + }) + + const view = render( + + + + ) + expect(await screen.findByTestId('katex-inline')).toBeInTheDocument() + expect(view.container).toMatchSnapshot() + }) +}) diff --git a/src/components/markdown-renderer/markdown-extension/katex/katex-replacer.tsx b/src/components/markdown-renderer/markdown-extension/katex/katex-replacer.tsx index 67e6056b3..933065021 100644 --- a/src/components/markdown-renderer/markdown-extension/katex/katex-replacer.tsx +++ b/src/components/markdown-renderer/markdown-extension/katex/katex-replacer.tsx @@ -8,49 +8,60 @@ import type { Element } from 'domhandler' import { isTag } from 'domhandler' import React from 'react' import { ComponentReplacer, DO_NOT_REPLACE } from '../../replace-components/component-replacer' -import 'katex/dist/katex.min.css' import { KatexMarkdownExtension } from './katex-markdown-extension' +import { Optional } from '@mrdrogdrog/optional' -/** - * Checks if the given node is a KaTeX block. - * - * @param node the node to check - * @return The given node if it is a KaTeX block element, {@link undefined} otherwise. - */ -const containsKatexBlock = (node: Element): Element | undefined => { - if (node.name !== 'p' || !node.children || node.children.length === 0) { - return - } - return node.children.filter(isTag).find((subnode) => { - return isKatexTag(subnode, false) ? subnode : undefined - }) -} - -/** - * Checks if the given node is a KaTeX element. - * - * @param node the node to check - * @param expectedInline defines if the found katex element is expected to be an inline or block element. - * @return {@link true} if the given node is a katex element. - */ -const isKatexTag = (node: Element, expectedInline: boolean) => { - return ( - node.name === KatexMarkdownExtension.tagName && (node.attribs?.['data-inline'] !== undefined) === expectedInline - ) -} - -const KaTeX = React.lazy(() => import(/* webpackChunkName: "katex" */ '@matejmazur/react-katex')) +const KaTeX = React.lazy(() => import(/* webpackChunkName: "katex" */ './katex-frame')) /** * Detects LaTeX syntax and renders it with KaTeX. */ export class KatexReplacer extends ComponentReplacer { public replace(node: Element): React.ReactElement | undefined { - if (!(isKatexTag(node, true) || containsKatexBlock(node)) || node.children?.[0] === undefined) { - return DO_NOT_REPLACE - } - const latexContent = ComponentReplacer.extractTextChildContent(node) - const isInline = !!node.attribs?.['data-inline'] - return + return this.extractKatexContent(node) + .map((latexContent) => { + const isInline = !!node.attribs?.['data-inline'] + return + }) + .orElse(DO_NOT_REPLACE) + } + + /** + * Checks the given node for katex expression tags and extracts the LaTeX code. + * + * @param node The node to scan for inline or block tags + * @return An optional that contains the extracted latex code + */ + private extractKatexContent(node: Element): Optional { + return this.isKatexTag(node, true) + ? Optional.of(ComponentReplacer.extractTextChildContent(node)) + : this.extractKatexBlock(node).map((childNode) => ComponentReplacer.extractTextChildContent(childNode)) + } + + /** + * Checks if the given node is a KaTeX element. + * + * @param node the node to check + * @param expectedInline defines if the found katex element is expected to be an inline or block element. + * @return {@link true} if the given node is a katex element. + */ + private isKatexTag(node: Element, expectedInline: boolean) { + return ( + node.name === KatexMarkdownExtension.tagName && (node.attribs?.['data-inline'] !== undefined) === expectedInline + ) + } + + /** + * Checks if the given node is a KaTeX block. + * + * @param node the node to check + * @return The given node if it is a KaTeX block element, {@link undefined} otherwise. + */ + private extractKatexBlock(node: Element): Optional { + return Optional.of(node) + .filter((node) => node.name === 'p' && node.children?.length > 0) + .map((node) => + node.children.filter(isTag).find((subNode) => (this.isKatexTag(subNode, false) ? subNode : undefined)) + ) } } diff --git a/yarn.lock b/yarn.lock index e68020c76..db60d5d9d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1946,7 +1946,6 @@ __metadata: "@hedgedoc/markdown-it-task-lists": 1.0.5 "@hedgedoc/realtime": 0.3.0 "@hpcc-js/wasm": 1.16.1 - "@matejmazur/react-katex": 3.1.3 "@mrdrogdrog/optional": 0.2.0 "@next/bundle-analyzer": 12.3.1 "@react-hook/resize-observer": 1.2.6 @@ -1963,6 +1962,7 @@ __metadata: "@types/dompurify": 2.3.4 "@types/jest": 29.0.3 "@types/js-yaml": 4.0.5 + "@types/katex": 0.14.0 "@types/luxon": 3.0.1 "@types/markdown-it": 12.2.3 "@types/markdown-it-container": 2.0.5 @@ -2582,16 +2582,6 @@ __metadata: languageName: node linkType: hard -"@matejmazur/react-katex@npm:3.1.3": - version: 3.1.3 - resolution: "@matejmazur/react-katex@npm:3.1.3" - peerDependencies: - katex: ">=0.9" - react: ">=16" - checksum: db9e9aa03d3b094fcb9854abeab3676732b04dbe4fbfe2dbf26782a6d50c197351d06ad833c78b90f7e418d769b77c30460a60906e80e800d84e16c938f3d6d1 - languageName: node - linkType: hard - "@mrdrogdrog/optional@npm:0.2.0": version: 0.2.0 resolution: "@mrdrogdrog/optional@npm:0.2.0" @@ -3428,6 +3418,13 @@ __metadata: languageName: node linkType: hard +"@types/katex@npm:0.14.0": + version: 0.14.0 + resolution: "@types/katex@npm:0.14.0" + checksum: 330e0d0337ba48c87f5b793965fbad673653789bf6e50dfe8d726a7b0cbefd37195055e31503aae629814aa79447e4f23a4b87ad1ac565c0d9a9d9978836f39b + languageName: node + linkType: hard + "@types/linkify-it@npm:*": version: 3.0.2 resolution: "@types/linkify-it@npm:3.0.2"