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 <github@erik.michelson.eu>
Signed-off-by: Philip Molares <philip.molares@udo.edu>
This commit is contained in:
Erik Michelson
2026-05-13 01:32:55 +02:00
committed by Philip Molares
parent 016ec6fd90
commit 89e441597d
38 changed files with 337 additions and 493 deletions
+5 -5
View File
@@ -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
@@ -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([]);
});
@@ -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,
-1
View File
@@ -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",
-1
View File
@@ -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'
+1 -1
View File
@@ -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',
@@ -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)
})
})
@@ -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),
}
}
@@ -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'
@@ -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')
})
})
@@ -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<RawNoteFrontmatter>({
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<SlideOptions>({
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<OpenGraph>({
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 }
}
}
@@ -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',
])
})
})
@@ -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)
}
@@ -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
}
@@ -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,
}
@@ -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,
}
@@ -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<string, string>
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
}
+6 -3
View File
@@ -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'
@@ -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<RevealOptions>)
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<typeof NoteFrontmatterSchema>
@@ -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',
}
@@ -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',
}
@@ -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'])
})
})
@@ -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<string, unknown>)
: 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,
}
}
}
@@ -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([])
})
})
@@ -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 []
}
@@ -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<RevealOptions, WantedRevealOptions>
@@ -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: {},
@@ -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.
@@ -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(() => {
@@ -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,
@@ -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 []
}
}
@@ -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<Reveal>()
const [revealStatus, setRevealStatus] = useState<REVEAL_STATUS>(REVEAL_STATUS.NOT_INITIALISED)
const currentSlideState = useRef<SlideState>(initialSlideState)
@@ -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<boolean>(false)
const [newLinesAreBreaks, setNewLinesAreBreaks] = useState<boolean>(true)
const [slideOptions, setSlideOptions] = useState<SlideOptions>()
const [slideOptions, setSlideOptions] = useState<RevealOptions>()
useRendererReceiveHandler(
CommunicationMessageType.SET_SLIDE_OPTIONS,
@@ -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
}
/**
@@ -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 {
@@ -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 = (
@@ -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: {
-53
View File
@@ -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"