mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2024-11-25 11:16:31 -05:00
Add markdown renderer for motd (#1840)
* Add markdown renderer for motd Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
21c12fafba
commit
57cb6f5b15
15 changed files with 102 additions and 55 deletions
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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><iframe></iframe></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(() => {
|
||||||
|
|
|
@ -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
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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
6
netlify/motd.md
Normal 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
3
netlify/motd.md.license
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
|
||||||
|
SPDX-License-Identifier: CC0-1.0
|
|
@ -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).
|
|
|
@ -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
|
||||||
|
|
|
@ -1 +1,2 @@
|
||||||
This is the test motd text
|
This is the test motd text
|
||||||
|
:smile:
|
|
@ -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()
|
||||||
|
|
|
@ -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'} />
|
||||||
|
|
48
src/components/common/motd-modal/motd-renderer.tsx
Normal file
48
src/components/common/motd-modal/motd-renderer.tsx
Normal 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
|
|
@ -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(
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue