feat(frontend): Add Asciinema replacer

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2023-02-04 14:26:28 +01:00
parent 9ccfaf3d0e
commit 5a2a3a4964
11 changed files with 253 additions and 0 deletions

View file

@ -53,6 +53,7 @@ SPDX-License-Identifier: CC-BY-SA-4.0
- Notes may now be deleted directly from the history page
- HedgeDoc instances can be branded either with a '@ \<custom string\>' or '@ \<custom logo\>' after the HedgeDoc logo and text
- Images will be loaded via proxy if an image proxy is configured in the backend
- Asciinema videos may be embedded by pasting the URL of one video into a single line
- The toolbar includes an emoji and fork-awesome icon picker.
- Collapsible blocks can be added via a toolbar button or via autocompletion of "<details"
- Added shortcodes for [fork-awesome icons](https://forkaweso.me/Fork-Awesome/icons/) (e.g. `:fa-picture-o:`)

View file

@ -0,0 +1,19 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Asciinema renders a click shield 1`] = `
<div>
<span>
This is a click shield for
<span
class="ratio ratio-16x9"
>
<iframe
allowfullscreen=""
class=""
src="https://asciinema.org/a/validAsciinemaId/iframe?autoplay=1"
title="asciinema cast validAsciinemaId"
/>
</span>
</span>
</div>
`;

View file

@ -0,0 +1,14 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`asciinema markdown extension renders plain asciinema URLs 1`] = `
<div>
<p>
<span>
this is a mock for the asciinema frame with id
190123709
</span>
</p>
</div>
`;

View file

@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { MarkdownRendererExtension } from '../../../components/markdown-renderer/extensions/base/markdown-renderer-extension'
import { AppExtension } from '../../base/app-extension'
import { AsciinemaMarkdownExtension } from './asciinema-markdown-extension'
/**
* Adds support for Asciinema embeddings to the markdown rendering.
*
* @see https://asciinema.org
*/
export class AsciinemaAppExtension extends AppExtension {
buildMarkdownRendererExtensions(): MarkdownRendererExtension[] {
return [new AsciinemaMarkdownExtension()]
}
}

View file

@ -0,0 +1,25 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as ClickShieldModule from '../../../components/markdown-renderer/replace-components/click-shield/click-shield'
import type { ClickShieldProps } from '../../../components/markdown-renderer/replace-components/click-shield/click-shield'
import { AsciinemaFrame } from './asciinema-frame'
import { render } from '@testing-library/react'
import React from 'react'
jest.mock('../../../components/markdown-renderer/replace-components/click-shield/click-shield')
describe('Asciinema', () => {
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(<AsciinemaFrame id={'validAsciinemaId'} />)
expect(view.container).toMatchSnapshot()
})
})

View file

@ -0,0 +1,34 @@
/*
* SPDX-FileCopyrightText: 2023 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 { IdProps } from '../../../components/markdown-renderer/replace-components/custom-tag-with-id-component-replacer'
import React from 'react'
/**
* Renders an embedding for https://asciinema.org
*
* @param id The id from the asciinema url
*/
export const AsciinemaFrame: React.FC<IdProps> = ({ id }) => {
return (
<ClickShield
hoverIcon={'play'}
targetDescription={'asciinema'}
fallbackPreviewImageUrl={`https://asciinema.org/a/${id}.png`}
fallbackBackgroundColor={'#d40000'}
containerClassName={''}
data-cypress-id={'click-shield-asciinema'}>
<span className={'ratio ratio-16x9'}>
<iframe
allowFullScreen={true}
className=''
title={`asciinema cast ${id}`}
src={`https://asciinema.org/a/${id}/iframe?autoplay=1`}
/>
</span>
</ClickShield>
)
}

View file

