Test multiple markdown extensions (#1886)

This commit adds multiple unit jest tests for components and removes e2e tests. 

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>

Signed-off-by: Philip Molares <philip.molares@udo.edu>

Co-authored-by: Philip Molares <philip.molares@udo.edu>
This commit is contained in:
Tilman Vatteroth 2022-04-27 09:59:01 +02:00 committed by GitHub
parent dca541ea1a
commit 32c6bbb8e3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
67 changed files with 2123 additions and 553 deletions

View file

@ -11,50 +11,14 @@ describe('Diagram codeblock ', () => {
/*
TODO: Readd test after fixing https://github.com/hedgedoc/react-client/issues/1709
it('renders markmap', () => {
cy.setCodemirrorContent('```markmap\n- pro\n- contra\n```')
cy.getMarkdownBody().findByCypressId('markmap').children().should('be.visible')
})
*/
it('renders vega-lite', () => {
cy.setCodemirrorContent(
'```vega-lite\n{"$schema":"https://vega.github.io/schema/vega-lite/v4.json","data":{"values":[{"a":"","b":28}]},"mark":"bar","encoding":{"x":{"field":"a"},"y":{"field":"b"}}}\n```'
)
cy.getMarkdownBody().find('.vega-embed').children().should('be.visible')
})
it('renders graphviz', () => {
cy.setCodemirrorContent('```graphviz\ngraph {\na -- b\n}\n```')
cy.getMarkdownBody().findByCypressId('graphviz').children().should('be.visible')
})
it('renders mermaid', () => {
cy.setCodemirrorContent('```mermaid\ngraph TD;\n A-->B;\n```')
cy.getMarkdownBody().findByCypressId('mermaid-frame').children().should('be.visible')
})
it('renders flowcharts', () => {
cy.setCodemirrorContent('```flow\nst=>start: Start\ne=>end: End\nst->e\n```')
cy.getMarkdownBody().findByCypressId('flowchart').children().should('be.visible')
})
it('renders abc scores', () => {
cy.setCodemirrorContent('```abc\nM:4/4\nK:G\n|:GABc dedB:|\n```')
cy.getMarkdownBody().findByCypressId('abcjs').children().should('be.visible')
})
it('renders csv as table', () => {
cy.setCodemirrorContent('```csv delimiter=; header\na;b;c;d\n1;2;3;4\n```')
cy.getMarkdownBody().findByCypressId('csv-html-table').first().should('be.visible')
})
it('renders plantuml', () => {
cy.setCodemirrorContent('```plantuml\nclass Example\n```')
cy.getMarkdownBody()
.find('img')
// PlantUML uses base64 encoded version of zip-deflated PlantUML code in the request URL.
.should('have.attr', 'src', 'http://mock-plantuml.local/svg/SoWkIImgAStDuKhEIImkLd2jICmjo4dbSaZDIm6A0W00')
})
})

View file

@ -1,28 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
describe('emojis', () => {
const HEDGEHOG_UNICODE_CHARACTER = '\n🦔\n'
beforeEach(() => {
cy.visitTestNote()
})
it('renders an emoji shortcode', () => {
cy.setCodemirrorContent(':hedgehog:')
cy.getMarkdownBody().should('have.text', HEDGEHOG_UNICODE_CHARACTER)
})
it('renders an emoji unicode character', () => {
cy.setCodemirrorContent(HEDGEHOG_UNICODE_CHARACTER)
cy.getMarkdownBody().should('have.text', HEDGEHOG_UNICODE_CHARACTER)
})
it('renders an fork awesome icon', () => {
cy.setCodemirrorContent(':fa-matrix-org:')
cy.getMarkdownBody().find('i.fa.fa-matrix-org').should('be.visible')
})
})

View file

@ -1,94 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
const findHljsCodeBlock = () => {
return cy.getMarkdownBody().findByCypressId('code-highlighter').should('be.visible')
}
describe('Code', () => {
beforeEach(() => {
cy.visitTestNote()
})
describe('with just the language', () => {
it("doesn't show a gutter", () => {
cy.setCodemirrorContent('```javascript \nlet x = 0\n```')
findHljsCodeBlock().should('have.attr', 'data-cypress-showgutter', 'false')
findHljsCodeBlock().findByCypressId('linenumber').should('not.be.visible')
})
describe('and line wrapping', () => {
it("doesn't show a gutter", () => {
cy.setCodemirrorContent('```javascript! \nlet x = 0\n```')
findHljsCodeBlock().should('have.attr', 'data-cypress-showgutter', 'false')
findHljsCodeBlock().should('have.attr', 'data-cypress-wrapLines', 'true')
findHljsCodeBlock().findByCypressId('linenumber').should('not.be.visible')
})
})
})
describe('with the language and show gutter', () => {
it('shows the correct line number', () => {
cy.setCodemirrorContent('```javascript= \nlet x = 0\n```')
findHljsCodeBlock().should('have.attr', 'data-cypress-showgutter', 'true')
findHljsCodeBlock().findByCypressId('linenumber').should('be.visible').text().should('eq', '1')
})
describe('and line wrapping', () => {
it('shows the correct line number', () => {
cy.setCodemirrorContent('```javascript=! \nlet x = 0\n```')
findHljsCodeBlock().should('have.attr', 'data-cypress-showgutter', 'true')
findHljsCodeBlock().should('have.attr', 'data-cypress-wrapLines', 'true')
findHljsCodeBlock().findByCypressId('linenumber').should('be.visible').text().should('eq', '1')
})
})
})
describe('with the language, show gutter with a start number', () => {
it('shows the correct line number', () => {
cy.setCodemirrorContent('```javascript=100 \nlet x = 0\n```')
findHljsCodeBlock().should('have.attr', 'data-cypress-showgutter', 'true')
findHljsCodeBlock().findByCypressId('linenumber').should('be.visible').text().should('eq', '100')
})
it('shows the correct line number and continues in another codeblock', () => {
cy.setCodemirrorContent('```javascript=100 \nlet x = 0\nlet y = 1\n```\n\n```javascript=+\nlet y = 2\n```\n')
findHljsCodeBlock().should('have.attr', 'data-cypress-showgutter', 'true')
findHljsCodeBlock().first().findByCypressId('linenumber').first().should('be.visible').text().should('eq', '100')
findHljsCodeBlock().first().findByCypressId('linenumber').last().should('be.visible').text().should('eq', '101')
findHljsCodeBlock().last().findByCypressId('linenumber').first().should('be.visible').text().should('eq', '102')
})
describe('and line wrapping', () => {
it('shows the correct line number', () => {
cy.setCodemirrorContent('```javascript=100! \nlet x = 0\n```')
findHljsCodeBlock().should('have.attr', 'data-cypress-showgutter', 'true')
findHljsCodeBlock().should('have.attr', 'data-cypress-wrapLines', 'true')
findHljsCodeBlock().findByCypressId('linenumber').should('be.visible').text().should('eq', '100')
})
it('shows the correct line number and continues in another codeblock', () => {
cy.setCodemirrorContent('```javascript=100! \nlet x = 0\nlet y = 1\n```\n\n```javascript=+\nlet y = 2\n```\n')
findHljsCodeBlock().should('have.attr', 'data-cypress-showgutter', 'true')
findHljsCodeBlock().should('have.attr', 'data-cypress-wrapLines', 'true')
findHljsCodeBlock()
.first()
.findByCypressId('linenumber')
.first()
.should('be.visible')
.text()
.should('eq', '100')
findHljsCodeBlock().first().findByCypressId('linenumber').last().should('be.visible').text().should('eq', '101')
findHljsCodeBlock().last().findByCypressId('linenumber').first().should('be.visible').text().should('eq', '102')
})
})
})
})

View file

@ -16,41 +16,4 @@ describe('Link gets replaced with embedding: ', () => {
cy.getMarkdownBody().findByCypressId('click-shield-gist').find('.preview-background').parent().click()
cy.getMarkdownBody().findByCypressId('gh-gist').should('be.visible')
})
it('YouTube', () => {
cy.setCodemirrorContent('https://www.youtube.com/watch?v=YE7VzlLtp-4')
cy.getMarkdownBody()
.findByCypressId('click-shield-youtube')
.find('.preview-background')
.should('have.attr', 'src', 'https://i.ytimg.com/vi/YE7VzlLtp-4/maxresdefault.jpg')
.parent()
.click()
cy.getMarkdownBody()
.find('iframe')
.should('have.attr', 'src', 'https://www.youtube-nocookie.com/embed/YE7VzlLtp-4?autoplay=1')
})
it('Vimeo', () => {
cy.intercept(
{
method: 'GET',
url: 'https://vimeo.com/api/v2/video/23237102.json'
},
{
statusCode: 200,
headers: {
'content-type': 'application/json'
},
body: '[{"thumbnail_large": "https://i.vimeocdn.com/video/503631401_640.jpg"}]'
}
)
cy.setCodemirrorContent('https://vimeo.com/23237102')
cy.getMarkdownBody()
.findByCypressId('click-shield-vimeo')
.find('.preview-background')
.should('have.attr', 'src', 'https://i.vimeocdn.com/video/503631401_640.jpg')
.parent()
.click()
cy.getMarkdownBody().find('iframe').should('have.attr', 'src', 'https://player.vimeo.com/video/23237102?autoplay=1')
})
})

