refactor: organize app extensions

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2023-04-14 09:13:37 +02:00
parent 8cddc96881
commit 1e4709c087
209 changed files with 286 additions and 243 deletions

View file

@ -0,0 +1,36 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { CheatsheetExtension } from '../../../components/editor-page/cheatsheet/cheatsheet-extension'
import { basicCompletion } from '../../../components/editor-page/editor-pane/autocompletions/basic-completion'
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
import { AppExtension } from '../../_base-classes/app-extension'
import { AlertMarkdownExtension } from './alert-markdown-extension'
import type { CompletionSource } from '@codemirror/autocomplete'
import { t } from 'i18next'
const alertRegex = /(?:^|\s):(?::|::|::\w+)?/
/**
* Adds alert boxes to the markdown rendering.
*/
export class AlertAppExtension extends AppExtension {
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
return [new AlertMarkdownExtension()]
}
buildCheatsheetExtensions(): CheatsheetExtension[] {
return [{ i18nKey: 'alert' }]
}
buildAutocompletion(): CompletionSource[] {
return [
basicCompletion(alertRegex, ':::success\n\n:::', t('editor.autocompletions.successBox') ?? undefined),
basicCompletion(alertRegex, ':::info\n\n:::', t('editor.autocompletions.infoBox') ?? undefined),
basicCompletion(alertRegex, ':::warning\n\n:::', t('editor.autocompletions.warningBox') ?? undefined),
basicCompletion(alertRegex, ':::danger\n\n:::', t('editor.autocompletions.errorBox') ?? undefined)
]
}
}

View file

@ -0,0 +1,30 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
import type MarkdownIt from 'markdown-it'
import markdownItContainer from 'markdown-it-container'
import type Renderer from 'markdown-it/lib/renderer'
import type Token from 'markdown-it/lib/token'
export const alertLevels = ['success', 'danger', 'info', 'warning']
/**
* Adds alert boxes to the markdown rendering.
*/
export class AlertMarkdownExtension extends MarkdownRendererExtension {
public configureMarkdownIt(markdownIt: MarkdownIt): void {
alertLevels.forEach((level) => {
markdownItContainer(markdownIt, level, {
render: (tokens: Token[], index: number, options: MarkdownIt.Options, env: unknown, self: Renderer) => {
tokens[index].attrJoin('role', 'alert')
tokens[index].attrJoin('class', 'alert')
tokens[index].attrJoin('class', `alert-${level}`)
return self.renderToken(tokens, index, options)
}
})
})
}
}

View file

@ -0,0 +1,70 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { CheatsheetExtension } from '../../../components/editor-page/cheatsheet/cheatsheet-extension'
import { basicCompletion } from '../../../components/editor-page/editor-pane/autocompletions/basic-completion'
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
import { AppExtension } from '../../_base-classes/app-extension'
import { BasicMarkdownSyntaxMarkdownExtension } from './basic-markdown-syntax-markdown-extension'
import type { CompletionSource } from '@codemirror/autocomplete'
import { t } from 'i18next'
export class BasicMarkdownSyntaxAppExtension extends AppExtension {
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
return [new BasicMarkdownSyntaxMarkdownExtension()]
}
buildCheatsheetExtensions(): CheatsheetExtension[] {
return [
{
i18nKey: 'basics.basicFormatting',
categoryI18nKey: 'basic'
},
{
i18nKey: 'basics.abbreviation',
categoryI18nKey: 'basic'
},
{
i18nKey: 'basics.footnote',
categoryI18nKey: 'basic'
},
{
i18nKey: 'basics.headlines',
categoryI18nKey: 'basic',
entries: [
{
i18nKey: 'hashtag'
},
{
i18nKey: 'equal'
}
]
},
{
i18nKey: 'basics.code',
categoryI18nKey: 'basic',
entries: [{ i18nKey: 'inline' }, { i18nKey: 'block' }]
},
{
i18nKey: 'basics.lists',
categoryI18nKey: 'basic',
entries: [{ i18nKey: 'unordered' }, { i18nKey: 'ordered' }]
},
{
i18nKey: 'basics.images',
categoryI18nKey: 'basic',
entries: [{ i18nKey: 'basic' }, { i18nKey: 'size' }]
},
{
i18nKey: 'basics.links',
categoryI18nKey: 'basic'
}
]
}
buildAutocompletion(): CompletionSource[] {
return [basicCompletion(/(^|\s)\[/, '[](https://)', t('editor.autocompletions.link') ?? undefined)]
}
}

View file

@ -0,0 +1,31 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
import { imageSize } from '@hedgedoc/markdown-it-plugins'
import type MarkdownIt from 'markdown-it'
import abbreviation from 'markdown-it-abbr'
import definitionList from 'markdown-it-deflist'
import footnote from 'markdown-it-footnote'
import inserted from 'markdown-it-ins'
import marked from 'markdown-it-mark'
import subscript from 'markdown-it-sub'
import superscript from 'markdown-it-sup'
/**
* Adds some common markdown syntaxes to the markdown rendering.
*/
export class BasicMarkdownSyntaxMarkdownExtension extends MarkdownRendererExtension {
public configureMarkdownIt(markdownIt: MarkdownIt): void {
abbreviation(markdownIt)
definitionList(markdownIt)
subscript(markdownIt)
superscript(markdownIt)
inserted(markdownIt)
marked(markdownIt)
footnote(markdownIt)
imageSize(markdownIt)
}
}

View file

@ -0,0 +1,231 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`blockquote extra tag renders the tag "> [color=#f00] text" correctly 1`] = `
<div>
<p>
blockquote
</p>
<blockquote
style="border-left-color: #f00;"
>
<p>
text
</p>
</blockquote>
</div>
`;
exports[`blockquote extra tag renders the tag "[=value]" correctly 1`] = `
<div>
<p>
blockquote
</p>
<p>
[=value]
</p>
</div>
`;
exports[`blockquote extra tag renders the tag "[color=#abcdef]" correctly 1`] = `
<div>
<p>
blockquote
</p>
<p>
<span
class="blockquote-extra"
style="color: rgb(171, 205, 239);"
>
BootstrapIconMock_Tag
</span>
</p>
</div>
`;
exports[`blockquote extra tag renders the tag "[color=#dfe]" correctly 1`] = `
<div>
<p>
blockquote
</p>
<p>
<span
class="blockquote-extra"
style="color: rgb(221, 255, 238);"
>
BootstrapIconMock_Tag
</span>
</p>
</div>
`;
exports[`blockquote extra tag renders the tag "[color=]" correctly 1`] = `
<div>
<p>
blockquote
</p>
<p>
[color=]
</p>
</div>
`;
exports[`blockquote extra tag renders the tag "[color=notarealcolor]" correctly 1`] = `
<div>
<p>
blockquote
</p>
<p>
<span
class="blockquote-extra"
>
notarealcolor
</span>
</p>
</div>
`;
exports[`blockquote extra tag renders the tag "[color=white]" correctly 1`] = `
<div>
<p>
blockquote
</p>
<p>
<span
class="blockquote-extra"
style="color: white;"
>
BootstrapIconMock_Tag
</span>
</p>
</div>
`;
exports[`blockquote extra tag renders the tag "[key=]" correctly 1`] = `
<div>
<p>
blockquote
</p>
<p>
[key=]
</p>
</div>
`;
exports[`blockquote extra tag renders the tag "[key]" correctly 1`] = `
<div>
<p>
blockquote
</p>
<p>
[key]
</p>
</div>
`;
exports[`blockquote extra tag renders the tag "[name=]" correctly 1`] = `
<div>
<p>
blockquote
</p>
<p>
[name=]
</p>
</div>
`;
exports[`blockquote extra tag renders the tag "[name=giowehg]" correctly 1`] = `
<div>
<p>
blockquote
</p>
<p>
<span
class="blockquote-extra"
>
giowehg
</span>
</p>
</div>
`;
exports[`blockquote extra tag renders the tag "[time=]" correctly 1`] = `
<div>
<p>
blockquote
</p>
<p>
[time=]
</p>
</div>
`;
exports[`blockquote extra tag renders the tag "[time=tomorrow]" correctly 1`] = `
<div>
<p>
blockquote
</p>
<p>
<span
class="blockquote-extra"
>
tomorrow
</span>
</p>
</div>
`;

View file

@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { CheatsheetExtension } from '../../../components/editor-page/cheatsheet/cheatsheet-extension'
import { basicCompletion } from '../../../components/editor-page/editor-pane/autocompletions/basic-completion'
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
import { AppExtension } from '../../_base-classes/app-extension'
import { BlockquoteExtraTagMarkdownExtension } from './blockquote-extra-tag-markdown-extension'
import type { CompletionSource } from '@codemirror/autocomplete'
import { t } from 'i18next'
const blockquoteTagRegex = /(?:^|\s)\[(?:\w+)?/
/**
* Adds support for generic blockquote extra tags and blockquote color extra tags.
*/
export class BlockquoteAppExtension extends AppExtension {
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
return [new BlockquoteExtraTagMarkdownExtension()]
}
buildCheatsheetExtensions(): CheatsheetExtension[] {
return [{ i18nKey: 'blockquoteTags', entries: [{ i18nKey: 'name' }, { i18nKey: 'color' }, { i18nKey: 'time' }] }]
}
buildAutocompletion(): CompletionSource[] {
return [
basicCompletion(blockquoteTagRegex, '[name=]', t('editor.autocompletions.tagName') ?? undefined),
basicCompletion(blockquoteTagRegex, '[time=]', t('editor.autocompletions.tagTime') ?? undefined),
basicCompletion(blockquoteTagRegex, '[color=]', t('editor.autocompletions.tagColor') ?? undefined)
]
}
}

View file

@ -0,0 +1,106 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { TravelerNodeProcessor } from '../../../components/markdown-renderer/node-preprocessors/traveler-node-processor'
import { BlockquoteExtraTagMarkdownExtension } from './blockquote-extra-tag-markdown-extension'
import { Optional } from '@mrdrogdrog/optional'
import type { Element, Node } from 'domhandler'
import { isTag, isText } from 'domhandler'
/**
* Detects blockquotes with blockquote color tags and uses them to color the blockquote border.
*/
export class BlockquoteBorderColorNodePreprocessor extends TravelerNodeProcessor {
protected processNode(node: Node): void {
if (!isTag(node) || isBlockquoteWithChildren(node)) {
return
}
Optional.ofNullable(findBlockquoteColorDefinitionAndParent(node.children)).ifPresent(([color, parentParagraph]) => {
removeColorDefinitionsFromParagraph(parentParagraph)
if (!cssColor.test(color)) {
return
}
setLeftBorderColor(node, color)
})
}
}
export const cssColor =
/^(#(?:[0-9a-f]{2}){2,4}|#[0-9a-f]{3}|black|silver|gray|whitesmoke|maroon|red|purple|fuchsia|green|lime|olivedrab|yellow|navy|blue|teal|aquamarine|orange|aliceblue|antiquewhite|aqua|azure|beige|bisque|blanchedalmond|blueviolet|brown|burlywood|cadetblue|chartreuse|chocolate|coral|cornflowerblue|cornsilk|crimson|currentcolor|darkblue|darkcyan|darkgoldenrod|darkgray|darkgreen|darkgrey|darkkhaki|darkmagenta|darkolivegreen|darkorange|darkorchid|darkred|darksalmon|darkseagreen|darkslateblue|darkslategray|darkslategrey|darkturquoise|darkviolet|deeppink|deepskyblue|dimgray|dimgrey|dodgerblue|firebrick|floralwhite|forestgreen|gainsboro|ghostwhite|goldenrod|gold|greenyellow|grey|honeydew|hotpink|indianred|indigo|ivory|khaki|lavenderblush|lavender|lawngreen|lemonchiffon|lightblue|lightcoral|lightcyan|lightgoldenrodyellow|lightgray|lightgreen|lightgrey|lightpink|lightsalmon|lightseagreen|lightskyblue|lightslategray|lightslategrey|lightsteelblue|lightyellow|limegreen|linen|mediumaquamarine|mediumblue|mediumorchid|mediumpurple|mediumseagreen|mediumslateblue|mediumspringgreen|mediumturquoise|mediumvioletred|midnightblue|mintcream|mistyrose|moccasin|navajowhite|oldlace|olive|orangered|orchid|palegoldenrod|palegreen|paleturquoise|palevioletred|papayawhip|peachpuff|peru|pink|plum|powderblue|rebeccapurple|rosybrown|royalblue|saddlebrown|salmon|sandybrown|seagreen|seashell|sienna|skyblue|slateblue|slategray|slategrey|snow|springgreen|steelblue|tan|thistle|tomato|transparent|turquoise|violet|wheat|white|yellowgreen)$/i
/**
* Checks if the given {@link Element} is a blockquote with children.
*
* @param element The {@link Element} to check
* @return {@link true} if the element is a blockquote with children.
*/
const isBlockquoteWithChildren = (element: Element): boolean => {
return element.name !== 'blockquote' || !element.children || element.children.length < 1
}
/**
* Searches for a blockquote color definition tag.
*
* @param elements The {@link Element} elements that should be searched through.
* @return The parent paragraph and the extracted color if a color definition was found. {@link undefined} otherwise.
*/
const findBlockquoteColorDefinitionAndParent = (
elements: Node[]
): [color: string, parentParagraph: Element] | undefined => {
for (const paragraph of elements) {
if (!isTag(paragraph) || paragraph.name !== 'p' || paragraph.children.length === 0) {
continue
}
for (const colorDefinition of paragraph.children) {
if (!isTag(colorDefinition)) {
continue
}
const content = extractBlockquoteColorDefinition(colorDefinition)
if (content !== undefined) {
return [content, paragraph]
}
}
}
}
/**
* Checks if the given node is a blockquote color definition.
*
* @param element The {@link Element} to check
* @return true if the checked node is a blockquote color definition
*/
const extractBlockquoteColorDefinition = (element: Element): string | undefined => {
if (
element.name === BlockquoteExtraTagMarkdownExtension.tagName &&
element.attribs['data-label'] === 'color' &&
element.children.length === 1 &&
isText(element.children[0])
) {
return element.children[0].data
}
}
/**
* Removes all color definition elements from the given paragraph {@link Element}.
*
* @param paragraph The {@link Element} whose children should be filtered
*/
const removeColorDefinitionsFromParagraph = (paragraph: Element): void => {
const childElements = paragraph.children
paragraph.children = childElements.filter((elem) => !isTag(elem) || !extractBlockquoteColorDefinition(elem))
}
/**
* Sets the left border color of the given {@link Element}.
*
* @param element The {@link Element} to change
* @param color The border color
*/
const setLeftBorderColor = (element: Element, color: string): void => {
element.attribs = Object.assign(element.attribs || {}, { style: `border-left-color: ${color};` })
}

View file

@ -0,0 +1,44 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { UiIcon } from '../../../components/common/icons/ui-icon'
import type { NodeReplacement } from '../../../components/markdown-renderer/replace-components/component-replacer'
import {
ComponentReplacer,
DO_NOT_REPLACE
} from '../../../components/markdown-renderer/replace-components/component-replacer'
import { cssColor } from './blockquote-border-color-node-preprocessor'
import { BlockquoteExtraTagMarkdownExtension } from './blockquote-extra-tag-markdown-extension'
import { Optional } from '@mrdrogdrog/optional'
import type { Element } from 'domhandler'
import { isText } from 'domhandler'
import type { Text } from 'domhandler/lib/node'
import { Tag as IconTag } from 'react-bootstrap-icons'
/**
* Replaces <blockquote-tag> elements with "color" as label and a valid color as content
* with an colored label icon.
*
* @see BlockquoteTagMarkdownItPlugin
*/
export class BlockquoteColorExtraTagReplacer extends ComponentReplacer {
replace(element: Element): NodeReplacement {
return Optional.of(element)
.filter(
(element) =>
element.tagName === BlockquoteExtraTagMarkdownExtension.tagName && element.attribs?.['data-label'] === 'color'
)
.map((element) => element.children[0])
.filter(isText)
.map((child) => (child as Text).data)
.filter((content) => cssColor.test(content))
.map((color) => (
<span className={'blockquote-extra'} key={1} style={{ color: color }}>
<UiIcon icon={IconTag} key='icon' className={'mx-1'} />
</span>
))
.orElse(DO_NOT_REPLACE)
}
}

View file

@ -0,0 +1,36 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { TestMarkdownRenderer } from '../../../components/markdown-renderer/test-utils/test-markdown-renderer'
import { BlockquoteExtraTagMarkdownExtension } from './blockquote-extra-tag-markdown-extension'
import { screen, render } from '@testing-library/react'
import React from 'react'
describe('blockquote extra tag', () => {
it.each([
'[color=white]',
'[color=#dfe]',
'[color=notarealcolor]',
'[color=#abcdef]',
'[color=]',
'[name=giowehg]',
'[name=]',
'[time=tomorrow]',
'[time=]',
'[key]',
'[key=]',
'[=value]',
'> [color=#f00] text'
])(`renders the tag "%s" correctly`, async (content) => {
const view = render(
<TestMarkdownRenderer
extensions={[new BlockquoteExtraTagMarkdownExtension()]}
content={'blockquote\n\n' + content}
/>
)
await screen.findByText('blockquote')
expect(view.container).toMatchSnapshot()
})
})

View file

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

View file

@ -0,0 +1,51 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { BlockquoteExtraTagMarkdownItPlugin } from './blockquote-extra-tag-markdown-it-plugin'
import MarkdownIt from 'markdown-it'
describe('Quote extra syntax parser', () => {
let markdownIt: MarkdownIt
beforeEach(() => {
markdownIt = new MarkdownIt('default', {
html: false,
breaks: true,
langPrefix: '',
typographer: true
})
new BlockquoteExtraTagMarkdownItPlugin('abc', 'markdown').registerRule(markdownIt)
})
it('should parse a valid tag', () => {
expect(markdownIt.renderInline('[abc=markdown]')).toEqual(
'<app-blockquote-tag data-label=\'abc\' data-icon="markdown">markdown</app-blockquote-tag>'
)
})
it("shouldn't parse a tag with no opener bracket", () => {
expect(markdownIt.renderInline('abc=def]')).toEqual('abc=def]')
})
it("shouldn't parse a tag with no closing bracket", () => {
expect(markdownIt.renderInline('[abc=def')).toEqual('[abc=def')
})
it("shouldn't parse a tag with no separation character", () => {
expect(markdownIt.renderInline('[abcdef]')).toEqual('[abcdef]')
})
it("shouldn't parse a tag with an empty label", () => {
expect(markdownIt.renderInline('[=def]')).toEqual('[=def]')
})
it("shouldn't parse a tag with an empty value", () => {
expect(markdownIt.renderInline('[abc=]')).toEqual('[abc=]')
})
it("shouldn't parse a tag with an empty body", () => {
expect(markdownIt.renderInline('[]')).toEqual('[]')
})
})

View file

@ -0,0 +1,193 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { BootstrapIconName } from '../../../components/common/icons/bootstrap-icons'
import { BlockquoteExtraTagMarkdownExtension } from './blockquote-extra-tag-markdown-extension'
import { Optional } from '@mrdrogdrog/optional'
import type MarkdownIt from 'markdown-it/lib'
import type { RuleInline } from 'markdown-it/lib/parser_inline'
import type StateInline from 'markdown-it/lib/rules_inline/state_inline'
import type Token from 'markdown-it/lib/token'
export interface QuoteExtraTagValues {
labelStartIndex: number
labelEndIndex: number
valueStartIndex: number
valueEndIndex: number
label: string
value: string
}
/**
* Detects the blockquote extra tag syntax `[label=value]` and creates <blockquote-tag> elements.
*/
export class BlockquoteExtraTagMarkdownItPlugin {
private static readonly BlockquoteExtraTagRuleName = 'blockquote_extra_tag'
constructor(private tagName: string, private icon: BootstrapIconName) {}
/**
* Registers an inline rule that detects blockquote extra tags and replaces them with blockquote tokens.
*
* @param markdownIt The {@link MarkdownIt markdown-it} in which the inline rule should be registered.
*/
public registerRule(markdownIt: MarkdownIt): void {
markdownIt.inline.ruler.before('link', `blockquote_extra_tag_${this.tagName}`, this.createInlineRuler())
BlockquoteExtraTagMarkdownItPlugin.registerRenderer(markdownIt)
}
/**
* Creates a {@link RuleInline markdown-it inline rule} that detects the configured blockquote extra tag.
*
* @return The created inline rule
*/
private createInlineRuler(): RuleInline {
return (state) =>
this.parseBlockquoteExtraTag(state.src, state.pos, state.posMax)
.map((parseResults) => {
this.createNewBlockquoteExtraTagToken(state, parseResults.label, parseResults.value)
state.pos = parseResults.valueEndIndex + 1
return true
})
.orElse(false)
}
/**
* Register the markdown-it renderer that translated `blockquote_tag` tokens into HTML
*
* @param markdownIt The {@link MarkdownIt markdown-it} in which the render should be registered.
*/
private static registerRenderer(markdownIt: MarkdownIt): void {
if (markdownIt.renderer.rules[BlockquoteExtraTagMarkdownItPlugin.BlockquoteExtraTagRuleName]) {
return
}
markdownIt.renderer.rules[BlockquoteExtraTagMarkdownItPlugin.BlockquoteExtraTagRuleName] = (
tokens,
idx,
options: MarkdownIt.Options,
env: unknown
) => {
const token = tokens[idx]
const innerTokens = token.children
const label = token.attrGet('label') ?? ''
const icon = token.attrGet('icon')
const iconAttribute = icon === null ? '' : ` data-icon="${icon}"`
const innerHtml = innerTokens === null ? '' : markdownIt.renderer.renderInline(innerTokens, options, env)
return `<${BlockquoteExtraTagMarkdownExtension.tagName} data-label='${label}'${iconAttribute}>${innerHtml}</${BlockquoteExtraTagMarkdownExtension.tagName}>`
}
}
/**
* Creates a new blockquote extra {@link Token token} using the given values.
*
* @param state The state in which the token should be inserted
* @param label The label for the extra token
* @param value The value for the extra token that will be inline parsed
* @return The generated token
*/
private createNewBlockquoteExtraTagToken(state: StateInline, label: string, value: string): Token {
const token = state.push(BlockquoteExtraTagMarkdownItPlugin.BlockquoteExtraTagRuleName, '', 0)
token.attrSet('label', label)
token.attrSet('icon', this.icon)
token.children = BlockquoteExtraTagMarkdownItPlugin.parseInlineContent(state, value)
return token
}
/**
* Parses the given content using the markdown-it instance of the given state.
*
* @param state The state whose inline parser should be used to parse the given content
* @param content The content to parse
* @return The generated tokens
*/
private static parseInlineContent(state: StateInline, content: string): Token[] {
const childTokens: Token[] = []
state.md.inline.parse(content, state.md, state.env, childTokens)
return childTokens
}
/**
* Parses a blockquote tag. The syntax is [label=value].
*
* @param line The line in which the tag should be looked for.
* @param startIndex The start index for the search.
* @param dontSearchAfterIndex The maximal position for the search.
*/
private parseBlockquoteExtraTag(
line: string,
startIndex: number,
dontSearchAfterIndex: number
): Optional<QuoteExtraTagValues> {
if (line[startIndex] !== '[') {
return Optional.empty()
}
const labelStartIndex = startIndex + 1
const labelEndIndex = BlockquoteExtraTagMarkdownItPlugin.parseLabel(line, labelStartIndex, dontSearchAfterIndex)
if (!labelEndIndex || labelStartIndex === labelEndIndex) {
return Optional.empty()
}
const label = line.slice(labelStartIndex, labelEndIndex)
if (label !== this.tagName) {
return Optional.empty()
}
const valueStartIndex = labelEndIndex + 1
const valueEndIndex = BlockquoteExtraTagMarkdownItPlugin.parseValue(line, valueStartIndex, dontSearchAfterIndex)
if (!valueEndIndex || valueStartIndex === valueEndIndex) {
return Optional.empty()
}
const value = line.slice(valueStartIndex, valueEndIndex)
return Optional.of({
labelStartIndex,
labelEndIndex,
valueStartIndex,
valueEndIndex,
label,
value
})
}
/**
* Parses the value part of a blockquote tag. That is [notthis=THIS] part. It also detects nested [] blocks.
*
* @param line The line in which the tag is.
* @param startIndex The start index of the tag.
* @param dontSearchAfterIndex The maximal position for the search.
* @return The value part of the blockquote tag
*/
private static parseValue(line: string, startIndex: number, dontSearchAfterIndex: number): number | undefined {
let level = 0
for (let position = startIndex; position <= dontSearchAfterIndex; position += 1) {
const currentCharacter = line[position]
if (currentCharacter === ']') {
if (level === 0) {
return position
}
level -= 1
} else if (currentCharacter === '[') {
level += 1
}
}
}
/**
* Parses the label part of a blockquote tag. That is [THIS=notthis] part.
*
* @param line The line in which the tag is.
* @param startIndex The start index of the tag.
* @param dontSearchAfterIndex The maximal position for the search.
* @return The label of the blockquote tag.
*/
private static parseLabel(line: string, startIndex: number, dontSearchAfterIndex: number): number | undefined {
for (let pos = startIndex; pos <= dontSearchAfterIndex; pos += 1) {
if (line[pos] === '=') {
return pos
}
}
}
}

View file

@ -0,0 +1,56 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { BootstrapIconName } from '../../../components/common/icons/bootstrap-icons'
import { isBootstrapIconName } from '../../../components/common/icons/bootstrap-icons'
import type { LazyBootstrapIconProps } from '../../../components/common/icons/lazy-bootstrap-icon'
import { LazyBootstrapIcon } from '../../../components/common/icons/lazy-bootstrap-icon'
import type {
NodeReplacement,
SubNodeTransform
} from '../../../components/markdown-renderer/replace-components/component-replacer'
import {
ComponentReplacer,
DO_NOT_REPLACE
} from '../../../components/markdown-renderer/replace-components/component-replacer'
import { BlockquoteExtraTagMarkdownExtension } from './blockquote-extra-tag-markdown-extension'
import { Optional } from '@mrdrogdrog/optional'
import type { Element } from 'domhandler'
import type { ReactElement } from 'react'
/**
* Replaces <blockquote-tag> elements with an icon and a small text.
*
* @see BlockquoteTagMarkdownItPlugin
* @see ColoredBlockquoteNodePreprocessor
*/
export class BlockquoteExtraTagReplacer extends ComponentReplacer {
replace(element: Element, subNodeTransform: SubNodeTransform): NodeReplacement {
return Optional.of(element)
.filter(
(element) => element.tagName === BlockquoteExtraTagMarkdownExtension.tagName && element.attribs !== undefined
)
.map((element) => (
<span className={'blockquote-extra'} key={1}>
{this.buildIconElement(element)}
{BlockquoteExtraTagReplacer.transformChildren(element, subNodeTransform)}
</span>
))
.orElse(DO_NOT_REPLACE)
}
/**
* Extracts an icon name from the node and builds a {@link LazyBootstrapIcon icon react element}.
*
* @param node The node that holds the "data-icon" attribute.
* @return the {@link LazyBootstrapIcon icon react element} or {@link undefined} if no icon name was found.
*/
private buildIconElement(node: Element): ReactElement<LazyBootstrapIconProps> | undefined {
return Optional.ofNullable(node.attribs['data-icon'] as BootstrapIconName)
.filter((iconName) => isBootstrapIconName(iconName))
.map((iconName) => <LazyBootstrapIcon key='icon' className={'mx-1'} icon={iconName} />)
.orElse(undefined)
}
}

View file

@ -0,0 +1,37 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`bootstrap icon markdown extension doesn't render invalid icon 1`] = `
<div>
<p>
:bi-INVALIDICONNAME:
</p>
</div>
`;
exports[`bootstrap icon markdown extension doesn't render missing icon 1`] = `
<div>
<p>
:bi-:
</p>
</div>
`;
exports[`bootstrap icon markdown extension renders correct icon 1`] = `
<div>
<p>
<span
data-svg-mock="true"
data-testid="lazy-bootstrap-icon-alarm"
fill="currentColor"
height="1em"
width="1em"
/>
</p>
</div>
`;

