Introduce Markdown extensions (#1614)

* Introduce markdown extensions

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2021-11-15 17:04:49 +01:00 committed by GitHub
parent e9defd60dc
commit 8a8bacc0aa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
148 changed files with 1878 additions and 1128 deletions

View file

@ -14,13 +14,13 @@ describe('Quote extra tags', function () {
cy.setCodemirrorContent('[name=testy mctestface]') cy.setCodemirrorContent('[name=testy mctestface]')
cy.getMarkdownBody() cy.getMarkdownBody()
.find('.quote-extra') .find('.blockquote-extra')
.should('be.visible') .should('be.visible')
.find('.fa-user') .find('.fa-user')
.should('be.visible') .should('be.visible')
cy.getMarkdownBody() cy.getMarkdownBody()
.find('.quote-extra') .find('.blockquote-extra')
.should('be.visible') .should('be.visible')
.contains('testy mctestface') .contains('testy mctestface')
}) })
@ -31,13 +31,13 @@ describe('Quote extra tags', function () {
cy.setCodemirrorContent(`[time=always]`) cy.setCodemirrorContent(`[time=always]`)
cy.getMarkdownBody() cy.getMarkdownBody()
.find('.quote-extra') .find('.blockquote-extra')
.should('be.visible') .should('be.visible')
.find('.fa-clock-o') .find('.fa-clock-o')
.should('be.visible') .should('be.visible')
cy.getMarkdownBody() cy.getMarkdownBody()
.find('.quote-extra') .find('.blockquote-extra')
.should('be.visible') .should('be.visible')
.contains('always') .contains('always')
}) })
@ -48,13 +48,13 @@ describe('Quote extra tags', function () {
cy.setCodemirrorContent(`[color=#b51f08]`) cy.setCodemirrorContent(`[color=#b51f08]`)
cy.getMarkdownBody() cy.getMarkdownBody()
.find('.quote-extra') .find('.blockquote-extra')
.should('be.visible') .should('be.visible')
.find('.fa-tag') .find('.fa-tag')
.should('be.visible') .should('be.visible')
cy.getMarkdownBody() cy.getMarkdownBody()
.find('.quote-extra') .find('.blockquote-extra')
.should('be.visible') .should('be.visible')
.should('have.css', 'color', 'rgb(181, 31, 8)') .should('have.css', 'color', 'rgb(181, 31, 8)')
}) })
@ -63,7 +63,7 @@ describe('Quote extra tags', function () {
cy.setCodemirrorContent(`> [color=#b51f08] HedgeDoc`) cy.setCodemirrorContent(`> [color=#b51f08] HedgeDoc`)
cy.getMarkdownBody() cy.getMarkdownBody()
.find('.quote-extra') .find('.blockquote-extra')
.should('not.exist') .should('not.exist')
cy.getMarkdownBody() cy.getMarkdownBody()

View file

@ -15,6 +15,9 @@
"locked": "Mouse input locked", "locked": "Mouse input locked",
"unlocked": "Mouse input unlocked" "unlocked": "Mouse input unlocked"
}, },
"plantuml": {
"notConfigured": "PlantUML plugin is enabled but not properly configured."
},
"flowchart": { "flowchart": {
"invalidSyntax": "Invalid flowchart.js syntax!" "invalidSyntax": "Invalid flowchart.js syntax!"
}, },

View file

