Add YAML-metadata for notes and change the document title accordingly (#310)

* Added yaml-frontmatter extracting and error handling
* add tests
* changed document-title, so the editor can change the title to the title of the yaml metadata. closes #303
* extracted first line parsing in a core rule of markdown-it
document title will now be determined like this:
1. yaml metadata title
2. opengraph title
3. first level one heading
4. 'Untitled'
* added documentTitle e2e test

Co-authored-by: Erik Michelson <github@erik.michelson.eu>
Co-authored-by: Philip Molares <philip@mauricedoepke.de>
Co-authored-by: Tilman Vatteroth <tilman.vatteroth@tu-dortmund.de>
Co-authored-by: mrdrogdrog <mr.drogdrog@gmail.com>
This commit is contained in:
Philip Molares 2020-07-18 22:17:36 +02:00 committed by GitHub
parent 07fed5c67e
commit 29709d2ba4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 499 additions and 20 deletions

View file

@ -2,12 +2,16 @@ import React, { useEffect } from 'react'
import { useSelector } from 'react-redux'
import { ApplicationState } from '../../../redux'
export const DocumentTitle: React.FC = () => {
export interface DocumentTitleProps {
title?: string
}
export const DocumentTitle: React.FC<DocumentTitleProps> = ({ title }) => {
const branding = useSelector((state: ApplicationState) => state.backendConfig.branding)
useEffect(() => {
document.title = `CodiMD ${branding.name ? ` @ ${branding.name}` : ''}`
}, [branding])
document.title = `${title ? title + ' - ' : ''}CodiMD ${branding.name ? ` @ ${branding.name}` : ''}`
}, [branding, title])
return null
}

View file