View file

@ -0,0 +1,37 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { BootstrapLazyIcons } from '../../../components/common/icons/bootstrap-icons'
import type { CheatsheetExtension } from '../../../components/editor-page/cheatsheet/cheatsheet-extension'
import { regexCompletion } from '../../../components/editor-page/editor-pane/autocompletions/regex-completion'
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
import { AppExtension } from '../../_base-classes/app-extension'
import { BootstrapIconMarkdownExtension } from './bootstrap-icon-markdown-extension'
import type { CompletionSource } from '@codemirror/autocomplete'
import { t } from 'i18next'
const bootstrapIconNames = Object.keys(BootstrapLazyIcons)
export class BootstrapIconAppExtension extends AppExtension {
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
return [new BootstrapIconMarkdownExtension()]
}
buildCheatsheetExtensions(): CheatsheetExtension[] {
return [{ i18nKey: 'bootstrapIcon', readMoreUrl: new URL('https://icons.getbootstrap.com/') }]
}
buildAutocompletion(): CompletionSource[] {
return [
regexCompletion(
/:(?:[\w-]+:?)?/,
bootstrapIconNames.map((icon) => ({
detail: t('editor.autocompletions.icon') ?? undefined,
label: `:bi-${icon}:`
}))
)
]
}
}

View file

@ -0,0 +1,40 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { isBootstrapIconName } from '../../../components/common/icons/bootstrap-icons'
import { LazyBootstrapIcon } from '../../../components/common/icons/lazy-bootstrap-icon'
import type { NodeReplacement } from '../../../components/markdown-renderer/replace-components/component-replacer'
import {
ComponentReplacer,
DO_NOT_REPLACE
} from '../../../components/markdown-renderer/replace-components/component-replacer'
import { BootstrapIconMarkdownExtension } from './bootstrap-icon-markdown-extension'
import type { Element } from 'domhandler'
import React from 'react'
/**
* Replaces a bootstrap icon tag with the bootstrap icon react component.
*
* @see BootstrapIcon
*/
export class BootstrapIconComponentReplacer extends ComponentReplacer {
constructor() {
super()
}
public replace(node: Element): NodeReplacement {
const iconName = this.extractIconName(node)
if (!iconName || !isBootstrapIconName(iconName)) {
return DO_NOT_REPLACE
}
return React.createElement(LazyBootstrapIcon, { icon: iconName })
}
private extractIconName(element: Element): string | undefined {
return element.name === BootstrapIconMarkdownExtension.tagName && element.attribs && element.attribs.id
? element.attribs.id
: undefined
}
}

View file

@ -0,0 +1,31 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { TestMarkdownRenderer } from '../../../components/markdown-renderer/test-utils/test-markdown-renderer'
import { BootstrapIconMarkdownExtension } from './bootstrap-icon-markdown-extension'
import { render, screen } from '@testing-library/react'
import React from 'react'
describe('bootstrap icon markdown extension', () => {
it('renders correct icon', async () => {
const view = render(
<TestMarkdownRenderer extensions={[new BootstrapIconMarkdownExtension()]} content={':bi-alarm:'} />
)
await screen.findByTestId('lazy-bootstrap-icon-alarm')
expect(view.container).toMatchSnapshot()
})
it("doesn't render missing icon", () => {
const view = render(<TestMarkdownRenderer extensions={[new BootstrapIconMarkdownExtension()]} content={':bi-:'} />)
expect(view.container).toMatchSnapshot()
})
it("doesn't render invalid icon", () => {
const view = render(
<TestMarkdownRenderer extensions={[new BootstrapIconMarkdownExtension()]} content={':bi-INVALIDICONNAME:'} />
)
expect(view.container).toMatchSnapshot()
})
})

View file

@ -0,0 +1,29 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
import type { ComponentReplacer } from '../../../components/markdown-renderer/replace-components/component-replacer'
import { BootstrapIconComponentReplacer } from './bootstrap-icon-component-replacer'
import { replaceBootstrapIconsMarkdownItPlugin } from './replace-bootstrap-icons'
import type MarkdownIt from 'markdown-it'
/**
* Adds Bootstrap icons via the :bi-$name: syntax.
*/
export class BootstrapIconMarkdownExtension extends MarkdownRendererExtension {
public static readonly tagName = 'app-bootstrap-icon'
public configureMarkdownIt(markdownIt: MarkdownIt): void {
replaceBootstrapIconsMarkdownItPlugin(markdownIt)
}
public buildReplacers(): ComponentReplacer[] {
return [new BootstrapIconComponentReplacer()]
}
public buildTagNameAllowList(): string[] {
return [BootstrapIconMarkdownExtension.tagName]
}
}

View file

@ -0,0 +1,39 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { replaceBootstrapIconsMarkdownItPlugin } from './replace-bootstrap-icons'
import MarkdownIt from 'markdown-it'
describe('Replace bootstrap icons', () => {
let markdownIt: MarkdownIt
beforeEach(() => {
markdownIt = new MarkdownIt('default', {
html: false,
breaks: true,
langPrefix: '',
typographer: true
})
markdownIt.use(replaceBootstrapIconsMarkdownItPlugin)
})
it(`can detect a correct icon`, () => {
expect(markdownIt.renderInline(':bi-alarm:')).toBe('<app-bootstrap-icon id="alarm"></app-bootstrap-icon>')
})
it("won't detect an invalid id", () => {
const invalidIcon = ':bi-invalid:'
expect(markdownIt.renderInline(invalidIcon)).toBe(invalidIcon)
})
it("won't detect an empty id", () => {
const invalidIcon = ':bi-:'
expect(markdownIt.renderInline(invalidIcon)).toBe(invalidIcon)
})
it("won't detect a wrong id", () => {
const invalidIcon = ':bi-%?(:'
expect(markdownIt.renderInline(invalidIcon)).toBe(invalidIcon)
})
})

View file

@ -0,0 +1,30 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { isBootstrapIconName } from '../../../components/common/icons/bootstrap-icons'
import type { RegexOptions } from '../../../external-types/markdown-it-regex/interface'
import { BootstrapIconMarkdownExtension } from './bootstrap-icon-markdown-extension'
import type MarkdownIt from 'markdown-it'
import markdownItRegex from 'markdown-it-regex'
const biRegex = /:bi-([\w-]+):/i
/**
* Replacer for bootstrap icon via the :bi-$name: syntax.
*/
export const replaceBootstrapIconsMarkdownItPlugin: MarkdownIt.PluginSimple = (markdownIt: MarkdownIt) =>
markdownItRegex(markdownIt, {
name: 'bootstrap-icons',
regex: biRegex,
replace: (match) => {
if (isBootstrapIconName(match)) {
// ESLint wants to collapse this tag, but then the tag won't be valid html anymore.
// noinspection CheckTagEmptyBody
return `<${BootstrapIconMarkdownExtension.tagName} id="${match}"></${BootstrapIconMarkdownExtension.tagName}>`
} else {
return `:bi-${match}:`
}
}
} as RegexOptions)

View file

@ -0,0 +1,17 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CSV Table Markdown Extension renders a csv codeblock 1`] = `
<div>
<pre>
<code
class="csv"
>
a;b;c
d;e;f
</code>
</pre>
</div>
`;

View file

@ -0,0 +1,79 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CSV Table renders correctly with header 1`] = `
<div>
<table
class="csv-html-table table-striped"
>
<thead>
<tr>
<th>
a
</th>
<th>
b
</th>
<th>
c
</th>
</tr>
</thead>
<tbody>
<tr>
<td>
d
</td>
<td>
e
</td>
<td>
f
</td>
</tr>
</tbody>
</table>
</div>
`;
exports[`CSV Table renders correctly without code 1`] = `
<div>
<table
class="csv-html-table table-striped"
>
<tbody />
</table>
</div>
`;
exports[`CSV Table renders correctly without header 1`] = `
<div>
<table
class="csv-html-table table-striped"
>
<tbody>
<tr>
<td>
a
</td>
<td>
b
</td>
<td>
c
</td>
</tr>
<tr>
<td>
d
</td>
<td>
e
</td>
<td>
f
</td>
</tr>
</tbody>
</table>
</div>
`;

View file

@ -0,0 +1,33 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { parseCsv } from './csv-parser'
describe('test CSV parser', () => {
it('normal table', () => {
const input = 'A;B;C\nD;E;F\nG;H;I'
const expected = [
['A', 'B', 'C'],
['D', 'E', 'F'],
['G', 'H', 'I']
]
expect(parseCsv(input, ';')).toEqual(expected)
})
it('blank lines', () => {
const input = 'A;B;C\n\nG;H;I'
const expected = [
['A', 'B', 'C'],
['G', 'H', 'I']
]
expect(parseCsv(input, ';')).toEqual(expected)
})
it('items with delimiter', () => {
const input = 'A;B;C\n"D;E;F"\nG;H;I'
const expected = [['A', 'B', 'C'], ['"D;E;F"'], ['G', 'H', 'I']]
expect(parseCsv(input, ';')).toEqual(expected)
})
})

View file

@ -0,0 +1,31 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* Parses a given text as comma separated values (CSV).
*
* @param csvText The raw csv text
* @param csvColumnDelimiter The delimiter for the columns
* @return the values splitted by rows and columns
*/
export const parseCsv = (csvText: string, csvColumnDelimiter: string): string[][] => {
const rows = csvText.split('\n')
if (!rows || rows.length === 0) {
return []
}
const splitRegex = new RegExp(`${escapeRegexCharacters(csvColumnDelimiter)}(?=(?:[^"]*"[^"]*")*[^"]*$)`)
return rows.filter((row) => row !== '').map((row) => row.split(splitRegex))
}
/**
* Escapes regex characters in the given string, so it can be used as literal string in another regex.
*
* @param unsafe The unescaped string
* @return The escaped string
*/
const escapeRegexCharacters = (unsafe: string): string => {
return unsafe.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

View file

@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { CodeBlockComponentReplacer } from '../../../components/markdown-renderer/replace-components/code-block-component-replacer'
import type { NodeReplacement } from '../../../components/markdown-renderer/replace-components/component-replacer'
import {
ComponentReplacer,
DO_NOT_REPLACE
} from '../../../components/markdown-renderer/replace-components/component-replacer'
import { CsvTable } from './csv-table'
import type { Element } from 'domhandler'
import React from 'react'
/**
* Detects code blocks with "csv" as language and renders them as table.
*/
export class CsvReplacer extends ComponentReplacer {
public replace(codeNode: Element): NodeReplacement {
const code = CodeBlockComponentReplacer.extractTextFromCodeNode(codeNode, 'csv')
if (!code) {
return DO_NOT_REPLACE
}
const extraData = codeNode.attribs['data-extra']
const extraRegex = /\s*(delimiter=([^\s]*))?\s*(header)?/
const extraInfos = extraRegex.exec(extraData)
const delimiter = extraInfos?.[2] ?? ','
const showHeader = extraInfos?.[3] !== undefined
return <CsvTable code={code} delimiter={delimiter} showHeader={showHeader} />
}
}

View file

@ -0,0 +1,31 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { CheatsheetExtension } from '../../../components/editor-page/cheatsheet/cheatsheet-extension'
import {
basicCompletion,
codeFenceRegex
} from '../../../components/editor-page/editor-pane/autocompletions/basic-completion'
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
import { AppExtension } from '../../_base-classes/app-extension'
import { CsvTableMarkdownExtension } from './csv-table-markdown-extension'
import type { CompletionSource } from '@codemirror/autocomplete'
/**
* Adds support for csv tables to the markdown rendering.
*/
export class CsvTableAppExtension extends AppExtension {
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
return [new CsvTableMarkdownExtension()]
}
buildCheatsheetExtensions(): CheatsheetExtension[] {
return [{ i18nKey: 'csv', entries: [{ i18nKey: 'table' }, { i18nKey: 'header' }] }]
}
buildAutocompletion(): CompletionSource[] {
return [basicCompletion(codeFenceRegex, '```csv delimiter=;\n\n```')]
}
}

