Restructure replacers (#266)

* Restructure replacers

Signed-off-by: Tilman Vatteroth <tilman.vatteroth@tu-dortmund.de>
This commit is contained in:
mrdrogdrog 2020-06-23 23:04:51 +02:00 committed by GitHub
parent eb56da7871
commit b74bb8e71d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 250 additions and 219 deletions

View file

@ -34,100 +34,102 @@ import { replaceQuoteExtraColor } from './regex-plugins/replace-quote-extra-colo
import { replaceQuoteExtraTime } from './regex-plugins/replace-quote-extra-time'
import { replaceVimeoLink } from './regex-plugins/replace-vimeo-link'
import { replaceYouTubeLink } from './regex-plugins/replace-youtube-link'
import { getGistReplacement } from './replace-components/gist/gist-frame'
import { getHighlightedFence } from './replace-components/highlighted-fence/highlighted-fence'
import { getMathJaxReplacement } from './replace-components/mathjax/mathjax-replacer'
import { getPDFReplacement } from './replace-components/pdf/pdf-frame'
import { getQuoteOptionsReplacement } from './replace-components/quote-options/quote-options'
import { getTOCReplacement } from './replace-components/toc/toc-replacer'
import { getVimeoReplacement } from './replace-components/vimeo/vimeo-frame'
import { getYouTubeReplacement } from './replace-components/youtube/youtube-frame'
import { ComponentReplacer, SubNodeConverter } from './replace-components/ComponentReplacer'
import { GistReplacer } from './replace-components/gist/gist-replacer'
import { HighlightedCodeReplacer } from './replace-components/highlighted-fence/highlighted-fence-replacer'
import { MathjaxReplacer } from './replace-components/mathjax/mathjax-replacer'
import { PdfReplacer } from './replace-components/pdf/pdf-replacer'
import { QuoteOptionsReplacer } from './replace-components/quote-options/quote-options-replacer'
import { TocReplacer } from './replace-components/toc/toc-replacer'
import { VimeoReplacer } from './replace-components/vimeo/vimeo-replacer'
import { YoutubeReplacer } from './replace-components/youtube/youtube-replacer'
export interface MarkdownPreviewProps {
content: string
}
export type SubNodeConverter = (node: DomElement, index: number) => ReactElement
export type ComponentReplacer = (node: DomElement, index: number, counterMap: Map<string, number>, nodeConverter: SubNodeConverter) => (ReactElement | undefined);
type ComponentReplacer2Identifier2CounterMap = Map<ComponentReplacer, Map<string, number>>
const createMarkdownIt = ():MarkdownIt => {
const md = new MarkdownIt('default', {
html: true,
breaks: true,
langPrefix: '',
typographer: true
})
md.use(taskList)
md.use(emoji)
md.use(abbreviation)
md.use(definitionList)
md.use(subscript)
md.use(superscript)
md.use(inserted)
md.use(marked)
md.use(footnote)
md.use(imsize)
// noinspection CheckTagEmptyBody
md.use(anchor, {
permalink: true,
permalinkBefore: true,
permalinkClass: 'heading-anchor text-dark',
permalinkSymbol: '<i class="fa fa-link"></i>'
})
md.use(toc, {
includeLevel: [1, 2, 3],
markerPattern: /^\[TOC]$/i
})
md.use(mathJax({
beforeMath: '<codimd-mathjax>',
afterMath: '</codimd-mathjax>',
beforeInlineMath: '<codimd-mathjax inline>',
afterInlineMath: '</codimd-mathjax>',
beforeDisplayMath: '<codimd-mathjax>',
afterDisplayMath: '</codimd-mathjax>'
}))
md.use(markdownItRegex, replaceLegacyYoutubeShortCode)
md.use(markdownItRegex, replaceLegacyVimeoShortCode)
md.use(markdownItRegex, replaceLegacyGistShortCode)
md.use(markdownItRegex, replaceLegacySlideshareShortCode)
md.use(markdownItRegex, replaceLegacySpeakerdeckShortCode)
md.use(markdownItRegex, replacePdfShortCode)
md.use(markdownItRegex, replaceYouTubeLink)
md.use(markdownItRegex, replaceVimeoLink)
md.use(markdownItRegex, replaceGistLink)
md.use(highlightedCode)
md.use(markdownItRegex, replaceQuoteExtraAuthor)
md.use(markdownItRegex, replaceQuoteExtraColor)
md.use(markdownItRegex, replaceQuoteExtraTime)
md.use(MarkdownItParserDebugger)
const allComponentReplacers: ComponentReplacer[] = [getYouTubeReplacement, getVimeoReplacement, getGistReplacement, getPDFReplacement, getTOCReplacement, getHighlightedFence, getQuoteOptionsReplacement, getMathJaxReplacement]
validAlertLevels.forEach(level => {
md.use(markdownItContainer, level, { render: createRenderContainer(level) })
})
const tryToReplaceNode = (node: DomElement, index:number, componentReplacer2Identifier2CounterMap: ComponentReplacer2Identifier2CounterMap, nodeConverter: SubNodeConverter) => {
return allComponentReplacers
.map((componentReplacer) => {
const identifier2CounterMap = componentReplacer2Identifier2CounterMap.get(componentReplacer) || new Map<string, number>()
return componentReplacer(node, index, identifier2CounterMap, nodeConverter)
})
return md
}
const tryToReplaceNode = (node: DomElement, index: number, allReplacers: ComponentReplacer[], nodeConverter: SubNodeConverter) => {
return allReplacers
.map((componentReplacer) => componentReplacer.getReplacement(node, index, nodeConverter))
.find((replacement) => !!replacement)
}
const MarkdownRenderer: React.FC<MarkdownPreviewProps> = ({ content }) => {
const markdownIt = useMemo(() => {
const md = new MarkdownIt('default', {
html: true,
breaks: true,
langPrefix: '',
typographer: true
})
md.use(taskList)
md.use(emoji)
md.use(abbreviation)
md.use(definitionList)
md.use(subscript)
md.use(superscript)
md.use(inserted)
md.use(marked)
md.use(footnote)
md.use(imsize)
// noinspection CheckTagEmptyBody
md.use(anchor, {
permalink: true,
permalinkBefore: true,
permalinkClass: 'heading-anchor text-dark',
permalinkSymbol: '<i class="fa fa-link"></i>'
})
md.use(toc, {
includeLevel: [1, 2, 3],
markerPattern: /^\[TOC]$/i
})
md.use(mathJax({
beforeMath: '<codimd-mathjax>',
afterMath: '</codimd-mathjax>',
beforeInlineMath: '<codimd-mathjax inline>',
afterInlineMath: '</codimd-mathjax>',
beforeDisplayMath: '<codimd-mathjax>',
afterDisplayMath: '</codimd-mathjax>'
}))
md.use(markdownItRegex, replaceLegacyYoutubeShortCode)
md.use(markdownItRegex, replaceLegacyVimeoShortCode)
md.use(markdownItRegex, replaceLegacyGistShortCode)
md.use(markdownItRegex, replaceLegacySlideshareShortCode)
md.use(markdownItRegex, replaceLegacySpeakerdeckShortCode)
md.use(markdownItRegex, replacePdfShortCode)
md.use(markdownItRegex, replaceYouTubeLink)
md.use(markdownItRegex, replaceVimeoLink)
md.use(markdownItRegex, replaceGistLink)
md.use(highlightedCode)
md.use(markdownItRegex, replaceQuoteExtraAuthor)
md.use(markdownItRegex, replaceQuoteExtraColor)
md.use(markdownItRegex, replaceQuoteExtraTime)
md.use(MarkdownItParserDebugger)
validAlertLevels.forEach(level => {
md.use(markdownItContainer, level, { render: createRenderContainer(level) })
})
return md
}, [])
const markdownIt = useMemo(createMarkdownIt, [])
const result: ReactElement[] = useMemo(() => {
const componentReplacer2Identifier2CounterMap = new Map<ComponentReplacer, Map<string, number>>()
const allReplacers: ComponentReplacer[] = [
new GistReplacer(),
new YoutubeReplacer(),
new VimeoReplacer(),
new PdfReplacer(),
new TocReplacer(),
new HighlightedCodeReplacer(),
new QuoteOptionsReplacer(),
new MathjaxReplacer()
]
const html: string = markdownIt.render(content)
const transform: Transform = (node, index) => {
const maybeReplacement = tryToReplaceNode(node, index, componentReplacer2Identifier2CounterMap,
(subNode, subIndex) => convertNodeToElement(subNode, subIndex, transform))
return maybeReplacement || convertNodeToElement(node, index, transform)
const subNodeConverter = (subNode: DomElement, subIndex: number) => convertNodeToElement(subNode, subIndex, transform)
return tryToReplaceNode(node, index, allReplacers, subNodeConverter) || convertNodeToElement(node, index, transform)
}
return ReactHtmlParser(html, { transform: transform })
}, [content, markdownIt])

View file

@ -0,0 +1,8 @@
import { DomElement } from 'domhandler'
import { ReactElement } from 'react'
export type SubNodeConverter = (node: DomElement, index: number) => ReactElement
export interface ComponentReplacer {
getReplacement: (node: DomElement, index:number, subNodeConverter: SubNodeConverter) => (ReactElement|undefined)
}

View file

@ -1,9 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { ComponentReplacer } from '../../markdown-renderer'
import { getAttributesFromCodiMdTag } from '../codi-md-tag-utils'
import { OneClickEmbedding } from '../one-click-frame/one-click-embedding'
import './gist-frame.scss'
import preview from './gist-preview.png'
export interface GistFrameProps {
id: string
@ -14,20 +10,6 @@ interface resizeEvent {
id: string
}
const getElementReplacement:ComponentReplacer = (node, index:number, counterMap) => {
const attributes = getAttributesFromCodiMdTag(node, 'gist')
if (attributes && attributes.id) {
const gistId = attributes.id
const count = (counterMap.get(gistId) || 0) + 1
counterMap.set(gistId, count)
return (
<OneClickEmbedding previewContainerClassName={'gist-frame'} key={`gist_${gistId}_${count}`} loadingImageUrl={preview} hoverIcon={'github'} tooltip={'click to load gist'}>
<GistFrame id={gistId}/>
</OneClickEmbedding>
)
}
}
export const GistFrame: React.FC<GistFrameProps> = ({ id }) => {
const iframeHtml = useMemo(() => {
return (`
@ -85,5 +67,3 @@ export const GistFrame: React.FC<GistFrameProps> = ({ id }) => {
src={`data:text/html;base64,${btoa(iframeHtml)}`}/>
)
}
export { getElementReplacement as getGistReplacement }

View file

@ -0,0 +1,25 @@
import { DomElement } from 'domhandler'
import React from 'react'
import { getAttributesFromCodiMdTag } from '../codi-md-tag-utils'
import { ComponentReplacer } from '../ComponentReplacer'
import { OneClickEmbedding } from '../one-click-frame/one-click-embedding'
import { GistFrame } from './gist-frame'
import preview from './gist-preview.png'
export class GistReplacer implements ComponentReplacer {
private counterMap: Map<string, number> = new Map<string, number>()
getReplacement (node: DomElement): React.ReactElement | undefined {
const attributes = getAttributesFromCodiMdTag(node, 'gist')
if (attributes && attributes.id) {
const gistId = attributes.id
const count = (this.counterMap.get(gistId) || 0) + 1
this.counterMap.set(gistId, count)
return (
<OneClickEmbedding previewContainerClassName={'gist-frame'} key={`gist_${gistId}_${count}`} loadingImageUrl={preview} hoverIcon={'github'} tooltip={'click to load gist'}>
<GistFrame id={gistId}/>
</OneClickEmbedding>
)
}
}
}

View file

@ -0,0 +1,18 @@
import { DomElement } from 'domhandler'
import React from 'react'
import { HighlightedCode } from '../../../../common/highlighted-code/highlighted-code'
import { ComponentReplacer, SubNodeConverter } from '../ComponentReplacer'
export class HighlightedCodeReplacer implements ComponentReplacer {
getReplacement (codeNode: DomElement, index: number, subNodeConverter: SubNodeConverter): React.ReactElement | undefined {
if (codeNode.name !== 'code' || !codeNode.attribs || !codeNode.attribs['data-highlight-language'] || !codeNode.children || !codeNode.children[0]) {
return
}
const language = codeNode.attribs['data-highlight-language']
const showGutter = codeNode.attribs['data-show-gutter'] !== undefined
const wrapLines = codeNode.attribs['data-wrap-lines'] !== undefined
return <HighlightedCode key={index} language={language} showGutter={showGutter} wrapLines={wrapLines} code={codeNode.children[0].data as string}/>
}
}

View file

@ -1,16 +0,0 @@
import { DomElement } from 'domhandler'
import React, { ReactElement } from 'react'
import { HighlightedCode } from '../../../../common/highlighted-code/highlighted-code'
const getElementReplacement = (codeNode: DomElement, index: number, counterMap: Map<string, number>): (ReactElement | undefined) => {
if (codeNode.name !== 'code' || !codeNode.attribs || !codeNode.attribs['data-highlight-language'] || !codeNode.children || !codeNode.children[0]) {
return
}
const language = codeNode.attribs['data-highlight-language']
const showGutter = codeNode.attribs['data-show-gutter'] !== undefined
const wrapLines = codeNode.attribs['data-wrap-lines'] !== undefined
return <HighlightedCode key={index} language={language} showGutter={showGutter} wrapLines={wrapLines} code={codeNode.children[0].data as string}/>
}
export { getElementReplacement as getHighlightedFence }

View file

@ -1,7 +1,7 @@
import React from 'react'
import { DomElement } from 'domhandler'
import { ComponentReplacer } from '../../markdown-renderer'
import React from 'react'
import MathJax from 'react-mathjax'
import { ComponentReplacer, SubNodeConverter } from '../ComponentReplacer'
const getNodeIfMathJaxBlock = (node: DomElement): (DomElement|undefined) => {
if (node.name !== 'p' || !node.children || node.children.length !== 1) {
@ -15,13 +15,13 @@ const getNodeIfInlineMathJax = (node: DomElement): (DomElement|undefined) => {
return (node.name === 'codimd-mathjax' && node.attribs?.inline !== undefined) ? node : undefined
}
const getElementReplacement: ComponentReplacer = (node, index: number, counterMap) => {
const mathJax = getNodeIfMathJaxBlock(node) || getNodeIfInlineMathJax(node)
if (mathJax?.children && mathJax.children[0]) {
const mathJaxContent = mathJax.children[0]?.data as string
const isInline = (mathJax.attribs?.inline) !== undefined
return <MathJax.Node key={index} inline={isInline} formula={mathJaxContent}/>
export class MathjaxReplacer implements ComponentReplacer {
getReplacement (node: DomElement, index: number, subNodeConverter: SubNodeConverter): React.ReactElement | undefined {
const mathJax = getNodeIfMathJaxBlock(node) || getNodeIfInlineMathJax(node)
if (mathJax?.children && mathJax.children[0]) {
const mathJaxContent = mathJax.children[0]?.data as string
const isInline = (mathJax.attribs?.inline) !== undefined
return <MathJax.Node key={index} inline={isInline} formula={mathJaxContent}/>
}
}
}
export { getElementReplacement as getMathJaxReplacement }

View file

@ -1,20 +1,8 @@
import { DomElement } from 'domhandler'
import React, { ReactElement } from 'react'
import React from 'react'
import { ExternalLink } from '../../../../common/links/external-link'
import { getAttributesFromCodiMdTag } from '../codi-md-tag-utils'
import { OneClickEmbedding } from '../one-click-frame/one-click-embedding'
import './pdf-frame.scss'
const getElementReplacement = (node: DomElement, index:number, counterMap: Map<string, number>): (ReactElement | undefined) => {
const attributes = getAttributesFromCodiMdTag(node, 'pdf')
if (attributes && attributes.url) {
const pdfUrl = attributes.url
const count = (counterMap.get(pdfUrl) || 0) + 1
counterMap.set(pdfUrl, count)
return <PdfFrame key={`pdf_${pdfUrl}_${count}`} url={pdfUrl}/>
}
}
export interface PdfFrameProps {
url: string
}
@ -30,5 +18,3 @@ export const PdfFrame: React.FC<PdfFrameProps> = ({ url }) => {
</OneClickEmbedding>
)
}
export { getElementReplacement as getPDFReplacement }

View file

@ -0,0 +1,19 @@
import { DomElement } from 'domhandler'
import React from 'react'
import { getAttributesFromCodiMdTag } from '../codi-md-tag-utils'
import { ComponentReplacer, SubNodeConverter } from '../ComponentReplacer'
import { PdfFrame } from './pdf-frame'
export class PdfReplacer implements ComponentReplacer {
private counterMap: Map<string, number> = new Map<string, number>()
getReplacement (node: DomElement, index: number, subNodeConverter: SubNodeConverter): React.ReactElement | undefined {
const attributes = getAttributesFromCodiMdTag(node, 'pdf')
if (attributes && attributes.url) {
const pdfUrl = attributes.url
const count = (this.counterMap.get(pdfUrl) || 0) + 1
this.counterMap.set(pdfUrl, count)
return <PdfFrame key={`pdf_${pdfUrl}_${count}`} url={pdfUrl}/>
}
}
}

View file

@ -0,0 +1,42 @@
import { DomElement } from 'domhandler'
import { ComponentReplacer, SubNodeConverter } from '../ComponentReplacer'
const isColorExtraElement = (node: DomElement | undefined): boolean => {
if (!node || !node.attribs || !node.attribs.class || !node.attribs['data-color']) {
return false
}
return (node.name === 'span' && node.attribs.class === 'quote-extra')
}
const findQuoteOptionsParent = (nodes: DomElement[]): DomElement | undefined => {
return nodes.find((child) => {
if (child.name !== 'p' || !child.children || child.children.length < 1) {
return false
}
return child.children.find(isColorExtraElement) !== undefined
})
}
export class QuoteOptionsReplacer implements ComponentReplacer {
getReplacement (node: DomElement, index: number, subNodeConverter: SubNodeConverter): React.ReactElement | undefined {
if (node.name !== 'blockquote' || !node.children || node.children.length < 1) {
return
}
const paragraph = findQuoteOptionsParent(node.children)
if (!paragraph) {
return
}
const childElements = paragraph.children || []
const optionsTag = childElements.find(isColorExtraElement)
if (!optionsTag) {
return
}
paragraph.children = childElements.filter(elem => !isColorExtraElement(elem))
const attributes = optionsTag.attribs
if (!attributes || !attributes['data-color']) {
return
}
node.attribs = Object.assign(node.attribs || {}, { style: `border-left-color: ${attributes['data-color']};` })
return subNodeConverter(node, index)
}
}

View file

@ -1,43 +0,0 @@
import { DomElement } from 'domhandler'
import { ReactElement } from 'react'
import { SubNodeConverter } from '../../markdown-renderer'
const isColorExtraElement = (node: DomElement | undefined): boolean => {
if (!node || !node.attribs || !node.attribs.class || !node.attribs['data-color']) {
return false
}
return (node.name === 'span' && node.attribs.class === 'quote-extra')
}
const findQuoteOptionsParent = (nodes: DomElement[]): DomElement | undefined => {
return nodes.find((child) => {
if (child.name !== 'p' || !child.children || child.children.length < 1) {
return false
}
return child.children.find(isColorExtraElement) !== undefined
})
}
const getElementReplacement = (node: DomElement, index: number, counterMap: Map<string, number>, nodeConverter: SubNodeConverter): (ReactElement | undefined) => {
if (node.name !== 'blockquote' || !node.children || node.children.length < 1) {
return
}
const paragraph = findQuoteOptionsParent(node.children)
if (!paragraph) {
return
}
const childElements = paragraph.children || []
const optionsTag = childElements.find(isColorExtraElement)
if (!optionsTag) {
return
}
paragraph.children = childElements.filter(elem => !isColorExtraElement(elem))
const attributes = optionsTag.attribs
if (!attributes || !attributes['data-color']) {
return
}
node.attribs = Object.assign(node.attribs || {}, { style: `border-left-color: ${attributes['data-color']};` })
return nodeConverter(node, index)
}
export { getElementReplacement as getQuoteOptionsReplacement }

View file

@ -1,17 +1,17 @@
import { DomElement } from 'domhandler'
import { ReactElement } from 'react'
import { SubNodeConverter } from '../../markdown-renderer'
import { ComponentReplacer, SubNodeConverter } from '../ComponentReplacer'
const getElementReplacement = (node: DomElement, index: number, counterMap: Map<string, number>, nodeConverter: SubNodeConverter): (ReactElement | undefined) => {
if (node.name === 'p' && node.children && node.children.length === 1) {
export class TocReplacer implements ComponentReplacer {
getReplacement (node: DomElement, index: number, subNodeConverter: SubNodeConverter): React.ReactElement | undefined {
if (node.name !== 'p' || node.children?.length !== 1) {
return
}
const possibleTocDiv = node.children[0]
if (possibleTocDiv.name === 'div' && possibleTocDiv.attribs && possibleTocDiv.attribs.class &&
possibleTocDiv.attribs.class === 'table-of-contents' && possibleTocDiv.children && possibleTocDiv.children.length === 1) {
possibleTocDiv.attribs.class === 'table-of-contents' && possibleTocDiv.children && possibleTocDiv.children.length === 1) {
const listElement = possibleTocDiv.children[0]
listElement.attribs = Object.assign(listElement.attribs || {}, { class: 'table-of-contents' })
return nodeConverter(listElement, index)
return subNodeConverter(listElement, index)
}
}
}
export { getElementReplacement as getTOCReplacement }

View file

@ -1,18 +1,6 @@
import React, { useCallback } from 'react'
import { ComponentReplacer } from '../../markdown-renderer'
import { getAttributesFromCodiMdTag } from '../codi-md-tag-utils'
import { OneClickEmbedding } from '../one-click-frame/one-click-embedding'
const getElementReplacement:ComponentReplacer = (node, index:number, counterMap) => {
const attributes = getAttributesFromCodiMdTag(node, 'vimeo')
if (attributes && attributes.id) {
const videoId = attributes.id
const count = (counterMap.get(videoId) || 0) + 1
counterMap.set(videoId, count)
return <VimeoFrame key={`vimeo_${videoId}_${count}`} id={videoId}/>
}
}
interface VimeoApiResponse {
// Vimeo uses strange names for their fields. ESLint doesn't like that.
// eslint-disable-next-line camelcase
@ -50,5 +38,3 @@ export const VimeoFrame: React.FC<VimeoFrameProps> = ({ id }) => {
</OneClickEmbedding>
)
}
export { getElementReplacement as getVimeoReplacement }

View file

@ -0,0 +1,19 @@
import { DomElement } from 'domhandler'
import React from 'react'
import { getAttributesFromCodiMdTag } from '../codi-md-tag-utils'
import { ComponentReplacer } from '../ComponentReplacer'
import { VimeoFrame } from './vimeo-frame'
export class VimeoReplacer implements ComponentReplacer {
private counterMap: Map<string, number> = new Map<string, number>()
getReplacement (node: DomElement): React.ReactElement | undefined {
const attributes = getAttributesFromCodiMdTag(node, 'vimeo')
if (attributes && attributes.id) {
const videoId = attributes.id
const count = (this.counterMap.get(videoId) || 0) + 1
this.counterMap.set(videoId, count)
return <VimeoFrame key={`vimeo_${videoId}_${count}`} id={videoId}/>
}
}
}

View file

@ -1,18 +1,6 @@
import React from 'react'
import { ComponentReplacer } from '../../markdown-renderer'
import { getAttributesFromCodiMdTag } from '../codi-md-tag-utils'
import { OneClickEmbedding } from '../one-click-frame/one-click-embedding'
const getElementReplacement: ComponentReplacer = (node, index:number, counterMap) => {
const attributes = getAttributesFromCodiMdTag(node, 'youtube')
if (attributes && attributes.id) {
const videoId = attributes.id
const count = (counterMap.get(videoId) || 0) + 1
counterMap.set(videoId, count)
return <YouTubeFrame key={`youtube_${videoId}_${count}`} id={videoId}/>
}
}
export interface YouTubeFrameProps {
id: string
}
@ -28,5 +16,3 @@ export const YouTubeFrame: React.FC<YouTubeFrameProps> = ({ id }) => {
</OneClickEmbedding>
)
}
export { getElementReplacement as getYouTubeReplacement }

View file

@ -0,0 +1,19 @@
import { DomElement } from 'domhandler'
import React from 'react'
import { getAttributesFromCodiMdTag } from '../codi-md-tag-utils'
import { ComponentReplacer } from '../ComponentReplacer'
import { YouTubeFrame } from './youtube-frame'
export class YoutubeReplacer implements ComponentReplacer {
private counterMap: Map<string, number> = new Map<string, number>()
getReplacement (node: DomElement): React.ReactElement | undefined {
const attributes = getAttributesFromCodiMdTag(node, 'youtube')
if (attributes && attributes.id) {
const videoId = attributes.id
const count = (this.counterMap.get(videoId) || 0) + 1
this.counterMap.set(videoId, count)
return <YouTubeFrame key={`youtube_${videoId}_${count}`} id={videoId}/>
}
}
}