@ -1,18 +1,31 @@
import React, { Fragment, useEffect, useState } from 'react'
import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import useMedia from 'use-media'
import { ApplicationState } from '../../redux'
import { setEditorModeConfig } from '../../redux/editor/methods'
import { DocumentTitle } from '../common/document-title/document-title'
import { Splitter } from '../common/splitter/splitter'
import { InfoBanner } from '../landing/layout/info-banner'
import { EditorWindow } from './editor-window/editor-window'
import { MarkdownRenderWindow } from './renderer-window/markdown-render-window'
import { EditorMode } from './task-bar/editor-view-mode'
import { TaskBar } from './task-bar/task-bar'
import { YAMLMetaData } from './yaml-metadata/yaml-metadata'
const Editor: React.FC = () => {
export const Editor: React.FC = () => {
const { t } = useTranslation()
const untitledNote = t('editor.untitledNote')
const editorMode: EditorMode = useSelector((state: ApplicationState) => state.editorConfig.editorMode)
const [markdownContent, setMarkdownContent] = useState(`# Embedding demo
const [markdownContent, setMarkdownContent] = useState(`---
title: Features
description: Many features, such wow!
robots: noindex
tags: codimd, demo, react
opengraph:
title: Features
---
# Embedding demo
[TOC]
## MathJax
@ -55,12 +68,36 @@ https://asciinema.org/a/117928
## Code highlighting
\`\`\`javascript=
let a = 1
\`\`\`
`)
const isWide = useMedia({ minWidth: 576 })
const [firstDraw, setFirstDraw] = useState(true)
const [documentTitle, setDocumentTitle] = useState(untitledNote)
const noteMetadata = useRef<YAMLMetaData>()
const firstHeading = useRef<string>()
const updateDocumentTitle = useCallback(() => {
if (noteMetadata.current?.title && noteMetadata.current?.title !== '') {
setDocumentTitle(noteMetadata.current.title)
} else if (noteMetadata.current?.opengraph && noteMetadata.current?.opengraph.get('title') && noteMetadata.current?.opengraph.get('title') !== '') {
setDocumentTitle(noteMetadata.current.opengraph.get('title') ?? untitledNote)
} else {
setDocumentTitle(firstHeading.current ?? untitledNote)
}
}, [untitledNote])
const onMetadataChange = useCallback((metaData: YAMLMetaData | undefined) => {
noteMetadata.current = metaData
updateDocumentTitle()
}, [updateDocumentTitle])
const onFirstHeadingChange = useCallback((newFirstHeading: string | undefined) => {
firstHeading.current = newFirstHeading
updateDocumentTitle()
}, [updateDocumentTitle])
useEffect(() => {
setFirstDraw(false)
@ -75,17 +112,16 @@ let a = 1
return (
<Fragment>
<InfoBanner/>
<DocumentTitle title={documentTitle}/>
<div className={'d-flex flex-column vh-100'}>
<TaskBar/>
<Splitter
showLeft={editorMode === EditorMode.EDITOR || editorMode === EditorMode.BOTH}
left={<EditorWindow onContentChange={content => setMarkdownContent(content)} content={markdownContent}/>}
showRight={editorMode === EditorMode.PREVIEW || (editorMode === EditorMode.BOTH)}
right={<MarkdownRenderWindow content={markdownContent} wide={editorMode === EditorMode.PREVIEW}/>}
right={<MarkdownRenderWindow content={markdownContent} wide={editorMode === EditorMode.PREVIEW} onMetadataChange={onMetadataChange} onFirstHeadingChange={onFirstHeadingChange}/>}
containerClassName={'overflow-hidden'}/>
</div>
</Fragment>
)
}
export { Editor }

View file

@ -1,5 +1,6 @@
import equal from 'deep-equal'
import { DomElement } from 'domhandler'
import yaml from 'js-yaml'
import MarkdownIt from 'markdown-it'
import abbreviation from 'markdown-it-abbr'
import anchor from 'markdown-it-anchor'
@ -7,6 +8,7 @@ import markdownItContainer from 'markdown-it-container'
import definitionList from 'markdown-it-deflist'
import emoji from 'markdown-it-emoji'
import footnote from 'markdown-it-footnote'
import frontmatter from 'markdown-it-front-matter'
import imsize from 'markdown-it-imsize'
import inserted from 'markdown-it-ins'
import marked from 'markdown-it-mark'
@ -16,11 +18,16 @@ import subscript from 'markdown-it-sub'
import superscript from 'markdown-it-sup'
import taskList from 'markdown-it-task-lists'
import toc from 'markdown-it-toc-done-right'
import React, { ReactElement, useEffect, useMemo, useState } from 'react'
import React, { ReactElement, useEffect, useMemo, useRef, useState } from 'react'
import { Alert } from 'react-bootstrap'
import ReactHtmlParser, { convertNodeToElement, Transform } from 'react-html-parser'
import { Trans } from 'react-i18next'
import MathJaxReact from 'react-mathjax'
import { TocAst } from '../../../external-types/markdown-it-toc-done-right/interface'
import { slugify } from '../../../utils/slugify'
import { InternalLink } from '../../common/links/internal-link'
import { ShowIf } from '../../common/show-if/show-if'
import { RawYAMLMetadata, YAMLMetaData } from '../yaml-metadata/yaml-metadata'
import { createRenderContainer, validAlertLevels } from './container-plugins/alert'
import { highlightedCode } from './markdown-it-plugins/highlighted-code'
import { linkifyExtra } from './markdown-it-plugins/linkify-extra'
@ -57,11 +64,34 @@ export interface MarkdownRendererProps {
wide?: boolean
className?: string
onTocChange?: (ast: TocAst) => void
onMetaDataChange?: (yamlMetaData: YAMLMetaData | undefined) => void
onFirstHeadingChange?: (firstHeading: string | undefined) => void
}
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, className, onTocChange, wide }) => {
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, onMetaDataChange, onFirstHeadingChange, onTocChange, className, wide }) => {
const [tocAst, setTocAst] = useState<TocAst>()
const [lastTocAst, setLastTocAst] = useState<TocAst>()
const [yamlError, setYamlError] = useState(false)
const rawMetaRef = useRef<RawYAMLMetadata>()
const oldMetaRef = useRef<RawYAMLMetadata>()
const firstHeadingRef = useRef<string>()
const oldFirstHeadingRef = useRef<string>()
useEffect(() => {
if (onMetaDataChange && !equal(oldMetaRef.current, rawMetaRef.current)) {
if (rawMetaRef.current) {
const newMetaData = new YAMLMetaData(rawMetaRef.current)
onMetaDataChange(newMetaData)
} else {
onMetaDataChange(undefined)
}
oldMetaRef.current = rawMetaRef.current
}
if (onFirstHeadingChange && !equal(firstHeadingRef.current, oldFirstHeadingRef.current)) {
onFirstHeadingChange(firstHeadingRef.current || undefined)
oldFirstHeadingRef.current = firstHeadingRef.current
}
})
const markdownIt = useMemo(() => {
const md = new MarkdownIt('default', {
@ -70,6 +100,32 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, cla
langPrefix: '',
typographer: true
})
if (onFirstHeadingChange) {
md.core.ruler.after('normalize', 'extract first L1 heading', (state) => {
const lines = state.src.split('\n')
const linkAltTextRegex = /!?\[([^\]]*)]\([^)]*\)/
for (const line of lines) {
if (line.startsWith('# ')) {
firstHeadingRef.current = line.replace('# ', '').replace(linkAltTextRegex, '$1')
return true
}
}
firstHeadingRef.current = undefined
return true
})
}
if (onMetaDataChange) {
md.use(frontmatter, (rawMeta: string) => {
try {
const meta: RawYAMLMetadata = yaml.safeLoad(rawMeta) as RawYAMLMetadata
setYamlError(false)
rawMetaRef.current = meta
} catch (e) {
console.error(e)
setYamlError(true)
}
})
}
md.use(taskList)
md.use(emoji)
md.use(abbreviation)
@ -79,6 +135,19 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, cla
md.use(inserted)
md.use(marked)
md.use(footnote)
if (onMetaDataChange) {
md.use(frontmatter, (rawMeta: string) => {
try {
const meta: RawYAMLMetadata = yaml.safeLoad(rawMeta) as RawYAMLMetadata
setYamlError(false)
rawMetaRef.current = meta
} catch (e) {
console.error(e)
setYamlError(true)
rawMetaRef.current = ({} as RawYAMLMetadata)
}
})
}
md.use(imsize)
// noinspection CheckTagEmptyBody
md.use(anchor, {
@ -126,7 +195,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, cla
})
return md
}, [])
}, [onMetaDataChange, onFirstHeadingChange])
useEffect(() => {
if (onTocChange && tocAst && !equal(tocAst, lastTocAst)) {
@ -155,6 +224,10 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, cla
new QuoteOptionsReplacer(),
new MathjaxReplacer()
]
if (onMetaDataChange) {
// This is used if the front-matter callback is never called, because the user deleted everything regarding metadata from the document
rawMetaRef.current = undefined
}
const html: string = markdownIt.render(content)
const transform: Transform = (node, index) => {
@ -162,10 +235,17 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, cla
return tryToReplaceNode(node, index, allReplacers, subNodeConverter) || convertNodeToElement(node, index, transform)
}
return ReactHtmlParser(html, { transform: transform })
}, [content, markdownIt])
}, [content, markdownIt, onMetaDataChange])
return (
<div className={`markdown-body ${className || ''} d-flex flex-column align-items-center ${wide ? 'wider' : ''}`}>
<ShowIf condition={yamlError}>
<Alert variant='warning' dir='auto'>
<Trans i18nKey='editor.invalidYaml'>
<InternalLink text='yaml-metadata' href='/n/yaml-metadata' className='text-dark'/>
</Trans>
</Alert>
</ShowIf>
<MathJaxReact.Provider>
{result}
</MathJaxReact.Provider>

View file

@ -6,13 +6,16 @@ import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon'
import { ShowIf } from '../../common/show-if/show-if'
import { MarkdownRenderer } from '../markdown-renderer/markdown-renderer'
import { MarkdownToc } from '../markdown-toc/markdown-toc'
import { YAMLMetaData } from '../yaml-metadata/yaml-metadata'
interface RenderWindowProps {
content: string
onMetadataChange: (metaData: YAMLMetaData | undefined) => void
onFirstHeadingChange: (firstHeading: string | undefined) => void
wide?: boolean
}
export const MarkdownRenderWindow: React.FC<RenderWindowProps> = ({ content, wide }) => {
export const MarkdownRenderWindow: React.FC<RenderWindowProps> = ({ content, onMetadataChange, onFirstHeadingChange, wide }) => {
const [tocAst, setTocAst] = useState<TocAst>()
const renderer = useRef<HTMLDivElement>(null)
const { width } = useResizeObserver({ ref: renderer })
@ -26,7 +29,10 @@ export const MarkdownRenderWindow: React.FC<RenderWindowProps> = ({ content, wid
className={'flex-fill'}
content={content}
wide={wide}
onTocChange={(tocAst) => setTocAst(tocAst)}/>
onTocChange={(tocAst) => setTocAst(tocAst)}
onMetaDataChange={onMetadataChange}
onFirstHeadingChange={onFirstHeadingChange}
/>
<div className={`col-md d-flex flex-column ${realWidth < 1280 ? 'justify-content-end' : ''}`}>
<ShowIf condition={realWidth >= 1280 && !!tocAst}>

View file

@ -0,0 +1,203 @@
import yaml from 'js-yaml'
import MarkdownIt from 'markdown-it'
import frontmatter from 'markdown-it-front-matter'
import { RawYAMLMetadata, YAMLMetaData } from './yaml-metadata'
describe('yaml tests', () => {
let raw: RawYAMLMetadata | undefined
let finished: YAMLMetaData | undefined
const md = new MarkdownIt('default', {
html: true,
breaks: true,
langPrefix: '',
typographer: true
})
md.use(frontmatter, (rawMeta: string) => {
raw = yaml.safeLoad(rawMeta) as RawYAMLMetadata
finished = new YAMLMetaData(raw)
})
// generate default YAMLMetadata
md.render('---\n---')
const defaultYAML = finished
const testMetadata = (input: string, expectedRaw: Partial<RawYAMLMetadata>, expectedFinished: Partial<YAMLMetaData>) => {
md.render(input)
expect(raw).not.toBe(undefined)
expect(raw).toEqual(expectedRaw)
expect(finished).not.toBe(undefined)
expect(finished).toEqual({
...defaultYAML,
...expectedFinished
})
}
beforeEach(() => {
raw = undefined
finished = undefined
})
it('title only', () => {
testMetadata(`---
title: test
___
`,
{
title: 'test'
},
{
title: 'test'
})
})
it('robots only', () => {
testMetadata(`---
robots: index, follow
___
`,
{
robots: 'index, follow'
},
{
robots: 'index, follow'
})
})
it('tags only', () => {
testMetadata(`---
tags: test123, abc
___
`,
{
tags: 'test123, abc'
},
{
tags: ['test123', 'abc']
})
})
it('breaks only', () => {
testMetadata(`---
breaks: false
___
`,
{
breaks: false
},
{
breaks: false
})
})
/*
it('slideOptions nothing', () => {
testMetadata(`---
slideOptions:
___
`,
{
slideOptions: null
},
{
slideOptions: {
theme: 'white',
transition: 'none'
}
})
})
it('slideOptions.theme only', () => {
testMetadata(`---
slideOptions:
theme: sky
___
`,
{
slideOptions: {
theme: 'sky',
transition: undefined
}
},
{
slideOptions: {
theme: 'sky',
transition: 'none'
}
})
})
it('slideOptions full', () => {
testMetadata(`---
slideOptions:
transition: zoom
theme: sky
___
`,
{
slideOptions: {
theme: 'sky',
transition: 'zoom'
}
},
{
slideOptions: {
theme: 'sky',
transition: 'zoom'
}
})
})
*/
it('opengraph nothing', () => {
testMetadata(`---
opengraph:
___
`,
{
opengraph: null
},
{
opengraph: new Map<string, string>()
})
})
it('opengraph title only', () => {
testMetadata(`---
opengraph:
title: Testtitle
___
`,
{
opengraph: {
title: 'Testtitle'
}
},
{
opengraph: new Map<string, string>(Object.entries({ title: 'Testtitle' }))
})
})
it('opengraph more attributes', () => {
testMetadata(`---
opengraph:
title: Testtitle
image: https://dummyimage.com/48.png
image:type: image/png
___
`,
{
opengraph: {
title: 'Testtitle',
image: 'https://dummyimage.com/48.png',
'image:type': 'image/png'
}
},
{
opengraph: new Map<string, string>(Object.entries({
title: 'Testtitle',
image: 'https://dummyimage.com/48.png',
'image:type': 'image/png'
}))
})
})
})

View file

@ -0,0 +1,53 @@
// import { RevealOptions } from 'reveal.js'
type iso6391 = 'aa' | 'ab' | 'af' | 'am' | 'ar' | 'ar-ae' | 'ar-bh' | 'ar-dz' | 'ar-eg' | 'ar-iq' | 'ar-jo' | 'ar-kw' | 'ar-lb' | 'ar-ly' | 'ar-ma' | 'ar-om' | 'ar-qa' | 'ar-sa' | 'ar-sy' | 'ar-tn' | 'ar-ye' | 'as' | 'ay' | 'de-at' | 'de-ch' | 'de-li' | 'de-lu' | 'div' | 'dz' | 'el' | 'en' | 'en-au' | 'en-bz' | 'en-ca' | 'en-gb' | 'en-ie' | 'en-jm' | 'en-nz' | 'en-ph' | 'en-tt' | 'en-us' | 'en-za' | 'en-zw' | 'eo' | 'es' | 'es-ar' | 'es-bo' | 'es-cl' | 'es-co' | 'es-cr' | 'es-do' | 'es-ec' | 'es-es' | 'es-gt' | 'es-hn' | 'es-mx' | 'es-ni' | 'es-pa' | 'es-pe' | 'es-pr' | 'es-py' | 'es-sv' | 'es-us' | 'es-uy' | 'es-ve' | 'et' | 'eu' | 'fa' | 'fi' | 'fj' | 'fo' | 'fr' | 'fr-be' | 'fr-ca' | 'fr-ch' | 'fr-lu' | 'fr-mc' | 'fy' | 'ga' | 'gd' | 'gl' | 'gn' | 'gu' | 'ha' | 'he' | 'hi' | 'hr' | 'hu' | 'hy' | 'ia' | 'id' | 'ie' | 'ik' | 'in' | 'is' | 'it' | 'it-ch' | 'iw' | 'ja' | 'ji' | 'jw' | 'ka' | 'kk' | 'kl' | 'km' | 'kn' | 'ko' | 'kok' | 'ks' | 'ku' | 'ky' | 'kz' | 'la' | 'ln' | 'lo' | 'ls' | 'lt' | 'lv' | 'mg' | 'mi' | 'mk' | 'ml' | 'mn' | 'mo' | 'mr' | 'ms' | 'mt' | 'my' | 'na' | 'nb-no' | 'ne' | 'nl' | 'nl-be' | 'nn-no' | 'no' | 'oc' | 'om' | 'or' | 'pa' | 'pl' | 'ps' | 'pt' | 'pt-br' | 'qu' | 'rm' | 'rn' | 'ro' | 'ro-md' | 'ru' | 'ru-md' | 'rw' | 'sa' | 'sb' | 'sd' | 'sg' | 'sh' | 'si' | 'sk' | 'sl' | 'sm' | 'sn' | 'so' | 'sq' | 'sr' | 'ss' | 'st' | 'su' | 'sv' | 'sv-fi' | 'sw' | 'sx' | 'syr' | 'ta' | 'te' | 'tg' | 'th' | 'ti' | 'tk' | 'tl' | 'tn' | 'to' | 'tr' | 'ts' | 'tt' | 'tw' | 'uk' | 'ur' | 'us' | 'uz' | 'vi' | 'vo' | 'wo' | 'xh' | 'yi' | 'yo' | 'zh' | 'zh-cn' | 'zh-hk' | 'zh-mo' | 'zh-sg' | 'zh-tw' | 'zu'
export interface RawYAMLMetadata {
title: string | undefined
description: string | undefined
tags: string | undefined
robots: string | undefined
lang: string | undefined
dir: string | undefined
breaks: boolean | undefined
GA: string | undefined
disqus: string | undefined
type: string | undefined
slideOptions: any
opengraph: any
}
export class YAMLMetaData {
title: string
description: string
tags: string[]
robots: string
lang: iso6391
dir: 'ltr' | 'rtl'
breaks: boolean
GA: string
disqus: string
type: 'slide' | ''
// slideOptions: RevealOptions
opengraph: Map<string, string>
constructor (rawData: RawYAMLMetadata) {
this.title = rawData?.title ?? ''
this.description = rawData?.description ?? ''
this.robots = rawData?.robots ?? ''
this.breaks = rawData?.breaks ?? true
this.GA = rawData?.GA ?? ''
this.disqus = rawData?.disqus ?? ''
this.type = (rawData?.type as YAMLMetaData['type']) ?? ''
this.lang = (rawData?.lang as iso6391) ?? 'en'
this.dir = (rawData?.dir as YAMLMetaData['dir']) ?? 'ltr'
/* this.slideOptions = (rawData?.slideOptions as RevealOptions) ?? {
transition: 'none',
theme: 'white'
} */
this.tags = rawData?.tags?.split(',').map(entry => entry.trim()) ?? []
this.opengraph = rawData?.opengraph ? new Map<string, string>(Object.entries(rawData.opengraph)) : new Map<string, string>()
}
}

View file

@ -0,0 +1,5 @@
declare module 'markdown-it-front-matter' {
import MarkdownIt from 'markdown-it/lib'
const markdownItFrontMatter: MarkdownIt.PluginSimple
export = markdownItFrontMatter
}