View file

@ -0,0 +1,40 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { CodeProps } from '../../../components/markdown-renderer/replace-components/code-block-component-replacer'
import { TestMarkdownRenderer } from '../../../components/markdown-renderer/test-utils/test-markdown-renderer'
import { mockI18n } from '../../../test-utils/mock-i18n'
import * as CsvTableModule from '../csv/csv-table'
import { CsvTableMarkdownExtension } from './csv-table-markdown-extension'
import { render } from '@testing-library/react'
import React from 'react'
jest.mock('../csv/csv-table')
describe('CSV Table Markdown Extension', () => {
beforeAll(async () => {
jest.spyOn(CsvTableModule, 'CsvTable').mockImplementation((({ code }) => {
return (
<span>
this is a mock for csv frame
<code>{code}</code>
</span>
)
}) as React.FC<CodeProps>)
await mockI18n()
})
afterAll(() => {
jest.resetModules()
jest.restoreAllMocks()
})
it('renders a csv codeblock', () => {
const view = render(
<TestMarkdownRenderer extensions={[new CsvTableMarkdownExtension()]} content={'```csv\na;b;c\nd;e;f\n```'} />
)
expect(view.container).toMatchSnapshot()
})
})

View file

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

View file

@ -0,0 +1,24 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { CsvTable } from './csv-table'
import { render } from '@testing-library/react'
describe('CSV Table', () => {
it('renders correctly with header', () => {
const view = render(<CsvTable code={'a;b;c\nd;e;f'} delimiter={';'} showHeader={true} />)
expect(view.container).toMatchSnapshot()
})
it('renders correctly without header', () => {
const view = render(<CsvTable code={'a;b;c\nd;e;f'} delimiter={';'} showHeader={false} />)
expect(view.container).toMatchSnapshot()
})
it('renders correctly without code', () => {
const view = render(<CsvTable code={''} delimiter={';'} showHeader={false} />)
expect(view.container).toMatchSnapshot()
})
})

View file

@ -0,0 +1,75 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { cypressId } from '../../../utils/cypress-attribute'
import { parseCsv } from './csv-parser'
import React, { useMemo } from 'react'
export interface CsvTableProps {
code: string
delimiter: string
showHeader: boolean
tableRowClassName?: string
tableColumnClassName?: string
}
/**
* Renders a csv table.
*
* @param code The csv code
* @param delimiter The delimiter to be used
* @param showHeader If the header should be shown.
* @param tableRowClassName Additional class name for the rows.
* @param tableColumnClassNameA Additional class name for the columns.
*/
export const CsvTable: React.FC<CsvTableProps> = ({
code,
delimiter,
showHeader,
tableRowClassName,
tableColumnClassName
}) => {
const { rowsWithColumns, headerRow } = useMemo(() => {
const rowsWithColumns = parseCsv(code.trim(), delimiter)
const headerRow = showHeader ? rowsWithColumns.splice(0, 1)[0] : []
return { rowsWithColumns, headerRow }
}, [code, delimiter, showHeader])
const renderTableHeader = useMemo(() => {
return headerRow.length === 0 ? undefined : (
<thead>
<tr>
{headerRow.map((column, columnNumber) => (
<th key={`header-${columnNumber}`}>{column}</th>
))}
</tr>
</thead>
)
}, [headerRow])
const renderTableBody = useMemo(
() => (
<tbody>
{rowsWithColumns.map((row, rowNumber) => (
<tr className={tableRowClassName} key={`row-${rowNumber}`}>
{row.map((column, columnIndex) => (
<td className={tableColumnClassName} key={`cell-${rowNumber}-${columnIndex}`}>
{column.replace(/^"|"$/g, '')}
</td>
))}
</tr>
))}
</tbody>
),
[rowsWithColumns, tableColumnClassName, tableRowClassName]
)
return (
<table className={'csv-html-table table-striped'} {...cypressId('csv-html-table')}>
{renderTableHeader}
{renderTableBody}
</table>
)
}

View file

@ -0,0 +1,21 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Emoji Markdown Extension renders a skin tone code 1`] = `
<div>
<p>
🏽
</p>
</div>
`;
exports[`Emoji Markdown Extension renders an emoji code 1`] = `
<div>
<p>
😄
</p>
</div>
`;

View file

@ -0,0 +1,40 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { CheatsheetExtension } from '../../../components/editor-page/cheatsheet/cheatsheet-extension'
import { regexCompletion } from '../../../components/editor-page/editor-pane/autocompletions/regex-completion'
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
import { AppExtension } from '../../_base-classes/app-extension'
import { EmojiMarkdownRendererExtension } from './emoji-markdown-renderer-extension'
import { emojiShortcodes } from './mapping'
import type { CompletionSource } from '@codemirror/autocomplete'
import { t } from 'i18next'
export class EmojiAppExtension extends AppExtension {
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
return [new EmojiMarkdownRendererExtension()]
}
buildCheatsheetExtensions(): CheatsheetExtension[] {
return [
{
i18nKey: 'emoji',
readMoreUrl: new URL('https://twemoji.twitter.com/')
}
]
}
buildAutocompletion(): CompletionSource[] {
return [
regexCompletion(
/:(?:[\w-+]+:?)?/,
emojiShortcodes.map((shortcode) => ({
detail: t('editor.autocompletions.emoji') ?? undefined,
label: `:${shortcode}:`
}))
)
]
}
}

View file

@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { TestMarkdownRenderer } from '../../../components/markdown-renderer/test-utils/test-markdown-renderer'
import { mockI18n } from '../../../test-utils/mock-i18n'
import { EmojiMarkdownRendererExtension } from './emoji-markdown-renderer-extension'
import { render } from '@testing-library/react'
import React from 'react'
describe('Emoji Markdown Extension', () => {
beforeAll(async () => {
await mockI18n()
})
afterAll(() => {
jest.resetModules()
jest.restoreAllMocks()
})
it('renders an emoji code', () => {
const view = render(
<TestMarkdownRenderer extensions={[new EmojiMarkdownRendererExtension()]} content={':smile:'} />
)
expect(view.container).toMatchSnapshot()
})
it('renders a skin tone code', () => {
const view = render(
<TestMarkdownRenderer extensions={[new EmojiMarkdownRendererExtension()]} content={':skin-tone-3:'} />
)
expect(view.container).toMatchSnapshot()
})
})

View file

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

View file

@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import emojiData from 'emoji-picker-element-data/en/emojibase/data.json'
interface EmojiEntry {
shortcodes: string[]
emoji: string
}
type ShortCodeMap = { [key: string]: string }
const shortCodeMap = (emojiData as unknown as EmojiEntry[]).reduce((reduceObject, emoji) => {
emoji.shortcodes.forEach((shortcode) => {
reduceObject[shortcode] = emoji.emoji
})
return reduceObject
}, {} as ShortCodeMap)
const emojiSkinToneModifierMap = [1, 2, 3, 4, 5].reduce((reduceObject, modifierValue) => {
const lightSkinCode = 127995
const codepoint = lightSkinCode + (modifierValue - 1)
const shortcode = `skin-tone-${modifierValue}`
reduceObject[shortcode] = `&#${codepoint};`
return reduceObject
}, {} as ShortCodeMap)
export const combinedEmojiData = {
...shortCodeMap,
...emojiSkinToneModifierMap
}
export const emojiShortcodes = Object.keys(combinedEmojiData)

View file

@ -0,0 +1,43 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { AlertAppExtension } from './alert/alert-app-extension'
import { BasicMarkdownSyntaxAppExtension } from './basic-markdown-syntax/basic-markdown-syntax-app-extension'
import { BlockquoteAppExtension } from './blockquote/blockquote-app-extension'
import { BootstrapIconAppExtension } from './bootstrap-icons/bootstrap-icon-app-extension'
import { CsvTableAppExtension } from './csv/csv-table-app-extension'
import { EmojiAppExtension } from './emoji/emoji-app-extension'
import { ExtractFirstHeadlineAppExtension } from './extract-first-headline/extract-first-headline-app-extension'
import { ForkAwesomeHtmlTagAppExtension } from './fork-awesome-html-tag/fork-awesome-html-tag-app-extension'
import { HeadlineAnchorsAppExtension } from './headline-anchors/headline-anchors-app-extension'
import { HighlightedCodeFenceAppExtension } from './highlighted-code-fence/highlighted-code-fence-app-extension'
import { IframeCapsuleAppExtension } from './iframe-capsule/iframe-capsule-app-extension'
import { ImagePlaceholderAppExtension } from './image-placeholder/image-placeholder-app-extension'
import { LegacyShortcodesAppExtension } from './legacy-short-codes/legacy-shortcodes-app-extension'
import { SpoilerAppExtension } from './spoiler/spoiler-app-extension'
import { TableOfContentsAppExtension } from './table-of-contents/table-of-contents-app-extension'
import { TaskListCheckboxAppExtension } from './task-list/task-list-checkbox-app-extension'
/**
* Contains all app extensions that are adding essential features or additional features developed by the HedgeDoc maintainers.
*/
export const essentialAppExtensions = [
new AlertAppExtension(),
new BasicMarkdownSyntaxAppExtension(),
new BlockquoteAppExtension(),
new BootstrapIconAppExtension(),
new CsvTableAppExtension(),
new EmojiAppExtension(),
new ExtractFirstHeadlineAppExtension(),
new ForkAwesomeHtmlTagAppExtension(),
new HighlightedCodeFenceAppExtension(),
new IframeCapsuleAppExtension(),
new ImagePlaceholderAppExtension(),
new LegacyShortcodesAppExtension(),
new SpoilerAppExtension(),
new TableOfContentsAppExtension(),
new TaskListCheckboxAppExtension(),
new HeadlineAnchorsAppExtension()
]

View file

@ -0,0 +1,27 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
import { RendererType } from '../../../components/render-page/window-post-message-communicator/rendering-message'
import type { MarkdownRendererExtensionOptions } from '../../_base-classes/app-extension'
import { AppExtension } from '../../_base-classes/app-extension'
import { ExtractFirstHeadlineEditorExtension } from './extract-first-headline-editor-extension'
import { ExtractFirstHeadlineMarkdownExtension } from './extract-first-headline-markdown-extension'
/**
* Provides first headline extraction
*/
export class ExtractFirstHeadlineAppExtension extends AppExtension {
buildMarkdownRendererExtensions(options: MarkdownRendererExtensionOptions): MarkdownRendererExtension[] {
if (options.rendererType === RendererType.SIMPLE) {
return []
}
return [new ExtractFirstHeadlineMarkdownExtension(options.eventEmitter)]
}
buildEditorExtensionComponent(): React.FC {
return ExtractFirstHeadlineEditorExtension
}
}

View file

@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useExtensionEventEmitterHandler } from '../../../components/markdown-renderer/hooks/use-extension-event-emitter'
import { updateNoteTitleByFirstHeading } from '../../../redux/note-details/methods'
import { ExtractFirstHeadlineNodeProcessor } from './extract-first-headline-node-processor'
import type React from 'react'
/**
* Receives the {@link ExtractFirstHeadlineNodeProcessor.EVENT_NAME first heading extraction event}
* and saves the title in the global application state.
*/
export const ExtractFirstHeadlineEditorExtension: React.FC = () => {
useExtensionEventEmitterHandler(ExtractFirstHeadlineNodeProcessor.EVENT_NAME, updateNoteTitleByFirstHeading)
return null
}

View file

@ -0,0 +1,17 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { EventMarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/event-markdown-renderer-extension'
import type { NodeProcessor } from '../../../components/markdown-renderer/node-preprocessors/node-processor'
import { ExtractFirstHeadlineNodeProcessor } from './extract-first-headline-node-processor'
/**
* Adds first heading extraction to the renderer
*/
export class ExtractFirstHeadlineMarkdownExtension extends EventMarkdownRendererExtension {
buildNodeProcessors(): NodeProcessor[] {
return [new ExtractFirstHeadlineNodeProcessor(this.eventEmitter)]
}
}

View file

@ -0,0 +1,28 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { NodeProcessor } from '../../../components/markdown-renderer/node-preprocessors/node-processor'
import { extractFirstHeading } from '@hedgedoc/commons'
import { Optional } from '@mrdrogdrog/optional'
import type { Document } from 'domhandler'
import type { EventEmitter2 } from 'eventemitter2'
/**
* Searches for the first headline tag and extracts its plain text content.
*/
export class ExtractFirstHeadlineNodeProcessor extends NodeProcessor {
public static readonly EVENT_NAME = 'HeadlineExtracted'
constructor(private eventEmitter: EventEmitter2) {
super()
}
process(nodes: Document): Document {
Optional.ofNullable(extractFirstHeading(nodes))
.filter((text) => text !== '')
.ifPresent((text) => this.eventEmitter.emit(ExtractFirstHeadlineNodeProcessor.EVENT_NAME, text))
return nodes
}
}

View file

@ -0,0 +1,25 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Linter } from '../../../components/editor-page/editor-pane/linter/linter'
import { SingleLineRegexLinter } from '../../../components/editor-page/editor-pane/linter/single-line-regex-linter'
import { AppExtension } from '../../_base-classes/app-extension'
import { t } from 'i18next'
const forkAwesomeRegex = /<i class=["'][\w\s]*fa-[\w-]+[\w\s-]*["'][^>]*\/?>(?:<\/i>)?/
/**
* Adds a linter for the icon html tag.
*/
export class ForkAwesomeHtmlTagAppExtension extends AppExtension {
buildCodeMirrorLinter(): Linter[] {
return [
new SingleLineRegexLinter(
forkAwesomeRegex,
t('editor.linter.fork-awesome', { link: 'https://docs.hedgedoc.org' }) // ToDo: Add correct link here
)
]
}
}

View file

@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
import { RendererType } from '../../../components/render-page/window-post-message-communicator/rendering-message'
import type { MarkdownRendererExtensionOptions } from '../../_base-classes/app-extension'
import { AppExtension } from '../../_base-classes/app-extension'
import { HeadlineAnchorsMarkdownRendererExtension } from './headline-anchors-markdown-renderer-extension'
/**
* Provides anchor links for headlines
*/
export class HeadlineAnchorsAppExtension extends AppExtension {
buildMarkdownRendererExtensions(options: MarkdownRendererExtensionOptions): MarkdownRendererExtension[] {
return options.rendererType === RendererType.DOCUMENT ? [new HeadlineAnchorsMarkdownRendererExtension()] : []
}
}

View file

@ -0,0 +1,24 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
import type MarkdownIt from 'markdown-it'
import anchor from 'markdown-it-anchor'
/**
* Adds headline anchors to the markdown rendering.
*/
export class HeadlineAnchorsMarkdownRendererExtension extends MarkdownRendererExtension {
public configureMarkdownIt(markdownIt: MarkdownIt): void {
anchor(markdownIt, {
permalink: anchor.permalink.ariaHidden({
symbol: '🔗',
class: 'heading-anchor text-dark',
renderHref: (slug: string): string => `#${slug}`,
placement: 'before'
})
})
}
}

View file

@ -0,0 +1,271 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Highlighted code markdown extension renders with just the language and line wrapping doesn't show a gutter 1`] = `
<div>
<pre>
<span>
this is a mock for highlighted code
<code>
let x = 0
</code>
<span>
language:
javascript
</span>
<span>
start line number:
</span>
<span>
wrap line:
true
</span>
</span>
</pre>
</div>
`;
exports[`Highlighted code markdown extension renders with just the language doesn't show a gutter 1`] = `
<div>
<pre>
<span>
this is a mock for highlighted code
<code>
let x = 0
</code>
<span>
language:
javascript
</span>
<span>
start line number:
</span>
<span>
wrap line:
false
</span>
</span>
</pre>
</div>
`;
exports[`Highlighted code markdown extension renders with the language and show gutter and line wrapping shows the correct line number 1`] = `
<div>
<pre>
<span>
this is a mock for highlighted code
<code>
let x = 0
</code>
<span>
language:
javascript
</span>
<span>
start line number:
1
</span>
<span>
wrap line:
true
</span>
</span>
</pre>
</div>
`;
exports[`Highlighted code markdown extension renders with the language and show gutter shows the correct line number 1`] = `
<div>
<pre>
<span>
this is a mock for highlighted code
<code>
let x = 0
</code>
<span>
language:
javascript
</span>
<span>
start line number:
1
</span>
<span>
wrap line:
false
</span>
</span>
</pre>
</div>
`;
exports[`Highlighted code markdown extension renders with the language, show gutter with a start number and line wrapping shows the correct line number 1`] = `
<div>
<pre>
<span>
this is a mock for highlighted code
<code>
let x = 0
</code>
<span>
language:
javascript
</span>
<span>
start line number:
100
</span>
<span>
wrap line:
true
</span>
</span>
</pre>
</div>
`;
exports[`Highlighted code markdown extension renders with the language, show gutter with a start number and line wrapping shows the correct line number and continues in another codeblock 1`] = `
<div>
<pre>
<span>
this is a mock for highlighted code
<code>
let x = 0
let y = 1
</code>
<span>
language:
javascript
</span>
<span>
start line number:
100
</span>
<span>
wrap line:
true
</span>
</span>
</pre>
<pre>
<span>
this is a mock for highlighted code
<code>
let y = 2
</code>
<span>
language:
javascript
</span>
<span>
start line number:
102
</span>
<span>
wrap line:
false
</span>
</span>
</pre>
</div>
`;
exports[`Highlighted code markdown extension renders with the language, show gutter with a start number shows the correct line number 1`] = `
<div>
<pre>
<span>
this is a mock for highlighted code
<code>
let x = 0
</code>
<span>
language:
javascript
</span>
<span>
start line number:
100
</span>
<span>
wrap line:
false
</span>
</span>
</pre>
</div>
`;
exports[`Highlighted code markdown extension renders with the language, show gutter with a start number shows the correct line number and continues in another codeblock 1`] = `
<div>
<pre>
<span>
this is a mock for highlighted code
<code>
let x = 0
let y = 1
</code>
<span>
language:
javascript
</span>
<span>
start line number:
100
</span>
<span>
wrap line:
false
</span>
</span>
</pre>
<pre>
<span>
this is a mock for highlighted code
<code>
let y = 2
</code>
<span>
language:
javascript
</span>
<span>
start line number:
102
</span>
<span>
wrap line:
false
</span>
</span>
</pre>
</div>
`;

View file

@ -0,0 +1,49 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { CheatsheetExtension } from '../../../components/editor-page/cheatsheet/cheatsheet-extension'
import { codeFenceRegex } from '../../../components/editor-page/editor-pane/autocompletions/basic-completion'
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
import { AppExtension } from '../../_base-classes/app-extension'
import { HighlightedCodeMarkdownExtension } from './highlighted-code-markdown-extension'
import type { CompletionSource } from '@codemirror/autocomplete'
import type { CompletionContext, CompletionResult } from '@codemirror/autocomplete'
import { languages } from '@codemirror/language-data'
/**
* Adds code highlighting to the markdown rendering.
*/
export class HighlightedCodeFenceAppExtension extends AppExtension {
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
return [new HighlightedCodeMarkdownExtension()]
}
buildCheatsheetExtensions(): CheatsheetExtension[] {
return [
{
i18nKey: 'codeHighlighting',
entries: [{ i18nKey: 'language' }, { i18nKey: 'lineNumbers' }, { i18nKey: 'lineWrapping' }]
}
]
}
buildAutocompletion(): CompletionSource[] {
return [
(context: CompletionContext): CompletionResult | null => {
const match = context.matchBefore(codeFenceRegex)
if (!match || (match.from === match.to && !context.explicit)) {
return null
}
return {
from: match.from,
options: languages.map((lang) => ({
detail: lang.name,
label: '```' + lang.alias[0] + '\n\n```'
}))
}
}
]
}
}

View file

@ -0,0 +1,130 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as HighlightedCodeModule from '../../../components/common/highlighted-code/highlighted-code'
import type { HighlightedCodeProps } from '../../../components/common/highlighted-code/highlighted-code'
import { TestMarkdownRenderer } from '../../../components/markdown-renderer/test-utils/test-markdown-renderer'
import { mockI18n } from '../../../test-utils/mock-i18n'
import { HighlightedCodeMarkdownExtension } from './highlighted-code-markdown-extension'
import { render } from '@testing-library/react'
import React from 'react'
jest.mock('../../../components/common/highlighted-code/highlighted-code')
describe('Highlighted code markdown extension', () => {
describe('renders', () => {
beforeAll(async () => {
jest.spyOn(HighlightedCodeModule, 'HighlightedCode').mockImplementation((({
code,
language,
startLineNumber,
wrapLines
}) => {
return (
<span>
this is a mock for highlighted code
<code>{code}</code>
<span>language: {language}</span>
<span>start line number: {startLineNumber}</span>
<span>wrap line: {wrapLines ? 'true' : 'false'}</span>
</span>
)
}) as React.FC<HighlightedCodeProps>)
await mockI18n()
})
describe('with just the language', () => {
it("doesn't show a gutter", () => {
const view = render(
<TestMarkdownRenderer
extensions={[new HighlightedCodeMarkdownExtension()]}
content={'```javascript \nlet x = 0\n```'}
/>
)
expect(view.container).toMatchSnapshot()
})
describe('and line wrapping', () => {
it("doesn't show a gutter", () => {
const view = render(
<TestMarkdownRenderer
extensions={[new HighlightedCodeMarkdownExtension()]}
content={'```javascript! \nlet x = 0\n```'}
/>
)
expect(view.container).toMatchSnapshot()
})
})
})
describe('with the language and show gutter', () => {
it('shows the correct line number', () => {
const view = render(
<TestMarkdownRenderer
extensions={[new HighlightedCodeMarkdownExtension()]}
content={'```javascript= \nlet x = 0\n```'}
/>
)
expect(view.container).toMatchSnapshot()
})
describe('and line wrapping', () => {
it('shows the correct line number', () => {
const view = render(
<TestMarkdownRenderer
extensions={[new HighlightedCodeMarkdownExtension()]}
content={'```javascript=! \nlet x = 0\n```'}
/>
)
expect(view.container).toMatchSnapshot()
})
})
})
describe('with the language, show gutter with a start number', () => {
it('shows the correct line number', () => {
const view = render(
<TestMarkdownRenderer
extensions={[new HighlightedCodeMarkdownExtension()]}
content={'```javascript=100 \nlet x = 0\n```'}
/>
)
expect(view.container).toMatchSnapshot()
})
it('shows the correct line number and continues in another codeblock', () => {
const view = render(
<TestMarkdownRenderer
extensions={[new HighlightedCodeMarkdownExtension()]}
content={'```javascript=100 \nlet x = 0\nlet y = 1\n```\n\n```javascript=+\nlet y = 2\n```\n'}
/>
)
expect(view.container).toMatchSnapshot()
})
describe('and line wrapping', () => {
it('shows the correct line number', () => {
const view = render(
<TestMarkdownRenderer
extensions={[new HighlightedCodeMarkdownExtension()]}
content={'```javascript=100! \nlet x = 0\n```'}
/>
)
expect(view.container).toMatchSnapshot()
})
it('shows the correct line number and continues in another codeblock', () => {
const view = render(
<TestMarkdownRenderer
extensions={[new HighlightedCodeMarkdownExtension()]}
content={'```javascript=100! \nlet x = 0\nlet y = 1\n```\n\n```javascript=+\nlet y = 2\n```\n'}
/>
)
expect(view.container).toMatchSnapshot()
})
})
})
})
})

View file

@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { CodeBlockMarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/code-block-markdown-extension/code-block-markdown-renderer-extension'
import type { ComponentReplacer } from '../../../components/markdown-renderer/replace-components/component-replacer'
import { HighlightedCodeReplacer } from './highlighted-code-replacer'
/**
* Adds code highlighting to the markdown rendering.
* Every code fence that is not replaced by another replacer is highlighted using highlight-js.
*/
export class HighlightedCodeMarkdownExtension extends CodeBlockMarkdownRendererExtension {
public buildReplacers(): ComponentReplacer[] {
return [new HighlightedCodeReplacer()]
}
}

View file

@ -0,0 +1,59 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import HighlightedCode from '../../../components/common/highlighted-code/highlighted-code'
import {
ComponentReplacer,
DO_NOT_REPLACE
} from '../../../components/markdown-renderer/replace-components/component-replacer'
import type { NodeReplacement } from '../../../components/markdown-renderer/replace-components/component-replacer'
import type { Element } from 'domhandler'
import React from 'react'
/**
* Detects code blocks and renders them as highlighted code blocks
*/
export class HighlightedCodeReplacer extends ComponentReplacer {
private lastLineNumber = 0
private static extractCode(codeNode: Element): string | undefined {
return codeNode.name === 'code' && !!codeNode.attribs['data-highlight-language'] && !!codeNode.children[0]
? ComponentReplacer.extractTextChildContent(codeNode)
: undefined
}
public replace(codeNode: Element): NodeReplacement {
const code = HighlightedCodeReplacer.extractCode(codeNode)
if (!code) {
return DO_NOT_REPLACE
}
const language = codeNode.attribs['data-highlight-language']
const extraData = codeNode.attribs['data-extra']
const extraInfos = /(=(\d+|\+)?)?(!?)/.exec(extraData)
const showLineNumbers = extraInfos ? extraInfos[1]?.startsWith('=') : false
const startLineNumberAttribute = extraInfos?.[2] ?? ''
const wrapLines = extraInfos?.[3] === '!'
const startLineNumber =
startLineNumberAttribute === '+' ? this.lastLineNumber : parseInt(startLineNumberAttribute) || 1
if (showLineNumbers) {
this.lastLineNumber = startLineNumber + code.split('\n').filter((line) => !!line).length
}
return (
<HighlightedCode
language={language}
startLineNumber={showLineNumbers ? startLineNumber : undefined}
wrapLines={wrapLines}
code={code}
/>
)
}
reset() {
this.lastLineNumber = 0
}
}

View file

@ -0,0 +1,24 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { CheatsheetExtension } from '../../../components/editor-page/cheatsheet/cheatsheet-extension'
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
import { AppExtension } from '../../_base-classes/app-extension'
import { IframeCapsuleMarkdownExtension } from './iframe-capsule-markdown-extension'
export class IframeCapsuleAppExtension extends AppExtension {
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
return [new IframeCapsuleMarkdownExtension()]
}
buildCheatsheetExtensions(): CheatsheetExtension[] {
return [
{
i18nKey: 'iframeCapsule',
categoryI18nKey: 'embedding'
}
]
}
}

View file

@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
import type { ComponentReplacer } from '../../../components/markdown-renderer/replace-components/component-replacer'
import { IframeCapsuleReplacer } from './iframe-capsule-replacer'
/**
* Adds a replacer that capsules iframes in a click shield.
*/
export class IframeCapsuleMarkdownExtension extends MarkdownRendererExtension {
public buildReplacers(): ComponentReplacer[] {
return [new IframeCapsuleReplacer()]
}
public buildTagNameAllowList(): string[] {
return ['iframe']
}
}

View file

@ -0,0 +1,38 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ClickShield } from '../../../components/markdown-renderer/replace-components/click-shield/click-shield'
import type {
NativeRenderer,
NodeReplacement,
SubNodeTransform
} from '../../../components/markdown-renderer/replace-components/component-replacer'
import {
ComponentReplacer,
DO_NOT_REPLACE
} from '../../../components/markdown-renderer/replace-components/component-replacer'
import type { Element } from 'domhandler'
import React from 'react'
import { Globe as IconGlobe } from 'react-bootstrap-icons'
/**
* Capsules <iframe> elements with a click shield.
*
* @see ClickShield
*/
export class IframeCapsuleReplacer extends ComponentReplacer {
replace(node: Element, subNodeTransform: SubNodeTransform, nativeRenderer: NativeRenderer): NodeReplacement {
return node.name !== 'iframe' ? (
DO_NOT_REPLACE
) : (
<ClickShield
hoverIcon={IconGlobe}
targetDescription={node.attribs.src}
data-cypress-id={'iframe-capsule-click-shield'}>
{nativeRenderer()}
</ClickShield>
)
}
}

View file

@ -0,0 +1,34 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ImagePlaceholderMarkdownExtension } from './image-placeholder-markdown-extension'
import type MarkdownIt from 'markdown-it/lib'
/**
* A {@link MarkdownIt.PluginSimple markdown it plugin} that adds the line number of the markdown code to every placeholder image.
*
* @param markdownIt The markdown it instance to which the plugin should be added
*/
export const addLineToPlaceholderImageTags: MarkdownIt.PluginSimple = (markdownIt: MarkdownIt) => {
markdownIt.core.ruler.push('image-placeholder', (state) => {
state.tokens.forEach((token) => {
if (token.type !== 'inline') {
return
}
token.children?.forEach((childToken) => {
if (
childToken.type === 'image' &&
childToken.attrGet('src') === ImagePlaceholderMarkdownExtension.PLACEHOLDER_URL
) {
const line = token.map?.[0]
if (line !== undefined) {
childToken.attrSet('data-line', String(line))
}
}
})
})
return true
})
}

View file

@ -0,0 +1,42 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useRendererToEditorCommunicator } from '../../../../components/editor-page/render-context/renderer-to-editor-communicator-context-provider'
import { CommunicationMessageType } from '../../../../components/render-page/window-post-message-communicator/rendering-message'
import { Logger } from '../../../../utils/logger'
import { FileContentFormat, readFile } from '../../../../utils/read-file'
import { useCallback } from 'react'
const log = new Logger('useOnImageUpload')
/**
* Provides a callback that sends a {@link File file} to the editor via iframe communication.
*
* @param lineIndex The index of the line in the markdown content where the placeholder is defined
* @param placeholderIndexInLine The index of the placeholder in the markdown content line
*/
export const useOnImageUpload = (
lineIndex: number | undefined,
placeholderIndexInLine: number | undefined
): ((file: File) => void) => {
const communicator = useRendererToEditorCommunicator()
return useCallback(
(file: File) => {
readFile(file, FileContentFormat.DATA_URL)
.then((dataUri) => {
communicator.sendMessageToOtherSide({
type: CommunicationMessageType.IMAGE_UPLOAD,
dataUri,
fileName: file.name,
lineIndex,
placeholderIndexInLine
})
})
.catch((error: ProgressEvent) => log.error('Error while uploading image', error))
},
[communicator, placeholderIndexInLine, lineIndex]
)
}

View file

@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { calculatePlaceholderContainerSize } from '../utils/build-placeholder-size-css'
import type { CSSProperties } from 'react'
import { useMemo } from 'react'
/**
* Creates the style attribute for a placeholder container with width and height.
*
* @param width The wanted width
* @param height The wanted height
* @return The created style attributes
*/
export const usePlaceholderSizeStyle = (width?: string | number, height?: string | number): CSSProperties => {
return useMemo(() => {
const [convertedWidth, convertedHeight] = calculatePlaceholderContainerSize(width, height)
return {
width: `${convertedWidth}px`,
height: `${convertedHeight}px`
}
}, [height, width])
}

View file

@ -0,0 +1,37 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { CheatsheetExtension } from '../../../components/editor-page/cheatsheet/cheatsheet-extension'
import { basicCompletion } from '../../../components/editor-page/editor-pane/autocompletions/basic-completion'
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
import { AppExtension } from '../../_base-classes/app-extension'
import { ImagePlaceholderMarkdownExtension } from './image-placeholder-markdown-extension'
import type { CompletionSource } from '@codemirror/autocomplete'
import { t } from 'i18next'
export class ImagePlaceholderAppExtension extends AppExtension {
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
return [new ImagePlaceholderMarkdownExtension()]
}
buildCheatsheetExtensions(): CheatsheetExtension[] {
return [
{
i18nKey: 'imagePlaceholder'
}
]
}
buildAutocompletion(): CompletionSource[] {
return [
basicCompletion(/(^|\s)!\[?/, '![alt text](https://)', t('editor.autocompletions.image') ?? undefined),
basicCompletion(
/(^|\s)!\[?/,
'![alt text](https:// =200x500)',
t('editor.autocompletions.imageWithDimensions') ?? undefined
)
]
}
}

View file

@ -0,0 +1,25 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
import type { ComponentReplacer } from '../../../components/markdown-renderer/replace-components/component-replacer'
import { addLineToPlaceholderImageTags } from './add-line-to-placeholder-image-tags'
import { ImagePlaceholderReplacer } from './image-placeholder-replacer'
import type MarkdownIt from 'markdown-it/lib'
/**
* Adds support for {@link ImagePlaceholder}.
*/
export class ImagePlaceholderMarkdownExtension extends MarkdownRendererExtension {
public static readonly PLACEHOLDER_URL = 'https://'
configureMarkdownIt(markdownIt: MarkdownIt): void {
addLineToPlaceholderImageTags(markdownIt)
}
buildReplacers(): ComponentReplacer[] {
return [new ImagePlaceholderReplacer()]
}
}

View file

@ -0,0 +1,47 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { NodeReplacement } from '../../../components/markdown-renderer/replace-components/component-replacer'
import {
ComponentReplacer,
DO_NOT_REPLACE
} from '../../../components/markdown-renderer/replace-components/component-replacer'
import { ImagePlaceholder } from './image-placeholder'
import { ImagePlaceholderMarkdownExtension } from './image-placeholder-markdown-extension'
import type { Element } from 'domhandler'
/**
* Replaces every image tag that has the {@link ImagePlaceholderMarkdownExtension.PLACEHOLDER_URL placeholder url} with the {@link ImagePlaceholder image placeholder element}.
*/
export class ImagePlaceholderReplacer extends ComponentReplacer {
private countPerSourceLine = new Map<number, number>()
constructor() {
super()
}
reset(): void {
this.countPerSourceLine = new Map<number, number>()
}
replace(node: Element): NodeReplacement {
if (node.name !== 'img' || node.attribs?.src !== ImagePlaceholderMarkdownExtension.PLACEHOLDER_URL) {
return DO_NOT_REPLACE
}
const lineIndex = Number(node.attribs['data-line'])
const indexInLine = this.countPerSourceLine.get(lineIndex) ?? 0
this.countPerSourceLine.set(lineIndex, indexInLine + 1)
return (
<ImagePlaceholder
alt={node.attribs.alt}
title={node.attribs.title}
width={node.attribs.width}
height={node.attribs.height}
lineIndex={isNaN(lineIndex) ? undefined : lineIndex}
placeholderIndexInLine={indexInLine}
/>
)
}
}

View file

@ -0,0 +1,21 @@
/*!
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
.image-drop {
border: 3px dashed var(--bs-dark);
border-radius: 3px;
transition: background-color 50ms, color 50ms;
.altText {
text-overflow: ellipsis;
flex: 1 1;
overflow: hidden;
width: 100%;
white-space: nowrap;
text-align: center;
}
}

View file

@ -0,0 +1,114 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { UiIcon } from '../../../components/common/icons/ui-icon'
import { acceptedMimeTypes } from '../../../components/common/upload-image-mimetypes'
import { cypressId } from '../../../utils/cypress-attribute'
import { useOnImageUpload } from './hooks/use-on-image-upload'
import { usePlaceholderSizeStyle } from './hooks/use-placeholder-size-style'
import styles from './image-placeholder.module.scss'
import React, { useCallback, useMemo, useRef, useState } from 'react'
import { Button } from 'react-bootstrap'
import { Upload as IconUpload } from 'react-bootstrap-icons'
import { Trans, useTranslation } from 'react-i18next'
export interface PlaceholderImageFrameProps {
alt?: string
title?: string
width?: string | number
height?: string | number
lineIndex?: number
placeholderIndexInLine?: number
}
/**
* Shows a placeholder for an actual image with the possibility to upload images via button or drag'n'drop.
*
* @param alt The alt text of the image. Will be shown in the placeholder
* @param title The title text of the image. Will be shown in the placeholder
* @param width The width of the placeholder
* @param height The height of the placeholder
* @param lineIndex The index of the line in the markdown content where the placeholder is defined
* @param placeholderIndexInLine The index of the placeholder in the markdown line
*/
export const ImagePlaceholder: React.FC<PlaceholderImageFrameProps> = ({
alt,
title,
width,
height,
lineIndex,
placeholderIndexInLine
}) => {
useTranslation()
const fileInputReference = useRef<HTMLInputElement>(null)
const onImageUpload = useOnImageUpload(lineIndex, placeholderIndexInLine)
const [showDragStatus, setShowDragStatus] = useState(false)
const onDropHandler = useCallback(
(event: React.DragEvent<HTMLSpanElement>) => {
event.preventDefault()
if (event?.dataTransfer?.files?.length > 0) {
onImageUpload(event.dataTransfer.files[0])
}
},
[onImageUpload]
)
const onDragOverHandler = useCallback((event: React.DragEvent<HTMLSpanElement>) => {
event.preventDefault()
setShowDragStatus(true)
}, [])
const onDragLeave = useCallback(() => {
setShowDragStatus(false)
}, [])
const onChangeHandler = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const fileList = event.target.files
if (!fileList || fileList.length < 1) {
return
}
onImageUpload(fileList[0])
},
[onImageUpload]
)
const uploadButtonClicked = useCallback(() => fileInputReference.current?.click(), [])
const containerStyle = usePlaceholderSizeStyle(width, height)
const containerDragClasses = useMemo(() => (showDragStatus ? 'bg-primary text-white' : 'text-dark'), [showDragStatus])
return (
<span
{...cypressId('image-placeholder-image-drop')}
className={`${styles['image-drop']} d-inline-flex flex-column align-items-center ${containerDragClasses} p-1`}
style={containerStyle}
onDrop={onDropHandler}
onDragOver={onDragOverHandler}
onDragLeave={onDragLeave}>
<input
type='file'
className='d-none'
accept={acceptedMimeTypes}
onChange={onChangeHandler}
ref={fileInputReference}
/>
<div className={'align-items-center flex-column justify-content-center flex-fill d-flex'}>
<div className={'d-flex flex-column'}>
<span className='my-2'>
<Trans i18nKey={'editor.embeddings.placeholderImage.placeholderText'} />
</span>
<span className={styles['altText']}>{alt ?? title ?? ''}</span>
</div>
</div>
<Button size={'sm'} variant={'primary'} onClick={uploadButtonClicked}>
<UiIcon icon={IconUpload} className='my-2' />
<Trans i18nKey={'editor.embeddings.placeholderImage.upload'} className='my-2' />
</Button>
</span>
)
}

View file

@ -0,0 +1,66 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { calculatePlaceholderContainerSize, parseSizeNumber } from './build-placeholder-size-css'
describe('parseSizeNumber', () => {
it('undefined', () => {
expect(parseSizeNumber(undefined)).toBe(undefined)
})
it('zero as number', () => {
expect(parseSizeNumber(0)).toBe(0)
})
it('positive number', () => {
expect(parseSizeNumber(234)).toBe(234)
})
it('negative number', () => {
expect(parseSizeNumber(-123)).toBe(-123)
})
it('zero as string', () => {
expect(parseSizeNumber('0')).toBe(0)
})
it('negative number as string', () => {
expect(parseSizeNumber('-123')).toBe(-123)
})
it('positive number as string', () => {
expect(parseSizeNumber('345')).toBe(345)
})
it('positive number with px as string', () => {
expect(parseSizeNumber('456px')).toBe(456)
})
it('negative number with px as string', () => {
expect(parseSizeNumber('-456px')).toBe(-456)
})
})
describe('calculatePlaceholderContainerSize', () => {
it('width undefined | height undefined', () => {
expect(calculatePlaceholderContainerSize(undefined, undefined)).toStrictEqual([500, 200])
})
it('width 200 | height undefined', () => {
expect(calculatePlaceholderContainerSize(200, undefined)).toStrictEqual([200, 80])
})
it('width undefined | height 100', () => {
expect(calculatePlaceholderContainerSize(undefined, 100)).toStrictEqual([250, 100])
})
it('width "0" | height 0', () => {
expect(calculatePlaceholderContainerSize('0', 0)).toStrictEqual([0, 0])
})
it('width 0 | height "0"', () => {
expect(calculatePlaceholderContainerSize(0, '0')).toStrictEqual([0, 0])
})
it('width -345 | height 234', () => {
expect(calculatePlaceholderContainerSize(-345, 234)).toStrictEqual([-345, 234])
})
it('width 345 | height -234', () => {
expect(calculatePlaceholderContainerSize(345, -234)).toStrictEqual([345, -234])
})
it('width "-345" | height -234', () => {
expect(calculatePlaceholderContainerSize('-345', -234)).toStrictEqual([-345, -234])
})
it('width -345 | height "-234"', () => {
expect(calculatePlaceholderContainerSize(-345, '-234')).toStrictEqual([-345, -234])
})
})

View file

@ -0,0 +1,66 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
const regex = /^(-?[0-9]+)px$/
/**
* Inspects the given value and checks if it is a number or a pixel size string.
*
* @param value The value to check
* @return the number representation of the string or undefined if it couldn't be parsed
*/
export const parseSizeNumber = (value: string | number | undefined): number | undefined => {
if (value === undefined) {
return undefined
}
if (typeof value === 'number') {
return value
}
const regexMatches = regex.exec(value)
if (regexMatches !== null) {
if (regexMatches && regexMatches.length > 1) {
return parseInt(regexMatches[1])
} else {
return undefined
}
}
if (!Number.isNaN(value)) {
return parseInt(value)
}
}
/**
* Calculates the final width and height for a placeholder container.
* Every parameter that is empty will be defaulted using a 500:200 ratio.
*
* @param width The wanted width
* @param height The wanted height
* @return the calculated size
*/
export const calculatePlaceholderContainerSize = (
width: string | number | undefined,
height: string | number | undefined
): [width: number, height: number] => {
const defaultWidth = 500
const defaultHeight = 200
const ratio = defaultWidth / defaultHeight
const convertedWidth = parseSizeNumber(width)
const convertedHeight = parseSizeNumber(height)
if (convertedWidth === undefined && convertedHeight !== undefined) {
return [convertedHeight * ratio, convertedHeight]
} else if (convertedWidth !== undefined && convertedHeight === undefined) {
return [convertedWidth, convertedWidth * (1 / ratio)]
} else if (convertedWidth !== undefined && convertedHeight !== undefined) {
return [convertedWidth, convertedHeight]
} else {
return [defaultWidth, defaultHeight]
}
}

View file

@ -0,0 +1,43 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Legacy shortcodes markdown extension transforms a pdf short code into an URL 1`] = `
<div>
<p>
<a
href="https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
>
https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf
</a>
</p>
</div>
`;
exports[`Legacy shortcodes markdown extension transforms a slideshare short code into an URL 1`] = `
<div>
<p>
<a
href="https://www.slideshare.net/example/123456789"
>
https://www.slideshare.net/example/123456789
</a>
</p>
</div>
`;
exports[`Legacy shortcodes markdown extension transforms a speakerdeck short code into an URL 1`] = `
<div>
<p>
<a
href="https://speakerdeck.com/example/123456789"
>
https://speakerdeck.com/example/123456789
</a>
</p>
</div>
`;

