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:
Tilman Vatteroth 2022-11-11 11:16:18 +01:00
parent 4e18ce38f3
commit 762a0a850e
No known key found for this signature in database
GPG key ID: B97799103358209B
1051 changed files with 0 additions and 35 deletions

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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, '\\$&')
}

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -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=&gt;start: Start
e=&gt;end: End
st-&gt;e
</code>
</span>
</pre>
</div>
`;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,114 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`KaTeX markdown extensions renders a valid block LaTeX expression in a single line 1`] = `
<div>
<div
data-testid="katex-block"
>
<span>
This is a mock for lib katex with this parameters:
</span>
<ul>
<li>
tex: \\alpha
</li>
<li>
block: true
</li>
</ul>
</div>
</div>
`;
exports[`KaTeX markdown extensions renders a valid block LaTeX expression in multi line 1`] = `
<div>
<div
data-testid="katex-block"
>
<span>
This is a mock for lib katex with this parameters:
</span>
<ul>
<li>
tex:
\\alpha
</li>
<li>
block: true
</li>
</ul>
</div>
</div>
`;
exports[`KaTeX markdown extensions renders a valid inline LaTeX expression 1`] = `
<div>
<p>
<span
data-testid="katex-inline"
>
<span>
This is a mock for lib katex with this parameters:
</span>
<ul>
<li>
tex: \\alpha
</li>
<li>
block: false
</li>
</ul>
</span>
</p>
</div>
`;
exports[`KaTeX markdown extensions renders an error message for an invalid LaTeX expression 1`] = `
<div>
<p>
<span
data-testid="katex-inline"
>
<div
class="fade d-inline-block alert alert-danger show"
role="alert"
>
mocked parseerror
</div>
</span>
</p>
</div>
`;

View file

@ -0,0 +1,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()]
}
}

View file

@ -0,0 +1,65 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { render } from '@testing-library/react'
import KatexFrame from './katex-frame'
import type { KatexOptions } from 'katex'
import { default as KatexDefault } from 'katex'
jest.mock('katex')
describe('katex frame', () => {
afterAll(() => {
jest.resetAllMocks()
jest.resetModules()
})
beforeEach(() => {
jest.spyOn(KatexDefault, 'renderToString').mockImplementation(
(tex: string, options?: KatexOptions) => `<span>This is a mock for lib katex with this parameters:</span>
<ul>
<li>tex: ${tex}</li>
<li>block: ${String(options?.displayMode)}</li>
</ul>`
)
})
describe('renders a valid latex expression', () => {
it('as implicit inline', () => {
const view = render(<KatexFrame expression={'\\int_0^\\infty x^2 dx'}></KatexFrame>)
expect(view.container).toMatchSnapshot()
})
it('as explicit inline', () => {
const view = render(<KatexFrame expression={'\\int_0^\\infty x^2 dx'} block={false}></KatexFrame>)
expect(view.container).toMatchSnapshot()
})
it('as explicit block', () => {
const view = render(<KatexFrame expression={'\\int_0^\\infty x^2 dx'} block={true}></KatexFrame>)
expect(view.container).toMatchSnapshot()
})
})
describe('renders an error for an invalid latex expression', () => {
beforeEach(() => {
jest.spyOn(KatexDefault, 'renderToString').mockImplementation(() => {
throw new Error('mocked parseerror')
})
})
it('as implicit inline', () => {
const view = render(<KatexFrame expression={'\\alf'}></KatexFrame>)
expect(view.container).toMatchSnapshot()
})
it('as explicit inline', () => {
const view = render(<KatexFrame expression={'\\alf'} block={false}></KatexFrame>)
expect(view.container).toMatchSnapshot()
})
it('as explicit block', () => {
const view = render(<KatexFrame expression={'\\alf'} block={true}></KatexFrame>)
expect(view.container).toMatchSnapshot()
})
})
})

View file

@ -0,0 +1,46 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useMemo } from 'react'
import KaTeX from 'katex'
import convertHtmlToReact from '@hedgedoc/html-to-react'
import 'katex/dist/katex.min.css'
import { Alert } from 'react-bootstrap'
import { 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

View file

@ -0,0 +1,75 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { render, screen } from '@testing-library/react'
import { KatexMarkdownExtension } from './katex-markdown-extension'
import { 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()
})
})

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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