Replace @matejmazur/react-katex with own katex component (#2381)

* Replace @matejmazur/react-katex with self-made katex component

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2022-09-25 15:17:08 +02:00 committed by GitHub
parent c57df024f3
commit a9435e3652
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 486 additions and 48 deletions

View file

@ -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",

View file

@ -0,0 +1,130 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`katex frame renders a valid latex expression as explicit block 1`] = `
<div>
<div
data-testid="katex-block"
>
<span>
This is a mock for lib katex with this parameters:
</span>
<ul>
<li>
tex: \\int_0^\\infty x^2 dx
</li>
<li>
block: true
</li>
</ul>
</div>
</div>
`;
exports[`katex frame renders a valid latex expression as explicit inline 1`] = `
<div>
<span
data-testid="katex-inline"
>
<span>
This is a mock for lib katex with this parameters:
</span>
<ul>
<li>
tex: \\int_0^\\infty x^2 dx
</li>
<li>
block: false
</li>
</ul>
</span>
</div>
`;
exports[`katex frame renders a valid latex expression as implicit inline 1`] = `
<div>
<span
data-testid="katex-inline"
>
<span>
This is a mock for lib katex with this parameters:
</span>
<ul>
<li>
tex: \\int_0^\\infty x^2 dx
</li>
<li>
block: false
</li>
</ul>
</span>
</div>
`;
exports[`katex frame renders an error for an invalid latex expression as explicit block 1`] = `
<div>
<div
data-testid="katex-block"
>
<div
class="fade alert alert-danger show"
role="alert"
>
mocked parseerror
</div>
</div>
</div>
`;
exports[`katex frame renders an error for an invalid latex expression as explicit inline 1`] = `
<div>
<span
data-testid="katex-inline"
>
<div
class="fade d-inline-block alert alert-danger show"
role="alert"
>
mocked parseerror
</div>
</span>
</div>
`;
exports[`katex frame renders an error for an invalid latex expression as implicit inline 1`] = `
<div>
<span
data-testid="katex-inline"
>
<div
class="fade d-inline-block alert alert-danger show"
role="alert"
>
mocked parseerror
</div>
</span>
</div>
`;

View file

@ -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`] = `
<div>
<div
data-testid="katex-block"
>
<span>
This is a mock for lib katex with this parameters:
</span>
<ul>
<li>
tex: \\alpha
</li>
<li>
block: true
</li>
</ul>
</div>
</div>
`;
exports[`KaTeX markdown extensions renders a valid block LaTeX expression in multi line 1`] = `
<div>
<div
data-testid="katex-block"
>
<span>
This is a mock for lib katex with this parameters:
</span>
<ul>
<li>
tex:
\\alpha
</li>
<li>
block: true
</li>
</ul>
</div>
</div>
`;
exports[`KaTeX markdown extensions renders a valid inline LaTeX expression 1`] = `
<div>
<p>
<span
data-testid="katex-inline"
>
<span>
This is a mock for lib katex with this parameters:
</span>
<ul>
<li>
tex: \\alpha
</li>
<li>
block: false
</li>
</ul>
</span>
</p>
</div>
`;
exports[`KaTeX markdown extensions renders an error message for an invalid LaTeX expression 1`] = `
<div>
<p>
<span
data-testid="katex-inline"
>
<div
class="fade d-inline-block alert alert-danger show"
role="alert"
>
mocked parseerror
</div>
</span>
</p>
</div>
`;

View file

@ -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) => `<span>This is a mock for lib katex with this parameters:</span>
<ul>
<li>tex: ${tex}</li>
<li>block: ${String(options?.displayMode)}</li>
</ul>`
)
})
describe('renders a valid latex expression', () => {
it('as implicit inline', () => {
const view = render(<KatexFrame expression={'\\int_0^\\infty x^2 dx'}></KatexFrame>)
expect(view.container).toMatchSnapshot()
})
it('as explicit inline', () => {
const view = render(<KatexFrame expression={'\\int_0^\\infty x^2 dx'} block={false}></KatexFrame>)
expect(view.container).toMatchSnapshot()
})
it('as explicit block', () => {
const view = render(<KatexFrame expression={'\\int_0^\\infty x^2 dx'} block={true}></KatexFrame>)
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(<KatexFrame expression={'\\alf'}></KatexFrame>)
expect(view.container).toMatchSnapshot()
})
it('as explicit inline', () => {
const view = render(<KatexFrame expression={'\\alf'} block={false}></KatexFrame>)
expect(view.container).toMatchSnapshot()
})
it('as explicit block', () => {
const view = render(<KatexFrame expression={'\\alf'} block={true}></KatexFrame>)
expect(view.container).toMatchSnapshot()
})
})
})

View file

@ -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<KatexFrameProps> = ({ 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 (
<Alert className={block ? '' : 'd-inline-block'} variant={'danger'}>
{(error as Error).message}
</Alert>
)
}
}, [block, expression])
return block ? <div {...testId('katex-block')}>{dom}</div> : <span {...testId('katex-inline')}>{dom}</span>
}
export default KatexFrame

View file

@ -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) => `<span>This is a mock for lib katex with this parameters:</span>
<ul>
<li>tex: ${tex}</li>
<li>block: ${String(options?.displayMode)}</li>
</ul>`
)
})
it('renders a valid inline LaTeX expression', async () => {
const view = render(
<Suspense fallback={null}>
<TestMarkdownRenderer extensions={[new KatexMarkdownExtension()]} content={'$\\alpha$'} />
</Suspense>
)
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(
<Suspense fallback={null}>
<TestMarkdownRenderer extensions={[new KatexMarkdownExtension()]} content={'$$$\\alpha$$$'} />
</Suspense>
)
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(
<Suspense fallback={null}>
<TestMarkdownRenderer extensions={[new KatexMarkdownExtension()]} content={'$$$\n\\alpha\n$$$'} />
</Suspense>
)
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(
<Suspense fallback={null}>
<TestMarkdownRenderer extensions={[new KatexMarkdownExtension()]} content={'$\\a$'} />
</Suspense>
)
expect(await screen.findByTestId('katex-inline')).toBeInTheDocument()
expect(view.container).toMatchSnapshot()
})
})

View file

@ -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 <KaTeX block={!isInline} math={latexContent} errorColor={'#cc0000'} />
return this.extractKatexContent(node)
.map((latexContent) => {
const isInline = !!node.attribs?.['data-inline']
return <KaTeX key={'katex'} block={!isInline} expression={latexContent} />
})
.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<string> {
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<Element> {
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))
)
}
}

View file

@ -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"