View file

@ -0,0 +1,43 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Linter } from '../../../components/editor-page/editor-pane/linter/linter'
import { SingleLineRegexLinter } from '../../../components/editor-page/editor-pane/linter/single-line-regex-linter'
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
import { AppExtension } from '../../_base-classes/app-extension'
import { LegacyShortcodesMarkdownExtension } from './legacy-shortcodes-markdown-extension'
import { legacyPdfRegex } from './replace-legacy-pdf-short-code'
import { legacySlideshareRegex } from './replace-legacy-slideshare-short-code'
import { legacySpeakerdeckRegex } from './replace-legacy-speakerdeck-short-code'
import { t } from 'i18next'
/**
* Adds support for legacy shortcodes (pdf, slideshare and speakerdeck) from HedgeDoc 1 to the markdown renderer.
*/
export class LegacyShortcodesAppExtension extends AppExtension {
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
return [new LegacyShortcodesMarkdownExtension()]
}
buildCodeMirrorLinter(): Linter[] {
return [
new SingleLineRegexLinter(
legacySpeakerdeckRegex,
t('editor.linter.shortcode', { shortcode: 'SpeakerDeck' }),
(match: string) => `https://speakerdeck.com/${match}`
),
new SingleLineRegexLinter(
legacySlideshareRegex,
t('editor.linter.shortcode', { shortcode: 'SlideShare' }),
(match: string) => `https://www.slideshare.net/${match}`
),
new SingleLineRegexLinter(
legacyPdfRegex,
t('editor.linter.shortcode', { shortcode: 'PDF' }),
(match: string) => match
)
]
}
}

View file

@ -0,0 +1,39 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { TestMarkdownRenderer } from '../../../components/markdown-renderer/test-utils/test-markdown-renderer'
import { LegacyShortcodesMarkdownExtension } from './legacy-shortcodes-markdown-extension'
import { render } from '@testing-library/react'
import React from 'react'
describe('Legacy shortcodes markdown extension', () => {
it('transforms a pdf short code into an URL', () => {
const view = render(
<TestMarkdownRenderer
extensions={[new LegacyShortcodesMarkdownExtension()]}
content={'{%pdf https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf %}'}
/>
)
expect(view.container).toMatchSnapshot()
})
it('transforms a slideshare short code into an URL', () => {
const view = render(
<TestMarkdownRenderer
extensions={[new LegacyShortcodesMarkdownExtension()]}
content={'{%slideshare example/123456789 %}'}
/>
)
expect(view.container).toMatchSnapshot()
})
it('transforms a speakerdeck short code into an URL', () => {
const view = render(
<TestMarkdownRenderer
extensions={[new LegacyShortcodesMarkdownExtension()]}
content={'{%speakerdeck example/123456789 %}'}
/>
)
expect(view.container).toMatchSnapshot()
})
})

View file

@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
import { legacyPdfShortCode } from './replace-legacy-pdf-short-code'
import { legacySlideshareShortCode } from './replace-legacy-slideshare-short-code'
import { legacySpeakerdeckShortCode } from './replace-legacy-speakerdeck-short-code'
import type MarkdownIt from 'markdown-it'
/**
* Adds support for legacy shortcodes (pdf, slideshare and speakerdeck) by replacing them with anchor elements.
*/
export class LegacyShortcodesMarkdownExtension extends MarkdownRendererExtension {
public configureMarkdownIt(markdownIt: MarkdownIt): void {
legacyPdfShortCode(markdownIt)
legacySlideshareShortCode(markdownIt)
legacySpeakerdeckShortCode(markdownIt)
}
}

View file

@ -0,0 +1,24 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { legacyPdfShortCode } from './replace-legacy-pdf-short-code'
import MarkdownIt from 'markdown-it'
describe('Legacy pdf short code', () => {
it('replaces with link', () => {
const markdownIt = new MarkdownIt('default', {
html: false,
breaks: true,
langPrefix: '',
typographer: true
})
markdownIt.use(legacyPdfShortCode)
expect(
markdownIt.renderInline('{%pdf https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf %}')
).toEqual(
`<a href="https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf">https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf</a>`
)
})
})

View file

@ -0,0 +1,23 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { RegexOptions } from '../../../external-types/markdown-it-regex/interface'
import markdownItRegex from 'markdown-it-regex'
import type MarkdownIt from 'markdown-it/lib'
export const legacyPdfRegex = /^{%pdf\s+(\S*)\s*%}$/
/**
* Configure the given {@link MarkdownIt} to render legacy hedgedoc 1 pdf shortcodes as html links.
*
* @param markdownIt The {@link MarkdownIt} to configure
*/
export const legacyPdfShortCode: MarkdownIt.PluginSimple = (markdownIt) => {
markdownItRegex(markdownIt, {
name: 'legacy-pdf-short-code',
regex: legacyPdfRegex,
replace: (match) => `<a href="${match}">${match}</a>`
} as RegexOptions)
}

View file

@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { legacySlideshareShortCode } from './replace-legacy-slideshare-short-code'
import MarkdownIt from 'markdown-it'
describe('Legacy slideshare short code', () => {
it('replaces with link', () => {
const markdownIt = new MarkdownIt('default', {
html: false,
breaks: true,
langPrefix: '',
typographer: true
})
markdownIt.use(legacySlideshareShortCode)
expect(markdownIt.renderInline('{%slideshare example/123456789 %}')).toEqual(
"<a href='https://www.slideshare.net/example/123456789'>https://www.slideshare.net/example/123456789</a>"
)
})
})

View file

@ -0,0 +1,23 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { RegexOptions } from '../../../external-types/markdown-it-regex/interface'
import markdownItRegex from 'markdown-it-regex'
import type MarkdownIt from 'markdown-it/lib'
export const legacySlideshareRegex = /^{%slideshare\s+(\w+\/[\w-]+)\s*%}$/
/**
* Configure the given {@link MarkdownIt} to render legacy hedgedoc 1 slideshare shortcodes as HTML links.
*
* @param markdownIt The {@link MarkdownIt} to configure
*/
export const legacySlideshareShortCode: MarkdownIt.PluginSimple = (markdownIt) => {
markdownItRegex(markdownIt, {
name: 'legacy-slideshare-short-code',
regex: legacySlideshareRegex,
replace: (match) => `<a href='https://www.slideshare.net/${match}'>https://www.slideshare.net/${match}</a>`
} as RegexOptions)
}

View file

@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { legacySpeakerdeckShortCode } from './replace-legacy-speakerdeck-short-code'
import MarkdownIt from 'markdown-it'
describe('Legacy speakerdeck short code', () => {
it('replaces with link', () => {
const markdownIt = new MarkdownIt('default', {
html: false,
breaks: true,
langPrefix: '',
typographer: true
})
markdownIt.use(legacySpeakerdeckShortCode)
expect(markdownIt.renderInline('{%speakerdeck example/123456789 %}')).toEqual(
'<a href="https://speakerdeck.com/example/123456789">https://speakerdeck.com/example/123456789</a>'
)
})
})

View file

@ -0,0 +1,23 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { RegexOptions } from '../../../external-types/markdown-it-regex/interface'
import markdownItRegex from 'markdown-it-regex'
import type MarkdownIt from 'markdown-it/lib'
export const legacySpeakerdeckRegex = /^{%speakerdeck\s+(\w+\/[\w-]+)\s*%}$/
/**
* Configure the given {@link MarkdownIt} to render legacy hedgedoc 1 speakerdeck shortcodes as HTML links.
*
* @param markdownIt The {@link MarkdownIt} to configure
*/
export const legacySpeakerdeckShortCode: MarkdownIt.PluginSimple = (markdownIt) => {
markdownItRegex(markdownIt, {
name: 'legacy-speakerdeck-short-code',
regex: legacySpeakerdeckRegex,
replace: (match) => `<a href="https://speakerdeck.com/${match}">https://speakerdeck.com/${match}</a>`
} as RegexOptions)
}

View file

@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { CheatsheetExtension } from '../../../components/editor-page/cheatsheet/cheatsheet-extension'
import { basicCompletion } from '../../../components/editor-page/editor-pane/autocompletions/basic-completion'
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
import { AppExtension } from '../../_base-classes/app-extension'
import { SpoilerMarkdownExtension } from './spoiler-markdown-extension'
import type { CompletionSource } from '@codemirror/autocomplete'
import { t } from 'i18next'
const spoilerRegex = /(?:^|\s):(?::|::|::\w+)?/
/**
* Adds support for html spoiler tags.
*
* @see https://www.w3schools.com/tags/tag_details.asp
*/
export class SpoilerAppExtension extends AppExtension {
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
return [new SpoilerMarkdownExtension()]
}
buildCheatsheetExtensions(): CheatsheetExtension[] {
return [{ i18nKey: 'spoiler' }]
}
buildAutocompletion(): CompletionSource[] {
return [
basicCompletion(spoilerRegex, ':::spoiler Label text\n\n:::', t('editor.autocompletions.spoiler') ?? undefined)
]
}
}

View file

@ -0,0 +1,34 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
import type MarkdownIt from 'markdown-it'
import markdownItContainer from 'markdown-it-container'
import { escapeHtml } from 'markdown-it/lib/common/utils'
import type Token from 'markdown-it/lib/token'
/**
* Adds support for html spoiler tags.
*
* @see https://www.w3schools.com/tags/tag_details.asp
*/
export class SpoilerMarkdownExtension extends MarkdownRendererExtension {
private static readonly spoilerRegEx = /^spoiler\s+(.*)$/
private static renderSpoilerContainer(tokens: Token[], index: number): string {
const matches = SpoilerMarkdownExtension.spoilerRegEx.exec(tokens[index].info.trim())
return tokens[index].nesting === 1 && matches && matches[1]
? `<details><summary>${escapeHtml(matches[1])}</summary>`
: '</details>\n'
}
public configureMarkdownIt(markdownIt: MarkdownIt): void {
markdownItContainer(markdownIt, 'spoiler', {
validate: (params: string) => SpoilerMarkdownExtension.spoilerRegEx.test(params),
render: SpoilerMarkdownExtension.renderSpoilerContainer.bind(this)
})
}
}

