From db43e1db3f558d9ebf1604b1e095220e1a50937f Mon Sep 17 00:00:00 2001 From: Tilman Vatteroth Date: Fri, 19 May 2023 11:49:29 +0200 Subject: [PATCH] refactor: move frontmatter parser into commons package Signed-off-by: Tilman Vatteroth --- commons/package.json | 1 + .../frontmatter-extractor/extractor.spec.ts | 0 .../frontmatter-extractor/extractor.ts | 0 .../frontmatter-extractor/types.ts | 0 commons/src/index.ts | 16 +- ...aw-frontmatter-to-note-frontmatter.spec.ts | 51 +++++++ ...ert-raw-frontmatter-to-note-frontmatter.ts | 30 ++++ .../note-frontmatter-parser/default-values.ts | 33 +++++ .../parse-raw-frontmatter-from-yaml.spec.ts | 76 ++++++++++ .../parse-raw-frontmatter-from-yaml.ts | 94 ++++++++++++ .../parse-tags.spec.ts | 31 ++++ .../src/note-frontmatter-parser/parse-tags.ts | 17 +++ commons/src/note-frontmatter-parser/types.ts | 26 ++++ .../types => note-frontmatter}/frontmatter.ts | 2 +- .../types => note-frontmatter}/iso6391.ts | 1 + .../slide-show-options.ts | 2 +- .../generate-note-title.spec.ts | 16 +- .../title-extraction/generate-note-title.ts | 20 +-- .../editor-pane/linter/frontmatter-linter.ts | 65 ++++---- ...ild-state-from-updated-markdown-content.ts | 52 ++++--- .../src/redux/note-details/initial-state.ts | 25 +--- .../parser.spec.ts | 64 -------- .../raw-note-frontmatter-parser/parser.ts | 139 ------------------ .../raw-note-frontmatter-parser/types.d.ts | 19 --- .../build-state-from-first-heading-update.ts | 2 +- yarn.lock | 1 + 26 files changed, 462 insertions(+), 321 deletions(-) rename commons/src/{title-extraction => }/frontmatter-extractor/extractor.spec.ts (100%) rename commons/src/{title-extraction => }/frontmatter-extractor/extractor.ts (100%) rename commons/src/{title-extraction => }/frontmatter-extractor/types.ts (100%) create mode 100644 commons/src/note-frontmatter-parser/convert-raw-frontmatter-to-note-frontmatter.spec.ts create mode 100644 commons/src/note-frontmatter-parser/convert-raw-frontmatter-to-note-frontmatter.ts create mode 100644 commons/src/note-frontmatter-parser/default-values.ts create mode 100644 commons/src/note-frontmatter-parser/parse-raw-frontmatter-from-yaml.spec.ts create mode 100644 commons/src/note-frontmatter-parser/parse-raw-frontmatter-from-yaml.ts create mode 100644 commons/src/note-frontmatter-parser/parse-tags.spec.ts create mode 100644 commons/src/note-frontmatter-parser/parse-tags.ts create mode 100644 commons/src/note-frontmatter-parser/types.ts rename commons/src/{title-extraction/types => note-frontmatter}/frontmatter.ts (96%) rename commons/src/{title-extraction/types => note-frontmatter}/iso6391.ts (99%) rename commons/src/{title-extraction/types => note-frontmatter}/slide-show-options.ts (79%) delete mode 100644 frontend/src/redux/note-details/raw-note-frontmatter-parser/parser.spec.ts delete mode 100644 frontend/src/redux/note-details/raw-note-frontmatter-parser/parser.ts delete mode 100644 frontend/src/redux/note-details/raw-note-frontmatter-parser/types.d.ts diff --git a/commons/package.json b/commons/package.json index 3370b85b2..570a72fce 100644 --- a/commons/package.json +++ b/commons/package.json @@ -41,6 +41,7 @@ "domhandler": "5.0.3", "eventemitter2": "6.4.9", "isomorphic-ws": "5.0.0", + "joi": "17.9.2", "reveal.js": "4.5.0", "ws": "8.13.0", "yjs": "13.6.1" diff --git a/commons/src/title-extraction/frontmatter-extractor/extractor.spec.ts b/commons/src/frontmatter-extractor/extractor.spec.ts similarity index 100% rename from commons/src/title-extraction/frontmatter-extractor/extractor.spec.ts rename to commons/src/frontmatter-extractor/extractor.spec.ts diff --git a/commons/src/title-extraction/frontmatter-extractor/extractor.ts b/commons/src/frontmatter-extractor/extractor.ts similarity index 100% rename from commons/src/title-extraction/frontmatter-extractor/extractor.ts rename to commons/src/frontmatter-extractor/extractor.ts diff --git a/commons/src/title-extraction/frontmatter-extractor/types.ts b/commons/src/frontmatter-extractor/types.ts similarity index 100% rename from commons/src/title-extraction/frontmatter-extractor/types.ts rename to commons/src/frontmatter-extractor/types.ts diff --git a/commons/src/index.ts b/commons/src/index.ts index acfad12cc..c4fb2e458 100644 --- a/commons/src/index.ts +++ b/commons/src/index.ts @@ -26,11 +26,17 @@ export { waitForOtherPromisesToFinish } from './utils/wait-for-other-promises-to export { RealtimeDoc } from './y-doc-sync/realtime-doc.js' -export * from './title-extraction/frontmatter-extractor/extractor.js' -export * from './title-extraction/frontmatter-extractor/types.js' +export * from './frontmatter-extractor/extractor.js' +export * from './frontmatter-extractor/types.js' +export * from './note-frontmatter-parser/parse-raw-frontmatter-from-yaml.js' +export * from './note-frontmatter-parser/convert-raw-frontmatter-to-note-frontmatter.js' +export * from './note-frontmatter-parser/default-values.js' +export * from './note-frontmatter-parser/parse-tags.js' +export * from './note-frontmatter-parser/types.js' + export * from './title-extraction/generate-note-title.js' -export * from './title-extraction/types/iso6391.js' -export * from './title-extraction/types/frontmatter.js' -export * from './title-extraction/types/slide-show-options.js' +export * from './note-frontmatter/iso6391.js' +export * from './note-frontmatter/frontmatter.js' +export * from './note-frontmatter/slide-show-options.js' export { extractFirstHeading } from './title-extraction/extract-first-heading.js' diff --git a/commons/src/note-frontmatter-parser/convert-raw-frontmatter-to-note-frontmatter.spec.ts b/commons/src/note-frontmatter-parser/convert-raw-frontmatter-to-note-frontmatter.spec.ts new file mode 100644 index 000000000..87cde1a70 --- /dev/null +++ b/commons/src/note-frontmatter-parser/convert-raw-frontmatter-to-note-frontmatter.spec.ts @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { + NoteFrontmatter, + NoteTextDirection, + NoteType, + OpenGraph +} from '../note-frontmatter/frontmatter.js' +import { SlideOptions } from '../note-frontmatter/slide-show-options.js' +import { convertRawFrontmatterToNoteFrontmatter } from './convert-raw-frontmatter-to-note-frontmatter.js' +import { describe, expect, it } from '@jest/globals' + +describe('convertRawFrontmatterToNoteFrontmatter', () => { + it.each([false, true])( + 'returns the correct note frontmatter with `breaks: %s`', + (breaks) => { + const slideOptions: SlideOptions = {} + const opengraph: OpenGraph = {} + expect( + convertRawFrontmatterToNoteFrontmatter({ + title: 'title', + description: 'description', + robots: 'robots', + lang: 'de', + type: NoteType.DOCUMENT, + dir: NoteTextDirection.LTR, + license: 'license', + breaks: breaks, + opengraph: opengraph, + slideOptions: slideOptions, + tags: 'tags' + }) + ).toStrictEqual({ + title: 'title', + description: 'description', + robots: 'robots', + newlinesAreBreaks: breaks, + lang: 'de', + type: NoteType.DOCUMENT, + dir: NoteTextDirection.LTR, + opengraph: opengraph, + slideOptions: slideOptions, + license: 'license', + tags: ['tags'] + } as NoteFrontmatter) + } + ) +}) diff --git a/commons/src/note-frontmatter-parser/convert-raw-frontmatter-to-note-frontmatter.ts b/commons/src/note-frontmatter-parser/convert-raw-frontmatter-to-note-frontmatter.ts new file mode 100644 index 000000000..2245bb0ce --- /dev/null +++ b/commons/src/note-frontmatter-parser/convert-raw-frontmatter-to-note-frontmatter.ts @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { NoteFrontmatter } from '../note-frontmatter/frontmatter.js' +import { parseTags } from './parse-tags.js' +import { RawNoteFrontmatter } from './types.js' + +/** + * Creates a new frontmatter metadata instance based on the given raw metadata properties. + * @param rawData A {@link RawNoteFrontmatter} object containing the properties of the parsed yaml frontmatter. + */ +export const convertRawFrontmatterToNoteFrontmatter = ( + rawData: RawNoteFrontmatter +): NoteFrontmatter => { + return { + title: rawData.title, + description: rawData.description, + robots: rawData.robots, + newlinesAreBreaks: rawData.breaks, + lang: rawData.lang, + type: rawData.type, + dir: rawData.dir, + opengraph: rawData.opengraph, + slideOptions: rawData.slideOptions, + license: rawData.license, + tags: parseTags(rawData.tags) + } +} diff --git a/commons/src/note-frontmatter-parser/default-values.ts b/commons/src/note-frontmatter-parser/default-values.ts new file mode 100644 index 000000000..21874db5b --- /dev/null +++ b/commons/src/note-frontmatter-parser/default-values.ts @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { + NoteFrontmatter, + NoteTextDirection, + NoteType +} from '../note-frontmatter/frontmatter.js' +import { SlideOptions } from '../note-frontmatter/slide-show-options.js' + +export const defaultSlideOptions: SlideOptions = { + transition: 'zoom', + autoSlide: 0, + autoSlideStoppable: true, + backgroundTransition: 'fade', + slideNumber: false +} + +export const defaultNoteFrontmatter: NoteFrontmatter = { + title: '', + description: '', + tags: [], + robots: '', + lang: 'en', + dir: NoteTextDirection.LTR, + newlinesAreBreaks: true, + license: '', + type: NoteType.DOCUMENT, + opengraph: {}, + slideOptions: defaultSlideOptions +} diff --git a/commons/src/note-frontmatter-parser/parse-raw-frontmatter-from-yaml.spec.ts b/commons/src/note-frontmatter-parser/parse-raw-frontmatter-from-yaml.spec.ts new file mode 100644 index 000000000..7417f6eb1 --- /dev/null +++ b/commons/src/note-frontmatter-parser/parse-raw-frontmatter-from-yaml.spec.ts @@ -0,0 +1,76 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { parseRawFrontmatterFromYaml } from './parse-raw-frontmatter-from-yaml.js' +import { describe, expect, it } from '@jest/globals' + +describe('yaml frontmatter', () => { + it('should parse "title"', () => { + const noteFrontmatter = parseRawFrontmatterFromYaml('title: test') + expect(noteFrontmatter.value?.title).toEqual('test') + }) + + it('should parse "robots"', () => { + const noteFrontmatter = parseRawFrontmatterFromYaml('robots: index, follow') + expect(noteFrontmatter.value?.robots).toEqual('index, follow') + }) + + it('should parse the deprecated tags syntax', () => { + const noteFrontmatter = parseRawFrontmatterFromYaml('tags: test123, abc') + expect(noteFrontmatter.value?.tags).toEqual('test123, abc') + }) + + it('should parse the tags list syntax', () => { + const noteFrontmatter = parseRawFrontmatterFromYaml(`tags: + - test123 + - abc + `) + expect(noteFrontmatter.value?.tags).toEqual(['test123', 'abc']) + }) + + it('should parse the tag inline-list syntax', () => { + const noteFrontmatter = parseRawFrontmatterFromYaml( + "tags: ['test123', 'abc']" + ) + expect(noteFrontmatter.value?.tags).toEqual(['test123', 'abc']) + }) + + it('should parse "breaks"', () => { + const noteFrontmatter = parseRawFrontmatterFromYaml('breaks: false') + expect(noteFrontmatter.value?.breaks).toEqual(false) + }) + + it('should parse an opengraph title', () => { + const noteFrontmatter = parseRawFrontmatterFromYaml(`opengraph: + title: Testtitle + `) + expect(noteFrontmatter.value?.opengraph.title).toEqual('Testtitle') + }) + + it('should parse multiple opengraph values', () => { + const noteFrontmatter = parseRawFrontmatterFromYaml(`opengraph: + title: Testtitle + image: https://dummyimage.com/48.png + image:type: image/png + `) + expect(noteFrontmatter.value?.opengraph.title).toEqual('Testtitle') + expect(noteFrontmatter.value?.opengraph.image).toEqual( + 'https://dummyimage.com/48.png' + ) + expect(noteFrontmatter.value?.opengraph['image:type']).toEqual('image/png') + }) + + it('allows unknown additional options', () => { + const noteFrontmatter = parseRawFrontmatterFromYaml(`title: title +additonal: "additonal"`) + + expect(noteFrontmatter.value?.title).toBe('title') + }) + + it('throws an error if the yaml is invalid', () => { + const a = parseRawFrontmatterFromYaml('A: asd\n B: asd') + expect(a.error?.message).toStrictEqual('Invalid YAML') + }) +}) diff --git a/commons/src/note-frontmatter-parser/parse-raw-frontmatter-from-yaml.ts b/commons/src/note-frontmatter-parser/parse-raw-frontmatter-from-yaml.ts new file mode 100644 index 000000000..cb6793aa7 --- /dev/null +++ b/commons/src/note-frontmatter-parser/parse-raw-frontmatter-from-yaml.ts @@ -0,0 +1,94 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { + NoteTextDirection, + NoteType, + OpenGraph +} from '../note-frontmatter/frontmatter.js' +import { ISO6391 } from '../note-frontmatter/iso6391.js' +import { SlideOptions } from '../note-frontmatter/slide-show-options.js' +import { defaultNoteFrontmatter } from './default-values.js' +import type { RawNoteFrontmatter } from './types.js' +import type { ValidationError } from 'joi' +import Joi from 'joi' +import { load } from 'js-yaml' + +const schema = Joi.object({ + title: Joi.string().optional().default(defaultNoteFrontmatter.title), + description: Joi.string() + .optional() + .default(defaultNoteFrontmatter.description), + tags: Joi.alternatives( + Joi.array().items(Joi.string()), + Joi.string(), + Joi.number().cast('string') + ) + .optional() + .default(defaultNoteFrontmatter.tags), + robots: Joi.string().optional().default(defaultNoteFrontmatter.robots), + lang: Joi.string() + .valid(...ISO6391) + .optional() + .default(defaultNoteFrontmatter.lang), + dir: Joi.string() + .valid(...Object.values(NoteTextDirection)) + .optional() + .default(defaultNoteFrontmatter.dir), + breaks: Joi.boolean() + .optional() + .default(defaultNoteFrontmatter.newlinesAreBreaks), + license: Joi.string().optional().default(defaultNoteFrontmatter.license), + type: Joi.string() + .valid(...Object.values(NoteType)) + .optional() + .default(defaultNoteFrontmatter.type), + slideOptions: Joi.object({ + autoSlide: Joi.number().optional(), + transition: Joi.string().optional(), + backgroundTransition: Joi.string().optional(), + autoSlideStoppable: Joi.boolean().optional(), + slideNumber: Joi.boolean().optional() + }) + .optional() + .default(defaultNoteFrontmatter.slideOptions), + opengraph: Joi.object({ + title: Joi.string().optional(), + image: Joi.string().uri().optional() + }) + .unknown(true) + .optional() + .default(defaultNoteFrontmatter.opengraph) +}) + .default(defaultNoteFrontmatter) + .unknown(true) + +const loadYaml = (rawYaml: string): unknown => { + try { + return load(rawYaml) + } catch { + return undefined + } +} + +type ParserResult = + | { + error: undefined + warning?: ValidationError + value: RawNoteFrontmatter + } + | { + error: Error + warning?: ValidationError + value: undefined + } + +export const parseRawFrontmatterFromYaml = (rawYaml: string): ParserResult => { + const rawNoteFrontmatter = loadYaml(rawYaml) + if (rawNoteFrontmatter === undefined) { + return { error: new Error('Invalid YAML'), value: undefined } + } + return schema.validate(rawNoteFrontmatter, { convert: true }) +} diff --git a/commons/src/note-frontmatter-parser/parse-tags.spec.ts b/commons/src/note-frontmatter-parser/parse-tags.spec.ts new file mode 100644 index 000000000..dae438e3c --- /dev/null +++ b/commons/src/note-frontmatter-parser/parse-tags.spec.ts @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { parseTags } from './parse-tags.js' +import { expect, it, describe } from '@jest/globals' + +describe('parse tags', () => { + it('converts comma separated string tags into string list', () => { + expect(parseTags('a,b,c,d,e,f')).toStrictEqual([ + 'a', + 'b', + 'c', + 'd', + 'e', + 'f' + ]) + }) + + it('accepts a string list as tags', () => { + expect(parseTags(['a', 'b', ' c', 'd ', 'e', 'f'])).toStrictEqual([ + 'a', + 'b', + 'c', + 'd', + 'e', + 'f' + ]) + }) +}) diff --git a/commons/src/note-frontmatter-parser/parse-tags.ts b/commons/src/note-frontmatter-parser/parse-tags.ts new file mode 100644 index 000000000..c4c7d7543 --- /dev/null +++ b/commons/src/note-frontmatter-parser/parse-tags.ts @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * Parses the given value as tags array. + * + * @param rawTags The raw value to parse + * @return the parsed tags + */ +export const parseTags = (rawTags: string | string[]): string[] => { + return (Array.isArray(rawTags) ? rawTags : rawTags.split(',')) + .map((entry) => entry.trim()) + .filter((tag) => !!tag) +} diff --git a/commons/src/note-frontmatter-parser/types.ts b/commons/src/note-frontmatter-parser/types.ts new file mode 100644 index 000000000..8c6f5313f --- /dev/null +++ b/commons/src/note-frontmatter-parser/types.ts @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { + Iso6391Language, + NoteTextDirection, + NoteType, + OpenGraph +} from '../note-frontmatter/frontmatter.js' +import { SlideOptions } from '../note-frontmatter/slide-show-options.js' + +export interface RawNoteFrontmatter { + title: string + description: string + tags: string | string[] + robots: string + lang: Iso6391Language + dir: NoteTextDirection + breaks: boolean + license: string + type: NoteType + slideOptions: SlideOptions + opengraph: OpenGraph +} diff --git a/commons/src/title-extraction/types/frontmatter.ts b/commons/src/note-frontmatter/frontmatter.ts similarity index 96% rename from commons/src/title-extraction/types/frontmatter.ts rename to commons/src/note-frontmatter/frontmatter.ts index adcc2bf35..394557c84 100644 --- a/commons/src/title-extraction/types/frontmatter.ts +++ b/commons/src/note-frontmatter/frontmatter.ts @@ -16,7 +16,7 @@ export enum NoteTextDirection { } export enum NoteType { - DOCUMENT = '', + DOCUMENT = 'document', SLIDE = 'slide' } export interface NoteFrontmatter { diff --git a/commons/src/title-extraction/types/iso6391.ts b/commons/src/note-frontmatter/iso6391.ts similarity index 99% rename from commons/src/title-extraction/types/iso6391.ts rename to commons/src/note-frontmatter/iso6391.ts index 9ebb248c5..9f5f18252 100644 --- a/commons/src/title-extraction/types/iso6391.ts +++ b/commons/src/note-frontmatter/iso6391.ts @@ -28,6 +28,7 @@ export const ISO6391 = [ 'ar-ye', 'as', 'ay', + 'de', 'de-at', 'de-ch', 'de-li', diff --git a/commons/src/title-extraction/types/slide-show-options.ts b/commons/src/note-frontmatter/slide-show-options.ts similarity index 79% rename from commons/src/title-extraction/types/slide-show-options.ts rename to commons/src/note-frontmatter/slide-show-options.ts index fffa9b6aa..a53a2f55d 100644 --- a/commons/src/title-extraction/types/slide-show-options.ts +++ b/commons/src/note-frontmatter/slide-show-options.ts @@ -12,4 +12,4 @@ type WantedRevealOptions = | 'backgroundTransition' | 'slideNumber' -export type SlideOptions = Required> +export type SlideOptions = Pick diff --git a/commons/src/title-extraction/generate-note-title.spec.ts b/commons/src/title-extraction/generate-note-title.spec.ts index 7abb3ad12..97de95e80 100644 --- a/commons/src/title-extraction/generate-note-title.spec.ts +++ b/commons/src/title-extraction/generate-note-title.spec.ts @@ -3,9 +3,12 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ +import { + NoteFrontmatter, + NoteTextDirection, + NoteType +} from '../note-frontmatter/frontmatter.js' import { generateNoteTitle } from './generate-note-title.js' -import { NoteFrontmatter, NoteTextDirection } from './types/frontmatter.js' -import { NoteType } from './types/frontmatter.js' import { describe, expect, it } from '@jest/globals' const testFrontmatter: NoteFrontmatter = { @@ -36,7 +39,7 @@ describe('generate note title', () => { title: 'frontmatter', opengraph: { title: 'opengraph' } }, - 'first-heading' + () => 'first-heading' ) expect(actual).toEqual('frontmatter') }) @@ -44,13 +47,16 @@ describe('generate note title', () => { it('will choose the opengraph title second', () => { const actual = generateNoteTitle( { ...testFrontmatter, opengraph: { title: 'opengraph' } }, - 'first-heading' + () => 'first-heading' ) expect(actual).toEqual('opengraph') }) it('will choose the first heading third', () => { - const actual = generateNoteTitle({ ...testFrontmatter }, 'first-heading') + const actual = generateNoteTitle( + { ...testFrontmatter }, + () => 'first-heading' + ) expect(actual).toEqual('first-heading') }) }) diff --git a/commons/src/title-extraction/generate-note-title.ts b/commons/src/title-extraction/generate-note-title.ts index 90a6d0340..56badf0ae 100644 --- a/commons/src/title-extraction/generate-note-title.ts +++ b/commons/src/title-extraction/generate-note-title.ts @@ -3,28 +3,24 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import type { NoteFrontmatter } from './types/frontmatter.js' +import type { NoteFrontmatter } from '../note-frontmatter/frontmatter.js' /** * Generates the note title from the given frontmatter or the first heading in the markdown content. * * @param frontmatter The frontmatter of the note - * @param firstHeading The first heading in the markdown content + * @param firstHeadingProvider A function that provides the first heading of the markdown content * @return The title from the frontmatter or, if no title is present in the frontmatter, the first heading. */ export const generateNoteTitle = ( - frontmatter: NoteFrontmatter, - firstHeading?: string + frontmatter: NoteFrontmatter | undefined, + firstHeadingProvider: () => string | undefined ): string => { - if (frontmatter?.title && frontmatter?.title !== '') { + if (frontmatter?.title) { return frontmatter.title.trim() - } else if ( - frontmatter?.opengraph && - frontmatter?.opengraph.title !== undefined && - frontmatter?.opengraph.title !== '' - ) { - return (frontmatter?.opengraph.title ?? firstHeading ?? '').trim() + } else if (frontmatter?.opengraph.title) { + return (frontmatter?.opengraph.title ?? firstHeadingProvider() ?? '').trim() } else { - return (firstHeading ?? '').trim() + return (firstHeadingProvider() ?? '').trim() } } diff --git a/frontend/src/components/editor-page/editor-pane/linter/frontmatter-linter.ts b/frontend/src/components/editor-page/editor-pane/linter/frontmatter-linter.ts index bbafb5335..9eb567e55 100644 --- a/frontend/src/components/editor-page/editor-pane/linter/frontmatter-linter.ts +++ b/frontend/src/components/editor-page/editor-pane/linter/frontmatter-linter.ts @@ -3,13 +3,11 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import type { RawNoteFrontmatter } from '../../../../redux/note-details/raw-note-frontmatter-parser/types' import type { Linter } from './linter' import type { Diagnostic } from '@codemirror/lint' import type { EditorView } from '@codemirror/view' -import { extractFrontmatter } from '@hedgedoc/commons' +import { extractFrontmatter, parseRawFrontmatterFromYaml, parseTags } from '@hedgedoc/commons' import { t } from 'i18next' -import { load } from 'js-yaml' /** * Creates a {@link Linter linter} for the yaml frontmatter. @@ -23,27 +21,42 @@ export class FrontmatterLinter implements Linter { if (frontmatterExtraction === undefined) { return [] } - const startOfYaml = lines[0].length + 1 const frontmatterLines = lines.slice(1, frontmatterExtraction.lineOffset - 1) - const rawNoteFrontmatter = FrontmatterLinter.loadYaml(frontmatterExtraction.rawText) - if (rawNoteFrontmatter === undefined) { - return [ - { - from: startOfYaml, - to: startOfYaml + frontmatterLines.join('\n').length, - message: t('editor.linter.frontmatter'), - severity: 'error' - } - ] + const startOfYaml = lines[0].length + 1 + const endOfYaml = startOfYaml + frontmatterLines.join('\n').length + const rawNoteFrontmatter = parseRawFrontmatterFromYaml(frontmatterExtraction.rawText) + if (rawNoteFrontmatter.error) { + return this.createErrorDiagnostics(startOfYaml, endOfYaml, rawNoteFrontmatter.error, 'error') + } else if (rawNoteFrontmatter.warning) { + return this.createErrorDiagnostics(startOfYaml, endOfYaml, rawNoteFrontmatter.warning, 'warning') + } else if (!Array.isArray(rawNoteFrontmatter.value.tags)) { + return this.createReplaceSingleStringTagsDiagnostic(rawNoteFrontmatter.value.tags, frontmatterLines, startOfYaml) } - if (typeof rawNoteFrontmatter.tags !== 'string' && typeof rawNoteFrontmatter.tags !== 'number') { - return [] - } - const tags: string[] = - rawNoteFrontmatter?.tags - .toString() - .split(',') - .map((entry) => entry.trim()) ?? [] + return [] + } + + private createErrorDiagnostics( + startOfYaml: number, + endOfYaml: number, + error: Error, + severity: 'error' | 'warning' + ): Diagnostic[] { + return [ + { + from: startOfYaml, + to: endOfYaml, + message: error.message, + severity: severity + } + ] + } + + private createReplaceSingleStringTagsDiagnostic( + rawTags: string, + frontmatterLines: string[], + startOfYaml: number + ): Diagnostic[] { + const tags: string[] = parseTags(rawTags) const replacedText = 'tags:\n- ' + tags.join('\n- ') const tagsLineIndex = frontmatterLines.findIndex((value) => value.startsWith('tags: ')) const linesBeforeTagsLine = frontmatterLines.slice(0, tagsLineIndex) @@ -68,12 +81,4 @@ export class FrontmatterLinter implements Linter { } ] } - - private static loadYaml(raw: string): RawNoteFrontmatter | undefined { - try { - return load(raw) as RawNoteFrontmatter - } catch { - return undefined - } - } } diff --git a/frontend/src/redux/note-details/build-state-from-updated-markdown-content.ts b/frontend/src/redux/note-details/build-state-from-updated-markdown-content.ts index 07e07e6fa..1ce7acb0a 100644 --- a/frontend/src/redux/note-details/build-state-from-updated-markdown-content.ts +++ b/frontend/src/redux/note-details/build-state-from-updated-markdown-content.ts @@ -5,10 +5,15 @@ */ import { calculateLineStartIndexes } from './calculate-line-start-indexes' import { initialState } from './initial-state' -import { createNoteFrontmatterFromYaml } from './raw-note-frontmatter-parser/parser' import type { NoteDetails } from './types/note-details' -import { extractFrontmatter, generateNoteTitle } from '@hedgedoc/commons' -import type { FrontmatterExtractionResult } from '@hedgedoc/commons' +import type { FrontmatterExtractionResult, NoteFrontmatter } from '@hedgedoc/commons' +import { + convertRawFrontmatterToNoteFrontmatter, + extractFrontmatter, + generateNoteTitle, + parseRawFrontmatterFromYaml +} from '@hedgedoc/commons' +import { Optional } from '@mrdrogdrog/optional' /** * Copies a {@link NoteDetails} but with another markdown content. @@ -62,7 +67,7 @@ const buildStateFromMarkdownContentAndLines = ( }, startOfContentLineOffset: 0, rawFrontmatter: '', - title: generateNoteTitle(initialState.frontmatter, state.firstHeading), + title: generateNoteTitle(initialState.frontmatter, () => state.firstHeading), frontmatter: initialState.frontmatter } } @@ -81,22 +86,27 @@ const buildStateFromFrontmatterUpdate = ( if (frontmatterExtraction.rawText === state.rawFrontmatter) { return state } - try { - const frontmatter = createNoteFrontmatterFromYaml(frontmatterExtraction.rawText) - return { - ...state, - rawFrontmatter: frontmatterExtraction.rawText, - frontmatter: frontmatter, - title: generateNoteTitle(frontmatter, state.firstHeading), - startOfContentLineOffset: frontmatterExtraction.lineOffset - } - } catch (e) { - return { - ...state, - title: generateNoteTitle(initialState.frontmatter, state.firstHeading), - rawFrontmatter: frontmatterExtraction.rawText, - frontmatter: initialState.frontmatter, - startOfContentLineOffset: frontmatterExtraction.lineOffset - } + return buildStateFromFrontmatter(state, parseFrontmatter(frontmatterExtraction), frontmatterExtraction) +} + +const parseFrontmatter = (frontmatterExtraction: FrontmatterExtractionResult) => { + return Optional.of(parseRawFrontmatterFromYaml(frontmatterExtraction.rawText)) + .filter((frontmatter) => frontmatter.error === undefined) + .map((frontmatter) => frontmatter.value) + .map((value) => convertRawFrontmatterToNoteFrontmatter(value)) + .orElse(initialState.frontmatter) +} + +const buildStateFromFrontmatter = ( + state: NoteDetails, + noteFrontmatter: NoteFrontmatter, + frontmatterExtraction: FrontmatterExtractionResult +) => { + return { + ...state, + title: generateNoteTitle(noteFrontmatter, () => state.firstHeading), + rawFrontmatter: frontmatterExtraction.rawText, + frontmatter: noteFrontmatter, + startOfContentLineOffset: frontmatterExtraction.lineOffset } } diff --git a/frontend/src/redux/note-details/initial-state.ts b/frontend/src/redux/note-details/initial-state.ts index d5024444e..e65bb3c4c 100644 --- a/frontend/src/redux/note-details/initial-state.ts +++ b/frontend/src/redux/note-details/initial-state.ts @@ -4,16 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import type { NoteDetails } from './types/note-details' -import { NoteTextDirection, NoteType } from '@hedgedoc/commons' -import type { SlideOptions } from '@hedgedoc/commons' - -export const initialSlideOptions: SlideOptions = { - transition: 'zoom', - autoSlide: 0, - autoSlideStoppable: true, - backgroundTransition: 'fade', - slideNumber: false -} +import { defaultNoteFrontmatter } from '@hedgedoc/commons' export const initialState: NoteDetails = { updateUsername: null, @@ -40,17 +31,5 @@ export const initialState: NoteDetails = { editedBy: [], title: '', firstHeading: '', - frontmatter: { - title: '', - description: '', - tags: [], - robots: '', - lang: 'en', - dir: NoteTextDirection.LTR, - newlinesAreBreaks: true, - license: '', - type: NoteType.DOCUMENT, - opengraph: {}, - slideOptions: initialSlideOptions - } + frontmatter: defaultNoteFrontmatter } diff --git a/frontend/src/redux/note-details/raw-note-frontmatter-parser/parser.spec.ts b/frontend/src/redux/note-details/raw-note-frontmatter-parser/parser.spec.ts deleted file mode 100644 index 6706b14b5..000000000 --- a/frontend/src/redux/note-details/raw-note-frontmatter-parser/parser.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { createNoteFrontmatterFromYaml } from './parser' - -describe('yaml frontmatter', () => { - it('should parse "title"', () => { - const noteFrontmatter = createNoteFrontmatterFromYaml('title: test') - expect(noteFrontmatter.title).toEqual('test') - }) - - it('should parse "robots"', () => { - const noteFrontmatter = createNoteFrontmatterFromYaml('robots: index, follow') - expect(noteFrontmatter.robots).toEqual('index, follow') - }) - - it('should parse the deprecated tags syntax', () => { - const noteFrontmatter = createNoteFrontmatterFromYaml('tags: test123, abc') - expect(noteFrontmatter.tags).toEqual(['test123', 'abc']) - }) - - it('should parse the tags list syntax', () => { - const noteFrontmatter = createNoteFrontmatterFromYaml(`tags: - - test123 - - abc - `) - expect(noteFrontmatter.tags).toEqual(['test123', 'abc']) - }) - - it('should parse the tag inline-list syntax', () => { - const noteFrontmatter = createNoteFrontmatterFromYaml("tags: ['test123', 'abc']") - expect(noteFrontmatter.tags).toEqual(['test123', 'abc']) - }) - - it('should parse "breaks"', () => { - const noteFrontmatter = createNoteFrontmatterFromYaml('breaks: false') - expect(noteFrontmatter.newlinesAreBreaks).toEqual(false) - }) - - it('should parse an empty opengraph object', () => { - const noteFrontmatter = createNoteFrontmatterFromYaml('opengraph:') - expect(noteFrontmatter.opengraph).toEqual({}) - }) - - it('should parse an opengraph title', () => { - const noteFrontmatter = createNoteFrontmatterFromYaml(`opengraph: - title: Testtitle - `) - expect(noteFrontmatter.opengraph.title).toEqual('Testtitle') - }) - - it('should parse multiple opengraph values', () => { - const noteFrontmatter = createNoteFrontmatterFromYaml(`opengraph: - title: Testtitle - image: https://dummyimage.com/48.png - image:type: image/png - `) - expect(noteFrontmatter.opengraph.title).toEqual('Testtitle') - expect(noteFrontmatter.opengraph.image).toEqual('https://dummyimage.com/48.png') - expect(noteFrontmatter.opengraph['image:type']).toEqual('image/png') - }) -}) diff --git a/frontend/src/redux/note-details/raw-note-frontmatter-parser/parser.ts b/frontend/src/redux/note-details/raw-note-frontmatter-parser/parser.ts deleted file mode 100644 index 2c69092ff..000000000 --- a/frontend/src/redux/note-details/raw-note-frontmatter-parser/parser.ts +++ /dev/null @@ -1,139 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { initialSlideOptions, initialState } from '../initial-state' -import type { RawNoteFrontmatter } from './types' -import type { Iso6391Language, NoteFrontmatter, OpenGraph, SlideOptions } from '@hedgedoc/commons' -import { ISO6391, NoteTextDirection, NoteType } from '@hedgedoc/commons' -import { load } from 'js-yaml' - -/** - * Creates a new frontmatter metadata instance based on a raw yaml string. - * @param rawYaml The frontmatter content in yaml format. - * @throws Error when the content string is invalid yaml. - * @return Frontmatter metadata instance containing the parsed properties from the yaml content. - */ -export const createNoteFrontmatterFromYaml = (rawYaml: string): NoteFrontmatter => { - const rawNoteFrontmatter = load(rawYaml) as RawNoteFrontmatter - return parseRawNoteFrontmatter(rawNoteFrontmatter) -} - -/** - * Creates a new frontmatter metadata instance based on the given raw metadata properties. - * @param rawData A {@link RawNoteFrontmatter} object containing the properties of the parsed yaml frontmatter. - */ -const parseRawNoteFrontmatter = (rawData: RawNoteFrontmatter): NoteFrontmatter => { - let tags: string[] - if (typeof rawData?.tags === 'string') { - tags = rawData?.tags?.split(',').map((entry) => entry.trim()) ?? [] - } else if (typeof rawData?.tags === 'object') { - tags = rawData?.tags?.filter((tag) => tag !== null) ?? [] - } else { - tags = [...initialState.frontmatter.tags] - } - - return { - title: rawData.title ?? initialState.frontmatter.title, - description: rawData.description ?? initialState.frontmatter.description, - robots: rawData.robots ?? initialState.frontmatter.robots, - newlinesAreBreaks: parseBoolean(rawData.breaks) ?? initialState.frontmatter.newlinesAreBreaks, - lang: parseLanguage(rawData), - type: parseNoteType(rawData), - dir: parseTextDirection(rawData), - opengraph: parseOpenGraph(rawData), - slideOptions: parseSlideOptions(rawData), - license: rawData.license ?? initialState.frontmatter.license, - tags - } -} - -/** - * Parses the {@link OpenGraph open graph} from the {@link RawNoteFrontmatter}. - * - * @param rawData The raw note frontmatter data. - * @return the parsed {@link OpenGraph open graph} - */ -const parseOpenGraph = (rawData: RawNoteFrontmatter): OpenGraph => { - return { ...(rawData.opengraph ?? initialState.frontmatter.opengraph) } -} - -/** - * Parses the {@link Iso6391Language iso 6391 language code} from the {@link RawNoteFrontmatter}. - * - * @param rawData The raw note frontmatter data. - * @return the parsed {@link Iso6391Language iso 6391 language code} - */ -const parseLanguage = (rawData: RawNoteFrontmatter): Iso6391Language => { - return (rawData.lang ? ISO6391.find((lang) => lang === rawData.lang) : undefined) ?? initialState.frontmatter.lang -} - -/** - * Parses the {@link NoteType note type} from the {@link RawNoteFrontmatter}. - * - * @param rawData The raw note frontmatter data. - * @return the parsed {@link NoteType note type} - */ -const parseNoteType = (rawData: RawNoteFrontmatter): NoteType => { - return rawData.type !== undefined - ? rawData.type === NoteType.SLIDE - ? NoteType.SLIDE - : NoteType.DOCUMENT - : initialState.frontmatter.type -} - -/** - * Parses the {@link NoteTextDirection note text direction} from the {@link RawNoteFrontmatter}. - * - * @param rawData The raw note frontmatter data. - * @return the parsed {@link NoteTextDirection note text direction} - */ -const parseTextDirection = (rawData: RawNoteFrontmatter): NoteTextDirection => { - return rawData.dir !== undefined - ? rawData.dir === NoteTextDirection.LTR - ? NoteTextDirection.LTR - : NoteTextDirection.RTL - : initialState.frontmatter.dir -} - -/** - * Parses the {@link SlideOptions} from the {@link RawNoteFrontmatter}. - * - * @param rawData The raw note frontmatter data. - * @return the parsed slide options - */ -const parseSlideOptions = (rawData: RawNoteFrontmatter): SlideOptions => { - const rawSlideOptions = rawData?.slideOptions - return { - autoSlide: parseNumber(rawSlideOptions?.autoSlide) ?? initialSlideOptions.autoSlide, - transition: rawSlideOptions?.transition ?? initialSlideOptions.transition, - backgroundTransition: rawSlideOptions?.backgroundTransition ?? initialSlideOptions.backgroundTransition, - autoSlideStoppable: parseBoolean(rawSlideOptions?.autoSlideStoppable) ?? initialSlideOptions.autoSlideStoppable, - slideNumber: parseBoolean(rawSlideOptions?.slideNumber) ?? initialSlideOptions.slideNumber - } -} - -/** - * Parses an unknown variable into a boolean. - * - * @param rawData The raw data - * @return The parsed boolean or undefined if it's not possible to parse the data. - */ -const parseBoolean = (rawData: unknown | undefined): boolean | undefined => { - return rawData === undefined ? undefined : rawData === true -} - -/** - * Parses an unknown variable into a number. - * - * @param rawData The raw data - * @return The parsed number or undefined if it's not possible to parse the data. - */ -const parseNumber = (rawData: unknown | undefined): number | undefined => { - if (rawData === undefined) { - return undefined - } - const numValue = Number(rawData) - return isNaN(numValue) ? undefined : numValue -} diff --git a/frontend/src/redux/note-details/raw-note-frontmatter-parser/types.d.ts b/frontend/src/redux/note-details/raw-note-frontmatter-parser/types.d.ts deleted file mode 100644 index db478af6a..000000000 --- a/frontend/src/redux/note-details/raw-note-frontmatter-parser/types.d.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export interface RawNoteFrontmatter { - title: string | undefined - description: string | undefined - tags: string | number | string[] | undefined - robots: string | undefined - lang: string | undefined - dir: string | undefined - breaks: boolean | undefined - license: string | undefined - type: string | undefined - slideOptions: { [key: string]: string } | null - opengraph: { [key: string]: string } | null -} diff --git a/frontend/src/redux/note-details/reducers/build-state-from-first-heading-update.ts b/frontend/src/redux/note-details/reducers/build-state-from-first-heading-update.ts index 8dc5a5832..95aa97ab2 100644 --- a/frontend/src/redux/note-details/reducers/build-state-from-first-heading-update.ts +++ b/frontend/src/redux/note-details/reducers/build-state-from-first-heading-update.ts @@ -16,6 +16,6 @@ export const buildStateFromFirstHeadingUpdate = (state: NoteDetails, firstHeadin return { ...state, firstHeading: firstHeading, - title: generateNoteTitle(state.frontmatter, firstHeading) + title: generateNoteTitle(state.frontmatter, () => firstHeading) } } diff --git a/yarn.lock b/yarn.lock index 0044fa651..b896a149f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2386,6 +2386,7 @@ __metadata: eventemitter2: 6.4.9 isomorphic-ws: 5.0.0 jest: 29.5.0 + joi: 17.9.2 microbundle: 0.15.1 prettier: 2.8.8 reveal.js: 4.5.0