mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2024-11-28 19:20:58 -05:00
Introduce Markdown extensions (#1614)
* Introduce markdown extensions Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
e9defd60dc
commit
8a8bacc0aa
148 changed files with 1878 additions and 1128 deletions
|
@ -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()
|
||||||
|
|
|
@ -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!"
|
||||||
},
|
},
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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', () => {
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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'))
|
||||||
|
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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[],
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
|
|
@ -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]
|
|
||||||
)
|
|
|
@ -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])
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
])
|
||||||
|
}
|
|
@ -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')
|
||||||
|
|
|
@ -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;
|
|
@ -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')]
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
|
@ -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]
|
||||||
|
}
|
||||||
|
}
|
|
@ -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}>`
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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};` })
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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]
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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.
|
|
@ -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()]
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
|
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -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')]
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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}.
|
|
@ -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]
|
||||||
|
}
|
||||||
|
}
|
|
@ -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}>`
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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}>`
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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')
|
||||||
|
|
|
@ -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')]
|
||||||
|
}
|
||||||
|
}
|
|
@ -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'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()]
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
|
@ -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;
|
|
@ -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 =
|
|
@ -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)]
|
||||||
|
}
|
||||||
|
}
|
|
@ -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) {
|
|
@ -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]
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
|
@ -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';
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>`
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
|
@ -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>`
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
|
@ -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>`
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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]
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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()]
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 []
|
||||||
|
}
|
||||||
|
}
|
|
@ -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')
|
||||||
|
|
|
@ -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')]
|
||||||
|
}
|
||||||
|
}
|
|
@ -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')
|
||||||
|
|
|
@ -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')]
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()]
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()]
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)]
|
||||||
|
}
|
||||||
|
}
|
|
@ -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')]
|
||||||
|
}
|
||||||
|
}
|
|
@ -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'
|
||||||
|
|
|
@ -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()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)]
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
|
@ -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()
|
|
@ -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
Loading…
Reference in a new issue