mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-18 17:25:16 -04:00
fix: Move content into to frontend directory
Doing this BEFORE the merge prevents a lot of merge conflicts. Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
4e18ce38f3
commit
762a0a850e
1051 changed files with 0 additions and 35 deletions
26
frontend/src/extensions/base/app-extension.ts
Normal file
26
frontend/src/extensions/base/app-extension.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Linter } from '../../components/editor-page/editor-pane/linter/linter'
|
||||
import type { MarkdownRendererExtension } from '../../components/markdown-renderer/extensions/base/markdown-renderer-extension'
|
||||
import type React from 'react'
|
||||
import { Fragment } from 'react'
|
||||
import type EventEmitter2 from 'eventemitter2'
|
||||
|
||||
export abstract class AppExtension {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
public buildMarkdownRendererExtensions(eventEmitter?: EventEmitter2): MarkdownRendererExtension[] {
|
||||
return []
|
||||
}
|
||||
|
||||
public buildCodeMirrorLinter(): Linter[] {
|
||||
return []
|
||||
}
|
||||
|
||||
public buildEditorExtensionComponent(): React.FC {
|
||||
return Fragment
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,17 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AbcJs Markdown Extension renders an abc codeblock 1`] = `
|
||||
<div>
|
||||
<pre>
|
||||
<span>
|
||||
this is a mock for abc js frame
|
||||
<code>
|
||||
X:1\\nT:Speed the Plough\\nM:4/4\\nC:Trad.\\nK:G\\n|:GABc dedB|dedB dedB|c2ec B2dB|c2A2 A2BA|\\nGABc dedB|dedB dedB|c2ec B2dB|A2F2 G4:|\\n|:g2gf gdBd|g2f2 e2d2|c2ec B2dB|c2A2 A2df|\\ng2gf g2Bd|g2f2 e2d2|c2ec B2dB|A2F2 G4:|
|
||||
|
||||
</code>
|
||||
</span>
|
||||
</pre>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { AbcFrame } from './abc-frame'
|
||||
import { mockI18n } from '../../../components/markdown-renderer/test-utils/mock-i18n'
|
||||
|
||||
describe('AbcFrame', () => {
|
||||
afterEach(() => {
|
||||
jest.resetModules()
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
beforeEach(() => mockI18n())
|
||||
|
||||
it('renders a music sheet', async () => {
|
||||
const element = (
|
||||
<AbcFrame
|
||||
code={
|
||||
'X:1\nT:Speed the Plough\nM:4/4\nC:Trad.\nK:G\n|:GABc dedB|dedB dedB|c2ec B2dB|c2A2 A2BA|\nGABc dedB|dedB dedB|c2ec B2dB|A2F2 G4:|\n|:g2gf gdBd|g2f2 e2d2|c2ec B2dB|c2A2 A2df|\ng2gf g2Bd|g2f2 e2d2|c2ec B2dB|A2F2 G4:|'
|
||||
}
|
||||
/>
|
||||
)
|
||||
const view = render(element)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
expect(await screen.findByText('Sheet Music for "Speed the Plough"')).toBeInTheDocument()
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it("renders an error if abcjs file can't be loaded", async () => {
|
||||
jest.mock('abcjs', () => {
|
||||
throw new Error('abc is exploded!')
|
||||
})
|
||||
const element = (
|
||||
<AbcFrame
|
||||
code={
|
||||
'X:1\nT:Speed the Plough\nM:4/4\nC:Trad.\nK:G\n|:GABc dedB|dedB dedB|c2ec B2dB|c2A2 A2BA|\nGABc dedB|dedB dedB|c2ec B2dB|A2F2 G4:|\n|:g2gf gdBd|g2f2 e2d2|c2ec B2dB|c2A2 A2df|\ng2gf g2Bd|g2f2 e2d2|c2ec B2dB|A2F2 G4:|'
|
||||
}
|
||||
/>
|
||||
)
|
||||
const view = render(element)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
expect(await screen.findByText('common.errorWhileLoading')).toBeInTheDocument()
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders an error if abcjs render function crashes', async () => {
|
||||
jest.mock('abcjs', () => ({
|
||||
renderAbc: () => {
|
||||
throw new Error('abc is exploded!')
|
||||
}
|
||||
}))
|
||||
const element = (
|
||||
<AbcFrame
|
||||
code={
|
||||
'X:1\nT:Speed the Plough\nM:4/4\nC:Trad.\nK:G\n|:GABc dedB|dedB dedB|c2ec B2dB|c2A2 A2BA|\nGABc dedB|dedB dedB|c2ec B2dB|A2F2 G4:|\n|:g2gf gdBd|g2f2 e2d2|c2ec B2dB|c2A2 A2df|\ng2gf g2Bd|g2f2 e2d2|c2ec B2dB|A2F2 G4:|'
|
||||
}
|
||||
/>
|
||||
)
|
||||
const view = render(element)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
expect(await screen.findByText('editor.embeddings.abcJs.errorWhileRendering')).toBeInTheDocument()
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
})
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { useRef } from 'react'
|
||||
import styles from './abc.module.scss'
|
||||
import { useAsync } from 'react-use'
|
||||
import { Alert } from 'react-bootstrap'
|
||||
import { Trans } from 'react-i18next'
|
||||
import { WaitSpinner } from '../../../components/common/wait-spinner/wait-spinner'
|
||||
import type { CodeProps } from '../../../components/markdown-renderer/replace-components/code-block-component-replacer'
|
||||
import { cypressId } from '../../../utils/cypress-attribute'
|
||||
import { AsyncLoadingBoundary } from '../../../components/common/async-loading-boundary'
|
||||
import { ShowIf } from '../../../components/common/show-if/show-if'
|
||||
import { useEffectWithCatch } from '../../../hooks/common/use-effect-with-catch'
|
||||
import { Logger } from '../../../utils/logger'
|
||||
|
||||
const log = new Logger('AbcFrame')
|
||||
|
||||
/**
|
||||
* Renders an abc.js note sheet.
|
||||
*
|
||||
* @param code The code to render.
|
||||
* @see https://www.abcjs.net/
|
||||
*/
|
||||
export const AbcFrame: React.FC<CodeProps> = ({ code }) => {
|
||||
const container = useRef<HTMLDivElement>(null)
|
||||
|
||||
const {
|
||||
error: loadingError,
|
||||
loading,
|
||||
value: abcLib
|
||||
} = useAsync(async () => {
|
||||
try {
|
||||
return import(/* webpackChunkName: "abc.js" */ 'abcjs')
|
||||
} catch (error) {
|
||||
log.error('Error while loading abcjs', error)
|
||||
throw error
|
||||
}
|
||||
}, [])
|
||||
|
||||
const renderError = useEffectWithCatch(() => {
|
||||
const actualContainer = container.current
|
||||
if (!actualContainer || !abcLib) {
|
||||
return
|
||||
}
|
||||
abcLib.renderAbc(actualContainer, code, {})
|
||||
}, [code, abcLib])
|
||||
|
||||
return (
|
||||
<AsyncLoadingBoundary loading={loading} error={!!loadingError} componentName={'abc.js'}>
|
||||
<ShowIf condition={!!renderError}>
|
||||
<Alert variant={'danger'}>
|
||||
<Trans i18nKey={'editor.embeddings.abcJs.errorWhileRendering'} />
|
||||
</Alert>
|
||||
</ShowIf>
|
||||
<div
|
||||
ref={container}
|
||||
className={`${styles['abcjs-score']} bg-white text-black svg-container`}
|
||||
{...cypressId('abcjs')}>
|
||||
<WaitSpinner />
|
||||
</div>
|
||||
</AsyncLoadingBoundary>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
|
||||
.abcjs-score {
|
||||
:global(.markdown-body) & {
|
||||
overflow-x: auto !important;
|
||||
}
|
||||
|
||||
& > svg {
|
||||
max-width: unset !important;
|
||||
}
|
||||
|
||||
&, text {
|
||||
@import "../../../../global-styles/variables.module";
|
||||
font-family: $font-family-base;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { AppExtension } from '../../base/app-extension'
|
||||
import { AbcjsMarkdownExtension } from './abcjs-markdown-extension'
|
||||
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/base/markdown-renderer-extension'
|
||||
|
||||
export class AbcjsAppExtension extends AppExtension {
|
||||
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
|
||||
return [new AbcjsMarkdownExtension()]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { render } from '@testing-library/react'
|
||||
import * as AbcFrameModule from './abc-frame'
|
||||
import { AbcjsMarkdownExtension } from './abcjs-markdown-extension'
|
||||
import { mockI18n } from '../../../components/markdown-renderer/test-utils/mock-i18n'
|
||||
import type { CodeProps } from '../../../components/markdown-renderer/replace-components/code-block-component-replacer'
|
||||
import { TestMarkdownRenderer } from '../../../components/markdown-renderer/test-utils/test-markdown-renderer'
|
||||
|
||||
jest.mock('./abc-frame')
|
||||
|
||||
describe('AbcJs Markdown Extension', () => {
|
||||
beforeAll(async () => {
|
||||
jest.spyOn(AbcFrameModule, 'AbcFrame').mockImplementation((({ code }) => {
|
||||
return (
|
||||
<span>
|
||||
this is a mock for abc js frame
|
||||
<code>{code}</code>
|
||||
</span>
|
||||
)
|
||||
}) as React.FC<CodeProps>)
|
||||
await mockI18n()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.resetModules()
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('renders an abc codeblock', () => {
|
||||
const view = render(
|
||||
<TestMarkdownRenderer
|
||||
extensions={[new AbcjsMarkdownExtension()]}
|
||||
content={
|
||||
'```abc\nX:1\\nT:Speed the Plough\\nM:4/4\\nC:Trad.\\nK:G\\n|:GABc dedB|dedB dedB|c2ec B2dB|c2A2 A2BA|\\nGABc dedB|dedB dedB|c2ec B2dB|A2F2 G4:|\\n|:g2gf gdBd|g2f2 e2d2|c2ec B2dB|c2A2 A2df|\\ng2gf g2Bd|g2f2 e2d2|c2ec B2dB|A2F2 G4:|\n```'
|
||||
}
|
||||
/>
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
})
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { AbcFrame } from './abc-frame'
|
||||
import { CodeBlockMarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/base/code-block-markdown-extension/code-block-markdown-renderer-extension'
|
||||
import { CodeBlockComponentReplacer } from '../../../components/markdown-renderer/replace-components/code-block-component-replacer'
|
||||
|
||||
/**
|
||||
* Adds support for abc.js to the markdown rendering using code fences with "abc" as language.
|
||||
*/
|
||||
export class AbcjsMarkdownExtension extends CodeBlockMarkdownRendererExtension {
|
||||
public buildReplacers(): CodeBlockComponentReplacer[] {
|
||||
return [new CodeBlockComponentReplacer(AbcFrame, 'abc')]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { AppExtension } from '../../base/app-extension'
|
||||
import { AlertMarkdownExtension } from './alert-markdown-extension'
|
||||
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/base/markdown-renderer-extension'
|
||||
|
||||
/**
|
||||
* Adds alert boxes to the markdown rendering.
|
||||
*/
|
||||
export class AlertAppExtension extends AppExtension {
|
||||
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
|
||||
return [new AlertMarkdownExtension()]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type MarkdownIt from 'markdown-it'
|
||||
import markdownItContainer from 'markdown-it-container'
|
||||
import type Token from 'markdown-it/lib/token'
|
||||
import type Renderer from 'markdown-it/lib/renderer'
|
||||
import { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/base/markdown-renderer-extension'
|
||||
|
||||
export const alertLevels = ['success', 'danger', 'info', 'warning']
|
||||
|
||||
/**
|
||||
* Adds alert boxes to the markdown rendering.
|
||||
*/
|
||||
export class AlertMarkdownExtension extends MarkdownRendererExtension {
|
||||
public configureMarkdownIt(markdownIt: MarkdownIt): void {
|
||||
alertLevels.forEach((level) => {
|
||||
markdownItContainer(markdownIt, level, {
|
||||
render: (tokens: Token[], index: number, options: MarkdownIt.Options, env: unknown, self: Renderer) => {
|
||||
tokens[index].attrJoin('role', 'alert')
|
||||
tokens[index].attrJoin('class', 'alert')
|
||||
tokens[index].attrJoin('class', `alert-${level}`)
|
||||
return self.renderToken(tokens, index, options)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { AppExtension } from '../../base/app-extension'
|
||||
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/base/markdown-renderer-extension'
|
||||
import { BlockquoteExtraTagMarkdownExtension } from './blockquote-extra-tag-markdown-extension'
|
||||
|
||||
/**
|
||||
* Adds support for generic blockquote extra tags and blockquote color extra tags.
|
||||
*/
|
||||
export class BlockquoteAppExtension extends AppExtension {
|
||||
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
|
||||
return [new BlockquoteExtraTagMarkdownExtension()]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,107 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Element, Node } from 'domhandler'
|
||||
import { isTag, isText } from 'domhandler'
|
||||
import { Optional } from '@mrdrogdrog/optional'
|
||||
import { BlockquoteExtraTagMarkdownExtension } from './blockquote-extra-tag-markdown-extension'
|
||||
import { TravelerNodeProcessor } from '../../../components/markdown-renderer/node-preprocessors/traveler-node-processor'
|
||||
|
||||
/**
|
||||
* Detects blockquotes with blockquote color tags and uses them to color the blockquote border.
|
||||
*/
|
||||
export class BlockquoteBorderColorNodePreprocessor extends TravelerNodeProcessor {
|
||||
protected processNode(node: Node): void {
|
||||
if (!isTag(node) || isBlockquoteWithChildren(node)) {
|
||||
return
|
||||
}
|
||||
|
||||
Optional.ofNullable(findBlockquoteColorDefinitionAndParent(node.children)).ifPresent(([color, parentParagraph]) => {
|
||||
removeColorDefinitionsFromParagraph(parentParagraph)
|
||||
if (!cssColor.test(color)) {
|
||||
return
|
||||
}
|
||||
setLeftBorderColor(node, color)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const cssColor =
|
||||
/^(#(?:[0-9a-f]{2}){2,4}|#[0-9a-f]{3}|black|silver|gray|whitesmoke|maroon|red|purple|fuchsia|green|lime|olivedrab|yellow|navy|blue|teal|aquamarine|orange|aliceblue|antiquewhite|aqua|azure|beige|bisque|blanchedalmond|blueviolet|brown|burlywood|cadetblue|chartreuse|chocolate|coral|cornflowerblue|cornsilk|crimson|currentcolor|darkblue|darkcyan|darkgoldenrod|darkgray|darkgreen|darkgrey|darkkhaki|darkmagenta|darkolivegreen|darkorange|darkorchid|darkred|darksalmon|darkseagreen|darkslateblue|darkslategray|darkslategrey|darkturquoise|darkviolet|deeppink|deepskyblue|dimgray|dimgrey|dodgerblue|firebrick|floralwhite|forestgreen|gainsboro|ghostwhite|goldenrod|gold|greenyellow|grey|honeydew|hotpink|indianred|indigo|ivory|khaki|lavenderblush|lavender|lawngreen|lemonchiffon|lightblue|lightcoral|lightcyan|lightgoldenrodyellow|lightgray|lightgreen|lightgrey|lightpink|lightsalmon|lightseagreen|lightskyblue|lightslategray|lightslategrey|lightsteelblue|lightyellow|limegreen|linen|mediumaquamarine|mediumblue|mediumorchid|mediumpurple|mediumseagreen|mediumslateblue|mediumspringgreen|mediumturquoise|mediumvioletred|midnightblue|mintcream|mistyrose|moccasin|navajowhite|oldlace|olive|orangered|orchid|palegoldenrod|palegreen|paleturquoise|palevioletred|papayawhip|peachpuff|peru|pink|plum|powderblue|rebeccapurple|rosybrown|royalblue|saddlebrown|salmon|sandybrown|seagreen|seashell|sienna|skyblue|slateblue|slategray|slategrey|snow|springgreen|steelblue|tan|thistle|tomato|transparent|turquoise|violet|wheat|white|yellowgreen)$/i
|
||||
|
||||
/**
|
||||
* Checks if the given {@link Element} is a blockquote with children.
|
||||
*
|
||||
* @param element The {@link Element} to check
|
||||
* @return {@link true} if the element is a blockquote with children.
|
||||
*/
|
||||
const isBlockquoteWithChildren = (element: Element): boolean => {
|
||||
return element.name !== 'blockquote' || !element.children || element.children.length < 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches for a blockquote color definition tag.
|
||||
*
|
||||
* @param elements The {@link Element} elements that should be searched through.
|
||||
* @return The parent paragraph and the extracted color if a color definition was found. {@link undefined} otherwise.
|
||||
*/
|
||||
const findBlockquoteColorDefinitionAndParent = (
|
||||
elements: Node[]
|
||||
): [color: string, parentParagraph: Element] | undefined => {
|
||||
for (const paragraph of elements) {
|
||||
if (!isTag(paragraph) || paragraph.name !== 'p' || paragraph.children.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const colorDefinition of paragraph.children) {
|
||||
if (!isTag(colorDefinition)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const content = extractBlockquoteColorDefinition(colorDefinition)
|
||||
if (content !== undefined) {
|
||||
return [content, paragraph]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given node is a blockquote color definition.
|
||||
*
|
||||
* @param element The {@link Element} to check
|
||||
* @return true if the checked node is a blockquote color definition
|
||||
*/
|
||||
const extractBlockquoteColorDefinition = (element: Element): string | undefined => {
|
||||
if (
|
||||
element.name === BlockquoteExtraTagMarkdownExtension.tagName &&
|
||||
element.attribs['data-label'] === 'color' &&
|
||||
element.children.length === 1 &&
|
||||
isText(element.children[0])
|
||||
) {
|
||||
return element.children[0].data
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all color definition elements from the given paragraph {@link Element}.
|
||||
*
|
||||
* @param paragraph The {@link Element} whose children should be filtered
|
||||
*/
|
||||
const removeColorDefinitionsFromParagraph = (paragraph: Element): void => {
|
||||
const childElements = paragraph.children
|
||||
paragraph.children = childElements.filter((elem) => !isTag(elem) || !extractBlockquoteColorDefinition(elem))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the left border color of the given {@link Element}.
|
||||
*
|
||||
* @param element The {@link Element} to change
|
||||
* @param color The border color
|
||||
*/
|
||||
const setLeftBorderColor = (element: Element, color: string): void => {
|
||||
element.attribs = Object.assign(element.attribs || {}, { style: `border-left-color: ${color};` })
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Element } from 'domhandler'
|
||||
import { isText } from 'domhandler'
|
||||
import { cssColor } from './blockquote-border-color-node-preprocessor'
|
||||
import type { Text } from 'domhandler/lib/node'
|
||||
import { BlockquoteExtraTagMarkdownExtension } from './blockquote-extra-tag-markdown-extension'
|
||||
import { Optional } from '@mrdrogdrog/optional'
|
||||
import type { NodeReplacement } from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import {
|
||||
ComponentReplacer,
|
||||
DO_NOT_REPLACE
|
||||
} from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import { ForkAwesomeIcon } from '../../../components/common/fork-awesome/fork-awesome-icon'
|
||||
|
||||
/**
|
||||
* Replaces <blockquote-tag> elements with "color" as label and a valid color as content
|
||||
* with an colored label icon.
|
||||
*
|
||||
* @see BlockquoteTagMarkdownItPlugin
|
||||
*/
|
||||
export class BlockquoteColorExtraTagReplacer extends ComponentReplacer {
|
||||
replace(element: Element): NodeReplacement {
|
||||
return Optional.of(element)
|
||||
.filter(
|
||||
(element) =>
|
||||
element.tagName === BlockquoteExtraTagMarkdownExtension.tagName && element.attribs?.['data-label'] === 'color'
|
||||
)
|
||||
.map((element) => element.children[0])
|
||||
.filter(isText)
|
||||
.map((child) => (child as Text).data)
|
||||
.filter((content) => cssColor.test(content))
|
||||
.map((color) => (
|
||||
<span className={'blockquote-extra'} key={1} style={{ color: color }}>
|
||||
<ForkAwesomeIcon key='icon' className={'mx-1'} icon={'tag'} />
|
||||
</span>
|
||||
))
|
||||
.orElse(DO_NOT_REPLACE)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { BlockquoteColorExtraTagReplacer } from './blockquote-color-extra-tag-replacer'
|
||||
import { BlockquoteExtraTagReplacer } from './blockquote-extra-tag-replacer'
|
||||
import type MarkdownIt from 'markdown-it'
|
||||
import { BlockquoteBorderColorNodePreprocessor } from './blockquote-border-color-node-preprocessor'
|
||||
import { BlockquoteExtraTagMarkdownItPlugin } from './blockquote-extra-tag-markdown-it-plugin'
|
||||
import type { NodeProcessor } from '../../../components/markdown-renderer/node-preprocessors/node-processor'
|
||||
import type { ComponentReplacer } from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/base/markdown-renderer-extension'
|
||||
|
||||
/**
|
||||
* Adds support for generic blockquote extra tags and blockquote color extra tags.
|
||||
*/
|
||||
export class BlockquoteExtraTagMarkdownExtension extends MarkdownRendererExtension {
|
||||
public static readonly tagName = 'app-blockquote-tag'
|
||||
|
||||
public configureMarkdownIt(markdownIt: MarkdownIt): void {
|
||||
new BlockquoteExtraTagMarkdownItPlugin('color', 'tag').registerRule(markdownIt)
|
||||
new BlockquoteExtraTagMarkdownItPlugin('name', 'user').registerRule(markdownIt)
|
||||
new BlockquoteExtraTagMarkdownItPlugin('time', 'clock-o').registerRule(markdownIt)
|
||||
}
|
||||
|
||||
public buildReplacers(): ComponentReplacer[] {
|
||||
return [new BlockquoteColorExtraTagReplacer(), new BlockquoteExtraTagReplacer()]
|
||||
}
|
||||
|
||||
public buildNodeProcessors(): NodeProcessor[] {
|
||||
return [new BlockquoteBorderColorNodePreprocessor()]
|
||||
}
|
||||
|
||||
public buildTagNameAllowList(): string[] {
|
||||
return [BlockquoteExtraTagMarkdownExtension.tagName]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { BlockquoteExtraTagMarkdownItPlugin } from './blockquote-extra-tag-markdown-it-plugin'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
|
||||
describe('Quote extra syntax parser', () => {
|
||||
let markdownIt: MarkdownIt
|
||||
|
||||
beforeEach(() => {
|
||||
markdownIt = new MarkdownIt('default', {
|
||||
html: false,
|
||||
breaks: true,
|
||||
langPrefix: '',
|
||||
typographer: true
|
||||
})
|
||||
new BlockquoteExtraTagMarkdownItPlugin('abc', 'markdown').registerRule(markdownIt)
|
||||
})
|
||||
|
||||
it('should parse a valid tag', () => {
|
||||
expect(markdownIt.renderInline('[abc=markdown]')).toEqual(
|
||||
'<app-blockquote-tag data-label=\'abc\' data-icon="markdown">markdown</app-blockquote-tag>'
|
||||
)
|
||||
})
|
||||
|
||||
it("shouldn't parse a tag with no opener bracket", () => {
|
||||
expect(markdownIt.renderInline('abc=def]')).toEqual('abc=def]')
|
||||
})
|
||||
|
||||
it("shouldn't parse a tag with no closing bracket", () => {
|
||||
expect(markdownIt.renderInline('[abc=def')).toEqual('[abc=def')
|
||||
})
|
||||
|
||||
it("shouldn't parse a tag with no separation character", () => {
|
||||
expect(markdownIt.renderInline('[abcdef]')).toEqual('[abcdef]')
|
||||
})
|
||||
|
||||
it("shouldn't parse a tag with an empty label", () => {
|
||||
expect(markdownIt.renderInline('[=def]')).toEqual('[=def]')
|
||||
})
|
||||
|
||||
it("shouldn't parse a tag with an empty value", () => {
|
||||
expect(markdownIt.renderInline('[abc=]')).toEqual('[abc=]')
|
||||
})
|
||||
|
||||
it("shouldn't parse a tag with an empty body", () => {
|
||||
expect(markdownIt.renderInline('[]')).toEqual('[]')
|
||||
})
|
||||
})
|
|
@ -0,0 +1,200 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type MarkdownIt from 'markdown-it/lib'
|
||||
import type Token from 'markdown-it/lib/token'
|
||||
import { Optional } from '@mrdrogdrog/optional'
|
||||
import type StateInline from 'markdown-it/lib/rules_inline/state_inline'
|
||||
import { BlockquoteExtraTagMarkdownExtension } from './blockquote-extra-tag-markdown-extension'
|
||||
import type { RuleInline } from 'markdown-it/lib/parser_inline'
|
||||
import type { IconName } from '../../../components/common/fork-awesome/types'
|
||||
|
||||
export interface BlockquoteTagOptions {
|
||||
parseSubTags?: boolean
|
||||
valueRegex?: RegExp
|
||||
icon?: IconName
|
||||
}
|
||||
|
||||
export interface QuoteExtraTagValues {
|
||||
labelStartIndex: number
|
||||
labelEndIndex: number
|
||||
valueStartIndex: number
|
||||
valueEndIndex: number
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects the blockquote extra tag syntax `[label=value]` and creates <blockquote-tag> elements.
|
||||
*/
|
||||
export class BlockquoteExtraTagMarkdownItPlugin {
|
||||
private static readonly BlockquoteExtraTagRuleName = 'blockquote_extra_tag'
|
||||
|
||||
constructor(private tagName: string, private icon: IconName) {}
|
||||
|
||||
/**
|
||||
* Registers an inline rule that detects blockquote extra tags and replaces them with blockquote tokens.
|
||||
*
|
||||
* @param markdownIt The {@link MarkdownIt markdown-it} in which the inline rule should be registered.
|
||||
*/
|
||||
public registerRule(markdownIt: MarkdownIt): void {
|
||||
markdownIt.inline.ruler.before('link', `blockquote_extra_tag_${this.tagName}`, this.createInlineRuler())
|
||||
BlockquoteExtraTagMarkdownItPlugin.registerRenderer(markdownIt)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link RuleInline markdown-it inline rule} that detects the configured blockquote extra tag.
|
||||
*
|
||||
* @return The created inline rule
|
||||
*/
|
||||
private createInlineRuler(): RuleInline {
|
||||
return (state) =>
|
||||
this.parseBlockquoteExtraTag(state.src, state.pos, state.posMax)
|
||||
.map((parseResults) => {
|
||||
this.createNewBlockquoteExtraTagToken(state, parseResults.label, parseResults.value)
|
||||
state.pos = parseResults.valueEndIndex + 1
|
||||
return true
|
||||
})
|
||||
.orElse(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the markdown-it renderer that translated `blockquote_tag` tokens into HTML
|
||||
*
|
||||
* @param markdownIt The {@link MarkdownIt markdown-it} in which the render should be registered.
|
||||
*/
|
||||
private static registerRenderer(markdownIt: MarkdownIt): void {
|
||||
if (markdownIt.renderer.rules[BlockquoteExtraTagMarkdownItPlugin.BlockquoteExtraTagRuleName]) {
|
||||
return
|
||||
}
|
||||
markdownIt.renderer.rules[BlockquoteExtraTagMarkdownItPlugin.BlockquoteExtraTagRuleName] = (
|
||||
tokens,
|
||||
idx,
|
||||
options: MarkdownIt.Options,
|
||||
env: unknown
|
||||
) => {
|
||||
const token = tokens[idx]
|
||||
const innerTokens = token.children
|
||||
const label = token.attrGet('label') ?? ''
|
||||
const icon = token.attrGet('icon')
|
||||
const iconAttribute = icon === null ? '' : ` data-icon="${icon}"`
|
||||
const innerHtml = innerTokens === null ? '' : markdownIt.renderer.renderInline(innerTokens, options, env)
|
||||
return `<${BlockquoteExtraTagMarkdownExtension.tagName} data-label='${label}'${iconAttribute}>${innerHtml}</${BlockquoteExtraTagMarkdownExtension.tagName}>`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new blockquote extra {@link Token token} using the given values.
|
||||
*
|
||||
* @param state The state in which the token should be inserted
|
||||
* @param label The label for the extra token
|
||||
* @param value The value for the extra token that will be inline parsed
|
||||
* @return The generated token
|
||||
*/
|
||||
private createNewBlockquoteExtraTagToken(state: StateInline, label: string, value: string): Token {
|
||||
const token = state.push(BlockquoteExtraTagMarkdownItPlugin.BlockquoteExtraTagRuleName, '', 0)
|
||||
token.attrSet('label', label)
|
||||
token.attrSet('icon', this.icon)
|
||||
token.children = BlockquoteExtraTagMarkdownItPlugin.parseInlineContent(state, value)
|
||||
return token
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the given content using the markdown-it instance of the given state.
|
||||
*
|
||||
* @param state The state whose inline parser should be used to parse the given content
|
||||
* @param content The content to parse
|
||||
* @return The generated tokens
|
||||
*/
|
||||
private static parseInlineContent(state: StateInline, content: string): Token[] {
|
||||
const childTokens: Token[] = []
|
||||
state.md.inline.parse(content, state.md, state.env, childTokens)
|
||||
return childTokens
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a blockquote tag. The syntax is [label=value].
|
||||
*
|
||||
* @param line The line in which the tag should be looked for.
|
||||
* @param startIndex The start index for the search.
|
||||
* @param dontSearchAfterIndex The maximal position for the search.
|
||||
*/
|
||||
private parseBlockquoteExtraTag(
|
||||
line: string,
|
||||
startIndex: number,
|
||||
dontSearchAfterIndex: number
|
||||
): Optional<QuoteExtraTagValues> {
|
||||
if (line[startIndex] !== '[') {
|
||||
return Optional.empty()
|
||||
}
|
||||
|
||||
const labelStartIndex = startIndex + 1
|
||||
const labelEndIndex = BlockquoteExtraTagMarkdownItPlugin.parseLabel(line, labelStartIndex, dontSearchAfterIndex)
|
||||
if (!labelEndIndex || labelStartIndex === labelEndIndex) {
|
||||
return Optional.empty()
|
||||
}
|
||||
|
||||
const label = line.slice(labelStartIndex, labelEndIndex)
|
||||
if (label !== this.tagName) {
|
||||
return Optional.empty()
|
||||
}
|
||||
|
||||
const valueStartIndex = labelEndIndex + 1
|
||||
const valueEndIndex = BlockquoteExtraTagMarkdownItPlugin.parseValue(line, valueStartIndex, dontSearchAfterIndex)
|
||||
if (!valueEndIndex || valueStartIndex === valueEndIndex) {
|
||||
return Optional.empty()
|
||||
}
|
||||
const value = line.slice(valueStartIndex, valueEndIndex)
|
||||
|
||||
return Optional.of({
|
||||
labelStartIndex,
|
||||
labelEndIndex,
|
||||
valueStartIndex,
|
||||
valueEndIndex,
|
||||
label,
|
||||
value
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the value part of a blockquote tag. That is [notthis=THIS] part. It also detects nested [] blocks.
|
||||
*
|
||||
* @param line The line in which the tag is.
|
||||
* @param startIndex The start index of the tag.
|
||||
* @param dontSearchAfterIndex The maximal position for the search.
|
||||
* @return The value part of the blockquote tag
|
||||
*/
|
||||
private static parseValue(line: string, startIndex: number, dontSearchAfterIndex: number): number | undefined {
|
||||
let level = 0
|
||||
for (let position = startIndex; position <= dontSearchAfterIndex; position += 1) {
|
||||
const currentCharacter = line[position]
|
||||
if (currentCharacter === ']') {
|
||||
if (level === 0) {
|
||||
return position
|
||||
}
|
||||
level -= 1
|
||||
} else if (currentCharacter === '[') {
|
||||
level += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the label part of a blockquote tag. That is [THIS=notthis] part.
|
||||
*
|
||||
* @param line The line in which the tag is.
|
||||
* @param startIndex The start index of the tag.
|
||||
* @param dontSearchAfterIndex The maximal position for the search.
|
||||
* @return The label of the blockquote tag.
|
||||
*/
|
||||
private static parseLabel(line: string, startIndex: number, dontSearchAfterIndex: number): number | undefined {
|
||||
for (let pos = startIndex; pos <= dontSearchAfterIndex; pos += 1) {
|
||||
if (line[pos] === '=') {
|
||||
return pos
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Element } from 'domhandler'
|
||||
import { Optional } from '@mrdrogdrog/optional'
|
||||
import type { ReactElement } from 'react'
|
||||
import { BlockquoteExtraTagMarkdownExtension } from './blockquote-extra-tag-markdown-extension'
|
||||
import type {
|
||||
NodeReplacement,
|
||||
SubNodeTransform
|
||||
} from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import {
|
||||
ComponentReplacer,
|
||||
DO_NOT_REPLACE
|
||||
} from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import { ForkAwesomeIcons } from '../../../components/common/fork-awesome/fork-awesome-icons'
|
||||
import type { ForkAwesomeIconProps } from '../../../components/common/fork-awesome/fork-awesome-icon'
|
||||
import { ForkAwesomeIcon } from '../../../components/common/fork-awesome/fork-awesome-icon'
|
||||
import type { IconName } from '../../../components/common/fork-awesome/types'
|
||||
|
||||
/**
|
||||
* Replaces <blockquote-tag> elements with an icon and a small text.
|
||||
*
|
||||
* @see BlockquoteTagMarkdownItPlugin
|
||||
* @see ColoredBlockquoteNodePreprocessor
|
||||
*/
|
||||
export class BlockquoteExtraTagReplacer extends ComponentReplacer {
|
||||
replace(element: Element, subNodeTransform: SubNodeTransform): NodeReplacement {
|
||||
return Optional.of(element)
|
||||
.filter(
|
||||
(element) => element.tagName === BlockquoteExtraTagMarkdownExtension.tagName && element.attribs !== undefined
|
||||
)
|
||||
.map((element) => (
|
||||
<span className={'blockquote-extra'} key={1}>
|
||||
{this.buildIconElement(element)}
|
||||
{BlockquoteExtraTagReplacer.transformChildren(element, subNodeTransform)}
|
||||
</span>
|
||||
))
|
||||
.orElse(DO_NOT_REPLACE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a fork awesome icon name from the node and builds a {@link ForkAwesomeIcon fork awesome icon react element}.
|
||||
*
|
||||
* @param node The node that holds the "data-icon" attribute.
|
||||
* @return the {@link ForkAwesomeIcon fork awesome icon react element} or {@link undefined} if no icon name was found.
|
||||
*/
|
||||
private buildIconElement(node: Element): ReactElement<ForkAwesomeIconProps> | undefined {
|
||||
return Optional.ofNullable(node.attribs['data-icon'] as IconName)
|
||||
.filter((iconName) => ForkAwesomeIcons.includes(iconName))
|
||||
.map((iconName) => <ForkAwesomeIcon key='icon' className={'mx-1'} icon={iconName} />)
|
||||
.orElse(undefined)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`CSV Table Markdown Extension renders a csv codeblock 1`] = `
|
||||
<div>
|
||||
<pre>
|
||||
<code
|
||||
class="csv"
|
||||
>
|
||||
a;b;c
|
||||
d;e;f
|
||||
|
||||
</code>
|
||||
</pre>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,79 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`CSV Table renders correctly with header 1`] = `
|
||||
<div>
|
||||
<table
|
||||
class="csv-html-table table-striped"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
a
|
||||
</th>
|
||||
<th>
|
||||
b
|
||||
</th>
|
||||
<th>
|
||||
c
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
d
|
||||
</td>
|
||||
<td>
|
||||
e
|
||||
</td>
|
||||
<td>
|
||||
f
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`CSV Table renders correctly without code 1`] = `
|
||||
<div>
|
||||
<table
|
||||
class="csv-html-table table-striped"
|
||||
>
|
||||
<tbody />
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`CSV Table renders correctly without header 1`] = `
|
||||
<div>
|
||||
<table
|
||||
class="csv-html-table table-striped"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
a
|
||||
</td>
|
||||
<td>
|
||||
b
|
||||
</td>
|
||||
<td>
|
||||
c
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
d
|
||||
</td>
|
||||
<td>
|
||||
e
|
||||
</td>
|
||||
<td>
|
||||
f
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { parseCsv } from './csv-parser'
|
||||
|
||||
describe('test CSV parser', () => {
|
||||
it('normal table', () => {
|
||||
const input = 'A;B;C\nD;E;F\nG;H;I'
|
||||
const expected = [
|
||||
['A', 'B', 'C'],
|
||||
['D', 'E', 'F'],
|
||||
['G', 'H', 'I']
|
||||
]
|
||||
expect(parseCsv(input, ';')).toEqual(expected)
|
||||
})
|
||||
|
||||
it('blank lines', () => {
|
||||
const input = 'A;B;C\n\nG;H;I'
|
||||
const expected = [
|
||||
['A', 'B', 'C'],
|
||||
['G', 'H', 'I']
|
||||
]
|
||||
expect(parseCsv(input, ';')).toEqual(expected)
|
||||
})
|
||||
|
||||
it('items with delimiter', () => {
|
||||
const input = 'A;B;C\n"D;E;F"\nG;H;I'
|
||||
const expected = [['A', 'B', 'C'], ['"D;E;F"'], ['G', 'H', 'I']]
|
||||
expect(parseCsv(input, ';')).toEqual(expected)
|
||||
})
|
||||
})
|
31
frontend/src/extensions/extra-integrations/csv/csv-parser.ts
Normal file
31
frontend/src/extensions/extra-integrations/csv/csv-parser.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parses a given text as comma separated values (CSV).
|
||||
*
|
||||
* @param csvText The raw csv text
|
||||
* @param csvColumnDelimiter The delimiter for the columns
|
||||
* @return the values splitted by rows and columns
|
||||
*/
|
||||
export const parseCsv = (csvText: string, csvColumnDelimiter: string): string[][] => {
|
||||
const rows = csvText.split('\n')
|
||||
if (!rows || rows.length === 0) {
|
||||
return []
|
||||
}
|
||||
const splitRegex = new RegExp(`${escapeRegexCharacters(csvColumnDelimiter)}(?=(?:[^"]*"[^"]*")*[^"]*$)`)
|
||||
return rows.filter((row) => row !== '').map((row) => row.split(splitRegex))
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes regex characters in the given string, so it can be used as literal string in another regex.
|
||||
*
|
||||
* @param unsafe The unescaped string
|
||||
* @return The escaped string
|
||||
*/
|
||||
const escapeRegexCharacters = (unsafe: string): string => {
|
||||
return unsafe.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Element } from 'domhandler'
|
||||
import React from 'react'
|
||||
import type { NodeReplacement } from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import { CsvTable } from './csv-table'
|
||||
import {
|
||||
ComponentReplacer,
|
||||
DO_NOT_REPLACE
|
||||
} from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import { CodeBlockComponentReplacer } from '../../../components/markdown-renderer/replace-components/code-block-component-replacer'
|
||||
|
||||
/**
|
||||
* Detects code blocks with "csv" as language and renders them as table.
|
||||
*/
|
||||
export class CsvReplacer extends ComponentReplacer {
|
||||
public replace(codeNode: Element): NodeReplacement {
|
||||
const code = CodeBlockComponentReplacer.extractTextFromCodeNode(codeNode, 'csv')
|
||||
if (!code) {
|
||||
return DO_NOT_REPLACE
|
||||
}
|
||||
|
||||
const extraData = codeNode.attribs['data-extra']
|
||||
const extraRegex = /\s*(delimiter=([^\s]*))?\s*(header)?/
|
||||
const extraInfos = extraRegex.exec(extraData)
|
||||
|
||||
const delimiter = extraInfos?.[2] ?? ','
|
||||
const showHeader = extraInfos?.[3] !== undefined
|
||||
|
||||
return <CsvTable code={code} delimiter={delimiter} showHeader={showHeader} />
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { AppExtension } from '../../base/app-extension'
|
||||
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/base/markdown-renderer-extension'
|
||||
import { CsvTableMarkdownExtension } from './csv-table-markdown-extension'
|
||||
|
||||
/**
|
||||
* Adds support for csv tables to the markdown rendering.
|
||||
*/
|
||||
export class CsvTableAppExtension extends AppExtension {
|
||||
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
|
||||
return [new CsvTableMarkdownExtension()]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as CsvTableModule from '../csv/csv-table'
|
||||
import React from 'react'
|
||||
import { render } from '@testing-library/react'
|
||||
import { CsvTableMarkdownExtension } from './csv-table-markdown-extension'
|
||||
import { mockI18n } from '../../../components/markdown-renderer/test-utils/mock-i18n'
|
||||
import type { CodeProps } from '../../../components/markdown-renderer/replace-components/code-block-component-replacer'
|
||||
import { TestMarkdownRenderer } from '../../../components/markdown-renderer/test-utils/test-markdown-renderer'
|
||||
|
||||
jest.mock('../csv/csv-table')
|
||||
|
||||
describe('CSV Table Markdown Extension', () => {
|
||||
beforeAll(async () => {
|
||||
jest.spyOn(CsvTableModule, 'CsvTable').mockImplementation((({ code }) => {
|
||||
return (
|
||||
<span>
|
||||
this is a mock for csv frame
|
||||
<code>{code}</code>
|
||||
</span>
|
||||
)
|
||||
}) as React.FC<CodeProps>)
|
||||
await mockI18n()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.resetModules()
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('renders a csv codeblock', () => {
|
||||
const view = render(
|
||||
<TestMarkdownRenderer extensions={[new CsvTableMarkdownExtension()]} content={'```csv\na;b;c\nd;e;f\n```'} />
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
})
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { CsvReplacer } from './csv-replacer'
|
||||
import { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/base/markdown-renderer-extension'
|
||||
import type { ComponentReplacer } from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
|
||||
/**
|
||||
* Adds support for csv tables to the markdown rendering using code fences with "csv" as language.
|
||||
*/
|
||||
export class CsvTableMarkdownExtension extends MarkdownRendererExtension {
|
||||
public buildReplacers(): ComponentReplacer[] {
|
||||
return [new CsvReplacer()]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { render } from '@testing-library/react'
|
||||
import { CsvTable } from './csv-table'
|
||||
|
||||
describe('CSV Table', () => {
|
||||
it('renders correctly with header', () => {
|
||||
const view = render(<CsvTable code={'a;b;c\nd;e;f'} delimiter={';'} showHeader={true} />)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders correctly without header', () => {
|
||||
const view = render(<CsvTable code={'a;b;c\nd;e;f'} delimiter={';'} showHeader={false} />)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders correctly without code', () => {
|
||||
const view = render(<CsvTable code={''} delimiter={';'} showHeader={false} />)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
})
|
76
frontend/src/extensions/extra-integrations/csv/csv-table.tsx
Normal file
76
frontend/src/extensions/extra-integrations/csv/csv-table.tsx
Normal file
|
@ -0,0 +1,76 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react'
|
||||
import { parseCsv } from './csv-parser'
|
||||
import { cypressId } from '../../../utils/cypress-attribute'
|
||||
|
||||
export interface CsvTableProps {
|
||||
code: string
|
||||
delimiter: string
|
||||
showHeader: boolean
|
||||
tableRowClassName?: string
|
||||
tableColumnClassName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a csv table.
|
||||
*
|
||||
* @param code The csv code
|
||||
* @param delimiter The delimiter to be used
|
||||
* @param showHeader If the header should be shown.
|
||||
* @param tableRowClassName Additional class name for the rows.
|
||||
* @param tableColumnClassNameA Additional class name for the columns.
|
||||
*/
|
||||
export const CsvTable: React.FC<CsvTableProps> = ({
|
||||
code,
|
||||
delimiter,
|
||||
showHeader,
|
||||
tableRowClassName,
|
||||
tableColumnClassName
|
||||
}) => {
|
||||
const { rowsWithColumns, headerRow } = useMemo(() => {
|
||||
const rowsWithColumns = parseCsv(code.trim(), delimiter)
|
||||
const headerRow = showHeader ? rowsWithColumns.splice(0, 1)[0] : []
|
||||
return { rowsWithColumns, headerRow }
|
||||
}, [code, delimiter, showHeader])
|
||||
|
||||
const renderTableHeader = useMemo(() => {
|
||||
return headerRow.length === 0 ? undefined : (
|
||||
<thead>
|
||||
<tr>
|
||||
{headerRow.map((column, columnNumber) => (
|
||||
<th key={`header-${columnNumber}`}>{column}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
)
|
||||
}, [headerRow])
|
||||
|
||||
const renderTableBody = useMemo(
|
||||
() => (
|
||||
<tbody>
|
||||
{rowsWithColumns.map((row, rowNumber) => (
|
||||
<tr className={tableRowClassName} key={`row-${rowNumber}`}>
|
||||
{row.map((column, columnIndex) => (
|
||||
<td className={tableColumnClassName} key={`cell-${rowNumber}-${columnIndex}`}>
|
||||
{column.replace(/^"|"$/g, '')}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
),
|
||||
[rowsWithColumns, tableColumnClassName, tableRowClassName]
|
||||
)
|
||||
|
||||
return (
|
||||
<table className={'csv-html-table table-striped'} {...cypressId('csv-html-table')}>
|
||||
{renderTableHeader}
|
||||
{renderTableBody}
|
||||
</table>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Flowchart markdown extensions renders a flowchart codeblock 1`] = `
|
||||
<div>
|
||||
<pre>
|
||||
<span>
|
||||
this is a mock for flowchart frame
|
||||
<code>
|
||||
st=>start: Start
|
||||
e=>end: End
|
||||
st->e
|
||||
|
||||
</code>
|
||||
</span>
|
||||
</pre>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,38 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Flowchart handles error if lib loading failed 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="fade alert alert-danger show"
|
||||
role="alert"
|
||||
>
|
||||
common.errorWhileLoading
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Flowchart handles error while rendering 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="fade alert alert-danger show"
|
||||
role="alert"
|
||||
>
|
||||
renderer.flowchart.invalidSyntax
|
||||
</div>
|
||||
<div
|
||||
class="text-center"
|
||||
data-testid="flowchart"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Flowchart renders correctly 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="text-center"
|
||||
data-testid="flowchart"
|
||||
>
|
||||
Flowchart rendering succeeded!
|
||||
</div>
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { AppExtension } from '../../base/app-extension'
|
||||
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/base/markdown-renderer-extension'
|
||||
import { FlowchartMarkdownExtension } from './flowchart-markdown-extension'
|
||||
|
||||
/**
|
||||
* Adds support for flow charts to the markdown rendering.
|
||||
*/
|
||||
export class FlowchartAppExtension extends AppExtension {
|
||||
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
|
||||
return [new FlowchartMarkdownExtension()]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { render } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import { FlowchartMarkdownExtension } from './flowchart-markdown-extension'
|
||||
import * as Flowchart from '../flowchart/flowchart'
|
||||
import { mockI18n } from '../../../components/markdown-renderer/test-utils/mock-i18n'
|
||||
import type { CodeProps } from '../../../components/markdown-renderer/replace-components/code-block-component-replacer'
|
||||
import { TestMarkdownRenderer } from '../../../components/markdown-renderer/test-utils/test-markdown-renderer'
|
||||
|
||||
jest.mock('../flowchart/flowchart')
|
||||
|
||||
describe('Flowchart markdown extensions', () => {
|
||||
beforeAll(async () => {
|
||||
jest.spyOn(Flowchart, 'FlowChart').mockImplementation((({ code }) => {
|
||||
return (
|
||||
<span>
|
||||
this is a mock for flowchart frame
|
||||
<code>{code}</code>
|
||||
</span>
|
||||
)
|
||||
}) as React.FC<CodeProps>)
|
||||
await mockI18n()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.resetModules()
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('renders a flowchart codeblock', () => {
|
||||
const view = render(
|
||||
<TestMarkdownRenderer
|
||||
extensions={[new FlowchartMarkdownExtension()]}
|
||||
content={'```flow\nst=>start: Start\ne=>end: End\nst->e\n```'}
|
||||
/>
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
})
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { FlowChart } from './flowchart'
|
||||
import { CodeBlockMarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/base/code-block-markdown-extension/code-block-markdown-renderer-extension'
|
||||
import { CodeBlockComponentReplacer } from '../../../components/markdown-renderer/replace-components/code-block-component-replacer'
|
||||
|
||||
/**
|
||||
* Adds support for flow charts to the markdown rendering using code fences with "flow" as language.
|
||||
*/
|
||||
export class FlowchartMarkdownExtension extends CodeBlockMarkdownRendererExtension {
|
||||
public buildReplacers(): CodeBlockComponentReplacer[] {
|
||||
return [new CodeBlockComponentReplacer(FlowChart, 'flow')]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { FlowChart } from './flowchart'
|
||||
import type * as flowchartJsModule from 'flowchart.js'
|
||||
import { mockI18n } from '../../../components/markdown-renderer/test-utils/mock-i18n'
|
||||
import { StoreProvider } from '../../../redux/store-provider'
|
||||
import * as useMediaQuery from '@restart/hooks/useMediaQuery'
|
||||
|
||||
jest.mock('@restart/hooks/useMediaQuery')
|
||||
|
||||
describe('Flowchart', () => {
|
||||
const successText = 'Flowchart rendering succeeded!'
|
||||
const expectedValidFlowchartCode = 'test code'
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.spyOn(useMediaQuery, 'default').mockImplementation(() => false)
|
||||
await mockI18n()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetModules()
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
const mockFlowchartDraw = (): jest.Mock<void, Parameters<flowchartJsModule.Instance['drawSVG']>> => {
|
||||
const drawSvg = jest.fn((container: HTMLElement | string) => {
|
||||
if (typeof container === 'string') {
|
||||
throw new Error('HTMLElement expected')
|
||||
} else {
|
||||
container.innerHTML = successText
|
||||
}
|
||||
})
|
||||
jest.mock('flowchart.js', () => ({
|
||||
parse: jest.fn((code) => {
|
||||
if (code !== expectedValidFlowchartCode) {
|
||||
throw new Error('invalid flowchart code')
|
||||
}
|
||||
return { drawSVG: drawSvg, clean: jest.fn() }
|
||||
})
|
||||
}))
|
||||
return drawSvg
|
||||
}
|
||||
|
||||
it('renders correctly', async () => {
|
||||
const successText = 'Flowchart rendering succeeded!'
|
||||
const validFlowchartCode = 'test code'
|
||||
const mockDrawSvg = mockFlowchartDraw()
|
||||
|
||||
const view = render(
|
||||
<StoreProvider>
|
||||
<FlowChart code={validFlowchartCode} />
|
||||
</StoreProvider>
|
||||
)
|
||||
await screen.findByText(successText)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
expect(mockDrawSvg).toBeCalled()
|
||||
})
|
||||
|
||||
it('handles error while rendering', async () => {
|
||||
const mockDrawSvg = mockFlowchartDraw()
|
||||
|
||||
const view = render(
|
||||
<StoreProvider>
|
||||
<FlowChart code={'Invalid!'} />
|
||||
</StoreProvider>
|
||||
)
|
||||
await screen.findByText('renderer.flowchart.invalidSyntax')
|
||||
expect(view.container).toMatchSnapshot()
|
||||
expect(mockDrawSvg).not.toBeCalled()
|
||||
})
|
||||
|
||||
it('handles error if lib loading failed', async () => {
|
||||
jest.mock('flowchart.js', () => {
|
||||
throw new Error('flowchart.js import is exploded!')
|
||||
})
|
||||
|
||||
const view = render(
|
||||
<StoreProvider>
|
||||
<FlowChart code={'Invalid!'} />
|
||||
</StoreProvider>
|
||||
)
|
||||
await screen.findByText('common.errorWhileLoading')
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
})
|
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Alert } from 'react-bootstrap'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import fontStyles from '../../../../global-styles/variables.module.scss'
|
||||
import { useAsync } from 'react-use'
|
||||
import type { CodeProps } from '../../../components/markdown-renderer/replace-components/code-block-component-replacer'
|
||||
import { AsyncLoadingBoundary } from '../../../components/common/async-loading-boundary'
|
||||
import { ShowIf } from '../../../components/common/show-if/show-if'
|
||||
import { testId } from '../../../utils/test-id'
|
||||
import { useDarkModeState } from '../../../hooks/common/use-dark-mode-state'
|
||||
import { Logger } from '../../../utils/logger'
|
||||
|
||||
const log = new Logger('FlowChart')
|
||||
|
||||
/**
|
||||
* Renders a flowchart.
|
||||
*
|
||||
* @param code The code to render the flowchart.
|
||||
* @see https://flowchart.js.org/
|
||||
*/
|
||||
export const FlowChart: React.FC<CodeProps> = ({ code }) => {
|
||||
const diagramRef = useRef<HTMLDivElement>(null)
|
||||
const [syntaxError, setSyntaxError] = useState(false)
|
||||
const darkModeActivated = useDarkModeState()
|
||||
|
||||
useTranslation()
|
||||
|
||||
const {
|
||||
value: flowchartLib,
|
||||
loading,
|
||||
error: libLoadingError
|
||||
} = useAsync(async () => import(/* webpackChunkName: "flowchart.js" */ 'flowchart.js'), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (libLoadingError) {
|
||||
log.error('Error while loading flowchart.js', libLoadingError)
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (diagramRef.current === null || flowchartLib === undefined) {
|
||||
return
|
||||
}
|
||||
const currentDiagramRef = diagramRef.current
|
||||
try {
|
||||
const parserOutput = flowchartLib.parse(code)
|
||||
parserOutput.drawSVG(currentDiagramRef, {
|
||||
'line-width': 2,
|
||||
fill: 'none',
|
||||
'font-size': 16,
|
||||
'line-color': darkModeActivated ? '#ffffff' : '#000000',
|
||||
'element-color': darkModeActivated ? '#ffffff' : '#000000',
|
||||
'font-color': darkModeActivated ? '#ffffff' : '#000000',
|
||||
'font-family': fontStyles['font-family-base']
|
||||
})
|
||||
setSyntaxError(false)
|
||||
} catch (error) {
|
||||
log.error('Error while rendering flowchart', error)
|
||||
setSyntaxError(true)
|
||||
}
|
||||
|
||||
return () => {
|
||||
Array.from(currentDiagramRef.children).forEach((value) => value.remove())
|
||||
}
|
||||
}, [code, darkModeActivated, flowchartLib])
|
||||
|
||||
return (
|
||||
<AsyncLoadingBoundary loading={loading} componentName={'flowchart.js'} error={!!libLoadingError}>
|
||||
<ShowIf condition={syntaxError}>
|
||||
<Alert variant={'danger'}>
|
||||
<Trans i18nKey={'renderer.flowchart.invalidSyntax'} />
|
||||
</Alert>
|
||||
</ShowIf>
|
||||
<div ref={diagramRef} {...testId('flowchart')} className={'text-center'} />
|
||||
</AsyncLoadingBoundary>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { AppExtension } from '../../base/app-extension'
|
||||
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/base/markdown-renderer-extension'
|
||||
import { GistMarkdownExtension } from './gist-markdown-extension'
|
||||
|
||||
/**
|
||||
* Adds support for embeddings of GitHub Gists to the markdown renderer.
|
||||
*/
|
||||
export class GistAppExtension extends AppExtension {
|
||||
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
|
||||
return [new GistMarkdownExtension()]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
.gist-resizer-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
.gist-resizer {
|
||||
display: flex;
|
||||
width: 48px;
|
||||
height: 5px;
|
||||
background: white;
|
||||
border-radius: 90px;
|
||||
cursor: row-resize;
|
||||
box-shadow: black 0 0 3px;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react'
|
||||
import styles from './gist-frame.module.scss'
|
||||
import { useResizeGistFrame } from './use-resize-gist-frame'
|
||||
import type { IdProps } from '../../../components/markdown-renderer/replace-components/custom-tag-with-id-component-replacer'
|
||||
import { cypressId } from '../../../utils/cypress-attribute'
|
||||
import { ClickShield } from '../../../components/markdown-renderer/replace-components/click-shield/click-shield'
|
||||
|
||||
/**
|
||||
* This component renders a GitHub Gist by placing the gist URL in an {@link HTMLIFrameElement iframe}.
|
||||
*
|
||||
* @param id The id of the gist
|
||||
*/
|
||||
export const GistFrame: React.FC<IdProps> = ({ id }) => {
|
||||
const [frameHeight, onStartResizing] = useResizeGistFrame(150)
|
||||
|
||||
const onStart = useCallback(
|
||||
(event: React.MouseEvent | React.TouchEvent) => {
|
||||
onStartResizing(event)
|
||||
},
|
||||
[onStartResizing]
|
||||
)
|
||||
|
||||
return (
|
||||
<ClickShield
|
||||
fallbackBackgroundColor={'#161b22'}
|
||||
hoverIcon={'github'}
|
||||
targetDescription={'GitHub Gist'}
|
||||
data-cypress-id={'click-shield-gist'}>
|
||||
<iframe
|
||||
sandbox=''
|
||||
{...cypressId('gh-gist')}
|
||||
width='100%'
|
||||
height={`${frameHeight}px`}
|
||||
frameBorder='0'
|
||||
title={`gist ${id}`}
|
||||
src={`https://gist.github.com/${id}.pibb`}
|
||||
/>
|
||||
<span className={styles['gist-resizer-row']}>
|
||||
<span className={styles['gist-resizer']} onMouseDown={onStart} onTouchStart={onStart} />
|
||||
</span>
|
||||
</ClickShield>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import markdownItRegex from 'markdown-it-regex'
|
||||
import type MarkdownIt from 'markdown-it'
|
||||
import { replaceGistLink } from './replace-gist-link'
|
||||
import { replaceLegacyGistShortCode } from './replace-legacy-gist-short-code'
|
||||
import { GistFrame } from './gist-frame'
|
||||
import type { ComponentReplacer } from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/base/markdown-renderer-extension'
|
||||
import { CustomTagWithIdComponentReplacer } from '../../../components/markdown-renderer/replace-components/custom-tag-with-id-component-replacer'
|
||||
|
||||
/**
|
||||
* Adds support for embeddings of GitHub Gists by detecting gist links and the legacy gist shortcode.
|
||||
*/
|
||||
export class GistMarkdownExtension extends MarkdownRendererExtension {
|
||||
public static readonly tagName = 'app-gist'
|
||||
|
||||
public configureMarkdownIt(markdownIt: MarkdownIt): void {
|
||||
markdownItRegex(markdownIt, replaceGistLink)
|
||||
markdownItRegex(markdownIt, replaceLegacyGistShortCode)
|
||||
}
|
||||
|
||||
public buildReplacers(): ComponentReplacer[] {
|
||||
return [new CustomTagWithIdComponentReplacer(GistFrame, GistMarkdownExtension.tagName)]
|
||||
}
|
||||
|
||||
public buildTagNameAllowList(): string[] {
|
||||
return [GistMarkdownExtension.tagName]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { GistMarkdownExtension } from './gist-markdown-extension'
|
||||
import type { RegexOptions } from '../../../external-types/markdown-it-regex/interface'
|
||||
|
||||
const linkRegex = /^(?:https?:\/\/)?gist\.github\.com\/(\w+\/\w+)(?:[./?#].*)?$/i
|
||||
|
||||
/**
|
||||
* Replacer for gist links.
|
||||
*/
|
||||
export const replaceGistLink: RegexOptions = {
|
||||
name: 'gist-link',
|
||||
regex: linkRegex,
|
||||
replace: (match) => {
|
||||
// ESLint wants to collapse this tag, but then the tag won't be valid html anymore.
|
||||
// noinspection CheckTagEmptyBody
|
||||
return `<${GistMarkdownExtension.tagName} id='${match}'></${GistMarkdownExtension.tagName}>`
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { GistMarkdownExtension } from './gist-markdown-extension'
|
||||
import type { RegexOptions } from '../../../external-types/markdown-it-regex/interface'
|
||||
|
||||
const finalRegex = /^{%gist\s+(\w+\/\w+)\s*%}$/
|
||||
|
||||
/**
|
||||
* Replacer for legacy hedgedoc 1 gist shortcodes.
|
||||
*/
|
||||
export const replaceLegacyGistShortCode: RegexOptions = {
|
||||
name: 'legacy-gist-short-code',
|
||||
regex: finalRegex,
|
||||
replace: (match) => {
|
||||
// ESLint wants to collapse this tag, but then the tag won't be valid html anymore.
|
||||
// noinspection CheckTagEmptyBody
|
||||
return `<${GistMarkdownExtension.tagName} id="${match}"></${GistMarkdownExtension.tagName}>`
|
||||
}
|
||||
}
|
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type React from 'react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useBindPointerMovementEventOnWindow } from '../../../hooks/common/use-bind-pointer-movement-event-on-window'
|
||||
|
||||
/**
|
||||
* Determines if the left mouse button is pressed in the given event
|
||||
*
|
||||
* @param mouseEvent the mouse event that should be checked
|
||||
* @return {@link true} if the left mouse button is pressed. {@link false} otherwise.
|
||||
*/
|
||||
const isLeftMouseButtonPressed = (mouseEvent: MouseEvent): boolean => {
|
||||
return mouseEvent.buttons === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the absolute vertical position of the mouse or touch point from the event.
|
||||
*
|
||||
* @param moveEvent the vertical position of the mouse pointer or the first touch pointer.
|
||||
* @return the extracted vertical position.
|
||||
*/
|
||||
const extractVerticalPointerPosition = (
|
||||
moveEvent: React.MouseEvent | React.TouchEvent | MouseEvent | TouchEvent
|
||||
): number => {
|
||||
if (isMouseEvent(moveEvent)) {
|
||||
return moveEvent.pageY
|
||||
} else {
|
||||
return moveEvent.touches[0]?.pageY
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given {@link Event} is a {@link MouseEvent} or a {@link React.MouseEvent}.
|
||||
*
|
||||
* @param event the event to check
|
||||
* @return {@link true} if the given event is a {@link MouseEvent} or a {@link React.MouseEvent}
|
||||
*/
|
||||
const isMouseEvent = (event: Event | React.UIEvent): event is MouseEvent | React.MouseEvent => {
|
||||
return (event as MouseEvent).buttons !== undefined
|
||||
}
|
||||
|
||||
export type PointerEvent = React.MouseEvent | React.TouchEvent
|
||||
export type PointerEventHandler = (event: PointerEvent) => void
|
||||
|
||||
/**
|
||||
* Provides logic for resizing a {@link GistFrame gist frame} by dragging an element.
|
||||
*
|
||||
* @param initialFrameHeight The initial size for the frame
|
||||
* @return An array containing the current frame height and function to start the resizing
|
||||
*/
|
||||
export const useResizeGistFrame = (initialFrameHeight: number): [number, PointerEventHandler] => {
|
||||
const [frameHeight, setFrameHeight] = useState(initialFrameHeight)
|
||||
const lastYPosition = useRef<number | undefined>(undefined)
|
||||
|
||||
const onMove = useCallback((moveEvent: MouseEvent | TouchEvent) => {
|
||||
if (lastYPosition.current === undefined) {
|
||||
return
|
||||
}
|
||||
if (isMouseEvent(moveEvent) && !isLeftMouseButtonPressed(moveEvent)) {
|
||||
lastYPosition.current = undefined
|
||||
moveEvent.preventDefault()
|
||||
return undefined
|
||||
}
|
||||
|
||||
const currentPointerPosition = extractVerticalPointerPosition(moveEvent)
|
||||
const deltaPointerPosition = currentPointerPosition - lastYPosition.current
|
||||
lastYPosition.current = currentPointerPosition
|
||||
setFrameHeight((oldFrameHeight) => Math.max(0, oldFrameHeight + deltaPointerPosition))
|
||||
moveEvent.preventDefault()
|
||||
}, [])
|
||||
|
||||
const onStartResizing = useCallback((event: React.MouseEvent | React.TouchEvent) => {
|
||||
lastYPosition.current = extractVerticalPointerPosition(event)
|
||||
}, [])
|
||||
|
||||
const onStopResizing = useCallback(() => {
|
||||
if (lastYPosition.current !== undefined) {
|
||||
lastYPosition.current = undefined
|
||||
}
|
||||
}, [])
|
||||
|
||||
useBindPointerMovementEventOnWindow(onMove, onStopResizing)
|
||||
|
||||
return [frameHeight, onStartResizing]
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`PlantUML markdown extensions renders a plantuml codeblock 1`] = `
|
||||
<div>
|
||||
<pre>
|
||||
<span>
|
||||
this is a mock for graphviz frame
|
||||
<code>
|
||||
graph {
|
||||
a -- b
|
||||
}
|
||||
|
||||
</code>
|
||||
</span>
|
||||
</pre>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { AppExtension } from '../../base/app-extension'
|
||||
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/base/markdown-renderer-extension'
|
||||
import { GraphvizMarkdownExtension } from './graphviz-markdown-extension'
|
||||
|
||||
/**
|
||||
* Adds support for graphviz to the markdown rendering.
|
||||
*/
|
||||
export class GraphvizAppExtension extends AppExtension {
|
||||
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
|
||||
return [new GraphvizMarkdownExtension()]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Alert } from 'react-bootstrap'
|
||||
import { useRouter } from 'next/router'
|
||||
import type { CodeProps } from '../../../components/markdown-renderer/replace-components/code-block-component-replacer'
|
||||
import { cypressId } from '../../../utils/cypress-attribute'
|
||||
import { ShowIf } from '../../../components/common/show-if/show-if'
|
||||
import { Logger } from '../../../utils/logger'
|
||||
import { useAsync } from 'react-use'
|
||||
import { AsyncLoadingBoundary } from '../../../components/common/async-loading-boundary'
|
||||
|
||||
const log = new Logger('GraphvizFrame')
|
||||
/**
|
||||
* Renders a graphviz diagram.
|
||||
*
|
||||
* @param code The code for the diagram
|
||||
* @see https://graphviz.org/
|
||||
*/
|
||||
export const GraphvizFrame: React.FC<CodeProps> = ({ code }) => {
|
||||
const container = useRef<HTMLDivElement>(null)
|
||||
const [error, setError] = useState<string>()
|
||||
|
||||
const { basePath } = useRouter()
|
||||
|
||||
const {
|
||||
value: graphvizImport,
|
||||
error: libLoadingError,
|
||||
loading: isLibLoading
|
||||
} = useAsync(
|
||||
async () =>
|
||||
import(/* webpackChunkName: "d3-graphviz" */ '@hpcc-js/wasm')
|
||||
.then((wasmPlugin) => {
|
||||
wasmPlugin.wasmFolder(`${basePath}/_next/static/js`)
|
||||
})
|
||||
.then(() => import(/* webpackChunkName: "d3-graphviz" */ 'd3-graphviz')),
|
||||
[]
|
||||
)
|
||||
|
||||
const showError = useCallback((error: string) => {
|
||||
if (!container.current) {
|
||||
return
|
||||
}
|
||||
setError(error)
|
||||
log.error(error)
|
||||
container.current.querySelectorAll('svg').forEach((child) => child.remove())
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!container.current || !graphvizImport) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setError(undefined)
|
||||
graphvizImport
|
||||
.graphviz(container.current, {
|
||||
useWorker: false,
|
||||
zoom: false
|
||||
})
|
||||
.onerror(showError)
|
||||
.renderDot(code)
|
||||
} catch (error) {
|
||||
showError(error as string)
|
||||
}
|
||||
}, [code, basePath, showError, graphvizImport])
|
||||
|
||||
return (
|
||||
<AsyncLoadingBoundary loading={isLibLoading} componentName={'graphviz'} error={libLoadingError}>
|
||||
<ShowIf condition={!!error}>
|
||||
<Alert variant={'warning'}>{error}</Alert>
|
||||
</ShowIf>
|
||||
<div className={'svg-container'} {...cypressId('graphviz')} ref={container} />
|
||||
</AsyncLoadingBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
export default GraphvizFrame
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { render } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import { GraphvizMarkdownExtension } from './graphviz-markdown-extension'
|
||||
import * as GraphvizFrameModule from '../graphviz/graphviz-frame'
|
||||
import { mockI18n } from '../../../components/markdown-renderer/test-utils/mock-i18n'
|
||||
import type { CodeProps } from '../../../components/markdown-renderer/replace-components/code-block-component-replacer'
|
||||
import { TestMarkdownRenderer } from '../../../components/markdown-renderer/test-utils/test-markdown-renderer'
|
||||
|
||||
jest.mock('../graphviz/graphviz-frame')
|
||||
|
||||
describe('PlantUML markdown extensions', () => {
|
||||
beforeAll(async () => {
|
||||
jest.spyOn(GraphvizFrameModule, 'GraphvizFrame').mockImplementation((({ code }) => {
|
||||
return (
|
||||
<span>
|
||||
this is a mock for graphviz frame
|
||||
<code>{code}</code>
|
||||
</span>
|
||||
)
|
||||
}) as React.FC<CodeProps>)
|
||||
await mockI18n()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.resetModules()
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('renders a plantuml codeblock', () => {
|
||||
const view = render(
|
||||
<TestMarkdownRenderer
|
||||
extensions={[new GraphvizMarkdownExtension()]}
|
||||
content={'```graphviz\ngraph {\na -- b\n}\n```'}
|
||||
/>
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
})
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { GraphvizFrame } from './graphviz-frame'
|
||||
import { CodeBlockMarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/base/code-block-markdown-extension/code-block-markdown-renderer-extension'
|
||||
import { CodeBlockComponentReplacer } from '../../../components/markdown-renderer/replace-components/code-block-component-replacer'
|
||||
|
||||
/**
|
||||
* Adds support for graphviz to the markdown rendering using code fences with "graphviz" as language.
|
||||
*/
|
||||
export class GraphvizMarkdownExtension extends CodeBlockMarkdownRendererExtension {
|
||||
public buildReplacers(): CodeBlockComponentReplacer[] {
|
||||
return [new CodeBlockComponentReplacer(GraphvizFrame, 'graphviz')]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,271 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Highlighted code markdown extension renders with just the language and line wrapping doesn't show a gutter 1`] = `
|
||||
<div>
|
||||
<pre>
|
||||
<span>
|
||||
this is a mock for highlighted code
|
||||
<code>
|
||||
let x = 0
|
||||
|
||||
</code>
|
||||
<span>
|
||||
language:
|
||||
javascript
|
||||
</span>
|
||||
<span>
|
||||
start line number:
|
||||
</span>
|
||||
<span>
|
||||
wrap line:
|
||||
true
|
||||
</span>
|
||||
</span>
|
||||
</pre>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Highlighted code markdown extension renders with just the language doesn't show a gutter 1`] = `
|
||||
<div>
|
||||
<pre>
|
||||
<span>
|
||||
this is a mock for highlighted code
|
||||
<code>
|
||||
let x = 0
|
||||
|
||||
</code>
|
||||
<span>
|
||||
language:
|
||||
javascript
|
||||
</span>
|
||||
<span>
|
||||
start line number:
|
||||
</span>
|
||||
<span>
|
||||
wrap line:
|
||||
false
|
||||
</span>
|
||||
</span>
|
||||
</pre>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Highlighted code markdown extension renders with the language and show gutter and line wrapping shows the correct line number 1`] = `
|
||||
<div>
|
||||
<pre>
|
||||
<span>
|
||||
this is a mock for highlighted code
|
||||
<code>
|
||||
let x = 0
|
||||
|
||||
</code>
|
||||
<span>
|
||||
language:
|
||||
javascript
|
||||
</span>
|
||||
<span>
|
||||
start line number:
|
||||
1
|
||||
</span>
|
||||
<span>
|
||||
wrap line:
|
||||
true
|
||||
</span>
|
||||
</span>
|
||||
</pre>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Highlighted code markdown extension renders with the language and show gutter shows the correct line number 1`] = `
|
||||
<div>
|
||||
<pre>
|
||||
<span>
|
||||
this is a mock for highlighted code
|
||||
<code>
|
||||
let x = 0
|
||||
|
||||
</code>
|
||||
<span>
|
||||
language:
|
||||
javascript
|
||||
</span>
|
||||
<span>
|
||||
start line number:
|
||||
1
|
||||
</span>
|
||||
<span>
|
||||
wrap line:
|
||||
false
|
||||
</span>
|
||||
</span>
|
||||
</pre>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Highlighted code markdown extension renders with the language, show gutter with a start number and line wrapping shows the correct line number 1`] = `
|
||||
<div>
|
||||
<pre>
|
||||
<span>
|
||||
this is a mock for highlighted code
|
||||
<code>
|
||||
let x = 0
|
||||
|
||||
</code>
|
||||
<span>
|
||||
language:
|
||||
javascript
|
||||
</span>
|
||||
<span>
|
||||
start line number:
|
||||
100
|
||||
</span>
|
||||
<span>
|
||||
wrap line:
|
||||
true
|
||||
</span>
|
||||
</span>
|
||||
</pre>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Highlighted code markdown extension renders with the language, show gutter with a start number and line wrapping shows the correct line number and continues in another codeblock 1`] = `
|
||||
<div>
|
||||
<pre>
|
||||
<span>
|
||||
this is a mock for highlighted code
|
||||
<code>
|
||||
let x = 0
|
||||
let y = 1
|
||||
|
||||
</code>
|
||||
<span>
|
||||
language:
|
||||
javascript
|
||||
</span>
|
||||
<span>
|
||||
start line number:
|
||||
100
|
||||
</span>
|
||||
<span>
|
||||
wrap line:
|
||||
true
|
||||
</span>
|
||||
</span>
|
||||
</pre>
|
||||
|
||||
|
||||
<pre>
|
||||
<span>
|
||||
this is a mock for highlighted code
|
||||
<code>
|
||||
let y = 2
|
||||
|
||||
</code>
|
||||
<span>
|
||||
language:
|
||||
javascript
|
||||
</span>
|
||||
<span>
|
||||
start line number:
|
||||
102
|
||||
</span>
|
||||
<span>
|
||||
wrap line:
|
||||
false
|
||||
</span>
|
||||
</span>
|
||||
</pre>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Highlighted code markdown extension renders with the language, show gutter with a start number shows the correct line number 1`] = `
|
||||
<div>
|
||||
<pre>
|
||||
<span>
|
||||
this is a mock for highlighted code
|
||||
<code>
|
||||
let x = 0
|
||||
|
||||
</code>
|
||||
<span>
|
||||
language:
|
||||
javascript
|
||||
</span>
|
||||
<span>
|
||||
start line number:
|
||||
100
|
||||
</span>
|
||||
<span>
|
||||
wrap line:
|
||||
false
|
||||
</span>
|
||||
</span>
|
||||
</pre>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Highlighted code markdown extension renders with the language, show gutter with a start number shows the correct line number and continues in another codeblock 1`] = `
|
||||
<div>
|
||||
<pre>
|
||||
<span>
|
||||
this is a mock for highlighted code
|
||||
<code>
|
||||
let x = 0
|
||||
let y = 1
|
||||
|
||||
</code>
|
||||
<span>
|
||||
language:
|
||||
javascript
|
||||
</span>
|
||||
<span>
|
||||
start line number:
|
||||
100
|
||||
</span>
|
||||
<span>
|
||||
wrap line:
|
||||
false
|
||||
</span>
|
||||
</span>
|
||||
</pre>
|
||||
|
||||
|
||||
<pre>
|
||||
<span>
|
||||
this is a mock for highlighted code
|
||||
<code>
|
||||
let y = 2
|
||||
|
||||
</code>
|
||||
<span>
|
||||
language:
|
||||
javascript
|
||||
</span>
|
||||
<span>
|
||||
start line number:
|
||||
102
|
||||
</span>
|
||||
<span>
|
||||
wrap line:
|
||||
false
|
||||
</span>
|
||||
</span>
|
||||
</pre>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,133 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Highlighted Code can hide the line numbers 1`] = `
|
||||
<code
|
||||
class="hljs wrapLines"
|
||||
data-testid="code-highlighter"
|
||||
>
|
||||
<span
|
||||
class="linenumber"
|
||||
>
|
||||
1
|
||||
</span>
|
||||
<div
|
||||
class="codeline"
|
||||
>
|
||||
<span>
|
||||
const a = 1
|
||||
</span>
|
||||
</div>
|
||||
</code>
|
||||
`;
|
||||
|
||||
exports[`Highlighted Code highlights code 1`] = `
|
||||
<code
|
||||
class="hljs showGutter "
|
||||
data-testid="code-highlighter"
|
||||
>
|
||||
<span
|
||||
class="linenumber"
|
||||
>
|
||||
1
|
||||
</span>
|
||||
<div
|
||||
class="codeline"
|
||||
>
|
||||
<span
|
||||
class="hljs-keyword"
|
||||
>
|
||||
const
|
||||
</span>
|
||||
a =
|
||||
<span
|
||||
class="hljs-number"
|
||||
>
|
||||
1
|
||||
</span>
|
||||
</div>
|
||||
</code>
|
||||
`;
|
||||
|
||||
exports[`Highlighted Code renders plain text 1`] = `
|
||||
<code
|
||||
class="hljs showGutter "
|
||||
data-testid="code-highlighter"
|
||||
>
|
||||
<span
|
||||
class="linenumber"
|
||||
>
|
||||
1
|
||||
</span>
|
||||
<div
|
||||
class="codeline"
|
||||
>
|
||||
<span>
|
||||
a
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="linenumber"
|
||||
>
|
||||
2
|
||||
</span>
|
||||
<div
|
||||
class="codeline"
|
||||
>
|
||||
<span>
|
||||
b
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="linenumber"
|
||||
>
|
||||
3
|
||||
</span>
|
||||
<div
|
||||
class="codeline"
|
||||
>
|
||||
<span>
|
||||
c
|
||||
</span>
|
||||
</div>
|
||||
</code>
|
||||
`;
|
||||
|
||||
exports[`Highlighted Code starts with a specific line 1`] = `
|
||||
<code
|
||||
class="hljs showGutter wrapLines"
|
||||
data-testid="code-highlighter"
|
||||
>
|
||||
<span
|
||||
class="linenumber"
|
||||
>
|
||||
100
|
||||
</span>
|
||||
<div
|
||||
class="codeline"
|
||||
>
|
||||
<span>
|
||||
const a = 1
|
||||
</span>
|
||||
</div>
|
||||
</code>
|
||||
`;
|
||||
|
||||
exports[`Highlighted Code wraps code 1`] = `
|
||||
<code
|
||||
class="hljs showGutter wrapLines"
|
||||
data-testid="code-highlighter"
|
||||
>
|
||||
<span
|
||||
class="linenumber"
|
||||
>
|
||||
1
|
||||
</span>
|
||||
<div
|
||||
class="codeline"
|
||||
>
|
||||
<span>
|
||||
const a = 1
|
||||
</span>
|
||||
</div>
|
||||
</code>
|
||||
`;
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { AppExtension } from '../../base/app-extension'
|
||||
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/base/markdown-renderer-extension'
|
||||
import { HighlightedCodeMarkdownExtension } from './highlighted-code-markdown-extension'
|
||||
|
||||
/**
|
||||
* Adds code highlighting to the markdown rendering.
|
||||
*/
|
||||
export class HighlightedCodeFenceAppExtension extends AppExtension {
|
||||
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
|
||||
return [new HighlightedCodeMarkdownExtension()]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,131 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { render } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import type { HighlightedCodeProps } from './highlighted-code'
|
||||
import * as HighlightedCodeModule from './highlighted-code'
|
||||
import { HighlightedCodeMarkdownExtension } from './highlighted-code-markdown-extension'
|
||||
import { mockI18n } from '../../../components/markdown-renderer/test-utils/mock-i18n'
|
||||
import { TestMarkdownRenderer } from '../../../components/markdown-renderer/test-utils/test-markdown-renderer'
|
||||
|
||||
jest.mock('./highlighted-code')
|
||||
|
||||
describe('Highlighted code markdown extension', () => {
|
||||
describe('renders', () => {
|
||||
beforeAll(async () => {
|
||||
jest.spyOn(HighlightedCodeModule, 'HighlightedCode').mockImplementation((({
|
||||
code,
|
||||
language,
|
||||
startLineNumber,
|
||||
wrapLines
|
||||
}) => {
|
||||
return (
|
||||
<span>
|
||||
this is a mock for highlighted code
|
||||
<code>{code}</code>
|
||||
<span>language: {language}</span>
|
||||
<span>start line number: {startLineNumber}</span>
|
||||
<span>wrap line: {wrapLines ? 'true' : 'false'}</span>
|
||||
</span>
|
||||
)
|
||||
}) as React.FC<HighlightedCodeProps>)
|
||||
await mockI18n()
|
||||
})
|
||||
|
||||
describe('with just the language', () => {
|
||||
it("doesn't show a gutter", () => {
|
||||
const view = render(
|
||||
<TestMarkdownRenderer
|
||||
extensions={[new HighlightedCodeMarkdownExtension()]}
|
||||
content={'```javascript \nlet x = 0\n```'}
|
||||
/>
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
describe('and line wrapping', () => {
|
||||
it("doesn't show a gutter", () => {
|
||||
const view = render(
|
||||
<TestMarkdownRenderer
|
||||
extensions={[new HighlightedCodeMarkdownExtension()]}
|
||||
content={'```javascript! \nlet x = 0\n```'}
|
||||
/>
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with the language and show gutter', () => {
|
||||
it('shows the correct line number', () => {
|
||||
const view = render(
|
||||
<TestMarkdownRenderer
|
||||
extensions={[new HighlightedCodeMarkdownExtension()]}
|
||||
content={'```javascript= \nlet x = 0\n```'}
|
||||
/>
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
describe('and line wrapping', () => {
|
||||
it('shows the correct line number', () => {
|
||||
const view = render(
|
||||
<TestMarkdownRenderer
|
||||
extensions={[new HighlightedCodeMarkdownExtension()]}
|
||||
content={'```javascript=! \nlet x = 0\n```'}
|
||||
/>
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with the language, show gutter with a start number', () => {
|
||||
it('shows the correct line number', () => {
|
||||
const view = render(
|
||||
<TestMarkdownRenderer
|
||||
extensions={[new HighlightedCodeMarkdownExtension()]}
|
||||
content={'```javascript=100 \nlet x = 0\n```'}
|
||||
/>
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('shows the correct line number and continues in another codeblock', () => {
|
||||
const view = render(
|
||||
<TestMarkdownRenderer
|
||||
extensions={[new HighlightedCodeMarkdownExtension()]}
|
||||
content={'```javascript=100 \nlet x = 0\nlet y = 1\n```\n\n```javascript=+\nlet y = 2\n```\n'}
|
||||
/>
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
describe('and line wrapping', () => {
|
||||
it('shows the correct line number', () => {
|
||||
const view = render(
|
||||
<TestMarkdownRenderer
|
||||
extensions={[new HighlightedCodeMarkdownExtension()]}
|
||||
content={'```javascript=100! \nlet x = 0\n```'}
|
||||
/>
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('shows the correct line number and continues in another codeblock', () => {
|
||||
const view = render(
|
||||
<TestMarkdownRenderer
|
||||
extensions={[new HighlightedCodeMarkdownExtension()]}
|
||||
content={'```javascript=100! \nlet x = 0\nlet y = 1\n```\n\n```javascript=+\nlet y = 2\n```\n'}
|
||||
/>
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { HighlightedCodeReplacer } from './highlighted-code-replacer'
|
||||
import { CodeBlockMarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/base/code-block-markdown-extension/code-block-markdown-renderer-extension'
|
||||
import type { ComponentReplacer } from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
|
||||
/**
|
||||
* Adds code highlighting to the markdown rendering.
|
||||
* Every code fence that is not replaced by another replacer is highlighted using highlight-js.
|
||||
*/
|
||||
export class HighlightedCodeMarkdownExtension extends CodeBlockMarkdownRendererExtension {
|
||||
public buildReplacers(): ComponentReplacer[] {
|
||||
return [new HighlightedCodeReplacer()]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Element } from 'domhandler'
|
||||
import React from 'react'
|
||||
import {
|
||||
ComponentReplacer,
|
||||
DO_NOT_REPLACE
|
||||
} from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import type { NodeReplacement } from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import { HighlightedCode } from './highlighted-code'
|
||||
|
||||
/**
|
||||
* Detects code blocks and renders them as highlighted code blocks
|
||||
*/
|
||||
export class HighlightedCodeReplacer extends ComponentReplacer {
|
||||
private lastLineNumber = 0
|
||||
|
||||
private static extractCode(codeNode: Element): string | undefined {
|
||||
return codeNode.name === 'code' && !!codeNode.attribs['data-highlight-language'] && !!codeNode.children[0]
|
||||
? ComponentReplacer.extractTextChildContent(codeNode)
|
||||
: undefined
|
||||
}
|
||||
|
||||
public replace(codeNode: Element): NodeReplacement {
|
||||
const code = HighlightedCodeReplacer.extractCode(codeNode)
|
||||
if (!code) {
|
||||
return DO_NOT_REPLACE
|
||||
}
|
||||
|
||||
const language = codeNode.attribs['data-highlight-language']
|
||||
const extraData = codeNode.attribs['data-extra']
|
||||
const extraInfos = /(=(\d+|\+)?)?(!?)/.exec(extraData)
|
||||
const showLineNumbers = extraInfos ? extraInfos[1]?.startsWith('=') : false
|
||||
const startLineNumberAttribute = extraInfos?.[2] ?? ''
|
||||
const wrapLines = extraInfos?.[3] === '!'
|
||||
const startLineNumber =
|
||||
startLineNumberAttribute === '+' ? this.lastLineNumber : parseInt(startLineNumberAttribute) || 1
|
||||
|
||||
if (showLineNumbers) {
|
||||
this.lastLineNumber = startLineNumber + code.split('\n').filter((line) => !!line).length
|
||||
}
|
||||
|
||||
return (
|
||||
<HighlightedCode
|
||||
language={language}
|
||||
startLineNumber={showLineNumbers ? startLineNumber : undefined}
|
||||
wrapLines={wrapLines}
|
||||
code={code}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.lastLineNumber = 0
|
||||
}
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
.code-highlighter {
|
||||
position: relative;
|
||||
|
||||
:global(code.hljs) {
|
||||
overflow-x: auto;
|
||||
background-color: rgba(27, 31, 35, .05);
|
||||
padding: 16px;
|
||||
display: grid !important;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
|
||||
:global(body.dark) & {
|
||||
background-color: rgb(27, 31, 35);
|
||||
}
|
||||
|
||||
.codeline {
|
||||
grid-column: 2;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.linenumber {
|
||||
grid-column: 1;
|
||||
position: relative;
|
||||
cursor: default;
|
||||
z-index: 4;
|
||||
padding: 0 8px 0 0;
|
||||
min-width: 20px;
|
||||
box-sizing: content-box;
|
||||
color: #afafaf;
|
||||
border-right: 3px solid #6ce26c;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
align-items: flex-end;
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.showGutter {
|
||||
.linenumber {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.codeline {
|
||||
margin: 0 0 0 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&.wrapLines .codeline {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import HighlightedCode from './highlighted-code'
|
||||
import { mockI18n } from '../../../components/markdown-renderer/test-utils/mock-i18n'
|
||||
|
||||
describe('Highlighted Code', () => {
|
||||
beforeAll(() => mockI18n())
|
||||
|
||||
it('renders plain text', async () => {
|
||||
render(<HighlightedCode code={'a\nb\nc'} startLineNumber={1} language={''} wrapLines={false}></HighlightedCode>)
|
||||
expect(await screen.findByTestId('code-highlighter')).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('highlights code', async () => {
|
||||
render(
|
||||
<HighlightedCode
|
||||
code={'const a = 1'}
|
||||
language={'typescript'}
|
||||
startLineNumber={1}
|
||||
wrapLines={false}></HighlightedCode>
|
||||
)
|
||||
expect(await screen.findByTestId('code-highlighter')).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('wraps code', async () => {
|
||||
render(<HighlightedCode code={'const a = 1'} wrapLines={true} startLineNumber={1} language={''}></HighlightedCode>)
|
||||
expect(await screen.findByTestId('code-highlighter')).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('starts with a specific line', async () => {
|
||||
render(
|
||||
<HighlightedCode code={'const a = 1'} startLineNumber={100} language={''} wrapLines={true}></HighlightedCode>
|
||||
)
|
||||
expect(await screen.findByTestId('code-highlighter')).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('can hide the line numbers', async () => {
|
||||
render(
|
||||
<HighlightedCode
|
||||
code={'const a = 1'}
|
||||
startLineNumber={undefined}
|
||||
language={''}
|
||||
wrapLines={true}></HighlightedCode>
|
||||
)
|
||||
expect(await screen.findByTestId('code-highlighter')).toMatchSnapshot()
|
||||
})
|
||||
})
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import styles from './highlighted-code.module.scss'
|
||||
import { useAsyncHighlightJsImport } from './hooks/use-async-highlight-js-import'
|
||||
import { useAttachLineNumbers } from './hooks/use-attach-line-numbers'
|
||||
import { useCodeDom } from './hooks/use-code-dom'
|
||||
import { cypressAttribute, cypressId } from '../../../utils/cypress-attribute'
|
||||
import { AsyncLoadingBoundary } from '../../../components/common/async-loading-boundary'
|
||||
import { CopyToClipboardButton } from '../../../components/common/copyable/copy-to-clipboard-button/copy-to-clipboard-button'
|
||||
import { testId } from '../../../utils/test-id'
|
||||
|
||||
export interface HighlightedCodeProps {
|
||||
code: string
|
||||
language?: string
|
||||
startLineNumber?: number
|
||||
wrapLines: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the given code as highlighted code block.
|
||||
*
|
||||
* @param code The code to highlight
|
||||
* @param language The language that should be used for highlighting
|
||||
* @param startLineNumber The number of the first line in the block. Will be 1 if omitted.
|
||||
* @param wrapLines Defines if lines should be wrapped or if the block should show a scroll bar.
|
||||
* @see https://highlightjs.org/
|
||||
*/
|
||||
export const HighlightedCode: React.FC<HighlightedCodeProps> = ({ code, language, startLineNumber, wrapLines }) => {
|
||||
const showGutter = startLineNumber !== undefined
|
||||
const { value: hljsApi, loading, error } = useAsyncHighlightJsImport()
|
||||
const codeDom = useCodeDom(code, hljsApi, language)
|
||||
const wrappedDomLines = useAttachLineNumbers(codeDom, startLineNumber)
|
||||
|
||||
return (
|
||||
<AsyncLoadingBoundary loading={loading} error={!!error} componentName={'highlight.js'}>
|
||||
<div className={styles['code-highlighter']} {...cypressId('highlighted-code-block')}>
|
||||
<code
|
||||
{...testId('code-highlighter')}
|
||||
{...cypressId('code-highlighter')}
|
||||
{...cypressAttribute('showgutter', showGutter ? 'true' : 'false')}
|
||||
{...cypressAttribute('wraplines', wrapLines ? 'true' : 'false')}
|
||||
className={`hljs ${showGutter ? styles['showGutter'] : ''} ${wrapLines ? styles['wrapLines'] : ''}`}>
|
||||
{wrappedDomLines}
|
||||
</code>
|
||||
<div className={'text-right button-inside'}>
|
||||
<CopyToClipboardButton content={code} {...cypressId('copy-code-button')} />
|
||||
</div>
|
||||
</div>
|
||||
</AsyncLoadingBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
export default HighlightedCode
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useAsync } from 'react-use'
|
||||
import type { AsyncState } from 'react-use/lib/useAsyncFn'
|
||||
import type { HLJSApi } from 'highlight.js'
|
||||
import { Logger } from '../../../../utils/logger'
|
||||
|
||||
const log = new Logger('HighlightedCode')
|
||||
|
||||
/**
|
||||
* Lazy loads the highlight js library.
|
||||
*
|
||||
* @return the loaded js lib
|
||||
*/
|
||||
export const useAsyncHighlightJsImport = (): AsyncState<HLJSApi> => {
|
||||
return useAsync(async () => {
|
||||
try {
|
||||
return (await import(/* webpackChunkName: "highlight.js" */ '../preconfigured-highlight-js')).default
|
||||
} catch (error) {
|
||||
log.error('Error while loading highlight.js', error)
|
||||
throw error
|
||||
}
|
||||
}, [])
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { ReactElement } from 'react'
|
||||
import { Fragment, useMemo } from 'react'
|
||||
import styles from '../highlighted-code.module.scss'
|
||||
import { cypressId } from '../../../../utils/cypress-attribute'
|
||||
|
||||
/**
|
||||
* Wraps the given {@link ReactElement elements} to attach line numbers to them.
|
||||
*
|
||||
* @param lines The elements to wrap
|
||||
* @param startLineNumber The line number to start with. Will default to 1 if omitted.
|
||||
*/
|
||||
export const useAttachLineNumbers = (
|
||||
lines: undefined | ReactElement[],
|
||||
startLineNumber = 1
|
||||
): undefined | ReactElement[] =>
|
||||
useMemo(
|
||||
() =>
|
||||
lines?.map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
<span {...cypressId('linenumber')} className={styles['linenumber']}>
|
||||
{startLineNumber + index}
|
||||
</span>
|
||||
<div {...cypressId('codeline')} className={styles['codeline']}>
|
||||
{line}
|
||||
</div>
|
||||
</Fragment>
|
||||
)),
|
||||
[startLineNumber, lines]
|
||||
)
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { ReactElement } from 'react'
|
||||
import React, { Fragment, useMemo } from 'react'
|
||||
import convertHtmlToReact from '@hedgedoc/html-to-react'
|
||||
import type { HLJSApi } from 'highlight.js'
|
||||
import { sanitize } from 'dompurify'
|
||||
|
||||
/**
|
||||
* Highlights the given code using highlight.js. If the language wasn't recognized then it won't be highlighted.
|
||||
*
|
||||
* @param code The code to highlight
|
||||
* @param hljs The highlight.js API. Needs to be imported or lazy loaded.
|
||||
* @param language The language of the code to use for highlighting
|
||||
* @return The react elements that represent the highlighted code
|
||||
*/
|
||||
export const useCodeDom = (code: string, hljs: HLJSApi | undefined, language?: string): ReactElement[] | undefined => {
|
||||
return useMemo(() => {
|
||||
if (!hljs) {
|
||||
return
|
||||
}
|
||||
if (!!language && hljs.listLanguages().includes(language)) {
|
||||
const highlightedHtml = hljs.highlight(code, { language }).value
|
||||
return createHtmlLinesToReactDOM(omitNewLineAtEnd(highlightedHtml).split('\n'))
|
||||
} else {
|
||||
return createPlaintextToReactDOM(code)
|
||||
}
|
||||
}, [code, hljs, language])
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the given html code to react elements without any custom transformation but with sanitizing.
|
||||
*
|
||||
* @param code The code to convert
|
||||
* @return the code represented as react elements
|
||||
*/
|
||||
const createHtmlLinesToReactDOM = (code: string[]): ReactElement[] => {
|
||||
return code.map((line, lineIndex) => <Fragment key={lineIndex}>{convertHtmlToReact(sanitize(line))}</Fragment>)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the given line based text to plain text react elements but without interpreting them as html first.
|
||||
*
|
||||
* @param text The text to convert
|
||||
* @return the text represented as react elements.
|
||||
*/
|
||||
const createPlaintextToReactDOM = (text: string): ReactElement[] => {
|
||||
return text.split('\n').map((line, lineIndex) => React.createElement('span', { key: lineIndex }, line))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the given code but without the last new line if the string ends with a new line.
|
||||
*
|
||||
* @param code The code to inspect
|
||||
* @return the modified code
|
||||
*/
|
||||
const omitNewLineAtEnd = (code: string): string => {
|
||||
if (code.endsWith('\n')) {
|
||||
return code.slice(0, -1)
|
||||
} else {
|
||||
return code
|
||||
}
|
||||
}
|
|
@ -0,0 +1,389 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import hljs from 'highlight.js/lib/core'
|
||||
import abnf from 'highlight.js/lib/languages/abnf'
|
||||
import accesslog from 'highlight.js/lib/languages/accesslog'
|
||||
import actionscript from 'highlight.js/lib/languages/actionscript'
|
||||
import ada from 'highlight.js/lib/languages/ada'
|
||||
import angelscript from 'highlight.js/lib/languages/angelscript'
|
||||
import apache from 'highlight.js/lib/languages/apache'
|
||||
import applescript from 'highlight.js/lib/languages/applescript'
|
||||
import arcade from 'highlight.js/lib/languages/arcade'
|
||||
import arduino from 'highlight.js/lib/languages/arduino'
|
||||
import armasm from 'highlight.js/lib/languages/armasm'
|
||||
import xml from 'highlight.js/lib/languages/xml'
|
||||
import asciidoc from 'highlight.js/lib/languages/asciidoc'
|
||||
import aspectj from 'highlight.js/lib/languages/aspectj'
|
||||
import autohotkey from 'highlight.js/lib/languages/autohotkey'
|
||||
import autoit from 'highlight.js/lib/languages/autoit'
|
||||
import avrasm from 'highlight.js/lib/languages/avrasm'
|
||||
import awk from 'highlight.js/lib/languages/awk'
|
||||
import axapta from 'highlight.js/lib/languages/axapta'
|
||||
import bash from 'highlight.js/lib/languages/bash'
|
||||
import basic from 'highlight.js/lib/languages/basic'
|
||||
import bnf from 'highlight.js/lib/languages/bnf'
|
||||
import brainfuck from 'highlight.js/lib/languages/brainfuck'
|
||||
import c from 'highlight.js/lib/languages/c'
|
||||
import cal from 'highlight.js/lib/languages/cal'
|
||||
import capnproto from 'highlight.js/lib/languages/capnproto'
|
||||
import ceylon from 'highlight.js/lib/languages/ceylon'
|
||||
import clean from 'highlight.js/lib/languages/clean'
|
||||
import clojure from 'highlight.js/lib/languages/clojure'
|
||||
import clojure_repl from 'highlight.js/lib/languages/clojure-repl'
|
||||
import cmake from 'highlight.js/lib/languages/cmake'
|
||||
import coffeescript from 'highlight.js/lib/languages/coffeescript'
|
||||
import coq from 'highlight.js/lib/languages/coq'
|
||||
import cos from 'highlight.js/lib/languages/cos'
|
||||
import cpp from 'highlight.js/lib/languages/cpp'
|
||||
import crmsh from 'highlight.js/lib/languages/crmsh'
|
||||
import crystal from 'highlight.js/lib/languages/crystal'
|
||||
import csharp from 'highlight.js/lib/languages/csharp'
|
||||
import csp from 'highlight.js/lib/languages/csp'
|
||||
import css from 'highlight.js/lib/languages/css'
|
||||
import d from 'highlight.js/lib/languages/d'
|
||||
import markdown from 'highlight.js/lib/languages/markdown'
|
||||
import dart from 'highlight.js/lib/languages/dart'
|
||||
import delphi from 'highlight.js/lib/languages/delphi'
|
||||
import diff from 'highlight.js/lib/languages/diff'
|
||||
import django from 'highlight.js/lib/languages/django'
|
||||
import dns from 'highlight.js/lib/languages/dns'
|
||||
import dockerfile from 'highlight.js/lib/languages/dockerfile'
|
||||
import dos from 'highlight.js/lib/languages/dos'
|
||||
import dsconfig from 'highlight.js/lib/languages/dsconfig'
|
||||
import dts from 'highlight.js/lib/languages/dts'
|
||||
import dust from 'highlight.js/lib/languages/dust'
|
||||
import ebnf from 'highlight.js/lib/languages/ebnf'
|
||||
import elixir from 'highlight.js/lib/languages/elixir'
|
||||
import elm from 'highlight.js/lib/languages/elm'
|
||||
import ruby from 'highlight.js/lib/languages/ruby'
|
||||
import erb from 'highlight.js/lib/languages/erb'
|
||||
import erlang_repl from 'highlight.js/lib/languages/erlang-repl'
|
||||
import erlang from 'highlight.js/lib/languages/erlang'
|
||||
import excel from 'highlight.js/lib/languages/excel'
|
||||
import fix from 'highlight.js/lib/languages/fix'
|
||||
import flix from 'highlight.js/lib/languages/flix'
|
||||
import fortran from 'highlight.js/lib/languages/fortran'
|
||||
import fsharp from 'highlight.js/lib/languages/fsharp'
|
||||
import gams from 'highlight.js/lib/languages/gams'
|
||||
import gauss from 'highlight.js/lib/languages/gauss'
|
||||
import gcode from 'highlight.js/lib/languages/gcode'
|
||||
import gherkin from 'highlight.js/lib/languages/gherkin'
|
||||
import glsl from 'highlight.js/lib/languages/glsl'
|
||||
import gml from 'highlight.js/lib/languages/gml'
|
||||
import go from 'highlight.js/lib/languages/go'
|
||||
import golo from 'highlight.js/lib/languages/golo'
|
||||
import gradle from 'highlight.js/lib/languages/gradle'
|
||||
import groovy from 'highlight.js/lib/languages/groovy'
|
||||
import haml from 'highlight.js/lib/languages/haml'
|
||||
import handlebars from 'highlight.js/lib/languages/handlebars'
|
||||
import haskell from 'highlight.js/lib/languages/haskell'
|
||||
import haxe from 'highlight.js/lib/languages/haxe'
|
||||
import hsp from 'highlight.js/lib/languages/hsp'
|
||||
import http from 'highlight.js/lib/languages/http'
|
||||
import hy from 'highlight.js/lib/languages/hy'
|
||||
import inform7 from 'highlight.js/lib/languages/inform7'
|
||||
import ini from 'highlight.js/lib/languages/ini'
|
||||
import irpf90 from 'highlight.js/lib/languages/irpf90'
|
||||
import isbl from 'highlight.js/lib/languages/isbl'
|
||||
import java from 'highlight.js/lib/languages/java'
|
||||
import javascript from 'highlight.js/lib/languages/javascript'
|
||||
import jboss_cli from 'highlight.js/lib/languages/jboss-cli'
|
||||
import json from 'highlight.js/lib/languages/json'
|
||||
import julia from 'highlight.js/lib/languages/julia'
|
||||
import julia_repl from 'highlight.js/lib/languages/julia-repl'
|
||||
import kotlin from 'highlight.js/lib/languages/kotlin'
|
||||
import lasso from 'highlight.js/lib/languages/lasso'
|
||||
import latex from 'highlight.js/lib/languages/latex'
|
||||
import ldif from 'highlight.js/lib/languages/ldif'
|
||||
import leaf from 'highlight.js/lib/languages/leaf'
|
||||
import less from 'highlight.js/lib/languages/less'
|
||||
import lisp from 'highlight.js/lib/languages/lisp'
|
||||
import livecodeserver from 'highlight.js/lib/languages/livecodeserver'
|
||||
import livescript from 'highlight.js/lib/languages/livescript'
|
||||
import llvm from 'highlight.js/lib/languages/llvm'
|
||||
import lsl from 'highlight.js/lib/languages/lsl'
|
||||
import lua from 'highlight.js/lib/languages/lua'
|
||||
import makefile from 'highlight.js/lib/languages/makefile'
|
||||
import mathematica from 'highlight.js/lib/languages/mathematica'
|
||||
import matlab from 'highlight.js/lib/languages/matlab'
|
||||
import maxima from 'highlight.js/lib/languages/maxima'
|
||||
import mel from 'highlight.js/lib/languages/mel'
|
||||
import mercury from 'highlight.js/lib/languages/mercury'
|
||||
import mipsasm from 'highlight.js/lib/languages/mipsasm'
|
||||
import mizar from 'highlight.js/lib/languages/mizar'
|
||||
import perl from 'highlight.js/lib/languages/perl'
|
||||
import mojolicious from 'highlight.js/lib/languages/mojolicious'
|
||||
import monkey from 'highlight.js/lib/languages/monkey'
|
||||
import moonscript from 'highlight.js/lib/languages/moonscript'
|
||||
import n1ql from 'highlight.js/lib/languages/n1ql'
|
||||
import nginx from 'highlight.js/lib/languages/nginx'
|
||||
import nim from 'highlight.js/lib/languages/nim'
|
||||
import nix from 'highlight.js/lib/languages/nix'
|
||||
import node_repl from 'highlight.js/lib/languages/node-repl'
|
||||
import nsis from 'highlight.js/lib/languages/nsis'
|
||||
import objectivec from 'highlight.js/lib/languages/objectivec'
|
||||
import ocaml from 'highlight.js/lib/languages/ocaml'
|
||||
import openscad from 'highlight.js/lib/languages/openscad'
|
||||
import oxygene from 'highlight.js/lib/languages/oxygene'
|
||||
import parser3 from 'highlight.js/lib/languages/parser3'
|
||||
import pf from 'highlight.js/lib/languages/pf'
|
||||
import pgsql from 'highlight.js/lib/languages/pgsql'
|
||||
import php from 'highlight.js/lib/languages/php'
|
||||
import php_template from 'highlight.js/lib/languages/php-template'
|
||||
import plaintext from 'highlight.js/lib/languages/plaintext'
|
||||
import pony from 'highlight.js/lib/languages/pony'
|
||||
import powershell from 'highlight.js/lib/languages/powershell'
|
||||
import processing from 'highlight.js/lib/languages/processing'
|
||||
import profile from 'highlight.js/lib/languages/profile'
|
||||
import prolog from 'highlight.js/lib/languages/prolog'
|
||||
import properties from 'highlight.js/lib/languages/properties'
|
||||
import protobuf from 'highlight.js/lib/languages/protobuf'
|
||||
import puppet from 'highlight.js/lib/languages/puppet'
|
||||
import purebasic from 'highlight.js/lib/languages/purebasic'
|
||||
import python from 'highlight.js/lib/languages/python'
|
||||
import python_repl from 'highlight.js/lib/languages/python-repl'
|
||||
import q from 'highlight.js/lib/languages/q'
|
||||
import qml from 'highlight.js/lib/languages/qml'
|
||||
import r from 'highlight.js/lib/languages/r'
|
||||
import reasonml from 'highlight.js/lib/languages/reasonml'
|
||||
import rib from 'highlight.js/lib/languages/rib'
|
||||
import roboconf from 'highlight.js/lib/languages/roboconf'
|
||||
import routeros from 'highlight.js/lib/languages/routeros'
|
||||
import rsl from 'highlight.js/lib/languages/rsl'
|
||||
import ruleslanguage from 'highlight.js/lib/languages/ruleslanguage'
|
||||
import rust from 'highlight.js/lib/languages/rust'
|
||||
import sas from 'highlight.js/lib/languages/sas'
|
||||
import scala from 'highlight.js/lib/languages/scala'
|
||||
import scheme from 'highlight.js/lib/languages/scheme'
|
||||
import scilab from 'highlight.js/lib/languages/scilab'
|
||||
import scss from 'highlight.js/lib/languages/scss'
|
||||
import shell from 'highlight.js/lib/languages/shell'
|
||||
import smali from 'highlight.js/lib/languages/smali'
|
||||
import smalltalk from 'highlight.js/lib/languages/smalltalk'
|
||||
import sml from 'highlight.js/lib/languages/sml'
|
||||
import sqf from 'highlight.js/lib/languages/sqf'
|
||||
import sql from 'highlight.js/lib/languages/sql'
|
||||
import stan from 'highlight.js/lib/languages/stan'
|
||||
import stata from 'highlight.js/lib/languages/stata'
|
||||
import step21 from 'highlight.js/lib/languages/step21'
|
||||
import stylus from 'highlight.js/lib/languages/stylus'
|
||||
import subunit from 'highlight.js/lib/languages/subunit'
|
||||
import swift from 'highlight.js/lib/languages/swift'
|
||||
import taggerscript from 'highlight.js/lib/languages/taggerscript'
|
||||
import yaml from 'highlight.js/lib/languages/yaml'
|
||||
import tap from 'highlight.js/lib/languages/tap'
|
||||
import tcl from 'highlight.js/lib/languages/tcl'
|
||||
import thrift from 'highlight.js/lib/languages/thrift'
|
||||
import tp from 'highlight.js/lib/languages/tp'
|
||||
import twig from 'highlight.js/lib/languages/twig'
|
||||
import typescript from 'highlight.js/lib/languages/typescript'
|
||||
import vala from 'highlight.js/lib/languages/vala'
|
||||
import vbnet from 'highlight.js/lib/languages/vbnet'
|
||||
import vbscript from 'highlight.js/lib/languages/vbscript'
|
||||
import vbscript_html from 'highlight.js/lib/languages/vbscript-html'
|
||||
import verilog from 'highlight.js/lib/languages/verilog'
|
||||
import vhdl from 'highlight.js/lib/languages/vhdl'
|
||||
import vim from 'highlight.js/lib/languages/vim'
|
||||
import x86asm from 'highlight.js/lib/languages/x86asm'
|
||||
import xl from 'highlight.js/lib/languages/xl'
|
||||
import xquery from 'highlight.js/lib/languages/xquery'
|
||||
import zephir from 'highlight.js/lib/languages/zephir'
|
||||
import wasm from 'highlight.js/lib/languages/wasm'
|
||||
import nestedtext from 'highlight.js/lib/languages/nestedtext'
|
||||
|
||||
hljs.registerLanguage('abnf', abnf)
|
||||
hljs.registerLanguage('accesslog', accesslog)
|
||||
hljs.registerLanguage('actionscript', actionscript)
|
||||
hljs.registerLanguage('ada', ada)
|
||||
hljs.registerLanguage('angelscript', angelscript)
|
||||
hljs.registerLanguage('apache', apache)
|
||||
hljs.registerLanguage('applescript', applescript)
|
||||
hljs.registerLanguage('arcade', arcade)
|
||||
hljs.registerLanguage('arduino', arduino)
|
||||
hljs.registerLanguage('armasm', armasm)
|
||||
hljs.registerLanguage('xml', xml)
|
||||
hljs.registerLanguage('asciidoc', asciidoc)
|
||||
hljs.registerLanguage('aspectj', aspectj)
|
||||
hljs.registerLanguage('autohotkey', autohotkey)
|
||||
hljs.registerLanguage('autoit', autoit)
|
||||
hljs.registerLanguage('avrasm', avrasm)
|
||||
hljs.registerLanguage('awk', awk)
|
||||
hljs.registerLanguage('axapta', axapta)
|
||||
hljs.registerLanguage('bash', bash)
|
||||
hljs.registerLanguage('basic', basic)
|
||||
hljs.registerLanguage('bnf', bnf)
|
||||
hljs.registerLanguage('brainfuck', brainfuck)
|
||||
hljs.registerLanguage('c', c)
|
||||
hljs.registerLanguage('cal', cal)
|
||||
hljs.registerLanguage('capnproto', capnproto)
|
||||
hljs.registerLanguage('ceylon', ceylon)
|
||||
hljs.registerLanguage('clean', clean)
|
||||
hljs.registerLanguage('clojure', clojure)
|
||||
hljs.registerLanguage('clojure-repl', clojure_repl)
|
||||
hljs.registerLanguage('cmake', cmake)
|
||||
hljs.registerLanguage('coffeescript', coffeescript)
|
||||
hljs.registerLanguage('coq', coq)
|
||||
hljs.registerLanguage('cos', cos)
|
||||
hljs.registerLanguage('cpp', cpp)
|
||||
hljs.registerLanguage('crmsh', crmsh)
|
||||
hljs.registerLanguage('crystal', crystal)
|
||||
hljs.registerLanguage('csharp', csharp)
|
||||
hljs.registerLanguage('csp', csp)
|
||||
hljs.registerLanguage('css', css)
|
||||
hljs.registerLanguage('d', d)
|
||||
hljs.registerLanguage('markdown', markdown)
|
||||
hljs.registerLanguage('dart', dart)
|
||||
hljs.registerLanguage('delphi', delphi)
|
||||
hljs.registerLanguage('diff', diff)
|
||||
hljs.registerLanguage('django', django)
|
||||
hljs.registerLanguage('dns', dns)
|
||||
hljs.registerLanguage('dockerfile', dockerfile)
|
||||
hljs.registerLanguage('dos', dos)
|
||||
hljs.registerLanguage('dsconfig', dsconfig)
|
||||
hljs.registerLanguage('dts', dts)
|
||||
hljs.registerLanguage('dust', dust)
|
||||
hljs.registerLanguage('ebnf', ebnf)
|
||||
hljs.registerLanguage('elixir', elixir)
|
||||
hljs.registerLanguage('elm', elm)
|
||||
hljs.registerLanguage('ruby', ruby)
|
||||
hljs.registerLanguage('erb', erb)
|
||||
hljs.registerLanguage('erlang-repl', erlang_repl)
|
||||
hljs.registerLanguage('erlang', erlang)
|
||||
hljs.registerLanguage('excel', excel)
|
||||
hljs.registerLanguage('fix', fix)
|
||||
hljs.registerLanguage('flix', flix)
|
||||
hljs.registerLanguage('fortran', fortran)
|
||||
hljs.registerLanguage('fsharp', fsharp)
|
||||
hljs.registerLanguage('gams', gams)
|
||||
hljs.registerLanguage('gauss', gauss)
|
||||
hljs.registerLanguage('gcode', gcode)
|
||||
hljs.registerLanguage('gherkin', gherkin)
|
||||
hljs.registerLanguage('glsl', glsl)
|
||||
hljs.registerLanguage('gml', gml)
|
||||
hljs.registerLanguage('go', go)
|
||||
hljs.registerLanguage('golo', golo)
|
||||
hljs.registerLanguage('gradle', gradle)
|
||||
hljs.registerLanguage('groovy', groovy)
|
||||
hljs.registerLanguage('haml', haml)
|
||||
hljs.registerLanguage('handlebars', handlebars)
|
||||
hljs.registerLanguage('haskell', haskell)
|
||||
hljs.registerLanguage('haxe', haxe)
|
||||
hljs.registerLanguage('hsp', hsp)
|
||||
hljs.registerLanguage('html', xml)
|
||||
hljs.registerLanguage('http', http)
|
||||
hljs.registerLanguage('hy', hy)
|
||||
hljs.registerLanguage('inform7', inform7)
|
||||
hljs.registerLanguage('ini', ini)
|
||||
hljs.registerLanguage('irpf90', irpf90)
|
||||
hljs.registerLanguage('isbl', isbl)
|
||||
hljs.registerLanguage('java', java)
|
||||
hljs.registerLanguage('javascript', javascript)
|
||||
hljs.registerLanguage('jboss-cli', jboss_cli)
|
||||
hljs.registerLanguage('js', javascript)
|
||||
hljs.registerLanguage('json', json)
|
||||
hljs.registerLanguage('julia', julia)
|
||||
hljs.registerLanguage('julia-repl', julia_repl)
|
||||
hljs.registerLanguage('kotlin', kotlin)
|
||||
hljs.registerLanguage('lasso', lasso)
|
||||
hljs.registerLanguage('latex', latex)
|
||||
hljs.registerLanguage('ldif', ldif)
|
||||
hljs.registerLanguage('leaf', leaf)
|
||||
hljs.registerLanguage('less', less)
|
||||
hljs.registerLanguage('lisp', lisp)
|
||||
hljs.registerLanguage('livecodeserver', livecodeserver)
|
||||
hljs.registerLanguage('livescript', livescript)
|
||||
hljs.registerLanguage('llvm', llvm)
|
||||
hljs.registerLanguage('lsl', lsl)
|
||||
hljs.registerLanguage('lua', lua)
|
||||
hljs.registerLanguage('makefile', makefile)
|
||||
hljs.registerLanguage('mathematica', mathematica)
|
||||
hljs.registerLanguage('matlab', matlab)
|
||||
hljs.registerLanguage('maxima', maxima)
|
||||
hljs.registerLanguage('mel', mel)
|
||||
hljs.registerLanguage('mercury', mercury)
|
||||
hljs.registerLanguage('mipsasm', mipsasm)
|
||||
hljs.registerLanguage('mizar', mizar)
|
||||
hljs.registerLanguage('perl', perl)
|
||||
hljs.registerLanguage('mojolicious', mojolicious)
|
||||
hljs.registerLanguage('monkey', monkey)
|
||||
hljs.registerLanguage('moonscript', moonscript)
|
||||
hljs.registerLanguage('nestedtext', nestedtext)
|
||||
hljs.registerLanguage('n1ql', n1ql)
|
||||
hljs.registerLanguage('nginx', nginx)
|
||||
hljs.registerLanguage('nim', nim)
|
||||
hljs.registerLanguage('nix', nix)
|
||||
hljs.registerLanguage('node-repl', node_repl)
|
||||
hljs.registerLanguage('nsis', nsis)
|
||||
hljs.registerLanguage('objectivec', objectivec)
|
||||
hljs.registerLanguage('ocaml', ocaml)
|
||||
hljs.registerLanguage('openscad', openscad)
|
||||
hljs.registerLanguage('oxygene', oxygene)
|
||||
hljs.registerLanguage('parser3', parser3)
|
||||
hljs.registerLanguage('pf', pf)
|
||||
hljs.registerLanguage('pgsql', pgsql)
|
||||
hljs.registerLanguage('php', php)
|
||||
hljs.registerLanguage('php-template', php_template)
|
||||
hljs.registerLanguage('plaintext', plaintext)
|
||||
hljs.registerLanguage('pony', pony)
|
||||
hljs.registerLanguage('powershell', powershell)
|
||||
hljs.registerLanguage('processing', processing)
|
||||
hljs.registerLanguage('profile', profile)
|
||||
hljs.registerLanguage('prolog', prolog)
|
||||
hljs.registerLanguage('properties', properties)
|
||||
hljs.registerLanguage('protobuf', protobuf)
|
||||
hljs.registerLanguage('puppet', puppet)
|
||||
hljs.registerLanguage('purebasic', purebasic)
|
||||
hljs.registerLanguage('python', python)
|
||||
hljs.registerLanguage('python-repl', python_repl)
|
||||
hljs.registerLanguage('q', q)
|
||||
hljs.registerLanguage('qml', qml)
|
||||
hljs.registerLanguage('r', r)
|
||||
hljs.registerLanguage('reasonml', reasonml)
|
||||
hljs.registerLanguage('rib', rib)
|
||||
hljs.registerLanguage('roboconf', roboconf)
|
||||
hljs.registerLanguage('routeros', routeros)
|
||||
hljs.registerLanguage('rsl', rsl)
|
||||
hljs.registerLanguage('ruleslanguage', ruleslanguage)
|
||||
hljs.registerLanguage('rust', rust)
|
||||
hljs.registerLanguage('sas', sas)
|
||||
hljs.registerLanguage('scala', scala)
|
||||
hljs.registerLanguage('scheme', scheme)
|
||||
hljs.registerLanguage('scilab', scilab)
|
||||
hljs.registerLanguage('scss', scss)
|
||||
hljs.registerLanguage('shell', shell)
|
||||
hljs.registerLanguage('smali', smali)
|
||||
hljs.registerLanguage('smalltalk', smalltalk)
|
||||
hljs.registerLanguage('sml', sml)
|
||||
hljs.registerLanguage('sqf', sqf)
|
||||
hljs.registerLanguage('sql', sql)
|
||||
hljs.registerLanguage('stan', stan)
|
||||
hljs.registerLanguage('stata', stata)
|
||||
hljs.registerLanguage('step21', step21)
|
||||
hljs.registerLanguage('stylus', stylus)
|
||||
hljs.registerLanguage('subunit', subunit)
|
||||
hljs.registerLanguage('swift', swift)
|
||||
hljs.registerLanguage('taggerscript', taggerscript)
|
||||
hljs.registerLanguage('yaml', yaml)
|
||||
hljs.registerLanguage('tap', tap)
|
||||
hljs.registerLanguage('tcl', tcl)
|
||||
hljs.registerLanguage('thrift', thrift)
|
||||
hljs.registerLanguage('tp', tp)
|
||||
hljs.registerLanguage('twig', twig)
|
||||
hljs.registerLanguage('typescript', typescript)
|
||||
hljs.registerLanguage('vala', vala)
|
||||
hljs.registerLanguage('vbnet', vbnet)
|
||||
hljs.registerLanguage('vbscript', vbscript)
|
||||
hljs.registerLanguage('vbscript-html', vbscript_html)
|
||||
hljs.registerLanguage('verilog', verilog)
|
||||
hljs.registerLanguage('vhdl', vhdl)
|
||||
hljs.registerLanguage('vim', vim)
|
||||
hljs.registerLanguage('x86asm', x86asm)
|
||||
hljs.registerLanguage('xl', xl)
|
||||
hljs.registerLanguage('xquery', xquery)
|
||||
hljs.registerLanguage('zephir', zephir)
|
||||
hljs.registerLanguage('wasmn', wasm)
|
||||
|
||||
export default hljs
|
|
@ -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>
|
||||
`;
|
|
@ -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>
|
||||
`;
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { AppExtension } from '../../base/app-extension'
|
||||
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/base/markdown-renderer-extension'
|
||||
import { KatexMarkdownExtension } from './katex-markdown-extension'
|
||||
|
||||
/**
|
||||
* Adds support for LaTeX rendering using KaTeX to the markdown rendering.
|
||||
*
|
||||
* @see https://katex.org/
|
||||
*/
|
||||
export class KatexAppExtension extends AppExtension {
|
||||
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
|
||||
return [new KatexMarkdownExtension()]
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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 { sanitize } from 'dompurify'
|
||||
import { testId } from '../../../utils/test-id'
|
||||
|
||||
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
|
|
@ -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 { Suspense } from 'react'
|
||||
import type { KatexOptions } from 'katex'
|
||||
import { default as KatexDefault } from 'katex'
|
||||
import { TestMarkdownRenderer } from '../../../components/markdown-renderer/test-utils/test-markdown-renderer'
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import mathJax from 'markdown-it-mathjax'
|
||||
import type MarkdownIt from 'markdown-it'
|
||||
import { KatexReplacer } from './katex-replacer'
|
||||
import type { ComponentReplacer } from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/base/markdown-renderer-extension'
|
||||
|
||||
/**
|
||||
* Adds support for rendering of LaTeX code using KaTeX.
|
||||
*
|
||||
* @see https://katex.org/
|
||||
*/
|
||||
export class KatexMarkdownExtension extends MarkdownRendererExtension {
|
||||
public static readonly tagName = 'app-katex'
|
||||
|
||||
public configureMarkdownIt(markdownIt: MarkdownIt): void {
|
||||
mathJax({
|
||||
beforeMath: `<${KatexMarkdownExtension.tagName}>`,
|
||||
afterMath: `</${KatexMarkdownExtension.tagName}>`,
|
||||
beforeInlineMath: `<${KatexMarkdownExtension.tagName} data-inline="true">`,
|
||||
afterInlineMath: `</${KatexMarkdownExtension.tagName}>`,
|
||||
beforeDisplayMath: `<${KatexMarkdownExtension.tagName}>`,
|
||||
afterDisplayMath: `</${KatexMarkdownExtension.tagName}>`
|
||||
})(markdownIt)
|
||||
}
|
||||
|
||||
public buildReplacers(): ComponentReplacer[] {
|
||||
return [new KatexReplacer()]
|
||||
}
|
||||
|
||||
public buildTagNameAllowList(): string[] {
|
||||
return [KatexMarkdownExtension.tagName]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Element } from 'domhandler'
|
||||
import { isTag } from 'domhandler'
|
||||
import React from 'react'
|
||||
import type { NodeReplacement } from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import {
|
||||
ComponentReplacer,
|
||||
DO_NOT_REPLACE
|
||||
} from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import { KatexMarkdownExtension } from './katex-markdown-extension'
|
||||
import { Optional } from '@mrdrogdrog/optional'
|
||||
|
||||
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): NodeReplacement {
|
||||
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))
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Legacy shortcodes markdown extension transforms a pdf short code into an URL 1`] = `
|
||||
<div>
|
||||
<p>
|
||||
<a
|
||||
href="https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
|
||||
>
|
||||
https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Legacy shortcodes markdown extension transforms a slideshare short code into an URL 1`] = `
|
||||
<div>
|
||||
<p>
|
||||
<a
|
||||
href="https://www.slideshare.net/example/123456789"
|
||||
>
|
||||
https://www.slideshare.net/example/123456789
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Legacy shortcodes markdown extension transforms a speakerdeck short code into an URL 1`] = `
|
||||
<div>
|
||||
<p>
|
||||
<a
|
||||
href="https://speakerdeck.com/example/123456789"
|
||||
>
|
||||
https://speakerdeck.com/example/123456789
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { AppExtension } from '../../base/app-extension'
|
||||
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/base/markdown-renderer-extension'
|
||||
import { LegacyShortcodesMarkdownExtension } from './legacy-shortcodes-markdown-extension'
|
||||
import type { Linter } from '../../../components/editor-page/editor-pane/linter/linter'
|
||||
import { SingleLineRegexLinter } from '../../../components/editor-page/editor-pane/linter/single-line-regex-linter'
|
||||
import { legacySpeakerdeckRegex } from './replace-legacy-speakerdeck-short-code'
|
||||
import { t } from 'i18next'
|
||||
import { legacySlideshareRegex } from './replace-legacy-slideshare-short-code'
|
||||
import { legacyPdfRegex } from './replace-legacy-pdf-short-code'
|
||||
|
||||
/**
|
||||
* Adds support for legacy shortcodes (pdf, slideshare and speakerdeck) from HedgeDoc 1 to the markdown renderer.
|
||||
*/
|
||||
export class LegacyShortcodesAppExtension extends AppExtension {
|
||||
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
|
||||
return [new LegacyShortcodesMarkdownExtension()]
|
||||
}
|
||||
|
||||
buildCodeMirrorLinter(): Linter[] {
|
||||
return [
|
||||
new SingleLineRegexLinter(
|
||||
legacySpeakerdeckRegex,
|
||||
t('editor.linter.shortcode', { shortcode: 'SpeakerDeck' }),
|
||||
(match: string) => `https://speakerdeck.com/${match}`
|
||||
),
|
||||
new SingleLineRegexLinter(
|
||||
legacySlideshareRegex,
|
||||
t('editor.linter.shortcode', { shortcode: 'SlideShare' }),
|
||||
(match: string) => `https://www.slideshare.net/${match}`
|
||||
),
|
||||
new SingleLineRegexLinter(
|
||||
legacyPdfRegex,
|
||||
t('editor.linter.shortcode', { shortcode: 'PDF' }),
|
||||
(match: string) => match
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { render } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import { LegacyShortcodesMarkdownExtension } from './legacy-shortcodes-markdown-extension'
|
||||
import { TestMarkdownRenderer } from '../../../components/markdown-renderer/test-utils/test-markdown-renderer'
|
||||
|
||||
describe('Legacy shortcodes markdown extension', () => {
|
||||
it('transforms a pdf short code into an URL', () => {
|
||||
const view = render(
|
||||
<TestMarkdownRenderer
|
||||
extensions={[new LegacyShortcodesMarkdownExtension()]}
|
||||
content={'{%pdf https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf %}'}
|
||||
/>
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
it('transforms a slideshare short code into an URL', () => {
|
||||
const view = render(
|
||||
<TestMarkdownRenderer
|
||||
extensions={[new LegacyShortcodesMarkdownExtension()]}
|
||||
content={'{%slideshare example/123456789 %}'}
|
||||
/>
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
it('transforms a speakerdeck short code into an URL', () => {
|
||||
const view = render(
|
||||
<TestMarkdownRenderer
|
||||
extensions={[new LegacyShortcodesMarkdownExtension()]}
|
||||
content={'{%speakerdeck example/123456789 %}'}
|
||||
/>
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
})
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type MarkdownIt from 'markdown-it'
|
||||
import { legacyPdfShortCode } from './replace-legacy-pdf-short-code'
|
||||
import { legacySlideshareShortCode } from './replace-legacy-slideshare-short-code'
|
||||
import { legacySpeakerdeckShortCode } from './replace-legacy-speakerdeck-short-code'
|
||||
import { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/base/markdown-renderer-extension'
|
||||
|
||||
/**
|
||||
* Adds support for legacy shortcodes (pdf, slideshare and speakerdeck) by replacing them with anchor elements.
|
||||
*/
|
||||
export class LegacyShortcodesMarkdownExtension extends MarkdownRendererExtension {
|
||||
public configureMarkdownIt(markdownIt: MarkdownIt): void {
|
||||
legacyPdfShortCode(markdownIt)
|
||||
legacySlideshareShortCode(markdownIt)
|
||||
legacySpeakerdeckShortCode(markdownIt)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { legacyPdfShortCode } from './replace-legacy-pdf-short-code'
|
||||
|
||||
describe('Legacy pdf short code', () => {
|
||||
it('replaces with link', () => {
|
||||
const markdownIt = new MarkdownIt('default', {
|
||||
html: false,
|
||||
breaks: true,
|
||||
langPrefix: '',
|
||||
typographer: true
|
||||
})
|
||||
markdownIt.use(legacyPdfShortCode)
|
||||
expect(
|
||||
markdownIt.renderInline('{%pdf https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf %}')
|
||||
).toEqual(
|
||||
`<a href="https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf">https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf</a>`
|
||||
)
|
||||
})
|
||||
})
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import markdownItRegex from 'markdown-it-regex'
|
||||
import type MarkdownIt from 'markdown-it/lib'
|
||||
import type { RegexOptions } from '../../../external-types/markdown-it-regex/interface'
|
||||
|
||||
export const legacyPdfRegex = /^{%pdf\s+(\S*)\s*%}$/
|
||||
|
||||
/**
|
||||
* Configure the given {@link MarkdownIt} to render legacy hedgedoc 1 pdf shortcodes as html links.
|
||||
*
|
||||
* @param markdownIt The {@link MarkdownIt} to configure
|
||||
*/
|
||||
export const legacyPdfShortCode: MarkdownIt.PluginSimple = (markdownIt) => {
|
||||
markdownItRegex(markdownIt, {
|
||||
name: 'legacy-pdf-short-code',
|
||||
regex: legacyPdfRegex,
|
||||
replace: (match) => `<a href="${match}">${match}</a>`
|
||||
} as RegexOptions)
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { legacySlideshareShortCode } from './replace-legacy-slideshare-short-code'
|
||||
|
||||
describe('Legacy slideshare short code', () => {
|
||||
it('replaces with link', () => {
|
||||
const markdownIt = new MarkdownIt('default', {
|
||||
html: false,
|
||||
breaks: true,
|
||||
langPrefix: '',
|
||||
typographer: true
|
||||
})
|
||||
markdownIt.use(legacySlideshareShortCode)
|
||||
expect(markdownIt.renderInline('{%slideshare example/123456789 %}')).toEqual(
|
||||
"<a href='https://www.slideshare.net/example/123456789'>https://www.slideshare.net/example/123456789</a>"
|
||||
)
|
||||
})
|
||||
})
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import markdownItRegex from 'markdown-it-regex'
|
||||
import type MarkdownIt from 'markdown-it/lib'
|
||||
import type { RegexOptions } from '../../../external-types/markdown-it-regex/interface'
|
||||
|
||||
export const legacySlideshareRegex = /^{%slideshare\s+(\w+\/[\w-]+)\s*%}$/
|
||||
|
||||
/**
|
||||
* Configure the given {@link MarkdownIt} to render legacy hedgedoc 1 slideshare shortcodes as HTML links.
|
||||
*
|
||||
* @param markdownIt The {@link MarkdownIt} to configure
|
||||
*/
|
||||
export const legacySlideshareShortCode: MarkdownIt.PluginSimple = (markdownIt) => {
|
||||
markdownItRegex(markdownIt, {
|
||||
name: 'legacy-slideshare-short-code',
|
||||
regex: legacySlideshareRegex,
|
||||
replace: (match) => `<a href='https://www.slideshare.net/${match}'>https://www.slideshare.net/${match}</a>`
|
||||
} as RegexOptions)
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { legacySpeakerdeckShortCode } from './replace-legacy-speakerdeck-short-code'
|
||||
|
||||
describe('Legacy speakerdeck short code', () => {
|
||||
it('replaces with link', () => {
|
||||
const markdownIt = new MarkdownIt('default', {
|
||||
html: false,
|
||||
breaks: true,
|
||||
langPrefix: '',
|
||||
typographer: true
|
||||
})
|
||||
markdownIt.use(legacySpeakerdeckShortCode)
|
||||
expect(markdownIt.renderInline('{%speakerdeck example/123456789 %}')).toEqual(
|
||||
'<a href="https://speakerdeck.com/example/123456789">https://speakerdeck.com/example/123456789</a>'
|
||||
)
|
||||
})
|
||||
})
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import markdownItRegex from 'markdown-it-regex'
|
||||
import type MarkdownIt from 'markdown-it/lib'
|
||||
import type { RegexOptions } from '../../../external-types/markdown-it-regex/interface'
|
||||
|
||||
export const legacySpeakerdeckRegex = /^{%speakerdeck\s+(\w+\/[\w-]+)\s*%}$/
|
||||
|
||||
/**
|
||||
* Configure the given {@link MarkdownIt} to render legacy hedgedoc 1 speakerdeck shortcodes as HTML links.
|
||||
*
|
||||
* @param markdownIt The {@link MarkdownIt} to configure
|
||||
*/
|
||||
export const legacySpeakerdeckShortCode: MarkdownIt.PluginSimple = (markdownIt) => {
|
||||
markdownItRegex(markdownIt, {
|
||||
name: 'legacy-speakerdeck-short-code',
|
||||
regex: legacySpeakerdeckRegex,
|
||||
replace: (match) => `<a href="https://speakerdeck.com/${match}">https://speakerdeck.com/${match}</a>`
|
||||
} as RegexOptions)
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { AppExtension } from '../../base/app-extension'
|
||||
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/base/markdown-renderer-extension'
|
||||
import { MermaidMarkdownExtension } from './mermaid-markdown-extension'
|
||||
|
||||
/**
|
||||
* Adds support for chart rendering using mermaid to the markdown renderer.
|
||||
*/
|
||||
export class MermaidAppExtension extends AppExtension {
|
||||
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
|
||||
return [new MermaidMarkdownExtension()]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { Fragment, useRef } from 'react'
|
||||
import { Alert } from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styles from './mermaid.module.scss'
|
||||
import type { CodeProps } from '../../../components/markdown-renderer/replace-components/code-block-component-replacer'
|
||||
import { cypressId } from '../../../utils/cypress-attribute'
|
||||
import { ShowIf } from '../../../components/common/show-if/show-if'
|
||||
import { Logger } from '../../../utils/logger'
|
||||
import { useAsync } from 'react-use'
|
||||
|
||||
const log = new Logger('MermaidChart')
|
||||
|
||||
let mermaidInitialized = false
|
||||
|
||||
const loadMermaid = async (): Promise<typeof import('mermaid')> => {
|
||||
try {
|
||||
return import(/* webpackChunkName: "mermaid" */ 'mermaid')
|
||||
} catch (error) {
|
||||
log.error('Error while loading mermaid', error)
|
||||
throw new Error('Error while loading mermaid')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a mermaid diagram.
|
||||
*
|
||||
* @param code The code for the diagram.
|
||||
* @see https://mermaid-js.github.io/mermaid/#/
|
||||
*/
|
||||
export const MermaidChart: React.FC<CodeProps> = ({ code }) => {
|
||||
const diagramContainer = useRef<HTMLDivElement>(null)
|
||||
const { t } = useTranslation()
|
||||
const { error } = useAsync(async () => {
|
||||
if (!diagramContainer.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const mermaid = await loadMermaid()
|
||||
|
||||
if (!mermaidInitialized) {
|
||||
mermaid.default.initialize({ startOnLoad: false })
|
||||
mermaidInitialized = true
|
||||
}
|
||||
|
||||
try {
|
||||
if (!diagramContainer.current) {
|
||||
return
|
||||
}
|
||||
mermaid.default.parse(code)
|
||||
delete diagramContainer.current.dataset.processed
|
||||
diagramContainer.current.textContent = code
|
||||
await mermaid.default.init(undefined, diagramContainer.current)
|
||||
} catch (error) {
|
||||
const message = (error as Error).message
|
||||
log.error(error)
|
||||
diagramContainer.current?.querySelectorAll('svg').forEach((child) => child.remove())
|
||||
throw new Error(message ?? t('renderer.mermaid.unknownError'))
|
||||
}
|
||||
}, [code, t])
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<ShowIf condition={!!error}>
|
||||
<Alert variant={'warning'}>{error?.message}</Alert>
|
||||
</ShowIf>
|
||||
<div
|
||||
{...cypressId('mermaid-frame')}
|
||||
className={`text-center ${styles['mermaid']} text-black`}
|
||||
ref={diagramContainer}
|
||||
/>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { MermaidChart } from './mermaid-chart'
|
||||
import { CodeBlockMarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/base/code-block-markdown-extension/code-block-markdown-renderer-extension'
|
||||
import { CodeBlockComponentReplacer } from '../../../components/markdown-renderer/replace-components/code-block-component-replacer'
|
||||
|
||||
/**
|
||||
* Adds support for chart rendering using mermaid to the markdown rendering using code fences with "mermaid" as language.
|
||||
*/
|
||||
export class MermaidMarkdownExtension extends CodeBlockMarkdownRendererExtension {
|
||||
public buildReplacers(): CodeBlockComponentReplacer[] {
|
||||
return [new CodeBlockComponentReplacer(MermaidChart, 'mermaid')]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
.mermaid > svg {
|
||||
background-color: #f8f9fa;
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { AbcjsAppExtension } from './abcjs/abcjs-app-extension'
|
||||
import { AlertAppExtension } from './alert/alert-app-extension'
|
||||
import { BlockquoteAppExtension } from './blockquote/blockquote-app-extension'
|
||||
import { CsvTableAppExtension } from './csv/csv-table-app-extension'
|
||||
import { FlowchartAppExtension } from './flowchart/flowchart-app-extension'
|
||||
import { GistAppExtension } from './gist/gist-app-extension'
|
||||
import { GraphvizAppExtension } from './graphviz/graphviz-app-extension'
|
||||
import { KatexAppExtension } from './katex/katex-app-extension'
|
||||
import { LegacyShortcodesAppExtension } from './legacy-short-codes/legacy-shortcodes-app-extension'
|
||||
import { MermaidAppExtension } from './mermaid/mermaid-app-extension'
|
||||
import { PlantumlAppExtension } from './plantuml/plantuml-app-extension'
|
||||
import { LegacySequenceDiagramAppExtension } from './sequence-diagram/legacy-sequence-diagram-app-extension'
|
||||
import { SpoilerAppExtension } from './spoiler/spoiler-app-extension'
|
||||
import { VegaLiteAppExtension } from './vega-lite/vega-lite-app-extension'
|
||||
import { VimeoAppExtension } from './vimeo/vimeo-app-extension'
|
||||
import { YoutubeAppExtension } from './youtube/youtube-app-extension'
|
||||
import { HighlightedCodeFenceAppExtension } from './highlighted-code-fence/highlighted-code-fence-app-extension'
|
||||
import type { AppExtension } from '../base/app-extension'
|
||||
import { TaskListCheckboxAppExtension } from './task-list/task-list-checkbox-app-extension'
|
||||
|
||||
/**
|
||||
* This array defines additional app extensions that are used in the editor, read only page and slideshow.
|
||||
*/
|
||||
export const optionalAppExtensions: AppExtension[] = [
|
||||
new AbcjsAppExtension(),
|
||||
new AlertAppExtension(),
|
||||
new BlockquoteAppExtension(),
|
||||
new CsvTableAppExtension(),
|
||||
new FlowchartAppExtension(),
|
||||
new GistAppExtension(),
|
||||
new GraphvizAppExtension(),
|
||||
new KatexAppExtension(),
|
||||
new LegacyShortcodesAppExtension(),
|
||||
new MermaidAppExtension(),
|
||||
new PlantumlAppExtension(),
|
||||
new LegacySequenceDiagramAppExtension(),
|
||||
new SpoilerAppExtension(),
|
||||
new VegaLiteAppExtension(),
|
||||
new VimeoAppExtension(),
|
||||
new YoutubeAppExtension(),
|
||||
new TaskListCheckboxAppExtension(),
|
||||
new HighlightedCodeFenceAppExtension()
|
||||
]
|
|
@ -0,0 +1,22 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`PlantUML markdown extensions renders a plantuml codeblock 1`] = `
|
||||
<div>
|
||||
<img
|
||||
alt="uml diagram"
|
||||
src="https://example.org/svg/SoWkIImgAStDuKhEIImkLd2jICmjo4dbSaZDIm6A0W00"
|
||||
/>
|
||||
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`PlantUML markdown extensions renders an error if no server is defined 1`] = `
|
||||
<div>
|
||||
<p
|
||||
class="alert alert-danger"
|
||||
>
|
||||
renderer.plantuml.notConfigured
|
||||
</p>
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { AppExtension } from '../../base/app-extension'
|
||||
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/base/markdown-renderer-extension'
|
||||
import { PlantumlMarkdownExtension } from './plantuml-markdown-extension'
|
||||
|
||||
/**
|
||||
* Adds support for chart rendering using plantuml to the markdown renderer.
|
||||
*
|
||||
* @see https://plantuml.com
|
||||
*/
|
||||
export class PlantumlAppExtension extends AppExtension {
|
||||
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
|
||||
return [new PlantumlMarkdownExtension()]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { render } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import { PlantumlMarkdownExtension } from './plantuml-markdown-extension'
|
||||
import * as reduxModule from '../../../redux'
|
||||
import { Mock } from 'ts-mockery'
|
||||
import { mockI18n } from '../../../components/markdown-renderer/test-utils/mock-i18n'
|
||||
import type { ApplicationState } from '../../../redux/application-state'
|
||||
import { TestMarkdownRenderer } from '../../../components/markdown-renderer/test-utils/test-markdown-renderer'
|
||||
|
||||
jest.mock('../../../redux')
|
||||
|
||||
describe('PlantUML markdown extensions', () => {
|
||||
beforeAll(() => mockI18n())
|
||||
|
||||
it('renders a plantuml codeblock', () => {
|
||||
jest.spyOn(reduxModule, 'getGlobalState').mockReturnValue(
|
||||
Mock.of<ApplicationState>({
|
||||
config: {
|
||||
plantumlServer: 'https://example.org'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<TestMarkdownRenderer
|
||||
extensions={[new PlantumlMarkdownExtension()]}
|
||||
content={'```plantuml\nclass Example\n```'}
|
||||
/>
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders an error if no server is defined', () => {
|
||||
jest.spyOn(reduxModule, 'getGlobalState').mockReturnValue(
|
||||
Mock.of<ApplicationState>({
|
||||
config: {
|
||||
plantumlServer: undefined
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<TestMarkdownRenderer
|
||||
extensions={[new PlantumlMarkdownExtension()]}
|
||||
content={'```plantuml\nclass Example\n```'}
|
||||
/>
|
||||
)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
})
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type MarkdownIt from 'markdown-it'
|
||||
import plantuml from 'markdown-it-plantuml'
|
||||
import type Renderer from 'markdown-it/lib/renderer'
|
||||
import type Token from 'markdown-it/lib/token'
|
||||
import type { Options } from 'markdown-it/lib'
|
||||
import { PlantumlNotConfiguredComponentReplacer } from './plantuml-not-configured-component-replacer'
|
||||
import { Optional } from '@mrdrogdrog/optional'
|
||||
import type { ComponentReplacer } from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import { getGlobalState } from '../../../redux'
|
||||
import { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/base/markdown-renderer-extension'
|
||||
|
||||
/**
|
||||
* Adds support for chart rendering using plantuml to the markdown rendering using code fences with "plantuml" as language.
|
||||
*
|
||||
* @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) => {
|
||||
return tokens[idx].info === 'plantuml'
|
||||
? '<plantuml-not-configured></plantuml-not-configured>'
|
||||
: defaultRenderer(tokens, idx, options, env, slf)
|
||||
}
|
||||
}
|
||||
|
||||
public configureMarkdownIt(markdownIt: MarkdownIt): void {
|
||||
Optional.ofNullable(getGlobalState().config.plantumlServer)
|
||||
.map((plantumlServer) =>
|
||||
plantuml(markdownIt, {
|
||||
openMarker: '```plantuml',
|
||||
closeMarker: '```',
|
||||
server: plantumlServer
|
||||
})
|
||||
)
|
||||
.orElseGet(() => this.plantumlError(markdownIt))
|
||||
}
|
||||
|
||||
public buildTagNameAllowList(): string[] {
|
||||
return ['plantuml-not-configured']
|
||||
}
|
||||
|
||||
buildReplacers(): ComponentReplacer[] {
|
||||
return [new PlantumlNotConfiguredComponentReplacer()]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
|
||||
/**
|
||||
* Renders an alert if plantuml is not configured.
|
||||
*/
|
||||
export const PlantumlNotConfiguredAlert: React.FC = () => {
|
||||
useTranslation()
|
||||
|
||||
return (
|
||||
<p className='alert alert-danger'>
|
||||
<Trans i18nKey={'renderer.plantuml.notConfigured'} />
|
||||
</p>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { PlantumlNotConfiguredAlert } from './plantuml-not-configured-alert'
|
||||
import type { Element } from 'domhandler'
|
||||
import type { NodeReplacement } from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import {
|
||||
ComponentReplacer,
|
||||
DO_NOT_REPLACE
|
||||
} from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
|
||||
/**
|
||||
* Replaces every plantuml-not-configured tag with a {@link PlantumlNotConfiguredAlert}.
|
||||
*/
|
||||
export class PlantumlNotConfiguredComponentReplacer extends ComponentReplacer {
|
||||
replace(node: Element): NodeReplacement {
|
||||
return node.tagName === 'plantuml-not-configured' ? <PlantumlNotConfiguredAlert /> : DO_NOT_REPLACE
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { AppExtension } from '../../base/app-extension'
|
||||
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/base/markdown-renderer-extension'
|
||||
import { LegacySequenceDiagramMarkdownExtension } from './legacy-sequence-diagram-markdown-extension'
|
||||
import type { Linter } from '../../../components/editor-page/editor-pane/linter/linter'
|
||||
import { SingleLineRegexLinter } from '../../../components/editor-page/editor-pane/linter/single-line-regex-linter'
|
||||
import { t } from 'i18next'
|
||||
|
||||
/**
|
||||
* Adds legacy support for sequence diagram syntax to the markdown renderer.
|
||||
*/
|
||||
export class LegacySequenceDiagramAppExtension extends AppExtension {
|
||||
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
|
||||
return [new LegacySequenceDiagramMarkdownExtension()]
|
||||
}
|
||||
|
||||
buildCodeMirrorLinter(): Linter[] {
|
||||
return [new SingleLineRegexLinter(/```sequence/, t('editor.linter.sequence'), () => '```mermaid\nsequenceDiagram')]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { SequenceDiagram } from './sequence-diagram'
|
||||
import { CodeBlockMarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/base/code-block-markdown-extension/code-block-markdown-renderer-extension'
|
||||
import { CodeBlockComponentReplacer } from '../../../components/markdown-renderer/replace-components/code-block-component-replacer'
|
||||
|
||||
/**
|
||||
* Adds legacy support for sequence diagram to the markdown rendering using code fences with "sequence" as language.
|
||||
*/
|
||||
export class LegacySequenceDiagramMarkdownExtension extends CodeBlockMarkdownRendererExtension {
|
||||
public buildReplacers(): CodeBlockComponentReplacer[] {
|
||||
return [new CodeBlockComponentReplacer(SequenceDiagram, 'sequence')]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { MermaidChart } from '../mermaid/mermaid-chart'
|
||||
import type { CodeProps } from '../../../components/markdown-renderer/replace-components/code-block-component-replacer'
|
||||
|
||||
/**
|
||||
* Renders a sequence diagram with a deprecation notice.
|
||||
*
|
||||
* @param code the sequence diagram code
|
||||
*/
|
||||
export const SequenceDiagram: React.FC<CodeProps> = ({ code }) => {
|
||||
return <MermaidChart code={'sequenceDiagram\n' + code} />
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { AppExtension } from '../../base/app-extension'
|
||||
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/base/markdown-renderer-extension'
|
||||
import { SpoilerMarkdownExtension } from './spoiler-markdown-extension'
|
||||
|
||||
/**
|
||||
* Adds support for html spoiler tags.
|
||||
*
|
||||
* @see https://www.w3schools.com/tags/tag_details.asp
|
||||
*/
|
||||
export class SpoilerAppExtension extends AppExtension {
|
||||
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
|
||||
return [new SpoilerMarkdownExtension()]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type MarkdownIt from 'markdown-it'
|
||||
import markdownItContainer from 'markdown-it-container'
|
||||
import type Token from 'markdown-it/lib/token'
|
||||
import { escapeHtml } from 'markdown-it/lib/common/utils'
|
||||
import { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/base/markdown-renderer-extension'
|
||||
|
||||
/**
|
||||
* Adds support for html spoiler tags.
|
||||
*
|
||||
* @see https://www.w3schools.com/tags/tag_details.asp
|
||||
*/
|
||||
export class SpoilerMarkdownExtension extends MarkdownRendererExtension {
|
||||
private static readonly spoilerRegEx = /^spoiler\s+(.*)$/
|
||||
|
||||
private static renderSpoilerContainer(tokens: Token[], index: number): string {
|
||||
const matches = SpoilerMarkdownExtension.spoilerRegEx.exec(tokens[index].info.trim())
|
||||
|
||||
return tokens[index].nesting === 1 && matches && matches[1]
|
||||
? `<details><summary>${escapeHtml(matches[1])}</summary>`
|
||||
: '</details>\n'
|
||||
}
|
||||
|
||||
public configureMarkdownIt(markdownIt: MarkdownIt): void {
|
||||
markdownItContainer(markdownIt, 'spoiler', {
|
||||
validate: (params: string) => SpoilerMarkdownExtension.spoilerRegEx.test(params),
|
||||
render: SpoilerMarkdownExtension.renderSpoilerContainer.bind(this)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react'
|
||||
import type { TaskCheckedChangeHandler, TaskListProps } from './task-list-checkbox'
|
||||
import { TaskListCheckbox } from './task-list-checkbox'
|
||||
import { useExtensionEventEmitter } from '../../../components/markdown-renderer/hooks/use-extension-event-emitter'
|
||||
import { TaskListCheckboxAppExtension } from './task-list-checkbox-app-extension'
|
||||
|
||||
type EventEmittingTaskListCheckboxProps = Omit<TaskListProps, 'onTaskCheckedChange' | 'disabled'>
|
||||
|
||||
export interface TaskCheckedEventPayload {
|
||||
lineInMarkdown: number
|
||||
checked: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a {@link TaskListCheckbox} but sends state changes to the current {@link EventEmitter2 event emitter}.
|
||||
*
|
||||
* @param props Props that will be forwarded to the checkbox.
|
||||
*/
|
||||
export const EventEmittingTaskListCheckbox: React.FC<EventEmittingTaskListCheckboxProps> = (props) => {
|
||||
const emitter = useExtensionEventEmitter()
|
||||
const sendEvent: TaskCheckedChangeHandler = useCallback(
|
||||
(lineInMarkdown: number, checked: boolean) => {
|
||||
emitter?.emit(TaskListCheckboxAppExtension.EVENT_NAME, { lineInMarkdown, checked } as TaskCheckedEventPayload)
|
||||
},
|
||||
[emitter]
|
||||
)
|
||||
|
||||
return <TaskListCheckbox onTaskCheckedChange={sendEvent} disabled={emitter === undefined} {...props} />
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type React from 'react'
|
||||
import { useSetCheckboxInEditor } from './use-set-checkbox-in-editor'
|
||||
import { useExtensionEventEmitterHandler } from '../../../components/markdown-renderer/hooks/use-extension-event-emitter'
|
||||
import { TaskListCheckboxAppExtension } from './task-list-checkbox-app-extension'
|
||||
|
||||
/**
|
||||
* Receives task-checkbox-change events and modify the current editor content.
|
||||
*/
|
||||
export const SetCheckboxInEditor: React.FC = () => {
|
||||
const changeCallback = useSetCheckboxInEditor()
|
||||
useExtensionEventEmitterHandler(TaskListCheckboxAppExtension.EVENT_NAME, changeCallback)
|
||||
return null
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type EventEmitter2 from 'eventemitter2'
|
||||
import { AppExtension } from '../../base/app-extension'
|
||||
import { TaskListMarkdownExtension } from './task-list-markdown-extension'
|
||||
import type React from 'react'
|
||||
import { SetCheckboxInEditor } from './set-checkbox-in-editor'
|
||||
|
||||
/**
|
||||
* Adds support for interactive checkbox lists to the markdown renderer.
|
||||
*/
|
||||
export class TaskListCheckboxAppExtension extends AppExtension {
|
||||
public static readonly EVENT_NAME = 'TaskListCheckbox'
|
||||
|
||||
buildMarkdownRendererExtensions(eventEmitter: EventEmitter2): TaskListMarkdownExtension[] {
|
||||
return [new TaskListMarkdownExtension(eventEmitter)]
|
||||
}
|
||||
|
||||
buildEditorExtensionComponent(): React.FC {
|
||||
return SetCheckboxInEditor
|
||||
}
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react'
|
||||
|
||||
export type TaskCheckedChangeHandler = (lineInMarkdown: number, checked: boolean) => void
|
||||
|
||||
export interface TaskListProps {
|
||||
onTaskCheckedChange?: TaskCheckedChangeHandler
|
||||
checked: boolean
|
||||
lineInMarkdown?: number
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a task list checkbox.
|
||||
*
|
||||
* @param onTaskCheckedChange A callback that is executed if the checkbox was clicked. If this prop is omitted then the checkbox will be disabled.
|
||||
* @param checked Determines if the checkbox should be rendered as checked
|
||||
* @param lineInMarkdown Defines the line in the markdown code this checkbox is mapped to. The information is send with the onTaskCheckedChange callback.
|
||||
*/
|
||||
export const TaskListCheckbox: React.FC<TaskListProps> = ({
|
||||
onTaskCheckedChange,
|
||||
checked,
|
||||
lineInMarkdown,
|
||||
disabled
|
||||
}) => {
|
||||
const onChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
if (onTaskCheckedChange && disabled !== true && lineInMarkdown !== undefined) {
|
||||
onTaskCheckedChange(lineInMarkdown, event.currentTarget.checked)
|
||||
}
|
||||
},
|
||||
[disabled, lineInMarkdown, onTaskCheckedChange]
|
||||
)
|
||||
|
||||
return (
|
||||
<input
|
||||
disabled={disabled !== true && onTaskCheckedChange === undefined}
|
||||
className='task-list-item-checkbox'
|
||||
type='checkbox'
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type MarkdownIt from 'markdown-it'
|
||||
import { TaskListReplacer } from './task-list-replacer'
|
||||
import type { ComponentReplacer } from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/base/markdown-renderer-extension'
|
||||
import { tasksLists } from '@hedgedoc/markdown-it-plugins'
|
||||
|
||||
/**
|
||||
* Adds support for interactive checkbox lists to the markdown rendering using the github checklist syntax.
|
||||
*/
|
||||
export class TaskListMarkdownExtension extends MarkdownRendererExtension {
|
||||
public configureMarkdownIt(markdownIt: MarkdownIt): void {
|
||||
tasksLists(markdownIt, {
|
||||
enabled: true,
|
||||
label: true,
|
||||
lineNumber: true
|
||||
})
|
||||
}
|
||||
|
||||
public buildReplacers(): ComponentReplacer[] {
|
||||
return [new TaskListReplacer()]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Element } from 'domhandler'
|
||||
import React from 'react'
|
||||
import type { NodeReplacement } from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import {
|
||||
ComponentReplacer,
|
||||
DO_NOT_REPLACE
|
||||
} from '../../../components/markdown-renderer/replace-components/component-replacer'
|
||||
import { EventEmittingTaskListCheckbox } from './event-emitting-task-list-checkbox'
|
||||
|
||||
/**
|
||||
* Detects task lists and renders them as checkboxes that execute a callback if clicked.
|
||||
*/
|
||||
export class TaskListReplacer extends ComponentReplacer {
|
||||
public replace(node: Element): NodeReplacement {
|
||||
if (node.attribs?.class !== 'task-list-item-checkbox') {
|
||||
return DO_NOT_REPLACE
|
||||
}
|
||||
const lineInMarkdown = Number(node.attribs['data-line'])
|
||||
return isNaN(lineInMarkdown) ? (
|
||||
DO_NOT_REPLACE
|
||||
) : (
|
||||
<EventEmittingTaskListCheckbox checked={node.attribs.checked !== undefined} lineInMarkdown={lineInMarkdown} />
|
||||
)
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue