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:
+
+
+
+
+
+
+ -
+ tex: \\int_0^\\infty x^2 dx
+
+
+
+ -
+ block: true
+
+
+
+
+
+
+`;
+
+exports[`katex frame renders a valid latex expression as explicit inline 1`] = `
+
+
+
+ This is a mock for lib katex with this parameters:
+
+
+
+
+
+
+ -
+ tex: \\int_0^\\infty x^2 dx
+
+
+
+ -
+ block: false
+
+
+
+
+
+
+`;
+
+exports[`katex frame renders a valid latex expression as implicit inline 1`] = `
+
+
+
+ This is a mock for lib katex with this parameters:
+
+
+
+
+
+
+ -
+ tex: \\int_0^\\infty x^2 dx
+
+
+
+ -
+ block: false
+
+
+
+
+
+
+`;
+
+exports[`katex frame renders an error for an invalid latex expression as explicit block 1`] = `
+
+
+
+ mocked parseerror
+
+
+
+`;
+
+exports[`katex frame renders an error for an invalid latex expression as explicit inline 1`] = `
+
+
+
+ mocked parseerror
+
+
+
+`;
+
+exports[`katex frame renders an error for an invalid latex expression as implicit inline 1`] = `
+
+
+
+ mocked parseerror
+
+
+
+`;
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:
+
+
+
+
+
+
+ -
+ tex: \\alpha
+
+
+
+ -
+ block: true
+
+
+
+
+
+
+
+
+`;
+
+exports[`KaTeX markdown extensions renders a valid block LaTeX expression in multi line 1`] = `
+
+
+
+ This is a mock for lib katex with this parameters:
+
+
+
+
+
+
+ -
+ tex:
+\\alpha
+
+
+
+
+ -
+ block: true
+
+
+
+
+
+
+
+
+`;
+
+exports[`KaTeX markdown extensions renders a valid inline LaTeX expression 1`] = `
+
+
+
+
+ This is a mock for lib katex with this parameters:
+
+
+
+
+
+
+ -
+ tex: \\alpha
+
+
+
+ -
+ block: false
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`KaTeX markdown extensions renders an error message for an invalid LaTeX expression 1`] = `
+
+
+
+
+ mocked parseerror
+
+
+
+
+
+
+`;
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:
+
+ - tex: ${tex}
+ - block: ${String(options?.displayMode)}
+
`
+ )
+ })
+
+ 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:
+
+ - tex: ${tex}
+ - block: ${String(options?.displayMode)}
+
`
+ )
+ })
+
+ 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"