@ -4,10 +4,13 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import type { ImageProxyResponse } from '../../components/markdown-renderer/replace-components/image/types'
import { isMockMode } from '../../utils/test-modes' import { isMockMode } from '../../utils/test-modes'
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils' import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils'
export interface ImageProxyResponse {
src: string
}
export const getProxiedUrl = async (imageUrl: string): Promise<ImageProxyResponse> => { export const getProxiedUrl = async (imageUrl: string): Promise<ImageProxyResponse> => {
const response = await fetch(getApiUrl() + 'media/proxy', { const response = await fetch(getApiUrl() + 'media/proxy', {
...defaultFetchConfig, ...defaultFetchConfig,

View file

@ -40,7 +40,7 @@ describe('yaml frontmatter', () => {
it('should parse "breaks"', () => { it('should parse "breaks"', () => {
const noteFrontmatter = createNoteFrontmatterFromYaml('breaks: false') const noteFrontmatter = createNoteFrontmatterFromYaml('breaks: false')
expect(noteFrontmatter.breaks).toEqual(false) expect(noteFrontmatter.newlinesAreBreaks).toEqual(false)
}) })
it('should parse an empty opengraph object', () => { it('should parse an empty opengraph object', () => {

View file

@ -21,7 +21,7 @@ export interface NoteFrontmatter {
robots: string robots: string
lang: typeof ISO6391[number] lang: typeof ISO6391[number]
dir: NoteTextDirection dir: NoteTextDirection
breaks: boolean newlinesAreBreaks: boolean
GA: string GA: string
disqus: string disqus: string
type: NoteType type: NoteType
@ -51,7 +51,7 @@ export const parseRawNoteFrontmatter = (rawData: RawNoteFrontmatter): NoteFrontm
title: rawData.title ?? '', title: rawData.title ?? '',
description: rawData.description ?? '', description: rawData.description ?? '',
robots: rawData.robots ?? '', robots: rawData.robots ?? '',
breaks: rawData.breaks ?? true, newlinesAreBreaks: rawData.breaks ?? true,
GA: rawData.GA ?? '', GA: rawData.GA ?? '',
disqus: rawData.disqus ?? '', disqus: rawData.disqus ?? '',
lang: (rawData.lang ? ISO6391.find((lang) => lang === rawData.lang) : undefined) ?? 'en', lang: (rawData.lang ? ISO6391.find((lang) => lang === rawData.lang) : undefined) ?? 'en',

View file

@ -13,7 +13,7 @@ export interface CheatsheetLineProps {
} }
const HighlightedCode = React.lazy( const HighlightedCode = React.lazy(
() => import('../../../markdown-renderer/replace-components/highlighted-fence/highlighted-code/highlighted-code') () => import('../../../markdown-renderer/markdown-extension/highlighted-fence/highlighted-code')
) )
const DocumentMarkdownRenderer = React.lazy(() => import('../../../markdown-renderer/document-markdown-renderer')) const DocumentMarkdownRenderer = React.lazy(() => import('../../../markdown-renderer/document-markdown-renderer'))

View file

@ -6,16 +6,16 @@
import type { Editor, Hint, Hints } from 'codemirror' import type { Editor, Hint, Hints } from 'codemirror'
import { Pos } from 'codemirror' import { Pos } from 'codemirror'
import { validAlertLevels } from '../../../markdown-renderer/markdown-it-plugins/alert-container'
import type { Hinter } from './index' import type { Hinter } from './index'
import { findWordAtCursor } from './index' import { findWordAtCursor } from './index'
import { alertLevels } from '../../../markdown-renderer/markdown-extension/alert-markdown-extension'
const wordRegExp = /^:::((?:\w|-|\+)*)$/ const wordRegExp = /^:::((?:\w|-|\+)*)$/
const spoilerSuggestion: Hint = { const spoilerSuggestion: Hint = {
text: ':::spoiler Toggle label\nToggled content\n::: \n', text: ':::spoiler Toggle label\nToggled content\n::: \n',
displayText: 'spoiler' displayText: 'spoiler'
} }
const suggestions = validAlertLevels const suggestions = alertLevels
.map( .map(
(suggestion: string): Hint => ({ (suggestion: string): Hint => ({
text: ':::' + suggestion + '\n\n::: \n', text: ':::' + suggestion + '\n\n::: \n',

View file

@ -5,7 +5,7 @@
*/ */
import React, { useCallback, useState } from 'react' import React, { useCallback, useState } from 'react'
import { ImageLightboxModal } from '../../markdown-renderer/replace-components/image/image-lightbox-modal' import { ImageLightboxModal } from '../../markdown-renderer/markdown-extension/image/image-lightbox-modal'
import type { import type {
ImageClickedMessage, ImageClickedMessage,
ImageDetails ImageDetails

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import type { LineMarkerPosition } from '../../markdown-renderer/types' import type { LineMarkerPosition } from '../../markdown-renderer/markdown-extension/linemarker/types'
export const findLineMarks = ( export const findLineMarks = (
lineMarks: LineMarkerPosition[], lineMarks: LineMarkerPosition[],

View file

@ -8,8 +8,8 @@ import type { TocAst } from 'markdown-it-toc-done-right'
import type { ReactElement } from 'react' import type { ReactElement } from 'react'
import React, { Fragment } from 'react' import React, { Fragment } from 'react'
import { ShowIf } from '../../common/show-if/show-if' import { ShowIf } from '../../common/show-if/show-if'
import { createJumpToMarkClickEventHandler } from '../../markdown-renderer/replace-components/link-replacer/link-replacer'
import { tocSlugify } from './toc-slugify' import { tocSlugify } from './toc-slugify'
import { JumpAnchor } from '../../markdown-renderer/markdown-extension/link-replacer/jump-anchor'
export const buildReactDomFromTocAst = ( export const buildReactDomFromTocAst = (
toc: TocAst, toc: TocAst,
@ -32,9 +32,9 @@ export const buildReactDomFromTocAst = (
const content = ( const content = (
<Fragment> <Fragment>
<ShowIf condition={toc.l > 0}> <ShowIf condition={toc.l > 0}>
<a href={headlineUrl} title={rawName} onClick={createJumpToMarkClickEventHandler(slug.substr(1))}> <JumpAnchor href={headlineUrl} title={rawName} jumpTargetId={slug.substr(1)}>
{rawName} {rawName}
</a> </JumpAnchor>
</ShowIf> </ShowIf>
<ShowIf condition={toc.c.length > 0}> <ShowIf condition={toc.c.length > 0}>
<ul> <ul>

View file

@ -5,17 +5,17 @@
*/ */
import type { TocAst } from 'markdown-it-toc-done-right' import type { TocAst } from 'markdown-it-toc-done-right'
import type { ImageClickHandler } from './replace-components/image/image-replacer' import type { ImageClickHandler } from './markdown-extension/image/proxy-image-replacer'
import type { Ref } from 'react' import type { Ref } from 'react'
export interface CommonMarkdownRendererProps { export interface CommonMarkdownRendererProps {
onFirstHeadingChange?: (firstHeading: string | undefined) => void onFirstHeadingChange?: (firstHeading: string | undefined) => void
onTaskCheckedChange?: (lineInMarkdown: number, checked: boolean) => void onTaskCheckedChange?: (lineInMarkdown: number, checked: boolean) => void
onTocChange?: (ast?: TocAst) => void onTocChange?: (ast?: TocAst) => void
baseUrl?: string baseUrl: string
onImageClick?: ImageClickHandler onImageClick?: ImageClickHandler
outerContainerRef?: Ref<HTMLDivElement> outerContainerRef?: Ref<HTMLDivElement>
useAlternativeBreaks?: boolean newlinesAreBreaks?: boolean
lineOffset?: number lineOffset?: number
className?: string className?: string
content: string content: string

View file

@ -8,17 +8,17 @@ import React, { useMemo, useRef } from 'react'
import { DocumentLengthLimitReachedAlert } from './document-length-limit-reached-alert' import { DocumentLengthLimitReachedAlert } from './document-length-limit-reached-alert'
import { useConvertMarkdownToReactDom } from './hooks/use-convert-markdown-to-react-dom' import { useConvertMarkdownToReactDom } from './hooks/use-convert-markdown-to-react-dom'
import './markdown-renderer.scss' import './markdown-renderer.scss'
import type { LineMarkerPosition } from './types' import type { LineMarkerPosition } from './markdown-extension/linemarker/types'
import { useComponentReplacers } from './hooks/use-component-replacers'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import type { LineMarkers } from './replace-components/linemarker/line-number-marker' import type { LineMarkers } from './markdown-extension/linemarker/add-line-marker-markdown-it-plugin'
import { useCalculateLineMarkerPosition } from './utils/calculate-line-marker-positions' import { useCalculateLineMarkerPosition } from './utils/calculate-line-marker-positions'
import { useExtractFirstHeadline } from './hooks/use-extract-first-headline' import { useExtractFirstHeadline } from './hooks/use-extract-first-headline'
import type { TocAst } from 'markdown-it-toc-done-right' import type { TocAst } from 'markdown-it-toc-done-right'
import { useOnRefChange } from './hooks/use-on-ref-change' import { useOnRefChange } from './hooks/use-on-ref-change'
import { useTrimmedContent } from './hooks/use-trimmed-content' import { useTrimmedContent } from './hooks/use-trimmed-content'
import type { CommonMarkdownRendererProps } from './common-markdown-renderer-props' import type { CommonMarkdownRendererProps } from './common-markdown-renderer-props'
import { DocumentMarkdownItConfigurator } from './markdown-it-configurator/document-markdown-it-configurator' import { useMarkdownExtensions } from './hooks/use-markdown-extensions'
import { HeadlineAnchorsMarkdownExtension } from './markdown-extension/headline-anchors-markdown-extension'
export interface DocumentMarkdownRendererProps extends CommonMarkdownRendererProps { export interface DocumentMarkdownRendererProps extends CommonMarkdownRendererProps {
onLineMarkerPositionChanged?: (lineMarkerPosition: LineMarkerPosition[]) => void onLineMarkerPositionChanged?: (lineMarkerPosition: LineMarkerPosition[]) => void
@ -34,7 +34,7 @@ export const DocumentMarkdownRenderer: React.FC<DocumentMarkdownRendererProps> =
baseUrl, baseUrl,
onImageClick, onImageClick,
outerContainerRef, outerContainerRef,
useAlternativeBreaks, newlinesAreBreaks,
lineOffset lineOffset
}) => { }) => {
const markdownBodyRef = useRef<HTMLDivElement>(null) const markdownBodyRef = useRef<HTMLDivElement>(null)
@ -42,21 +42,16 @@ export const DocumentMarkdownRenderer: React.FC<DocumentMarkdownRendererProps> =
const tocAst = useRef<TocAst>() const tocAst = useRef<TocAst>()
const [trimmedContent, contentExceedsLimit] = useTrimmedContent(content) const [trimmedContent, contentExceedsLimit] = useTrimmedContent(content)
const markdownIt = useMemo( const extensions = useMarkdownExtensions(
() => baseUrl,
new DocumentMarkdownItConfigurator({ currentLineMarkers,
onTocChange: (toc) => (tocAst.current = toc), useMemo(() => [new HeadlineAnchorsMarkdownExtension()], []),
onLineMarkers: lineOffset,
onLineMarkerPositionChanged === undefined onTaskCheckedChange,
? undefined onImageClick,
: (lineMarkers) => (currentLineMarkers.current = lineMarkers), onTocChange
useAlternativeBreaks,
lineOffset
}).buildConfiguredMarkdownIt(),
[onLineMarkerPositionChanged, useAlternativeBreaks, lineOffset]
) )
const replacers = useComponentReplacers(onTaskCheckedChange, onImageClick, baseUrl, lineOffset) const markdownReactDom = useConvertMarkdownToReactDom(trimmedContent, extensions, newlinesAreBreaks)
const markdownReactDom = useConvertMarkdownToReactDom(trimmedContent, markdownIt, replacers)
useTranslation() useTranslation()
useCalculateLineMarkerPosition( useCalculateLineMarkerPosition(

View file

@ -1,25 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Document } from 'domhandler'
import render from 'dom-serializer'
import DOMPurify from 'dompurify'
import { parseDocument } from 'htmlparser2'
const customTags = ['app-linemarker', 'app-katex', 'app-gist', 'app-youtube', 'app-vimeo', 'app-asciinema']
/**
* Sanitizes the given {@link Document document}.
*
* @param document The dirty document
* @return the sanitized Document
*/
export const domPurifierNodePreprocessor = (document: Document): Document => {
const sanitizedHtml = DOMPurify.sanitize(render(document), {
ADD_TAGS: customTags
})
return parseDocument(sanitizedHtml)
}

View file

@ -1,72 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useMemo } from 'react'
import type { ComponentReplacer } from '../replace-components/component-replacer'
import { CsvReplacer } from '../replace-components/csv/csv-replacer'
import { HighlightedCodeReplacer } from '../replace-components/highlighted-fence/highlighted-fence-replacer'
import type { ImageClickHandler } from '../replace-components/image/image-replacer'
import { ImageReplacer } from '../replace-components/image/image-replacer'
import { KatexReplacer } from '../replace-components/katex/katex-replacer'
import { LinemarkerReplacer } from '../replace-components/linemarker/linemarker-replacer'
import { LinkReplacer } from '../replace-components/link-replacer/link-replacer'
import { ColoredBlockquoteReplacer } from '../replace-components/colored-blockquote/colored-blockquote-replacer'
import type { TaskCheckedChangeHandler } from '../replace-components/task-list/task-list-replacer'
import { TaskListReplacer } from '../replace-components/task-list/task-list-replacer'
import { CodeBlockComponentReplacer } from '../replace-components/code-block-component-replacer'
import { GraphvizFrame } from '../replace-components/graphviz/graphviz-frame'
import { MarkmapFrame } from '../replace-components/markmap/markmap-frame'
import { VegaChart } from '../replace-components/vega-lite/vega-chart'
import { MermaidChart } from '../replace-components/mermaid/mermaid-chart'
import { FlowChart } from '../replace-components/flow/flowchart'
import { SequenceDiagram } from '../replace-components/sequence-diagram/sequence-diagram'
import { AbcFrame } from '../replace-components/abc/abc-frame'
import { CustomTagWithIdComponentReplacer } from '../replace-components/custom-tag-with-id-component-replacer'
import { GistFrame } from '../replace-components/gist/gist-frame'
import { YouTubeFrame } from '../replace-components/youtube/youtube-frame'
import { VimeoFrame } from '../replace-components/vimeo/vimeo-frame'
import { AsciinemaFrame } from '../replace-components/asciinema/asciinema-frame'
/**
* Provides a function that creates a list of {@link ComponentReplacer component replacer} instances.
*
* @param onTaskCheckedChange A callback that gets executed if a task checkbox gets clicked
* @param onImageClick A callback that should be executed if an image gets clicked
* @param baseUrl The base url for relative links
* @param frontmatterLinesToSkip The number of lines of the frontmatter part to add this as offset to line-numbers.
*
* @return the created list
*/
export const useComponentReplacers = (
onTaskCheckedChange?: TaskCheckedChangeHandler,
onImageClick?: ImageClickHandler,
baseUrl?: string,
frontmatterLinesToSkip?: number
): ComponentReplacer[] =>
useMemo(
() => [
new LinemarkerReplacer(),
new CustomTagWithIdComponentReplacer(GistFrame, 'gist'),
new CustomTagWithIdComponentReplacer(YouTubeFrame, 'youtube'),
new CustomTagWithIdComponentReplacer(VimeoFrame, 'vimeo'),
new CustomTagWithIdComponentReplacer(AsciinemaFrame, 'asciinema'),
new ImageReplacer(onImageClick),
new CsvReplacer(),
new CodeBlockComponentReplacer(AbcFrame, 'abc'),
new CodeBlockComponentReplacer(SequenceDiagram, 'sequence'),
new CodeBlockComponentReplacer(FlowChart, 'flow'),
new CodeBlockComponentReplacer(MermaidChart, 'mermaid'),
new CodeBlockComponentReplacer(GraphvizFrame, 'graphviz'),
new CodeBlockComponentReplacer(MarkmapFrame, 'markmap'),
new CodeBlockComponentReplacer(VegaChart, 'vega-lite'),
new HighlightedCodeReplacer(),
new ColoredBlockquoteReplacer(),
new KatexReplacer(),
new TaskListReplacer(frontmatterLinesToSkip, onTaskCheckedChange),
new LinkReplacer(baseUrl)
],
[onImageClick, onTaskCheckedChange, baseUrl, frontmatterLinesToSkip]
)

View file

@ -4,50 +4,83 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import type MarkdownIt from 'markdown-it/lib' import MarkdownIt from 'markdown-it/lib'
import { useMemo } from 'react' import { useMemo } from 'react'
import type { ComponentReplacer, ValidReactDomElement } from '../replace-components/component-replacer' import type { ComponentReplacer, ValidReactDomElement } from '../replace-components/component-replacer'
import convertHtmlToReact from '@hedgedoc/html-to-react' import convertHtmlToReact from '@hedgedoc/html-to-react'
import type { Document } from 'domhandler'
import { NodeToReactTransformer } from '../utils/node-to-react-transformer' import { NodeToReactTransformer } from '../utils/node-to-react-transformer'
import { LineIdMapper } from '../utils/line-id-mapper' import { LineIdMapper } from '../utils/line-id-mapper'
import { domPurifierNodePreprocessor } from './dom-purifier-node-preprocessor' import type { MarkdownExtension } from '../markdown-extension/markdown-extension'
import type { NodeProcessor } from '../node-preprocessors/node-processor'
import type { Document } from 'domhandler'
import { SanitizerMarkdownExtension } from '../markdown-extension/sanitizer/sanitizer-markdown-extension'
/** /**
* Renders markdown code into react elements * Renders markdown code into react elements
* *
* @param markdownCode The markdown code that should be rendered * @param markdownCode The markdown code that should be rendered
* @param markdownIt The configured {@link MarkdownIt markdown it} instance that should render the code * @param additionalMarkdownExtensions A list of {@link MarkdownExtension markdown extensions} that should be used
* @param replacers A function that provides a list of {@link ComponentReplacer component replacers} * @param newlinesAreBreaks Defines if the alternative break mode of markdown it should be used
* @param preprocessNodes A function that processes nodes after parsing the html code that is generated by markdown it.
* @return The React DOM that represents the rendered markdown code * @return The React DOM that represents the rendered markdown code
*/ */
export const useConvertMarkdownToReactDom = ( export const useConvertMarkdownToReactDom = (
markdownCode: string, markdownCode: string,
markdownIt: MarkdownIt, additionalMarkdownExtensions: MarkdownExtension[],
replacers: ComponentReplacer[], newlinesAreBreaks?: boolean
preprocessNodes?: (nodes: Document) => Document
): ValidReactDomElement[] => { ): ValidReactDomElement[] => {
const lineNumberMapper = useMemo(() => new LineIdMapper(), []) const lineNumberMapper = useMemo(() => new LineIdMapper(), [])
const htmlToReactTransformer = useMemo(() => new NodeToReactTransformer(), []) const htmlToReactTransformer = useMemo(() => new NodeToReactTransformer(), [])
const markdownExtensions = useMemo(() => {
const tagNameWhiteList = additionalMarkdownExtensions.reduce(
(state, extension) => [...state, ...extension.buildTagNameWhitelist()],
[] as string[]
)
return [...additionalMarkdownExtensions, new SanitizerMarkdownExtension(tagNameWhiteList)]
}, [additionalMarkdownExtensions])
const markdownIt = useMemo(() => {
const newMarkdownIt = new MarkdownIt('default', {
html: true,
breaks: newlinesAreBreaks ?? true,
langPrefix: '',
typographer: true
})
markdownExtensions.forEach((extension) =>
newMarkdownIt.use((markdownIt) => extension.configureMarkdownIt(markdownIt))
)
markdownExtensions.forEach((extension) =>
newMarkdownIt.use((markdownIt) => extension.configureMarkdownItPost(markdownIt))
)
return newMarkdownIt
}, [markdownExtensions, newlinesAreBreaks])
useMemo(() => { useMemo(() => {
const replacers = markdownExtensions.reduce(
(state, extension) => [...state, ...extension.buildReplacers()],
[] as ComponentReplacer[]
)
htmlToReactTransformer.setReplacers(replacers) htmlToReactTransformer.setReplacers(replacers)
}, [htmlToReactTransformer, replacers]) }, [htmlToReactTransformer, markdownExtensions])
useMemo(() => { useMemo(() => {
htmlToReactTransformer.setLineIds(lineNumberMapper.updateLineMapping(markdownCode)) htmlToReactTransformer.setLineIds(lineNumberMapper.updateLineMapping(markdownCode))
}, [htmlToReactTransformer, lineNumberMapper, markdownCode]) }, [htmlToReactTransformer, lineNumberMapper, markdownCode])
const nodePreProcessor = useMemo(() => {
return markdownExtensions
.reduce((state, extension) => [...state, ...extension.buildNodeProcessors()], [] as NodeProcessor[])
.reduce(
(state, processor) => (document: Document) => state(processor.process(document)),
(document: Document) => document
)
}, [markdownExtensions])
return useMemo(() => { return useMemo(() => {
const html = markdownIt.render(markdownCode) const html = markdownIt.render(markdownCode)
return convertHtmlToReact(html, { return convertHtmlToReact(html, {
transform: (node, index) => htmlToReactTransformer.translateNodeToReactElement(node, index), transform: (node, index) => htmlToReactTransformer.translateNodeToReactElement(node, index),
preprocessNodes: (document: Document): Document => { preprocessNodes: (document) => nodePreProcessor(document)
const processedDocument = preprocessNodes ? preprocessNodes(document) : document
return domPurifierNodePreprocessor(processedDocument)
}
}) })
}, [htmlToReactTransformer, markdownCode, markdownIt, preprocessNodes]) }, [htmlToReactTransformer, markdownCode, markdownIt, nodePreProcessor])
} }

View file

@ -0,0 +1,111 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { MutableRefObject } from 'react'
import { useMemo } from 'react'
import { TableOfContentsMarkdownExtension } from '../markdown-extension/table-of-contents-markdown-extension'
import { VegaLiteMarkdownExtension } from '../markdown-extension/vega-lite/vega-lite-markdown-extension'
import { MarkmapMarkdownExtension } from '../markdown-extension/markmap/markmap-markdown-extension'
import { LinemarkerMarkdownExtension } from '../markdown-extension/linemarker/linemarker-markdown-extension'
import { GistMarkdownExtension } from '../markdown-extension/gist/gist-markdown-extension'
import { YoutubeMarkdownExtension } from '../markdown-extension/youtube/youtube-markdown-extension'
import { VimeoMarkdownExtension } from '../markdown-extension/vimeo/vimeo-markdown-extension'
import { AsciinemaMarkdownExtension } from '../markdown-extension/asciinema/asciinema-markdown-extension'
import { ProxyImageMarkdownExtension } from '../markdown-extension/image/proxy-image-markdown-extension'
import { CsvTableMarkdownExtension } from '../markdown-extension/csv/csv-table-markdown-extension'
import { AbcjsMarkdownExtension } from '../markdown-extension/abcjs/abcjs-markdown-extension'
import { SequenceDiagramMarkdownExtension } from '../markdown-extension/sequence-diagram/sequence-diagram-markdown-extension'
import { FlowchartMarkdownExtension } from '../markdown-extension/flowchart/flowchart-markdown-extension'
import { MermaidMarkdownExtension } from '../markdown-extension/mermaid/mermaid-markdown-extension'
import { GraphvizMarkdownExtension } from '../markdown-extension/graphviz/graphviz-markdown-extension'
import { BlockquoteExtraTagMarkdownExtension } from '../markdown-extension/blockquote/blockquote-extra-tag-markdown-extension'
import { LinkAdjustmentMarkdownExtension } from '../markdown-extension/link-replacer/link-adjustment-markdown-extension'
import { KatexMarkdownExtension } from '../markdown-extension/katex/katex-markdown-extension'
import { TaskListMarkdownExtension } from '../markdown-extension/task-list/task-list-markdown-extension'
import { PlantumlMarkdownExtension } from '../markdown-extension/plantuml/plantuml-markdown-extension'
import { LegacyShortcodesMarkdownExtension } from '../markdown-extension/legacy-short-codes/legacy-shortcodes-markdown-extension'
import { EmojiMarkdownExtension } from '../markdown-extension/emoji/emoji-markdown-extension'
import { GenericSyntaxMarkdownExtension } from '../markdown-extension/generic-syntax-markdown-extension'
import { AlertMarkdownExtension } from '../markdown-extension/alert-markdown-extension'
import { SpoilerMarkdownExtension } from '../markdown-extension/spoiler-markdown-extension'
import { LinkifyFixMarkdownExtension } from '../markdown-extension/linkify-fix-markdown-extension'
import { HighlightedCodeMarkdownExtension } from '../markdown-extension/highlighted-fence/highlighted-code-markdown-extension'
import { DebuggerMarkdownExtension } from '../markdown-extension/debugger-markdown-extension'
import { useApplicationState } from '../../../hooks/common/use-application-state'
import type { LineMarkers } from '../markdown-extension/linemarker/add-line-marker-markdown-it-plugin'
import type { ImageClickHandler } from '../markdown-extension/image/proxy-image-replacer'
import type { TocAst } from 'markdown-it-toc-done-right'
import type { MarkdownExtension } from '../markdown-extension/markdown-extension'
/**
* Provides a list of {@link MarkdownExtension markdown extensions} that is a combination of the common extensions and the given additional.
*
* @param baseUrl The base url for the {@link LinkAdjustmentMarkdownExtension}
* @param currentLineMarkers A {@link MutableRefObject reference} to {@link LineMarkers} for the {@link LinemarkerMarkdownExtension}
* @param additionalExtensions The additional extensions that should be included in the list
* @param lineOffset The line offset for the {@link LinemarkerMarkdownExtension} and {@link TaskListMarkdownExtension}
* @param onTaskCheckedChange The checkbox click callback for the {@link TaskListMarkdownExtension}
* @param onImageClick The image click callback for the {@link ProxyImageMarkdownExtension}
* @param onTocChange The toc-changed callback for the {@link TableOfContentsMarkdownExtension}
* @return The created list of markdown extensions
*/
export const useMarkdownExtensions = (
baseUrl: string,
currentLineMarkers: MutableRefObject<LineMarkers[] | undefined> | undefined,
additionalExtensions: MarkdownExtension[],
lineOffset?: number,
onTaskCheckedChange?: (lineInMarkdown: number, checked: boolean) => void,
onImageClick?: ImageClickHandler,
onTocChange?: (ast?: TocAst) => void
): MarkdownExtension[] => {
const plantumlServer = useApplicationState((state) => state.config.plantumlServer)
return useMemo(() => {
return [
new TableOfContentsMarkdownExtension(onTocChange),
...additionalExtensions,
new VegaLiteMarkdownExtension(),
new MarkmapMarkdownExtension(),
new LinemarkerMarkdownExtension(
currentLineMarkers ? (lineMarkers) => (currentLineMarkers.current = lineMarkers) : undefined,
lineOffset
),
new GistMarkdownExtension(),
new YoutubeMarkdownExtension(),
new VimeoMarkdownExtension(),
new AsciinemaMarkdownExtension(),
new ProxyImageMarkdownExtension(onImageClick),
new CsvTableMarkdownExtension(),
new AbcjsMarkdownExtension(),
new SequenceDiagramMarkdownExtension(),
new FlowchartMarkdownExtension(),
new MermaidMarkdownExtension(),
new GraphvizMarkdownExtension(),
new BlockquoteExtraTagMarkdownExtension(),
new LinkAdjustmentMarkdownExtension(baseUrl),
new KatexMarkdownExtension(),
new TaskListMarkdownExtension(lineOffset, onTaskCheckedChange),
new PlantumlMarkdownExtension(plantumlServer),
new LegacyShortcodesMarkdownExtension(),
new EmojiMarkdownExtension(),
new GenericSyntaxMarkdownExtension(),
new AlertMarkdownExtension(),
new SpoilerMarkdownExtension(),
new LinkifyFixMarkdownExtension(),
new HighlightedCodeMarkdownExtension(),
new DebuggerMarkdownExtension()
]
}, [
additionalExtensions,
baseUrl,
currentLineMarkers,
lineOffset,
onImageClick,
onTaskCheckedChange,
onTocChange,
plantumlServer
])
}

View file

@ -7,7 +7,7 @@
import React, { useEffect, useRef } from 'react' import React, { useEffect, useRef } from 'react'
import './abc.scss' import './abc.scss'
import { Logger } from '../../../../utils/logger' import { Logger } from '../../../../utils/logger'
import type { CodeProps } from '../code-block-component-replacer' import type { CodeProps } from '../../replace-components/code-block-component-replacer'
const log = new Logger('AbcFrame') const log = new Logger('AbcFrame')

View file

@ -6,7 +6,7 @@
.abcjs-score { .abcjs-score {
@import "../../../../style/variables.scss"; @import "../../../../style/variables";
.markdown-body & { .markdown-body & {
overflow-x: auto !important; overflow-x: auto !important;

View file

@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownExtension } from '../markdown-extension'
import { CodeBlockComponentReplacer } from '../../replace-components/code-block-component-replacer'
import { AbcFrame } from './abc-frame'
import type { ComponentReplacer } from '../../replace-components/component-replacer'
/**
* Adds support for abc.js to the markdown rendering using code fences with "abc" as language.
*/
export class AbcjsMarkdownExtension extends MarkdownExtension {
public buildReplacers(): ComponentReplacer[] {
return [new CodeBlockComponentReplacer(AbcFrame, 'abc')]
}
}

View file

@ -0,0 +1,28 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownExtension } from './markdown-extension'
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'
export const alertLevels = ['success', 'danger', 'info', 'warning']
export class AlertMarkdownExtension extends MarkdownExtension {
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

@ -5,8 +5,8 @@
*/ */
import React from 'react' import React from 'react'
import { ClickShield } from '../click-shield/click-shield' import type { IdProps } from '../../replace-components/custom-tag-with-id-component-replacer'
import type { IdProps } from '../custom-tag-with-id-component-replacer' import { ClickShield } from '../../replace-components/click-shield/click-shield'
/** /**
* Renders an embedding for https://asciinema.org * Renders an embedding for https://asciinema.org

View file

@ -0,0 +1,32 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownExtension } from '../markdown-extension'
import markdownItRegex from 'markdown-it-regex'
import type MarkdownIt from 'markdown-it'
import type { ComponentReplacer } from '../../replace-components/component-replacer'
import { CustomTagWithIdComponentReplacer } from '../../replace-components/custom-tag-with-id-component-replacer'
import { AsciinemaFrame } from './asciinema-frame'
import { replaceAsciinemaLink } from './replace-asciinema-link'
/**
* Adds asciinema embeddings to the markdown rendering by detecting asciinema.org links.
*/
export class AsciinemaMarkdownExtension extends MarkdownExtension {
public static readonly tagName = 'app-asciinema'
public configureMarkdownIt(markdownIt: MarkdownIt): void {
markdownItRegex(markdownIt, replaceAsciinemaLink)
}
public buildReplacers(): ComponentReplacer[] {
return [new CustomTagWithIdComponentReplacer(AsciinemaFrame, AsciinemaMarkdownExtension.tagName)]
}
public buildTagNameWhitelist(): string[] {
return [AsciinemaMarkdownExtension.tagName]
}
}

View file

@ -5,26 +5,23 @@
*/ */
import type { RegexOptions } from '../../../../external-types/markdown-it-regex/interface' import type { RegexOptions } from '../../../../external-types/markdown-it-regex/interface'
import type MarkdownIt from 'markdown-it/lib' import { AsciinemaMarkdownExtension } from './asciinema-markdown-extension'
import markdownItRegex from 'markdown-it-regex'
const protocolRegex = /(?:http(?:s)?:\/\/)?/ const protocolRegex = /(?:http(?:s)?:\/\/)?/
const domainRegex = /(?:asciinema\.org\/a\/)/ const domainRegex = /(?:asciinema\.org\/a\/)/
const idRegex = /(\d+)/ const idRegex = /(\d+)/
const tailRegex = /(?:[./?#].*)?/ const tailRegex = /(?:[./?#].*)?/
const gistUrlRegex = new RegExp(`(?:${protocolRegex.source}${domainRegex.source}${idRegex.source}${tailRegex.source})`) const asciinemaUrlRegex = new RegExp(
const linkRegex = new RegExp(`^${gistUrlRegex.source}$`, 'i') `^(?:${protocolRegex.source}${domainRegex.source}${idRegex.source}${tailRegex.source})$`,
'i'
export const asciinemaMarkdownItPlugin: MarkdownIt.PluginSimple = (markdownIt: MarkdownIt) => { )
markdownItRegex(markdownIt, replaceAsciinemaLink)
}
export const replaceAsciinemaLink: RegexOptions = { export const replaceAsciinemaLink: RegexOptions = {
name: 'asciinema-link', name: 'asciinema-link',
regex: linkRegex, regex: asciinemaUrlRegex,
replace: (match) => { replace: (match) => {
// ESLint wants to collapse this tag, but then the tag won't be valid html anymore. // ESLint wants to collapse this tag, but then the tag won't be valid html anymore.
// noinspection CheckTagEmptyBody // noinspection CheckTagEmptyBody
return `<app-asciinema id="${match}"></app-asciinema>` return `<${AsciinemaMarkdownExtension.tagName} id='${match}'></${AsciinemaMarkdownExtension.tagName}>`
} }
} }

View file

@ -0,0 +1,104 @@
/*
* SPDX-FileCopyrightText: 2021 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 { TravelerNodeProcessor } from '../../node-preprocessors/traveler-node-processor'
import Optional from 'optional-js'
import { BlockquoteExtraTagMarkdownExtension } from './blockquote-extra-tag-markdown-extension'
/**
* 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 {@code 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. {@code 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,42 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { NativeRenderer, NodeReplacement, SubNodeTransform } from '../../replace-components/component-replacer'
import { ComponentReplacer, DO_NOT_REPLACE } from '../../replace-components/component-replacer'
import type { Element } from 'domhandler'
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
import { isText } from 'domhandler'
import { cssColor } from './blockquote-border-color-node-preprocessor'
import Optional from 'optional-js'
import type { Text } from 'domhandler/lib/node'
import { BlockquoteExtraTagMarkdownExtension } from './blockquote-extra-tag-markdown-extension'
/**
* 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, subNodeTransform: SubNodeTransform, nativeRenderer: NativeRenderer): NodeReplacement {
if (
element.tagName === BlockquoteExtraTagMarkdownExtension.tagName &&
element.attribs?.['data-label'] === 'color' &&
element.children !== undefined
) {
return Optional.ofNullable(element.children[0])
.filter(isText)
.map((child) => (child as Text).data)
.filter((content) => cssColor.test(content))
.map<NodeReplacement>((color) => (
<span className={'blockquote-extra'} style={{ color: color }}>
<ForkAwesomeIcon key='icon' className={'mx-1'} icon={'tag'} />
</span>
))
.orElse(DO_NOT_REPLACE)
}
}
}

View file

@ -0,0 +1,40 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownExtension } from '../markdown-extension'
import { BlockquoteColorExtraTagReplacer } from './blockquote-color-extra-tag-replacer'
import { BlockquoteExtraTagReplacer } from './blockquote-extra-tag-replacer'
import type { ComponentReplacer } from '../../replace-components/component-replacer'
import { BlockquoteExtraTagMarkdownItPlugin } from './blockquote-extra-tag-markdown-it-plugin'
import type MarkdownIt from 'markdown-it'
import type { NodeProcessor } from '../../node-preprocessors/node-processor'
import { BlockquoteBorderColorNodePreprocessor } from './blockquote-border-color-node-preprocessor'
/**
* Adds support for generic blockquote extra tags and blockquote color extra tags.
*/
export class BlockquoteExtraTagMarkdownExtension extends MarkdownExtension {
public static readonly tagName = 'app-blockquote-tag'
public configureMarkdownIt(markdownIt: MarkdownIt): void {
new BlockquoteExtraTagMarkdownItPlugin('color', 'tag').registerInlineRule(markdownIt)
new BlockquoteExtraTagMarkdownItPlugin('name', 'user').registerInlineRule(markdownIt)
new BlockquoteExtraTagMarkdownItPlugin('time', 'clock-o').registerInlineRule(markdownIt)
BlockquoteExtraTagMarkdownItPlugin.registerRenderer(markdownIt)
}
public buildReplacers(): ComponentReplacer[] {
return [new BlockquoteColorExtraTagReplacer(), new BlockquoteExtraTagReplacer()]
}
public buildNodeProcessors(): NodeProcessor[] {
return [new BlockquoteBorderColorNodePreprocessor()]
}
public buildTagNameWhitelist(): string[] {
return [BlockquoteExtraTagMarkdownExtension.tagName]
}
}

View file

@ -4,8 +4,8 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import type { QuoteExtraTagValues } from './quote-extra' import type { QuoteExtraTagValues } from './parse-blockquote-extra-tag'
import { parseQuoteExtraTag } from './quote-extra' import { parseBlockquoteExtraTag } from './parse-blockquote-extra-tag'
describe('Quote extra syntax parser', () => { describe('Quote extra syntax parser', () => {
it('should parse a valid tag', () => { it('should parse a valid tag', () => {
@ -17,46 +17,46 @@ describe('Quote extra syntax parser', () => {
label: 'abc', label: 'abc',
value: 'def' value: 'def'
} }
expect(parseQuoteExtraTag('[abc=def]', 0, 1000)).toEqual(expected) expect(parseBlockquoteExtraTag('[abc=def]', 0, 1000)).toEqual(expected)
}) })
it("shouldn't parse a tag with no opener bracket", () => { it("shouldn't parse a tag with no opener bracket", () => {
expect(parseQuoteExtraTag('abc=def]', 0, 1000)).toEqual(undefined) expect(parseBlockquoteExtraTag('abc=def]', 0, 1000)).toEqual(undefined)
}) })
it("shouldn't parse a tag with no closing bracket", () => { it("shouldn't parse a tag with no closing bracket", () => {
expect(parseQuoteExtraTag('[abc=def', 0, 1000)).toEqual(undefined) expect(parseBlockquoteExtraTag('[abc=def', 0, 1000)).toEqual(undefined)
}) })
it("shouldn't parse a tag with no separation character", () => { it("shouldn't parse a tag with no separation character", () => {
expect(parseQuoteExtraTag('[abcdef]', 0, 1000)).toEqual(undefined) expect(parseBlockquoteExtraTag('[abcdef]', 0, 1000)).toEqual(undefined)
}) })
it("shouldn't parse a tag with an empty label", () => { it("shouldn't parse a tag with an empty label", () => {
expect(parseQuoteExtraTag('[=def]', 0, 1000)).toEqual(undefined) expect(parseBlockquoteExtraTag('[=def]', 0, 1000)).toEqual(undefined)
}) })
it("shouldn't parse a tag with an empty value", () => { it("shouldn't parse a tag with an empty value", () => {
expect(parseQuoteExtraTag('[abc=]', 0, 1000)).toEqual(undefined) expect(parseBlockquoteExtraTag('[abc=]', 0, 1000)).toEqual(undefined)
}) })
it("shouldn't parse a tag with an empty body", () => { it("shouldn't parse a tag with an empty body", () => {
expect(parseQuoteExtraTag('[]', 0, 1000)).toEqual(undefined) expect(parseBlockquoteExtraTag('[]', 0, 1000)).toEqual(undefined)
}) })
it("shouldn't parse a tag with an empty body", () => { it("shouldn't parse a tag with an empty body", () => {
expect(parseQuoteExtraTag('[]', 0, 1000)).toEqual(undefined) expect(parseBlockquoteExtraTag('[]', 0, 1000)).toEqual(undefined)
}) })
it("shouldn't parse a correct tag if start index isn't at the opening bracket", () => { it("shouldn't parse a correct tag if start index isn't at the opening bracket", () => {
expect(parseQuoteExtraTag('[abc=def]', 1, 1000)).toEqual(undefined) expect(parseBlockquoteExtraTag('[abc=def]', 1, 1000)).toEqual(undefined)
}) })
it("shouldn't parse a correct tag if maxPos ends before tag end", () => { it("shouldn't parse a correct tag if maxPos ends before tag end", () => {
expect(parseQuoteExtraTag('[abc=def]', 0, 1)).toEqual(undefined) expect(parseBlockquoteExtraTag('[abc=def]', 0, 1)).toEqual(undefined)
}) })
it("shouldn't parse a correct tag if start index is after maxPos", () => { it("shouldn't parse a correct tag if start index is after maxPos", () => {
expect(parseQuoteExtraTag(' [abc=def]', 3, 2)).toEqual(undefined) expect(parseBlockquoteExtraTag(' [abc=def]', 3, 2)).toEqual(undefined)
}) })
}) })

View file

@ -0,0 +1,78 @@
/*
* SPDX-FileCopyrightText: 2021 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 type { QuoteExtraTagValues } from './parse-blockquote-extra-tag'
import { parseBlockquoteExtraTag } from './parse-blockquote-extra-tag'
import type { IconName } from '../../../common/fork-awesome/types'
import Optional from 'optional-js'
import type StateInline from 'markdown-it/lib/rules_inline/state_inline'
import { BlockquoteExtraTagMarkdownExtension } from './blockquote-extra-tag-markdown-extension'
export interface BlockquoteTagOptions {
parseSubTags?: boolean
valueRegex?: RegExp
icon?: IconName
}
/**
* Detects the blockquote extra tag syntax `[label=value]` and creates <blockquote-tag> elements.
*/
export class BlockquoteExtraTagMarkdownItPlugin {
constructor(private tagName: string, private icon: IconName) {}
public static registerRenderer(markdownIt: MarkdownIt): void {
if (markdownIt.renderer.rules['blockquote_tag']) {
return
}
markdownIt.renderer.rules['blockquote_tag'] = (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}>`
}
}
public registerInlineRule(markdownIt: MarkdownIt): void {
markdownIt.inline.ruler.before('link', `blockquote_${this.tagName}`, (state) =>
this.parseSpecificBlockquoteTag(state)
.map((parseResults) => {
const token = this.createBlockquoteTagToken(state, parseResults)
this.processTagValue(token, state, parseResults)
return true
})
.orElse(false)
)
}
private parseSpecificBlockquoteTag(state: StateInline): Optional<QuoteExtraTagValues> {
return Optional.ofNullable(parseBlockquoteExtraTag(state.src, state.pos, state.posMax))
.filter((results) => results.label === this.tagName)
.map((parseResults) => {
state.pos = parseResults.valueEndIndex + 1
return parseResults
})
}
private createBlockquoteTagToken(state: StateInline, parseResults: QuoteExtraTagValues): Token {
const token = state.push('blockquote_tag', '', 0)
token.attrSet('label', parseResults.label)
token.attrSet('icon', this.icon)
return token
}
protected processTagValue(token: Token, state: StateInline, parseResults: QuoteExtraTagValues): void {
const childTokens: Token[] = []
state.md.inline.parse(parseResults.value, state.md, state.env, childTokens)
token.children = childTokens
}
}

View file

@ -0,0 +1,52 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { NativeRenderer, NodeReplacement, SubNodeTransform } from '../../replace-components/component-replacer'
import { ComponentReplacer, DO_NOT_REPLACE } from '../../replace-components/component-replacer'
import type { Element } from 'domhandler'
import type { ForkAwesomeIconProps } from '../../../common/fork-awesome/fork-awesome-icon'
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
import type { IconName } from '../../../common/fork-awesome/types'
import { ForkAwesomeIcons } from '../../../common/fork-awesome/fork-awesome-icons'
import Optional from 'optional-js'
import type { ReactElement } from 'react'
import { BlockquoteExtraTagMarkdownExtension } from './blockquote-extra-tag-markdown-extension'
/**
* 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, nativeRenderer: NativeRenderer): NodeReplacement {
if (element.tagName !== BlockquoteExtraTagMarkdownExtension.tagName || !element.attribs) {
return DO_NOT_REPLACE
}
return (
<span className={'blockquote-extra'}>
{this.buildIconElement(element)}
{BlockquoteExtraTagReplacer.transformChildren(element, subNodeTransform)}
</span>
)
}
/**
* 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 {@code 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<ReactElement<ForkAwesomeIconProps> | undefined>((iconName) => (
<ForkAwesomeIcon key='icon' className={'mx-1'} icon={iconName} />
))
.orElse(undefined)
}
}

View file

@ -0,0 +1,89 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export interface QuoteExtraTagValues {
labelStartIndex: number
labelEndIndex: number
valueStartIndex: number
valueEndIndex: number
label: string
value: string
}
/**
* 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.
*/
export const parseBlockquoteExtraTag = (
line: string,
startIndex: number,
dontSearchAfterIndex: number
): QuoteExtraTagValues | undefined => {
if (line[startIndex] !== '[') {
return
}
const labelStartIndex = startIndex + 1
const labelEndIndex = parseLabel(line, labelStartIndex, dontSearchAfterIndex)
if (!labelEndIndex || labelStartIndex === labelEndIndex) {
return
}
const valueStartIndex = labelEndIndex + 1
const valueEndIndex = parseValue(line, valueStartIndex, dontSearchAfterIndex)
if (!valueEndIndex || valueStartIndex === valueEndIndex) {
return
}
return {
labelStartIndex,
labelEndIndex,
valueStartIndex,
valueEndIndex,
label: line.substr(labelStartIndex, labelEndIndex - labelStartIndex),
value: line.substr(valueStartIndex, valueEndIndex - valueStartIndex)
}
}
/**
* 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.
*/
const 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.
*/
const parseLabel = (line: string, startIndex: number, dontSearchAfterIndex: number): number | undefined => {
for (let pos = startIndex; pos <= dontSearchAfterIndex; pos += 1) {
if (line[pos] === '=') {
return pos
}
}
}

View file

@ -6,9 +6,9 @@
import type { Element } from 'domhandler' import type { Element } from 'domhandler'
import React from 'react' import React from 'react'
import { ComponentReplacer } from '../component-replacer' import { ComponentReplacer } from '../../replace-components/component-replacer'
import { CsvTable } from './csv-table' import { CsvTable } from './csv-table'
import { CodeBlockComponentReplacer } from '../code-block-component-replacer' import { CodeBlockComponentReplacer } from '../../replace-components/code-block-component-replacer'
/** /**
* Detects code blocks with "csv" as language and renders them as table. * Detects code blocks with "csv" as language and renders them as table.

View file

@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownExtension } from '../markdown-extension'
import { CsvReplacer } from './csv-replacer'
import type { ComponentReplacer } from '../../replace-components/component-replacer'
/**
* Adds support for csv tables to the markdown rendering using code fences with "csv" as language.
*/
export class CsvTableMarkdownExtension extends MarkdownExtension {
public buildReplacers(): ComponentReplacer[] {
return [new CsvReplacer()]
}
}

View file

@ -31,24 +31,24 @@ export const CsvTable: React.FC<CsvTableProps> = ({
return { rowsWithColumns, headerRow } return { rowsWithColumns, headerRow }
}, [code, delimiter, showHeader]) }, [code, delimiter, showHeader])
const renderTableHeader = (row: string[]) => { const renderTableHeader = useMemo(
if (row !== []) { () =>
return ( headerRow === [] ? undefined : (
<thead> <thead>
<tr> <tr>
{row.map((column, columnNumber) => ( {headerRow.map((column, columnNumber) => (
<th key={`header-${columnNumber}`}>{column}</th> <th key={`header-${columnNumber}`}>{column}</th>
))} ))}
</tr> </tr>
</thead> </thead>
),
[headerRow]
) )
}
}
const renderTableBody = (rows: string[][]) => { const renderTableBody = useMemo(
return ( () => (
<tbody> <tbody>
{rows.map((row, rowNumber) => ( {rowsWithColumns.map((row, rowNumber) => (
<tr className={tableRowClassName} key={`row-${rowNumber}`}> <tr className={tableRowClassName} key={`row-${rowNumber}`}>
{row.map((column, columnIndex) => ( {row.map((column, columnIndex) => (
<td className={tableColumnClassName} key={`cell-${rowNumber}-${columnIndex}`}> <td className={tableColumnClassName} key={`cell-${rowNumber}-${columnIndex}`}>
@ -58,13 +58,14 @@ export const CsvTable: React.FC<CsvTableProps> = ({
</tr> </tr>
))} ))}
</tbody> </tbody>
),
[rowsWithColumns, tableColumnClassName, tableRowClassName]
) )
}
return ( return (
<table className={'csv-html-table table-striped'}> <table className={'csv-html-table table-striped'}>
{renderTableHeader(headerRow)} {renderTableHeader}
{renderTableBody(rowsWithColumns)} {renderTableBody}
</table> </table>
) )
} }

View file

@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownExtension } from './markdown-extension'
import type MarkdownIt from 'markdown-it'
import { Logger } from '../../../utils/logger'
const log = new Logger('DebuggerMarkdownExtension')
export class DebuggerMarkdownExtension extends MarkdownExtension {
public configureMarkdownItPost(markdownIt: MarkdownIt): void {
if (process.env.NODE_ENV !== 'production') {
markdownIt.core.ruler.push('printStateToConsole', (state) => {
log.debug('Current state', state)
return false
})
}
}
}

View file

@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownExtension } from '../markdown-extension'
import type MarkdownIt from 'markdown-it'
import emoji from 'markdown-it-emoji/bare'
import { combinedEmojiData } from './mapping'
/**
* Adds support for utf-8 emojis.
*/
export class EmojiMarkdownExtension extends MarkdownExtension {
public configureMarkdownIt(markdownIt: MarkdownIt): void {
markdownIt.use(emoji, {
defs: combinedEmojiData
})
}
}

View file

@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownExtension } from '../markdown-extension'
import { CodeBlockComponentReplacer } from '../../replace-components/code-block-component-replacer'
import type { ComponentReplacer } from '../../replace-components/component-replacer'
import { FlowChart } from './flowchart'
/**
* Adds support for flow charts to the markdown rendering using code fences with "flow" as language.
*/
export class FlowchartMarkdownExtension extends MarkdownExtension {
public buildReplacers(): ComponentReplacer[] {
return [new CodeBlockComponentReplacer(FlowChart, 'flow')]
}
}

View file

@ -0,0 +1,29 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownExtension } from './markdown-extension'
import type MarkdownIt from 'markdown-it'
import abbreviation from 'markdown-it-abbr'
import definitionList from 'markdown-it-deflist'
import subscript from 'markdown-it-sub'
import superscript from 'markdown-it-sup'
import inserted from 'markdown-it-ins'
import marked from 'markdown-it-mark'
import footnote from 'markdown-it-footnote'
import { imageSize } from '@hedgedoc/markdown-it-image-size'
export class GenericSyntaxMarkdownExtension extends MarkdownExtension {
public configureMarkdownIt(markdownIt: MarkdownIt): void {
abbreviation(markdownIt)
definitionList(markdownIt)
subscript(markdownIt)
superscript(markdownIt)
inserted(markdownIt)
marked(markdownIt)
footnote(markdownIt)
imageSize(markdownIt)
}
}

View file

@ -8,8 +8,8 @@ import React, { useCallback } from 'react'
import { cypressId } from '../../../../utils/cypress-attribute' import { cypressId } from '../../../../utils/cypress-attribute'
import './gist-frame.scss' import './gist-frame.scss'
import { useResizeGistFrame } from './use-resize-gist-frame' import { useResizeGistFrame } from './use-resize-gist-frame'
import { ClickShield } from '../click-shield/click-shield' import type { IdProps } from '../../replace-components/custom-tag-with-id-component-replacer'
import type { IdProps } from '../custom-tag-with-id-component-replacer' import { ClickShield } from '../../replace-components/click-shield/click-shield'
/** /**
* This component renders a GitHub Gist by placing the gist URL in an {@link HTMLIFrameElement iframe}. * This component renders a GitHub Gist by placing the gist URL in an {@link HTMLIFrameElement iframe}.

View file

@ -0,0 +1,34 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownExtension } from '../markdown-extension'
import markdownItRegex from 'markdown-it-regex'
import type MarkdownIt from 'markdown-it'
import type { ComponentReplacer } from '../../replace-components/component-replacer'
import { CustomTagWithIdComponentReplacer } from '../../replace-components/custom-tag-with-id-component-replacer'
import { replaceGistLink } from './replace-gist-link'
import { replaceLegacyGistShortCode } from './replace-legacy-gist-short-code'
import { GistFrame } from './gist-frame'
/**
* Adds support for embeddings of GitHub Gists by detecting gist links and the legacy gist shortcode.
*/
export class GistMarkdownExtension extends MarkdownExtension {
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 buildTagNameWhitelist(): string[] {
return [GistMarkdownExtension.tagName]
}
}

View file

@ -5,6 +5,7 @@
*/ */
import type { RegexOptions } from '../../../../external-types/markdown-it-regex/interface' import type { RegexOptions } from '../../../../external-types/markdown-it-regex/interface'
import { GistMarkdownExtension } from './gist-markdown-extension'
const protocolRegex = /(?:http(?:s)?:\/\/)?/ const protocolRegex = /(?:http(?:s)?:\/\/)?/
const domainRegex = /(?:gist\.github\.com\/)/ const domainRegex = /(?:gist\.github\.com\/)/
@ -19,6 +20,6 @@ export const replaceGistLink: RegexOptions = {
replace: (match) => { replace: (match) => {
// ESLint wants to collapse this tag, but then the tag won't be valid html anymore. // ESLint wants to collapse this tag, but then the tag won't be valid html anymore.
// noinspection CheckTagEmptyBody // noinspection CheckTagEmptyBody
return `<app-gist id="${match}"></app-gist>` return `<${GistMarkdownExtension.tagName} id='${match}'></${GistMarkdownExtension.tagName}>`
} }
} }

View file

@ -5,6 +5,7 @@
*/ */
import type { RegexOptions } from '../../../../external-types/markdown-it-regex/interface' import type { RegexOptions } from '../../../../external-types/markdown-it-regex/interface'
import { GistMarkdownExtension } from './gist-markdown-extension'
const finalRegex = /^{%gist (\w+\/\w+) ?%}$/ const finalRegex = /^{%gist (\w+\/\w+) ?%}$/
@ -14,6 +15,6 @@ export const replaceLegacyGistShortCode: RegexOptions = {
replace: (match) => { replace: (match) => {
// ESLint wants to collapse this tag, but then the tag won't be valid html anymore. // ESLint wants to collapse this tag, but then the tag won't be valid html anymore.
// noinspection CheckTagEmptyBody // noinspection CheckTagEmptyBody
return `<app-gist id="${match}"></app-gist>` return `<${GistMarkdownExtension.tagName} id="${match}"></${GistMarkdownExtension.tagName}>`
} }
} }

View file

@ -10,7 +10,7 @@ import { ShowIf } from '../../../common/show-if/show-if'
import { useFrontendBaseUrl } from '../../../../hooks/common/use-frontend-base-url' import { useFrontendBaseUrl } from '../../../../hooks/common/use-frontend-base-url'
import { Logger } from '../../../../utils/logger' import { Logger } from '../../../../utils/logger'
import { cypressId } from '../../../../utils/cypress-attribute' import { cypressId } from '../../../../utils/cypress-attribute'
import type { CodeProps } from '../code-block-component-replacer' import type { CodeProps } from '../../replace-components/code-block-component-replacer'
const log = new Logger('GraphvizFrame') const log = new Logger('GraphvizFrame')

View file

@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownExtension } from '../markdown-extension'
import { CodeBlockComponentReplacer } from '../../replace-components/code-block-component-replacer'
import type { ComponentReplacer } from '../../replace-components/component-replacer'
import { GraphvizFrame } from './graphviz-frame'
/**
* Adds support for graphviz to the markdown rendering using code fences with "graphviz" as language.
*/
export class GraphvizMarkdownExtension extends MarkdownExtension {
public buildReplacers(): ComponentReplacer[] {
return [new CodeBlockComponentReplacer(GraphvizFrame, 'graphviz')]
}
}

View file

@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownExtension } from './markdown-extension'
import type MarkdownIt from 'markdown-it'
import anchor from 'markdown-it-anchor'
export class HeadlineAnchorsMarkdownExtension extends MarkdownExtension {
public configureMarkdownIt(markdownIt: MarkdownIt): void {
anchor(markdownIt, {
permalink: anchor.permalink.ariaHidden({
symbol: '<i class="fa fa-link"></i>',
class: 'heading-anchor text-dark',
renderHref: (slug: string): string => `#${slug}`,
placement: 'before'
})
})
}
}

View file

@ -0,0 +1,42 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownExtension } from '../markdown-extension'
import type MarkdownIt from 'markdown-it'
import { HighlightedCodeReplacer } from './highlighted-code-replacer'
import type { ComponentReplacer } from '../../replace-components/component-replacer'
/**
* Adds support code highlighting to the markdown rendering.
* Every code fence that is not replaced by another replacer is highlighted using highlightjs.
*/
export class HighlightedCodeMarkdownExtension extends MarkdownExtension {
private static readonly highlightRegex = /^ *([\w-]*)(.*)$/
public configureMarkdownIt(markdownIt: MarkdownIt): void {
markdownIt.core.ruler.push('highlighted-code', (state) => {
state.tokens.forEach((token) => {
if (token.type === 'fence') {
const highlightInfos = HighlightedCodeMarkdownExtension.highlightRegex.exec(token.info)
if (!highlightInfos) {
return
}
if (highlightInfos[1]) {
token.attrJoin('data-highlight-language', highlightInfos[1])
}
if (highlightInfos[2]) {
token.attrJoin('data-extra', highlightInfos[2])
}
}
})
return true
})
}
public buildReplacers(): ComponentReplacer[] {
return [new HighlightedCodeReplacer()]
}
}

View file

@ -6,8 +6,8 @@
import type { Element } from 'domhandler' import type { Element } from 'domhandler'
import React from 'react' import React from 'react'
import { ComponentReplacer } from '../component-replacer' import { ComponentReplacer } from '../../replace-components/component-replacer'
import { HighlightedCode } from './highlighted-code/highlighted-code' import { HighlightedCode } from './highlighted-code'
/** /**
* Detects code blocks and renders them as highlighted code blocks * Detects code blocks and renders them as highlighted code blocks

View file

@ -5,10 +5,10 @@
*/ */
.code-highlighter { .code-highlighter {
@import '../../../../../../node_modules/highlight.js/styles/github'; @import '../../../../../node_modules/highlight.js/styles/github';
body.dark & { body.dark & {
@import '../../../../../../node_modules/highlight.js/styles/github-dark'; @import '../../../../../node_modules/highlight.js/styles/github-dark';
} }
position: relative; position: relative;

View file

@ -7,11 +7,11 @@
import type { ReactElement } from 'react' import type { ReactElement } from 'react'
import React, { Fragment, useEffect, useState } from 'react' import React, { Fragment, useEffect, useState } from 'react'
import convertHtmlToReact from '@hedgedoc/html-to-react' import convertHtmlToReact from '@hedgedoc/html-to-react'
import { CopyToClipboardButton } from '../../../../common/copyable/copy-to-clipboard-button/copy-to-clipboard-button' import { CopyToClipboardButton } from '../../../common/copyable/copy-to-clipboard-button/copy-to-clipboard-button'
import '../../../utils/button-inside.scss' import '../../utils/button-inside.scss'
import './highlighted-code.scss' import './highlighted-code.scss'
import { Logger } from '../../../../../utils/logger' import { Logger } from '../../../../utils/logger'
import { cypressId } from '../../../../../utils/cypress-attribute' import { cypressId } from '../../../../utils/cypress-attribute'
const log = new Logger('HighlightedCode') const log = new Logger('HighlightedCode')
@ -45,7 +45,7 @@ export const HighlightedCode: React.FC<HighlightedCodeProps> = ({ code, language
const [dom, setDom] = useState<ReactElement[]>() const [dom, setDom] = useState<ReactElement[]>()
useEffect(() => { useEffect(() => {
import(/* webpackChunkName: "highlight.js" */ '../../../../common/hljs/hljs') import(/* webpackChunkName: "highlight.js" */ '../../../common/hljs/hljs')
.then((hljs) => { .then((hljs) => {
const languageSupported = (lang: string) => hljs.default.listLanguages().includes(lang) const languageSupported = (lang: string) => hljs.default.listLanguages().includes(lang)
const unreplacedCode = const unreplacedCode =

View file

@ -0,0 +1,23 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownExtension } from '../markdown-extension'
import type { ComponentReplacer } from '../../replace-components/component-replacer'
import type { ImageClickHandler } from './proxy-image-replacer'
import { ProxyImageReplacer } from './proxy-image-replacer'
/**
* Adds support for image lightbox and image proxy redirection.
*/
export class ProxyImageMarkdownExtension extends MarkdownExtension {
constructor(private onImageClick?: ImageClickHandler) {
super()
}
buildReplacers(): ComponentReplacer[] {
return [new ProxyImageReplacer(this.onImageClick)]
}
}

View file

@ -6,7 +6,7 @@
import type { Element } from 'domhandler' import type { Element } from 'domhandler'
import React from 'react' import React from 'react'
import { ComponentReplacer } from '../component-replacer' import { ComponentReplacer } from '../../replace-components/component-replacer'
import { ProxyImageFrame } from './proxy-image-frame' import { ProxyImageFrame } from './proxy-image-frame'
export type ImageClickHandler = (event: React.MouseEvent<HTMLImageElement, MouseEvent>) => void export type ImageClickHandler = (event: React.MouseEvent<HTMLImageElement, MouseEvent>) => void
@ -14,7 +14,7 @@ export type ImageClickHandler = (event: React.MouseEvent<HTMLImageElement, Mouse
/** /**
* Detects image tags and loads them via image proxy if configured. * Detects image tags and loads them via image proxy if configured.
*/ */
export class ImageReplacer extends ComponentReplacer { export class ProxyImageReplacer extends ComponentReplacer {
private readonly clickHandler?: ImageClickHandler private readonly clickHandler?: ImageClickHandler
constructor(clickHandler?: ImageClickHandler) { constructor(clickHandler?: ImageClickHandler) {

View file

@ -0,0 +1,37 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownExtension } from '../markdown-extension'
import mathJax from 'markdown-it-mathjax'
import type MarkdownIt from 'markdown-it'
import type { ComponentReplacer } from '../../replace-components/component-replacer'
import { KatexReplacer } from './katex-replacer'
/**
* Adds support for rendering of LaTeX code using KaTeX.
*/
export class KatexMarkdownExtension extends MarkdownExtension {
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 buildTagNameWhitelist(): string[] {
return [KatexMarkdownExtension.tagName]
}
}

View file

@ -6,11 +6,10 @@
import type { Element } from 'domhandler' import type { Element } from 'domhandler'
import { isTag } from 'domhandler' import { isTag } from 'domhandler'
import type MarkdownIt from 'markdown-it'
import mathJax from 'markdown-it-mathjax'
import React from 'react' import React from 'react'
import { ComponentReplacer, DO_NOT_REPLACE } from '../component-replacer' import { ComponentReplacer, DO_NOT_REPLACE } from '../../replace-components/component-replacer'
import './katex.scss' import './katex.scss'
import { KatexMarkdownExtension } from './katex-markdown-extension'
/** /**
* Checks if the given node is a KaTeX block. * Checks if the given node is a KaTeX block.
@ -35,7 +34,9 @@ const containsKatexBlock = (node: Element): Element | undefined => {
* @return {@code true} if the given node is a katex element. * @return {@code true} if the given node is a katex element.
*/ */
const isKatexTag = (node: Element, expectedInline: boolean) => { const isKatexTag = (node: Element, expectedInline: boolean) => {
return node.name === 'app-katex' && (node.attribs?.['data-inline'] !== undefined) === expectedInline return (
node.name === KatexMarkdownExtension.tagName && (node.attribs?.['data-inline'] !== undefined) === expectedInline
)
} }
const KaTeX = React.lazy(() => import(/* webpackChunkName: "katex" */ '@matejmazur/react-katex')) const KaTeX = React.lazy(() => import(/* webpackChunkName: "katex" */ '@matejmazur/react-katex'))
@ -44,15 +45,6 @@ const KaTeX = React.lazy(() => import(/* webpackChunkName: "katex" */ '@matejmaz
* Detects LaTeX syntax and renders it with KaTeX. * Detects LaTeX syntax and renders it with KaTeX.
*/ */
export class KatexReplacer extends ComponentReplacer { export class KatexReplacer extends ComponentReplacer {
public static readonly markdownItPlugin: MarkdownIt.PluginSimple = mathJax({
beforeMath: '<app-katex>',
afterMath: '</app-katex>',
beforeInlineMath: '<app-katex data-inline="true">',
afterInlineMath: '</app-katex>',
beforeDisplayMath: '<app-katex>',
afterDisplayMath: '</app-katex>'
})
public replace(node: Element): React.ReactElement | undefined { public replace(node: Element): React.ReactElement | undefined {
if (!(isKatexTag(node, true) || containsKatexBlock(node)) || node.children?.[0] === undefined) { if (!(isKatexTag(node, true) || containsKatexBlock(node)) || node.children?.[0] === undefined) {
return DO_NOT_REPLACE return DO_NOT_REPLACE

View file

@ -4,4 +4,4 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@import '../../../../../node_modules/katex/dist/katex.min'; @import '../../../../../node_modules/katex/dist/katex.min.css';

View file

@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownExtension } from '../markdown-extension'
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'
/**
* Adds support for legacy shortcodes (pdf, slideshare and speakerdeck) by replacing them with anchor elements.
*/
export class LegacyShortcodesMarkdownExtension extends MarkdownExtension {
public configureMarkdownIt(markdownIt: MarkdownIt): void {
legacyPdfShortCode(markdownIt)
legacySlideshareShortCode(markdownIt)
legacySpeakerdeckShortCode(markdownIt)
}
}

View file

@ -13,8 +13,6 @@ export const legacyPdfShortCode: MarkdownIt.PluginSimple = (markdownIt) => {
markdownItRegex(markdownIt, { markdownItRegex(markdownIt, {
name: 'legacy-pdf-short-code', name: 'legacy-pdf-short-code',
regex: finalRegex, regex: finalRegex,
replace: (match: string) => { replace: (match: string) => `<a href="${match}">${match}</a>`
return `<a target="_blank" rel="noopener noreferrer" href="${match}">${match}</a>`
}
}) })
} }

View file

@ -13,8 +13,6 @@ export const legacySlideshareShortCode: MarkdownIt.PluginSimple = (markdownIt) =
markdownItRegex(markdownIt, { markdownItRegex(markdownIt, {
name: 'legacy-slideshare-short-code', name: 'legacy-slideshare-short-code',
regex: finalRegex, regex: finalRegex,
replace: (match: string) => { replace: (match: string) => `<a href='https://www.slideshare.net/${match}'>https://www.slideshare.net/${match}</a>`
return `<a href="https://www.slideshare.net/${match}">https://www.slideshare.net/${match}</a>`
}
}) })
} }

View file

@ -13,8 +13,6 @@ export const legacySpeakerdeckShortCode: MarkdownIt.PluginSimple = (markdownIt)
markdownItRegex(markdownIt, { markdownItRegex(markdownIt, {
name: 'legacy-speakerdeck-short-code', name: 'legacy-speakerdeck-short-code',
regex: finalRegex, regex: finalRegex,
replace: (match: string) => { replace: (match: string) => `<a href="https://speakerdeck.com/${match}">https://speakerdeck.com/${match}</a>`
return `<a target="_blank" rel="noopener noreferrer" href="https://speakerdeck.com/${match}">https://speakerdeck.com/${match}</a>`
}
}) })
} }

View file

@ -0,0 +1,87 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type MarkdownIt from 'markdown-it/lib'
import Token from 'markdown-it/lib/token'
import { LinemarkerMarkdownExtension } from './linemarker-markdown-extension'
export interface LineMarkers {
startLine: number
endLine: number
}
/**
* This plugin adds markers to the dom, that are used to map line numbers to dom elements.
* It also provides a list of line numbers for the top level dom elements.
*/
export const addLineMarkerMarkdownItPlugin: (
markdownIt: MarkdownIt,
lineOffset: number,
onLineMarkerChange?: (lineMarkers: LineMarkers[]) => void
) => void = (md: MarkdownIt, lineOffset, onLineMarkerChange) => {
// add app_linemarker token before each opening or self-closing level-0 tag
md.core.ruler.push('line_number_marker', (state) => {
const lineMarkers: LineMarkers[] = []
tagTokens(state.tokens, lineMarkers, lineOffset)
if (onLineMarkerChange) {
onLineMarkerChange(lineMarkers)
}
return true
})
md.renderer.rules.app_linemarker = (tokens: Token[], index: number): string => {
const startLineNumber = tokens[index].attrGet('data-start-line')
const endLineNumber = tokens[index].attrGet('data-end-line')
if (!startLineNumber || !endLineNumber) {
// don't render broken linemarkers without a linenumber
return ''
}
// noinspection CheckTagEmptyBody
return `<${LinemarkerMarkdownExtension.tagName} data-start-line='${startLineNumber}' data-end-line='${endLineNumber}'></${LinemarkerMarkdownExtension.tagName}>`
}
const insertNewLineMarker = (
startLineNumber: number,
endLineNumber: number,
tokenPosition: number,
level: number,
tokens: Token[]
) => {
const startToken = new Token('app_linemarker', LinemarkerMarkdownExtension.tagName, 0)
startToken.level = level
startToken.attrPush(['data-start-line', `${startLineNumber}`])
startToken.attrPush(['data-end-line', `${endLineNumber}`])
tokens.splice(tokenPosition, 0, startToken)
}
const tagTokens = (tokens: Token[], lineMarkers: LineMarkers[], lineOffset: number) => {
for (let tokenPosition = 0; tokenPosition < tokens.length; tokenPosition++) {
const token = tokens[tokenPosition]
if (token.hidden) {
continue
}
if (!token.map) {
continue
}
const startLineNumber = token.map[0] + 1
const endLineNumber = token.map[1] + 1
if (token.level === 0) {
lineMarkers.push({ startLine: startLineNumber + lineOffset, endLine: endLineNumber + lineOffset })
}
insertNewLineMarker(startLineNumber, endLineNumber, tokenPosition, token.level, tokens)
tokenPosition += 1
if (token.children) {
tagTokens(token.children, lineMarkers, lineOffset)
}
}
}
}

View file

@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownExtension } from '../markdown-extension'
import { LinemarkerReplacer } from './linemarker-replacer'
import type { ComponentReplacer } from '../../replace-components/component-replacer'
import type { LineMarkers } from './add-line-marker-markdown-it-plugin'
import { addLineMarkerMarkdownItPlugin } from './add-line-marker-markdown-it-plugin'
import type MarkdownIt from 'markdown-it'
/**
* Adds support for the generation of line marker elements which are needed for synced scrolling.
*/
export class LinemarkerMarkdownExtension extends MarkdownExtension {
public static readonly tagName = 'app-linemarker'
constructor(private onLineMarkers?: (lineMarkers: LineMarkers[]) => void, private lineOffset?: number) {
super()
}
public configureMarkdownIt(markdownIt: MarkdownIt): void {
addLineMarkerMarkdownItPlugin(markdownIt, this.lineOffset ?? 0, this.onLineMarkers)
}
public buildReplacers(): ComponentReplacer[] {
return [new LinemarkerReplacer()]
}
public buildTagNameWhitelist(): string[] {
return [LinemarkerMarkdownExtension.tagName]
}
}

View file

@ -5,13 +5,14 @@
*/ */
import type { Element } from 'domhandler' import type { Element } from 'domhandler'
import { ComponentReplacer } from '../component-replacer' import { ComponentReplacer } from '../../replace-components/component-replacer'
import { LinemarkerMarkdownExtension } from './linemarker-markdown-extension'
/** /**
* Detects line markers and suppresses them in the resulting DOM. * Detects line markers and suppresses them in the resulting DOM.
*/ */
export class LinemarkerReplacer extends ComponentReplacer { export class LinemarkerReplacer extends ComponentReplacer {
public replace(codeNode: Element): null | undefined { public replace(codeNode: Element): null | undefined {
return codeNode.name === 'app-linemarker' ? null : undefined return codeNode.name === LinemarkerMarkdownExtension.tagName ? null : undefined
} }
} }

View file

@ -0,0 +1,44 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { TravelerNodeProcessor } from '../../node-preprocessors/traveler-node-processor'
import type { Node } from 'domhandler'
import { isTag } from 'domhandler'
export class AnchorNodePreprocessor extends TravelerNodeProcessor {
constructor(private baseUrl: string) {
super()
}
protected processNode(node: Node): void {
if (!isTag(node) || node.name !== 'a' || !node.attribs || !node.attribs.href) {
return
}
const url = node.attribs.href.trim()
// eslint-disable-next-line no-script-url
if (url.startsWith('data:') || url.startsWith('javascript:') || url.startsWith('vbscript:')) {
delete node.attribs.href
return
}
const isJumpMark = url.substr(0, 1) === '#'
if (isJumpMark) {
node.attribs['data-jump-target-id'] = url.substr(1)
} else {
node.attribs.rel = 'noreferer noopener'
node.attribs.target = '_blank'
}
try {
node.attribs.href = new URL(url, this.baseUrl).toString()
} catch (e) {
node.attribs.href = url
}
}
}

View file

@ -0,0 +1,31 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Element } from 'domhandler'
import type { AllHTMLAttributes } from 'react'
import React from 'react'
import type { NativeRenderer, NodeReplacement, SubNodeTransform } from '../../replace-components/component-replacer'
import { ComponentReplacer, DO_NOT_REPLACE } from '../../replace-components/component-replacer'
import { JumpAnchor } from './jump-anchor'
/**
* Detects anchors that should jump to scroll to another element.
*/
export class JumpAnchorReplacer extends ComponentReplacer {
public replace(node: Element, subNodeTransform: SubNodeTransform, nativeRenderer: NativeRenderer): NodeReplacement {
if (node.name !== 'a' || !node.attribs || !node.attribs['data-jump-target-id']) {
return DO_NOT_REPLACE
}
const jumpId = node.attribs['data-jump-target-id']
delete node.attribs['data-jump-target-id']
const replacement = nativeRenderer()
if (replacement === null || typeof replacement === 'string') {
return replacement
} else {
return <JumpAnchor {...(replacement.props as AllHTMLAttributes<HTMLAnchorElement>)} jumpTargetId={jumpId} />
}
}
}

View file

@ -0,0 +1,28 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { AllHTMLAttributes } from 'react'
import React, { useCallback } from 'react'
export interface JumpAnchorProps extends AllHTMLAttributes<HTMLAnchorElement> {
jumpTargetId: string
}
export const JumpAnchor: React.FC<JumpAnchorProps> = ({ jumpTargetId, children, ...props }) => {
const jumpToTargetId = useCallback(
(event: React.MouseEvent<HTMLElement, MouseEvent>): void => {
document.getElementById(jumpTargetId)?.scrollIntoView({ behavior: 'smooth' })
event.preventDefault()
},
[jumpTargetId]
)
return (
<a {...props} onClick={jumpToTargetId}>
{children}
</a>
)
}

View file

@ -0,0 +1,28 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownExtension } from '../markdown-extension'
import { JumpAnchorReplacer } from './jump-anchor-replacer'
import type { ComponentReplacer } from '../../replace-components/component-replacer'
import type { NodeProcessor } from '../../node-preprocessors/node-processor'
import { AnchorNodePreprocessor } from './anchor-node-preprocessor'
/**
* Adds tweaks for anchor tags which are needed for the use in the secured iframe.
*/
export class LinkAdjustmentMarkdownExtension extends MarkdownExtension {
constructor(private baseUrl: string) {
super()
}
public buildNodeProcessors(): NodeProcessor[] {
return [new AnchorNodePreprocessor(this.baseUrl)]
}
public buildReplacers(): ComponentReplacer[] {
return [new JumpAnchorReplacer()]
}
}

View file

@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownExtension } from './markdown-extension'
import linkify from 'markdown-it/lib/rules_core/linkify'
import type MarkdownIt from 'markdown-it'
export class LinkifyFixMarkdownExtension extends MarkdownExtension {
public configureMarkdownItPost(markdownIt: MarkdownIt): void {
markdownIt.core.ruler.push('linkify', (state) => {
try {
state.md.options.linkify = true
return linkify(state)
} finally {
state.md.options.linkify = false
}
})
}
}

View file

@ -0,0 +1,31 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type MarkdownIt from 'markdown-it'
import type { NodeProcessor } from '../node-preprocessors/node-processor'
import type { ComponentReplacer } from '../replace-components/component-replacer'
export abstract class MarkdownExtension {
public configureMarkdownIt(markdownIt: MarkdownIt): void {
return
}
public configureMarkdownItPost(markdownIt: MarkdownIt): void {
return
}
public buildNodeProcessors(): NodeProcessor[] {
return []
}
public buildReplacers(): ComponentReplacer[] {
return []
}
public buildTagNameWhitelist(): string[] {
return []
}
}

View file

@ -10,7 +10,7 @@ import { LockButton } from '../../../common/lock-button/lock-button'
import '../../utils/button-inside.scss' import '../../utils/button-inside.scss'
import { Logger } from '../../../../utils/logger' import { Logger } from '../../../../utils/logger'
import { cypressId } from '../../../../utils/cypress-attribute' import { cypressId } from '../../../../utils/cypress-attribute'
import type { CodeProps } from '../code-block-component-replacer' import type { CodeProps } from '../../replace-components/code-block-component-replacer'
const log = new Logger('MarkmapFrame') const log = new Logger('MarkmapFrame')

View file

@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownExtension } from '../markdown-extension'
import { CodeBlockComponentReplacer } from '../../replace-components/code-block-component-replacer'
import type { ComponentReplacer } from '../../replace-components/component-replacer'
import { MarkmapFrame } from './markmap-frame'
/**
* Adds support for markmap to the markdown rendering using code fences with "markmap" as language.
*/
export class MarkmapMarkdownExtension extends MarkdownExtension {
public buildReplacers(): ComponentReplacer[] {
return [new CodeBlockComponentReplacer(MarkmapFrame, 'markmap')]
}
}

View file

@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next'
import { ShowIf } from '../../../common/show-if/show-if' import { ShowIf } from '../../../common/show-if/show-if'
import './mermaid.scss' import './mermaid.scss'
import { Logger } from '../../../../utils/logger' import { Logger } from '../../../../utils/logger'
import type { CodeProps } from '../code-block-component-replacer' import type { CodeProps } from '../../replace-components/code-block-component-replacer'
const log = new Logger('MermaidChart') const log = new Logger('MermaidChart')

View file

@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownExtension } from '../markdown-extension'
import { CodeBlockComponentReplacer } from '../../replace-components/code-block-component-replacer'
import type { ComponentReplacer } from '../../replace-components/component-replacer'
import { MermaidChart } from './mermaid-chart'
/**
* Adds support for chart rendering using mermaid to the markdown rendering using code fences with "mermaid" as language.
*/
export class MermaidMarkdownExtension extends MarkdownExtension {
public buildReplacers(): ComponentReplacer[] {
return [new CodeBlockComponentReplacer(MermaidChart, 'mermaid')]
}
}

View file

@ -0,0 +1,46 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownExtension } from '../markdown-extension'
import type MarkdownIt from 'markdown-it'
import plantuml from 'markdown-it-plantuml'
import type Renderer from 'markdown-it/lib/renderer'
import type { RenderRule } from 'markdown-it/lib/renderer'
import type Token from 'markdown-it/lib/token'
import type { Options } from 'markdown-it/lib'
import type { ComponentReplacer } from '../../replace-components/component-replacer'
import { PlantumlNotConfiguredComponentReplacer } from './plantuml-not-configured-component-replacer'
export class PlantumlMarkdownExtension extends MarkdownExtension {
constructor(private plantumlServer: string | null) {
super()
}
private plantumlError(markdownIt: MarkdownIt): void {
const defaultRenderer: 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 {
if (this.plantumlServer) {
plantuml(markdownIt, {
openMarker: '```plantuml',
closeMarker: '```',
server: this.plantumlServer
})
} else {
this.plantumlError(markdownIt)
}
}
buildReplacers(): ComponentReplacer[] {
return [new PlantumlNotConfiguredComponentReplacer()]
}
}

View file

@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import { Trans, useTranslation } from 'react-i18next'
export const PlantumlNotConfiguredAlert: React.FC = () => {
useTranslation()
return (
<p className='alert alert-danger'>
<Trans i18nKey={'renderer.plantuml.notConfigured'} />
</p>
)
}

View file

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { NativeRenderer, NodeReplacement, SubNodeTransform } from '../../replace-components/component-replacer'
import { ComponentReplacer, DO_NOT_REPLACE } from '../../replace-components/component-replacer'
import { PlantumlNotConfiguredAlert } from './plantuml-not-configured-alert'
import type { Element } from 'domhandler'
export class PlantumlNotConfiguredComponentReplacer extends ComponentReplacer {
replace(node: Element, subNodeTransform: SubNodeTransform, nativeRenderer: NativeRenderer): NodeReplacement {
return node.tagName === 'plantuml-not-configured' ? <PlantumlNotConfiguredAlert /> : DO_NOT_REPLACE
}
}

View file

@ -3,9 +3,10 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import type { DataNode, Document, Element, Node } from 'domhandler' import type { DataNode, Element, Node } from 'domhandler'
import { hasChildren, isComment, isTag } from 'domhandler' import { isComment, isTag } from 'domhandler'
import { Logger } from '../../utils/logger' import { Logger } from '../../../../utils/logger'
import { TravelerNodeProcessor } from '../../node-preprocessors/traveler-node-processor'
const log = new Logger('reveal.js > Comment Node Preprocessor') const log = new Logger('reveal.js > Comment Node Preprocessor')
const revealCommandSyntax = /^\s*\.(\w*):(.*)$/g const revealCommandSyntax = /^\s*\.(\w*):(.*)$/g
@ -17,20 +18,11 @@ const dataAttributesSyntax = /\s*(data-[\w-]*|class)=(?:"((?:[^"\\]|\\"|\\)*)"|'
* @param doc The document that should be changed * @param doc The document that should be changed
* @return The edited document * @return The edited document
*/ */
export const processRevealCommentNodes = (doc: Document): Document => { export class RevealCommentCommandNodePreprocessor extends TravelerNodeProcessor {
visitNode(doc) protected processNode(node: Node): void {
return doc
}
/**
* Processes the given {@link Node} if it is a comment node. If the node has children then all child nodes will be processed.
* @param node The node to process.
*/
const visitNode = (node: Node): void => {
if (isComment(node)) { if (isComment(node)) {
processCommentNode(node) processCommentNode(node)
} else if (hasChildren(node)) { }
node.childNodes.forEach((childNode) => visitNode(childNode))
} }
} }

View file

@ -0,0 +1,25 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownExtension } from '../markdown-extension'
import type MarkdownIt from 'markdown-it'
import { addSlideSectionsMarkdownItPlugin } from './reveal-sections'
import { RevealCommentCommandNodePreprocessor } from './process-reveal-comment-nodes'
import type { NodeProcessor } from '../../node-preprocessors/node-processor'
/**
* Adds support for reveal.js to the markdown rendering.
* This includes the generation of sections and the manipulation of elements using reveal comments.
*/
export class RevealMarkdownExtension extends MarkdownExtension {
public configureMarkdownIt(markdownIt: MarkdownIt): void {
addSlideSectionsMarkdownItPlugin(markdownIt)
}
public buildNodeProcessors(): NodeProcessor[] {
return [new RevealCommentCommandNodePreprocessor()]
}
}

View file

@ -0,0 +1,27 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Document } from 'domhandler'
import render from 'dom-serializer'
import DOMPurify from 'dompurify'
import { parseDocument } from 'htmlparser2'
import { NodeProcessor } from '../../node-preprocessors/node-processor'
/**
* Sanitizes the given {@link Document document}.
*/
export class SanitizerNodePreprocessor extends NodeProcessor {
constructor(private tagNameWhiteList: string[]) {
super()
}
process(nodes: Document): Document {
const sanitizedHtml = DOMPurify.sanitize(render(nodes), {
ADD_TAGS: this.tagNameWhiteList
})
return parseDocument(sanitizedHtml)
}
}

View file

@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownExtension } from '../markdown-extension'
import { SanitizerNodePreprocessor } from './dom-purifier-node-preprocessor'
import type { NodeProcessor } from '../../node-preprocessors/node-processor'
/**
* Adds support for html sanitizing using dompurify to the markdown rendering.
*/
export class SanitizerMarkdownExtension extends MarkdownExtension {
constructor(private tagNameWhiteList: string[]) {
super()
}
public buildNodeProcessors(): NodeProcessor[] {
return [new SanitizerNodePreprocessor(this.tagNameWhiteList)]
}
}

View file

@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownExtension } from '../markdown-extension'
import { CodeBlockComponentReplacer } from '../../replace-components/code-block-component-replacer'
import type { ComponentReplacer } from '../../replace-components/component-replacer'
import { SequenceDiagram } from './sequence-diagram'
/**
* Adds legacy support for sequence diagram to the markdown rendering using code fences with "sequence" as language.
*/
export class SequenceDiagramMarkdownExtension extends MarkdownExtension {
public buildReplacers(): ComponentReplacer[] {
return [new CodeBlockComponentReplacer(SequenceDiagram, 'sequence')]
}
}

View file

@ -5,7 +5,7 @@
*/ */
import React, { Fragment } from 'react' import React, { Fragment } from 'react'
import type { CodeProps } from '../code-block-component-replacer' import type { CodeProps } from '../../replace-components/code-block-component-replacer'
import { MermaidChart } from '../mermaid/mermaid-chart' import { MermaidChart } from '../mermaid/mermaid-chart'
import { DeprecationWarning } from './deprecation-warning' import { DeprecationWarning } from './deprecation-warning'

View file

@ -0,0 +1,36 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownExtension } from './markdown-extension'
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'
export class SpoilerMarkdownExtension extends MarkdownExtension {
private static readonly spoilerRegEx = /^spoiler\s+(.*)$/
private createSpoilerContainer(): (tokens: Token[], index: number) => void {
return (tokens: Token[], index: number) => {
const matches = SpoilerMarkdownExtension.spoilerRegEx.exec(tokens[index].info.trim())
if (tokens[index].nesting === 1 && matches && matches[1]) {
// opening tag
return `<details><summary>${escapeHtml(matches[1])}</summary>`
} else {
// closing tag
return '</details>\n'
}
}
}
public configureMarkdownIt(markdownIt: MarkdownIt): void {
markdownItContainer(markdownIt, 'spoiler', {
validate: (params: string) => SpoilerMarkdownExtension.spoilerRegEx.test(params),
render: () => this.createSpoilerContainer()
})
}
}

View file

@ -0,0 +1,32 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownExtension } from './markdown-extension'
import type MarkdownIt from 'markdown-it'
import type { TocAst } from 'markdown-it-toc-done-right'
import toc from 'markdown-it-toc-done-right'
import { tocSlugify } from '../../editor-page/table-of-contents/toc-slugify'
export class TableOfContentsMarkdownExtension extends MarkdownExtension {
constructor(private onTocChange?: (ast: TocAst) => void) {
super()
}
public configureMarkdownIt(markdownIt: MarkdownIt): void {
if (!this.onTocChange) {
return
}
toc(markdownIt, {
placeholder: '(\\[TOC\\]|\\[toc\\])',
listType: 'ul',
level: [1, 2, 3],
callback: (code: string, ast: TocAst): void => {
this.onTocChange?.(ast)
},
slugify: tocSlugify
})
}
}

View file

@ -0,0 +1,33 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownExtension } from '../markdown-extension'
import type MarkdownIt from 'markdown-it'
import type { ComponentReplacer } from '../../replace-components/component-replacer'
import type { TaskCheckedChangeHandler } from './task-list-replacer'
import { TaskListReplacer } from './task-list-replacer'
import markdownItTaskLists from '@hedgedoc/markdown-it-task-lists'
/**
* Adds support for interactive checkbox lists to the markdown rendering using the github checklist syntax.
*/
export class TaskListMarkdownExtension extends MarkdownExtension {
constructor(private frontmatterLinesToSkip?: number, private onTaskCheckedChange?: TaskCheckedChangeHandler) {
super()
}
public configureMarkdownIt(markdownIt: MarkdownIt): void {
markdownItTaskLists(markdownIt, {
enabled: true,
label: true,
lineNumber: true
})
}
public buildReplacers(): ComponentReplacer[] {
return [new TaskListReplacer(this.frontmatterLinesToSkip, this.onTaskCheckedChange)]
}
}

View file

@ -7,7 +7,7 @@
import type { Element } from 'domhandler' import type { Element } from 'domhandler'
import type { ReactElement } from 'react' import type { ReactElement } from 'react'
import React from 'react' import React from 'react'
import { ComponentReplacer } from '../component-replacer' import { ComponentReplacer } from '../../replace-components/component-replacer'
import { TaskListCheckbox } from './task-list-checkbox' import { TaskListCheckbox } from './task-list-checkbox'
export type TaskCheckedChangeHandler = (lineInMarkdown: number, checked: boolean) => void export type TaskCheckedChangeHandler = (lineInMarkdown: number, checked: boolean) => void

View file

@ -10,11 +10,11 @@ import { useTranslation } from 'react-i18next'
import type { VisualizationSpec } from 'vega-embed' import type { VisualizationSpec } from 'vega-embed'
import { ShowIf } from '../../../common/show-if/show-if' import { ShowIf } from '../../../common/show-if/show-if'
import { Logger } from '../../../../utils/logger' import { Logger } from '../../../../utils/logger'
import type { CodeProps } from '../code-block-component-replacer' import type { CodeProps } from '../../replace-components/code-block-component-replacer'
const log = new Logger('VegaChart') const log = new Logger('VegaChart')
export const VegaChart: React.FC<CodeProps> = ({ code }) => { export const VegaLiteChart: React.FC<CodeProps> = ({ code }) => {
const diagramContainer = useRef<HTMLDivElement>(null) const diagramContainer = useRef<HTMLDivElement>(null)
const [error, setError] = useState<string>() const [error, setError] = useState<string>()
const { t } = useTranslation() const { t } = useTranslation()

View file

@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownExtension } from '../markdown-extension'
import { CodeBlockComponentReplacer } from '../../replace-components/code-block-component-replacer'
import type { ComponentReplacer } from '../../replace-components/component-replacer'
import { VegaLiteChart } from './vega-lite-chart'
/**
* Adds support for chart rendering using vega lite to the markdown rendering using code fences with "vega-lite" as language.
*/
export class VegaLiteMarkdownExtension extends MarkdownExtension {
public buildReplacers(): ComponentReplacer[] {
return [new CodeBlockComponentReplacer(VegaLiteChart, 'vega-lite')]
}
}

Some files were not shown because too many files have changed in this diff Show more