Add markdown renderer for motd (#1840)

* Add markdown renderer for motd

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2022-02-10 09:27:09 +01:00 committed by GitHub
parent 21c12fafba
commit 57cb6f5b15
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 102 additions and 55 deletions

View file

@ -76,6 +76,7 @@ SPDX-License-Identifier: CC-BY-SA-4.0
- Image tags with placeholder urls (`https://`) will be replaced with a placeholder frame. - Image tags with placeholder urls (`https://`) will be replaced with a placeholder frame.
- Images that are currently uploading will be rendered as "uploading". - Images that are currently uploading will be rendered as "uploading".
- Code blocks with `plantuml` as language are rendered as [PlantUML](https://plantuml.com/) diagram using a configured render server. - Code blocks with `plantuml` as language are rendered as [PlantUML](https://plantuml.com/) diagram using a configured render server.
- File based motd that supports markdown without html.
### Changed ### Changed

View file

@ -6,17 +6,18 @@
const MOTD_LOCAL_STORAGE_KEY = 'motd.lastModified' const MOTD_LOCAL_STORAGE_KEY = 'motd.lastModified'
const MOCK_LAST_MODIFIED = 'mockETag' const MOCK_LAST_MODIFIED = 'mockETag'
const motdMockContent = 'This is the mock Motd call' const motdMockContent = 'This is the **mock** Motd call'
const motdMockHtml = 'This is the <strong>mock</strong> Motd call'
describe('Motd', () => { describe('Motd', () => {
const mockExistingMotd = (useEtag?: boolean) => { const mockExistingMotd = (useEtag?: boolean, content = motdMockContent) => {
cy.intercept('GET', '/mock-backend/public/motd.txt', { cy.intercept('GET', '/mock-backend/public/motd.md', {
statusCode: 200, statusCode: 200,
headers: { [useEtag ? 'etag' : 'Last-Modified']: MOCK_LAST_MODIFIED }, headers: { [useEtag ? 'etag' : 'Last-Modified']: MOCK_LAST_MODIFIED },
body: motdMockContent body: content
}) })
cy.intercept('HEAD', '/mock-backend/public/motd.txt', { cy.intercept('HEAD', '/mock-backend/public/motd.md', {
statusCode: 200, statusCode: 200,
headers: { [useEtag ? 'etag' : 'Last-Modified']: MOCK_LAST_MODIFIED } headers: { [useEtag ? 'etag' : 'Last-Modified']: MOCK_LAST_MODIFIED }
}) })
@ -29,13 +30,19 @@ describe('Motd', () => {
it('shows the correct alert Motd text', () => { it('shows the correct alert Motd text', () => {
mockExistingMotd() mockExistingMotd()
cy.visitHome() cy.visitHome()
cy.getByCypressId('motd').contains(motdMockContent) cy.getByCypressId('motd').find('.markdown-body').should('contain.html', motdMockHtml)
})
it("doesn't allow html in the motd", () => {
mockExistingMotd(false, '<iframe></iframe>')
cy.visitHome()
cy.getByCypressId('motd').find('.markdown-body').should('have.html', '<p>&lt;iframe&gt;&lt;/iframe&gt;</p>\n')
}) })
it('can be dismissed using etag', () => { it('can be dismissed using etag', () => {
mockExistingMotd(true) mockExistingMotd(true)
cy.visitHome() cy.visitHome()
cy.getByCypressId('motd').contains(motdMockContent) cy.getByCypressId('motd').find('.markdown-body').should('contain.html', motdMockHtml)
cy.getByCypressId('motd-dismiss') cy.getByCypressId('motd-dismiss')
.click() .click()
.then(() => { .then(() => {
@ -47,7 +54,7 @@ describe('Motd', () => {
it('can be dismissed', () => { it('can be dismissed', () => {
mockExistingMotd() mockExistingMotd()
cy.visitHome() cy.visitHome()
cy.getByCypressId('motd').contains(motdMockContent) cy.getByCypressId('motd').find('.markdown-body').should('contain.html', motdMockHtml)
cy.getByCypressId('motd-dismiss') cy.getByCypressId('motd-dismiss')
.click() .click()
.then(() => { .then(() => {
@ -59,7 +66,7 @@ describe('Motd', () => {
it("won't show again after dismiss and reload", () => { it("won't show again after dismiss and reload", () => {
mockExistingMotd() mockExistingMotd()
cy.visitHome() cy.visitHome()
cy.getByCypressId('motd').contains(motdMockContent) cy.getByCypressId('motd').find('.markdown-body').should('contain.html', motdMockHtml)
cy.getByCypressId('motd-dismiss') cy.getByCypressId('motd-dismiss')
.click() .click()
.then(() => { .then(() => {
@ -74,16 +81,16 @@ describe('Motd', () => {
it('will show again after reload without dismiss', () => { it('will show again after reload without dismiss', () => {
mockExistingMotd() mockExistingMotd()
cy.visitHome() cy.visitHome()
cy.getByCypressId('motd').contains(motdMockContent) cy.getByCypressId('motd').find('.markdown-body').should('contain.html', motdMockHtml)
cy.reload() cy.reload()
cy.get('main').should('exist') cy.get('main').should('exist')
cy.getByCypressId('motd').contains(motdMockContent) cy.getByCypressId('motd').find('.markdown-body').should('contain.html', motdMockHtml)
}) })
it("won't show again after dismiss and page navigation", () => { it("won't show again after dismiss and page navigation", () => {
mockExistingMotd() mockExistingMotd()
cy.visitHome() cy.visitHome()
cy.getByCypressId('motd').contains(motdMockContent) cy.getByCypressId('motd').find('.markdown-body').should('contain.html', motdMockHtml)
cy.getByCypressId('motd-dismiss') cy.getByCypressId('motd-dismiss')
.click() .click()
.then(() => { .then(() => {

View file

@ -68,11 +68,11 @@ Cypress.Commands.add('loadConfig', (additionalConfig?: Partial<typeof config>) =
beforeEach(() => { beforeEach(() => {
cy.loadConfig() cy.loadConfig()
cy.intercept('GET', '/mock-backend/public/motd.txt', { cy.intercept('GET', '/mock-backend/public/motd.md', {
body: '404 Not Found!', body: '404 Not Found!',
statusCode: 404 statusCode: 404
}) })
cy.intercept('HEAD', '/mock-backend/public/motd.txt', { cy.intercept('HEAD', '/mock-backend/public/motd.md', {
statusCode: 404 statusCode: 404
}) })
}) })

View file

@ -1,10 +1,4 @@
<!-- :::info
SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: CC-BY-SA-4.0
-->
:::warning
What you see is an UI-Test! It's filled with dummy data, not connected to a backend and no data will be saved. What you see is an UI-Test! It's filled with dummy data, not connected to a backend and no data will be saved.
::: :::

6
netlify/motd.md Normal file
View file

@ -0,0 +1,6 @@
This demo is hosted by [netlify](https://netlify.com).
Please check their [privacy policy](https://netlify.com/privacy) as well as [our privacy policy](https://hedgedoc.org/privacy-policy).
:::info
What you see is an UI-Test! It's filled with dummy data, not connected to a backend and no data will be saved.
:::

3
netlify/motd.md.license Normal file
View file

@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: CC0-1.0

View file

@ -1,2 +0,0 @@
This demo is hosted by netlify.
Please check their privacy policy (https://netlify.com/privacy) as well as ours (https://hedgedoc.org/privacy-policy).

View file

@ -7,8 +7,8 @@ set -e
echo 'Patch intro.md to include netlify banner.' echo 'Patch intro.md to include netlify banner.'
cp netlify/intro.md public/mock-backend/public/intro.md cp netlify/intro.md public/mock-backend/public/intro.md
echo 'Patch motd.txt to include privacy policy.' echo 'Patch motd.md to include privacy policy.'
cp netlify/motd.txt public/mock-backend/public/motd.txt cp netlify/motd.md public/mock-backend/public/motd.md
echo 'Patch version.json to include git hash' echo 'Patch version.json to include git hash'
jq ".version = \"0.0.0+${GITHUB_SHA:0:8}\"" src/version.json > src/_version.json jq ".version = \"0.0.0+${GITHUB_SHA:0:8}\"" src/version.json > src/_version.json
mv src/_version.json src/version.json mv src/_version.json src/version.json

View file

@ -1 +1,2 @@
This is the test motd text This is the test motd text
:smile:

View file

@ -17,12 +17,12 @@ const log = new Logger('Motd')
* To check if the motd has changed the "last modified" header from the request * To check if the motd has changed the "last modified" header from the request
* will be compared to the saved value from the browser's local storage. * will be compared to the saved value from the browser's local storage.
* *
* @param customizeAssetsUrl the URL where the motd.txt can be found. * @param customizeAssetsUrl the URL where the motd.md can be found.
* @return A promise that gets resolved if the motd was fetched successfully. * @return A promise that gets resolved if the motd was fetched successfully.
*/ */
export const fetchMotd = async (customizeAssetsUrl: string): Promise<void> => { export const fetchMotd = async (customizeAssetsUrl: string): Promise<void> => {
const cachedLastModified = window.localStorage.getItem(MOTD_LOCAL_STORAGE_KEY) const cachedLastModified = window.localStorage.getItem(MOTD_LOCAL_STORAGE_KEY)
const motdUrl = `${customizeAssetsUrl}motd.txt` const motdUrl = `${customizeAssetsUrl}motd.md`
if (cachedLastModified) { if (cachedLastModified) {
const response = await fetch(motdUrl, { const response = await fetch(motdUrl, {
@ -48,7 +48,7 @@ export const fetchMotd = async (customizeAssetsUrl: string): Promise<void> => {
const lastModified = response.headers.get('Last-Modified') || response.headers.get('etag') const lastModified = response.headers.get('Last-Modified') || response.headers.get('etag')
if (!lastModified) { if (!lastModified) {
log.warn("'Last-Modified' or 'Etag' not found for motd.txt!") log.warn("'Last-Modified' or 'Etag' not found for motd.md!")
} }
const motdText = await response.text() const motdText = await response.text()

View file

@ -4,13 +4,16 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import React, { Fragment, useCallback, useMemo } from 'react' import React, { Suspense, useCallback } from 'react'
import { Button, Modal } from 'react-bootstrap' import { Button, Modal } from 'react-bootstrap'
import { CommonModal } from '../modals/common-modal' import { CommonModal } from '../modals/common-modal'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import { useApplicationState } from '../../../hooks/common/use-application-state' import { useApplicationState } from '../../../hooks/common/use-application-state'
import { dismissMotd } from '../../../redux/motd/methods' import { dismissMotd } from '../../../redux/motd/methods'
import { cypressId } from '../../../utils/cypress-attribute' import { cypressId } from '../../../utils/cypress-attribute'
import { WaitSpinner } from '../wait-spinner/wait-spinner'
const MotdRenderer = React.lazy(() => import('./motd-renderer'))
/** /**
* Reads the motd from the global application state and shows it in a modal. * Reads the motd from the global application state and shows it in a modal.
@ -21,23 +24,6 @@ export const MotdModal: React.FC = () => {
useTranslation() useTranslation()
const motdState = useApplicationState((state) => state.motd) const motdState = useApplicationState((state) => state.motd)
const domContent = useMemo(() => {
if (!motdState) {
return null
}
let index = 0
return motdState.text
?.split('\n')
.map((line) => <span key={(index += 1)}>{line}</span>)
.reduce((previousLine, currentLine, currentLineIndex) => (
<Fragment key={currentLineIndex}>
{previousLine}
<br />
{currentLine}
</Fragment>
))
}, [motdState])
const dismiss = useCallback(() => { const dismiss = useCallback(() => {
if (!motdState) { if (!motdState) {
return return
@ -49,8 +35,12 @@ export const MotdModal: React.FC = () => {
return null return null
} else { } else {
return ( return (
<CommonModal {...cypressId('motd')} show={!!motdState} title={'motd.title'}> <CommonModal {...cypressId('motd')} show={true} title={'motd.title'}>
<Modal.Body>{domContent}</Modal.Body> <Modal.Body>
<Suspense fallback={<WaitSpinner />}>
<MotdRenderer />
</Suspense>
</Modal.Body>
<Modal.Footer> <Modal.Footer>
<Button variant={'success'} onClick={dismiss} {...cypressId('motd-dismiss')}> <Button variant={'success'} onClick={dismiss} {...cypressId('motd-dismiss')}>
<Trans i18nKey={'common.dismiss'} /> <Trans i18nKey={'common.dismiss'} />

View file

@ -0,0 +1,48 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useMemo } from 'react'
import { GenericSyntaxMarkdownExtension } from '../../markdown-renderer/markdown-extension/generic-syntax-markdown-extension'
import { useConvertMarkdownToReactDom } from '../../markdown-renderer/hooks/use-convert-markdown-to-react-dom'
import { LinkifyFixMarkdownExtension } from '../../markdown-renderer/markdown-extension/linkify-fix-markdown-extension'
import { EmojiMarkdownExtension } from '../../markdown-renderer/markdown-extension/emoji/emoji-markdown-extension'
import { DebuggerMarkdownExtension } from '../../markdown-renderer/markdown-extension/debugger-markdown-extension'
import { useApplicationState } from '../../../hooks/common/use-application-state'
import { ProxyImageMarkdownExtension } from '../../markdown-renderer/markdown-extension/image/proxy-image-markdown-extension'
import { YoutubeMarkdownExtension } from '../../markdown-renderer/markdown-extension/youtube/youtube-markdown-extension'
import { AlertMarkdownExtension } from '../../markdown-renderer/markdown-extension/alert-markdown-extension'
import { SpoilerMarkdownExtension } from '../../markdown-renderer/markdown-extension/spoiler-markdown-extension'
import { BlockquoteExtraTagMarkdownExtension } from '../../markdown-renderer/markdown-extension/blockquote/blockquote-extra-tag-markdown-extension'
import { VimeoMarkdownExtension } from '../../markdown-renderer/markdown-extension/vimeo/vimeo-markdown-extension'
/**
* Reads the motd from the global application state and renders it as markdown with a subset of the usual features and without HTML support.
*/
export const MotdRenderer: React.FC = () => {
const extensions = useMemo(
() => [
new YoutubeMarkdownExtension(),
new VimeoMarkdownExtension(),
new ProxyImageMarkdownExtension(),
new BlockquoteExtraTagMarkdownExtension(),
new AlertMarkdownExtension(),
new SpoilerMarkdownExtension(),
new GenericSyntaxMarkdownExtension(),
new LinkifyFixMarkdownExtension(),
new EmojiMarkdownExtension(),
new DebuggerMarkdownExtension()
],
[]
)
const motdState = useApplicationState((state) => state.motd)
const lines = useMemo(() => (motdState ? motdState.text.split('\n') : []), [motdState])
const dom = useConvertMarkdownToReactDom(lines, extensions, true, false)
return <div className={'markdown-body'}>{dom}</div>
}
export default MotdRenderer

View file

@ -21,12 +21,14 @@ import { SanitizerMarkdownExtension } from '../markdown-extension/sanitizer/sani
* @param markdownContentLines The markdown code lines that should be rendered * @param markdownContentLines The markdown code lines that should be rendered
* @param additionalMarkdownExtensions A list of {@link MarkdownExtension markdown extensions} that should be used * @param additionalMarkdownExtensions A list of {@link MarkdownExtension markdown extensions} that should be used
* @param newlinesAreBreaks Defines if the alternative break mode of markdown it should be used * @param newlinesAreBreaks Defines if the alternative break mode of markdown it should be used
* @param allowHtml Defines if html is allowed in markdown
* @return The React DOM that represents the rendered markdown code * @return The React DOM that represents the rendered markdown code
*/ */
export const useConvertMarkdownToReactDom = ( export const useConvertMarkdownToReactDom = (
markdownContentLines: string[], markdownContentLines: string[],
additionalMarkdownExtensions: MarkdownExtension[], additionalMarkdownExtensions: MarkdownExtension[],
newlinesAreBreaks?: boolean newlinesAreBreaks = true,
allowHtml = true
): ValidReactDomElement[] => { ): ValidReactDomElement[] => {
const lineNumberMapper = useMemo(() => new LineIdMapper(), []) const lineNumberMapper = useMemo(() => new LineIdMapper(), [])
const htmlToReactTransformer = useMemo(() => new NodeToReactTransformer(), []) const htmlToReactTransformer = useMemo(() => new NodeToReactTransformer(), [])
@ -40,8 +42,8 @@ export const useConvertMarkdownToReactDom = (
const markdownIt = useMemo(() => { const markdownIt = useMemo(() => {
const newMarkdownIt = new MarkdownIt('default', { const newMarkdownIt = new MarkdownIt('default', {
html: true, html: allowHtml,
breaks: newlinesAreBreaks ?? true, breaks: newlinesAreBreaks,
langPrefix: '', langPrefix: '',
typographer: true typographer: true
}) })
@ -52,7 +54,7 @@ export const useConvertMarkdownToReactDom = (
newMarkdownIt.use((markdownIt) => extension.configureMarkdownItPost(markdownIt)) newMarkdownIt.use((markdownIt) => extension.configureMarkdownItPost(markdownIt))
) )
return newMarkdownIt return newMarkdownIt
}, [markdownExtensions, newlinesAreBreaks]) }, [allowHtml, markdownExtensions, newlinesAreBreaks])
useMemo(() => { useMemo(() => {
const replacers = markdownExtensions.reduce( const replacers = markdownExtensions.reduce(

View file

@ -6,7 +6,6 @@
import type { Element, Node } from 'domhandler' import type { Element, Node } from 'domhandler'
import { isText } from 'domhandler' import { isText } from 'domhandler'
import type MarkdownIt from 'markdown-it'
import type { ReactElement } from 'react' import type { ReactElement } from 'react'
export type ValidReactDomElement = ReactElement | string | null export type ValidReactDomElement = ReactElement | string | null
@ -15,8 +14,6 @@ export type SubNodeTransform = (node: Node, subKey: number | string) => NodeRepl
export type NativeRenderer = () => ValidReactDomElement export type NativeRenderer = () => ValidReactDomElement
export type MarkdownItPlugin = MarkdownIt.PluginSimple | MarkdownIt.PluginWithOptions | MarkdownIt.PluginWithParams
export const REPLACE_WITH_NOTHING = null export const REPLACE_WITH_NOTHING = null
export const DO_NOT_REPLACE = undefined export const DO_NOT_REPLACE = undefined
export type NodeReplacement = ValidReactDomElement | typeof REPLACE_WITH_NOTHING | typeof DO_NOT_REPLACE export type NodeReplacement = ValidReactDomElement | typeof REPLACE_WITH_NOTHING | typeof DO_NOT_REPLACE