From 89e441597d17f50853847f669d0ca9dff5c3d754 Mon Sep 17 00:00:00 2001 From: Erik Michelson Date: Wed, 13 May 2026 01:32:55 +0200 Subject: [PATCH] refactor(commons): frontmatter validator uses zod and allows custom options The frontmatter validator was still the one left place that used Joi instead of the now widely used zod in HedgeDoc. Since zod can do validation, coercion and providing types based on the schema, the code could be drastically reduced compared to the old frontmatter validator. At the same time, the validator is now less strict. Custom fields are still allowed for people that want to add their own frontmatter tags which are unrelated to HedgeDoc. Furthermore, we now allow the complete set of RevealOptions for the slideOptions key instead of only a few handpicked ones. Fixes #5946 Signed-off-by: Erik Michelson Signed-off-by: Philip Molares --- backend/src/explore/explore.service.spec.ts | 10 +-- ...act-revision-metadata-from-content.spec.ts | 2 +- .../extract-revision-metadata-from-content.ts | 11 ++- commons/package.json | 1 - commons/src/index.ts | 1 - commons/src/message-transporters/message.ts | 2 +- ...aw-frontmatter-to-note-frontmatter.spec.ts | 48 ----------- ...ert-raw-frontmatter-to-note-frontmatter.ts | 30 ------- commons/src/note-frontmatter-parser/index.ts | 10 --- .../parse-raw-frontmatter-from-yaml.spec.ts | 72 ----------------- .../parse-raw-frontmatter-from-yaml.ts | 76 ------------------ .../parse-tags.spec.ts | 24 ------ .../src/note-frontmatter-parser/parse-tags.ts | 17 ---- commons/src/note-frontmatter-parser/types.ts | 26 ------ .../default-note-frontmatter.ts | 28 +++++++ .../src/note-frontmatter/default-values.ts | 29 ------- commons/src/note-frontmatter/frontmatter.ts | 37 --------- commons/src/note-frontmatter/index.ts | 9 ++- .../src/note-frontmatter/note-frontmatter.ts | 58 ++++++++++++++ .../note-frontmatter/note-text-direction.ts | 9 +++ commons/src/note-frontmatter/note-type.ts | 9 +++ .../parse-note-frontmatter.spec.ts | 25 ++++++ .../parse-note-frontmatter.ts | 80 +++++++++++++++++++ .../note-frontmatter/parse-tags-field.spec.ts | 29 +++++++ .../src/note-frontmatter/parse-tags-field.ts | 26 ++++++ .../note-frontmatter/slide-show-options.ts | 15 ---- .../generate-note-title.spec.ts | 8 +- .../title-extraction/generate-note-title.ts | 2 +- ...nd-additional-configuration-to-renderer.ts | 2 +- .../linter/frontmatter-linter.spec.ts | 6 ++ .../editor-pane/linter/frontmatter-linter.ts | 37 ++++++--- .../markdown-renderer/hooks/use-reveal.ts | 4 +- .../render-page/render-page-content.tsx | 4 +- .../slideshow/slideshow-markdown-renderer.tsx | 4 +- .../rendering-message.ts | 4 +- ...ild-state-from-updated-markdown-content.ts | 20 ++--- ...ate-from-set-note-data-from-server.spec.ts | 2 +- yarn.lock | 53 ------------ 38 files changed, 337 insertions(+), 493 deletions(-) delete mode 100644 commons/src/note-frontmatter-parser/convert-raw-frontmatter-to-note-frontmatter.spec.ts delete mode 100644 commons/src/note-frontmatter-parser/convert-raw-frontmatter-to-note-frontmatter.ts delete mode 100644 commons/src/note-frontmatter-parser/index.ts delete mode 100644 commons/src/note-frontmatter-parser/parse-raw-frontmatter-from-yaml.spec.ts delete mode 100644 commons/src/note-frontmatter-parser/parse-raw-frontmatter-from-yaml.ts delete mode 100644 commons/src/note-frontmatter-parser/parse-tags.spec.ts delete mode 100644 commons/src/note-frontmatter-parser/parse-tags.ts delete mode 100644 commons/src/note-frontmatter-parser/types.ts create mode 100644 commons/src/note-frontmatter/default-note-frontmatter.ts delete mode 100644 commons/src/note-frontmatter/default-values.ts delete mode 100644 commons/src/note-frontmatter/frontmatter.ts create mode 100644 commons/src/note-frontmatter/note-frontmatter.ts create mode 100644 commons/src/note-frontmatter/note-text-direction.ts create mode 100644 commons/src/note-frontmatter/note-type.ts create mode 100644 commons/src/note-frontmatter/parse-note-frontmatter.spec.ts create mode 100644 commons/src/note-frontmatter/parse-note-frontmatter.ts create mode 100644 commons/src/note-frontmatter/parse-tags-field.spec.ts create mode 100644 commons/src/note-frontmatter/parse-tags-field.ts delete mode 100644 commons/src/note-frontmatter/slide-show-options.ts diff --git a/backend/src/explore/explore.service.spec.ts b/backend/src/explore/explore.service.spec.ts index 793925c0c..f43918307 100644 --- a/backend/src/explore/explore.service.spec.ts +++ b/backend/src/explore/explore.service.spec.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect, beforeAll, beforeEach, afterEach, jest } from '@jest/globals'; import { SortMode } from '@hedgedoc/commons'; -import { OptionalNoteType, OptionalSortMode } from '@hedgedoc/commons'; +import type { OptionalSortMode } from '@hedgedoc/commons'; import { FieldNameGroup, FieldNameNote, @@ -157,7 +157,7 @@ describe('ExploreService', () => { /select "alias"."alias" as "primaryAlias", "revision"."title" as "title", "revision"."note_type" as "noteType", "user"."username" as "ownerUsername", "note"."created_at" as "createdAt", "revision"."created_at" as "lastChangedAt", "revision"."uuid" as "revisionUuid" from "note" inner join "alias" on "alias"."note_id" = "note"."id" inner join "user" on "user"."id" = "note"."owner_id" inner join \(select "uuid", "note_id" from \(select "uuid", "note_id", row_number\(\) over \(partition by "note_id" order by "created_at" desc\) as rn from "revision"\) as "latest_revisions_per_note" where "rn" = \$1\) as "latest_revision" on "latest_revision"."note_id" = "note"."id" inner join "revision" on "revision"."note_id" = "note"."id" and "revision"."uuid" = "latest_revision"."uuid" where "alias"."is_primary" = \$2 and "note"."owner_id" = \$3 and LOWER\("revision"."title"\) LIKE \$4 order by "revision"."created_at" desc limit \$5/, [1, true, mockUserId, '%test%', ENTRIES_PER_PAGE_LIMIT], ], - ] as [string, OptionalNoteType, OptionalSortMode, string, RegExp, unknown[]][])( + ] as [string, NoteType, OptionalSortMode, string, RegExp, unknown[]][])( 'correctly get all notes owned by user with', (name, noteType, sortBy, search, regex, bindings) => { // oxlint-disable-next-line jest/valid-title @@ -226,7 +226,7 @@ describe('ExploreService', () => { /select "alias"."alias" as "primaryAlias", "revision"."title" as "title", "revision"."note_type" as "noteType", "user"."username" as "ownerUsername", "note"."created_at" as "createdAt", "revision"."created_at" as "lastChangedAt", "revision"."uuid" as "revisionUuid" from "note_user_permission" inner join "note" on "note_user_permission"."note_id" = "note"."id" inner join "alias" on "alias"."note_id" = "note"."id" inner join "user" on "user"."id" = "note"."owner_id" inner join \(select "uuid", "note_id" from \(select "uuid", "note_id", row_number\(\) over \(partition by "note_id" order by "created_at" desc\) as rn from "revision"\) as "latest_revisions_per_note" where "rn" = \$1\) as "latest_revision" on "latest_revision"."note_id" = "note"."id" inner join "revision" on "revision"."note_id" = "note"."id" and "revision"."uuid" = "latest_revision"."uuid" where "alias"."is_primary" = \$2 and "note_user_permission"."user_id" = \$3 and LOWER\("revision"."title"\) LIKE \$4 order by "revision"."created_at" desc limit \$5/, [1, true, mockUserId, '%test%', ENTRIES_PER_PAGE_LIMIT], ], - ] as [string, OptionalNoteType, OptionalSortMode, string, RegExp, unknown[]][])( + ] as [string, NoteType, OptionalSortMode, string, RegExp, unknown[]][])( 'correctly get all notes shared with the user with', (name, noteType, sortBy, search, regex, bindings) => { // oxlint-disable-next-line jest/valid-title @@ -295,7 +295,7 @@ describe('ExploreService', () => { /select "alias"."alias" as "primaryAlias", "revision"."title" as "title", "revision"."note_type" as "noteType", "user"."username" as "ownerUsername", "note"."created_at" as "createdAt", "revision"."created_at" as "lastChangedAt", "revision"."uuid" as "revisionUuid" from "note" inner join "note_group_permission" on "note"."id" = "note_group_permission"."note_id" inner join "alias" on "alias"."note_id" = "note"."id" inner join "user" on "user"."id" = "note"."owner_id" inner join \(select "uuid", "note_id" from \(select "uuid", "note_id", row_number\(\) over \(partition by "note_id" order by "created_at" desc\) as rn from "revision"\) as "latest_revisions_per_note" where "rn" = \$1\) as "latest_revision" on "latest_revision"."note_id" = "note"."id" inner join "revision" on "revision"."note_id" = "note"."id" and "revision"."uuid" = "latest_revision"."uuid" where "alias"."is_primary" = \$2 and "note_group_permission"."group_id" = \$3 and "note"."publicly_visible" = \$4 and LOWER\("revision"."title"\) LIKE \$5 order by "revision"."created_at" desc limit \$6/, [1, true, mockEveryoneGroupId, true, '%test%', ENTRIES_PER_PAGE_LIMIT], ], - ] as [string, OptionalNoteType, OptionalSortMode, string, RegExp, unknown[]][])( + ] as [string, NoteType, OptionalSortMode, string, RegExp, unknown[]][])( 'correctly get all public notes with', (name, noteType, sortBy, search, regex, bindings) => { // oxlint-disable-next-line jest/valid-title @@ -403,7 +403,7 @@ describe('ExploreService', () => { /select "alias"."alias" as "primaryAlias", "revision"."title" as "title", "revision"."note_type" as "noteType", "user"."username" as "ownerUsername", "note"."created_at" as "createdAt", "revision"."created_at" as "lastChangedAt", "revision"."uuid" as "revisionUuid", "visited_notes"."visited_at" as "lastVisitedAt", "note"."id" as "noteId" from "note" inner join "alias" on "alias"."note_id" = "note"."id" inner join "user" on "user"."id" = "note"."owner_id" inner join \(select "uuid", "note_id" from \(select "uuid", "note_id", row_number\(\) over \(partition by "note_id" order by "created_at" desc\) as rn from "revision"\) as "latest_revisions_per_note" where "rn" = \$1\) as "latest_revision" on "latest_revision"."note_id" = "note"."id" inner join "revision" on "revision"."note_id" = "note"."id" and "revision"."uuid" = "latest_revision"."uuid" left join "visited_notes" on "visited_notes"."note_id" = "note"."id" where "alias"."is_primary" = \$2 and "visited_notes"."user_id" = \$3 and LOWER\("revision"."title"\) LIKE \$4 order by "revision"."created_at" desc limit \$5/, [1, true, mockUserId, '%test%', ENTRIES_PER_PAGE_LIMIT], ], - ] as [string, OptionalNoteType, OptionalSortMode, string, RegExp, unknown[]][])( + ] as [string, NoteType, OptionalSortMode, string, RegExp, unknown[]][])( 'correctly get all notes visited by the user with', (name, noteType, sortBy, search, regex, bindings) => { // oxlint-disable-next-line jest/valid-title diff --git a/backend/src/revisions/utils/extract-revision-metadata-from-content.spec.ts b/backend/src/revisions/utils/extract-revision-metadata-from-content.spec.ts index d46d4ffff..28d9524e8 100644 --- a/backend/src/revisions/utils/extract-revision-metadata-from-content.spec.ts +++ b/backend/src/revisions/utils/extract-revision-metadata-from-content.spec.ts @@ -20,7 +20,7 @@ describe('revision entity', () => { '---\ntitle: \n - 1\n - 2\n---\nThis is a note content', ); - expect(title).toBe(''); + expect(title).toBe('1,2'); expect(description).toBe(''); expect(tags).toStrictEqual([]); }); diff --git a/backend/src/revisions/utils/extract-revision-metadata-from-content.ts b/backend/src/revisions/utils/extract-revision-metadata-from-content.ts index bd0b68409..58402d212 100644 --- a/backend/src/revisions/utils/extract-revision-metadata-from-content.ts +++ b/backend/src/revisions/utils/extract-revision-metadata-from-content.ts @@ -4,14 +4,13 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { - convertRawFrontmatterToNoteFrontmatter, defaultNoteFrontmatter, extractFirstHeading, extractFrontmatter, generateNoteTitle, - NoteFrontmatter, + type NoteFrontmatter, NoteType, - parseRawFrontmatterFromYaml, + parseNoteFrontmatter, } from '@hedgedoc/commons'; import { parseDocument } from 'htmlparser2'; import MarkdownIt from 'markdown-it'; @@ -81,11 +80,11 @@ function parseFrontmatter(content: string): FrontmatterParserResult | undefined } const firstLineOfContentIndex = extractionResult.lineOffset + 1; - const rawDataValidation = parseRawFrontmatterFromYaml(rawText); + const frontmatterParseResult = parseNoteFrontmatter(rawText); const noteFrontmatter = - rawDataValidation.error !== undefined + frontmatterParseResult.error !== undefined ? defaultNoteFrontmatter - : convertRawFrontmatterToNoteFrontmatter(rawDataValidation.value); + : frontmatterParseResult.value; return { frontmatter: noteFrontmatter, firstLineOfContentIndex: firstLineOfContentIndex, diff --git a/commons/package.json b/commons/package.json index b0acf220a..aca34d6de 100644 --- a/commons/package.json +++ b/commons/package.json @@ -46,7 +46,6 @@ "dependencies": { "domhandler": "5.0.3", "eventemitter2": "6.4.9", - "joi": "17.13.3", "js-yaml": "4.1.1", "reveal.js": "5.2.1", "ws": "8.19.0", diff --git a/commons/src/index.ts b/commons/src/index.ts index 83dd81531..ac51ff58f 100644 --- a/commons/src/index.ts +++ b/commons/src/index.ts @@ -9,7 +9,6 @@ export * from './explore-page/index.js' export * from './frontmatter-extractor/index.js' export * from './message-transporters/index.js' export * from './note-frontmatter/index.js' -export * from './note-frontmatter-parser/index.js' export * from './parse-url/index.js' export * from './permissions/index.js' export * from './title-extraction/index.js' diff --git a/commons/src/message-transporters/message.ts b/commons/src/message-transporters/message.ts index 49de1b9a8..db05f51fd 100644 --- a/commons/src/message-transporters/message.ts +++ b/commons/src/message-transporters/message.ts @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { RealtimeUser, RemoteCursor } from './realtime-user.js' +import type { RealtimeUser, RemoteCursor } from './realtime-user.js' export enum MessageType { NOTE_CONTENT_STATE_REQUEST = 'NOTE_CONTENT_STATE_REQUEST', 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 deleted file mode 100644 index abd2d1ae8..000000000 --- a/commons/src/note-frontmatter-parser/convert-raw-frontmatter-to-note-frontmatter.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 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 deleted file mode 100644 index 4d8c3b5af..000000000 --- a/commons/src/note-frontmatter-parser/convert-raw-frontmatter-to-note-frontmatter.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { NoteFrontmatter } from '../note-frontmatter/index.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/index.ts b/commons/src/note-frontmatter-parser/index.ts deleted file mode 100644 index e207ee8a9..000000000 --- a/commons/src/note-frontmatter-parser/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export * from './parse-raw-frontmatter-from-yaml.js' -export * from './convert-raw-frontmatter-to-note-frontmatter.js' -export * from './parse-tags.js' -export * from './types.js' 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 deleted file mode 100644 index 52372b96e..000000000 --- a/commons/src/note-frontmatter-parser/parse-raw-frontmatter-from-yaml.spec.ts +++ /dev/null @@ -1,72 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 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 deleted file mode 100644 index 7cf5f7736..000000000 --- a/commons/src/note-frontmatter-parser/parse-raw-frontmatter-from-yaml.ts +++ /dev/null @@ -1,76 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { defaultNoteFrontmatter } from '../note-frontmatter/index.js' -import { NoteTextDirection, NoteType, OpenGraph } from '../note-frontmatter/index.js' -import { SlideOptions } from '../note-frontmatter/index.js' -import { ISO6391 } from '../note-frontmatter/iso6391.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) - .lowercase() - .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) - -type ParserResult = - | { - error: undefined - warning?: ValidationError - value: RawNoteFrontmatter - } - | { - error: Error - warning?: ValidationError - value: undefined - } - -export const parseRawFrontmatterFromYaml = (rawYaml: string): ParserResult => { - try { - const rawNoteFrontmatter = load(rawYaml) - return schema.validate(rawNoteFrontmatter, { convert: true }) - } catch { - return { error: new Error('Invalid YAML'), value: undefined } - } -} diff --git a/commons/src/note-frontmatter-parser/parse-tags.spec.ts b/commons/src/note-frontmatter-parser/parse-tags.spec.ts deleted file mode 100644 index 670a0930b..000000000 --- a/commons/src/note-frontmatter-parser/parse-tags.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 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 deleted file mode 100644 index c4c7d7543..000000000 --- a/commons/src/note-frontmatter-parser/parse-tags.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * 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 deleted file mode 100644 index c3575dca0..000000000 --- a/commons/src/note-frontmatter-parser/types.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { - Iso6391Language, - NoteTextDirection, - NoteType, - OpenGraph, -} from '../note-frontmatter/index.js' -import { SlideOptions } from '../note-frontmatter/index.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/note-frontmatter/default-note-frontmatter.ts b/commons/src/note-frontmatter/default-note-frontmatter.ts new file mode 100644 index 000000000..c5a9dcb68 --- /dev/null +++ b/commons/src/note-frontmatter/default-note-frontmatter.ts @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { NoteType } from './note-type.js' +import { NoteTextDirection } from './note-text-direction.js' + +export const defaultNoteFrontmatter = { + title: '', + description: '', + tags: [], + type: NoteType.DOCUMENT as const, + breaks: true, + dir: NoteTextDirection.LTR, + robots: '', + lang: 'en' as const, + license: '', + opengraph: {}, + slideOptions: { + transition: 'zoom', + autoSlide: 0, + autoSlideStoppable: true, + backgroundTransition: 'fade', + slideNumber: false, + } as const, +} diff --git a/commons/src/note-frontmatter/default-values.ts b/commons/src/note-frontmatter/default-values.ts deleted file mode 100644 index 03123c7a4..000000000 --- a/commons/src/note-frontmatter/default-values.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { NoteFrontmatter, NoteTextDirection, NoteType } from './frontmatter.js' -import { SlideOptions } from './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/frontmatter.ts b/commons/src/note-frontmatter/frontmatter.ts deleted file mode 100644 index 94e512514..000000000 --- a/commons/src/note-frontmatter/frontmatter.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { ISO6391 } from './iso6391.js' -import { SlideOptions } from './slide-show-options.js' - -export type Iso6391Language = (typeof ISO6391)[number] - -export type OpenGraph = Record - -export enum NoteTextDirection { - LTR = 'ltr', - RTL = 'rtl', -} - -export enum NoteType { - DOCUMENT = 'document', - SLIDE = 'slide', -} - -export type OptionalNoteType = NoteType | '' - -export interface NoteFrontmatter { - title: string - description: string - tags: string[] - robots: string - lang: Iso6391Language - dir: NoteTextDirection - newlinesAreBreaks: boolean - license: string - type: NoteType - opengraph: OpenGraph - slideOptions: SlideOptions -} diff --git a/commons/src/note-frontmatter/index.ts b/commons/src/note-frontmatter/index.ts index 82311938d..8c560a843 100644 --- a/commons/src/note-frontmatter/index.ts +++ b/commons/src/note-frontmatter/index.ts @@ -4,7 +4,10 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +export * from './default-note-frontmatter.js' export * from './iso6391.js' -export * from './frontmatter.js' -export * from './slide-show-options.js' -export * from './default-values.js' +export * from './note-frontmatter.js' +export * from './note-text-direction.js' +export * from './note-type.js' +export * from './parse-note-frontmatter.js' +export * from './parse-tags-field.js' diff --git a/commons/src/note-frontmatter/note-frontmatter.ts b/commons/src/note-frontmatter/note-frontmatter.ts new file mode 100644 index 000000000..4196f767f --- /dev/null +++ b/commons/src/note-frontmatter/note-frontmatter.ts @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import z from 'zod' +import type { RevealOptions } from 'reveal.js' +import { parseTagsField } from './parse-tags-field.js' +import { NoteType } from './note-type.js' +import { ISO6391 } from './iso6391.js' +import { defaultNoteFrontmatter } from './default-note-frontmatter.js' +import { NoteTextDirection } from './note-text-direction.js' + +// Reveal.js provides types but no runtime validation of fields, so we validate them as unknown and accept everything +const slideOptionsSchema = z + .record(z.unknown()) + .default(defaultNoteFrontmatter.slideOptions) + .transform((slideOptions) => slideOptions as Partial) + +export const NoteFrontmatterSchema = z + .object({ + title: z.coerce.string().default(defaultNoteFrontmatter.title).describe('Title of the note'), + description: z.coerce + .string() + .default(defaultNoteFrontmatter.description) + .describe('Description of the note'), + tags: z + .preprocess(parseTagsField, z.array(z.string())) + .describe('List of tags for filtering on the explore page') + .default([]), + type: z + .nativeEnum(NoteType) + .default(defaultNoteFrontmatter.type) + .describe('Type of the renderer to use'), + robots: z.string().default(defaultNoteFrontmatter.robots).describe('Robots meta tag'), + lang: z.enum(ISO6391).default(defaultNoteFrontmatter.lang).describe('Language of the note'), + dir: z + .nativeEnum(NoteTextDirection) + .default(defaultNoteFrontmatter.dir) + .describe('Text writing direction'), + breaks: z + .boolean() + .default(defaultNoteFrontmatter.breaks) + .describe('Treat newlines as line break'), + license: z + .string() + .default(defaultNoteFrontmatter.license) + .describe('License header field to add to the HTML'), + opengraph: z + .record(z.coerce.string()) + .default(defaultNoteFrontmatter.opengraph) + .describe('OpenGraph meta tags'), + slideOptions: slideOptionsSchema.describe('Reveal.js options for slides'), + }) + .describe('Frontmatter options parsed by HedgeDoc, others are ignored') + .passthrough() + +export type NoteFrontmatter = z.infer diff --git a/commons/src/note-frontmatter/note-text-direction.ts b/commons/src/note-frontmatter/note-text-direction.ts new file mode 100644 index 000000000..a14e46fc4 --- /dev/null +++ b/commons/src/note-frontmatter/note-text-direction.ts @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +export enum NoteTextDirection { + LTR = 'ltr', + RTL = 'rtl', +} diff --git a/commons/src/note-frontmatter/note-type.ts b/commons/src/note-frontmatter/note-type.ts new file mode 100644 index 000000000..a5ee74e55 --- /dev/null +++ b/commons/src/note-frontmatter/note-type.ts @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +export enum NoteType { + DOCUMENT = 'document', + SLIDE = 'slide', +} diff --git a/commons/src/note-frontmatter/parse-note-frontmatter.spec.ts b/commons/src/note-frontmatter/parse-note-frontmatter.spec.ts new file mode 100644 index 000000000..d9d8a5aea --- /dev/null +++ b/commons/src/note-frontmatter/parse-note-frontmatter.spec.ts @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { describe, expect, it } from '@jest/globals' +import { parseNoteFrontmatter } from './parse-note-frontmatter.js' + +describe('parseNoteFrontmatter', () => { + it('marks comma-separated tags as deprecated', () => { + const result = parseNoteFrontmatter('tags: foo, bar') + + expect(result.error).toBeUndefined() + expect(result.usesDeprecatedTagsFormat).toBe(true) + expect(result.value?.tags).toStrictEqual(['foo', 'bar']) + }) + + it('does not mark array tags as deprecated', () => { + const result = parseNoteFrontmatter('tags:\n- foo\n- bar') + + expect(result.error).toBeUndefined() + expect(result.usesDeprecatedTagsFormat).toBe(false) + expect(result.value?.tags).toStrictEqual(['foo', 'bar']) + }) +}) diff --git a/commons/src/note-frontmatter/parse-note-frontmatter.ts b/commons/src/note-frontmatter/parse-note-frontmatter.ts new file mode 100644 index 000000000..a64647bb8 --- /dev/null +++ b/commons/src/note-frontmatter/parse-note-frontmatter.ts @@ -0,0 +1,80 @@ +/* + * SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { load } from 'js-yaml' +import { type NoteFrontmatter, NoteFrontmatterSchema } from './note-frontmatter.js' + +export type NoteFrontmatterParserResult = + | { + error: undefined + value: NoteFrontmatter + usesDeprecatedTagsFormat: boolean + rawTagsField: unknown + } + | { + error: Error + value: undefined + usesDeprecatedTagsFormat: boolean + rawTagsField: unknown + } + +/** + * Parses the note frontmatter from the given YAML string and returns the parsed result with collected errors. + * + * @param rawYaml The raw YAML string to parse + * @returns A wrapper containing the parsed note frontmatter and an error in case parsing failed + */ +export const parseNoteFrontmatter = (rawYaml: string): NoteFrontmatterParserResult => { + try { + const rawNoteFrontmatter = load(rawYaml) + const rawNoteFrontmatterRecord = + typeof rawNoteFrontmatter === 'object' && + rawNoteFrontmatter !== null && + !Array.isArray(rawNoteFrontmatter) + ? (rawNoteFrontmatter as Record) + : undefined + const rawTagsField = rawNoteFrontmatterRecord?.tags + const usesDeprecatedTagsFormat = + rawNoteFrontmatterRecord !== undefined && + Object.hasOwn(rawNoteFrontmatterRecord, 'tags') && + !Array.isArray(rawNoteFrontmatterRecord.tags) + if ( + typeof rawNoteFrontmatter !== 'object' || + rawNoteFrontmatter === null || + Array.isArray(rawNoteFrontmatter) || + Object.keys(rawNoteFrontmatter).length === 0 + ) { + return { + error: new Error('Invalid YAML'), + value: undefined, + usesDeprecatedTagsFormat: false, + rawTagsField: undefined, + } + } + const parsedNoteFrontmatter = NoteFrontmatterSchema.safeParse(rawNoteFrontmatter) + if (!parsedNoteFrontmatter.success) { + return { + error: parsedNoteFrontmatter.error, + value: undefined, + usesDeprecatedTagsFormat, + rawTagsField: rawTagsField, + } + } + return { + error: undefined, + value: parsedNoteFrontmatter.data, + usesDeprecatedTagsFormat, + rawTagsField: rawTagsField, + } + } catch (error: unknown) { + return { + error: error as Error, + value: undefined, + usesDeprecatedTagsFormat: false, + rawTagsField: undefined, + } + } +} diff --git a/commons/src/note-frontmatter/parse-tags-field.spec.ts b/commons/src/note-frontmatter/parse-tags-field.spec.ts new file mode 100644 index 000000000..edc13bdd0 --- /dev/null +++ b/commons/src/note-frontmatter/parse-tags-field.spec.ts @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { describe, expect, it } from '@jest/globals' +import { parseTagsField } from './parse-tags-field.js' + +describe('parseTagsField', () => { + it('parses comma-separated strings', () => { + expect(parseTagsField('foo, bar,baz')).toStrictEqual(['foo', 'bar', 'baz']) + }) + + it('removes empty string entries from comma-separated strings', () => { + expect(parseTagsField('foo,, bar')).toStrictEqual(['foo', 'bar']) + }) + + it('parses arrays by stringifying each item', () => { + expect(parseTagsField(['foo', 123, true])).toStrictEqual(['foo', '123', 'true']) + }) + + it('removes empty string entries from array entries', () => { + expect(parseTagsField(['foo', '', 'bar'])).toStrictEqual(['foo', 'bar']) + }) + + it('returns an empty array for invalid input', () => { + expect(parseTagsField(undefined)).toStrictEqual([]) + }) +}) diff --git a/commons/src/note-frontmatter/parse-tags-field.ts b/commons/src/note-frontmatter/parse-tags-field.ts new file mode 100644 index 000000000..6476a2d98 --- /dev/null +++ b/commons/src/note-frontmatter/parse-tags-field.ts @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * Parses the tags field from the note frontmatter, which should be an array, + * but for backwards-compatibility we also accept comma-separated strings. + * Empty tags are dropped from the list and whitespace is trimmed. + * + * @param input The input to parse + * @returns The parsed list of tags or an empty array if the input is invalid + */ +export const parseTagsField = (input: unknown): string[] => { + if (typeof input === 'string') { + return input + .split(',') + .map((tag) => tag.trim()) + .filter((tag) => !!tag) + } + if (Array.isArray(input)) { + return input.map((tag) => tag.toString().trim()).filter((tag) => !!tag) + } + return [] +} diff --git a/commons/src/note-frontmatter/slide-show-options.ts b/commons/src/note-frontmatter/slide-show-options.ts deleted file mode 100644 index a53a2f55d..000000000 --- a/commons/src/note-frontmatter/slide-show-options.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import type { RevealOptions } from 'reveal.js' - -type WantedRevealOptions = - | 'autoSlide' - | 'autoSlideStoppable' - | 'transition' - | 'backgroundTransition' - | 'slideNumber' - -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 3fe686502..6011ebd34 100644 --- a/commons/src/title-extraction/generate-note-title.spec.ts +++ b/commons/src/title-extraction/generate-note-title.spec.ts @@ -3,9 +3,11 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { NoteFrontmatter, NoteTextDirection, NoteType } from '../note-frontmatter/frontmatter.js' -import { generateNoteTitle } from './generate-note-title.js' import { describe, expect, it } from '@jest/globals' +import type { NoteFrontmatter } from '../note-frontmatter/note-frontmatter.js' +import { NoteType } from '../note-frontmatter/note-type.js' +import { generateNoteTitle } from './generate-note-title.js' +import { NoteTextDirection } from '../note-frontmatter/note-text-direction.js' const testFrontmatter: NoteFrontmatter = { title: '', @@ -14,7 +16,7 @@ const testFrontmatter: NoteFrontmatter = { robots: '', lang: 'en', dir: NoteTextDirection.LTR, - newlinesAreBreaks: true, + breaks: true, license: '', type: NoteType.DOCUMENT, opengraph: {}, diff --git a/commons/src/title-extraction/generate-note-title.ts b/commons/src/title-extraction/generate-note-title.ts index 6478c7a6c..60d56a2ef 100644 --- a/commons/src/title-extraction/generate-note-title.ts +++ b/commons/src/title-extraction/generate-note-title.ts @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import type { NoteFrontmatter } from '../note-frontmatter/frontmatter.js' +import type { NoteFrontmatter } from '../note-frontmatter/note-frontmatter.js' /** * Generates the note title from the given frontmatter or the first heading in the markdown content. diff --git a/frontend/src/components/common/renderer-iframe/hooks/use-send-additional-configuration-to-renderer.ts b/frontend/src/components/common/renderer-iframe/hooks/use-send-additional-configuration-to-renderer.ts index 68fc253db..6dc943592 100644 --- a/frontend/src/components/common/renderer-iframe/hooks/use-send-additional-configuration-to-renderer.ts +++ b/frontend/src/components/common/renderer-iframe/hooks/use-send-additional-configuration-to-renderer.ts @@ -15,7 +15,7 @@ import { useMemo } from 'react' */ export const useSendAdditionalConfigurationToRenderer = (rendererReady: boolean): void => { const darkModePreference = useApplicationState((state) => state.darkMode.darkModePreference) - const newlinesAreBreaks = useApplicationState((state) => state.noteDetails?.frontmatter.newlinesAreBreaks) + const newlinesAreBreaks = useApplicationState((state) => state.noteDetails?.frontmatter.breaks) useSendToRenderer( useMemo(() => { diff --git a/frontend/src/components/editor-page/editor-pane/linter/frontmatter-linter.spec.ts b/frontend/src/components/editor-page/editor-pane/linter/frontmatter-linter.spec.ts index 704c5abc0..e5b88efa4 100644 --- a/frontend/src/components/editor-page/editor-pane/linter/frontmatter-linter.spec.ts +++ b/frontend/src/components/editor-page/editor-pane/linter/frontmatter-linter.spec.ts @@ -75,6 +75,12 @@ describe('FrontmatterLinter', () => { ) }) }) + it('does not warn for the new array format', () => { + const frontmatterLinter = new FrontmatterLinter() + const editorView = mockEditorView('---\ntags:\n- a\n---') + + expect(frontmatterLinter.lint(editorView)).toStrictEqual([]) + }) it('with invalid yaml', () => { testFrontmatterLinter('---\n1\n 2: 3\n---', { from: 4, 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 315df4fda..25cba3d82 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 @@ -6,7 +6,7 @@ import type { Linter } from './linter' import type { Diagnostic } from '@codemirror/lint' import type { EditorView } from '@codemirror/view' -import { extractFrontmatter, parseRawFrontmatterFromYaml, parseTags } from '@hedgedoc/commons' +import { extractFrontmatter, parseNoteFrontmatter, parseTagsField } from '@hedgedoc/commons' import { t } from 'i18next' /** @@ -24,13 +24,15 @@ export class FrontmatterLinter implements Linter { const frontmatterLines = lines.slice(1, frontmatterExtraction.lineOffset - 1) 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) + const frontmatterParseResult = parseNoteFrontmatter(frontmatterExtraction.rawText) + if (frontmatterParseResult.error) { + return this.createErrorDiagnostics(startOfYaml, endOfYaml, frontmatterParseResult.error, 'error') + } else if (frontmatterParseResult.usesDeprecatedTagsFormat) { + return this.createReplaceSingleStringTagsDiagnostic( + frontmatterParseResult.rawTagsField, + frontmatterLines, + startOfYaml + ) } return [] } @@ -52,12 +54,12 @@ export class FrontmatterLinter implements Linter { } private createReplaceSingleStringTagsDiagnostic( - rawTags: string, + rawTags: unknown, frontmatterLines: string[], startOfYaml: number ): Diagnostic[] { - const tags: string[] = parseTags(rawTags) - const replacedText = 'tags:\n- ' + tags.join('\n- ') + const tags = this.parseDeprecatedTags(rawTags) + const replacedText = `tags:\n- ${tags.join('\n- ')}` const tagsLineIndex = frontmatterLines.findIndex((value) => value.startsWith('tags: ')) const linesBeforeTagsLine = frontmatterLines.slice(0, tagsLineIndex) const from = startOfYaml + linesBeforeTagsLine.join('\n').length + linesBeforeTagsLine.length @@ -81,4 +83,17 @@ export class FrontmatterLinter implements Linter { } ] } + + private parseDeprecatedTags(rawTags: unknown): string[] { + if (rawTags === undefined || rawTags === null) { + return [] + } + if (typeof rawTags === 'string' || Array.isArray(rawTags)) { + return parseTagsField(rawTags) + } + if (typeof rawTags === 'number' || typeof rawTags === 'boolean' || typeof rawTags === 'bigint') { + return parseTagsField(String(rawTags)) + } + return [] + } } diff --git a/frontend/src/components/markdown-renderer/hooks/use-reveal.ts b/frontend/src/components/markdown-renderer/hooks/use-reveal.ts index 9c2290856..cf148c2ac 100644 --- a/frontend/src/components/markdown-renderer/hooks/use-reveal.ts +++ b/frontend/src/components/markdown-renderer/hooks/use-reveal.ts @@ -4,9 +4,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { Logger } from '../../../utils/logger' -import type { SlideOptions } from '@hedgedoc/commons' import { useEffect, useRef, useState } from 'react' import type Reveal from 'reveal.js' +import type { RevealOptions } from 'reveal.js' const log = new Logger('reveal.js') @@ -33,7 +33,7 @@ const initialSlideState: SlideState = { * @return The current state of reveal.js * @see https://revealjs.com/ */ -export const useReveal = (markdownContentLines: string[], slideOptions?: SlideOptions): REVEAL_STATUS => { +export const useReveal = (markdownContentLines: string[], slideOptions?: RevealOptions): REVEAL_STATUS => { const [deck, setDeck] = useState() const [revealStatus, setRevealStatus] = useState(REVEAL_STATUS.NOT_INITIALISED) const currentSlideState = useRef(initialSlideState) diff --git a/frontend/src/components/render-page/render-page-content.tsx b/frontend/src/components/render-page/render-page-content.tsx index d0a522a45..7e8d84f37 100644 --- a/frontend/src/components/render-page/render-page-content.tsx +++ b/frontend/src/components/render-page/render-page-content.tsx @@ -14,7 +14,7 @@ import { useRendererReceiveHandler } from './window-post-message-communicator/ho import type { BaseConfiguration } from './window-post-message-communicator/rendering-message' import { CommunicationMessageType, RendererType } from './window-post-message-communicator/rendering-message' import { countWords } from './word-counter' -import type { SlideOptions } from '@hedgedoc/commons' +import type { RevealOptions } from 'reveal.js' import { EventEmitter2 } from 'eventemitter2' import React, { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' import { setPrintMode } from '../../redux/print-mode/methods' @@ -32,7 +32,7 @@ export const RenderPageContent: React.FC = () => { const communicator = useRendererToEditorCommunicator() const sendScrolling = useRef(false) const [newLinesAreBreaks, setNewLinesAreBreaks] = useState(true) - const [slideOptions, setSlideOptions] = useState() + const [slideOptions, setSlideOptions] = useState() useRendererReceiveHandler( CommunicationMessageType.SET_SLIDE_OPTIONS, diff --git a/frontend/src/components/render-page/renderers/slideshow/slideshow-markdown-renderer.tsx b/frontend/src/components/render-page/renderers/slideshow/slideshow-markdown-renderer.tsx index 901c5c19e..63a109f4e 100644 --- a/frontend/src/components/render-page/renderers/slideshow/slideshow-markdown-renderer.tsx +++ b/frontend/src/components/render-page/renderers/slideshow/slideshow-markdown-renderer.tsx @@ -11,11 +11,11 @@ import { RendererType } from '../../window-post-message-communicator/rendering-m import type { CommonMarkdownRendererProps } from '../common-markdown-renderer-props' import { LoadingSlide } from './loading-slide' import styles from './slideshow.module.scss' -import type { SlideOptions } from '@hedgedoc/commons' +import type { RevealOptions } from 'reveal.js' import React, { useMemo, useRef } from 'react' export interface SlideshowMarkdownRendererProps extends CommonMarkdownRendererProps { - slideOptions?: SlideOptions + slideOptions?: RevealOptions } /** diff --git a/frontend/src/components/render-page/window-post-message-communicator/rendering-message.ts b/frontend/src/components/render-page/window-post-message-communicator/rendering-message.ts index 537a569f4..3f5945c2d 100644 --- a/frontend/src/components/render-page/window-post-message-communicator/rendering-message.ts +++ b/frontend/src/components/render-page/window-post-message-communicator/rendering-message.ts @@ -5,7 +5,7 @@ */ import type { DarkModePreference } from '../../../redux/dark-mode/types' import type { ScrollState } from '../../editor-page/synced-scroll/scroll-props' -import type { SlideOptions } from '@hedgedoc/commons' +import type { RevealOptions } from 'reveal.js' export enum CommunicationMessageType { SET_MARKDOWN_CONTENT = 'SET_MARKDOWN_CONTENT', @@ -81,7 +81,7 @@ export interface SetScrollStateMessage { export interface SetSlideOptionsMessage { type: CommunicationMessageType.SET_SLIDE_OPTIONS - slideOptions: SlideOptions + slideOptions: RevealOptions } export interface OnHeightChangeMessage { 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 80aa288d2..f6b0c3139 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 @@ -7,13 +7,7 @@ import { calculateLineStartIndexes } from './calculate-line-start-indexes' import { initialState } from './initial-state' import type { NoteDetails } from './types' import type { FrontmatterExtractionResult, NoteFrontmatter } from '@hedgedoc/commons' -import { - convertRawFrontmatterToNoteFrontmatter, - extractFrontmatter, - generateNoteTitle, - parseRawFrontmatterFromYaml -} from '@hedgedoc/commons' -import { Optional } from '@mrdrogdrog/optional' +import { extractFrontmatter, generateNoteTitle, parseNoteFrontmatter } from '@hedgedoc/commons' /** * Copies a {@link NoteDetails} but with another markdown content. @@ -93,12 +87,12 @@ const buildStateFromFrontmatterUpdate = ( 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 parseFrontmatter = (frontmatterExtraction: FrontmatterExtractionResult): NoteFrontmatter => { + const parseResult = parseNoteFrontmatter(frontmatterExtraction.rawText) + if (parseResult.error !== undefined) { + return initialState.frontmatter + } + return parseResult.value } const buildStateFromFrontmatter = ( diff --git a/frontend/src/redux/note-details/reducers/build-state-from-set-note-data-from-server.spec.ts b/frontend/src/redux/note-details/reducers/build-state-from-set-note-data-from-server.spec.ts index b79c1bfdb..7df09c181 100644 --- a/frontend/src/redux/note-details/reducers/build-state-from-set-note-data-from-server.spec.ts +++ b/frontend/src/redux/note-details/reducers/build-state-from-set-note-data-from-server.spec.ts @@ -78,7 +78,7 @@ describe('build state from set note data from server', () => { lang: 'en', license: '', dir: NoteTextDirection.LTR, - newlinesAreBreaks: true, + breaks: true, type: NoteType.DOCUMENT, opengraph: {}, slideOptions: { diff --git a/yarn.lock b/yarn.lock index 161f03185..cf4a45f30 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2832,22 +2832,6 @@ __metadata: languageName: node linkType: hard -"@hapi/hoek@npm:^9.0.0, @hapi/hoek@npm:^9.3.0": - version: 9.3.0 - resolution: "@hapi/hoek@npm:9.3.0" - checksum: 10c0/a096063805051fb8bba4c947e293c664b05a32b47e13bc654c0dd43813a1cec993bdd8f29ceb838020299e1d0f89f68dc0d62a603c13c9cc8541963f0beca055 - languageName: node - linkType: hard - -"@hapi/topo@npm:^5.1.0": - version: 5.1.0 - resolution: "@hapi/topo@npm:5.1.0" - dependencies: - "@hapi/hoek": "npm:^9.0.0" - checksum: 10c0/b16b06d9357947149e032bdf10151eb71aea8057c79c4046bf32393cb89d0d0f7ca501c40c0f7534a5ceca078de0700d2257ac855c15e59fe4e00bba2f25c86f - languageName: node - linkType: hard - "@hedgedoc/backend@workspace:backend": version: 0.0.0-use.local resolution: "@hedgedoc/backend@workspace:backend" @@ -2945,7 +2929,6 @@ __metadata: domhandler: "npm:5.0.3" eventemitter2: "npm:6.4.9" jest: "npm:29.7.0" - joi: "npm:17.13.3" js-yaml: "npm:4.1.1" reveal.js: "npm:5.2.1" ts-jest: "npm:29.4.6" @@ -5246,29 +5229,6 @@ __metadata: languageName: node linkType: hard -"@sideway/address@npm:^4.1.5": - version: 4.1.5 - resolution: "@sideway/address@npm:4.1.5" - dependencies: - "@hapi/hoek": "npm:^9.0.0" - checksum: 10c0/638eb6f7e7dba209053dd6c8da74d7cc995e2b791b97644d0303a7dd3119263bcb7225a4f6804d4db2bc4f96e5a9d262975a014f58eae4d1753c27cbc96ef959 - languageName: node - linkType: hard - -"@sideway/formula@npm:^3.0.1": - version: 3.0.1 - resolution: "@sideway/formula@npm:3.0.1" - checksum: 10c0/3fe81fa9662efc076bf41612b060eb9b02e846ea4bea5bd114f1662b7f1541e9dedcf98aff0d24400bcb92f113964a50e0290b86e284edbdf6346fa9b7e2bf2c - languageName: node - linkType: hard - -"@sideway/pinpoint@npm:^2.0.0": - version: 2.0.0 - resolution: "@sideway/pinpoint@npm:2.0.0" - checksum: 10c0/d2ca75dacaf69b8fc0bb8916a204e01def3105ee44d8be16c355e5f58189eb94039e15ce831f3d544f229889ccfa35562a0ce2516179f3a7ee1bbe0b71e55b36 - languageName: node - linkType: hard - "@sinclair/typebox@npm:^0.27.8": version: 0.27.8 resolution: "@sinclair/typebox@npm:0.27.8" @@ -12377,19 +12337,6 @@ __metadata: languageName: node linkType: hard -"joi@npm:17.13.3": - version: 17.13.3 - resolution: "joi@npm:17.13.3" - dependencies: - "@hapi/hoek": "npm:^9.3.0" - "@hapi/topo": "npm:^5.1.0" - "@sideway/address": "npm:^4.1.5" - "@sideway/formula": "npm:^3.0.1" - "@sideway/pinpoint": "npm:^2.0.0" - checksum: 10c0/9262aef1da3f1bec5b03caf50c46368899fe03b8ff26cbe3d53af4584dd1049079fc97230bbf1500b6149db7cc765b9ee45f0deb24bb6fc3fa06229d7148c17f - languageName: node - linkType: hard - "jose@npm:5.10.0": version: 5.10.0 resolution: "jose@npm:5.10.0"