View file

@ -1,41 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
describe('Short code gets replaced or rendered: ', () => {
beforeEach(() => {
cy.visitTestNote()
})
describe('pdf', () => {
it('renders a plain link', () => {
cy.setCodemirrorContent(`{%pdf https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf %}`)
cy.getMarkdownBody()
.find('a')
.should('have.attr', 'href', 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf')
})
})
describe('slideshare', () => {
it('renders a plain link', () => {
cy.setCodemirrorContent(`{%slideshare example/123456789 %}`)
cy.getMarkdownBody().find('a').should('have.attr', 'href', 'https://www.slideshare.net/example/123456789')
})
})
describe('speakerdeck', () => {
it('renders a plain link', () => {
cy.setCodemirrorContent(`{%speakerdeck example/123456789 %}`)
cy.getMarkdownBody().find('a').should('have.attr', 'href', 'https://speakerdeck.com/example/123456789')
})
})
describe('youtube', () => {
it('renders click-shield', () => {
cy.setCodemirrorContent(`{%youtube YE7VzlLtp-4 %}`)
cy.getMarkdownBody().findByCypressId('click-shield-youtube')
})
})
})

View file

@ -1,9 +1,4 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
exports[`AbcFrame renders a music sheet 1`] = `
<div>

View file

@ -0,0 +1,17 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AbcJs Markdown Extension renders an abc codeblock 1`] = `
<div>
<pre>
<span>
this is a mock for abc js frame
<code>
X:1\\nT:Speed the Plough\\nM:4/4\\nC:Trad.\\nK:G\\n|:GABc dedB|dedB dedB|c2ec B2dB|c2A2 A2BA|\\nGABc dedB|dedB dedB|c2ec B2dB|A2F2 G4:|\\n|:g2gf gdBd|g2f2 e2d2|c2ec B2dB|c2A2 A2df|\\ng2gf g2Bd|g2f2 e2d2|c2ec B2dB|A2F2 G4:|
</code>
</span>
</pre>
</div>
`;

View file

@ -28,7 +28,7 @@ export const AbcFrame: React.FC<CodeProps> = ({ code }) => {
value: abcLib
} = useAsync(async () => {
try {
return await import(/* webpackChunkName: "abc.js" */ 'abcjs')
return import(/* webpackChunkName: "abc.js" */ 'abcjs')
} catch (error) {
log.error('Error while loading abcjs', error)
throw error

View file

@ -0,0 +1,44 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import { render } from '@testing-library/react'
import { TestMarkdownRenderer } from '../../test-utils/test-markdown-renderer'
import * as AbcFrameModule from './abc-frame'
import type { CodeProps } from '../../replace-components/code-block-component-replacer'
import { AbcjsMarkdownExtension } from './abcjs-markdown-extension'
import { mockI18n } from '../../test-utils/mock-i18n'
describe('AbcJs Markdown Extension', () => {
beforeAll(async () => {
jest.spyOn(AbcFrameModule, 'AbcFrame').mockImplementation((({ code }) => {
return (
<span>
this is a mock for abc js frame
<code>{code}</code>
</span>
)
}) as React.FC<CodeProps>)
await mockI18n()
})
afterAll(() => {
jest.resetModules()
jest.restoreAllMocks()
})
it('renders an abc codeblock', () => {
const view = render(
<TestMarkdownRenderer
extensions={[new AbcjsMarkdownExtension()]}
content={
'```abc\nX:1\\nT:Speed the Plough\\nM:4/4\\nC:Trad.\\nK:G\\n|:GABc dedB|dedB dedB|c2ec B2dB|c2A2 A2BA|\\nGABc dedB|dedB dedB|c2ec B2dB|A2F2 G4:|\\n|:g2gf gdBd|g2f2 e2d2|c2ec B2dB|c2A2 A2df|\\ng2gf g2Bd|g2f2 e2d2|c2ec B2dB|A2F2 G4:|\n```'
}
/>
)
expect(view.container).toMatchSnapshot()
})
})

View file

@ -8,10 +8,10 @@ import { MarkdownExtension } from '../markdown-extension'
import { BlockquoteColorExtraTagReplacer } from './blockquote-color-extra-tag-replacer'
import { BlockquoteExtraTagReplacer } from './blockquote-extra-tag-replacer'
import type { ComponentReplacer } from '../../replace-components/component-replacer'
import { BlockquoteExtraTagMarkdownItPlugin } from './blockquote-extra-tag-markdown-it-plugin'
import type MarkdownIt from 'markdown-it'
import type { NodeProcessor } from '../../node-preprocessors/node-processor'
import { BlockquoteBorderColorNodePreprocessor } from './blockquote-border-color-node-preprocessor'
import { BlockquoteExtraTagMarkdownItPlugin } from './blockquote-extra-tag-markdown-it-plugin'
/**
* Adds support for generic blockquote extra tags and blockquote color extra tags.
@ -20,10 +20,9 @@ export class BlockquoteExtraTagMarkdownExtension extends MarkdownExtension {
public static readonly tagName = 'app-blockquote-tag'
public configureMarkdownIt(markdownIt: MarkdownIt): void {
new BlockquoteExtraTagMarkdownItPlugin('color', 'tag').registerInlineRule(markdownIt)
new BlockquoteExtraTagMarkdownItPlugin('name', 'user').registerInlineRule(markdownIt)
new BlockquoteExtraTagMarkdownItPlugin('time', 'clock-o').registerInlineRule(markdownIt)
BlockquoteExtraTagMarkdownItPlugin.registerRenderer(markdownIt)
new BlockquoteExtraTagMarkdownItPlugin('color', 'tag').registerRule(markdownIt)
new BlockquoteExtraTagMarkdownItPlugin('name', 'user').registerRule(markdownIt)
new BlockquoteExtraTagMarkdownItPlugin('time', 'clock-o').registerRule(markdownIt)
}
public buildReplacers(): ComponentReplacer[] {

View file

@ -4,59 +4,49 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { QuoteExtraTagValues } from './parse-blockquote-extra-tag'
import { parseBlockquoteExtraTag } from './parse-blockquote-extra-tag'
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', () => {
const expected: QuoteExtraTagValues = {
labelStartIndex: 1,
labelEndIndex: 4,
valueStartIndex: 5,
valueEndIndex: 8,
label: 'abc',
value: 'def'
}
expect(parseBlockquoteExtraTag('[abc=def]', 0, 1000)).toEqual(expected)
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(parseBlockquoteExtraTag('abc=def]', 0, 1000)).toEqual(undefined)
expect(markdownIt.renderInline('abc=def]')).toEqual('abc=def]')
})
it("shouldn't parse a tag with no closing bracket", () => {
expect(parseBlockquoteExtraTag('[abc=def', 0, 1000)).toEqual(undefined)
expect(markdownIt.renderInline('[abc=def')).toEqual('[abc=def')
})
it("shouldn't parse a tag with no separation character", () => {
expect(parseBlockquoteExtraTag('[abcdef]', 0, 1000)).toEqual(undefined)
expect(markdownIt.renderInline('[abcdef]')).toEqual('[abcdef]')
})
it("shouldn't parse a tag with an empty label", () => {
expect(parseBlockquoteExtraTag('[=def]', 0, 1000)).toEqual(undefined)
expect(markdownIt.renderInline('[=def]')).toEqual('[=def]')
})
it("shouldn't parse a tag with an empty value", () => {
expect(parseBlockquoteExtraTag('[abc=]', 0, 1000)).toEqual(undefined)
expect(markdownIt.renderInline('[abc=]')).toEqual('[abc=]')
})
it("shouldn't parse a tag with an empty body", () => {
expect(parseBlockquoteExtraTag('[]', 0, 1000)).toEqual(undefined)
})
it("shouldn't parse a tag with an empty body", () => {
expect(parseBlockquoteExtraTag('[]', 0, 1000)).toEqual(undefined)
})
it("shouldn't parse a correct tag if start index isn't at the opening bracket", () => {
expect(parseBlockquoteExtraTag('[abc=def]', 1, 1000)).toEqual(undefined)
})
it("shouldn't parse a correct tag if maxPos ends before tag end", () => {
expect(parseBlockquoteExtraTag('[abc=def]', 0, 1)).toEqual(undefined)
})
it("shouldn't parse a correct tag if start index is after maxPos", () => {
expect(parseBlockquoteExtraTag(' [abc=def]', 3, 2)).toEqual(undefined)
expect(markdownIt.renderInline('[]')).toEqual('[]')
})
})

View file

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

@ -1,89 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export interface QuoteExtraTagValues {
labelStartIndex: number
labelEndIndex: number
valueStartIndex: number
valueEndIndex: number
label: string
value: string
}
/**
* Parses a blockquote tag. The syntax is [label=value].
*
* @param line The line in which the tag should be looked for.
* @param startIndex The start index for the search.
* @param dontSearchAfterIndex The maximal position for the search.
*/
export const parseBlockquoteExtraTag = (
line: string,
startIndex: number,
dontSearchAfterIndex: number
): QuoteExtraTagValues | undefined => {
if (line[startIndex] !== '[') {
return
}
const labelStartIndex = startIndex + 1
const labelEndIndex = parseLabel(line, labelStartIndex, dontSearchAfterIndex)
if (!labelEndIndex || labelStartIndex === labelEndIndex) {
return
}
const valueStartIndex = labelEndIndex + 1
const valueEndIndex = parseValue(line, valueStartIndex, dontSearchAfterIndex)
if (!valueEndIndex || valueStartIndex === valueEndIndex) {
return
}
return {
labelStartIndex,
labelEndIndex,
valueStartIndex,
valueEndIndex,
label: line.slice(labelStartIndex, labelEndIndex),
value: line.slice(valueStartIndex, valueEndIndex)
}
}
/**
* Parses the value part of a blockquote tag. That is [notthis=THIS] part. It also detects nested [] blocks.
*
* @param line The line in which the tag is.
* @param startIndex The start index of the tag.
* @param dontSearchAfterIndex The maximal position for the search.
*/
const parseValue = (line: string, startIndex: number, dontSearchAfterIndex: number): number | undefined => {
let level = 0
for (let position = startIndex; position <= dontSearchAfterIndex; position += 1) {
const currentCharacter = line[position]
if (currentCharacter === ']') {
if (level === 0) {
return position
}
level -= 1
} else if (currentCharacter === '[') {
level += 1
}
}
}
/**
* Parses the label part of a blockquote tag. That is [THIS=notthis] part.
*
* @param line The line in which the tag is.
* @param startIndex The start index of the tag.
* @param dontSearchAfterIndex The maximal position for the search.
*/
const parseLabel = (line: string, startIndex: number, dontSearchAfterIndex: number): number | undefined => {
for (let pos = startIndex; pos <= dontSearchAfterIndex; pos += 1) {
if (line[pos] === '=') {
return pos
}
}
}

View file

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

@ -21,7 +21,7 @@ export const parseCsv = (csvText: string, csvColumnDelimiter: string): string[][
}
/**
* Escapes regex characters in the given string so it can be used as literal string in another regex.
* 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
*/

View file

@ -24,13 +24,8 @@ export class CsvReplacer extends ComponentReplacer {
const extraRegex = /\s*(delimiter=([^\s]*))?\s*(header)?/
const extraInfos = extraRegex.exec(extraData)
let delimiter = ','
let showHeader = false
if (extraInfos) {
delimiter = extraInfos[2] || delimiter
showHeader = extraInfos[3] !== undefined
}
const delimiter = extraInfos?.[2] ?? ','
const showHeader = extraInfos?.[3] !== undefined
return <CsvTable code={code} delimiter={delimiter} showHeader={showHeader} />
}

View file

@ -0,0 +1,39 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as CsvTableModule from '../csv/csv-table'
import React from 'react'
import type { CodeProps } from '../../replace-components/code-block-component-replacer'
import { mockI18n } from '../../test-utils/mock-i18n'
import { render } from '@testing-library/react'
import { TestMarkdownRenderer } from '../../test-utils/test-markdown-renderer'
import { CsvTableMarkdownExtension } from './csv-table-markdown-extension'
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,25 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { render } from '@testing-library/react'
import { CsvTable } from './csv-table'
describe('CSV Table', () => {
it('renders correctly with header', () => {
const view = render(<CsvTable code={'a;b;c\nd;e;f'} delimiter={';'} showHeader={true} />)
expect(view.container).toMatchSnapshot()
})
it('renders correctly without header', () => {
const view = render(<CsvTable code={'a;b;c\nd;e;f'} delimiter={';'} showHeader={false} />)
expect(view.container).toMatchSnapshot()
})
it('renders correctly without code', () => {
const view = render(<CsvTable code={''} delimiter={';'} showHeader={false} />)
expect(view.container).toMatchSnapshot()
})
})

View file

@ -25,16 +25,12 @@ export const CsvTable: React.FC<CsvTableProps> = ({
}) => {
const { rowsWithColumns, headerRow } = useMemo(() => {
const rowsWithColumns = parseCsv(code.trim(), delimiter)
let headerRow: string[] = []
if (showHeader) {
headerRow = rowsWithColumns.splice(0, 1)[0]
}
const headerRow = showHeader ? rowsWithColumns.splice(0, 1)[0] : []
return { rowsWithColumns, headerRow }
}, [code, delimiter, showHeader])
const renderTableHeader = useMemo(
() =>
headerRow === [] ? undefined : (
const renderTableHeader = useMemo(() => {
return headerRow.length === 0 ? undefined : (
<thead>
<tr>
{headerRow.map((column, columnNumber) => (
@ -42,9 +38,8 @@ export const CsvTable: React.FC<CsvTableProps> = ({
))}
</tr>
</thead>
),
[headerRow]
)
}, [headerRow])
const renderTableBody = useMemo(
() => (

View file

@ -0,0 +1,33 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Emoji Markdown Extension renders a fork awesome code 1`] = `
<div>
<p>
<i
class="fa fa-circle-thin"
/>
</p>
</div>
`;
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,39 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import { mockI18n } from '../../test-utils/mock-i18n'
import { render } from '@testing-library/react'
import { TestMarkdownRenderer } from '../../test-utils/test-markdown-renderer'
import { EmojiMarkdownExtension } from './emoji-markdown-extension'
describe('Emoji Markdown Extension', () => {
beforeAll(async () => {
await mockI18n()
})
afterAll(() => {
jest.resetModules()
jest.restoreAllMocks()
})
it('renders an emoji code', () => {
const view = render(<TestMarkdownRenderer extensions={[new EmojiMarkdownExtension()]} content={':smile:'} />)
expect(view.container).toMatchSnapshot()
})
it('renders a fork awesome code', () => {
const view = render(
<TestMarkdownRenderer extensions={[new EmojiMarkdownExtension()]} content={':fa-circle-thin:'} />
)
expect(view.container).toMatchSnapshot()
})
it('renders a skin tone code', () => {
const view = render(<TestMarkdownRenderer extensions={[new EmojiMarkdownExtension()]} content={':skin-tone-3:'} />)
expect(view.container).toMatchSnapshot()
})
})

View file

@ -0,0 +1,19 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Flowchart markdown extensions renders a flowchart codeblock 1`] = `
<div>
<pre>
<span>
this is a mock for flowchart frame
<code>
st=&gt;start: Start
e=&gt;end: End
st-&gt;e
</code>
</span>
</pre>
</div>
`;

View file

@ -0,0 +1,38 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Flowchart handles error if lib loading failed 1`] = `
<div>
<div
class="fade alert alert-danger show"
role="alert"
>
common.errorWhileLoading
</div>
</div>
`;
exports[`Flowchart handles error while rendering 1`] = `
<div>
<div
class="fade alert alert-danger show"
role="alert"
>
renderer.flowchart.invalidSyntax
</div>
<div
class="text-center"
data-testid="flowchart"
/>
</div>
`;
exports[`Flowchart renders correctly 1`] = `
<div>
<div
class="text-center"
data-testid="flowchart"
>
Flowchart rendering succeeded!
</div>
</div>
`;

View file

@ -0,0 +1,42 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { render } from '@testing-library/react'
import { TestMarkdownRenderer } from '../../test-utils/test-markdown-renderer'
import React from 'react'
import { mockI18n } from '../../test-utils/mock-i18n'
import { FlowchartMarkdownExtension } from './flowchart-markdown-extension'
import * as Flowchart from '../flowchart/flowchart'
import type { CodeProps } from '../../replace-components/code-block-component-replacer'
describe('Flowchart markdown extensions', () => {
beforeAll(async () => {
jest.spyOn(Flowchart, 'FlowChart').mockImplementation((({ code }) => {
return (
<span>
this is a mock for flowchart frame
<code>{code}</code>
</span>
)
}) as React.FC<CodeProps>)
await mockI18n()
})
afterAll(() => {
jest.resetModules()
jest.restoreAllMocks()
})
it('renders a flowchart codeblock', () => {
const view = render(
<TestMarkdownRenderer
extensions={[new FlowchartMarkdownExtension()]}
content={'```flow\nst=>start: Start\ne=>end: End\nst->e\n```'}
/>
)
expect(view.container).toMatchSnapshot()
})
})

View file

@ -0,0 +1,86 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { mockI18n } from '../../test-utils/mock-i18n'
import { render, screen } from '@testing-library/react'
import { FlowChart } from './flowchart'
import type * as flowchartJsModule from 'flowchart.js'
import { StoreProvider } from '../../../../redux/store-provider'
describe('Flowchart', () => {
const successText = 'Flowchart rendering succeeded!'
const expectedValidFlowchartCode = 'test code'
beforeAll(async () => {
await mockI18n()
})
afterEach(() => {
jest.resetModules()
jest.restoreAllMocks()
})
const mockFlowchartDraw = (): jest.Mock<void, Parameters<flowchartJsModule.Instance['drawSVG']>> => {
const drawSvg = jest.fn((container: HTMLElement | string) => {
if (typeof container === 'string') {
throw new Error('HTMLElement expected')
} else {
container.innerHTML = successText
}
})
jest.mock('flowchart.js', () => ({
parse: jest.fn((code) => {
if (code !== expectedValidFlowchartCode) {
throw new Error('invalid flowchart code')
}
return { drawSVG: drawSvg, clean: jest.fn() }
})
}))
return drawSvg
}
it('renders correctly', async () => {
const successText = 'Flowchart rendering succeeded!'
const validFlowchartCode = 'test code'
const mockDrawSvg = mockFlowchartDraw()
const view = render(
<StoreProvider>
<FlowChart code={validFlowchartCode} />
</StoreProvider>
)
await screen.findByText(successText)
expect(view.container).toMatchSnapshot()
expect(mockDrawSvg).toBeCalled()
})
it('handles error while rendering', async () => {
const mockDrawSvg = mockFlowchartDraw()
const view = render(
<StoreProvider>
<FlowChart code={'Invalid!'} />
</StoreProvider>
)
await screen.findByText('renderer.flowchart.invalidSyntax')
expect(view.container).toMatchSnapshot()
expect(mockDrawSvg).not.toBeCalled()
})
it('handles error if lib loading failed', async () => {
jest.mock('flowchart.js', () => {
throw new Error('flowchart.js import is exploded!')
})
const view = render(
<StoreProvider>
<FlowChart code={'Invalid!'} />
</StoreProvider>
)
await screen.findByText('common.errorWhileLoading')
expect(view.container).toMatchSnapshot()
})
})

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -9,31 +9,41 @@ import { Alert } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import { useIsDarkModeActivated } from '../../../../hooks/common/use-is-dark-mode-activated'
import { Logger } from '../../../../utils/logger'
import { cypressId } from '../../../../utils/cypress-attribute'
import fontStyles from '../../../../../global-styles/variables.module.scss'
import { useAsync } from 'react-use'
import { AsyncLoadingBoundary } from '../../../common/async-loading-boundary'
import { ShowIf } from '../../../common/show-if/show-if'
import type { CodeProps } from '../../replace-components/code-block-component-replacer'
import { testId } from '../../../../utils/test-id'
const log = new Logger('FlowChart')
export interface FlowChartProps {
code: string
}
export const FlowChart: React.FC<FlowChartProps> = ({ code }) => {
export const FlowChart: React.FC<CodeProps> = ({ code }) => {
const diagramRef = useRef<HTMLDivElement>(null)
const [error, setError] = useState(false)
const [syntaxError, setSyntaxError] = useState(false)
const darkModeActivated = useIsDarkModeActivated()
useTranslation()
const {
value: flowchartLib,
loading,
error: libLoadingError
} = useAsync(async () => import(/* webpackChunkName: "flowchart.js" */ 'flowchart.js'), [])
useEffect(() => {
if (diagramRef.current === null) {
if (libLoadingError) {
log.error('Error while loading flowchart.js', libLoadingError)
}
})
useEffect(() => {
if (diagramRef.current === null || flowchartLib === undefined) {
return
}
const currentDiagramRef = diagramRef.current
import(/* webpackChunkName: "flowchart.js" */ 'flowchart.js')
.then((importedLibrary) => {
const parserOutput = importedLibrary.parse(code)
try {
const parserOutput = flowchartLib.parse(code)
parserOutput.drawSVG(currentDiagramRef, {
'line-width': 2,
fill: 'none',
@ -43,25 +53,25 @@ export const FlowChart: React.FC<FlowChartProps> = ({ code }) => {
'font-color': darkModeActivated ? '#ffffff' : '#000000',
'font-family': fontStyles['font-family-base']
})
setError(false)
setSyntaxError(false)
} catch (error) {
setError(true)
log.error('Error while rendering flowchart', error)
setSyntaxError(true)
}
})
.catch((error: Error) => log.error('Error while loading flowchart.js', error))
return () => {
Array.from(currentDiagramRef.children).forEach((value) => value.remove())
}
}, [code, darkModeActivated])
}, [code, darkModeActivated, flowchartLib])
if (error) {
return (
<AsyncLoadingBoundary loading={loading} componentName={'flowchart.js'} error={!!libLoadingError}>
<ShowIf condition={syntaxError}>
<Alert variant={'danger'}>
<Trans i18nKey={'renderer.flowchart.invalidSyntax'} />
</Alert>
</ShowIf>
<div ref={diagramRef} {...testId('flowchart')} className={'text-center'} />
</AsyncLoadingBoundary>
)
} else {
return <div ref={diagramRef} {...cypressId('flowchart')} className={'text-center'} />
}
}

View file

@ -0,0 +1,19 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PlantUML markdown extensions renders a plantuml codeblock 1`] = `
<div>
<pre>
<span>
this is a mock for graphviz frame
<code>
graph {
a -- b
}
</code>
</span>
</pre>
</div>
`;

View file

@ -0,0 +1,42 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { render } from '@testing-library/react'
import { TestMarkdownRenderer } from '../../test-utils/test-markdown-renderer'
import React from 'react'
import { mockI18n } from '../../test-utils/mock-i18n'
import { GraphvizMarkdownExtension } from './graphviz-markdown-extension'
import * as GraphvizFrameModule from '../graphviz/graphviz-frame'
import type { CodeProps } from '../../replace-components/code-block-component-replacer'
describe('PlantUML markdown extensions', () => {
beforeAll(async () => {
jest.spyOn(GraphvizFrameModule, 'GraphvizFrame').mockImplementation((({ code }) => {
return (
<span>
this is a mock for graphviz frame
<code>{code}</code>
</span>
)
}) as React.FC<CodeProps>)
await mockI18n()
})
afterAll(() => {
jest.resetModules()
jest.restoreAllMocks()
})
it('renders a plantuml codeblock', () => {
const view = render(
<TestMarkdownRenderer
extensions={[new GraphvizMarkdownExtension()]}
content={'```graphviz\ngraph {\na -- b\n}\n```'}
/>
)
expect(view.container).toMatchSnapshot()
})
})

View file

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

View file

@ -0,0 +1,133 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Highlighted Code can hide the line numbers 1`] = `
<code
class="hljs wrapLines"
data-testid="code-highlighter"
>
<span
class="linenumber"
>
1
</span>
<div
class="codeline"
>
<span>
const a = 1
</span>
</div>
</code>
`;
exports[`Highlighted Code highlights code 1`] = `
<code
class="hljs showGutter "
data-testid="code-highlighter"
>
<span
class="linenumber"
>
1
</span>
<div
class="codeline"
>
<span
class="hljs-keyword"
>
const
</span>
a =
<span
class="hljs-number"
>
1
</span>
</div>
</code>
`;
exports[`Highlighted Code renders plain text 1`] = `
<code
class="hljs showGutter "
data-testid="code-highlighter"
>
<span
class="linenumber"
>
1
</span>
<div
class="codeline"
>
<span>
a
</span>
</div>
<span
class="linenumber"
>
2
</span>
<div
class="codeline"
>
<span>
b
</span>
</div>
<span
class="linenumber"
>
3
</span>
<div
class="codeline"
>
<span>
c
</span>
</div>
</code>
`;
exports[`Highlighted Code starts with a specific line 1`] = `
<code
class="hljs showGutter wrapLines"
data-testid="code-highlighter"
>
<span
class="linenumber"
>
100
</span>
<div
class="codeline"
>
<span>
const a = 1
</span>
</div>
</code>
`;
exports[`Highlighted Code wraps code 1`] = `
<code
class="hljs showGutter wrapLines"
data-testid="code-highlighter"
>
<span
class="linenumber"
>
1
</span>
<div
class="codeline"
>
<span>
const a = 1
</span>
</div>
</code>
`;

View file

@ -0,0 +1,129 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { render } from '@testing-library/react'
import { TestMarkdownRenderer } from '../../test-utils/test-markdown-renderer'
import React from 'react'
import type { HighlightedCodeProps } from './highlighted-code'
import * as HighlightedCodeModule from './highlighted-code'
import { mockI18n } from '../../test-utils/mock-i18n'
import { HighlightedCodeMarkdownExtension } from './highlighted-code-markdown-extension'
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,52 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { render, screen } from '@testing-library/react'
import HighlightedCode from './highlighted-code'
import { mockI18n } from '../../test-utils/mock-i18n'
describe('Highlighted Code', () => {
beforeAll(() => mockI18n())
it('renders plain text', async () => {
render(<HighlightedCode code={'a\nb\nc'} startLineNumber={1} language={''} wrapLines={false}></HighlightedCode>)
expect(await screen.findByTestId('code-highlighter')).toMatchSnapshot()
})
it('highlights code', async () => {
render(
<HighlightedCode
code={'const a = 1'}
language={'typescript'}
startLineNumber={1}
wrapLines={false}></HighlightedCode>
)
expect(await screen.findByTestId('code-highlighter')).toMatchSnapshot()
})
it('wraps code', async () => {
render(<HighlightedCode code={'const a = 1'} wrapLines={true} startLineNumber={1} language={''}></HighlightedCode>)
expect(await screen.findByTestId('code-highlighter')).toMatchSnapshot()
})
it('starts with a specific line', async () => {
render(
<HighlightedCode code={'const a = 1'} startLineNumber={100} language={''} wrapLines={true}></HighlightedCode>
)
expect(await screen.findByTestId('code-highlighter')).toMatchSnapshot()
})
it('can hide the line numbers', async () => {
render(
<HighlightedCode
code={'const a = 1'}
startLineNumber={undefined}
language={''}
wrapLines={true}></HighlightedCode>
)
expect(await screen.findByTestId('code-highlighter')).toMatchSnapshot()
})
})

View file

@ -11,6 +11,7 @@ import { cypressAttribute, cypressId } from '../../../../utils/cypress-attribute
import { AsyncLoadingBoundary } from '../../../common/async-loading-boundary'
import { useAsyncHighlightedCodeDom } from './hooks/use-async-highlighted-code-dom'
import { useAttachLineNumbers } from './hooks/use-attach-line-numbers'
import { testId } from '../../../../utils/test-id'
export interface HighlightedCodeProps {
code: string
@ -36,6 +37,7 @@ export const HighlightedCode: React.FC<HighlightedCodeProps> = ({ code, language
<AsyncLoadingBoundary loading={loading} error={!!error} componentName={'highlight.js'}>
<div className={styles['code-highlighter']} {...cypressId('highlighted-code-block')}>
<code
{...testId('code-highlighter')}
{...cypressId('code-highlighter')}
{...cypressAttribute('showgutter', showGutter ? 'true' : 'false')}
{...cypressAttribute('wraplines', wrapLines ? 'true' : 'false')}

View file

@ -0,0 +1,31 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Legacy shortcodes markdown extension renders correctly 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>
<br />
<a
href="https://www.slideshare.net/example/123456789"
>
https://www.slideshare.net/example/123456789
</a>
<br />
<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 * as replaceLegacyPdfShortCodeModule from './replace-legacy-pdf-short-code'
import * as replaceLegacySlideshareShortCodeModule from './replace-legacy-slideshare-short-code'
import * as replaceLegacySpeakerdeckShortCodeModule from './replace-legacy-speakerdeck-short-code'
import { render } from '@testing-library/react'
import { TestMarkdownRenderer } from '../../test-utils/test-markdown-renderer'
import React from 'react'
import { LegacyShortcodesMarkdownExtension } from './legacy-shortcodes-markdown-extension'
import MarkdownIt from 'markdown-it'
describe('Legacy shortcodes markdown extension', () => {
const replaceLegacyPdfShortCodeMarkdownItExtensionMock = jest.spyOn(
replaceLegacyPdfShortCodeModule,
'legacyPdfShortCode'
)
const replaceLegacySlideshareShortCodeMarkdownItExtensionMock = jest.spyOn(
replaceLegacySlideshareShortCodeModule,
'legacySlideshareShortCode'
)
const replaceLegacySpeakerdeckShortCodeMarkdownItExtensionMock = jest.spyOn(
replaceLegacySpeakerdeckShortCodeModule,
'legacySpeakerdeckShortCode'
)
it('renders correctly', () => {
const view = render(
<TestMarkdownRenderer
extensions={[new LegacyShortcodesMarkdownExtension()]}
content={
'{%pdf https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf %}\n{%slideshare example/123456789 %}\n{%speakerdeck example/123456789 %}'
}
/>
)
expect(replaceLegacyPdfShortCodeMarkdownItExtensionMock).toHaveBeenCalledWith(expect.any(MarkdownIt))
expect(replaceLegacySlideshareShortCodeMarkdownItExtensionMock).toHaveBeenCalledWith(expect.any(MarkdownIt))
expect(replaceLegacySpeakerdeckShortCodeMarkdownItExtensionMock).toHaveBeenCalledWith(expect.any(MarkdownIt))
expect(view.container).toMatchSnapshot()
})
})

View file

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

View file

@ -6,13 +6,14 @@
import markdownItRegex from 'markdown-it-regex'
import type MarkdownIt from 'markdown-it/lib'
import type { RegexOptions } from '../../../../external-types/markdown-it-regex/interface'
const finalRegex = /^{%pdf (.*) ?%}$/
const finalRegex = /^{%pdf (\S*) *%}$/
export const legacyPdfShortCode: MarkdownIt.PluginSimple = (markdownIt) => {
markdownItRegex(markdownIt, {
name: 'legacy-pdf-short-code',
regex: finalRegex,
replace: (match: string) => `<a href="${match}">${match}</a>`
})
replace: (match) => `<a href="${match}">${match}</a>`
} as RegexOptions)
}

View file

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

View file

@ -6,6 +6,7 @@
import markdownItRegex from 'markdown-it-regex'
import type MarkdownIt from 'markdown-it/lib'
import type { RegexOptions } from '../../../../external-types/markdown-it-regex/interface'
const finalRegex = /^{%slideshare (\w+\/[\w-]+) ?%}$/
@ -13,6 +14,6 @@ export const legacySlideshareShortCode: MarkdownIt.PluginSimple = (markdownIt) =
markdownItRegex(markdownIt, {
name: 'legacy-slideshare-short-code',
regex: finalRegex,
replace: (match: string) => `<a href='https://www.slideshare.net/${match}'>https://www.slideshare.net/${match}</a>`
})
replace: (match) => `<a href='https://www.slideshare.net/${match}'>https://www.slideshare.net/${match}</a>`
} as RegexOptions)
}

View file

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

View file

@ -6,6 +6,7 @@
import markdownItRegex from 'markdown-it-regex'
import type MarkdownIt from 'markdown-it/lib'
import type { RegexOptions } from '../../../../external-types/markdown-it-regex/interface'
const finalRegex = /^{%speakerdeck (\w+\/[\w-]+) ?%}$/
@ -13,6 +14,6 @@ export const legacySpeakerdeckShortCode: MarkdownIt.PluginSimple = (markdownIt)
markdownItRegex(markdownIt, {
name: 'legacy-speakerdeck-short-code',
regex: finalRegex,
replace: (match: string) => `<a href="https://speakerdeck.com/${match}">https://speakerdeck.com/${match}</a>`
})
replace: (match) => `<a href="https://speakerdeck.com/${match}">https://speakerdeck.com/${match}</a>`
} as RegexOptions)
}

