Skip to content

refactor: zod poc #1724

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@
"uuid": "^8.3.2",
"y-prosemirror": "^1.3.4",
"y-protocols": "^1.0.6",
"yjs": "^13.6.15"
"yjs": "^13.6.15",
"zod": "^3.25.30"
},
"devDependencies": {
"@types/emoji-mart": "^3.0.14",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { DOMSerializer, Fragment } from "prosemirror-model";

import * as z from "zod/v4/core";
import { PartialBlock } from "../../../../blocks/defaultBlocks.js";
import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
import {
Expand Down Expand Up @@ -90,10 +91,13 @@ function serializeBlock<
if (!block.props) {
props = {};
for (const [name, spec] of Object.entries(
editor.schema.blockSchema[block.type as any].propSchema,
(editor.schema.blockSchema[block.type as any].propSchema as z.$ZodObject) // TODO
._zod.def.shape,
)) {
if (spec.default !== undefined) {
(props as any)[name] = spec.default;
const def =
spec instanceof z.$ZodDefault ? spec._zod.def.defaultValue : undefined;
if (def !== undefined) {
(props as any)[name] = def;
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { DOMSerializer, Fragment } from "prosemirror-model";

import * as z from "zod/v4/core";
import { PartialBlock } from "../../../../blocks/defaultBlocks.js";
import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
import {
Expand Down Expand Up @@ -65,10 +66,13 @@ function serializeBlock<
if (!block.props) {
props = {};
for (const [name, spec] of Object.entries(
editor.schema.blockSchema[block.type as any].propSchema,
(editor.schema.blockSchema[block.type as any].propSchema as z.$ZodObject) // TODO
._zod.def.shape,
)) {
if (spec.default !== undefined) {
(props as any)[name] = spec.default;
const def =
spec instanceof z.$ZodDefault ? spec._zod.def.defaultValue : undefined;
if (def !== undefined) {
(props as any)[name] = def;
}
}
}
Expand Down
21 changes: 16 additions & 5 deletions packages/core/src/api/nodeConversions/nodeToBlock.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Mark, Node, Schema, Slice } from "@tiptap/pm/model";
import * as z from "zod/v4/core";
import type { Block } from "../../blocks/defaultBlocks.js";
import UniqueID from "../../extensions/UniqueID/UniqueID.js";
import type {
Expand Down Expand Up @@ -429,12 +430,22 @@ export function nodeToBlock<
...node.attrs,
...(blockInfo.isBlockContainer ? blockInfo.blockContent.node.attrs : {}),
})) {
const propSchema = blockSpec.propSchema;
const propSchema =
blockSpec.propSchema._zod.def.shape[
attr as keyof typeof blockSpec.propSchema._zod.def.shape
];

if (
attr in propSchema &&
!(propSchema[attr].default === undefined && value === undefined)
) {
if (!propSchema) {
continue;
}

const def =
propSchema instanceof z.$ZodDefault
? propSchema._zod.def.defaultValue
: undefined;

// TODO: is this if statement correct?
if (!(def === undefined && value === undefined)) {
props[attr] = value;
}
}
Expand Down
41 changes: 18 additions & 23 deletions packages/core/src/blocks/AudioBlockContent/AudioBlockContent.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import z from "zod/v4";
import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import {
BlockFromConfig,
createBlockSpec,
FileBlockConfig,
Props,
PropSchema,
} from "../../schema/index.js";
import { defaultProps } from "../defaultProps.js";

import {
baseFilePropSchema,
optionalFilePropFields,
} from "../FileBlockContent/FileBlockContent.js";
import { parseFigureElement } from "../FileBlockContent/helpers/parse/parseFigureElement.js";
import { createFileBlockWrapper } from "../FileBlockContent/helpers/render/createFileBlockWrapper.js";
import { createFigureWithCaption } from "../FileBlockContent/helpers/toExternalHTML/createFigureWithCaption.js";
Expand All @@ -17,30 +20,22 @@ import { parseAudioElement } from "./parseAudioElement.js";
export const FILE_AUDIO_ICON_SVG =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M2 16.0001H5.88889L11.1834 20.3319C11.2727 20.405 11.3846 20.4449 11.5 20.4449C11.7761 20.4449 12 20.2211 12 19.9449V4.05519C12 3.93977 11.9601 3.8279 11.887 3.73857C11.7121 3.52485 11.3971 3.49335 11.1834 3.66821L5.88889 8.00007H2C1.44772 8.00007 1 8.44778 1 9.00007V15.0001C1 15.5524 1.44772 16.0001 2 16.0001ZM23 12C23 15.292 21.5539 18.2463 19.2622 20.2622L17.8445 18.8444C19.7758 17.1937 21 14.7398 21 12C21 9.26016 19.7758 6.80629 17.8445 5.15557L19.2622 3.73779C21.5539 5.75368 23 8.70795 23 12ZM18 12C18 10.0883 17.106 8.38548 15.7133 7.28673L14.2842 8.71584C15.3213 9.43855 16 10.64 16 12C16 13.36 15.3213 14.5614 14.2842 15.2841L15.7133 16.7132C17.106 15.6145 18 13.9116 18 12Z"></path></svg>';

export const audioPropSchema = {
backgroundColor: defaultProps.backgroundColor,
// File name.
name: {
default: "" as const,
},
// File url.
url: {
default: "" as const,
},
// File caption.
caption: {
default: "" as const,
},

showPreview: {
default: true,
},
} satisfies PropSchema;
export const audioPropSchema = z.object({
...defaultProps.pick({
backgroundColor: true,
}).shape,
...baseFilePropSchema.shape,
...optionalFilePropFields.pick({
url: true,
showPreview: true,
previewWidth: true,
}).shape,
});

export const audioBlockConfig = {
type: "audio" as const,
propSchema: audioPropSchema,
content: "none",
content: "none" as const,
isFileBlock: true,
fileBlockAccept: ["audio/*"],
} satisfies FileBlockConfig;
Expand Down Expand Up @@ -76,7 +71,7 @@ export const audioRender = (

export const audioParse = (
element: HTMLElement,
): Partial<Props<typeof audioBlockConfig.propSchema>> | undefined => {
): Partial<z.output<typeof audioBlockConfig.propSchema>> | undefined => {
if (element.tagName === "AUDIO") {
// Ignore if parent figure has already been parsed.
if (element.closest("figure")) {
Expand Down
11 changes: 4 additions & 7 deletions packages/core/src/blocks/CodeBlockContent/CodeBlockContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,13 @@ import { InputRule, isTextSelection } from "@tiptap/core";
import { TextSelection } from "@tiptap/pm/state";
import { Parser, createHighlightPlugin } from "prosemirror-highlight";
import { createParser } from "prosemirror-highlight/shiki";
import { z } from "zod/v4";
import { BlockNoteEditor } from "../../index.js";
import {
PropSchema,
createBlockSpecFromStronglyTypedTiptapNode,
createStronglyTypedTiptapNode,
} from "../../schema/index.js";
import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers.js";

export type CodeBlockOptions = {
/**
* Whether to indent lines with a tab when the user presses `Tab` in a code block.
Expand Down Expand Up @@ -66,11 +65,9 @@ export const shikiParserSymbol = Symbol.for("blocknote.shikiParser");
export const shikiHighlighterPromiseSymbol = Symbol.for(
"blocknote.shikiHighlighterPromise",
);
export const defaultCodeBlockPropSchema = {
language: {
default: "text",
},
} satisfies PropSchema;
export const defaultCodeBlockPropSchema = z.object({
language: z.string().default("text"),
});

const CodeBlockContent = createStronglyTypedTiptapNode({
name: "codeBlock",
Expand Down
42 changes: 25 additions & 17 deletions packages/core/src/blocks/FileBlockContent/FileBlockContent.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,39 @@
import * as z from "zod/v4";
import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import {
BlockFromConfig,
FileBlockConfig,
PropSchema,
createBlockSpec,
FileBlockConfig,
} from "../../schema/index.js";
import { defaultProps } from "../defaultProps.js";
import { parseEmbedElement } from "./helpers/parse/parseEmbedElement.js";
import { parseFigureElement } from "./helpers/parse/parseFigureElement.js";
import { createFileBlockWrapper } from "./helpers/render/createFileBlockWrapper.js";
import { createLinkWithCaption } from "./helpers/toExternalHTML/createLinkWithCaption.js";

export const filePropSchema = {
backgroundColor: defaultProps.backgroundColor,
// File name.
name: {
default: "" as const,
},
// File url.
url: {
default: "" as const,
},
// File caption.
caption: {
default: "" as const,
},
} satisfies PropSchema;
export const baseFilePropSchema = z.object({
caption: z.string().default(""),
name: z.string().default(""),
});

export const optionalFilePropFields = z.object({
// URL is optional, as we also want to accept files with no URL, but for example ids
// (ids can be used for files that are resolved on the backend)
url: z.string().default(""),
// Whether to show the file preview or the name only.
// This is useful for some file blocks, but not all
// (e.g.: not relevant for default "file" block which doesn;'t show previews)
showPreview: z.boolean().default(true),
// File preview width in px.
previewWidth: z.number(),
});

export const filePropSchema = defaultProps
.pick({
backgroundColor: true,
})
.extend(baseFilePropSchema.shape)
.extend(optionalFilePropFields.pick({ url: true }).shape);

export const fileBlockConfig = {
type: "file" as const,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
import { InputRule } from "@tiptap/core";
import * as z from "zod/v4";
import { updateBlockCommand } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js";
import { getBlockInfoFromSelection } from "../../api/getBlockInfoFromPos.js";
import {
PropSchema,
createBlockSpecFromStronglyTypedTiptapNode,
createStronglyTypedTiptapNode,
propsToAttributes,
} from "../../schema/index.js";
import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers.js";
import { defaultProps } from "../defaultProps.js";

export const headingPropSchema = {
...defaultProps,
level: { default: 1, values: [1, 2, 3] as const },
} satisfies PropSchema;
export const headingPropSchema = defaultProps.extend({
level: z.number().int().min(1).max(3).default(1),
});

const HeadingBlockContent = createStronglyTypedTiptapNode({
name: "heading",
Expand Down
49 changes: 20 additions & 29 deletions packages/core/src/blocks/ImageBlockContent/ImageBlockContent.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,37 @@
import z from "zod/v4";
import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import {
BlockFromConfig,
createBlockSpec,
FileBlockConfig,
Props,
PropSchema,
} from "../../schema/index.js";
import { defaultProps } from "../defaultProps.js";
import { parseFigureElement } from "../FileBlockContent/helpers/parse/parseFigureElement.js";
import { createResizableFileBlockWrapper } from "../FileBlockContent/helpers/render/createResizableFileBlockWrapper.js";
import { createFigureWithCaption } from "../FileBlockContent/helpers/toExternalHTML/createFigureWithCaption.js";
import { createLinkWithCaption } from "../FileBlockContent/helpers/toExternalHTML/createLinkWithCaption.js";
import { createResizableFileBlockWrapper } from "../FileBlockContent/helpers/render/createResizableFileBlockWrapper.js";
import { parseImageElement } from "./parseImageElement.js";

export const FILE_IMAGE_ICON_SVG =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M5 11.1005L7 9.1005L12.5 14.6005L16 11.1005L19 14.1005V5H5V11.1005ZM4 3H20C20.5523 3 21 3.44772 21 4V20C21 20.5523 20.5523 21 20 21H4C3.44772 21 3 20.5523 3 20V4C3 3.44772 3.44772 3 4 3ZM15.5 10C14.6716 10 14 9.32843 14 8.5C14 7.67157 14.6716 7 15.5 7C16.3284 7 17 7.67157 17 8.5C17 9.32843 16.3284 10 15.5 10Z"></path></svg>';

export const imagePropSchema = {
textAlignment: defaultProps.textAlignment,
backgroundColor: defaultProps.backgroundColor,
// File name.
name: {
default: "" as const,
},
// File url.
url: {
default: "" as const,
},
// File caption.
caption: {
default: "" as const,
},

showPreview: {
default: true,
},
// File preview width in px.
previewWidth: {
default: undefined,
type: "number",
},
} satisfies PropSchema;
export const imagePropSchema = defaultProps
.pick({
textAlignment: true,
backgroundColor: true,
})
.extend({
// File name.
name: z.string().default(""),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we want to do meta descriptions: https://zod.dev/metadata#meta

// File url.
url: z.string().default(""),
// File caption.
caption: z.string().default(""),
// Show preview.
showPreview: z.boolean().default(true),
// File preview width in px.
previewWidth: z.number().optional(),
});

export const imageBlockConfig = {
type: "image" as const,
Expand Down Expand Up @@ -87,7 +78,7 @@ export const imageRender = (

export const imageParse = (
element: HTMLElement,
): Partial<Props<typeof imageBlockConfig.propSchema>> | undefined => {
): Partial<z.infer<typeof imageBlockConfig.propSchema>> | undefined => {
if (element.tagName === "IMG") {
// Ignore if parent figure has already been parsed.
if (element.closest("figure")) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { InputRule } from "@tiptap/core";
import { updateBlockCommand } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js";
import { getBlockInfoFromSelection } from "../../../api/getBlockInfoFromPos.js";
import {
PropSchema,
createBlockSpecFromStronglyTypedTiptapNode,
createStronglyTypedTiptapNode,
} from "../../../schema/index.js";
Expand All @@ -11,9 +10,7 @@ import { defaultProps } from "../../defaultProps.js";
import { getListItemContent } from "../getListItemContent.js";
import { handleEnter } from "../ListItemKeyboardShortcuts.js";

export const bulletListItemPropSchema = {
...defaultProps,
} satisfies PropSchema;
export const bulletListItemPropSchema = defaultProps;

const BulletListItemBlockContent = createStronglyTypedTiptapNode({
name: "bulletListItem",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { InputRule } from "@tiptap/core";
import * as z from "zod/v4";
import { updateBlockCommand } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js";
import {
getBlockInfoFromSelection,
getNearestBlockPos,
} from "../../../api/getBlockInfoFromPos.js";
import {
PropSchema,
createBlockSpecFromStronglyTypedTiptapNode,
createStronglyTypedTiptapNode,
propsToAttributes,
Expand All @@ -15,12 +15,9 @@ import { defaultProps } from "../../defaultProps.js";
import { getListItemContent } from "../getListItemContent.js";
import { handleEnter } from "../ListItemKeyboardShortcuts.js";

export const checkListItemPropSchema = {
...defaultProps,
checked: {
default: false,
},
} satisfies PropSchema;
export const checkListItemPropSchema = defaultProps.extend({
checked: z.boolean().default(false),
});

const checkListItemBlockContent = createStronglyTypedTiptapNode({
name: "checkListItem",
Expand Down
Loading