refactor(renderer): convert html/markdown-to-react converters from hooks to components

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2023-03-13 20:42:50 +01:00
parent 0457a633cc
commit 958b23e25a
30 changed files with 523 additions and 203 deletions

View file

@ -0,0 +1,30 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`HTML to React renders basic html correctly 1`] = `
<div>
<p>
This is a test
<b>
sentence
</b>
</p>
</div>
`;
exports[`HTML to React will forward the DomPurify settings 1`] = `
<div>
<test-tag>
Test!
</test-tag>
</div>
`;
exports[`HTML to React will forward the parser options 1`] = `
<div>
<p>
Hijacked!
</p>
</div>
`;
exports[`HTML to React won't render script tags 1`] = `<div />`;

View file

@ -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(<HtmlToReact htmlCode={'<p>This is a test <b>sentence</b></p>'} />)
expect(view.container).toMatchSnapshot()
})
it("won't render script tags", () => {
const view = render(<HtmlToReact htmlCode={'<script type="application/javascript">alert("XSS!")</script>'} />)
expect(view.container).toMatchSnapshot()
})
it('will forward the DomPurify settings', () => {
const view = render(
<HtmlToReact domPurifyConfig={{ ADD_TAGS: ['test-tag'] }} htmlCode={'<test-tag>Test!</test-tag>'} />
)
expect(view.container).toMatchSnapshot()
})
it('will forward the parser options', () => {
let transformerVisited = false
let preprocessNodesVisited = false
const view = render(
<HtmlToReact
htmlCode={'<p>This is a sentence</p>'}
parserOptions={{
transform: () => {
transformerVisited = true
return <p>Hijacked!</p>
},
preprocessNodes: (document) => {
preprocessNodesVisited = true
return document
}
}}
/>
)
expect(view.container).toMatchSnapshot()
expect(preprocessNodesVisited).toBeTruthy()
expect(transformerVisited).toBeTruthy()
})
})

View file

@ -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<HtmlToReactProps> = ({ 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 <Fragment>{elements}</Fragment>
}

View file

@ -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<DocumentMarkdownRendererProps> =
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<DocumentMarkdownRendererProps> =
ref={markdownBodyRef}
data-word-count-target={true}
className={`${className ?? ''} markdown-body w-100 d-flex flex-column align-items-center`}>
{markdownReactDom}
<MarkdownToReact
markdownContentLines={markdownContentLines}
markdownRenderExtensions={extensions}
newlinesAreBreaks={newlinesAreBreaks}
allowHtml={true}
/>
</div>
</div>
)

View file

@ -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)
}

View file

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

View file

@ -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)
}
}

View file

@ -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)]
}
}

View file

@ -18,6 +18,8 @@ export class TableOfContentsMarkdownExtension extends MarkdownRendererExtension
private lastAst: TocAst | undefined = undefined
public configureMarkdownIt(markdownIt: MarkdownIt): void {
const eventEmitter = this.eventEmitter
if (eventEmitter !== undefined) {
toc(markdownIt, {
listType: 'ul',
level: [1, 2, 3],
@ -26,9 +28,10 @@ export class TableOfContentsMarkdownExtension extends MarkdownRendererExtension
return
}
this.lastAst = ast
this.eventEmitter?.emit(TableOfContentsMarkdownExtension.EVENT_NAME, ast)
eventEmitter.emit(TableOfContentsMarkdownExtension.EVENT_NAME, ast)
},
slugify: tocSlugify
})
}
}
}

View file

@ -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<HTMLDivElement>,
lineMarkers?: LineMarkers[],
onLineMarkerPositionChanged?: (lineMarkerPosition: LineMarkerPosition[]) => void,
offset?: number
lineMarkers: LineMarkers[] | undefined,
onLineMarkerPositionChanged?: (lineMarkerPosition: LineMarkerPosition[]) => void
): void => {
const lastLineMarkerPositions = useRef<LineMarkerPosition[]>()
@ -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()

View file

@ -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 (
<Fragment key={'root'}>
{convertHtmlToReact(html, {
transform: (node, index) => htmlToReactTransformer.translateNodeToReactElement(node, index),
preprocessNodes: (document) => nodePreProcessor(document)
})}
</Fragment>
)
}, [htmlToReactTransformer, markdownContentLines, markdownIt, nodePreProcessor])
}

View file

@ -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<LineMarkers[] | undefined> | 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])
}

View file

@ -0,0 +1,92 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`markdown to react can render html if allowed 1`] = `
<div>
<p>
<span>
test
</span>
</p>
</div>
`;
exports[`markdown to react can render markdown with newlines as line breaks 1`] = `
<div>
<h1>
This is a headline
</h1>
<p>
This is content
<br />
This Too
</p>
</div>
`;
exports[`markdown to react will use markdown render extensions 1`] = `
<div>
<p>
&lt;span&gt;test&lt;/span&gt;
</p>
<span>
configure
</span>
<span>
post
</span>
<span>
NodeProcessor!
</span>
<span
data-children="true"
>
node processor children
</span>
<span
data-native="true"
>
<nodeprocessor>
node processor children
</nodeprocessor>
</span>
</div>
`;
exports[`markdown to react won't render html if forbidden 1`] = `
<div>
<p>
&lt;span&gt;test&lt;/span&gt;
</p>
</div>
`;
exports[`markdown to react won't render markdown with newlines as line breaks if forbidden 1`] = `
<div>
<h1>
This is a headline
</h1>
<p>
This is content
This Too
</p>
</div>
`;