View file

@ -0,0 +1,22 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PlantUML markdown extensions renders a plantuml codeblock 1`] = `
<div>
<img
alt="uml diagram"
src="http://example.org/svg/SoWkIImgAStDuKhEIImkLd2jICmjo4dbSaZDIm6A0W00"
/>
</div>
`;
exports[`PlantUML markdown extensions renders an error if no server is defined 1`] = `
<div>
<p
class="alert alert-danger"
>
renderer.plantuml.notConfigured
</p>
</div>
`;

View file

@ -0,0 +1,37 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { render } from '@testing-library/react'
import { TestMarkdownRenderer } from '../../test-utils/test-markdown-renderer'
import React from 'react'
import { PlantumlMarkdownExtension } from './plantuml-markdown-extension'
import { mockI18n } from '../../test-utils/mock-i18n'
describe('PlantUML markdown extensions', () => {
beforeAll(async () => {
await mockI18n()
})
it('renders a plantuml codeblock', () => {
const view = render(
<TestMarkdownRenderer
extensions={[new PlantumlMarkdownExtension('http://example.org')]}
content={'```plantuml\nclass Example\n```'}
/>
)
expect(view.container).toMatchSnapshot()
})
it('renders an error if no server is defined', () => {
const view = render(
<TestMarkdownRenderer
extensions={[new PlantumlMarkdownExtension(null)]}
content={'```plantuml\nclass Example\n```'}
/>
)
expect(view.container).toMatchSnapshot()
})
})

View file

@ -0,0 +1,17 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Vega-Lite markdown extensions renders a vega-lite codeblock 1`] = `
<div>
<pre>
<span>
this is a mock for vega lite
<code>
{"$schema":"https://vega.github.io/schema/vega-lite/v4.json","data":{"values":[{"a":"","b":28}]},"mark":"bar","encoding":{"x":{"field":"a"},"y":{"field":"b"}}}
</code>
</span>
</pre>
</div>
`;

