diff --git a/frontend/src/components/common/html-to-react/__snapshots__/html-to-react.test.tsx.snap b/frontend/src/components/common/html-to-react/__snapshots__/html-to-react.test.tsx.snap new file mode 100644 index 000000000..83f048ac9 --- /dev/null +++ b/frontend/src/components/common/html-to-react/__snapshots__/html-to-react.test.tsx.snap @@ -0,0 +1,30 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`HTML to React renders basic html correctly 1`] = ` +
+

+ This is a test + + sentence + +

+
+`; + +exports[`HTML to React will forward the DomPurify settings 1`] = ` +
+ + Test! + +
+`; + +exports[`HTML to React will forward the parser options 1`] = ` +
+

+ Hijacked! +

+
+`; + +exports[`HTML to React won't render script tags 1`] = `
`; diff --git a/frontend/src/components/common/html-to-react/html-to-react.test.tsx b/frontend/src/components/common/html-to-react/html-to-react.test.tsx new file mode 100644 index 000000000..6c6f18dcb --- /dev/null +++ b/frontend/src/components/common/html-to-react/html-to-react.test.tsx @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { HtmlToReact } from './html-to-react' +import { render } from '@testing-library/react' + +describe('HTML to React', () => { + it('renders basic html correctly', () => { + const view = render(This is a test sentence

'} />) + expect(view.container).toMatchSnapshot() + }) + + it("won't render script tags", () => { + const view = render(alert("XSS!")'} />) + expect(view.container).toMatchSnapshot() + }) + + it('will forward the DomPurify settings', () => { + const view = render( + Test!'} /> + ) + expect(view.container).toMatchSnapshot() + }) + + it('will forward the parser options', () => { + let transformerVisited = false + let preprocessNodesVisited = false + + const view = render( + This is a sentence

'} + parserOptions={{ + transform: () => { + transformerVisited = true + return

Hijacked!

+ }, + preprocessNodes: (document) => { + preprocessNodesVisited = true + return document + } + }} + /> + ) + expect(view.container).toMatchSnapshot() + expect(preprocessNodesVisited).toBeTruthy() + expect(transformerVisited).toBeTruthy() + }) +}) diff --git a/frontend/src/components/common/html-to-react/html-to-react.tsx b/frontend/src/components/common/html-to-react/html-to-react.tsx new file mode 100644 index 000000000..0e2dfac13 --- /dev/null +++ b/frontend/src/components/common/html-to-react/html-to-react.tsx @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import type { ParserOptions } from '@hedgedoc/html-to-react' +import convertHtmlToReact from '@hedgedoc/html-to-react' +import type DOMPurify from 'dompurify' +import { sanitize } from 'dompurify' +import React, { Fragment, useMemo } from 'react' + +export interface HtmlToReactProps { + htmlCode: string + domPurifyConfig?: DOMPurify.Config + parserOptions?: ParserOptions +} + +/** + * Renders + * @param htmlCode + * @param domPurifyConfig + * @param parserOptions + * @constructor + */ +export const HtmlToReact: React.FC = ({ htmlCode, domPurifyConfig, parserOptions }) => { + const elements = useMemo(() => { + const sanitizedHtmlCode = sanitize(htmlCode, { ...domPurifyConfig, RETURN_DOM_FRAGMENT: false, RETURN_DOM: false }) + return convertHtmlToReact(sanitizedHtmlCode, parserOptions) + }, [domPurifyConfig, htmlCode, parserOptions]) + + return {elements} +} diff --git a/frontend/src/components/markdown-renderer/document-markdown-renderer.tsx b/frontend/src/components/markdown-renderer/document-markdown-renderer.tsx index cff07a872..b6e0ef3e0 100644 --- a/frontend/src/components/markdown-renderer/document-markdown-renderer.tsx +++ b/frontend/src/components/markdown-renderer/document-markdown-renderer.tsx @@ -7,11 +7,12 @@ import { cypressId } from '../../utils/cypress-attribute' import type { CommonMarkdownRendererProps } from './common-markdown-renderer-props' import { HeadlineAnchorsMarkdownExtension } from './extensions/headline-anchors-markdown-extension' import type { LineMarkers } from './extensions/linemarker/add-line-marker-markdown-it-plugin' +import { LinemarkerMarkdownExtension } from './extensions/linemarker/linemarker-markdown-extension' import type { LineMarkerPosition } from './extensions/linemarker/types' -import { useConvertMarkdownToReactDom } from './hooks/use-convert-markdown-to-react-dom' +import { useCalculateLineMarkerPosition } from './hooks/use-calculate-line-marker-positions' import { useExtractFirstHeadline } from './hooks/use-extract-first-headline' import { useMarkdownExtensions } from './hooks/use-markdown-extensions' -import { useCalculateLineMarkerPosition } from './utils/calculate-line-marker-positions' +import { MarkdownToReact } from './markdown-to-react/markdown-to-react' import React, { useEffect, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' @@ -27,9 +28,7 @@ export interface DocumentMarkdownRendererProps extends CommonMarkdownRendererPro * @param onFirstHeadingChange The callback to call if the first heading changes. * @param onLineMarkerPositionChanged The callback to call with changed {@link LineMarkers} * @param onTaskCheckedChange The callback to call if a task is checked or unchecked. - * @param onTocChange The callback to call if the toc changes. * @param baseUrl The base url of the renderer - * @param onImageClick The callback to call if a image is clicked * @param outerContainerRef A reference for the outer container * @param newlinesAreBreaks If newlines are rendered as breaks or not */ @@ -47,19 +46,17 @@ export const DocumentMarkdownRenderer: React.FC = const extensions = useMarkdownExtensions( baseUrl, - currentLineMarkers, - useMemo(() => [new HeadlineAnchorsMarkdownExtension()], []) + useMemo( + () => [ + new HeadlineAnchorsMarkdownExtension(), + new LinemarkerMarkdownExtension((values) => (currentLineMarkers.current = values)) + ], + [] + ) ) - const markdownReactDom = useConvertMarkdownToReactDom(markdownContentLines, extensions, newlinesAreBreaks, true) - useTranslation() - useCalculateLineMarkerPosition( - markdownBodyRef, - currentLineMarkers.current, - onLineMarkerPositionChanged, - markdownBodyRef.current?.offsetTop ?? 0 - ) + useCalculateLineMarkerPosition(markdownBodyRef, currentLineMarkers.current, onLineMarkerPositionChanged) const extractFirstHeadline = useExtractFirstHeadline(markdownBodyRef, onFirstHeadingChange) useEffect(() => { extractFirstHeadline() @@ -72,7 +69,12 @@ export const DocumentMarkdownRenderer: React.FC = ref={markdownBodyRef} data-word-count-target={true} className={`${className ?? ''} markdown-body w-100 d-flex flex-column align-items-center`}> - {markdownReactDom} +
) diff --git a/frontend/src/components/markdown-renderer/extensions/image-placeholder/image-placeholder-markdown-extension.ts b/frontend/src/components/markdown-renderer/extensions/image-placeholder/image-placeholder-markdown-extension.ts index f1f75d2b5..89b5405a6 100644 --- a/frontend/src/components/markdown-renderer/extensions/image-placeholder/image-placeholder-markdown-extension.ts +++ b/frontend/src/components/markdown-renderer/extensions/image-placeholder/image-placeholder-markdown-extension.ts @@ -15,10 +15,6 @@ import type MarkdownIt from 'markdown-it/lib' export class ImagePlaceholderMarkdownExtension extends MarkdownRendererExtension { public static readonly PLACEHOLDER_URL = 'https://' - constructor() { - super() - } - configureMarkdownIt(markdownIt: MarkdownIt): void { addLineToPlaceholderImageTags(markdownIt) } diff --git a/frontend/src/components/markdown-renderer/extensions/linemarker/types.d.ts b/frontend/src/components/markdown-renderer/extensions/linemarker/types.d.ts index 44566a65a..5fcdba88b 100644 --- a/frontend/src/components/markdown-renderer/extensions/linemarker/types.d.ts +++ b/frontend/src/components/markdown-renderer/extensions/linemarker/types.d.ts @@ -9,6 +9,9 @@ export interface LineWithId { id: number } +/** + * Defines the line number of a line marker and its absolute scroll position on the page. + */ export interface LineMarkerPosition { line: number position: number diff --git a/frontend/src/components/markdown-renderer/extensions/sanitizer/dom-purifier-node-preprocessor.ts b/frontend/src/components/markdown-renderer/extensions/sanitizer/dom-purifier-node-preprocessor.ts deleted file mode 100644 index 6f0c87ec4..000000000 --- a/frontend/src/components/markdown-renderer/extensions/sanitizer/dom-purifier-node-preprocessor.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { NodeProcessor } from '../../node-preprocessors/node-processor' -import render from 'dom-serializer' -import type { Document } from 'domhandler' -import DOMPurify from 'dompurify' -import { parseDocument } from 'htmlparser2' - -/** - * Sanitizes the given {@link Document document}. - * - * @see https://cure53.de/purify - */ -export class SanitizerNodePreprocessor extends NodeProcessor { - constructor(private tagNameWhiteList: string[]) { - super() - } - - process(nodes: Document): Document { - const sanitizedHtml = DOMPurify.sanitize(render(nodes), { - ADD_TAGS: this.tagNameWhiteList - }) - return parseDocument(sanitizedHtml) - } -} diff --git a/frontend/src/components/markdown-renderer/extensions/sanitizer/sanitizer-markdown-extension.ts b/frontend/src/components/markdown-renderer/extensions/sanitizer/sanitizer-markdown-extension.ts deleted file mode 100644 index ce1d1ad87..000000000 --- a/frontend/src/components/markdown-renderer/extensions/sanitizer/sanitizer-markdown-extension.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import type { NodeProcessor } from '../../node-preprocessors/node-processor' -import { MarkdownRendererExtension } from '../base/markdown-renderer-extension' -import { SanitizerNodePreprocessor } from './dom-purifier-node-preprocessor' - -/** - * Adds support for html sanitizing using dompurify to the markdown rendering. - */ -export class SanitizerMarkdownExtension extends MarkdownRendererExtension { - constructor(private tagNameWhiteList: string[]) { - super() - } - - public buildNodeProcessors(): NodeProcessor[] { - return [new SanitizerNodePreprocessor(this.tagNameWhiteList)] - } -} diff --git a/frontend/src/components/markdown-renderer/extensions/table-of-contents-markdown-extension.ts b/frontend/src/components/markdown-renderer/extensions/table-of-contents-markdown-extension.ts index ed8278149..6a436d680 100644 --- a/frontend/src/components/markdown-renderer/extensions/table-of-contents-markdown-extension.ts +++ b/frontend/src/components/markdown-renderer/extensions/table-of-contents-markdown-extension.ts @@ -18,17 +18,20 @@ export class TableOfContentsMarkdownExtension extends MarkdownRendererExtension private lastAst: TocAst | undefined = undefined public configureMarkdownIt(markdownIt: MarkdownIt): void { - toc(markdownIt, { - listType: 'ul', - level: [1, 2, 3], - callback: (ast: TocAst): void => { - if (equal(ast, this.lastAst)) { - return - } - this.lastAst = ast - this.eventEmitter?.emit(TableOfContentsMarkdownExtension.EVENT_NAME, ast) - }, - slugify: tocSlugify - }) + const eventEmitter = this.eventEmitter + if (eventEmitter !== undefined) { + toc(markdownIt, { + listType: 'ul', + level: [1, 2, 3], + callback: (ast: TocAst): void => { + if (equal(ast, this.lastAst)) { + return + } + this.lastAst = ast + eventEmitter.emit(TableOfContentsMarkdownExtension.EVENT_NAME, ast) + }, + slugify: tocSlugify + }) + } } } diff --git a/frontend/src/components/markdown-renderer/utils/calculate-line-marker-positions.ts b/frontend/src/components/markdown-renderer/hooks/use-calculate-line-marker-positions.ts similarity index 90% rename from frontend/src/components/markdown-renderer/utils/calculate-line-marker-positions.ts rename to frontend/src/components/markdown-renderer/hooks/use-calculate-line-marker-positions.ts index 3c2ebb2d0..09aed8e29 100644 --- a/frontend/src/components/markdown-renderer/utils/calculate-line-marker-positions.ts +++ b/frontend/src/components/markdown-renderer/hooks/use-calculate-line-marker-positions.ts @@ -52,13 +52,11 @@ const calculateLineMarkerPositions = ( * @param documentElement A reference to the rendered document. * @param lineMarkers A list of {@link LineMarkers} * @param onLineMarkerPositionChanged The callback to call if the {@link LineMarkerPosition line marker positions} change e.g. by rendering or resizing. - * @param offset The optional offset */ export const useCalculateLineMarkerPosition = ( documentElement: RefObject, - lineMarkers?: LineMarkers[], - onLineMarkerPositionChanged?: (lineMarkerPosition: LineMarkerPosition[]) => void, - offset?: number + lineMarkers: LineMarkers[] | undefined, + onLineMarkerPositionChanged?: (lineMarkerPosition: LineMarkerPosition[]) => void ): void => { const lastLineMarkerPositions = useRef() @@ -67,13 +65,17 @@ export const useCalculateLineMarkerPosition = ( return } - const newLines = calculateLineMarkerPositions(documentElement.current, lineMarkers, offset) + const newLines = calculateLineMarkerPositions( + documentElement.current, + lineMarkers, + documentElement.current.offsetTop ?? 0 + ) if (!equal(newLines, lastLineMarkerPositions)) { lastLineMarkerPositions.current = newLines onLineMarkerPositionChanged(newLines) } - }, [documentElement, lineMarkers, offset, onLineMarkerPositionChanged]) + }, [documentElement, lineMarkers, onLineMarkerPositionChanged]) useEffect(() => { calculateNewLineMarkerPositions() diff --git a/frontend/src/components/markdown-renderer/hooks/use-convert-markdown-to-react-dom.tsx b/frontend/src/components/markdown-renderer/hooks/use-convert-markdown-to-react-dom.tsx deleted file mode 100644 index d03ac959f..000000000 --- a/frontend/src/components/markdown-renderer/hooks/use-convert-markdown-to-react-dom.tsx +++ /dev/null @@ -1,62 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import type { MarkdownRendererExtension } from '../extensions/base/markdown-renderer-extension' -import { SanitizerMarkdownExtension } from '../extensions/sanitizer/sanitizer-markdown-extension' -import type { ValidReactDomElement } from '../replace-components/component-replacer' -import { LineIdMapper } from '../utils/line-id-mapper' -import { NodeToReactTransformer } from '../utils/node-to-react-transformer' -import { useCombinedNodePreprocessor } from './use-combined-node-preprocessor' -import { useConfiguredMarkdownIt } from './use-configured-markdown-it' -import convertHtmlToReact from '@hedgedoc/html-to-react' -import React, { Fragment, useMemo } from 'react' - -/** - * Renders Markdown-Code into react elements. - * - * @param markdownContentLines The Markdown code lines that should be rendered - * @param additionalMarkdownExtensions A list of {@link MarkdownRendererExtension markdown extensions} that should be used - * @param newlinesAreBreaks Defines if the alternative break mode of markdown it should be used - * @param allowHtml Defines if html is allowed in markdown - * @return The React DOM that represents the rendered Markdown code - */ -export const useConvertMarkdownToReactDom = ( - markdownContentLines: string[], - additionalMarkdownExtensions: MarkdownRendererExtension[], - newlinesAreBreaks = true, - allowHtml = true -): ValidReactDomElement => { - const lineNumberMapper = useMemo(() => new LineIdMapper(), []) - const htmlToReactTransformer = useMemo(() => new NodeToReactTransformer(), []) - const markdownExtensions = useMemo(() => { - const tagWhiteLists = additionalMarkdownExtensions.flatMap((extension) => extension.buildTagNameAllowList()) - return [...additionalMarkdownExtensions, new SanitizerMarkdownExtension(tagWhiteLists)] - }, [additionalMarkdownExtensions]) - - useMemo(() => { - htmlToReactTransformer.setReplacers(markdownExtensions.flatMap((extension) => extension.buildReplacers())) - }, [htmlToReactTransformer, markdownExtensions]) - - useMemo(() => { - htmlToReactTransformer.setLineIds(lineNumberMapper.updateLineMapping(markdownContentLines)) - }, [htmlToReactTransformer, lineNumberMapper, markdownContentLines]) - - const nodePreProcessor = useCombinedNodePreprocessor(markdownExtensions) - const markdownIt = useConfiguredMarkdownIt(markdownExtensions, allowHtml, newlinesAreBreaks) - - return useMemo(() => { - const html = markdownIt.render(markdownContentLines.join('\n')) - htmlToReactTransformer.resetReplacers() - - return ( - - {convertHtmlToReact(html, { - transform: (node, index) => htmlToReactTransformer.translateNodeToReactElement(node, index), - preprocessNodes: (document) => nodePreProcessor(document) - })} - - ) - }, [htmlToReactTransformer, markdownContentLines, markdownIt, nodePreProcessor]) -} diff --git a/frontend/src/components/markdown-renderer/hooks/use-markdown-extensions.ts b/frontend/src/components/markdown-renderer/hooks/use-markdown-extensions.ts index c8484ef00..c3997e29a 100644 --- a/frontend/src/components/markdown-renderer/hooks/use-markdown-extensions.ts +++ b/frontend/src/components/markdown-renderer/hooks/use-markdown-extensions.ts @@ -12,43 +12,30 @@ import { GenericSyntaxMarkdownExtension } from '../extensions/generic-syntax-mar import { IframeCapsuleMarkdownExtension } from '../extensions/iframe-capsule/iframe-capsule-markdown-extension' import { ImagePlaceholderMarkdownExtension } from '../extensions/image-placeholder/image-placeholder-markdown-extension' import { ProxyImageMarkdownExtension } from '../extensions/image/proxy-image-markdown-extension' -import type { LineMarkers } from '../extensions/linemarker/add-line-marker-markdown-it-plugin' -import { LinemarkerMarkdownExtension } from '../extensions/linemarker/linemarker-markdown-extension' import { LinkAdjustmentMarkdownExtension } from '../extensions/link-replacer/link-adjustment-markdown-extension' import { LinkifyFixMarkdownExtension } from '../extensions/linkify-fix/linkify-fix-markdown-extension' import { TableOfContentsMarkdownExtension } from '../extensions/table-of-contents-markdown-extension' import { UploadIndicatingImageFrameMarkdownExtension } from '../extensions/upload-indicating-image-frame/upload-indicating-image-frame-markdown-extension' import { useExtensionEventEmitter } from './use-extension-event-emitter' -import type { MutableRefObject } from 'react' import { useMemo } from 'react' -const optionalMarkdownRendererExtensions = optionalAppExtensions.flatMap((value) => - value.buildMarkdownRendererExtensions() -) - /** * Provides a list of {@link MarkdownRendererExtension markdown extensions} that is a combination of the common extensions and the given additional. * * @param baseUrl The base url for the {@link LinkAdjustmentMarkdownExtension} - * @param currentLineMarkers A {@link MutableRefObject reference} to {@link LineMarkers} for the {@link LinemarkerMarkdownExtension} * @param additionalExtensions The additional extensions that should be included in the list * @return The created list of markdown extensions */ export const useMarkdownExtensions = ( baseUrl: string, - currentLineMarkers: MutableRefObject | undefined, additionalExtensions: MarkdownRendererExtension[] ): MarkdownRendererExtension[] => { const extensionEventEmitter = useExtensionEventEmitter() - //replace with global list return useMemo(() => { return [ - ...optionalMarkdownRendererExtensions, + ...optionalAppExtensions.flatMap((extension) => extension.buildMarkdownRendererExtensions(extensionEventEmitter)), ...additionalExtensions, - new TableOfContentsMarkdownExtension(extensionEventEmitter), - new LinemarkerMarkdownExtension( - currentLineMarkers ? (lineMarkers) => (currentLineMarkers.current = lineMarkers) : undefined - ), + new TableOfContentsMarkdownExtension(), new IframeCapsuleMarkdownExtension(), new ImagePlaceholderMarkdownExtension(), new UploadIndicatingImageFrameMarkdownExtension(), @@ -60,5 +47,5 @@ export const useMarkdownExtensions = ( new DebuggerMarkdownExtension(), new ProxyImageMarkdownExtension() ] - }, [additionalExtensions, baseUrl, currentLineMarkers, extensionEventEmitter]) + }, [additionalExtensions, baseUrl, extensionEventEmitter]) } diff --git a/frontend/src/components/markdown-renderer/markdown-to-react/__snapshots__/markdown-to-react.test.tsx.snap b/frontend/src/components/markdown-renderer/markdown-to-react/__snapshots__/markdown-to-react.test.tsx.snap new file mode 100644 index 000000000..4152abd0d --- /dev/null +++ b/frontend/src/components/markdown-renderer/markdown-to-react/__snapshots__/markdown-to-react.test.tsx.snap @@ -0,0 +1,92 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`markdown to react can render html if allowed 1`] = ` +
+

+ + test + +

+ + +
+`; + +exports[`markdown to react can render markdown with newlines as line breaks 1`] = ` +
+

+ This is a headline +

+ + +

+ This is content +
+ +This Too +

+ + +
+`; + +exports[`markdown to react will use markdown render extensions 1`] = ` +
+

+ <span>test</span> +

+ + + + configure + + + post + + + NodeProcessor! + + + + node processor children + + + + + + node processor children + + + +
+`; + +exports[`markdown to react won't render html if forbidden 1`] = ` +
+

+ <span>test</span> +

+ + +
+`; + +exports[`markdown to react won't render markdown with newlines as line breaks if forbidden 1`] = ` +
+

+ This is a headline +

+ + +

+ This is content +This Too +

+ + +
+`; diff --git a/frontend/src/components/markdown-renderer/hooks/use-combined-node-preprocessor.ts b/frontend/src/components/markdown-renderer/markdown-to-react/hooks/use-combined-node-preprocessor.ts similarity index 89% rename from frontend/src/components/markdown-renderer/hooks/use-combined-node-preprocessor.ts rename to frontend/src/components/markdown-renderer/markdown-to-react/hooks/use-combined-node-preprocessor.ts index 6d8255907..dd7594c7c 100644 --- a/frontend/src/components/markdown-renderer/hooks/use-combined-node-preprocessor.ts +++ b/frontend/src/components/markdown-renderer/markdown-to-react/hooks/use-combined-node-preprocessor.ts @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import type { MarkdownRendererExtension } from '../extensions/base/markdown-renderer-extension' +import type { MarkdownRendererExtension } from '../../extensions/base/markdown-renderer-extension' import type { Document } from 'domhandler' import { useMemo } from 'react' diff --git a/frontend/src/components/markdown-renderer/hooks/use-configured-markdown-it.ts b/frontend/src/components/markdown-renderer/markdown-to-react/hooks/use-configured-markdown-it.ts similarity index 92% rename from frontend/src/components/markdown-renderer/hooks/use-configured-markdown-it.ts rename to frontend/src/components/markdown-renderer/markdown-to-react/hooks/use-configured-markdown-it.ts index c1ea7f04d..7057839bb 100644 --- a/frontend/src/components/markdown-renderer/hooks/use-configured-markdown-it.ts +++ b/frontend/src/components/markdown-renderer/markdown-to-react/hooks/use-configured-markdown-it.ts @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import type { MarkdownRendererExtension } from '../extensions/base/markdown-renderer-extension' +import type { MarkdownRendererExtension } from '../../extensions/base/markdown-renderer-extension' import MarkdownIt from 'markdown-it/lib' import { useMemo } from 'react' diff --git a/frontend/src/components/markdown-renderer/markdown-to-react/markdown-to-react.test.tsx b/frontend/src/components/markdown-renderer/markdown-to-react/markdown-to-react.test.tsx new file mode 100644 index 000000000..310eb3af1 --- /dev/null +++ b/frontend/src/components/markdown-renderer/markdown-to-react/markdown-to-react.test.tsx @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { MarkdownToReact } from './markdown-to-react' +import { TestMarkdownRendererExtension } from './test-utils/test-markdown-renderer-extension' +import { render } from '@testing-library/react' +import type { EventMap } from 'eventemitter2' +import { EventEmitter2 } from 'eventemitter2' + +describe('markdown to react', () => { + it('can render markdown with newlines as line breaks', () => { + const view = render( + + ) + expect(view.container).toMatchSnapshot() + }) + + it("won't render markdown with newlines as line breaks if forbidden", () => { + const view = render( + + ) + expect(view.container).toMatchSnapshot() + }) + + it('can render html if allowed', () => { + const view = render( + test']} + markdownRenderExtensions={[]} + newlinesAreBreaks={true} + allowHtml={true} + /> + ) + expect(view.container).toMatchSnapshot() + }) + + it("won't render html if forbidden", () => { + const view = render( + test']} + markdownRenderExtensions={[]} + newlinesAreBreaks={true} + allowHtml={false} + /> + ) + expect(view.container).toMatchSnapshot() + }) + + it('will use markdown render extensions', () => { + const view = render( + test']} + markdownRenderExtensions={[new TestMarkdownRendererExtension(new EventEmitter2())]} + newlinesAreBreaks={true} + allowHtml={false} + /> + ) + expect(view.container).toMatchSnapshot() + }) +}) diff --git a/frontend/src/components/markdown-renderer/markdown-to-react/markdown-to-react.tsx b/frontend/src/components/markdown-renderer/markdown-to-react/markdown-to-react.tsx new file mode 100644 index 000000000..c39892f58 --- /dev/null +++ b/frontend/src/components/markdown-renderer/markdown-to-react/markdown-to-react.tsx @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { HtmlToReact } from '../../common/html-to-react/html-to-react' +import type { MarkdownRendererExtension } from '../extensions/base/markdown-renderer-extension' +import { useCombinedNodePreprocessor } from './hooks/use-combined-node-preprocessor' +import { useConfiguredMarkdownIt } from './hooks/use-configured-markdown-it' +import { LineContentToLineIdMapper } from './utils/line-content-to-line-id-mapper' +import { NodeToReactTransformer } from './utils/node-to-react-transformer' +import type { ParserOptions } from '@hedgedoc/html-to-react' +import type DOMPurify from 'dompurify' +import React, { useMemo } from 'react' + +export interface MarkdownToReactProps { + markdownContentLines: string[] + markdownRenderExtensions: MarkdownRendererExtension[] + newlinesAreBreaks?: boolean + allowHtml: boolean +} + +/** + * Renders Markdown code. + * + * @param markdownContentLines The Markdown code lines that should be rendered + * @param additionalMarkdownExtensions A list of {@link MarkdownRendererExtension markdown extensions} that should be used + * @param newlinesAreBreaks Defines if the alternative break mode of markdown it should be used + * @param allowHtml Defines if html is allowed in markdown + */ +export const MarkdownToReact: React.FC = ({ + markdownContentLines, + markdownRenderExtensions, + newlinesAreBreaks, + allowHtml +}) => { + const lineNumberMapper = useMemo(() => new LineContentToLineIdMapper(), []) + const nodeToReactTransformer = useMemo(() => new NodeToReactTransformer(), []) + + useMemo(() => { + nodeToReactTransformer.setReplacers(markdownRenderExtensions.flatMap((extension) => extension.buildReplacers())) + }, [nodeToReactTransformer, markdownRenderExtensions]) + + useMemo(() => { + nodeToReactTransformer.setLineIds(lineNumberMapper.updateLineMapping(markdownContentLines)) + }, [nodeToReactTransformer, lineNumberMapper, markdownContentLines]) + + const nodePreProcessor = useCombinedNodePreprocessor(markdownRenderExtensions) + const markdownIt = useConfiguredMarkdownIt(markdownRenderExtensions, allowHtml, newlinesAreBreaks ?? true) + + const parserOptions: ParserOptions = useMemo( + () => ({ + transform: (node, index) => nodeToReactTransformer.translateNodeToReactElement(node, index), + preprocessNodes: (document) => { + nodeToReactTransformer.resetReplacers() + return nodePreProcessor(document) + } + }), + [nodeToReactTransformer, nodePreProcessor] + ) + + const html = useMemo(() => markdownIt.render(markdownContentLines.join('\n')), [markdownContentLines, markdownIt]) + const domPurifyConfig: DOMPurify.Config = useMemo( + () => ({ + ADD_TAGS: markdownRenderExtensions.flatMap((extension) => extension.buildTagNameAllowList()) + }), + [markdownRenderExtensions] + ) + + return +} diff --git a/frontend/src/components/markdown-renderer/markdown-to-react/test-utils/test-markdown-renderer-extension.ts b/frontend/src/components/markdown-renderer/markdown-to-react/test-utils/test-markdown-renderer-extension.ts new file mode 100644 index 000000000..8555b8c7a --- /dev/null +++ b/frontend/src/components/markdown-renderer/markdown-to-react/test-utils/test-markdown-renderer-extension.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { MarkdownRendererExtension } from '../../extensions/base/markdown-renderer-extension' +import type { NodeProcessor } from '../../node-preprocessors/node-processor' +import type { ComponentReplacer } from '../../replace-components/component-replacer' +import { TestNodeProcessor } from './test-node-processor' +import { TestReplacer } from './test-replacer' +import type MarkdownIt from 'markdown-it' +import Token from 'markdown-it/lib/token' + +export class TestMarkdownRendererExtension extends MarkdownRendererExtension { + buildNodeProcessors(): NodeProcessor[] { + return [new TestNodeProcessor()] + } + + configureMarkdownIt(markdownIt: MarkdownIt) { + markdownIt.use(() => { + markdownIt.core.ruler.push('configure', (core) => core.tokens.push(new Token('configure', 'configure', 0))) + markdownIt.renderer.rules.configure = () => 'configure' + }) + } + + buildReplacers(): ComponentReplacer[] { + return [new TestReplacer()] + } + + buildTagNameAllowList(): string[] { + return ['nodeProcessor'] + } + + configureMarkdownItPost(markdownIt: MarkdownIt) { + markdownIt.use(() => { + markdownIt.core.ruler.push('post', (core) => core.tokens.push(new Token('post', 'post', 0))) + markdownIt.renderer.rules.post = () => 'post' + }) + } +} diff --git a/frontend/src/components/markdown-renderer/markdown-to-react/test-utils/test-node-processor.ts b/frontend/src/components/markdown-renderer/markdown-to-react/test-utils/test-node-processor.ts new file mode 100644 index 000000000..4cca862e8 --- /dev/null +++ b/frontend/src/components/markdown-renderer/markdown-to-react/test-utils/test-node-processor.ts @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { NodeProcessor } from '../../node-preprocessors/node-processor' +import type { Document } from 'domhandler' +import { Element, Text } from 'domhandler' + +export class TestNodeProcessor extends NodeProcessor { + process(document: Document): Document { + document.childNodes.push(new Element('nodeProcessor', {}, [new Text('node processor children')])) + return document + } +} diff --git a/frontend/src/components/markdown-renderer/markdown-to-react/test-utils/test-replacer.tsx b/frontend/src/components/markdown-renderer/markdown-to-react/test-utils/test-replacer.tsx new file mode 100644 index 000000000..378a3036e --- /dev/null +++ b/frontend/src/components/markdown-renderer/markdown-to-react/test-utils/test-replacer.tsx @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import type { NativeRenderer, NodeReplacement, SubNodeTransform } from '../../replace-components/component-replacer' +import { ComponentReplacer, DO_NOT_REPLACE } from '../../replace-components/component-replacer' +import type { Element } from 'domhandler' +import React, { Fragment } from 'react' + +export class TestReplacer extends ComponentReplacer { + replace(node: Element, subNodeTransform: SubNodeTransform, nativeRenderer: NativeRenderer): NodeReplacement { + return node.tagName === 'nodeProcessor' ? ( + + NodeProcessor! + {node.childNodes.map(subNodeTransform)} + {nativeRenderer()} + + ) : ( + DO_NOT_REPLACE + ) + } +} diff --git a/frontend/src/components/markdown-renderer/utils/line-id-mapper.test.ts b/frontend/src/components/markdown-renderer/markdown-to-react/utils/line-content-to-line-id-mapper.test.ts similarity index 92% rename from frontend/src/components/markdown-renderer/utils/line-id-mapper.test.ts rename to frontend/src/components/markdown-renderer/markdown-to-react/utils/line-content-to-line-id-mapper.test.ts index 1e964cb2c..be5c1602b 100644 --- a/frontend/src/components/markdown-renderer/utils/line-id-mapper.test.ts +++ b/frontend/src/components/markdown-renderer/markdown-to-react/utils/line-content-to-line-id-mapper.test.ts @@ -3,13 +3,13 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { LineIdMapper } from './line-id-mapper' +import { LineContentToLineIdMapper } from './line-content-to-line-id-mapper' describe('line id mapper', () => { - let lineIdMapper: LineIdMapper + let lineIdMapper: LineContentToLineIdMapper beforeEach(() => { - lineIdMapper = new LineIdMapper() + lineIdMapper = new LineContentToLineIdMapper() }) it('should be case sensitive', () => { diff --git a/frontend/src/components/markdown-renderer/utils/line-id-mapper.ts b/frontend/src/components/markdown-renderer/markdown-to-react/utils/line-content-to-line-id-mapper.ts similarity index 92% rename from frontend/src/components/markdown-renderer/utils/line-id-mapper.ts rename to frontend/src/components/markdown-renderer/markdown-to-react/utils/line-content-to-line-id-mapper.ts index f0f08df0f..91e800803 100644 --- a/frontend/src/components/markdown-renderer/utils/line-id-mapper.ts +++ b/frontend/src/components/markdown-renderer/markdown-to-react/utils/line-content-to-line-id-mapper.ts @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import type { LineWithId } from '../extensions/linemarker/types' +import type { LineWithId } from '../../extensions/linemarker/types' import type { ArrayChange } from 'diff' import { diffArrays } from 'diff' @@ -14,7 +14,7 @@ type LineChange = ArrayChange * Calculates ids for every line in a given text and memorized the state of the last given text. * It also assigns ids for new lines on every update. */ -export class LineIdMapper { +export class LineContentToLineIdMapper { private lastLines: LineWithId[] = [] private lastUsedLineId = 0 @@ -58,7 +58,11 @@ export class LineIdMapper { */ private convertChangesToLinesWithIds(changes: LineChange[]): LineWithId[] { return changes - .filter((change) => LineIdMapper.changeIsNotChangingLines(change) || LineIdMapper.changeIsAddingLines(change)) + .filter( + (change) => + LineContentToLineIdMapper.changeIsNotChangingLines(change) || + LineContentToLineIdMapper.changeIsAddingLines(change) + ) .reduce( (previousLineKeys, currentChange) => [...previousLineKeys, ...this.convertChangeToLinesWithIds(currentChange)], [] as LineWithId[] @@ -94,7 +98,7 @@ export class LineIdMapper { * @return The created or reused {@link LineWithId lines with ids} */ private convertChangeToLinesWithIds(change: LineChange): LineWithId[] { - if (LineIdMapper.changeIsAddingLines(change)) { + if (LineContentToLineIdMapper.changeIsAddingLines(change)) { return change.value.map((line) => { this.lastUsedLineId += 1 return { line: line, id: this.lastUsedLineId } diff --git a/frontend/src/components/markdown-renderer/utils/node-to-react-transformer.test.tsx b/frontend/src/components/markdown-renderer/markdown-to-react/utils/node-to-react-transformer.test.tsx similarity index 95% rename from frontend/src/components/markdown-renderer/utils/node-to-react-transformer.test.tsx rename to frontend/src/components/markdown-renderer/markdown-to-react/utils/node-to-react-transformer.test.tsx index 933d0214a..aa62cb90d 100644 --- a/frontend/src/components/markdown-renderer/utils/node-to-react-transformer.test.tsx +++ b/frontend/src/components/markdown-renderer/markdown-to-react/utils/node-to-react-transformer.test.tsx @@ -3,8 +3,8 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import type { NodeReplacement } from '../replace-components/component-replacer' -import { ComponentReplacer, DO_NOT_REPLACE } from '../replace-components/component-replacer' +import type { NodeReplacement } from '../../replace-components/component-replacer' +import { ComponentReplacer, DO_NOT_REPLACE } from '../../replace-components/component-replacer' import { NodeToReactTransformer } from './node-to-react-transformer' import { Element } from 'domhandler' import type { ReactElement, ReactHTMLElement } from 'react' diff --git a/frontend/src/components/markdown-renderer/utils/node-to-react-transformer.tsx b/frontend/src/components/markdown-renderer/markdown-to-react/utils/node-to-react-transformer.tsx similarity index 94% rename from frontend/src/components/markdown-renderer/utils/node-to-react-transformer.tsx rename to frontend/src/components/markdown-renderer/markdown-to-react/utils/node-to-react-transformer.tsx index 9668f8355..a7d2027d7 100644 --- a/frontend/src/components/markdown-renderer/utils/node-to-react-transformer.tsx +++ b/frontend/src/components/markdown-renderer/markdown-to-react/utils/node-to-react-transformer.tsx @@ -3,10 +3,14 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { LinemarkerMarkdownExtension } from '../extensions/linemarker/linemarker-markdown-extension' -import type { LineWithId } from '../extensions/linemarker/types' -import type { ComponentReplacer, NodeReplacement, ValidReactDomElement } from '../replace-components/component-replacer' -import { DO_NOT_REPLACE } from '../replace-components/component-replacer' +import { LinemarkerMarkdownExtension } from '../../extensions/linemarker/linemarker-markdown-extension' +import type { LineWithId } from '../../extensions/linemarker/types' +import type { + ComponentReplacer, + NodeReplacement, + ValidReactDomElement +} from '../../replace-components/component-replacer' +import { DO_NOT_REPLACE } from '../../replace-components/component-replacer' import { convertNodeToReactElement } from '@hedgedoc/html-to-react/dist/convertNodeToReactElement' import { Optional } from '@mrdrogdrog/optional' import type { Element, Node } from 'domhandler' diff --git a/frontend/src/components/markdown-renderer/slideshow-markdown-renderer.tsx b/frontend/src/components/markdown-renderer/slideshow-markdown-renderer.tsx index 479f6ffc8..22fa5b28b 100644 --- a/frontend/src/components/markdown-renderer/slideshow-markdown-renderer.tsx +++ b/frontend/src/components/markdown-renderer/slideshow-markdown-renderer.tsx @@ -7,11 +7,11 @@ import type { SlideOptions } from '../../redux/note-details/types/slide-show-opt import type { ScrollProps } from '../editor-page/synced-scroll/scroll-props' import type { CommonMarkdownRendererProps } from './common-markdown-renderer-props' import { RevealMarkdownExtension } from './extensions/reveal/reveal-markdown-extension' -import { useConvertMarkdownToReactDom } from './hooks/use-convert-markdown-to-react-dom' import { useExtractFirstHeadline } from './hooks/use-extract-first-headline' import { useMarkdownExtensions } from './hooks/use-markdown-extensions' import { REVEAL_STATUS, useReveal } from './hooks/use-reveal' import { LoadingSlide } from './loading-slide' +import { MarkdownToReact } from './markdown-to-react/markdown-to-react' import React, { useEffect, useMemo, useRef } from 'react' export interface SlideshowMarkdownRendererProps extends CommonMarkdownRendererProps { @@ -25,10 +25,7 @@ export interface SlideshowMarkdownRendererProps extends CommonMarkdownRendererPr * @param markdownContentLines The markdown lines * @param onFirstHeadingChange The callback to call if the first heading changes. * @param onLineMarkerPositionChanged The callback to call with changed {@link LineMarkers} - * @param onTaskCheckedChange The callback to call if a task is checked or unchecked. - * @param onTocChange The callback to call if the toc changes. * @param baseUrl The base url of the renderer - * @param onImageClick The callback to call if a image is clicked * @param newlinesAreBreaks If newlines are rendered as breaks or not * @param slideOptions The {@link SlideOptions} to use */ @@ -44,11 +41,9 @@ export const SlideshowMarkdownRenderer: React.FC [new RevealMarkdownExtension()], []) ) - const markdownReactDom = useConvertMarkdownToReactDom(markdownContentLines, extensions, newlinesAreBreaks, true) const revealStatus = useReveal(markdownContentLines, slideOptions) const extractFirstHeadline = useExtractFirstHeadline(markdownBodyRef, onFirstHeadingChange) @@ -59,8 +54,18 @@ export const SlideshowMarkdownRenderer: React.FC (revealStatus === REVEAL_STATUS.INITIALISED ? markdownReactDom : ), - [markdownReactDom, revealStatus] + () => + revealStatus === REVEAL_STATUS.INITIALISED ? ( + + ) : ( + + ), + [extensions, markdownContentLines, newlinesAreBreaks, revealStatus] ) return ( diff --git a/frontend/src/components/markdown-renderer/test-utils/test-markdown-renderer.tsx b/frontend/src/components/markdown-renderer/test-utils/test-markdown-renderer.tsx index 32bfaccb3..13a6f8e7a 100644 --- a/frontend/src/components/markdown-renderer/test-utils/test-markdown-renderer.tsx +++ b/frontend/src/components/markdown-renderer/test-utils/test-markdown-renderer.tsx @@ -5,7 +5,7 @@ */ import { StoreProvider } from '../../../redux/store-provider' import type { MarkdownRendererExtension } from '../extensions/base/markdown-renderer-extension' -import { useConvertMarkdownToReactDom } from '../hooks/use-convert-markdown-to-react-dom' +import { MarkdownToReact } from '../markdown-to-react/markdown-to-react' import React, { useMemo } from 'react' export interface SimpleMarkdownRendererProps { @@ -21,7 +21,14 @@ export interface SimpleMarkdownRendererProps { */ export const TestMarkdownRenderer: React.FC = ({ content, extensions }) => { const lines = useMemo(() => content.split('\n'), [content]) - const dom = useConvertMarkdownToReactDom(lines, extensions, true, false) - - return {dom} + return ( + + + + ) } diff --git a/frontend/src/components/render-page/hooks/sync-scroll/use-document-sync-scrolling.ts b/frontend/src/components/render-page/hooks/sync-scroll/use-document-sync-scrolling.ts index b3711af83..2c23bcdbf 100644 --- a/frontend/src/components/render-page/hooks/sync-scroll/use-document-sync-scrolling.ts +++ b/frontend/src/components/render-page/hooks/sync-scroll/use-document-sync-scrolling.ts @@ -31,7 +31,7 @@ export const useDocumentSyncScrolling = ( ): [(lineMarkers: LineMarkerPosition[]) => void, React.UIEventHandler] => { const [lineMarks, setLineMarks] = useState() - const onLineMarkerPositionChanged = useCallback( + const recalculateLineMarkerPositions = useCallback( (linkMarkerPositions: LineMarkerPosition[]) => { if (!outerContainerRef.current || !rendererRef.current) { return @@ -51,5 +51,5 @@ export const useDocumentSyncScrolling = ( const onUserScroll = useOnUserScroll(lineMarks, outerContainerRef, onScroll) useScrollToLineMark(scrollState, lineMarks, numberOfLines, outerContainerRef) - return useMemo(() => [onLineMarkerPositionChanged, onUserScroll], [onLineMarkerPositionChanged, onUserScroll]) + return useMemo(() => [recalculateLineMarkerPositions, onUserScroll], [recalculateLineMarkerPositions, onUserScroll]) } diff --git a/frontend/src/components/render-page/markdown-document.tsx b/frontend/src/components/render-page/markdown-document.tsx index 94b522373..d0eab827c 100644 --- a/frontend/src/components/render-page/markdown-document.tsx +++ b/frontend/src/components/render-page/markdown-document.tsx @@ -72,7 +72,7 @@ export const MarkdownDocument: React.FC = ({ const newlinesAreBreaks = useApplicationState((state) => state.noteDetails.frontmatter.newlinesAreBreaks) const contentLineCount = useMemo(() => markdownContentLines.length, [markdownContentLines]) - const [onLineMarkerPositionChanged, onUserScroll] = useDocumentSyncScrolling( + const [recalculateLineMarkers, onUserScroll] = useDocumentSyncScrolling( internalDocumentRenderPaneRef, rendererRef, contentLineCount, @@ -95,7 +95,7 @@ export const MarkdownDocument: React.FC = ({ className={`mb-3 ${additionalRendererClasses ?? ''}`} markdownContentLines={markdownContentLines} onFirstHeadingChange={onFirstHeadingChange} - onLineMarkerPositionChanged={onLineMarkerPositionChanged} + onLineMarkerPositionChanged={recalculateLineMarkers} baseUrl={baseUrl} newlinesAreBreaks={newlinesAreBreaks} /> diff --git a/frontend/src/extensions/extra-integrations/highlighted-code-fence/hooks/use-code-dom.tsx b/frontend/src/extensions/extra-integrations/highlighted-code-fence/hooks/use-code-dom.tsx index d104d2a44..17dc3f24e 100644 --- a/frontend/src/extensions/extra-integrations/highlighted-code-fence/hooks/use-code-dom.tsx +++ b/frontend/src/extensions/extra-integrations/highlighted-code-fence/hooks/use-code-dom.tsx @@ -3,11 +3,10 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import convertHtmlToReact from '@hedgedoc/html-to-react' -import { sanitize } from 'dompurify' +import { HtmlToReact } from '../../../../components/common/html-to-react/html-to-react' import type { HLJSApi } from 'highlight.js' import type { ReactElement } from 'react' -import React, { Fragment, useMemo } from 'react' +import React, { useMemo } from 'react' /** * Highlights the given code using highlight.js. If the language wasn't recognized then it won't be highlighted. @@ -38,7 +37,7 @@ export const useCodeDom = (code: string, hljs: HLJSApi | undefined, language?: s * @return the code represented as react elements */ const createHtmlLinesToReactDOM = (code: string[]): ReactElement[] => { - return code.map((line, lineIndex) => {convertHtmlToReact(sanitize(line))}) + return code.map((line, lineIndex) => ) } /** diff --git a/frontend/src/extensions/extra-integrations/plantuml/plantuml-markdown-extension.ts b/frontend/src/extensions/extra-integrations/plantuml/plantuml-markdown-extension.ts index 4d0dd6657..0d3c61721 100644 --- a/frontend/src/extensions/extra-integrations/plantuml/plantuml-markdown-extension.ts +++ b/frontend/src/extensions/extra-integrations/plantuml/plantuml-markdown-extension.ts @@ -20,10 +20,6 @@ import type Token from 'markdown-it/lib/token' * @see https://plantuml.com */ export class PlantumlMarkdownExtension extends MarkdownRendererExtension { - constructor() { - super() - } - private plantumlError(markdownIt: MarkdownIt): void { const defaultRenderer: Renderer.RenderRule = markdownIt.renderer.rules.fence || (() => '') markdownIt.renderer.rules.fence = (tokens: Token[], idx: number, options: Options, env, slf: Renderer) => {