View file

@ -0,0 +1,39 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { CheatsheetExtension } from '../../../components/editor-page/cheatsheet/cheatsheet-extension'
import { basicCompletion } from '../../../components/editor-page/editor-pane/autocompletions/basic-completion'
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/markdown-renderer-extension'
import type { MarkdownRendererExtensionOptions } from '../../_base-classes/app-extension'
import { AppExtension } from '../../_base-classes/app-extension'
import { TableOfContentsMarkdownExtension } from './table-of-contents-markdown-extension'
import type { CompletionSource } from '@codemirror/autocomplete'
import { t } from 'i18next'
export class TableOfContentsAppExtension extends AppExtension {
buildMarkdownRendererExtensions(options: MarkdownRendererExtensionOptions): MarkdownRendererExtension[] {
return [new TableOfContentsMarkdownExtension(options.eventEmitter)]
}
buildCheatsheetExtensions(): CheatsheetExtension[] {
return [
{
i18nKey: 'toc',
entries: [
{
i18nKey: 'basic'
},
{
i18nKey: 'levelLimit'
}
]
}
]
}
buildAutocompletion(): CompletionSource[] {
return [basicCompletion(/\[(?:t|to|toc)?/, '[toc]', t('editor.autocompletions.toc') ?? undefined)]
}
}

View file

@ -0,0 +1,37 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { tocSlugify } from '../../../components/editor-page/table-of-contents/toc-slugify'
import { EventMarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/event-markdown-renderer-extension'
import type { TocAst } from '@hedgedoc/markdown-it-plugins'
import { toc } from '@hedgedoc/markdown-it-plugins'
import equal from 'fast-deep-equal'
import type MarkdownIt from 'markdown-it'
/**
* Adds table of content to the markdown rendering.
*/
export class TableOfContentsMarkdownExtension extends EventMarkdownRendererExtension {
public static readonly EVENT_NAME = 'TocChange'
private lastAst: TocAst | undefined = undefined
public configureMarkdownIt(markdownIt: MarkdownIt): void {
const eventEmitter = this.eventEmitter
if (eventEmitter !== undefined) {
toc(markdownIt, {
listType: 'ul',
level: [1, 2, 3],
callback: (ast: TocAst): void => {
if (equal(ast, this.lastAst)) {
return
}
this.lastAst = ast
eventEmitter.emit(TableOfContentsMarkdownExtension.EVENT_NAME, ast)
},
slugify: tocSlugify
})
}
}
}

View file

@ -0,0 +1,14 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* Returns the markdown line prefix for a task list checkbox.
*
* @param state The check state of the checkbox.
*/
export const createCheckboxContent = (state: boolean) => {
return `[${state ? 'x' : ' '}]`
}

View file

@ -0,0 +1,37 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useExtensionEventEmitter } from '../../../components/markdown-renderer/hooks/use-extension-event-emitter'
import type { TaskCheckedChangeHandler, TaskListProps } from './task-list-checkbox'
import { TaskListCheckbox } from './task-list-checkbox'
import { TaskListCheckboxAppExtension } from './task-list-checkbox-app-extension'
import React, { useCallback } from 'react'
type EventEmittingTaskListCheckboxProps = Omit<TaskListProps, 'onTaskCheckedChange' | 'disabled'>
export interface TaskCheckedEventPayload {
lineInMarkdown: number
newCheckedState: boolean
}
/**
* Wraps a {@link TaskListCheckbox} but sends state changes to the current {@link EventEmitter2 event emitter}.
*
* @param props Props that will be forwarded to the checkbox.
*/
export const EventEmittingTaskListCheckbox: React.FC<EventEmittingTaskListCheckboxProps> = (props) => {
const emitter = useExtensionEventEmitter()
const sendEvent: TaskCheckedChangeHandler = useCallback(
(lineInMarkdown: number, checked: boolean) => {
emitter?.emit(TaskListCheckboxAppExtension.EVENT_NAME, {
lineInMarkdown,
newCheckedState: checked
} as TaskCheckedEventPayload)
},
[emitter]
)
return <TaskListCheckbox onTaskCheckedChange={sendEvent} disabled={emitter === undefined} {...props} />
}

View file

@ -0,0 +1,34 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Optional } from '@mrdrogdrog/optional'
const TASK_REGEX = /^(\s*(?:[-*+]|\d+[.)]) )(\[[ xX]?])/
/**
* Checks if the given markdown content contains a task list checkbox at the given line index.
*
* @param markdownContent The content that should be checked
* @param lineIndex The index of the line that should be checked for a task list checkbox
* @return An {@link Optional} that contains the start and end index of the found checkbox
*/
export const findCheckBox = (
markdownContent: string,
lineIndex: number
): Optional<[startIndex: number, endIndex: number]> => {
const lines = markdownContent.split('\n')
const lineStartIndex = findStartIndexOfLine(lines, lineIndex)
return Optional.ofNullable(TASK_REGEX.exec(lines[lineIndex])).map(([, beforeCheckbox, oldCheckbox]) => [
lineStartIndex + beforeCheckbox.length,
lineStartIndex + beforeCheckbox.length + oldCheckbox.length
])
}
const findStartIndexOfLine = (lines: string[], wantedLineIndex: number): number => {
return lines
.map((value) => value.length)
.filter((value, index) => index < wantedLineIndex)
.reduce((state, lineLength) => state + lineLength + 1, 0)
}

View file

@ -0,0 +1,38 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { CheatsheetExtensionComponentProps } from '../../../components/editor-page/cheatsheet/cheatsheet-extension'
import { useExtensionEventEmitterHandler } from '../../../components/markdown-renderer/hooks/use-extension-event-emitter'
import { createCheckboxContent } from './create-checkbox-content'
import type { TaskCheckedEventPayload } from './event-emitting-task-list-checkbox'
import { findCheckBox } from './find-check-box'
import { TaskListCheckboxAppExtension } from './task-list-checkbox-app-extension'
import type React from 'react'
import { useCallback } from 'react'
/**
* Receives task-checkbox-change events and modify the current editor content.
*/
export const SetCheckboxInCheatsheet: React.FC<CheatsheetExtensionComponentProps> = ({ setContent }) => {
useExtensionEventEmitterHandler(
TaskListCheckboxAppExtension.EVENT_NAME,
useCallback(
(event: TaskCheckedEventPayload) => {
setContent((previousContent) =>
findCheckBox(previousContent, event.lineInMarkdown)
.map(
([startIndex, endIndex]) =>
previousContent.slice(0, startIndex) +
createCheckboxContent(event.newCheckedState) +
previousContent.slice(endIndex)
)
.orElse(previousContent)
)
},
[setContent]
)
)
return null
}

View file

@ -0,0 +1,66 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useChangeEditorContentCallback } from '../../../components/editor-page/change-content-context/use-change-editor-content-callback'
import type { ContentEdits } from '../../../components/editor-page/editor-pane/tool-bar/formatters/types/changes'
import { useExtensionEventEmitterHandler } from '../../../components/markdown-renderer/hooks/use-extension-event-emitter'
import { store } from '../../../redux'
import { createCheckboxContent } from './create-checkbox-content'
import type { TaskCheckedEventPayload } from './event-emitting-task-list-checkbox'
import { findCheckBox } from './find-check-box'
import { TaskListCheckboxAppExtension } from './task-list-checkbox-app-extension'
import type React from 'react'
import { useCallback } from 'react'
/**
* Receives task-checkbox-change events and modify the current editor content.
*/
export const SetCheckboxInEditor: React.FC = () => {
const changeCallback = useSetCheckboxInEditor()
useExtensionEventEmitterHandler(TaskListCheckboxAppExtension.EVENT_NAME, changeCallback)
return null
}
/**
* Provides a callback that changes the state of a checkbox in a given line in the current codemirror instance.
*/
export const useSetCheckboxInEditor = () => {
const changeEditorContent = useChangeEditorContentCallback()
return useCallback(
({ lineInMarkdown, newCheckedState }: TaskCheckedEventPayload): void => {
changeEditorContent?.(({ markdownContent }) => {
const correctedLineIndex = lineInMarkdown + store.getState().noteDetails.frontmatterRendererInfo.lineOffset
const edits = findCheckBox(markdownContent, correctedLineIndex)
.map(([startIndex, endIndex]) => createCheckboxContentEdit(startIndex, endIndex, newCheckedState))
.orElse([])
return [edits, undefined]
})
},
[changeEditorContent]
)
}
/**
* Creates a {@link ContentEdits content edit} for the change of a checkbox at a given position.
*
* @param checkboxStartIndex The start index of the old checkbox code
* @param checkboxEndIndex The end index of the old checkbox code
* @param newCheckboxState The new status of the checkbox
* @return the created {@link ContentEdits edit}
*/
const createCheckboxContentEdit = (
checkboxStartIndex: number,
checkboxEndIndex: number,
newCheckboxState: boolean
): ContentEdits => {
return [
{
from: checkboxStartIndex,
to: checkboxEndIndex,
insert: createCheckboxContent(newCheckboxState)
}
]
}

View file

@ -0,0 +1,31 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { CheatsheetExtension } from '../../../components/editor-page/cheatsheet/cheatsheet-extension'
import type { MarkdownRendererExtensionOptions } from '../../_base-classes/app-extension'
import { AppExtension } from '../../_base-classes/app-extension'
import { SetCheckboxInCheatsheet } from './set-checkbox-in-cheatsheet'
import { SetCheckboxInEditor } from './set-checkbox-in-editor'
import { TaskListMarkdownExtension } from './task-list-markdown-extension'
import type React from 'react'
/**
* Adds support for interactive checkbox lists to the markdown renderer.
*/
export class TaskListCheckboxAppExtension extends AppExtension {
public static readonly EVENT_NAME = 'TaskListCheckbox'
buildMarkdownRendererExtensions(options: MarkdownRendererExtensionOptions): TaskListMarkdownExtension[] {
return [new TaskListMarkdownExtension(options.eventEmitter)]
}
buildEditorExtensionComponent(): React.FC {
return SetCheckboxInEditor
}
buildCheatsheetExtensions(): CheatsheetExtension[] {
return [{ i18nKey: 'taskList', cheatsheetExtensionComponent: SetCheckboxInCheatsheet }]
}
}

View file

@ -0,0 +1,48 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useCallback } from 'react'
export type TaskCheckedChangeHandler = (lineInMarkdown: number, checked: boolean) => void
export interface TaskListProps {
onTaskCheckedChange?: TaskCheckedChangeHandler
checked: boolean
lineInMarkdown?: number
disabled?: boolean
}
/**
* Renders a task list checkbox.
*
* @param onTaskCheckedChange A callback that is executed if the checkbox was clicked. If this prop is omitted then the checkbox will be disabled.
* @param checked Determines if the checkbox should be rendered as checked
* @param lineInMarkdown Defines the line in the markdown code this checkbox is mapped to. The information is send with the onTaskCheckedChange callback.
*/
export const TaskListCheckbox: React.FC<TaskListProps> = ({
onTaskCheckedChange,
checked,
lineInMarkdown,
disabled
}) => {
const onChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>): void => {
if (onTaskCheckedChange && disabled !== true && lineInMarkdown !== undefined) {
onTaskCheckedChange(lineInMarkdown, event.currentTarget.checked)
}
},
[disabled, lineInMarkdown, onTaskCheckedChange]
)
return (
<input
disabled={disabled !== true && onTaskCheckedChange === undefined}
className='task-list-item-checkbox'
type='checkbox'
checked={checked}
onChange={onChange}
/>
)
}

View file

@ -0,0 +1,27 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { EventMarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/_base-classes/event-markdown-renderer-extension'
import type { ComponentReplacer } from '../../../components/markdown-renderer/replace-components/component-replacer'
import { TaskListReplacer } from './task-list-replacer'
import { taskLists } from '@hedgedoc/markdown-it-plugins'
import type MarkdownIt from 'markdown-it'
/**
* Adds support for interactive checkbox lists to the markdown rendering using the github checklist syntax.
*/
export class TaskListMarkdownExtension extends EventMarkdownRendererExtension {
public configureMarkdownIt(markdownIt: MarkdownIt): void {
taskLists(markdownIt, {
enabled: true,
label: true,
lineNumber: true
})
}
public buildReplacers(): ComponentReplacer[] {
return [new TaskListReplacer()]
}
}

View file

@ -0,0 +1,30 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { NodeReplacement } from '../../../components/markdown-renderer/replace-components/component-replacer'
import {
ComponentReplacer,
DO_NOT_REPLACE
} from '../../../components/markdown-renderer/replace-components/component-replacer'
import { EventEmittingTaskListCheckbox } from './event-emitting-task-list-checkbox'
import type { Element } from 'domhandler'
import React from 'react'
/**
* Detects task lists and renders them as checkboxes that execute a callback if clicked.
*/
export class TaskListReplacer extends ComponentReplacer {
public replace(node: Element): NodeReplacement {
if (node.attribs?.class !== 'task-list-item-checkbox') {
return DO_NOT_REPLACE
}
const lineInMarkdown = Number(node.attribs['data-line'])
return isNaN(lineInMarkdown) ? (
DO_NOT_REPLACE
) : (
<EventEmittingTaskListCheckbox checked={node.attribs.checked !== undefined} lineInMarkdown={lineInMarkdown} />
)
}
}