View file

@ -4,43 +4,35 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react'
import React, { useEffect, useRef } from 'react'
import { Alert } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { Trans, useTranslation } from 'react-i18next'
import type { VisualizationSpec } from 'vega-embed'
import { ShowIf } from '../../../common/show-if/show-if'
import { Logger } from '../../../../utils/logger'
import type { CodeProps } from '../../replace-components/code-block-component-replacer'
import { useAsync } from 'react-use'
import { AsyncLoadingBoundary } from '../../../common/async-loading-boundary'
const log = new Logger('VegaChart')
export const VegaLiteChart: React.FC<CodeProps> = ({ code }) => {
const diagramContainer = useRef<HTMLDivElement>(null)
const [error, setError] = useState<string>()
const { t } = useTranslation()
const showError = useCallback((error: string) => {
if (!diagramContainer.current) {
return
}
log.error(error)
setError(error)
}, [])
const {
value: vegaEmbed,
error: libLoadingError,
loading: libLoading
} = useAsync(async () => (await import(/* webpackChunkName: "vega" */ 'vega-embed')).default, [])
useEffect(() => {
if (!diagramContainer.current) {
const { error: renderingError } = useAsync(async () => {
const container = diagramContainer.current
if (!container || !vegaEmbed) {
return
}
import(/* webpackChunkName: "vega" */ 'vega-embed')
.then((embed) => {
try {
if (!diagramContainer.current) {
return
}
const spec = JSON.parse(code) as VisualizationSpec
embed
.default(diagramContainer.current, spec, {
await vegaEmbed(container, spec, {
actions: {
export: true,
source: false,
@ -52,25 +44,24 @@ export const VegaLiteChart: React.FC<CodeProps> = ({ code }) => {
SVG_ACTION: t('renderer.vega-lite.svg')
}
})
.then(() => setError(undefined))
.catch((error: Error) => showError(error.message))
} catch (error) {
showError(t('renderer.vega-lite.errorJson'))
}, [code, vegaEmbed])
useEffect(() => {
if (renderingError) {
log.error('Error while rendering vega lite diagram', renderingError)
}
})
.catch((error: Error) => {
log.error('Error while loading vega-light', error)
})
}, [code, showError, t])
}, [renderingError])
return (
<Fragment>
<ShowIf condition={!!error}>
<Alert variant={'danger'}>{error}</Alert>
<AsyncLoadingBoundary loading={libLoading} error={libLoadingError} componentName={'Vega Lite'}>
<ShowIf condition={!!renderingError}>
<Alert variant={'danger'}>
<Trans i18nKey={'renderer.vega-lite.errorJson'} />
</Alert>
</ShowIf>
<div className={'text-center'}>
<div ref={diagramContainer} />
</div>
</Fragment>
</AsyncLoadingBoundary>
)
}

View file

@ -0,0 +1,39 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { render } from '@testing-library/react'
import { TestMarkdownRenderer } from '../../test-utils/test-markdown-renderer'
import React from 'react'
import { mockI18n } from '../../test-utils/mock-i18n'
import { VegaLiteMarkdownExtension } from './vega-lite-markdown-extension'
import * as VegaLiteChartModule from '../vega-lite/vega-lite-chart'
import type { CodeProps } from '../../replace-components/code-block-component-replacer'
describe('Vega-Lite markdown extensions', () => {
beforeAll(async () => {
jest.spyOn(VegaLiteChartModule, 'VegaLiteChart').mockImplementation((({ code }) => {
return (
<span>
this is a mock for vega lite
<code>{code}</code>
</span>
)
}) as React.FC<CodeProps>)
await mockI18n()
})
it('renders a vega-lite codeblock', () => {
const view = render(
<TestMarkdownRenderer
extensions={[new VegaLiteMarkdownExtension()]}
content={
'```vega-lite\n{"$schema":"https://vega.github.io/schema/vega-lite/v4.json","data":{"values":[{"a":"","b":28}]},"mark":"bar","encoding":{"x":{"field":"a"},"y":{"field":"b"}}}\n```'
}
/>
)
expect(view.container).toMatchSnapshot()
})
})

View file

@ -0,0 +1,19 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`VimeoFrame renders a click shield 1`] = `
<div>
<span>
This is a click shield for
<span
class="embed-responsive embed-responsive-16by9"
>
<iframe
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
class="embed-responsive-item"
src="https://player.vimeo.com/video/valid vimeo id?autoplay=1"
title="vimeo video of valid vimeo id"
/>
</span>
</span>
</div>
`;

View file

@ -0,0 +1,46 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import MarkdownIt from 'markdown-it'
import { replaceLegacyVimeoShortCodeMarkdownItPlugin } from './replace-legacy-vimeo-short-code'
describe('Replace legacy youtube short codes', () => {
let markdownIt: MarkdownIt
beforeEach(() => {
markdownIt = new MarkdownIt('default', {
html: false,
breaks: true,
langPrefix: '',
typographer: true
})
markdownIt.use(replaceLegacyVimeoShortCodeMarkdownItPlugin)
})
it('detects a valid legacy youtube short code', () => {
expect(markdownIt.renderInline('{%vimeo 12312312312 %}')).toBe('<app-vimeo id="12312312312"></app-vimeo>')
})
it("won't detect an empty string", () => {
const code = '{%vimeo %}'
expect(markdownIt.renderInline(code)).toBe(code)
})
it("won't detect letters", () => {
const code = '{%vimeo 123123a2311 %}'
expect(markdownIt.renderInline(code)).toBe(code)
})
it("won't detect an invalid(to short) youtube id", () => {
const code = '{%vimeo 1 %}'
expect(markdownIt.renderInline(code)).toBe(code)
})
it("won't detect an invalid(to long) youtube id", () => {
const code = '{%vimeo 111111111111111111111111111111111 %}'
expect(markdownIt.renderInline(code)).toBe(code)
})
})

View file

@ -6,8 +6,10 @@
import type { RegexOptions } from '../../../../external-types/markdown-it-regex/interface'
import { VimeoMarkdownExtension } from './vimeo-markdown-extension'
import type MarkdownIt from 'markdown-it'
import markdownItRegex from 'markdown-it-regex'
export const replaceLegacyVimeoShortCode: RegexOptions = {
const replaceLegacyVimeoShortCode: RegexOptions = {
name: 'legacy-vimeo-short-code',
regex: /^{%vimeo ([\d]{6,11}) ?%}$/,
replace: (match) => {
@ -16,3 +18,6 @@ export const replaceLegacyVimeoShortCode: RegexOptions = {
return `<${VimeoMarkdownExtension.tagName} id="${match}"></${VimeoMarkdownExtension.tagName}>`
}
}
export const replaceLegacyVimeoShortCodeMarkdownItPlugin: MarkdownIt.PluginSimple = (markdownIt: MarkdownIt) =>
markdownItRegex(markdownIt, replaceLegacyVimeoShortCode)

View file

@ -0,0 +1,39 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import MarkdownIt from 'markdown-it'
import { replaceVimeoLinkMarkdownItPlugin } from './replace-vimeo-link'
describe('Replace youtube link', () => {
let markdownIt: MarkdownIt
beforeEach(() => {
markdownIt = new MarkdownIt('default', {
html: false,
breaks: true,
langPrefix: '',
typographer: true
})
markdownIt.use(replaceVimeoLinkMarkdownItPlugin)
})
;['http://', 'https://', ''].forEach((protocol) => {
;['player.', ''].forEach((subdomain) => {
;['vimeo.com'].forEach((domain) => {
const origin = `${protocol}${subdomain}${domain}/`
describe(origin, () => {
const validUrl = `${origin}23237102`
it(`can detect a correct vimeo video url`, () => {
expect(markdownIt.renderInline(validUrl)).toBe("<app-vimeo id='23237102'></app-vimeo>")
})
it("won't detect an URL without video id", () => {
expect(markdownIt.renderInline(origin)).toBe(origin)
})
})
})
})
})
})

View file

@ -6,6 +6,8 @@
import type { RegexOptions } from '../../../../external-types/markdown-it-regex/interface'
import { VimeoMarkdownExtension } from './vimeo-markdown-extension'
import type MarkdownIt from 'markdown-it'
import markdownItRegex from 'markdown-it-regex'
const protocolRegex = /(?:http(?:s)?:\/\/)?/
const domainRegex = /(?:player\.)?(?:vimeo\.com\/)(?:(?:channels|album|ondemand|groups)\/\w+\/)?(?:video\/)?/
@ -16,7 +18,7 @@ const vimeoVideoUrlRegex = new RegExp(
)
const linkRegex = new RegExp(`^${vimeoVideoUrlRegex.source}$`, 'i')
export const replaceVimeoLink: RegexOptions = {
const replaceVimeoLink: RegexOptions = {
name: 'vimeo-link',
regex: linkRegex,
replace: (match) => {
@ -25,3 +27,6 @@ export const replaceVimeoLink: RegexOptions = {
return `<${VimeoMarkdownExtension.tagName} id='${match}'></${VimeoMarkdownExtension.tagName}>`
}
}
export const replaceVimeoLinkMarkdownItPlugin: MarkdownIt.PluginSimple = (markdownIt: MarkdownIt) =>
markdownItRegex(markdownIt, replaceVimeoLink)

View file

@ -0,0 +1,23 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { render } from '@testing-library/react'
import { VimeoFrame } from './vimeo-frame'
import React from 'react'
import type { ClickShieldProps } from '../../replace-components/click-shield/click-shield'
import * as ClickShieldModule from '../../replace-components/click-shield/click-shield'
describe('VimeoFrame', () => {
beforeEach(() => {
jest.spyOn(ClickShieldModule, 'ClickShield').mockImplementation((({ children }) => {
return <span>This is a click shield for {children}</span>
}) as React.FC<ClickShieldProps>)
})
it('renders a click shield', () => {
const view = render(<VimeoFrame id={'valid vimeo id'} />)
expect(view.container).toMatchSnapshot()
})
})

View file

@ -5,13 +5,12 @@
*/
import { MarkdownExtension } from '../markdown-extension'
import markdownItRegex from 'markdown-it-regex'
import type MarkdownIt from 'markdown-it'
import type { ComponentReplacer } from '../../replace-components/component-replacer'
import { CustomTagWithIdComponentReplacer } from '../../replace-components/custom-tag-with-id-component-replacer'
import { replaceVimeoLink } from './replace-vimeo-link'
import { replaceVimeoLinkMarkdownItPlugin } from './replace-vimeo-link'
import { VimeoFrame } from './vimeo-frame'
import { replaceLegacyVimeoShortCode } from './replace-legacy-vimeo-short-code'
import { replaceLegacyVimeoShortCodeMarkdownItPlugin } from './replace-legacy-vimeo-short-code'
/**
* Adds vimeo video embeddings using link detection and the legacy vimeo short code syntax.
@ -20,8 +19,8 @@ export class VimeoMarkdownExtension extends MarkdownExtension {
public static readonly tagName = 'app-vimeo'
public configureMarkdownIt(markdownIt: MarkdownIt): void {
markdownItRegex(markdownIt, replaceVimeoLink)
markdownItRegex(markdownIt, replaceLegacyVimeoShortCode)
replaceLegacyVimeoShortCodeMarkdownItPlugin(markdownIt)
replaceVimeoLinkMarkdownItPlugin(markdownIt)
}
public buildReplacers(): ComponentReplacer[] {

View file

@ -0,0 +1,19 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`YoutubeFrame renders a click shield 1`] = `
<div>
<span>
This is a click shield for
<span
class="embed-responsive embed-responsive-16by9"
>
<iframe
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
class="embed-responsive-item"
src="https://www.youtube-nocookie.com/embed/valid youtube id?autoplay=1"
title="youtube video of valid youtube id"
/>
</span>
</span>
</div>
`;

View file

@ -0,0 +1,37 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`youtube markdown extension doesn't render invalid youtube ids in short code syntax 1`] = `
<div>
<p>
{%youtube a %}
</p>
</div>
`;
exports[`youtube markdown extension renders legacy youtube syntax 1`] = `
<div>
<p>
<span>
this is a mock for the youtube frame with id
XDnhKh5V5XQ
</span>
</p>
</div>
`;
exports[`youtube markdown extension renders plain youtube URLs 1`] = `
<div>
<p>
<span>
this is a mock for the youtube frame with id
XDnhKh5V5XQ
</span>
</p>
</div>
`;

View file

@ -0,0 +1,41 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import MarkdownIt from 'markdown-it'
import { replaceLegacyYoutubeShortCodeMarkdownItPlugin } from './replace-legacy-youtube-short-code'
describe('Replace legacy youtube short codes', () => {
let markdownIt: MarkdownIt
beforeEach(() => {
markdownIt = new MarkdownIt('default', {
html: false,
breaks: true,
langPrefix: '',
typographer: true
})
markdownIt.use(replaceLegacyYoutubeShortCodeMarkdownItPlugin)
})
it('detects a valid legacy youtube short code', () => {
expect(markdownIt.renderInline('{%youtube 12312312312 %}')).toBe('<app-youtube id="12312312312"></app-youtube>')
})
it("won't detect an empty string", () => {
const code = '{%youtube %}'
expect(markdownIt.renderInline(code)).toBe(code)
})
it("won't detect an invalid(to short) youtube id", () => {
const code = '{%youtube 1 %}'
expect(markdownIt.renderInline(code)).toBe(code)
})
it("won't detect an invalid(to long) youtube id", () => {
const code = '{%youtube 111111111111111111111111111111111 %}'
expect(markdownIt.renderInline(code)).toBe(code)
})
})

View file

@ -6,8 +6,11 @@
import type { RegexOptions } from '../../../../external-types/markdown-it-regex/interface'
import { YoutubeMarkdownExtension } from './youtube-markdown-extension'
import markdownItRegex from 'markdown-it-regex'
import type MarkdownIt from 'markdown-it'
export const replaceLegacyYoutubeShortCode: RegexOptions = {
export const replaceLegacyYoutubeShortCodeMarkdownItPlugin: MarkdownIt.PluginSimple = (markdownIt: MarkdownIt): void =>
markdownItRegex(markdownIt, {
name: 'legacy-youtube-short-code',
regex: /^{%youtube ([^"&?\\/\s]{11}) ?%}$/,
replace: (match) => {
@ -15,4 +18,4 @@ export const replaceLegacyYoutubeShortCode: RegexOptions = {
// noinspection CheckTagEmptyBody
return `<${YoutubeMarkdownExtension.tagName} id="${match}"></${YoutubeMarkdownExtension.tagName}>`
}
}
} as RegexOptions)

View file

@ -0,0 +1,39 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { replaceYouTubeLinkMarkdownItPlugin } from './replace-youtube-link'
import MarkdownIt from 'markdown-it'
describe('Replace youtube link', () => {
let markdownIt: MarkdownIt
beforeEach(() => {
markdownIt = new MarkdownIt('default', {
html: false,
breaks: true,
langPrefix: '',
typographer: true
})
markdownIt.use(replaceYouTubeLinkMarkdownItPlugin)
})
;['http://', 'https://', ''].forEach((protocol) => {
;['www.', ''].forEach((subdomain) => {
;['youtube.com', 'youtube-nocookie.com'].forEach((domain) => {
const origin = `${protocol}${subdomain}${domain}/`
describe(origin, () => {
const validUrl = `${origin}?v=12312312312`
it(`can detect a correct youtube video url`, () => {
expect(markdownIt.renderInline(validUrl)).toBe('<app-youtube id="12312312312"></app-youtube>')
})
it("won't detect an URL without video id", () => {
expect(markdownIt.renderInline(origin)).toBe(origin)
})
})
})
})
})
})

View file

@ -6,6 +6,8 @@
import type { RegexOptions } from '../../../../external-types/markdown-it-regex/interface'
import { YoutubeMarkdownExtension } from './youtube-markdown-extension'
import markdownItRegex from 'markdown-it-regex'
import type MarkdownIt from 'markdown-it'
const protocolRegex = /(?:http(?:s)?:\/\/)?/
const subdomainRegex = /(?:www.)?/
@ -17,7 +19,8 @@ const youtubeVideoUrlRegex = new RegExp(
)
const linkRegex = new RegExp(`^${youtubeVideoUrlRegex.source}$`, 'i')
export const replaceYouTubeLink: RegexOptions = {
export const replaceYouTubeLinkMarkdownItPlugin: MarkdownIt.PluginSimple = (markdownIt: MarkdownIt) =>
markdownItRegex(markdownIt, {
name: 'youtube-link',
regex: linkRegex,
replace: (match) => {
@ -25,4 +28,4 @@ export const replaceYouTubeLink: RegexOptions = {
// noinspection CheckTagEmptyBody
return `<${YoutubeMarkdownExtension.tagName} id="${match}"></${YoutubeMarkdownExtension.tagName}>`
}
}
} as RegexOptions)

View file

@ -0,0 +1,23 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { render } from '@testing-library/react'
import { YouTubeFrame } from './youtube-frame'
import React from 'react'
import type { ClickShieldProps } from '../../replace-components/click-shield/click-shield'
import * as ClickShieldModule from '../../replace-components/click-shield/click-shield'
describe('YoutubeFrame', () => {
beforeEach(() => {
jest.spyOn(ClickShieldModule, 'ClickShield').mockImplementation((({ children }) => {
return <span>This is a click shield for {children}</span>
}) as React.FC<ClickShieldProps>)
})
it('renders a click shield', () => {
const view = render(<YouTubeFrame id={'valid youtube id'} />)
expect(view.container).toMatchSnapshot()
})
})

View file

@ -0,0 +1,71 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { YoutubeMarkdownExtension } from './youtube-markdown-extension'
import { render } from '@testing-library/react'
import { TestMarkdownRenderer } from '../../test-utils/test-markdown-renderer'
import * as YouTubeFrameModule from './youtube-frame'
import React from 'react'
import type { IdProps } from '../../replace-components/custom-tag-with-id-component-replacer'
import { mockI18n } from '../../test-utils/mock-i18n'
import MarkdownIt from 'markdown-it'
import * as replaceLegacyYoutubeShortCodeMarkdownItPluginModule from './replace-legacy-youtube-short-code'
import * as replaceYouTubeLinkMarkdownItPluginModule from './replace-youtube-link'
describe('youtube markdown extension', () => {
const replaceYouTubeLinkMarkdownItPluginSpy = jest.spyOn(
replaceYouTubeLinkMarkdownItPluginModule,
'replaceYouTubeLinkMarkdownItPlugin'
)
const replaceLegacyYoutubeShortCodeMarkdownItPluginSpy = jest.spyOn(
replaceLegacyYoutubeShortCodeMarkdownItPluginModule,
'replaceLegacyYoutubeShortCodeMarkdownItPlugin'
)
beforeAll(async () => {
jest
.spyOn(YouTubeFrameModule, 'YouTubeFrame')
.mockImplementation((({ id }) => (
<span>this is a mock for the youtube frame with id {id}</span>
)) as React.FC<IdProps>)
await mockI18n()
})
afterAll(() => {
jest.resetAllMocks()
jest.resetModules()
})
it('renders plain youtube URLs', () => {
const view = render(
<TestMarkdownRenderer
extensions={[new YoutubeMarkdownExtension()]}
content={'https://www.youtube.com/watch?v=XDnhKh5V5XQ'}
/>
)
expect(replaceYouTubeLinkMarkdownItPluginSpy).toHaveBeenCalledWith(expect.any(MarkdownIt))
expect(replaceLegacyYoutubeShortCodeMarkdownItPluginSpy).toHaveBeenCalledWith(expect.any(MarkdownIt))
expect(view.container).toMatchSnapshot()
})
it('renders legacy youtube syntax', () => {
const view = render(
<TestMarkdownRenderer extensions={[new YoutubeMarkdownExtension()]} content={'{%youtube XDnhKh5V5XQ %}'} />
)
expect(replaceYouTubeLinkMarkdownItPluginSpy).toHaveBeenCalledWith(expect.any(MarkdownIt))
expect(replaceLegacyYoutubeShortCodeMarkdownItPluginSpy).toHaveBeenCalledWith(expect.any(MarkdownIt))
expect(view.container).toMatchSnapshot()
})
it("doesn't render invalid youtube ids in short code syntax", () => {
const view = render(
<TestMarkdownRenderer extensions={[new YoutubeMarkdownExtension()]} content={'{%youtube a %}'} />
)
expect(replaceYouTubeLinkMarkdownItPluginSpy).toHaveBeenCalledWith(expect.any(MarkdownIt))
expect(replaceLegacyYoutubeShortCodeMarkdownItPluginSpy).toHaveBeenCalledWith(expect.any(MarkdownIt))
expect(view.container).toMatchSnapshot()
})
})

View file

@ -5,9 +5,8 @@
*/
import { MarkdownExtension } from '../markdown-extension'
import markdownItRegex from 'markdown-it-regex'
import { replaceYouTubeLink } from './replace-youtube-link'
import { replaceLegacyYoutubeShortCode } from './replace-legacy-youtube-short-code'
import { replaceYouTubeLinkMarkdownItPlugin } from './replace-youtube-link'
import { replaceLegacyYoutubeShortCodeMarkdownItPlugin } from './replace-legacy-youtube-short-code'
import type MarkdownIt from 'markdown-it'
import type { ComponentReplacer } from '../../replace-components/component-replacer'
import { CustomTagWithIdComponentReplacer } from '../../replace-components/custom-tag-with-id-component-replacer'
@ -20,8 +19,8 @@ export class YoutubeMarkdownExtension extends MarkdownExtension {
public static readonly tagName = 'app-youtube'
public configureMarkdownIt(markdownIt: MarkdownIt): void {
markdownItRegex(markdownIt, replaceYouTubeLink)
markdownItRegex(markdownIt, replaceLegacyYoutubeShortCode)
replaceYouTubeLinkMarkdownItPlugin(markdownIt)
replaceLegacyYoutubeShortCodeMarkdownItPlugin(markdownIt)
}
public buildReplacers(): ComponentReplacer[] {

View file

@ -19,7 +19,7 @@ import { ProxyImageFrame } from '../../markdown-extension/image/proxy-image-fram
const log = new Logger('OneClickEmbedding')
interface ClickShieldProps extends PropsWithDataCypressId {
export interface ClickShieldProps extends PropsWithChildren<PropsWithDataCypressId> {
onImageFetch?: () => Promise<string>
fallbackPreviewImageUrl?: string
hoverIcon: IconName
@ -39,7 +39,7 @@ interface ClickShieldProps extends PropsWithDataCypressId {
* @param fallbackBackgroundColor A color that should be used if no background image was provided or could be loaded.
* @param children The children element that should be shielded.
*/
export const ClickShield: React.FC<PropsWithChildren<ClickShieldProps>> = ({
export const ClickShield: React.FC<ClickShieldProps> = ({
containerClassName,
onImageFetch,
fallbackPreviewImageUrl,

View file

@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useMemo } from 'react'
import { useConvertMarkdownToReactDom } from '../hooks/use-convert-markdown-to-react-dom'
import type { MarkdownExtension } from '../markdown-extension/markdown-extension'
import { StoreProvider } from '../../../redux/store-provider'
export interface SimpleMarkdownRendererProps {
content: string
extensions: MarkdownExtension[]
}
export const TestMarkdownRenderer: React.FC<SimpleMarkdownRendererProps> = ({ content, extensions }) => {
const lines = useMemo(() => content.split('\n'), [content])
const dom = useConvertMarkdownToReactDom(lines, extensions, true, false)
return <StoreProvider>{dom}</StoreProvider>
}

View file

@ -4,14 +4,13 @@
SPDX-License-Identifier: AGPL-3.0-only
*/
import type { AppProps } from 'next/app'
import { store } from '../redux'
import { Provider } from 'react-redux'
import { ErrorBoundary } from '../components/error-boundary/error-boundary'
import { ApplicationLoader } from '../components/application-loader/application-loader'
import '../../global-styles/dark.scss'
import '../../global-styles/index.scss'
import type { NextPage } from 'next'
import { BaseHead } from '../components/layout/base-head'
import { StoreProvider } from '../redux/store-provider'
/**
* The actual hedgedoc next js app.
@ -19,14 +18,14 @@ import { BaseHead } from '../components/layout/base-head'
*/
const HedgeDocApp: NextPage<AppProps> = ({ Component, pageProps }: AppProps) => {
return (
<Provider store={store}>
<StoreProvider>
<BaseHead />
<ApplicationLoader>
<ErrorBoundary>
<Component {...pageProps} />
</ErrorBoundary>
</ApplicationLoader>
</Provider>
</StoreProvider>
)
}

View file

@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { PropsWithChildren } from 'react'
import React from 'react'
import { Provider } from 'react-redux'
import { store } from './index'
/**
* Sets the redux store for the children components.
*
* @param children The child components that should access the redux store
*/
export const StoreProvider: React.FC<PropsWithChildren<unknown>> = ({ children }) => {
return <Provider store={store}>{children}</Provider>
}