View file

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

View file

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

View file

@ -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(
<MarkdownToReact
markdownContentLines={['# This is a headline', 'This is content', 'This Too']}
allowHtml={false}
newlinesAreBreaks={true}
markdownRenderExtensions={[]}></MarkdownToReact>
)
expect(view.container).toMatchSnapshot()
})
it("won't render markdown with newlines as line breaks if forbidden", () => {
const view = render(
<MarkdownToReact
markdownContentLines={['# This is a headline', 'This is content', 'This Too']}
allowHtml={false}
newlinesAreBreaks={false}
markdownRenderExtensions={[]}></MarkdownToReact>
)
expect(view.container).toMatchSnapshot()
})
it('can render html if allowed', () => {
const view = render(
<MarkdownToReact
markdownContentLines={['<span>test</span>']}
markdownRenderExtensions={[]}
newlinesAreBreaks={true}
allowHtml={true}
/>
)
expect(view.container).toMatchSnapshot()
})
it("won't render html if forbidden", () => {
const view = render(
<MarkdownToReact
markdownContentLines={['<span>test</span>']}
markdownRenderExtensions={[]}
newlinesAreBreaks={true}
allowHtml={false}
/>
)
expect(view.container).toMatchSnapshot()
})
it('will use markdown render extensions', () => {
const view = render(
<MarkdownToReact
markdownContentLines={['<span>test</span>']}
markdownRenderExtensions={[new TestMarkdownRendererExtension(new EventEmitter2<EventMap>())]}
newlinesAreBreaks={true}
allowHtml={false}
/>
)
expect(view.container).toMatchSnapshot()
})
})

View file

@ -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<MarkdownToReactProps> = ({
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 <HtmlToReact htmlCode={html} parserOptions={parserOptions} domPurifyConfig={domPurifyConfig} />
}

View file

@ -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 = () => '<span>configure</span>'
})
}
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 = () => '<span>post</span>'
})
}
}

View file

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

View file

@ -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' ? (
<Fragment>
<span>NodeProcessor! </span>
<span data-children={true}> {node.childNodes.map(subNodeTransform)} </span>
<span data-native={true}> {nativeRenderer()} </span>
</Fragment>
) : (
DO_NOT_REPLACE
)
}
}

View file

@ -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', () => {

View file

@ -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<NewLine | LineWithId>
* 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 }

View file

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

View file

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

View file

@ -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<SlideshowMarkdownRendererProps
const extensions = useMarkdownExtensions(
baseUrl,
undefined,
useMemo(() => [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<SlideshowMarkdownRendererProps
}, [extractFirstHeadline, markdownContentLines, revealStatus])
const slideShowDOM = useMemo(
() => (revealStatus === REVEAL_STATUS.INITIALISED ? markdownReactDom : <LoadingSlide />),
[markdownReactDom, revealStatus]
() =>
revealStatus === REVEAL_STATUS.INITIALISED ? (
<MarkdownToReact
markdownContentLines={markdownContentLines}
markdownRenderExtensions={extensions}
allowHtml={true}
newlinesAreBreaks={newlinesAreBreaks}
/>
) : (
<LoadingSlide />
),
[extensions, markdownContentLines, newlinesAreBreaks, revealStatus]
)
return (

View file

@ -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<SimpleMarkdownRendererProps> = ({ content, extensions }) => {
const lines = useMemo(() => content.split('\n'), [content])
const dom = useConvertMarkdownToReactDom(lines, extensions, true, false)
return <StoreProvider>{dom}</StoreProvider>
return (
<StoreProvider>
<MarkdownToReact
markdownContentLines={lines}
markdownRenderExtensions={extensions}
newlinesAreBreaks={true}
allowHtml={false}
/>
</StoreProvider>
)
}

View file

@ -31,7 +31,7 @@ export const useDocumentSyncScrolling = (
): [(lineMarkers: LineMarkerPosition[]) => void, React.UIEventHandler<HTMLElement>] => {
const [lineMarks, setLineMarks] = useState<LineMarkerPosition[]>()
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])
}

View file

@ -72,7 +72,7 @@ export const MarkdownDocument: React.FC<MarkdownDocumentProps> = ({
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<MarkdownDocumentProps> = ({
className={`mb-3 ${additionalRendererClasses ?? ''}`}
markdownContentLines={markdownContentLines}
onFirstHeadingChange={onFirstHeadingChange}
onLineMarkerPositionChanged={onLineMarkerPositionChanged}
onLineMarkerPositionChanged={recalculateLineMarkers}
baseUrl={baseUrl}
newlinesAreBreaks={newlinesAreBreaks}
/>

View file

@ -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) => <Fragment key={lineIndex}>{convertHtmlToReact(sanitize(line))}</Fragment>)
return code.map((line, lineIndex) => <HtmlToReact htmlCode={line} key={lineIndex} />)
}
/**

View file

@ -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) => {