@ -0,0 +1,40 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { IdProps } from '../../../components/markdown-renderer/replace-components/custom-tag-with-id-component-replacer'
import { mockI18n } from '../../../components/markdown-renderer/test-utils/mock-i18n'
import { TestMarkdownRenderer } from '../../../components/markdown-renderer/test-utils/test-markdown-renderer'
import * as AsciinemaFrameModule from './asciinema-frame'
import { AsciinemaMarkdownExtension } from './asciinema-markdown-extension'
import { render } from '@testing-library/react'
import React from 'react'
jest.mock('./asciinema-frame')
describe('asciinema markdown extension', () => {
beforeAll(async () => {
jest
.spyOn(AsciinemaFrameModule, 'AsciinemaFrame')
.mockImplementation((({ id }) => (
<span>this is a mock for the asciinema frame with id {id}</span>
)) as React.FC<IdProps>)
await mockI18n()
})
afterAll(() => {
jest.resetAllMocks()
jest.resetModules()
})
it('renders plain asciinema URLs', () => {
const view = render(
<TestMarkdownRenderer
extensions={[new AsciinemaMarkdownExtension()]}
content={'https://asciinema.org/a/190123709'}
/>
)
expect(view.container).toMatchSnapshot()
})
})

View file

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

View file

@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { replaceAsciinemaLinkMarkdownItPlugin } from './replace-asciinema-link'
import MarkdownIt from 'markdown-it/lib'
describe('Replace asciinema link', () => {
let markdownIt: MarkdownIt
beforeEach(() => {
markdownIt = new MarkdownIt('default', {
html: false,
breaks: true,
langPrefix: '',
typographer: true
})
markdownIt.use(replaceAsciinemaLinkMarkdownItPlugin)
})
it('will replace a valid URL', () => {
expect(markdownIt.renderInline('https://asciinema.org/a/123981234')).toBe(
`<app-asciinema id='123981234'></app-asciinema>`
)
})
it("won't replace an URL without path", () => {
expect(markdownIt.renderInline('https://asciinema.org/123981234')).toBe(`https://asciinema.org/123981234`)
})
it("won't replace an URL with non-numeric id", () => {
expect(markdownIt.renderInline('https://asciinema.org/a/12f3981234')).toBe(`https://asciinema.org/a/12f3981234`)
})
})

View file

@ -0,0 +1,34 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { RegexOptions } from '../../../external-types/markdown-it-regex/interface'
import { AsciinemaMarkdownExtension } from './asciinema-markdown-extension'
import type MarkdownIt from 'markdown-it'
import markdownItRegex from 'markdown-it-regex'
const protocolRegex = /(?:http(?:s)?:\/\/)?/
const domainRegex = /(?:asciinema\.org\/a\/)/
const idRegex = /(\d+)/
const tailRegex = /(?:[./?#].*)?/
const asciinemaUrlRegex = new RegExp(
`^(?:${protocolRegex.source}${domainRegex.source}${idRegex.source}${tailRegex.source})$`,
'i'
)
const replaceAsciinemaLink: RegexOptions = {
name: 'asciinema-link',
regex: asciinemaUrlRegex,
replace: (match) => {
// ESLint wants to collapse this tag, but then the tag won't be valid html anymore.
// noinspection CheckTagEmptyBody
return `<${AsciinemaMarkdownExtension.tagName} id='${match}'></${AsciinemaMarkdownExtension.tagName}>`
}
}
/**
* Replacer for asciinema links.
*/
export const replaceAsciinemaLinkMarkdownItPlugin: MarkdownIt.PluginSimple = (markdownIt: MarkdownIt) =>
markdownItRegex(markdownIt, replaceAsciinemaLink)

View file

@ -6,6 +6,7 @@
import type { AppExtension } from '../base/app-extension'
import { AbcjsAppExtension } from './abcjs/abcjs-app-extension'
import { AlertAppExtension } from './alert/alert-app-extension'
import { AsciinemaAppExtension } from './asciinema/asciinema-app-extension'
import { BlockquoteAppExtension } from './blockquote/blockquote-app-extension'
import { CsvTableAppExtension } from './csv/csv-table-app-extension'
import { FlowchartAppExtension } from './flowchart/flowchart-app-extension'
@ -36,6 +37,7 @@ export const optionalAppExtensions: AppExtension[] = [
new GistAppExtension(),
new GraphvizAppExtension(),
new KatexAppExtension(),
new AsciinemaAppExtension(),
new LegacyShortcodesAppExtension(),
new MermaidAppExtension(),
new PlantumlAppExtension(),