diff --git a/packages/editor/CHANGELOG.md b/packages/editor/CHANGELOG.md new file mode 100644 index 0000000000..bdadb633d7 --- /dev/null +++ b/packages/editor/CHANGELOG.md @@ -0,0 +1,2 @@ +# @react-email/editor + diff --git a/packages/editor/license.md b/packages/editor/license.md new file mode 100644 index 0000000000..a2c628c9e0 --- /dev/null +++ b/packages/editor/license.md @@ -0,0 +1,7 @@ +Copyright 2026 Plus Five Five, Inc + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/editor/package.json b/packages/editor/package.json new file mode 100644 index 0000000000..39d2f8d78d --- /dev/null +++ b/packages/editor/package.json @@ -0,0 +1,69 @@ +{ + "name": "@react-email/editor", + "version": "0.0.0-experimental.6", + "description": "", + "sideEffects": false, + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "files": [ + "dist/**" + ], + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + } + }, + "license": "MIT", + "scripts": { + "build": "tsdown src/index.ts --format esm,cjs --dts --external react", + "build:watch": "tsdown src/index.ts --format esm,cjs --dts --external react --watch", + "clean": "rm -rf dist", + "test": "vitest run", + "test:watch": "vitest" + }, + "repository": { + "type": "git", + "url": "https://github.com/resend/react-email.git", + "directory": "packages/editor" + }, + "keywords": [ + "react", + "email" + ], + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@react-email/components": "workspace:*", + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + }, + "dependencies": { + "@tiptap/core": "^3.17.1", + "@tiptap/extension-code-block": "^3.17.1", + "@tiptap/extension-heading": "^3.17.1", + "@tiptap/extension-horizontal-rule": "^3.17.1", + "@tiptap/extension-placeholder": "^3.17.1", + "@tiptap/html": "^3.17.1", + "@tiptap/pm": "^3.17.1", + "@tiptap/react": "^3.17.1", + "@tiptap/starter-kit": "^3.17.1", + "hast-util-from-html": "^2.0.3", + "prismjs": "^1.30.0" + }, + "devDependencies": { + "@types/prismjs": "1.26.5", + "tsconfig": "workspace:*", + "typescript": "5.8.3" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/editor/readme.md b/packages/editor/readme.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/editor/src/core/email-node.spec.ts b/packages/editor/src/core/email-node.spec.ts new file mode 100644 index 0000000000..9411dcb597 --- /dev/null +++ b/packages/editor/src/core/email-node.spec.ts @@ -0,0 +1,45 @@ +import { Heading } from '@tiptap/extension-heading'; +import { EmailNode } from './email-node'; + +describe('EmailNode', () => { + it('maintains all user-defined properties from Heading', () => { + const Component = vi.fn(() => 'some important value'); + const CustomHeader = EmailNode.from(Heading, Component); + expect(CustomHeader).toBeInstanceOf(EmailNode); + expect(Heading.config).not.toHaveProperty('renderToReactEmail'); + + expect(CustomHeader.options).toStrictEqual(Heading.options); + expect(CustomHeader.storage).toStrictEqual(Heading.storage); + expect(CustomHeader.child).toStrictEqual(Heading.child); + expect(CustomHeader.type).toStrictEqual(Heading.type); + expect(CustomHeader.name).toStrictEqual(Heading.name); + expect(CustomHeader.parent).toStrictEqual(Heading.parent); + expect(CustomHeader.config).toHaveProperty('renderToReactEmail'); + + expect( + CustomHeader.config.renderToReactEmail( + {} as unknown as Parameters< + typeof CustomHeader.config.renderToReactEmail + >[0], + ), + ).toBe('some important value'); + const configWithoutRender = { ...CustomHeader.config } as Record< + string, + unknown + >; + delete configWithoutRender.renderToReactEmail; + expect(configWithoutRender).toStrictEqual(Heading.config); + }); + + it('remains an EmailNode instance and preserves renderToReactEmail after configure()', () => { + const Component = vi.fn(() => 'rendered'); + const CustomHeader = EmailNode.from(Heading, Component); + + const configured = CustomHeader.configure({ levels: [1, 2] }); + + expect(configured).toBeInstanceOf(EmailNode); + expect(configured.config).toHaveProperty('renderToReactEmail'); + expect(configured.config.renderToReactEmail).toBe(Component); + expect(configured.name).toBe(CustomHeader.name); + }); +}); diff --git a/packages/editor/src/core/email-node.ts b/packages/editor/src/core/email-node.ts new file mode 100644 index 0000000000..7459ac491e --- /dev/null +++ b/packages/editor/src/core/email-node.ts @@ -0,0 +1,91 @@ +import { + type Editor, + type JSONContent, + Node, + type NodeConfig, + type NodeType, +} from '@tiptap/core'; +import type { CssJs } from '../utils/types'; + +export type RendererComponent = (props: { + node: JSONContent; + styles: CssJs; + children?: React.ReactNode; +}) => React.ReactNode; + +export interface EmailNodeConfig + extends NodeConfig { + renderToReactEmail: RendererComponent; +} + +type ConfigParameter = Partial< + Omit, 'renderToReactEmail'> +> & + Pick, 'renderToReactEmail'>; + +export class EmailNode< + Options = Record, + Storage = Record, +> extends Node { + declare config: EmailNodeConfig; + + // biome-ignore lint/complexity/noUselessConstructor: This is only meant to change the types for config, hence why we keep it + constructor(config: ConfigParameter) { + super(config); + } + + /** + * Create a new Node instance + * @param config - Node configuration object or a function that returns a configuration object + */ + static create, S = Record>( + config: ConfigParameter | (() => ConfigParameter), + ) { + // If the config is a function, execute it to get the configuration object + const resolvedConfig = typeof config === 'function' ? config() : config; + return new EmailNode(resolvedConfig); + } + + static from( + node: Node, + renderToReactEmail: RendererComponent, + ): EmailNode { + const customNode = EmailNode.create({} as ConfigParameter); + // This only makes a shallow copy, so if there's nested objects here mutating things will be dangerous + Object.assign(customNode, { ...node }); + customNode.config = { ...node.config, renderToReactEmail }; + return customNode; + } + + configure(options?: Partial) { + return super.configure(options) as EmailNode; + } + + extend< + ExtendedOptions = Options, + ExtendedStorage = Storage, + ExtendedConfig extends NodeConfig< + ExtendedOptions, + ExtendedStorage + > = EmailNodeConfig, + >( + extendedConfig?: + | (() => Partial) + | (Partial & + ThisType<{ + name: string; + options: ExtendedOptions; + storage: ExtendedStorage; + editor: Editor; + type: NodeType; + }>), + ): EmailNode { + // If the extended config is a function, execute it to get the configuration object + const resolvedConfig = + typeof extendedConfig === 'function' ? extendedConfig() : extendedConfig; + return super.extend(resolvedConfig) as EmailNode< + ExtendedOptions, + ExtendedStorage + >; + } +} diff --git a/packages/editor/src/core/index.ts b/packages/editor/src/core/index.ts new file mode 100644 index 0000000000..3a791b93b1 --- /dev/null +++ b/packages/editor/src/core/index.ts @@ -0,0 +1 @@ +export * from './email-node'; diff --git a/packages/editor/src/extensions/__snapshots__/body.spec.tsx.snap b/packages/editor/src/extensions/__snapshots__/body.spec.tsx.snap new file mode 100644 index 0000000000..14fc817cba --- /dev/null +++ b/packages/editor/src/extensions/__snapshots__/body.spec.tsx.snap @@ -0,0 +1,9 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Body Node > renders React Email properly 1`] = ` +" + +
Body content
+ +" +`; diff --git a/packages/editor/src/extensions/__snapshots__/button.spec.tsx.snap b/packages/editor/src/extensions/__snapshots__/button.spec.tsx.snap new file mode 100644 index 0000000000..c58b97f35e --- /dev/null +++ b/packages/editor/src/extensions/__snapshots__/button.spec.tsx.snap @@ -0,0 +1,36 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`EditorButton Node > renders React Email properly 1`] = ` +" + + + + + + + +
+ Click me +
+ +" +`; diff --git a/packages/editor/src/extensions/__snapshots__/code-block.spec.tsx.snap b/packages/editor/src/extensions/__snapshots__/code-block.spec.tsx.snap new file mode 100644 index 0000000000..73001452bf --- /dev/null +++ b/packages/editor/src/extensions/__snapshots__/code-block.spec.tsx.snap @@ -0,0 +1,10 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`CodeBlockPrism Node > renders React Email properly 1`] = ` +" + +
const ‍​x ‍​= ‍​1;
+ +" +`; diff --git a/packages/editor/src/extensions/__snapshots__/columns.spec.tsx.snap b/packages/editor/src/extensions/__snapshots__/columns.spec.tsx.snap new file mode 100644 index 0000000000..dda9bea9f4 --- /dev/null +++ b/packages/editor/src/extensions/__snapshots__/columns.spec.tsx.snap @@ -0,0 +1,124 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Column Variants > renders ColumnsColumn with custom width 1`] = ` +" + + + Column content + + +" +`; + +exports[`Column Variants > renders ColumnsColumn with inline styles 1`] = ` +" + + + Content + + +" +`; + +exports[`Column Variants > renders FourColumns with 4 column children 1`] = ` +" + + + + + + + + + + +
ABCD
+ +" +`; + +exports[`Column Variants > renders ThreeColumns with 3 column children 1`] = ` +" + + + + + + + + + +
+ Column A + + Column B + + Column C +
+ +" +`; + +exports[`Column Variants > renders TwoColumns with 2 column children 1`] = ` +" + + + + + + + + +
+ Column A + + Column B +
+ +" +`; + +exports[`Column Variants > renders column parent with inline styles 1`] = ` +" + + + + + Content + + + + +" +`; diff --git a/packages/editor/src/extensions/__snapshots__/div.spec.tsx.snap b/packages/editor/src/extensions/__snapshots__/div.spec.tsx.snap new file mode 100644 index 0000000000..de3065c56d --- /dev/null +++ b/packages/editor/src/extensions/__snapshots__/div.spec.tsx.snap @@ -0,0 +1,9 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Div Node > renders React Email properly 1`] = ` +" + +
Div content
+ +" +`; diff --git a/packages/editor/src/extensions/__snapshots__/section.spec.tsx.snap b/packages/editor/src/extensions/__snapshots__/section.spec.tsx.snap new file mode 100644 index 0000000000..8a9a91ea9e --- /dev/null +++ b/packages/editor/src/extensions/__snapshots__/section.spec.tsx.snap @@ -0,0 +1,23 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Section Node > renders React Email properly 1`] = ` +" + + + + + + + + + +" +`; diff --git a/packages/editor/src/extensions/__snapshots__/table.spec.tsx.snap b/packages/editor/src/extensions/__snapshots__/table.spec.tsx.snap new file mode 100644 index 0000000000..a190008d60 --- /dev/null +++ b/packages/editor/src/extensions/__snapshots__/table.spec.tsx.snap @@ -0,0 +1,42 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Table Nodes > renders Table React Email properly 1`] = ` +" + + + + + + + +
Table content
+ +" +`; + +exports[`Table Nodes > renders TableCell React Email properly 1`] = ` +" + + + Cell content + + +" +`; + +exports[`Table Nodes > renders TableRow React Email properly 1`] = ` +" + + + Row content + + +" +`; diff --git a/packages/editor/src/extensions/alignment-attribute.tsx b/packages/editor/src/extensions/alignment-attribute.tsx new file mode 100644 index 0000000000..0234a04204 --- /dev/null +++ b/packages/editor/src/extensions/alignment-attribute.tsx @@ -0,0 +1,102 @@ +import { Extension } from '@tiptap/core'; + +interface AlignmentOptions { + types: string[]; + alignments: string[]; +} + +declare module '@tiptap/core' { + interface Commands { + alignment: { + /** + * Set the text align attribute + */ + setAlignment: (alignment: string) => ReturnType; + }; + } +} + +export const AlignmentAttribute = Extension.create({ + name: 'alignmentAttribute', + + addOptions() { + return { + types: [], + alignments: ['left', 'center', 'right', 'justify'], + }; + }, + + addGlobalAttributes() { + return [ + { + types: this.options.types, + attributes: { + alignment: { + parseHTML: (element) => { + const explicitAlign = + element.getAttribute('align') || + element.getAttribute('alignment') || + element.style.textAlign; + if ( + explicitAlign && + this.options.alignments.includes(explicitAlign) + ) { + return explicitAlign; + } + + // Return null to let natural inheritance work + return null; + }, + renderHTML: (attributes) => { + if (attributes.alignment === 'left') { + return {}; + } + + return { alignment: attributes.alignment }; + }, + }, + }, + }, + ]; + }, + + addCommands() { + return { + setAlignment: + (alignment) => + ({ commands }) => { + if (!this.options.alignments.includes(alignment)) { + return false; + } + + return this.options.types.every((type) => + commands.updateAttributes(type, { alignment }), + ); + }, + }; + }, + + addKeyboardShortcuts() { + return { + Enter: () => { + // Get the current node's alignment + const { from } = this.editor.state.selection; + const node = this.editor.state.doc.nodeAt(from); + const currentAlignment = node?.attrs?.alignment; + + if (currentAlignment) { + requestAnimationFrame(() => { + // Preserve the current alignment when creating new nodes + this.editor.commands.setAlignment(currentAlignment); + }); + } + + return false; + }, + 'Mod-Shift-l': () => this.editor.commands.setAlignment('left'), + 'Mod-Shift-e': () => this.editor.commands.setAlignment('center'), + 'Mod-Shift-r': () => this.editor.commands.setAlignment('right'), + 'Mod-Shift-j': () => this.editor.commands.setAlignment('justify'), + }; + }, +}); diff --git a/packages/editor/src/extensions/body.spec.tsx b/packages/editor/src/extensions/body.spec.tsx new file mode 100644 index 0000000000..8732577cc0 --- /dev/null +++ b/packages/editor/src/extensions/body.spec.tsx @@ -0,0 +1,26 @@ +import { render } from '@react-email/components'; +import { RESET_THEMES } from '../plugins/theming/themes'; +import { Body } from './body'; + +describe('Body Node', () => { + it('renders React Email properly', async () => { + const Component = Body.config.renderToReactEmail; + expect(Component).toBeDefined(); + expect( + await render( + + Body content + , + { pretty: true }, + ), + ).toMatchSnapshot(); + }); +}); diff --git a/packages/editor/src/extensions/body.tsx b/packages/editor/src/extensions/body.tsx new file mode 100644 index 0000000000..3b12d37251 --- /dev/null +++ b/packages/editor/src/extensions/body.tsx @@ -0,0 +1,77 @@ +import { mergeAttributes } from '@tiptap/core'; +import { EmailNode } from '../core/email-node'; +import { + COMMON_HTML_ATTRIBUTES, + createStandardAttributes, + LAYOUT_ATTRIBUTES, +} from '../utils/attribute-helpers'; +import { inlineCssToJs } from '../utils/styles'; + +export interface BodyOptions { + HTMLAttributes: Record; +} + +export const Body = EmailNode.create({ + name: 'body', + + group: 'block', + + content: 'block+', + + defining: true, + isolating: true, + + addAttributes() { + return { + ...createStandardAttributes([ + ...COMMON_HTML_ATTRIBUTES, + ...LAYOUT_ATTRIBUTES, + ]), + }; + }, + + parseHTML() { + return [ + { + tag: 'body', + getAttrs: (node) => { + if (typeof node === 'string') { + return false; + } + const element = node as HTMLElement; + const attrs: Record = {}; + + // Preserve all attributes + Array.from(element.attributes).forEach((attr) => { + attrs[attr.name] = attr.value; + }); + + return attrs; + }, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'div', + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), + 0, + ]; + }, + + renderToReactEmail({ children, node, styles }) { + const inlineStyles = inlineCssToJs(node.attrs?.style); + return ( +
+ {children} +
+ ); + }, +}); diff --git a/packages/editor/src/extensions/bold.ts b/packages/editor/src/extensions/bold.ts new file mode 100644 index 0000000000..95863e4738 --- /dev/null +++ b/packages/editor/src/extensions/bold.ts @@ -0,0 +1,147 @@ +import { + Mark, + markInputRule, + markPasteRule, + mergeAttributes, +} from '@tiptap/core'; + +export interface BoldOptions { + /** + * HTML attributes to add to the bold element. + * @default {} + * @example { class: 'foo' } + */ + HTMLAttributes: Record; +} + +declare module '@tiptap/core' { + interface Commands { + bold: { + /** + * Set a bold mark + */ + setBold: () => ReturnType; + /** + * Toggle a bold mark + */ + toggleBold: () => ReturnType; + /** + * Unset a bold mark + */ + unsetBold: () => ReturnType; + }; + } +} + +/** + * Matches bold text via `**` as input. + */ +const starInputRegex = /(?:^|\s)(\*\*(?!\s+\*\*)((?:[^*]+))\*\*(?!\s+\*\*))$/; + +/** + * Matches bold text via `**` while pasting. + */ +const starPasteRegex = /(?:^|\s)(\*\*(?!\s+\*\*)((?:[^*]+))\*\*(?!\s+\*\*))/g; + +/** + * Matches bold text via `__` as input. + */ +const underscoreInputRegex = /(?:^|\s)(__(?!\s+__)((?:[^_]+))__(?!\s+__))$/; + +/** + * Matches bold text via `__` while pasting. + */ +const underscorePasteRegex = /(?:^|\s)(__(?!\s+__)((?:[^_]+))__(?!\s+__))/g; + +/** + * This extension allows you to mark text as bold. + * @see https://tiptap.dev/api/marks/bold + */ +export const Bold = Mark.create({ + name: 'bold', + + addOptions() { + return { + HTMLAttributes: {}, + }; + }, + + parseHTML() { + return [ + { + tag: 'strong', + }, + { + tag: 'b', + getAttrs: (node) => + (node as HTMLElement).style.fontWeight !== 'normal' && null, + }, + { + style: 'font-weight=400', + clearMark: (mark) => mark.type.name === this.name, + }, + // Removed the font-weight inference rule that was in the original + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'strong', + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), + 0, + ]; + }, + + addCommands() { + return { + setBold: + () => + ({ commands }) => { + return commands.setMark(this.name); + }, + toggleBold: + () => + ({ commands }) => { + return commands.toggleMark(this.name); + }, + unsetBold: + () => + ({ commands }) => { + return commands.unsetMark(this.name); + }, + }; + }, + + addKeyboardShortcuts() { + return { + 'Mod-b': () => this.editor.commands.toggleBold(), + 'Mod-B': () => this.editor.commands.toggleBold(), + }; + }, + + addInputRules() { + return [ + markInputRule({ + find: starInputRegex, + type: this.type, + }), + markInputRule({ + find: underscoreInputRegex, + type: this.type, + }), + ]; + }, + + addPasteRules() { + return [ + markPasteRule({ + find: starPasteRegex, + type: this.type, + }), + markPasteRule({ + find: underscorePasteRegex, + type: this.type, + }), + ]; + }, +}); diff --git a/packages/editor/src/extensions/button.spec.tsx b/packages/editor/src/extensions/button.spec.tsx new file mode 100644 index 0000000000..e365261881 --- /dev/null +++ b/packages/editor/src/extensions/button.spec.tsx @@ -0,0 +1,28 @@ +import { render } from '@react-email/components'; +import { RESET_THEMES } from '../plugins/theming/themes'; +import { Button } from './button'; + +describe('EditorButton Node', () => { + it('renders React Email properly', async () => { + const Component = Button.config.renderToReactEmail; + expect(Component).toBeDefined(); + expect( + await render( + + Click me + , + { pretty: true }, + ), + ).toMatchSnapshot(); + }); +}); diff --git a/packages/editor/src/extensions/button.tsx b/packages/editor/src/extensions/button.tsx new file mode 100644 index 0000000000..9ed5b0787a --- /dev/null +++ b/packages/editor/src/extensions/button.tsx @@ -0,0 +1,131 @@ +import { + Column, + Button as ReactEmailButton, + Row, +} from '@react-email/components'; +import { mergeAttributes } from '@tiptap/core'; +import { EmailNode } from '../core/email-node'; +import { inlineCssToJs } from '../utils/styles'; + +interface EditorButtonOptions { + HTMLAttributes: Record; + [key: string]: unknown; +} + +declare module '@tiptap/core' { + interface Commands { + button: { + setButton: () => ReturnType; + updateButton: (attributes: Record) => ReturnType; + }; + } +} + +export const Button = EmailNode.create({ + name: 'button', + group: 'block', + content: 'inline*', + defining: true, + draggable: true, + marks: 'bold', + + addAttributes() { + return { + class: { + default: 'button', + }, + href: { + default: '#', + }, + alignment: { + default: 'left', + }, + }; + }, + + parseHTML() { + return [ + { + tag: 'a[data-id="react-email-button"]', + getAttrs: (node) => { + if (typeof node === 'string') { + return false; + } + const element = node as HTMLElement; + const attrs: Record = {}; + + // Preserve all attributes + Array.from(element.attributes).forEach((attr) => { + attrs[attr.name] = attr.value; + }); + + return attrs; + }, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'div', + mergeAttributes({ + class: `align-${HTMLAttributes?.alignment}`, + }), + [ + 'a', + mergeAttributes({ + class: `node-button ${HTMLAttributes?.class}`, + style: HTMLAttributes?.style, + 'data-id': 'react-email-button', + 'data-href': HTMLAttributes?.href, + }), + 0, + ], + ]; + }, + + addCommands() { + return { + updateButton: + (attributes) => + ({ commands }) => { + return commands.updateAttributes('button', attributes); + }, + + setButton: + () => + ({ commands }) => { + return commands.insertContent({ + type: 'button', + content: [ + { + type: 'text', + text: 'Button', + }, + ], + }); + }, + }; + }, + + renderToReactEmail({ children, node, styles }) { + const inlineStyles = inlineCssToJs(node.attrs?.style); + return ( + + + + {children} + + + + ); + }, +}); diff --git a/packages/editor/src/extensions/class-attribute.tsx b/packages/editor/src/extensions/class-attribute.tsx new file mode 100644 index 0000000000..db660d61f9 --- /dev/null +++ b/packages/editor/src/extensions/class-attribute.tsx @@ -0,0 +1,80 @@ +import { Extension } from '@tiptap/core'; + +interface ClassAttributeOptions { + types: string[]; + class: string[]; +} + +declare module '@tiptap/core' { + interface Commands { + class: { + /** + * Set the class attribute + */ + setClass: (classList: string) => ReturnType; + /** + * Unset the class attribute + */ + unsetClass: () => ReturnType; + }; + } +} + +export const ClassAttribute = Extension.create({ + name: 'classAttribute', + + addOptions() { + return { + types: [], + class: [], + }; + }, + + addGlobalAttributes() { + return [ + { + types: this.options.types, + attributes: { + class: { + default: '', + parseHTML: (element) => element.className || '', + renderHTML: (attributes) => { + return attributes.class ? { class: attributes.class } : {}; + }, + }, + }, + }, + ]; + }, + + addCommands() { + return { + unsetClass: + () => + ({ commands }) => { + return this.options.types.every((type) => + commands.resetAttributes(type, 'class'), + ); + }, + setClass: + (classList: string) => + ({ commands }) => { + return this.options.types.every((type) => + commands.updateAttributes(type, { class: classList }), + ); + }, + }; + }, + + addKeyboardShortcuts() { + return { + Enter: ({ editor }) => { + requestAnimationFrame(() => { + editor.commands.resetAttributes('paragraph', 'class'); + }); + + return false; + }, + }; + }, +}); diff --git a/packages/editor/src/extensions/code-block.spec.tsx b/packages/editor/src/extensions/code-block.spec.tsx new file mode 100644 index 0000000000..cf41c35465 --- /dev/null +++ b/packages/editor/src/extensions/code-block.spec.tsx @@ -0,0 +1,51 @@ +import { dracula, render } from '@react-email/components'; +import { describe, expect, it } from 'vitest'; +import { RESET_THEMES } from '../plugins/theming/themes'; +import { CodeBlockPrism } from './code-block'; + +describe('CodeBlockPrism Node', () => { + it('renders React Email properly', async () => { + const Component = CodeBlockPrism.config.renderToReactEmail; + expect(Component).toBeDefined(); + expect( + await render( + , + { pretty: true }, + ), + ).toMatchSnapshot(); + }); + + it('does not modify the theme', async () => { + const Component = CodeBlockPrism.config.renderToReactEmail; + expect(Component).toBeDefined(); + + const originalTheme = structuredClone(dracula); + await render( + , + { pretty: true }, + ); + expect(dracula).toStrictEqual(originalTheme); + }); +}); diff --git a/packages/editor/src/extensions/code-block.tsx b/packages/editor/src/extensions/code-block.tsx new file mode 100644 index 0000000000..4ad76a6a5e --- /dev/null +++ b/packages/editor/src/extensions/code-block.tsx @@ -0,0 +1,277 @@ +import * as ReactEmailComponents from '@react-email/components'; +import { + type PrismLanguage, + CodeBlock as ReactEmailCodeBlock, +} from '@react-email/components'; +import { mergeAttributes } from '@tiptap/core'; +import type { CodeBlockOptions } from '@tiptap/extension-code-block'; +import CodeBlock from '@tiptap/extension-code-block'; +import { TextSelection } from '@tiptap/pm/state'; +import { EmailNode } from '../core/email-node'; +import { PrismPlugin } from './prism-plugin'; + +export interface CodeBlockPrismOptions extends CodeBlockOptions { + defaultLanguage: string; + defaultTheme: string; +} + +const tabSize = 2; + +export const CodeBlockPrism = EmailNode.from( + CodeBlock.extend({ + addOptions(): CodeBlockPrismOptions { + return { + languageClassPrefix: 'language-', + exitOnTripleEnter: false, + exitOnArrowDown: false, + enableTabIndentation: true, + tabSize: tabSize, + defaultLanguage: 'javascript', + defaultTheme: 'default', + HTMLAttributes: {}, + }; + }, + + addAttributes() { + return { + ...this.parent?.(), + language: { + default: this.options.defaultLanguage, + parseHTML: (element: HTMLElement | null) => { + if (!element) { + return null; + } + const { languageClassPrefix } = this.options; + if (!languageClassPrefix) { + return null; + } + const classNames = [ + ...(element.firstElementChild?.classList || []), + ]; + const languages = classNames + .filter((className) => + className.startsWith(languageClassPrefix || ''), + ) + .map((className) => className.replace(languageClassPrefix, '')); + const language = languages[0]; + + if (!language) { + return null; + } + + return language; + }, + rendered: false, + }, + theme: { + default: this.options.defaultTheme, + rendered: false, + }, + }; + }, + + addKeyboardShortcuts() { + return { + ...this.parent?.(), + Tab: ({ editor }) => { + if (!this.options.enableTabIndentation) { + return false; + } + const { state } = editor; + const { selection } = state; + const { $from, empty } = selection; + if ($from.parent.type !== this.type) { + return false; + } + const indent = ' '.repeat(tabSize); + if (empty) { + return editor.commands.insertContent(indent); + } + return editor.commands.command(({ tr }) => { + const { from, to } = selection; + const text = state.doc.textBetween(from, to, '\n', '\n'); + const lines = text.split('\n'); + const indentedText = lines.map((line) => indent + line).join('\n'); + tr.replaceWith(from, to, state.schema.text(indentedText)); + return true; + }); + }, + 'Shift-Tab': ({ editor }) => { + if (!this.options.enableTabIndentation) { + return false; + } + const { state } = editor; + const { selection } = state; + const { $from, empty } = selection; + if ($from.parent.type !== this.type) { + return false; + } + if (empty) { + return editor.commands.command(({ tr }) => { + const { pos } = $from; + const codeBlockStart = $from.start(); + const codeBlockEnd = $from.end(); + const allText = state.doc.textBetween( + codeBlockStart, + codeBlockEnd, + '\n', + '\n', + ); + const lines = allText.split('\n'); + let currentLineIndex = 0; + let charCount = 0; + const relativeCursorPos = pos - codeBlockStart; + for (let i = 0; i < lines.length; i += 1) { + if (charCount + lines[i].length >= relativeCursorPos) { + currentLineIndex = i; + break; + } + charCount += lines[i].length + 1; + } + const currentLine = lines[currentLineIndex]; + const leadingSpaces = currentLine.match(/^ */)?.[0] || ''; + const spacesToRemove = Math.min(leadingSpaces.length, tabSize); + if (spacesToRemove === 0) { + return true; + } + let lineStartPos = codeBlockStart; + for (let i = 0; i < currentLineIndex; i += 1) { + lineStartPos += lines[i].length + 1; + } + tr.delete(lineStartPos, lineStartPos + spacesToRemove); + const cursorPosInLine = pos - lineStartPos; + if (cursorPosInLine <= spacesToRemove) { + tr.setSelection(TextSelection.create(tr.doc, lineStartPos)); + } + return true; + }); + } + return editor.commands.command(({ tr }) => { + const { from, to } = selection; + const text = state.doc.textBetween(from, to, '\n', '\n'); + const lines = text.split('\n'); + const reverseIndentText = lines + .map((line) => { + const leadingSpaces = line.match(/^ */)?.[0] || ''; + const spacesToRemove = Math.min(leadingSpaces.length, tabSize); + return line.slice(spacesToRemove); + }) + .join('\n'); + tr.replaceWith(from, to, state.schema.text(reverseIndentText)); + return true; + }); + }, + }; + }, + + renderHTML({ node, HTMLAttributes }) { + return [ + 'pre', + mergeAttributes( + this.options.HTMLAttributes, + HTMLAttributes, + { + class: node.attrs.language + ? `${this.options.languageClassPrefix}${node.attrs.language}` + : null, + }, + { 'data-theme': node.attrs.theme }, + ), + [ + 'code', + { + class: node.attrs.language + ? `${this.options.languageClassPrefix}${node.attrs.language} node-codeTag` + : 'node-codeTag', + }, + 0, + ], + ]; + }, + + addKeyboardShortcuts() { + return { + ...this.parent?.(), + 'Mod-a': ({ editor }) => { + const { state } = editor; + const { selection } = state; + const { $from } = selection; + + for (let depth = $from.depth; depth >= 1; depth--) { + if ($from.node(depth).type.name === this.name) { + const blockStart = $from.start(depth); + const blockEnd = $from.end(depth); + + const alreadyFullySelected = + selection.from === blockStart && selection.to === blockEnd; + if (alreadyFullySelected) { + return false; + } + + const tr = state.tr.setSelection( + TextSelection.create(state.doc, blockStart, blockEnd), + ); + editor.view.dispatch(tr); + return true; + } + } + + return false; + }, + }; + }, + + addProseMirrorPlugins() { + return [ + ...(this.parent?.() || []), + PrismPlugin({ + name: this.name, + defaultLanguage: this.options.defaultLanguage, + defaultTheme: this.options.defaultTheme, + }), + ]; + }, + }), + ({ node, styles }) => { + const language = node.attrs?.language + ? `${node.attrs.language}` + : 'javascript'; + + // @ts-expect-error -- @react-email/components does not export theme objects by name; dynamic access needed for user-selected themes + const userTheme = ReactEmailComponents[node.attrs?.theme]; + + // Without theme, render a gray code block + const theme = userTheme + ? { + ...userTheme, + base: { + ...userTheme.base, + borderRadius: '0.125rem', + padding: '0.75rem 1rem', + }, + } + : { + base: { + color: '#1e293b', + background: '#f1f5f9', + lineHeight: '1.5', + fontFamily: + '"Fira Code", "Fira Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace', + padding: '0.75rem 1rem', + borderRadius: '0.125rem', + }, + }; + + return ( + + ); + }, +); diff --git a/packages/editor/src/extensions/columns.spec.tsx b/packages/editor/src/extensions/columns.spec.tsx new file mode 100644 index 0000000000..cfc4437132 --- /dev/null +++ b/packages/editor/src/extensions/columns.spec.tsx @@ -0,0 +1,166 @@ +import { render } from '@react-email/components'; +import { RESET_THEMES } from '../plugins/theming/themes'; +import { + ColumnsColumn, + FourColumns, + ThreeColumns, + TwoColumns, +} from './columns'; + +describe('Column Variants', () => { + it('renders TwoColumns with 2 column children', async () => { + const Parent = TwoColumns.config.renderToReactEmail; + const Child = ColumnsColumn.config.renderToReactEmail; + + expect( + await render( + + + Column A + + + Column B + + , + { pretty: true }, + ), + ).toMatchSnapshot(); + }); + + it('renders ThreeColumns with 3 column children', async () => { + const Parent = ThreeColumns.config.renderToReactEmail; + const Child = ColumnsColumn.config.renderToReactEmail; + + expect( + await render( + + + Column A + + + Column B + + + Column C + + , + { pretty: true }, + ), + ).toMatchSnapshot(); + }); + + it('renders FourColumns with 4 column children', async () => { + const Parent = FourColumns.config.renderToReactEmail; + const Child = ColumnsColumn.config.renderToReactEmail; + + expect( + await render( + + + A + + + B + + + C + + + D + + , + { pretty: true }, + ), + ).toMatchSnapshot(); + }); + + it('renders ColumnsColumn with custom width', async () => { + const Component = ColumnsColumn.config.renderToReactEmail; + + expect( + await render( + + Column content + , + { pretty: true }, + ), + ).toMatchSnapshot(); + }); + + it('renders column parent with inline styles', async () => { + const Component = TwoColumns.config.renderToReactEmail; + + expect( + await render( + + Content + , + { pretty: true }, + ), + ).toMatchSnapshot(); + }); + + it('renders ColumnsColumn with inline styles', async () => { + const Component = ColumnsColumn.config.renderToReactEmail; + + expect( + await render( + + Content + , + { pretty: true }, + ), + ).toMatchSnapshot(); + }); +}); diff --git a/packages/editor/src/extensions/columns.tsx b/packages/editor/src/extensions/columns.tsx new file mode 100644 index 0000000000..ea0465e83d --- /dev/null +++ b/packages/editor/src/extensions/columns.tsx @@ -0,0 +1,265 @@ +import { Column, Row } from '@react-email/components'; +import { type CommandProps, mergeAttributes } from '@tiptap/core'; +import type { Node as ProseMirrorNode } from '@tiptap/pm/model'; +import { TextSelection } from '@tiptap/pm/state'; +import { EmailNode } from '../core/email-node'; +import { + COMMON_HTML_ATTRIBUTES, + createStandardAttributes, + LAYOUT_ATTRIBUTES, +} from '../utils/attribute-helpers'; +import { inlineCssToJs } from '../utils/styles'; + +declare module '@tiptap/core' { + interface Commands { + columns: { + insertColumns: (count: 2 | 3 | 4) => ReturnType; + }; + } +} + +export const COLUMN_PARENT_TYPES = [ + 'twoColumns', + 'threeColumns', + 'fourColumns', +] as const; + +const COLUMN_PARENT_SET = new Set(COLUMN_PARENT_TYPES); + +export const MAX_COLUMNS_DEPTH = 3; + +export function getColumnsDepth(doc: ProseMirrorNode, from: number): number { + const $from = doc.resolve(from); + let depth = 0; + for (let d = $from.depth; d > 0; d--) { + if (COLUMN_PARENT_SET.has($from.node(d).type.name)) { + depth++; + } + } + return depth; +} + +interface ColumnsVariantConfig { + name: (typeof COLUMN_PARENT_TYPES)[number]; + columnCount: number; + content: string; + dataType: string; +} + +const VARIANTS: ColumnsVariantConfig[] = [ + { + name: 'twoColumns', + columnCount: 2, + content: 'columnsColumn columnsColumn', + dataType: 'two-columns', + }, + { + name: 'threeColumns', + columnCount: 3, + content: 'columnsColumn columnsColumn columnsColumn', + dataType: 'three-columns', + }, + { + name: 'fourColumns', + columnCount: 4, + content: 'columnsColumn{4}', + dataType: 'four-columns', + }, +]; + +const NODE_TYPE_MAP: Record = { + 2: 'twoColumns', + 3: 'threeColumns', + 4: 'fourColumns', +}; + +function createColumnsNode( + config: ColumnsVariantConfig, + includeCommands: boolean, +) { + return EmailNode.create({ + name: config.name, + group: 'block', + content: config.content, + isolating: true, + defining: true, + + addAttributes() { + return createStandardAttributes([ + ...LAYOUT_ATTRIBUTES, + ...COMMON_HTML_ATTRIBUTES, + ]); + }, + + parseHTML() { + return [{ tag: `div[data-type="${config.dataType}"]` }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'div', + mergeAttributes( + { 'data-type': config.dataType, class: 'node-columns' }, + HTMLAttributes, + ), + 0, + ]; + }, + + ...(includeCommands && { + addCommands() { + return { + insertColumns: + (count: 2 | 3 | 4) => + ({ + commands, + state, + }: CommandProps & { + state: { doc: ProseMirrorNode; selection: { from: number } }; + }) => { + if ( + getColumnsDepth(state.doc, state.selection.from) >= + MAX_COLUMNS_DEPTH + ) { + return false; + } + const nodeType = NODE_TYPE_MAP[count]; + const children = Array.from({ length: count }, () => ({ + type: 'columnsColumn', + content: [{ type: 'paragraph', content: [] }], + })); + return commands.insertContent({ + type: nodeType, + content: children, + }); + }, + }; + }, + }), + + renderToReactEmail({ children, node, styles }) { + const inlineStyles = inlineCssToJs(node.attrs?.style); + return ( + + {children} + + ); + }, + }); +} + +export const TwoColumns = createColumnsNode(VARIANTS[0], true); +export const ThreeColumns = createColumnsNode(VARIANTS[1], false); +export const FourColumns = createColumnsNode(VARIANTS[2], false); + +export const ColumnsColumn = EmailNode.create({ + name: 'columnsColumn', + group: 'columnsColumn', + content: 'block+', + isolating: true, + + addAttributes() { + return { + ...createStandardAttributes([ + ...LAYOUT_ATTRIBUTES, + ...COMMON_HTML_ATTRIBUTES, + ]), + }; + }, + + parseHTML() { + return [{ tag: 'div[data-type="column"]' }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'div', + mergeAttributes( + { 'data-type': 'column', class: 'node-column' }, + HTMLAttributes, + ), + 0, + ]; + }, + + addKeyboardShortcuts() { + return { + Backspace: ({ editor }) => { + const { state } = editor; + const { selection } = state; + const { empty, $from } = selection; + + if (!empty) return false; + + for (let depth = $from.depth; depth >= 1; depth--) { + if ($from.pos !== $from.start(depth)) break; + + const indexInParent = $from.index(depth - 1); + + if (indexInParent === 0) continue; + + const parent = $from.node(depth - 1); + const prevNode = parent.child(indexInParent - 1); + + if (COLUMN_PARENT_SET.has(prevNode.type.name)) { + const deleteFrom = $from.before(depth) - prevNode.nodeSize; + const deleteTo = $from.before(depth); + editor.view.dispatch(state.tr.delete(deleteFrom, deleteTo)); + return true; + } + + break; + } + + return false; + }, + 'Mod-a': ({ editor }) => { + const { state } = editor; + const { $from } = state.selection; + + for (let d = $from.depth; d > 0; d--) { + if ($from.node(d).type.name !== 'columnsColumn') { + continue; + } + + const columnStart = $from.start(d); + const columnEnd = $from.end(d); + const { from, to } = state.selection; + + if (from === columnStart && to === columnEnd) { + return false; + } + + editor.view.dispatch( + state.tr.setSelection( + TextSelection.create(state.doc, columnStart, columnEnd), + ), + ); + return true; + } + + return false; + }, + }; + }, + + renderToReactEmail({ children, node, styles }) { + const inlineStyles = inlineCssToJs(node.attrs?.style); + const width = node.attrs?.width; + return ( + + {children} + + ); + }, +}); diff --git a/packages/editor/src/extensions/div.spec.tsx b/packages/editor/src/extensions/div.spec.tsx new file mode 100644 index 0000000000..827c4b3e8d --- /dev/null +++ b/packages/editor/src/extensions/div.spec.tsx @@ -0,0 +1,26 @@ +import { render } from '@react-email/components'; +import { RESET_THEMES } from '../plugins/theming/themes'; +import { Div } from './div'; + +describe('Div Node', () => { + it('renders React Email properly', async () => { + const Component = Div.config.renderToReactEmail; + expect(Component).toBeDefined(); + expect( + await render( + + Div content + , + { pretty: true }, + ), + ).toMatchSnapshot(); + }); +}); diff --git a/packages/editor/src/extensions/div.tsx b/packages/editor/src/extensions/div.tsx new file mode 100644 index 0000000000..02d2491d75 --- /dev/null +++ b/packages/editor/src/extensions/div.tsx @@ -0,0 +1,77 @@ +import { mergeAttributes } from '@tiptap/core'; +import { EmailNode } from '../core/email-node'; +import { + COMMON_HTML_ATTRIBUTES, + createStandardAttributes, + LAYOUT_ATTRIBUTES, +} from '../utils/attribute-helpers'; +import { inlineCssToJs } from '../utils/styles'; + +export interface DivOptions { + HTMLAttributes: Record; +} + +export const Div = EmailNode.create({ + name: 'div', + + group: 'block', + + content: 'block+', + + defining: true, + isolating: true, + + parseHTML() { + return [ + { + tag: 'div:not([data-type])', + getAttrs: (node) => { + if (typeof node === 'string') { + return false; + } + const element = node as HTMLElement; + const attrs: Record = {}; + + // Preserve all attributes + Array.from(element.attributes).forEach((attr) => { + attrs[attr.name] = attr.value; + }); + + return attrs; + }, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'div', + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), + 0, + ]; + }, + + addAttributes() { + return { + ...createStandardAttributes([ + ...COMMON_HTML_ATTRIBUTES, + ...LAYOUT_ATTRIBUTES, + ]), + }; + }, + + renderToReactEmail({ children, node, styles }) { + const inlineStyles = inlineCssToJs(node.attrs?.style); + return ( +
+ {children} +
+ ); + }, +}); diff --git a/packages/editor/src/extensions/index.ts b/packages/editor/src/extensions/index.ts new file mode 100644 index 0000000000..5f7ab5f784 --- /dev/null +++ b/packages/editor/src/extensions/index.ts @@ -0,0 +1,16 @@ +export * from './alignment-attribute'; +export * from './body'; +export * from './bold'; +export * from './button'; +export * from './class-attribute'; +export * from './code-block'; +export * from './columns'; +export * from './div'; +export * from './max-nesting'; +export * from './placeholder'; +export * from './preserved-style'; +export * from './preview-text'; +export * from './section'; +export * from './style-attribute'; +export * from './sup'; +export * from './table'; diff --git a/packages/editor/src/extensions/max-nesting.ts b/packages/editor/src/extensions/max-nesting.ts new file mode 100644 index 0000000000..baf6776849 --- /dev/null +++ b/packages/editor/src/extensions/max-nesting.ts @@ -0,0 +1,135 @@ +import { Extension } from '@tiptap/core'; +import type { NodeRange } from '@tiptap/pm/model'; +import { Plugin, PluginKey } from '@tiptap/pm/state'; + +interface MaxNestingOptions { + maxDepth: number; + nodeTypes?: string[]; +} + +export const MaxNesting = Extension.create({ + name: 'maxNesting', + + addOptions() { + return { + maxDepth: 3, + nodeTypes: undefined, + }; + }, + + addProseMirrorPlugins() { + const { maxDepth, nodeTypes } = this.options; + + if (typeof maxDepth !== 'number' || maxDepth < 1) { + throw new Error('maxDepth must be a positive number'); + } + + return [ + new Plugin({ + key: new PluginKey('maxNesting'), + + appendTransaction(transactions, _oldState, newState) { + const docChanged = transactions.some((tr) => tr.docChanged); + if (!docChanged) { + return null; + } + + // Collect all ranges that need to be lifted + const rangesToLift: { range: NodeRange; target: number }[] = []; + + newState.doc.descendants((node, pos) => { + let depth = 0; + let currentPos = pos; + let currentNode = node; + + while (currentNode && depth <= maxDepth) { + if (!nodeTypes || nodeTypes.includes(currentNode.type.name)) { + depth++; + } + + const $pos = newState.doc.resolve(currentPos); + if ($pos.depth === 0) { + break; + } + + currentPos = $pos.before($pos.depth); + currentNode = newState.doc.nodeAt(currentPos)!; + } + + if (depth > maxDepth) { + const $pos = newState.doc.resolve(pos); + if ($pos.depth > 0) { + const range = $pos.blockRange(); + if ( + range && + 'canReplace' in newState.schema.nodes.doc && + typeof newState.schema.nodes.doc.canReplace === 'function' && + newState.schema.nodes.doc.canReplace( + range.start - 1, + range.end + 1, + newState.doc.slice(range.start, range.end).content, + ) + ) { + rangesToLift.push({ range, target: range.start - 1 }); + } + } + } + }); + + if (rangesToLift.length === 0) { + return null; + } + + // Process ranges in reverse order (end to start) to maintain position validity + const tr = newState.tr; + for (let i = rangesToLift.length - 1; i >= 0; i--) { + const { range, target } = rangesToLift[i]; + tr.lift(range, target); + } + + return tr; + }, + + filterTransaction(tr) { + if (!tr.docChanged) { + return true; + } + + let wouldCreateDeepNesting = false; + const newDoc = tr.doc; + + newDoc.descendants((node, pos) => { + if (wouldCreateDeepNesting) { + return false; + } + + let depth = 0; + let currentPos = pos; + let currentNode = node; + + while (currentNode && depth <= maxDepth) { + if (!nodeTypes || nodeTypes.includes(currentNode.type.name)) { + depth++; + } + + const $pos = newDoc.resolve(currentPos); + if ($pos.depth === 0) { + break; + } + + currentPos = $pos.before($pos.depth); + currentNode = newDoc.nodeAt(currentPos)!; + } + + if (depth > maxDepth) { + wouldCreateDeepNesting = true; + return false; + } + }); + + return !wouldCreateDeepNesting; + }, + }), + ]; + }, +}); diff --git a/packages/editor/src/extensions/placeholder.spec.ts b/packages/editor/src/extensions/placeholder.spec.ts new file mode 100644 index 0000000000..162dffe70f --- /dev/null +++ b/packages/editor/src/extensions/placeholder.spec.ts @@ -0,0 +1,186 @@ +import { Editor } from '@tiptap/core'; +import StarterKit from '@tiptap/starter-kit'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { Placeholder } from './placeholder'; + +describe('Placeholder Extension', () => { + let editor: Editor; + + beforeEach(() => { + editor = new Editor({ + extensions: [StarterKit.configure({}), Placeholder], + content: '', + }); + }); + + afterEach(() => { + editor.destroy(); + }); + + describe('Extension Configuration', () => { + it('be properly configured', () => { + const extension = editor.extensionManager.extensions.find( + (ext) => ext.name === 'placeholder', + ); + + expect(extension).toBeDefined(); + expect(extension?.options.includeChildren).toBe(true); + }); + + it('allows custom configuration', () => { + const customExtension = Placeholder.configure({ + includeChildren: false, + }); + + expect(customExtension.options.placeholder).toStrictEqual( + Placeholder.options.placeholder, + ); + expect(customExtension.options.includeChildren).toBe(false); + }); + }); + + describe('Placeholder Content', () => { + it('returns default placeholder for paragraph nodes', () => { + const { schema } = editor; + const paragraphNode = schema.nodes.paragraph.create(); + + const extension = editor.extensionManager.extensions.find( + (ext) => ext.name === 'placeholder', + ); + + const placeholderText = extension?.options.placeholder({ + node: paragraphNode, + }); + + expect(placeholderText).toBe("Press '/' for commands"); + }); + + it('returns heading placeholder for heading nodes', () => { + const testEditor = new Editor({ + extensions: [StarterKit.configure({ heading: {} }), Placeholder], + content: '', + }); + + const { schema } = testEditor; + const headingNode = schema.nodes.heading.create({ level: 1 }); + + const extension = testEditor.extensionManager.extensions.find( + (ext) => ext.name === 'placeholder', + ); + + const placeholderText = extension?.options.placeholder({ + node: headingNode, + }); + + expect(placeholderText).toBe('Heading 1'); + + testEditor.destroy(); + }); + + it('supports different heading levels', () => { + const testEditor = new Editor({ + extensions: [StarterKit.configure({ heading: {} }), Placeholder], + content: '', + }); + + const { schema } = testEditor; + const extension = testEditor.extensionManager.extensions.find( + (ext) => ext.name === 'placeholder', + ); + + [1, 2, 3, 4, 5, 6].forEach((level) => { + const headingNode = schema.nodes.heading.create({ level }); + const placeholderText = extension?.options.placeholder({ + node: headingNode, + }); + expect(placeholderText).toBe(`Heading ${level}`); + }); + + testEditor.destroy(); + }); + }); + + describe('Integration with Editor', () => { + it('adds placeholder attributes to empty paragraphs', () => { + editor.commands.setContent(''); + + const editorElement = editor.view.dom; + const paragraph = editorElement.querySelector('p'); + + expect(paragraph).toBeTruthy(); + expect( + paragraph?.classList.contains('is-empty') || + paragraph?.hasAttribute('data-placeholder'), + ).toBe(true); + }); + + it('remove placeholder when content is added', () => { + editor.commands.setContent(''); + editor.commands.setContent('

Hello World

'); + + const editorElement = editor.view.dom; + const paragraph = editorElement.querySelector('p'); + + expect(paragraph?.textContent).toBe('Hello World'); + expect(paragraph?.classList.contains('is-empty')).toBe(false); + }); + + it('handles multiple paragraphs with empty lines correctly', () => { + editor.commands.setContent( + '

First paragraph

Third paragraph

', + ); + + const editorElement = editor.view.dom; + const paragraphs = editorElement.querySelectorAll('p'); + + expect(paragraphs).toHaveLength(3); + expect(paragraphs[0].textContent).toBe('First paragraph'); + expect(paragraphs[1].textContent).toBe(''); + expect(paragraphs[2].textContent).toBe('Third paragraph'); + + expect(paragraphs[1].nodeName.toLowerCase()).toBe('p'); + }); + + it('keeps placeholder on first line when all content is removed', () => { + editor.commands.setContent('

Some content

More content

'); + + let editorElement = editor.view.dom; + let paragraphs = editorElement.querySelectorAll('p'); + + expect(paragraphs).toHaveLength(2); + expect(paragraphs[0].textContent).toBe('Some content'); + expect(paragraphs[1].textContent).toBe('More content'); + + editor.commands.setContent(''); + + editorElement = editor.view.dom; + paragraphs = editorElement.querySelectorAll('p'); + + expect(paragraphs).toHaveLength(1); + expect(paragraphs[0].textContent).toBe(''); + expect( + paragraphs[0].classList.contains('is-empty') || + paragraphs[0].hasAttribute('data-placeholder'), + ).toBe(true); + }); + + it('maintains proper document structure when navigating between empty and filled paragraphs', () => { + editor.commands.setContent('

First line

Third line

'); + + const editorElement = editor.view.dom; + const paragraphs = editorElement.querySelectorAll('p'); + + expect(paragraphs).toHaveLength(3); + expect(paragraphs[0].textContent).toBe('First line'); + expect(paragraphs[1].textContent).toBe(''); + expect(paragraphs[2].textContent).toBe('Third line'); + + editor.commands.focus(); + + const emptyParagraphPos = editor.state.doc.resolve(15).pos; + editor.commands.setTextSelection(emptyParagraphPos); + + expect(paragraphs[1].nodeName.toLowerCase()).toBe('p'); + }); + }); +}); diff --git a/packages/editor/src/extensions/placeholder.ts b/packages/editor/src/extensions/placeholder.ts new file mode 100644 index 0000000000..d0d7cc9643 --- /dev/null +++ b/packages/editor/src/extensions/placeholder.ts @@ -0,0 +1,17 @@ +import TipTapPlaceholder from '@tiptap/extension-placeholder'; +import type { Node } from '@tiptap/pm/model'; + +export interface PlaceholderOptions { + placeholder?: string | ((props: { node: Node }) => string); + includeChildren?: boolean; +} + +export const Placeholder = TipTapPlaceholder.configure({ + placeholder: ({ node }) => { + if (node.type.name === 'heading') { + return `Heading ${node.attrs.level}`; + } + return "Press '/' for commands"; + }, + includeChildren: true, +}); diff --git a/packages/editor/src/extensions/preserved-style.ts b/packages/editor/src/extensions/preserved-style.ts new file mode 100644 index 0000000000..5b1b292256 --- /dev/null +++ b/packages/editor/src/extensions/preserved-style.ts @@ -0,0 +1,116 @@ +import { Mark, mergeAttributes } from '@tiptap/core'; + +export const PreservedStyle = Mark.create({ + name: 'preservedStyle', + + addAttributes() { + return { + style: { + default: null, + parseHTML: (element) => element.getAttribute('style'), + renderHTML: (attributes) => { + if (!attributes.style) { + return {}; + } + return { style: attributes.style }; + }, + }, + }; + }, + + parseHTML() { + return [ + { + tag: 'span[style]', + getAttrs: (element) => { + if (typeof element === 'string') { + return false; + } + const style = element.getAttribute('style'); + if (style && hasPreservableStyles(style)) { + return { style }; + } + return false; + }, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ['span', mergeAttributes(HTMLAttributes), 0]; + }, +}); + +const LINK_INDICATOR_STYLES = [ + 'color', + 'text-decoration', + 'text-decoration-line', + 'text-decoration-color', + 'text-decoration-style', +]; + +function parseStyleString(styleString: string): CSSStyleDeclaration { + const temp = document.createElement('div'); + temp.style.cssText = styleString; + return temp.style; +} + +function hasBackground(style: CSSStyleDeclaration): boolean { + const bgColor = style.backgroundColor; + const bg = style.background; + + if (bgColor && bgColor !== 'transparent' && bgColor !== 'rgba(0, 0, 0, 0)') { + return true; + } + + if ( + bg && + bg !== 'transparent' && + bg !== 'none' && + bg !== 'rgba(0, 0, 0, 0)' + ) { + return true; + } + + return false; +} + +function hasPreservableStyles(styleString: string): boolean { + return processStylesForUnlink(styleString) !== null; +} + +/** + * Processes styles when unlinking: + * - Has background (button-like): preserve all styles + * - No background: strip link-indicator styles (color, text-decoration), keep the rest + */ +export function processStylesForUnlink( + styleString: string | null | undefined, +): string | null { + if (!styleString) { + return null; + } + + const style = parseStyleString(styleString); + + if (hasBackground(style)) { + return styleString; + } + + const filtered: string[] = []; + + for (let i = 0; i < style.length; i++) { + const prop = style[i]; + + if (LINK_INDICATOR_STYLES.includes(prop)) { + continue; + } + + const value = style.getPropertyValue(prop); + if (value) { + filtered.push(`${prop}: ${value}`); + } + } + + return filtered.length > 0 ? filtered.join('; ') : null; +} diff --git a/packages/editor/src/extensions/preview-text.ts b/packages/editor/src/extensions/preview-text.ts new file mode 100644 index 0000000000..b2a0b70b8e --- /dev/null +++ b/packages/editor/src/extensions/preview-text.ts @@ -0,0 +1,82 @@ +import { Node } from '@tiptap/core'; + +export interface PreviewTextOptions { + HTMLAttributes: Record; +} + +export const PreviewText = Node.create({ + name: 'previewText', + + group: 'block', + + selectable: false, + draggable: false, + atom: true, + + addOptions() { + return { + HTMLAttributes: {}, + }; + }, + + addStorage() { + return { + previewText: null, + }; + }, + + renderHTML() { + return ['div', { style: 'display: none' }]; + }, + + parseHTML() { + return [ + // react-email parsing + { + tag: 'div[data-skip-in-text="true"]', + getAttrs: (node) => { + if (typeof node === 'string') { + return false; + } + const element = node as HTMLElement; + + // Extract and store preview text directly + let directText = ''; + for (const child of element.childNodes) { + if (child.nodeType === 3) { + // TEXT_NODE = 3 + // Anything other than text will be pruned + // This is particularly useful for react email, + // because we have a nested div full of white spaces that will just be ignored + directText += child.textContent || ''; + } + } + const cleanText = directText.trim(); + + if (cleanText) { + this.storage.previewText = cleanText; + } + + return false; // Don't create a node + }, + }, + // preheader class parsing + { + tag: 'span.preheader', + getAttrs: (node) => { + if (typeof node === 'string') { + return false; + } + const element = node as HTMLElement; + const preheaderText = element.textContent?.trim(); + + if (preheaderText) { + this.storage.previewText = preheaderText; + } + + return false; // Don't create a node, just extract to storage + }, + }, + ]; + }, +}); diff --git a/packages/editor/src/extensions/prism-plugin.ts b/packages/editor/src/extensions/prism-plugin.ts new file mode 100644 index 0000000000..c1b0cc2d4b --- /dev/null +++ b/packages/editor/src/extensions/prism-plugin.ts @@ -0,0 +1,242 @@ +import { findChildren } from '@tiptap/core'; +import type { Node as ProsemirrorNode } from '@tiptap/pm/model'; +import { Plugin, PluginKey } from '@tiptap/pm/state'; +import type { EditorView } from '@tiptap/pm/view'; +import { Decoration, DecorationSet } from '@tiptap/pm/view'; +import { fromHtml } from 'hast-util-from-html'; +import Prism from 'prismjs'; +import { + hasPrismThemeLoaded, + loadPrismTheme, + removePrismTheme, +} from '../utils/prism-utils'; + +const PRISM_LANGUAGE_LOADED_META = 'prismLanguageLoaded'; + +interface RefractorNode { + properties?: { className: string[] }; + children?: RefractorNode[]; + value?: string; +} + +function parseNodes( + nodes: RefractorNode[], + className: string[] = [], +): { text: string; classes: string[] }[] { + return nodes.flatMap((node) => { + const classes = [ + ...className, + ...(node.properties ? node.properties.className : []), + ]; + + if (node.children) { + return parseNodes(node.children, classes); + } + + return { + text: node.value ?? '', + classes, + }; + }); +} + +function getHighlightNodes(html: string) { + return fromHtml(html, { fragment: true }).children; +} + +function registeredLang(aliasOrLanguage: string) { + const allSupportLang = Object.keys(Prism.languages).filter( + (id) => typeof Prism.languages[id] === 'object', + ); + return Boolean(allSupportLang.find((x) => x === aliasOrLanguage)); +} + +function getDecorations({ + doc, + name, + defaultLanguage, + defaultTheme, + loadingLanguages, + onLanguageLoaded, +}: { + doc: ProsemirrorNode; + name: string; + defaultLanguage: string | null | undefined; + defaultTheme: string | null | undefined; + loadingLanguages: Set; + onLanguageLoaded: (language: string) => void; +}) { + const decorations: Decoration[] = []; + + findChildren(doc, (node) => node.type.name === name).forEach((block) => { + let from = block.pos + 1; + const language = block.node.attrs.language || defaultLanguage; + const theme = block.node.attrs.theme || defaultTheme; + let html = ''; + + try { + if (!registeredLang(language) && !loadingLanguages.has(language)) { + loadingLanguages.add(language); + import(`prismjs/components/prism-${language}`) + .then(() => { + loadingLanguages.delete(language); + onLanguageLoaded(language); + }) + .catch(() => { + loadingLanguages.delete(language); + }); + } + + if (!hasPrismThemeLoaded(theme)) { + loadPrismTheme(theme); + } + + html = Prism.highlight( + block.node.textContent, + Prism.languages[language], + language, + ); + } catch { + html = Prism.highlight( + block.node.textContent, + Prism.languages.javascript, + 'js', + ); + } + + const nodes = getHighlightNodes(html); + + parseNodes(nodes as RefractorNode[]).forEach((node) => { + const to = from + node.text.length; + + if (node.classes.length) { + const decoration = Decoration.inline(from, to, { + class: node.classes.join(' '), + }); + + decorations.push(decoration); + } + + from = to; + }); + }); + + return DecorationSet.create(doc, decorations); +} + +export function PrismPlugin({ + name, + defaultLanguage, + defaultTheme, +}: { + name: string; + defaultLanguage: string; + defaultTheme: string; +}) { + if (!defaultLanguage) { + throw Error('You must specify the defaultLanguage parameter'); + } + + const loadingLanguages = new Set(); + let pluginView: EditorView | null = null; + + const onLanguageLoaded = (language: string) => { + if (pluginView) { + pluginView.dispatch( + pluginView.state.tr.setMeta(PRISM_LANGUAGE_LOADED_META, language), + ); + } + }; + + const prismjsPlugin: Plugin = new Plugin({ + key: new PluginKey('prism'), + + view(view) { + pluginView = view; + return { + destroy() { + pluginView = null; + }, + }; + }, + + state: { + init: (_, { doc }) => { + return getDecorations({ + doc, + name, + defaultLanguage, + defaultTheme, + loadingLanguages, + onLanguageLoaded, + }); + }, + apply: (transaction, decorationSet, oldState, newState) => { + const oldNodeName = oldState.selection.$head.parent.type.name; + const newNodeName = newState.selection.$head.parent.type.name; + + const oldNodes = findChildren( + oldState.doc, + (node) => node.type.name === name, + ); + const newNodes = findChildren( + newState.doc, + (node) => node.type.name === name, + ); + + if ( + transaction.getMeta(PRISM_LANGUAGE_LOADED_META) || + (transaction.docChanged && + // Apply decorations if: + // selection includes named node, + ([oldNodeName, newNodeName].includes(name) || + // OR transaction adds/removes named node, + newNodes.length !== oldNodes.length || + // OR transaction has changes that completely encapsulate a node + // (for example, a transaction that affects the entire document). + // Such transactions can happen during collab syncing via y-prosemirror, for example. + transaction.steps.some((step) => { + const rangeStep = step as unknown as { + from?: number; + to?: number; + }; + return ( + rangeStep.from !== undefined && + rangeStep.to !== undefined && + oldNodes.some((node) => { + return ( + node.pos >= rangeStep.from! && + node.pos + node.node.nodeSize <= rangeStep.to! + ); + }) + ); + }))) + ) { + return getDecorations({ + doc: transaction.doc, + name, + defaultLanguage, + defaultTheme, + loadingLanguages, + onLanguageLoaded, + }); + } + + return decorationSet.map(transaction.mapping, transaction.doc); + }, + }, + + props: { + decorations(state) { + return prismjsPlugin.getState(state); + }, + }, + + destroy() { + pluginView = null; + removePrismTheme(); + }, + }); + + return prismjsPlugin; +} diff --git a/packages/editor/src/extensions/section.spec.tsx b/packages/editor/src/extensions/section.spec.tsx new file mode 100644 index 0000000000..f9d8c1be21 --- /dev/null +++ b/packages/editor/src/extensions/section.spec.tsx @@ -0,0 +1,27 @@ +import { render } from '@react-email/components'; +import { RESET_THEMES } from '../plugins/theming/themes'; +import { Section } from './section'; + +describe('Section Node', () => { + it('renders React Email properly', async () => { + const Component = Section.config.renderToReactEmail; + expect(Component).toBeDefined(); + expect( + await render( + + Section content + , + { pretty: true }, + ), + ).toMatchSnapshot(); + }); +}); diff --git a/packages/editor/src/extensions/section.tsx b/packages/editor/src/extensions/section.tsx new file mode 100644 index 0000000000..00a53f9e28 --- /dev/null +++ b/packages/editor/src/extensions/section.tsx @@ -0,0 +1,81 @@ +import { Section as ReactEmailSection } from '@react-email/components'; +import { mergeAttributes } from '@tiptap/core'; +import type * as React from 'react'; +import { EmailNode } from '../core/email-node'; +import { getTextAlignment } from '../utils/get-text-alignment'; +import { inlineCssToJs } from '../utils/styles'; + +interface SectionOptions { + HTMLAttributes: Record; + [key: string]: unknown; +} + +declare module '@tiptap/core' { + interface Commands { + section: { + insertSection: () => ReturnType; + }; + } +} + +export const Section = EmailNode.create({ + name: 'section', + group: 'block', + content: 'block+', + isolating: true, + defining: true, + + parseHTML() { + return [{ tag: 'section[data-type="section"]' }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'section', + mergeAttributes( + { 'data-type': 'section', class: 'node-section' }, + HTMLAttributes, + ), + 0, + ]; + }, + + addCommands() { + return { + insertSection: + () => + ({ commands }) => { + return commands.insertContent({ + type: this.name, + content: [ + { + type: 'paragraph', + content: [], + }, + ], + }); + }, + }; + }, + + renderToReactEmail({ children, node, styles }) { + const inlineStyles = inlineCssToJs(node.attrs?.style); + const textAlign = node.attrs?.align || node.attrs?.alignment; + + return ( + + {children} + + ); + }, +}); diff --git a/packages/editor/src/extensions/style-attribute.spec.ts b/packages/editor/src/extensions/style-attribute.spec.ts new file mode 100644 index 0000000000..3afbe79b1c --- /dev/null +++ b/packages/editor/src/extensions/style-attribute.spec.ts @@ -0,0 +1,84 @@ +/* @vitest-environment node */ + +import { generateJSON } from '@tiptap/html'; +import StarterKit from '@tiptap/starter-kit'; +import { Div } from './div'; +// import { Heading } from './heaidng'; +import { StyleAttribute } from './style-attribute'; +import { Table, TableCell, TableHeader, TableRow } from './table'; + +const extensions: Parameters[1] = [ + StarterKit.configure({ + heading: false, + }) as (typeof extensions)[number], + // Heading, + Div, + Table, + TableRow, + TableCell, + TableHeader, + StyleAttribute.configure({ + types: [ + // 'heading', + 'paragraph', + 'div', + 'table', + 'tableRow', + 'tableCell', + 'tableHeader', + ], + }), +]; + +function getStyleFromJson(json: ReturnType) { + return json.content?.[0]?.attrs?.style; +} + +describe('StyleAttribute', () => { + it('preserves inline styles on paragraph', () => { + const html = + '

Hello

'; + const json = generateJSON(html, extensions); + expect(getStyleFromJson(json)).toBe( + 'font-size: 14px; color: rgb(0, 0, 0); font-family: Arial', + ); + }); + + // it('preserves inline styles on heading', () => { + // const html = '

Title

'; + // const json = generateJSON(html, extensions); + // expect(getStyleFromJson(json)).toBe('margin: 0; font-size: 32px'); + // }); + + it('preserves inline styles on div', () => { + const html = + '

Content

'; + const json = generateJSON(html, extensions); + expect(getStyleFromJson(json)).toBe( + 'padding: 20px 0; background-color: #f4f4f5', + ); + }); + + it('preserves inline styles on table elements', () => { + const html = ` + + + +
Cell
`; + const json = generateJSON(html, extensions); + + expect(json.content[0].attrs.style).toBe( + 'border-collapse: collapse; width: 100%', + ); + const row = json.content[0].content[0]; + expect(row.attrs.style).toBe('background-color: #ffffff'); + const cell = row.content[0]; + expect(cell.attrs.style).toBe('padding: 8px; border: 1px solid #e5e7eb'); + }); + + it('returns empty string when no style attribute', () => { + const html = '

No styles

'; + const json = generateJSON(html, extensions); + expect(getStyleFromJson(json)).toBe(''); + }); +}); diff --git a/packages/editor/src/extensions/style-attribute.tsx b/packages/editor/src/extensions/style-attribute.tsx new file mode 100644 index 0000000000..93f74c86a2 --- /dev/null +++ b/packages/editor/src/extensions/style-attribute.tsx @@ -0,0 +1,99 @@ +import { Extension } from '@tiptap/core'; + +interface StyleAttributeOptions { + types: string[]; + style: string[]; +} + +declare module '@tiptap/core' { + interface Commands { + textAlign: { + /** + * Set the style attribute + */ + setStyle: (style: string) => ReturnType; + /** + * Unset the style attribute + */ + unsetStyle: () => ReturnType; + }; + } +} + +export const StyleAttribute = Extension.create({ + name: 'styleAttribute', + priority: 101, + + addOptions() { + return { + types: [], + style: [], + }; + }, + + addGlobalAttributes() { + return [ + { + types: this.options.types, + attributes: { + style: { + default: '', + parseHTML: (element) => element.getAttribute('style') || '', + renderHTML: (attributes) => { + return { style: attributes.style ?? '' }; + }, + }, + }, + }, + ]; + }, + + addCommands() { + return { + unsetStyle: + () => + ({ commands }) => { + return this.options.types.every((type) => + commands.resetAttributes(type, 'style'), + ); + }, + setStyle: + (style: string) => + ({ commands }) => { + return this.options.types.every((type) => + commands.updateAttributes(type, { style }), + ); + }, + }; + }, + + addKeyboardShortcuts() { + return { + Enter: ({ editor }) => { + // Check if any suggestion plugin is active by looking for decorations + // that indicate an active suggestion/autocomplete + const { state } = editor.view; + const { selection } = state; + const { $from } = selection; + + // Check if we're in a position where suggestion might be active + // by looking at the text before cursor for trigger characters + const textBefore = $from.nodeBefore?.text || ''; + const hasTrigger = + textBefore.includes('{{') || textBefore.includes('{{{'); + + // If we have trigger characters, assume suggestion might be handling this + // Don't reset styles + if (hasTrigger) { + return false; + } + + // Otherwise, reset paragraph styles on Enter + requestAnimationFrame(() => { + editor.commands.resetAttributes('paragraph', 'style'); + }); + return false; + }, + }; + }, +}); diff --git a/packages/editor/src/extensions/sup.ts b/packages/editor/src/extensions/sup.ts new file mode 100644 index 0000000000..537d5ba77e --- /dev/null +++ b/packages/editor/src/extensions/sup.ts @@ -0,0 +1,79 @@ +import { Mark, mergeAttributes } from '@tiptap/core'; + +export interface SupOptions { + /** + * HTML attributes to add to the sup element. + * @default {} + * @example { class: 'foo' } + */ + HTMLAttributes: Record; +} + +declare module '@tiptap/core' { + interface Commands { + sup: { + /** + * Set a superscript mark + */ + setSup: () => ReturnType; + /** + * Toggle a superscript mark + */ + toggleSup: () => ReturnType; + /** + * Unset a superscript mark + */ + unsetSup: () => ReturnType; + }; + } +} + +/** + * This extension allows you to mark text as superscript. + * @see https://tiptap.dev/api/marks/superscript + */ +export const Sup = Mark.create({ + name: 'sup', + + addOptions() { + return { + HTMLAttributes: {}, + }; + }, + + parseHTML() { + return [ + { + tag: 'sup', + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'sup', + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), + 0, + ]; + }, + + addCommands() { + return { + setSup: + () => + ({ commands }) => { + return commands.setMark(this.name); + }, + toggleSup: + () => + ({ commands }) => { + return commands.toggleMark(this.name); + }, + unsetSup: + () => + ({ commands }) => { + return commands.unsetMark(this.name); + }, + }; + }, +}); diff --git a/packages/editor/src/extensions/table.spec.tsx b/packages/editor/src/extensions/table.spec.tsx new file mode 100644 index 0000000000..5289306626 --- /dev/null +++ b/packages/editor/src/extensions/table.spec.tsx @@ -0,0 +1,67 @@ +import { render } from '@react-email/components'; +import { RESET_THEMES } from '../plugins/theming/themes'; +import { Table, TableCell, TableRow } from './table'; + +describe('Table Nodes', () => { + it('renders Table React Email properly', async () => { + const Component = Table.config.renderToReactEmail; + expect(Component).toBeDefined(); + expect( + await render( + + Table content + , + { pretty: true }, + ), + ).toMatchSnapshot(); + }); + + it('renders TableRow React Email properly', async () => { + const Component = TableRow.config.renderToReactEmail; + expect(Component).toBeDefined(); + expect( + await render( + + Row content + , + { pretty: true }, + ), + ).toMatchSnapshot(); + }); + + it('renders TableCell React Email properly', async () => { + const Component = TableCell.config.renderToReactEmail; + expect(Component).toBeDefined(); + expect( + await render( + + Cell content + , + { pretty: true }, + ), + ).toMatchSnapshot(); + }); +}); diff --git a/packages/editor/src/extensions/table.tsx b/packages/editor/src/extensions/table.tsx new file mode 100644 index 0000000000..373358be27 --- /dev/null +++ b/packages/editor/src/extensions/table.tsx @@ -0,0 +1,280 @@ +import { Column, Section } from '@react-email/components'; +import type { ParentConfig } from '@tiptap/core'; +import { mergeAttributes, Node } from '@tiptap/core'; +import { EmailNode } from '../core/email-node'; +import { + COMMON_HTML_ATTRIBUTES, + createStandardAttributes, + LAYOUT_ATTRIBUTES, + TABLE_ATTRIBUTES, + TABLE_CELL_ATTRIBUTES, + TABLE_HEADER_ATTRIBUTES, +} from '../utils/attribute-helpers'; +import { inlineCssToJs, resolveConflictingStyles } from '../utils/styles'; + +declare module '@tiptap/core' { + interface NodeConfig { + /** + * A string or function to determine the role of the table. + * @default 'table' + * @example () => 'table' + */ + tableRole?: + | string + | ((this: { + name: string; + options: Options; + storage: Storage; + parent: ParentConfig>['tableRole']; + }) => string); + } +} + +export interface TableOptions { + HTMLAttributes: Record; +} + +export const Table = EmailNode.create({ + name: 'table', + + group: 'block', + + content: 'tableRow+', + + isolating: true, + + tableRole: 'table', + + addAttributes() { + return { + ...createStandardAttributes([ + ...TABLE_ATTRIBUTES, + ...LAYOUT_ATTRIBUTES, + ...COMMON_HTML_ATTRIBUTES, + ]), + }; + }, + + parseHTML() { + return [ + { + tag: 'table', + getAttrs: (node) => { + if (typeof node === 'string') { + return false; + } + const element = node as HTMLElement; + const attrs: Record = {}; + + Array.from(element.attributes).forEach((attr) => { + attrs[attr.name] = attr.value; + }); + + return attrs; + }, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + const attrs = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes); + + return ['table', attrs, ['tbody', {}, 0]]; + }, + + renderToReactEmail({ children, node, styles }) { + const inlineStyles = inlineCssToJs(node.attrs?.style); + const alignment = node.attrs?.align || node.attrs?.alignment; + const width = node.attrs?.width; + + const centeringStyles: Record = + alignment === 'center' ? { marginLeft: 'auto', marginRight: 'auto' } : {}; + + return ( +
+ {children} +
+ ); + }, +}); + +interface TableRowOptions extends Record { + HTMLAttributes?: Record; +} + +export const TableRow = EmailNode.create({ + name: 'tableRow', + + group: 'tableRow', + + content: '(tableCell | tableHeader)+', + + addAttributes() { + return { + ...createStandardAttributes([ + ...TABLE_CELL_ATTRIBUTES, + ...LAYOUT_ATTRIBUTES, + ...COMMON_HTML_ATTRIBUTES, + ]), + }; + }, + + parseHTML() { + return [ + { + tag: 'tr', + getAttrs: (node) => { + if (typeof node === 'string') { + return false; + } + const element = node as HTMLElement; + const attrs: Record = {}; + + Array.from(element.attributes).forEach((attr) => { + attrs[attr.name] = attr.value; + }); + + return attrs; + }, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ['tr', HTMLAttributes, 0]; + }, + + renderToReactEmail({ children, node, styles }) { + const inlineStyles = inlineCssToJs(node.attrs?.style); + return ( + + {children} + + ); + }, +}); + +interface TableCellOptions extends Record { + HTMLAttributes?: Record; +} + +export const TableCell = EmailNode.create({ + name: 'tableCell', + + group: 'tableCell', + + content: 'block+', + + isolating: true, + + addAttributes() { + return { + ...createStandardAttributes([ + ...TABLE_CELL_ATTRIBUTES, + ...LAYOUT_ATTRIBUTES, + ...COMMON_HTML_ATTRIBUTES, + ]), + }; + }, + + parseHTML() { + return [ + { + tag: 'td', + getAttrs: (node) => { + if (typeof node === 'string') { + return false; + } + const element = node as HTMLElement; + const attrs: Record = {}; + + Array.from(element.attributes).forEach((attr) => { + attrs[attr.name] = attr.value; + }); + + return attrs; + }, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ['td', HTMLAttributes, 0]; + }, + + renderToReactEmail({ children, node, styles }) { + const inlineStyles = inlineCssToJs(node.attrs?.style); + return ( + + {children} + + ); + }, +}); + +export const TableHeader = Node.create({ + name: 'tableHeader', + + group: 'tableCell', + + content: 'block+', + + isolating: true, + + addAttributes() { + return { + ...createStandardAttributes([ + ...TABLE_HEADER_ATTRIBUTES, + ...TABLE_CELL_ATTRIBUTES, + ...LAYOUT_ATTRIBUTES, + ...COMMON_HTML_ATTRIBUTES, + ]), + }; + }, + + parseHTML() { + return [ + { + tag: 'th', + getAttrs: (node) => { + if (typeof node === 'string') { + return false; + } + const element = node as HTMLElement; + const attrs: Record = {}; + + Array.from(element.attributes).forEach((attr) => { + attrs[attr.name] = attr.value; + }); + + return attrs; + }, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ['th', HTMLAttributes, 0]; + }, +}); diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts new file mode 100644 index 0000000000..a158f47bbe --- /dev/null +++ b/packages/editor/src/index.ts @@ -0,0 +1,2 @@ +export * from './core'; +export * from './extensions'; diff --git a/packages/editor/src/plugins/theming/themes.ts b/packages/editor/src/plugins/theming/themes.ts new file mode 100644 index 0000000000..a1d837d4b9 --- /dev/null +++ b/packages/editor/src/plugins/theming/themes.ts @@ -0,0 +1,560 @@ +import type { + EditorThemes, + PanelGroup, + ResetTheme, + SupportedCssProperties, +} from '../../utils/types'; + +const THEME_BASIC: PanelGroup[] = [ + { + title: 'Body', + classReference: 'body', + inputs: [], + }, + { + title: 'Container', + classReference: 'container', + inputs: [ + { + label: 'Align', + type: 'select', + value: 'left', + options: { + left: 'Left', + center: 'Center', + right: 'Right', + }, + prop: 'align', + classReference: 'container', + }, + { + label: 'Width', + type: 'number', + value: 600, + unit: 'px', + prop: 'width', + classReference: 'container', + }, + { + label: 'Padding Left', + type: 'number', + value: 0, + unit: 'px', + prop: 'paddingLeft', + classReference: 'container', + }, + { + label: 'Padding Right', + type: 'number', + value: 0, + unit: 'px', + prop: 'paddingRight', + classReference: 'container', + }, + ], + }, + { + title: 'Typography', + classReference: 'body', + inputs: [ + { + label: 'Font size', + type: 'number', + value: 14, + unit: 'px', + prop: 'fontSize', + classReference: 'body', + }, + { + label: 'Line Height', + type: 'number', + value: 155, + unit: '%', + prop: 'lineHeight', + classReference: 'container', + }, + ], + }, + { + title: 'Link', + classReference: 'link', + inputs: [ + { + label: 'Color', + type: 'color', + value: '#0670DB', + prop: 'color', + classReference: 'link', + }, + { + label: 'Decoration', + type: 'select', + value: 'underline', + prop: 'textDecoration', + options: { + underline: 'Underline', + none: 'None', + }, + classReference: 'link', + }, + ], + }, + { + title: 'Image', + classReference: 'image', + inputs: [ + { + label: 'Border radius', + type: 'number', + value: 8, + unit: 'px', + prop: 'borderRadius', + classReference: 'image', + }, + ], + }, + { + title: 'Button', + classReference: 'button', + inputs: [ + { + label: 'Background', + type: 'color', + value: '#000000', + prop: 'backgroundColor', + classReference: 'button', + }, + { + label: 'Text color', + type: 'color', + value: '#ffffff', + prop: 'color', + classReference: 'button', + }, + { + label: 'Radius', + type: 'number', + value: 4, + unit: 'px', + prop: 'borderRadius', + classReference: 'button', + }, + { + label: 'Padding Top', + type: 'number', + value: 7, + unit: 'px', + prop: 'paddingTop', + classReference: 'button', + }, + { + label: 'Padding Right', + type: 'number', + value: 12, + unit: 'px', + prop: 'paddingRight', + classReference: 'button', + }, + { + label: 'Padding Bottom', + type: 'number', + value: 7, + unit: 'px', + prop: 'paddingBottom', + classReference: 'button', + }, + { + label: 'Padding Left', + type: 'number', + value: 12, + unit: 'px', + prop: 'paddingLeft', + classReference: 'button', + }, + ], + }, + { + title: 'Code Block', + classReference: 'codeBlock', + inputs: [ + { + label: 'Border Radius', + type: 'number', + value: 4, + unit: 'px', + prop: 'borderRadius', + classReference: 'codeBlock', + }, + { + label: 'Padding Top', + type: 'number', + value: 12, + unit: 'px', + prop: 'paddingTop', + classReference: 'codeBlock', + }, + { + label: 'Padding Bottom', + type: 'number', + value: 12, + unit: 'px', + prop: 'paddingBottom', + classReference: 'codeBlock', + }, + { + label: 'Padding Left', + type: 'number', + value: 16, + unit: 'px', + prop: 'paddingLeft', + classReference: 'codeBlock', + }, + { + label: 'Padding Right', + type: 'number', + value: 16, + unit: 'px', + prop: 'paddingRight', + classReference: 'codeBlock', + }, + ], + }, + { + title: 'Inline Code', + classReference: 'inlineCode', + inputs: [ + { + label: 'Background', + type: 'color', + value: '#e5e7eb', + prop: 'backgroundColor', + classReference: 'inlineCode', + }, + { + label: 'Text color', + type: 'color', + value: '#1e293b', + prop: 'color', + classReference: 'inlineCode', + }, + { + label: 'Radius', + type: 'number', + value: 4, + unit: 'px', + prop: 'borderRadius', + classReference: 'inlineCode', + }, + ], + }, +]; + +const THEME_MINIMAL = THEME_BASIC.map((item) => ({ ...item, inputs: [] })); + +const RESET_BASIC: ResetTheme = { + reset: { + margin: '0', + padding: '0', + }, + body: { + fontFamily: + "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif", + fontSize: '16px', + minHeight: '100%', + lineHeight: '155%', + }, + container: {}, + h1: { + fontSize: '2.25em', + lineHeight: '1.44em', + paddingTop: '0.389em', + fontWeight: 600, + }, + h2: { + fontSize: '1.8em', + lineHeight: '1.44em', + paddingTop: '0.389em', + fontWeight: 600, + }, + h3: { + fontSize: '1.4em', + lineHeight: '1.08em', + paddingTop: '0.389em', + fontWeight: 600, + }, + paragraph: { + fontSize: '1em', + paddingTop: '0.5em', + paddingBottom: '0.5em', + }, + list: { + paddingLeft: '1.1em', + paddingBottom: '1em', + }, + nestedList: { + paddingLeft: '1.1em', + paddingBottom: '0', + }, + listItem: { + marginLeft: '1em', + marginBottom: '0.3em', + marginTop: '0.3em', + }, + listParagraph: { padding: '0', margin: '0' }, + blockquote: { + borderLeft: '3px solid #acb3be', + color: '#7e8a9a', + marginLeft: 0, + paddingLeft: '0.8em', + fontSize: '1.1em', + fontFamily: + "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif", + }, + link: { textDecoration: 'underline' }, + footer: { + fontSize: '0.8em', + }, + hr: { + paddingBottom: '1em', + borderWidth: '2px', + }, + image: { + maxWidth: '100%', + }, + button: { + lineHeight: '100%', + display: 'inline-block', + }, + inlineCode: { + paddingTop: '0.25em', + paddingBottom: '0.25em', + paddingLeft: '0.4em', + paddingRight: '0.4em', + background: '#e5e7eb', + color: '#1e293b', + borderRadius: '4px', + }, + codeBlock: { + fontFamily: 'monospace', + fontWeight: '500', + fontSize: '.92em', + }, + codeTag: { + lineHeight: '130%', + fontFamily: 'monospace', + fontSize: '.92em', + }, + section: { + padding: '10px 20px 10px 20px', + boxSizing: 'border-box' as const, + }, +}; + +const RESET_MINIMAL: ResetTheme = { + ...Object.keys(RESET_BASIC).reduce((acc, key) => { + acc[key as keyof ResetTheme] = {}; + return acc; + }, {} as ResetTheme), + reset: RESET_BASIC.reset, +}; + +export const RESET_THEMES: Record = { + basic: RESET_BASIC, + minimal: RESET_MINIMAL, +}; + +export const EDITOR_THEMES: Record = { + basic: THEME_BASIC, + minimal: THEME_MINIMAL, +}; + +/** + * Use to make the preview nicer once the theme might miss some + * important properties to make layout accurate + */ +export const PREVIEW_THEME_OVERWRITE: Partial = { + body: { + color: '#000000', + fontSize: '14px', + lineHeight: '155%', + }, + container: { + width: 600, + }, +}; + +export const SUPPORTED_CSS_PROPERTIES: SupportedCssProperties = { + align: { + label: 'Align', + type: 'select', + options: { + left: 'Left', + center: 'Center', + right: 'Right', + }, + defaultValue: 'left', + category: 'layout', + }, + backgroundColor: { + label: 'Background', + type: 'color', + excludeNodes: ['image', 'youtube'], + defaultValue: '#ffffff', + category: 'appearance', + }, + color: { + label: 'Text color', + type: 'color', + excludeNodes: ['image', 'youtube'], + defaultValue: '#000000', + category: 'typography', + }, + fontSize: { + label: 'Font size', + type: 'number', + unit: 'px', + excludeNodes: ['image', 'youtube'], + defaultValue: 14, + category: 'typography', + }, + fontWeight: { + label: 'Font weight', + type: 'select', + options: { + 300: 'Light', + 400: 'Normal', + 600: 'Semi Bold', + 700: 'Bold', + 800: 'Extra Bold', + }, + excludeNodes: ['image', 'youtube'], + defaultValue: 400, + category: 'typography', + }, + lineHeight: { + label: 'Line height', + type: 'number', + unit: '%', + defaultValue: 155, + category: 'typography', + }, + textDecoration: { + label: 'Text decoration', + type: 'select', + options: { + none: 'None', + underline: 'Underline', + 'line-through': 'Line-through', + }, + defaultValue: 'none', + category: 'typography', + }, + borderRadius: { + label: 'Border Radius', + type: 'number', + unit: 'px', + defaultValue: 8, + category: 'appearance', + }, + borderTopLeftRadius: { + label: 'Border Radius (Top-Left)', + type: 'number', + unit: 'px', + defaultValue: 8, + category: 'appearance', + }, + borderTopRightRadius: { + label: 'Border Radius (Top-Right)', + type: 'number', + unit: 'px', + defaultValue: 8, + category: 'appearance', + }, + borderBottomLeftRadius: { + label: 'Border Radius (Bottom-Left)', + type: 'number', + unit: 'px', + defaultValue: 8, + category: 'appearance', + }, + borderBottomRightRadius: { + label: 'Border Radius (Bottom-Right)', + type: 'number', + unit: 'px', + defaultValue: 8, + category: 'appearance', + }, + borderWidth: { + label: 'Border Width', + type: 'number', + unit: 'px', + defaultValue: 1, + category: 'appearance', + }, + borderStyle: { + label: 'Border Style', + type: 'select', + options: { + solid: 'Solid', + dashed: 'Dashed', + dotted: 'Dotted', + }, + defaultValue: 'solid', + category: 'appearance', + }, + borderColor: { + label: 'Border Color', + type: 'color', + defaultValue: '#000000', + category: 'appearance', + }, + padding: { + label: 'Padding', + type: 'number', + unit: 'px', + defaultValue: 8, + category: 'layout', + }, + paddingTop: { + label: 'Padding Top', + type: 'number', + unit: 'px', + defaultValue: 8, + category: 'layout', + }, + paddingLeft: { + label: 'Padding Left', + type: 'number', + unit: 'px', + defaultValue: 8, + category: 'layout', + }, + paddingBottom: { + label: 'Padding Bottom', + type: 'number', + unit: 'px', + defaultValue: 8, + category: 'layout', + }, + paddingRight: { + label: 'Padding Right', + type: 'number', + unit: 'px', + defaultValue: 8, + category: 'layout', + }, + width: { + label: 'Width', + type: 'number', + unit: 'px', + defaultValue: 600, + category: 'layout', + }, + height: { + label: 'Height', + type: 'number', + unit: 'px', + defaultValue: 400, + category: 'layout', + }, +}; diff --git a/packages/editor/src/utils/attribute-helpers.ts b/packages/editor/src/utils/attribute-helpers.ts new file mode 100644 index 0000000000..c8364aca42 --- /dev/null +++ b/packages/editor/src/utils/attribute-helpers.ts @@ -0,0 +1,89 @@ +/** + * Creates TipTap attribute definitions for a list of HTML attributes. + * Each attribute will have the same pattern: + * - default: null + * - parseHTML: extracts the attribute from the element + * - renderHTML: conditionally renders the attribute if it has a value + * + * @param attributeNames - Array of HTML attribute names to create definitions for + * @returns Object with TipTap attribute definitions + * + * @example + * const attrs = createStandardAttributes(['class', 'id', 'title']); + * // Returns: + * // { + * // class: { + * // default: null, + * // parseHTML: (element) => element.getAttribute('class'), + * // renderHTML: (attributes) => attributes.class ? { class: attributes.class } : {} + * // }, + * // ... + * // } + */ +export function createStandardAttributes(attributeNames: readonly string[]) { + return Object.fromEntries( + attributeNames.map((attr) => [ + attr, + { + default: null, + parseHTML: (element: HTMLElement) => element.getAttribute(attr), + renderHTML: (attributes: Record) => { + if (!attributes[attr]) { + return {}; + } + + return { + [attr]: attributes[attr], + }; + }, + }, + ]), + ); +} + +/** + * Common HTML attributes used across multiple extensions. + * These preserve attributes during HTML import and editing for better + * fidelity when importing existing email templates. + */ +export const COMMON_HTML_ATTRIBUTES = [ + 'id', + 'class', + 'title', + 'lang', + 'dir', + 'data-id', +] as const; + +/** + * Layout-specific HTML attributes used for positioning and sizing. + */ +export const LAYOUT_ATTRIBUTES = ['align', 'width', 'height'] as const; + +/** + * Table-specific HTML attributes used for table layout and styling. + */ +export const TABLE_ATTRIBUTES = [ + 'border', + 'cellpadding', + 'cellspacing', +] as const; + +/** + * Table cell-specific HTML attributes. + */ +export const TABLE_CELL_ATTRIBUTES = [ + 'valign', + 'bgcolor', + 'colspan', + 'rowspan', +] as const; + +/** + * Table header cell-specific HTML attributes. + * These are additional attributes that only apply to elements. + */ +export const TABLE_HEADER_ATTRIBUTES = [ + ...TABLE_CELL_ATTRIBUTES, + 'scope', +] as const; diff --git a/packages/editor/src/utils/get-text-alignment.ts b/packages/editor/src/utils/get-text-alignment.ts new file mode 100644 index 0000000000..3d31f982b8 --- /dev/null +++ b/packages/editor/src/utils/get-text-alignment.ts @@ -0,0 +1,12 @@ +export function getTextAlignment(alignment: string | undefined) { + switch (alignment) { + case 'left': + return { textAlign: 'left' }; + case 'center': + return { textAlign: 'center' }; + case 'right': + return { textAlign: 'right' }; + default: + return {}; + } +} diff --git a/packages/editor/src/utils/prism-utils.ts b/packages/editor/src/utils/prism-utils.ts new file mode 100644 index 0000000000..6600d604f2 --- /dev/null +++ b/packages/editor/src/utils/prism-utils.ts @@ -0,0 +1,30 @@ +const publicURL = '/styles/prism'; + +export function loadPrismTheme(theme: string) { + // Create new link element for the new theme + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = `${publicURL}/prism-${theme}.css`; + link.setAttribute('data-prism-theme', ''); // Mark this link as the Prism theme + + // Append the new link element to the head + document.head.appendChild(link); +} + +export function removePrismTheme() { + const existingTheme = document.querySelectorAll( + 'link[rel="stylesheet"][data-prism-theme]', + ); + if (existingTheme.length > 0) { + existingTheme.forEach((cssLinkTag) => { + cssLinkTag.remove(); + }); + } +} + +export function hasPrismThemeLoaded(theme: string) { + const existingTheme = document.querySelector( + `link[rel="stylesheet"][data-prism-theme][href="${publicURL}/prism-${theme}.css"]`, + ); + return !!existingTheme; +} diff --git a/packages/editor/src/utils/styles.spec.ts b/packages/editor/src/utils/styles.spec.ts new file mode 100644 index 0000000000..ea9552c211 --- /dev/null +++ b/packages/editor/src/utils/styles.spec.ts @@ -0,0 +1,834 @@ +import { + expandShorthandProperties, + inlineCssToJs, + jsToInlineCss, + resolveConflictingStyles, +} from './styles'; + +vi.mock('@/actions/ai', () => ({ + uploadImageViaAI: vi.fn(), +})); + +describe('resolveConflictingStyles', () => { + describe('basic functionality', () => { + it('should merge reset styles with inline styles', () => { + const resetStyles = { margin: '0', padding: '0' }; + const inlineStyles = { color: 'red' }; + + const result = resolveConflictingStyles(resetStyles, inlineStyles); + + expect(result).toEqual({ + marginTop: '0', + marginRight: '0', + marginBottom: '0', + marginLeft: '0', + paddingTop: '0', + paddingRight: '0', + paddingBottom: '0', + paddingLeft: '0', + color: 'red', + }); + }); + + it('should allow inline styles to override expanded reset styles', () => { + const resetStyles = { margin: '0' }; + const inlineStyles = { marginTop: '10px' }; + + const result = resolveConflictingStyles(resetStyles, inlineStyles); + + expect(result).toEqual({ + marginTop: '10px', + marginRight: '0', + marginBottom: '0', + marginLeft: '0', + }); + }); + }); + + describe('margin conflicts', () => { + it('should resolve margin auto centering conflict', () => { + const resetStyles = { margin: '0' }; + const inlineStyles = { marginLeft: 'auto', marginRight: 'auto' }; + + const result = resolveConflictingStyles(resetStyles, inlineStyles); + + expect(result).toEqual({ + marginTop: '0', + marginRight: 'auto', + marginBottom: '0', + marginLeft: 'auto', + }); + }); + + it('should allow partial margin overrides', () => { + const resetStyles = { margin: '0' }; + const inlineStyles = { marginTop: '20px', marginBottom: '20px' }; + + const result = resolveConflictingStyles(resetStyles, inlineStyles); + + expect(result).toEqual({ + marginTop: '20px', + marginRight: '0', + marginBottom: '20px', + marginLeft: '0', + }); + }); + + it('should handle margin with multiple values in reset', () => { + const resetStyles = { margin: '10px 20px' }; + const inlineStyles = { marginLeft: 'auto' }; + + const result = resolveConflictingStyles(resetStyles, inlineStyles); + + expect(result).toEqual({ + marginTop: '10px', + marginRight: '20px', + marginBottom: '10px', + marginLeft: 'auto', + }); + }); + }); + + describe('padding conflicts', () => { + it('should resolve padding conflicts', () => { + const resetStyles = { padding: '0' }; + const inlineStyles = { paddingTop: '15px', paddingBottom: '15px' }; + + const result = resolveConflictingStyles(resetStyles, inlineStyles); + + expect(result).toEqual({ + paddingTop: '15px', + paddingRight: '0', + paddingBottom: '15px', + paddingLeft: '0', + }); + }); + + it('should handle padding with multiple values in reset', () => { + const resetStyles = { padding: '5px 10px' }; + const inlineStyles = { paddingLeft: '20px' }; + + const result = resolveConflictingStyles(resetStyles, inlineStyles); + + expect(result).toEqual({ + paddingTop: '5px', + paddingRight: '10px', + paddingBottom: '5px', + paddingLeft: '20px', + }); + }); + }); + + describe('mixed properties', () => { + it('should handle both margin and padding conflicts', () => { + const resetStyles = { margin: '0', padding: '0' }; + const inlineStyles = { + marginLeft: 'auto', + marginRight: 'auto', + paddingTop: '10px', + }; + + const result = resolveConflictingStyles(resetStyles, inlineStyles); + + expect(result).toEqual({ + marginTop: '0', + marginRight: 'auto', + marginBottom: '0', + marginLeft: 'auto', + paddingTop: '10px', + paddingRight: '0', + paddingBottom: '0', + paddingLeft: '0', + }); + }); + + it('should preserve non-spacing properties from both sources', () => { + const resetStyles = { + margin: '0', + padding: '0', + fontSize: '16px', + }; + const inlineStyles = { + marginTop: '10px', + color: 'blue', + backgroundColor: 'white', + }; + + const result = resolveConflictingStyles(resetStyles, inlineStyles); + + expect(result).toEqual({ + marginTop: '10px', + marginRight: '0', + marginBottom: '0', + marginLeft: '0', + paddingTop: '0', + paddingRight: '0', + paddingBottom: '0', + paddingLeft: '0', + fontSize: '16px', + color: 'blue', + backgroundColor: 'white', + }); + }); + }); + + describe('edge cases', () => { + it('should handle empty inline styles', () => { + const resetStyles = { margin: '0', padding: '0' }; + const inlineStyles = {}; + + const result = resolveConflictingStyles(resetStyles, inlineStyles); + + expect(result).toEqual({ + marginTop: '0', + marginRight: '0', + marginBottom: '0', + marginLeft: '0', + paddingTop: '0', + paddingRight: '0', + paddingBottom: '0', + paddingLeft: '0', + }); + }); + + it('should handle reset styles without shorthand properties', () => { + const resetStyles = { fontSize: '16px', lineHeight: '1.5' }; + const inlineStyles = { color: 'red' }; + + const result = resolveConflictingStyles(resetStyles, inlineStyles); + + expect(result).toEqual({ + fontSize: '16px', + lineHeight: '1.5', + color: 'red', + }); + }); + + it('should handle inline styles overriding non-spacing properties', () => { + const resetStyles = { margin: '0', fontSize: '16px' }; + const inlineStyles = { fontSize: '20px' }; + + const result = resolveConflictingStyles(resetStyles, inlineStyles); + + expect(result).toEqual({ + marginTop: '0', + marginRight: '0', + marginBottom: '0', + marginLeft: '0', + fontSize: '20px', + }); + }); + }); + + describe('real-world email scenarios', () => { + it('should handle centered button with margin auto', () => { + const resetStyles = { margin: '0', padding: '0' }; + const inlineStyles = { + marginLeft: 'auto', + marginRight: 'auto', + paddingTop: '12px', + paddingBottom: '12px', + paddingLeft: '24px', + paddingRight: '24px', + }; + + const result = resolveConflictingStyles(resetStyles, inlineStyles); + + expect(result).toEqual({ + marginTop: '0', + marginRight: 'auto', + marginBottom: '0', + marginLeft: 'auto', + paddingTop: '12px', + paddingRight: '24px', + paddingBottom: '12px', + paddingLeft: '24px', + }); + }); + + it('should handle section with custom spacing', () => { + const resetStyles = { margin: '0', padding: '0' }; + const inlineStyles = { + paddingTop: '20px', + paddingBottom: '20px', + marginTop: '10px', + }; + + const result = resolveConflictingStyles(resetStyles, inlineStyles); + + expect(result).toEqual({ + marginTop: '10px', + marginRight: '0', + marginBottom: '0', + marginLeft: '0', + paddingTop: '20px', + paddingRight: '0', + paddingBottom: '20px', + paddingLeft: '0', + }); + }); + + it('should handle complex reset with multiple properties', () => { + const resetStyles = { + margin: '0', + padding: '0', + fontSize: '16px', + lineHeight: '1.5', + fontWeight: 400, + }; + const inlineStyles = { + marginLeft: 'auto', + marginRight: 'auto', + fontSize: '18px', + }; + + const result = resolveConflictingStyles(resetStyles, inlineStyles); + + expect(result).toEqual({ + marginTop: '0', + marginRight: 'auto', + marginBottom: '0', + marginLeft: 'auto', + paddingTop: '0', + paddingRight: '0', + paddingBottom: '0', + paddingLeft: '0', + fontSize: '18px', + lineHeight: '1.5', + fontWeight: 400, + }); + }); + }); +}); + +describe('jsToInlineCss', () => { + it('should convert a simple style object to inline CSS', () => { + expect(jsToInlineCss({ color: 'red' })).toBe('color:red;'); + }); + + it('should convert camelCase keys to kebab-case', () => { + expect(jsToInlineCss({ backgroundColor: 'blue' })).toBe( + 'background-color:blue;', + ); + expect(jsToInlineCss({ fontSize: '16px' })).toBe('font-size:16px;'); + expect(jsToInlineCss({ borderTopLeftRadius: '4px' })).toBe( + 'border-top-left-radius:4px;', + ); + }); + + it('should join multiple properties with semicolons', () => { + expect(jsToInlineCss({ color: 'red', fontSize: '16px' })).toBe( + 'color:red;font-size:16px;', + ); + }); + + it('should return an empty string for an empty object', () => { + expect(jsToInlineCss({})).toBe(''); + }); + + it('should filter out undefined values', () => { + expect(jsToInlineCss({ color: undefined })).toBe(''); + }); + + it('should filter out null values', () => { + expect(jsToInlineCss({ color: null })).toBe(''); + }); + + it('should filter out empty string values', () => { + expect(jsToInlineCss({ color: '' })).toBe(''); + }); + + // Meant to avoid https://github.com/resend/resend/pull/8030#discussion_r2789386279, but we're unsure if this is actually bug + // it('should keep 0 as a valid CSS value', () => { + // expect(jsToInlineCss({ margin: 0 })).toBe('margin:0;'); + // expect(jsToInlineCss({ padding: 0 })).toBe('padding:0;'); + // expect(jsToInlineCss({ border: 0 })).toBe('border:0;'); + // expect(jsToInlineCss({ opacity: 0 })).toBe('opacity:0;'); + // expect(jsToInlineCss({ top: 0 })).toBe('top:0;'); + // expect(jsToInlineCss({ left: 0 })).toBe('left:0;'); + // expect(jsToInlineCss({ borderRadius: 0 })).toBe('border-radius:0;'); + // expect(jsToInlineCss({ fontSize: 0 })).toBe('font-size:0;'); + // }); + + it('should keep non-zero numeric values', () => { + expect(jsToInlineCss({ lineHeight: 1.5 })).toBe('line-height:1.5;'); + expect(jsToInlineCss({ zIndex: 10 })).toBe('z-index:10;'); + }); + + it('should handle a mix of valid and filtered-out values', () => { + expect( + jsToInlineCss({ + color: 'red', + margin: 0, + padding: undefined, + fontSize: '14px', + border: null, + display: '', + }), + ).toBe('color:red;font-size:14px;'); + }); + + it('should handle keys that are already lowercase', () => { + expect(jsToInlineCss({ display: 'flex', color: 'green' })).toBe( + 'display:flex;color:green;', + ); + }); +}); + +describe('inlineCssToJs', () => { + it('should return an empty object for an empty string', () => { + expect(inlineCssToJs('')).toEqual({}); + }); + + it('should return an empty object for undefined input', () => { + expect(inlineCssToJs(undefined as unknown as string)).toEqual({}); + }); + + it('should return an empty object for an object input', () => { + expect(inlineCssToJs({} as unknown as string)).toEqual({}); + }); + + it('should parse a single CSS property', () => { + expect(inlineCssToJs('color: red')).toEqual({ color: 'red' }); + }); + + it('should parse multiple CSS properties', () => { + expect(inlineCssToJs('color: red; font-size: 16px')).toEqual({ + color: 'red', + fontSize: '16px', + }); + }); + + it('should handle a trailing semicolon', () => { + expect(inlineCssToJs('color: red;')).toEqual({ color: 'red' }); + }); + + it('should convert kebab-case to camelCase', () => { + expect(inlineCssToJs('background-color: blue')).toEqual({ + backgroundColor: 'blue', + }); + }); + + it('should convert multi-hyphen properties to camelCase', () => { + expect(inlineCssToJs('border-top-left-radius: 4px')).toEqual({ + borderTopLeftRadius: '4px', + }); + }); + + it('should trim whitespace around keys and values', () => { + expect(inlineCssToJs(' color : red ; font-size : 14px ')).toEqual({ + color: 'red', + fontSize: '14px', + }); + }); + + it('should skip properties with no value', () => { + expect(inlineCssToJs('color:')).toEqual({}); + }); + + it('should skip properties with only whitespace as value', () => { + expect(inlineCssToJs('color: ')).toEqual({}); + }); + + describe('removeUnit option', () => { + it('should remove px units when removeUnit is true', () => { + expect(inlineCssToJs('width: 100px', { removeUnit: true })).toEqual({ + width: '100', + }); + }); + + it('should remove % units when removeUnit is true', () => { + expect(inlineCssToJs('width: 50%', { removeUnit: true })).toEqual({ + width: '50', + }); + }); + + it('should remove multiple units in one value when removeUnit is true', () => { + expect(inlineCssToJs('margin: 10px 20px', { removeUnit: true })).toEqual({ + margin: '10 20', + }); + }); + + it('should not remove units when removeUnit is false', () => { + expect(inlineCssToJs('width: 100px', { removeUnit: false })).toEqual({ + width: '100px', + }); + }); + + it('should not remove units by default', () => { + expect(inlineCssToJs('width: 100px')).toEqual({ + width: '100px', + }); + }); + }); +}); + +describe('expandShorthandProperties', () => { + describe('margin shorthand expansion', () => { + it('should expand single value margin to all sides', () => { + const result = expandShorthandProperties({ margin: '0' }); + expect(result).toEqual({ + marginTop: '0', + marginRight: '0', + marginBottom: '0', + marginLeft: '0', + }); + }); + + it('should expand two value margin (vertical horizontal)', () => { + const result = expandShorthandProperties({ margin: '10px 20px' }); + expect(result).toEqual({ + marginTop: '10px', + marginRight: '20px', + marginBottom: '10px', + marginLeft: '20px', + }); + }); + + it('should expand three value margin (top horizontal bottom)', () => { + const result = expandShorthandProperties({ margin: '10px 20px 30px' }); + expect(result).toEqual({ + marginTop: '10px', + marginRight: '20px', + marginBottom: '30px', + marginLeft: '20px', + }); + }); + + it('should expand four value margin (top right bottom left)', () => { + const result = expandShorthandProperties({ + margin: '10px 20px 30px 40px', + }); + expect(result).toEqual({ + marginTop: '10px', + marginRight: '20px', + marginBottom: '30px', + marginLeft: '40px', + }); + }); + + it('should handle margin with auto values', () => { + const result = expandShorthandProperties({ margin: '0 auto' }); + expect(result).toEqual({ + marginTop: '0', + marginRight: 'auto', + marginBottom: '0', + marginLeft: 'auto', + }); + }); + + it('should handle numeric margin values', () => { + const result = expandShorthandProperties({ margin: '0' }); + expect(result).toEqual({ + marginTop: '0', + marginRight: '0', + marginBottom: '0', + marginLeft: '0', + }); + }); + }); + + describe('padding shorthand expansion', () => { + it('should expand single value padding to all sides', () => { + const result = expandShorthandProperties({ padding: '0' }); + expect(result).toEqual({ + paddingTop: '0', + paddingRight: '0', + paddingBottom: '0', + paddingLeft: '0', + }); + }); + + it('should expand two value padding (vertical horizontal)', () => { + const result = expandShorthandProperties({ padding: '10px 20px' }); + expect(result).toEqual({ + paddingTop: '10px', + paddingRight: '20px', + paddingBottom: '10px', + paddingLeft: '20px', + }); + }); + + it('should expand three value padding (top horizontal bottom)', () => { + const result = expandShorthandProperties({ padding: '10px 20px 30px' }); + expect(result).toEqual({ + paddingTop: '10px', + paddingRight: '20px', + paddingBottom: '30px', + paddingLeft: '20px', + }); + }); + + it('should expand four value padding (top right bottom left)', () => { + const result = expandShorthandProperties({ + padding: '10px 20px 30px 40px', + }); + expect(result).toEqual({ + paddingTop: '10px', + paddingRight: '20px', + paddingBottom: '30px', + paddingLeft: '40px', + }); + }); + + it('should handle numeric padding values', () => { + const result = expandShorthandProperties({ padding: '0' }); + expect(result).toEqual({ + paddingTop: '0', + paddingRight: '0', + paddingBottom: '0', + paddingLeft: '0', + }); + }); + }); + + describe('border radius longhand handling', () => { + it('should preserve a single border radius corner', () => { + const result = expandShorthandProperties({ + borderTopLeftRadius: '8px', + }); + expect(result).toEqual({ + borderTopLeftRadius: '8px', + }); + }); + + it('should preserve two different border radius corners', () => { + const result = expandShorthandProperties({ + borderTopLeftRadius: '8px', + borderTopRightRadius: '4px', + }); + expect(result).toEqual({ + borderTopLeftRadius: '8px', + borderTopRightRadius: '4px', + }); + }); + + it('should preserve three border radius corners without collapsing', () => { + const result = expandShorthandProperties({ + borderTopLeftRadius: '8px', + borderTopRightRadius: '8px', + borderBottomLeftRadius: '8px', + }); + expect(result).toEqual({ + borderTopLeftRadius: '8px', + borderTopRightRadius: '8px', + borderBottomLeftRadius: '8px', + }); + }); + + it('should preserve all four corners when they differ', () => { + const result = expandShorthandProperties({ + borderTopLeftRadius: '4px', + borderTopRightRadius: '8px', + borderBottomLeftRadius: '12px', + borderBottomRightRadius: '16px', + }); + expect(result).toEqual({ + borderTopLeftRadius: '4px', + borderTopRightRadius: '8px', + borderBottomLeftRadius: '12px', + borderBottomRightRadius: '16px', + }); + }); + + it('should add borderRadius shorthand when all four corners are identical', () => { + const result = expandShorthandProperties({ + borderTopLeftRadius: '8px', + borderTopRightRadius: '8px', + borderBottomLeftRadius: '8px', + borderBottomRightRadius: '8px', + }); + expect(result).toEqual({ + borderTopLeftRadius: '8px', + borderTopRightRadius: '8px', + borderBottomLeftRadius: '8px', + borderBottomRightRadius: '8px', + borderRadius: '8px', + }); + }); + + it('should preserve border radius corners alongside other properties', () => { + const result = expandShorthandProperties({ + borderTopLeftRadius: '8px', + borderBottomRightRadius: '4px', + margin: '0', + color: 'red', + }); + expect(result).toEqual({ + borderTopLeftRadius: '8px', + borderBottomRightRadius: '4px', + marginTop: '0', + marginRight: '0', + marginBottom: '0', + marginLeft: '0', + color: 'red', + }); + }); + }); + + describe('mixed properties', () => { + it('should expand both margin and padding', () => { + const result = expandShorthandProperties({ + margin: '0', + padding: '10px 20px', + }); + expect(result).toEqual({ + marginTop: '0', + marginRight: '0', + marginBottom: '0', + marginLeft: '0', + paddingTop: '10px', + paddingRight: '20px', + paddingBottom: '10px', + paddingLeft: '20px', + }); + }); + + it('should preserve longhand properties alongside shorthand', () => { + const result = expandShorthandProperties({ + margin: '0', + paddingTop: '10px', + color: 'red', + }); + expect(result).toEqual({ + marginTop: '0', + marginRight: '0', + marginBottom: '0', + marginLeft: '0', + paddingTop: '10px', + color: 'red', + }); + }); + + it('should preserve non-spacing properties', () => { + const result = expandShorthandProperties({ + margin: '0', + fontSize: '16px', + color: 'blue', + backgroundColor: 'white', + }); + expect(result).toEqual({ + marginTop: '0', + marginRight: '0', + marginBottom: '0', + marginLeft: '0', + fontSize: '16px', + color: 'blue', + backgroundColor: 'white', + }); + }); + }); + + describe('edge cases', () => { + it('should handle empty object', () => { + const result = expandShorthandProperties({}); + expect(result).toEqual({}); + }); + + it('should handle null input', () => { + const result = expandShorthandProperties(null as any); + expect(result).toEqual({}); + }); + + it('should handle undefined input', () => { + const result = expandShorthandProperties(undefined as any); + expect(result).toEqual({}); + }); + + it('should skip undefined values', () => { + const result = expandShorthandProperties({ + margin: undefined as any, + padding: '10px', + }); + expect(result).toEqual({ + paddingTop: '10px', + paddingRight: '10px', + paddingBottom: '10px', + paddingLeft: '10px', + }); + }); + + it('should skip null values', () => { + const result = expandShorthandProperties({ + margin: null as any, + padding: '10px', + }); + expect(result).toEqual({ + paddingTop: '10px', + paddingRight: '10px', + paddingBottom: '10px', + paddingLeft: '10px', + }); + }); + + it('should handle extra whitespace in shorthand values', () => { + const result = expandShorthandProperties({ margin: ' 10px 20px ' }); + expect(result).toEqual({ + marginTop: '10px', + marginRight: '20px', + marginBottom: '10px', + marginLeft: '20px', + }); + }); + }); + + describe('real-world scenarios', () => { + it('should handle the margin auto centering case', () => { + const resetStyles = { margin: '0', padding: '0' }; + const inlineStyles = { marginLeft: 'auto', marginRight: 'auto' }; + + const expandedReset = expandShorthandProperties(resetStyles); + const merged = { ...expandedReset, ...inlineStyles }; + + expect(merged).toEqual({ + marginTop: '0', + marginRight: 'auto', + marginBottom: '0', + marginLeft: 'auto', + paddingTop: '0', + paddingRight: '0', + paddingBottom: '0', + paddingLeft: '0', + }); + }); + + it('should allow specific padding overrides', () => { + const resetStyles = { padding: '0' }; + const inlineStyles = { paddingTop: '20px', paddingBottom: '20px' }; + + const expandedReset = expandShorthandProperties(resetStyles); + const merged = { ...expandedReset, ...inlineStyles }; + + expect(merged).toEqual({ + paddingTop: '20px', + paddingRight: '0', + paddingBottom: '20px', + paddingLeft: '0', + }); + }); + + it('should handle complex reset styles with multiple properties', () => { + const resetStyles = { + margin: '0', + padding: '0', + fontSize: '16px', + lineHeight: '1.5', + }; + + const result = expandShorthandProperties(resetStyles); + + expect(result).toEqual({ + marginTop: '0', + marginRight: '0', + marginBottom: '0', + marginLeft: '0', + paddingTop: '0', + paddingRight: '0', + paddingBottom: '0', + paddingLeft: '0', + fontSize: '16px', + lineHeight: '1.5', + }); + }); + }); +}); diff --git a/packages/editor/src/utils/styles.ts b/packages/editor/src/utils/styles.ts new file mode 100644 index 0000000000..1807ceb948 --- /dev/null +++ b/packages/editor/src/utils/styles.ts @@ -0,0 +1,254 @@ +import type { CssJs } from './types'; + +const WHITE_SPACE_REGEX = /\s+/; + +export const jsToInlineCss = (styleObject: { [key: string]: any }) => { + const parts: string[] = []; + + for (const key in styleObject) { + const value = styleObject[key]; + if (value !== 0 && value !== undefined && value !== null && value !== '') { + const KEBAB_CASE_REGEX = /[A-Z]/g; + const formattedKey = key.replace( + KEBAB_CASE_REGEX, + (match) => `-${match.toLowerCase()}`, + ); + parts.push(`${formattedKey}:${value}`); + } + } + + return parts.join(';') + (parts.length ? ';' : ''); +}; + +export const inlineCssToJs = ( + inlineStyle: string, + options: { removeUnit?: boolean } = {}, +) => { + const styleObject: { [key: string]: string } = {}; + + if (!inlineStyle || inlineStyle === '' || typeof inlineStyle === 'object') { + return styleObject; + } + + inlineStyle.split(';').forEach((style: string) => { + if (style.trim()) { + const [key, value] = style.split(':'); + const valueTrimmed = value?.trim(); + + if (!valueTrimmed) { + return; + } + + const formattedKey = key + .trim() + .replace(/-\w/g, (match) => match[1].toUpperCase()); + + const UNIT_REGEX = /px|%/g; + const sanitizedValue = options?.removeUnit + ? valueTrimmed.replace(UNIT_REGEX, '') + : valueTrimmed; + + styleObject[formattedKey] = sanitizedValue; + } + }); + + return styleObject; +}; + +/** + * Expands CSS shorthand properties (margin, padding) into their longhand equivalents. + * This prevents shorthand properties from overriding specific longhand properties in email clients. + * + * @param styles - Style object that may contain shorthand properties + * @returns New style object with shorthand properties expanded to longhand + * + * @example + * expandShorthandProperties({ margin: '0', paddingTop: '10px' }) + * // Returns: { marginTop: '0', marginRight: '0', marginBottom: '0', marginLeft: '0', paddingTop: '10px' } + */ +export function expandShorthandProperties( + styles: Record, +): Record { + if (!styles || typeof styles !== 'object') { + return {}; + } + + const expanded: Record = {}; + + for (const key in styles) { + const value = styles[key]; + if (value === undefined || value === null || value === '') { + continue; + } + + switch (key) { + case 'margin': { + const values = parseShorthandValue(value); + expanded.marginTop = values.top; + expanded.marginRight = values.right; + expanded.marginBottom = values.bottom; + expanded.marginLeft = values.left; + break; + } + case 'padding': { + const values = parseShorthandValue(value); + expanded.paddingTop = values.top; + expanded.paddingRight = values.right; + expanded.paddingBottom = values.bottom; + expanded.paddingLeft = values.left; + break; + } + case 'border': { + const values = convertBorderValue(value); + expanded.borderStyle = values.style; + expanded.borderWidth = values.width; + expanded.borderColor = values.color; + break; + } + case 'borderTopLeftRadius': + case 'borderTopRightRadius': + case 'borderBottomLeftRadius': + case 'borderBottomRightRadius': { + // Always preserve the longhand property + expanded[key] = value; + + // When all four corners are present and identical, also add the shorthand + if ( + styles.borderTopLeftRadius && + styles.borderTopRightRadius && + styles.borderBottomLeftRadius && + styles.borderBottomRightRadius + ) { + const values = [ + styles.borderTopLeftRadius, + styles.borderTopRightRadius, + styles.borderBottomLeftRadius, + styles.borderBottomRightRadius, + ]; + + if (new Set(values).size === 1) { + expanded.borderRadius = values[0]; + } + } + + break; + } + + default: { + // Keep all other properties as-is + expanded[key] = value; + } + } + } + + return expanded; +} + +/** + * Parses CSS shorthand value (1-4 values) into individual side values. + * Follows CSS specification for shorthand property value parsing. + * + * @param value - Shorthand value string (e.g., '0', '10px 20px', '5px 10px 15px 20px') + * @returns Object with top, right, bottom, left values + */ +function parseShorthandValue(value: string | number): { + top: string; + right: string; + bottom: string; + left: string; +} { + const stringValue = String(value).trim(); + const parts = stringValue.split(WHITE_SPACE_REGEX); + const len = parts.length; + + if (len === 1) { + return { top: parts[0], right: parts[0], bottom: parts[0], left: parts[0] }; + } + if (len === 2) { + return { top: parts[0], right: parts[1], bottom: parts[0], left: parts[1] }; + } + if (len === 3) { + return { top: parts[0], right: parts[1], bottom: parts[2], left: parts[1] }; + } + if (len === 4) { + return { top: parts[0], right: parts[1], bottom: parts[2], left: parts[3] }; + } + + return { + top: stringValue, + right: stringValue, + bottom: stringValue, + left: stringValue, + }; +} + +function convertBorderValue(value: string | number): { + style: string; + width: string; + color: string; +} { + const stringValue = String(value).trim(); + const parts = stringValue.split(WHITE_SPACE_REGEX); + + switch (parts.length) { + case 1: + // border: 1px → all sides + return { + style: 'solid', + width: parts[0], + color: 'black', + }; + case 2: + // border: 1px solid → top/bottom, left/right + return { + style: parts[1], + width: parts[0], + color: 'black', + }; + case 3: + // border: 1px solid #000 → top, left/right, bottom + return { + style: parts[1], + width: parts[0], + color: parts[2], + }; + case 4: + // border: 1px solid #000 #fff → top, right, bottom, left + return { + style: parts[1], + width: parts[0], + color: parts[2], + }; + default: + // Invalid format, return the original value for all sides + return { + style: 'solid', + width: stringValue, + color: 'black', + }; + } +} + +/** + * Resolves conflicts between reset styles and inline styles by expanding + * shorthand properties (margin, padding) to longhand before merging. + * This prevents shorthand properties from overriding specific longhand properties. + * + * @param resetStyles - Base reset styles that may contain shorthand properties + * @param inlineStyles - Inline styles that should override reset styles + * @returns Merged styles with inline styles taking precedence + */ +export function resolveConflictingStyles( + resetStyles: CssJs['reset'], + inlineStyles: Record, +) { + const expandedResetStyles = expandShorthandProperties( + resetStyles as Record, + ); + const expandedInlineStyles = expandShorthandProperties(inlineStyles); + + return { + ...expandedResetStyles, + ...expandedInlineStyles, + }; +} diff --git a/packages/editor/src/utils/types.ts b/packages/editor/src/utils/types.ts new file mode 100644 index 0000000000..ec97ce9de6 --- /dev/null +++ b/packages/editor/src/utils/types.ts @@ -0,0 +1,120 @@ +import type { Editor } from '@tiptap/core'; +import type { Attrs } from '@tiptap/pm/model'; +import type * as React from 'react'; + +export type NodeClickedEvent = { + nodeType: string; + nodeAttrs: Attrs; + nodePos: { pos: number; inside: number }; +}; + +type InputType = 'color' | 'number' | 'select' | 'text' | 'textarea'; +type InputUnit = 'px' | '%'; +type Options = Record; + +export type EditorThemes = 'basic' | 'minimal'; +export type KnownThemeComponents = + | 'reset' + | 'body' + | 'container' + | 'h1' + | 'h2' + | 'h3' + | 'paragraph' + | 'nestedList' + | 'list' + | 'listItem' + | 'listParagraph' + | 'blockquote' + | 'codeBlock' + | 'inlineCode' + | 'codeTag' + | 'link' + | 'footer' + | 'hr' + | 'image' + | 'button' + | 'section'; + +export type KnownCssProperties = + | 'align' + | 'backgroundColor' + | 'color' + | 'fontSize' + | 'fontWeight' + | 'lineHeight' + | 'textDecoration' + | 'borderRadius' + | 'borderTopLeftRadius' + | 'borderTopRightRadius' + | 'borderBottomLeftRadius' + | 'borderBottomRightRadius' + | 'borderWidth' + | 'borderStyle' + | 'borderColor' + | 'padding' + | 'paddingTop' + | 'paddingRight' + | 'paddingBottom' + | 'paddingLeft' + | 'width' + | 'height'; + +export type ResetTheme = Record; + +export type CssJs = { + [K in KnownThemeComponents]: React.CSSProperties & { + // TODO: remove align as soon as possible + align?: 'center' | 'left' | 'right'; + }; +}; +export type SupportedCssProperties = { + [K in KnownCssProperties]: { + category: 'layout' | 'appearance' | 'typography'; + label: string; + type: InputType; + defaultValue: string | number; + unit?: InputUnit; + options?: Options; + excludeNodes?: string[]; + placeholder?: string; + customUpdate?: ( + props: Record, + update: (func: (tree: PanelGroup[]) => PanelGroup[]) => void, + ) => void; + }; +}; + +export interface PanelInputProperty { + label: string; + type: InputType; + value: string | number; + prop: KnownCssProperties; + classReference?: KnownThemeComponents; + unit?: InputUnit; + options?: Options; + placeholder?: string; + category: SupportedCssProperties[KnownCssProperties]['category']; +} + +export interface PanelGroup { + title: string; + headerSlot?: React.ReactNode; + classReference?: KnownThemeComponents; + inputs: Omit[]; +} + +export interface ContextProperties { + theme: EditorThemes; + styles: PanelGroup[] & { + toCss: () => Record; + }; + css: string; +} + +export interface ContextValue extends ContextProperties { + subscribe: ( + editor: Editor, + propagateChanges?: (context: ContextProperties) => void, + ) => void; +} diff --git a/packages/editor/tsconfig.json b/packages/editor/tsconfig.json new file mode 100644 index 0000000000..cd6c94d6e8 --- /dev/null +++ b/packages/editor/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "tsconfig/react-library.json", + "include": ["."], + "exclude": ["dist", "build", "node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a4dc8c8c63..6ab2d9ba4e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -113,7 +113,7 @@ importers: dependencies: mintlify: specifier: 4.2.280 - version: 4.2.280(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/node@25.0.6)(@types/react@19.2.13)(acorn@8.11.2)(react-dom@19.0.0(react@19.0.0))(typescript@5.9.3) + version: 4.2.280(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/node@25.0.6)(@types/react@19.2.13)(acorn@8.15.0)(react-dom@19.0.0(react@19.0.0))(typescript@5.9.3) zod: specifier: 3.24.3 version: 3.24.3 @@ -513,6 +513,58 @@ importers: specifier: 5.8.3 version: 5.8.3 + packages/editor: + dependencies: + '@react-email/components': + specifier: workspace:* + version: link:../components + '@tiptap/core': + specifier: ^3.17.1 + version: 3.20.0(@tiptap/pm@3.20.0) + '@tiptap/extension-code-block': + specifier: ^3.17.1 + version: 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + '@tiptap/extension-heading': + specifier: ^3.17.1 + version: 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0)) + '@tiptap/extension-horizontal-rule': + specifier: ^3.17.1 + version: 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + '@tiptap/extension-placeholder': + specifier: ^3.17.1 + version: 3.20.0(@tiptap/extensions@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)) + '@tiptap/html': + specifier: ^3.17.1 + version: 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)(happy-dom@20.0.11) + '@tiptap/pm': + specifier: ^3.17.1 + version: 3.20.0 + '@tiptap/react': + specifier: ^3.17.1 + version: 3.20.0(@floating-ui/dom@1.6.12)(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@tiptap/starter-kit': + specifier: ^3.17.1 + version: 3.20.0 + hast-util-from-html: + specifier: ^2.0.3 + version: 2.0.3 + prismjs: + specifier: ^1.30.0 + version: 1.30.0 + react: + specifier: ^19.0.0 + version: 19.0.0 + devDependencies: + '@types/prismjs': + specifier: 1.26.5 + version: 1.26.5 + tsconfig: + specifier: workspace:* + version: link:../tsconfig + typescript: + specifier: 5.8.3 + version: 5.8.3 + packages/font: dependencies: react: @@ -4024,6 +4076,9 @@ packages: react-native: optional: true + '@remirror/core-constants@3.0.0': + resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} + '@responsive-email/react-email@0.0.4': resolution: {integrity: sha512-oRpI+tmiHR4Ff86+cuX2fdlIN/5PdwLQL8wa+2ztypLdX/3J7MQQq9IKLt3X5tuwshtGXkotcydXDpXs8yfPQA==} peerDependencies: @@ -4542,6 +4597,167 @@ packages: '@tailwindcss/postcss@4.1.17': resolution: {integrity: sha512-+nKl9N9mN5uJ+M7dBOOCzINw94MPstNR/GtIhz1fpZysxL/4a+No64jCBD6CPN+bIHWFx3KWuu8XJRrj/572Dw==} + '@tiptap/core@3.20.0': + resolution: {integrity: sha512-aC9aROgia/SpJqhsXFiX9TsligL8d+oeoI8W3u00WI45s0VfsqjgeKQLDLF7Tu7hC+7F02teC84SAHuup003VQ==} + peerDependencies: + '@tiptap/pm': ^3.20.0 + + '@tiptap/extension-blockquote@3.20.0': + resolution: {integrity: sha512-LQzn6aGtL4WXz2+rYshl/7/VnP2qJTpD7fWL96GXAzhqviPEY1bJES7poqJb3MU/gzl8VJUVzVzU1VoVfUKlbA==} + peerDependencies: + '@tiptap/core': ^3.20.0 + + '@tiptap/extension-bold@3.20.0': + resolution: {integrity: sha512-sQklEWiyf58yDjiHtm5vmkVjfIc/cBuSusmCsQ0q9vGYnEF1iOHKhGpvnCeEXNeqF3fiJQRlquzt/6ymle3Iwg==} + peerDependencies: + '@tiptap/core': ^3.20.0 + + '@tiptap/extension-bubble-menu@3.20.0': + resolution: {integrity: sha512-MDosUfs8Tj+nwg8RC+wTMWGkLJORXmbR6YZgbiX4hrc7G90Gopdd6kj6ht5/T8t7dLLaX7N0+DEHdUEPGED7dw==} + peerDependencies: + '@tiptap/core': ^3.20.0 + '@tiptap/pm': ^3.20.0 + + '@tiptap/extension-bullet-list@3.20.0': + resolution: {integrity: sha512-OcKMeopBbqWzhSi6o8nNz0aayogg1sfOAhto3NxJu3Ya32dwBFqmHXSYM6uW4jOphNvVPyjiq9aNRh3qTdd1dw==} + peerDependencies: + '@tiptap/extension-list': ^3.20.0 + + '@tiptap/extension-code-block@3.20.0': + resolution: {integrity: sha512-lBbmNek14aCjrHcBcq3PRqWfNLvC6bcRa2Osc6e/LtmXlcpype4f6n+Yx+WZ+f2uUh0UmDRCz7BEyUETEsDmlQ==} + peerDependencies: + '@tiptap/core': ^3.20.0 + '@tiptap/pm': ^3.20.0 + + '@tiptap/extension-code@3.20.0': + resolution: {integrity: sha512-TYDWFeSQ9umiyrqsT6VecbuhL8XIHkUhO+gEk0sVvH67ZLwjFDhAIIgWIr1/dbIGPcvMZM19E7xUUhAdIaXaOQ==} + peerDependencies: + '@tiptap/core': ^3.20.0 + + '@tiptap/extension-document@3.20.0': + resolution: {integrity: sha512-oJfLIG3vAtZo/wg29WiBcyWt22KUgddpP8wqtCE+kY5Dw8znLR9ehNmVWlSWJA5OJUMO0ntAHx4bBT+I2MBd5w==} + peerDependencies: + '@tiptap/core': ^3.20.0 + + '@tiptap/extension-dropcursor@3.20.0': + resolution: {integrity: sha512-d+cxplRlktVgZPwatnc34IArlppM0IFKS1J5wLk+ba1jidizsbMVh45tP/BTK2flhyfRqcNoB5R0TArhUpbkNQ==} + peerDependencies: + '@tiptap/extensions': ^3.20.0 + + '@tiptap/extension-floating-menu@3.20.0': + resolution: {integrity: sha512-rYs4Bv5pVjqZ/2vvR6oe7ammZapkAwN51As/WDbemvYDjfOGRqK58qGauUjYZiDzPOEIzI2mxGwsZ4eJhPW4Ig==} + peerDependencies: + '@floating-ui/dom': ^1.0.0 + '@tiptap/core': ^3.20.0 + '@tiptap/pm': ^3.20.0 + + '@tiptap/extension-gapcursor@3.20.0': + resolution: {integrity: sha512-P/LasfvG9/qFq43ZAlNbAnPnXC+/RJf49buTrhtFvI9Zg0+Lbpjx1oh6oMHB19T88Y28KtrckfFZ8aTSUWDq6w==} + peerDependencies: + '@tiptap/extensions': ^3.20.0 + + '@tiptap/extension-hard-break@3.20.0': + resolution: {integrity: sha512-rqvhMOw4f+XQmEthncbvDjgLH6fz8L9splnKZC7OeS0eX8b0qd7+xI1u5kyxF3KA2Z0BnigES++jjWuecqV6mA==} + peerDependencies: + '@tiptap/core': ^3.20.0 + + '@tiptap/extension-heading@3.20.0': + resolution: {integrity: sha512-JgJhurnCe3eN6a0lEsNQM/46R1bcwzwWWZEFDSb1P9dR8+t1/5v7cMZWsSInpD7R4/74iJn0+M5hcXLwCmBmYA==} + peerDependencies: + '@tiptap/core': ^3.20.0 + + '@tiptap/extension-horizontal-rule@3.20.0': + resolution: {integrity: sha512-6uvcutFMv+9wPZgptDkbRDjAm3YVxlibmkhWD5GuaWwS9L/yUtobpI3GycujRSUZ8D3q6Q9J7LqpmQtQRTalWA==} + peerDependencies: + '@tiptap/core': ^3.20.0 + '@tiptap/pm': ^3.20.0 + + '@tiptap/extension-italic@3.20.0': + resolution: {integrity: sha512-/DhnKQF8yN8RxtuL8abZ28wd5281EaGoE2Oha35zXSOF1vNYnbyt8Ymkv/7u1BcWEWTvRPgaju0YCGXisPRLYw==} + peerDependencies: + '@tiptap/core': ^3.20.0 + + '@tiptap/extension-link@3.20.0': + resolution: {integrity: sha512-qI/5A+R0ZWBxo/8HxSn1uOyr7odr3xHBZ/gzOR1GUJaZqjlJxkWFX0RtXMbLKEGEvT25o345cF7b0wFznEh8qA==} + peerDependencies: + '@tiptap/core': ^3.20.0 + '@tiptap/pm': ^3.20.0 + + '@tiptap/extension-list-item@3.20.0': + resolution: {integrity: sha512-qEtjaaGPuqaFB4VpLrGDoIe9RHnckxPfu6d3rc22ap6TAHCDyRv05CEyJogqccnFceG/v5WN4znUBER8RWnWHA==} + peerDependencies: + '@tiptap/extension-list': ^3.20.0 + + '@tiptap/extension-list-keymap@3.20.0': + resolution: {integrity: sha512-Z4GvKy04Ms4cLFN+CY6wXswd36xYsT2p/YL0V89LYFMZTerOeTjFYlndzn6svqL8NV1PRT5Diw4WTTxJSmcJPA==} + peerDependencies: + '@tiptap/extension-list': ^3.20.0 + + '@tiptap/extension-list@3.20.0': + resolution: {integrity: sha512-+V0/gsVWAv+7vcY0MAe6D52LYTIicMSHw00wz3ISZgprSb2yQhJ4+4gurOnUrQ4Du3AnRQvxPROaofwxIQ66WQ==} + peerDependencies: + '@tiptap/core': ^3.20.0 + '@tiptap/pm': ^3.20.0 + + '@tiptap/extension-ordered-list@3.20.0': + resolution: {integrity: sha512-jVKnJvrizLk7etwBMfyoj6H2GE4M+PD4k7Bwp6Bh1ohBWtfIA1TlngdS842Mx5i1VB2e3UWIwr8ZH46gl6cwMA==} + peerDependencies: + '@tiptap/extension-list': ^3.20.0 + + '@tiptap/extension-paragraph@3.20.0': + resolution: {integrity: sha512-mM99zK4+RnEXIMCv6akfNATAs0Iija6FgyFA9J9NZ6N4o8y9QiNLLa6HjLpAC+W+VoCgQIekyoF/Q9ftxmAYDQ==} + peerDependencies: + '@tiptap/core': ^3.20.0 + + '@tiptap/extension-placeholder@3.20.0': + resolution: {integrity: sha512-ZhYD3L5m16ydSe2z8vqz+RdtAG/iOQaFHHedFct70tKRoLqi2ajF5kgpemu8DwpaRTcyiCN4G99J/+MqehKNjQ==} + peerDependencies: + '@tiptap/extensions': ^3.20.0 + + '@tiptap/extension-strike@3.20.0': + resolution: {integrity: sha512-0vcTZRRAiDfon3VM1mHBr9EFmTkkUXMhm0Xtdtn0bGe+sIqufyi+hUYTEw93EQOD9XNsPkrud6jzQNYpX2H3AQ==} + peerDependencies: + '@tiptap/core': ^3.20.0 + + '@tiptap/extension-text@3.20.0': + resolution: {integrity: sha512-tf8bE8tSaOEWabCzPm71xwiUhyMFKqY9jkP5af3Kr1/F45jzZFIQAYZooHI/+zCHRrgJ99MQHKHe1ZNvODrKHQ==} + peerDependencies: + '@tiptap/core': ^3.20.0 + + '@tiptap/extension-underline@3.20.0': + resolution: {integrity: sha512-LzNXuy2jwR/y+ymoUqC72TiGzbOCjioIjsDu0MNYpHuHqTWPK5aV9Mh0nbZcYFy/7fPlV1q0W139EbJeYBZEAQ==} + peerDependencies: + '@tiptap/core': ^3.20.0 + + '@tiptap/extensions@3.20.0': + resolution: {integrity: sha512-HIsXX942w3nbxEQBlMAAR/aa6qiMBEP7CsSMxaxmTIVAmW35p6yUASw6GdV1u0o3lCZjXq2OSRMTskzIqi5uLg==} + peerDependencies: + '@tiptap/core': ^3.20.0 + '@tiptap/pm': ^3.20.0 + + '@tiptap/html@3.20.0': + resolution: {integrity: sha512-dbkXYp/ye3pheHcgJHI1Q3dWJWQnufd+Ki2Md1TQJuL39+J3M41bZQOWdn3QM0GYAzWlVhKBTJrL0ek6y7/Dlw==} + peerDependencies: + '@tiptap/core': ^3.20.0 + '@tiptap/pm': ^3.20.0 + happy-dom: ^20.0.2 + + '@tiptap/pm@3.20.0': + resolution: {integrity: sha512-jn+2KnQZn+b+VXr8EFOJKsnjVNaA4diAEr6FOazupMt8W8ro1hfpYtZ25JL87Kao/WbMze55sd8M8BDXLUKu1A==} + + '@tiptap/react@3.20.0': + resolution: {integrity: sha512-jFLNzkmn18zqefJwPje0PPd9VhZ7Oy28YHiSvSc7YpBnQIbuN/HIxZ2lrOsKyEHta0WjRZjfU5X1pGxlbcGwOA==} + peerDependencies: + '@tiptap/core': ^3.20.0 + '@tiptap/pm': ^3.20.0 + '@types/react': 19.2.13 + '@types/react-dom': 19.2.3 + react: ^19.0.0 + react-dom: ^19.0.0 + + '@tiptap/starter-kit@3.20.0': + resolution: {integrity: sha512-W4+1re35pDNY/7rpXVg+OKo/Fa4Gfrn08Bq3E3fzlJw6gjE3tYU8dY9x9vC2rK9pd9NOp7Af11qCFDaWpohXkw==} + '@tootallnate/quickjs-emscripten@0.23.0': resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} @@ -4626,9 +4842,18 @@ packages: '@types/katex@0.16.7': resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==} + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/mdx@2.0.13': resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} @@ -4702,6 +4927,9 @@ packages: '@types/urijs@1.19.25': resolution: {integrity: sha512-XOfUup9r3Y06nFAZh3WvO0rBU4OtlfPB/vgxpjg+NRdGU6CN6djdc6OEiH+PcqHCY6eFLo9Ista73uarf4gnBg==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/webpack@5.28.5': resolution: {integrity: sha512-wR87cgvxj3p6D0Crt1r5avwqffqPXUkNlnQ1mjU93G7gCuFjufZR4I6j8cz5g1F1tTYpfOOFvly+cmIQwL9wvw==} @@ -5477,6 +5705,9 @@ packages: typescript: optional: true + crelt@1.0.6: + resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + cross-env@10.1.0: resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} engines: {node: '>=20'} @@ -6054,6 +6285,10 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-equals@5.4.0: + resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} + engines: {node: '>=6.0.0'} + fast-fifo@1.3.2: resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} @@ -7079,6 +7314,12 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + + linkifyjs@4.3.2: + resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} + loader-runner@4.3.1: resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==} engines: {node: '>=6.11.5'} @@ -7158,6 +7399,10 @@ packages: resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} engines: {node: '>=16'} + markdown-it@14.1.1: + resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} + hasBin: true + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -7233,6 +7478,9 @@ packages: mdn-data@2.12.2: resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} @@ -7727,6 +7975,9 @@ packages: resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} engines: {node: '>=18'} + orderedmap@2.1.1: + resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} + outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} @@ -8025,6 +8276,64 @@ packages: property-information@7.0.0: resolution: {integrity: sha512-7D/qOz/+Y4X/rzSB6jKxKUsQnphO046ei8qxG59mtM3RG3DHgTK81HrxrmoDVINJb8NKT5ZsRbwHvQ6B68Iyhg==} + prosemirror-changeset@2.4.0: + resolution: {integrity: sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng==} + + prosemirror-collab@1.3.1: + resolution: {integrity: sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==} + + prosemirror-commands@1.7.1: + resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==} + + prosemirror-dropcursor@1.8.2: + resolution: {integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==} + + prosemirror-gapcursor@1.4.0: + resolution: {integrity: sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==} + + prosemirror-history@1.5.0: + resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==} + + prosemirror-inputrules@1.5.1: + resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==} + + prosemirror-keymap@1.2.3: + resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} + + prosemirror-markdown@1.13.4: + resolution: {integrity: sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==} + + prosemirror-menu@1.2.5: + resolution: {integrity: sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==} + + prosemirror-model@1.25.4: + resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==} + + prosemirror-schema-basic@1.2.4: + resolution: {integrity: sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==} + + prosemirror-schema-list@1.5.1: + resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} + + prosemirror-state@1.4.4: + resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==} + + prosemirror-tables@1.8.5: + resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==} + + prosemirror-trailing-node@3.0.0: + resolution: {integrity: sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==} + peerDependencies: + prosemirror-model: ^1.22.1 + prosemirror-state: ^1.4.2 + prosemirror-view: ^1.33.8 + + prosemirror-transform@1.11.0: + resolution: {integrity: sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==} + + prosemirror-view@1.41.6: + resolution: {integrity: sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -8043,6 +8352,10 @@ packages: pump@3.0.2: resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==} + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -8410,6 +8723,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rope-sequence@1.3.4: + resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} + rrweb-cssom@0.8.0: resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} @@ -9195,6 +9511,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + ufo@1.5.4: resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==} @@ -9471,6 +9790,9 @@ packages: jsdom: optional: true + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -11270,6 +11592,36 @@ snapshots: - acorn - supports-color + '@mdx-js/mdx@3.1.0(acorn@8.15.0)': + dependencies: + '@types/estree': 1.0.8 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdx': 2.0.13 + collapse-white-space: 2.1.0 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-util-scope: 1.0.0 + estree-walker: 3.0.3 + hast-util-to-jsx-runtime: 2.3.3 + markdown-extensions: 2.0.0 + recma-build-jsx: 1.0.0 + recma-jsx: 1.0.0(acorn@8.15.0) + recma-stringify: 1.0.0 + rehype-recma: 1.0.0 + remark-mdx: 3.1.0 + remark-parse: 11.0.0 + remark-rehype: 11.1.1 + source-map: 0.7.4 + unified: 11.0.5 + unist-util-position-from-estree: 2.0.0 + unist-util-stringify-position: 4.0.0 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + transitivePeerDependencies: + - acorn + - supports-color + '@mdx-js/react@3.1.0(@types/react@19.2.13)(react@19.0.0)': dependencies: '@types/mdx': 2.0.13 @@ -11278,15 +11630,15 @@ snapshots: '@mediapipe/tasks-vision@0.10.17': {} - '@mintlify/cli@4.0.884(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/node@25.0.6)(@types/react@19.2.13)(acorn@8.11.2)(react-dom@19.0.0(react@19.0.0))(typescript@5.9.3)': + '@mintlify/cli@4.0.884(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/node@25.0.6)(@types/react@19.2.13)(acorn@8.15.0)(react-dom@19.0.0(react@19.0.0))(typescript@5.9.3)': dependencies: '@inquirer/prompts': 7.9.0(@types/node@25.0.6) '@mintlify/common': 1.0.666(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3) - '@mintlify/link-rot': 3.0.823(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.11.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3) + '@mintlify/link-rot': 3.0.823(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.15.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3) '@mintlify/models': 0.0.257 - '@mintlify/prebuild': 1.0.801(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.11.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3) - '@mintlify/previewing': 4.0.857(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.11.2)(react-dom@19.0.0(react@19.0.0))(typescript@5.9.3) - '@mintlify/validation': 0.1.558(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.11.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3) + '@mintlify/prebuild': 1.0.801(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.15.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3) + '@mintlify/previewing': 4.0.857(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.15.0)(react-dom@19.0.0(react@19.0.0))(typescript@5.9.3) + '@mintlify/validation': 0.1.558(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.15.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3) adm-zip: 0.5.16 chalk: 5.2.0 color: 4.2.3 @@ -11437,13 +11789,13 @@ snapshots: - ts-node - typescript - '@mintlify/link-rot@3.0.823(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.11.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)': + '@mintlify/link-rot@3.0.823(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.15.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)': dependencies: '@mintlify/common': 1.0.666(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3) - '@mintlify/prebuild': 1.0.801(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.11.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3) - '@mintlify/previewing': 4.0.857(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.11.2)(react-dom@19.0.0(react@19.0.0))(typescript@5.9.3) + '@mintlify/prebuild': 1.0.801(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.15.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3) + '@mintlify/previewing': 4.0.857(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.15.0)(react-dom@19.0.0(react@19.0.0))(typescript@5.9.3) '@mintlify/scraping': 4.0.522(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3) - '@mintlify/validation': 0.1.558(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.11.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3) + '@mintlify/validation': 0.1.558(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.15.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3) fs-extra: 11.1.0 unist-util-visit: 4.1.2 transitivePeerDependencies: @@ -11489,6 +11841,33 @@ snapshots: - supports-color - typescript + '@mintlify/mdx@3.0.4(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.15.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)': + dependencies: + '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@shikijs/transformers': 3.15.0 + '@shikijs/twoslash': 3.15.0(typescript@5.9.3) + arktype: 2.1.27 + hast-util-to-string: 3.0.1 + mdast-util-from-markdown: 2.0.2 + mdast-util-gfm: 3.1.0 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-to-hast: 13.2.0 + next-mdx-remote-client: 1.0.7(@types/react@19.2.13)(acorn@8.15.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + rehype-katex: 7.0.1 + remark-gfm: 4.0.1 + remark-math: 6.0.0 + remark-smartypants: 3.0.2 + shiki: 3.15.0 + unified: 11.0.5 + unist-util-visit: 5.0.0 + transitivePeerDependencies: + - '@types/react' + - acorn + - supports-color + - typescript + '@mintlify/models@0.0.255': dependencies: axios: 1.10.0 @@ -11512,12 +11891,12 @@ snapshots: leven: 4.0.0 yaml: 2.6.1 - '@mintlify/prebuild@1.0.801(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.11.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)': + '@mintlify/prebuild@1.0.801(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.15.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)': dependencies: '@mintlify/common': 1.0.666(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3) '@mintlify/openapi-parser': 0.0.8 '@mintlify/scraping': 4.0.527(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3) - '@mintlify/validation': 0.1.558(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.11.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3) + '@mintlify/validation': 0.1.558(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.15.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3) chalk: 5.3.0 favicons: 7.2.0 front-matter: 4.0.2 @@ -11543,11 +11922,11 @@ snapshots: - typescript - utf-8-validate - '@mintlify/previewing@4.0.857(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.11.2)(react-dom@19.0.0(react@19.0.0))(typescript@5.9.3)': + '@mintlify/previewing@4.0.857(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.15.0)(react-dom@19.0.0(react@19.0.0))(typescript@5.9.3)': dependencies: '@mintlify/common': 1.0.666(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3) - '@mintlify/prebuild': 1.0.801(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.11.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3) - '@mintlify/validation': 0.1.558(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.11.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3) + '@mintlify/prebuild': 1.0.801(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.15.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3) + '@mintlify/validation': 0.1.558(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.15.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3) better-opn: 3.0.2 chalk: 5.2.0 chokidar: 3.5.3 @@ -11692,6 +12071,29 @@ snapshots: - supports-color - typescript + '@mintlify/validation@0.1.558(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.15.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3)': + dependencies: + '@mintlify/mdx': 3.0.4(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/react@19.2.13)(acorn@8.15.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3) + '@mintlify/models': 0.0.257 + arktype: 2.1.27 + js-yaml: 4.1.0 + lcm: 0.0.3 + lodash: 4.17.21 + object-hash: 3.0.0 + openapi-types: 12.1.3 + uuid: 11.1.0 + zod: 3.21.4 + zod-to-json-schema: 3.20.4(zod@3.21.4) + transitivePeerDependencies: + - '@radix-ui/react-popover' + - '@types/react' + - acorn + - debug + - react + - react-dom + - supports-color + - typescript + '@monogrid/gainmap-js@3.1.0(three@0.170.0)': dependencies: promise-worker-transferable: 1.0.4 @@ -12844,6 +13246,8 @@ snapshots: - '@types/react' - immer + '@remirror/core-constants@3.0.0': {} + '@responsive-email/react-email@0.0.4(react@19.0.0)': dependencies: '@react-email/section': 0.0.14(react@19.0.0) @@ -13350,6 +13754,193 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.1.17 + '@tiptap/core@3.20.0(@tiptap/pm@3.20.0)': + dependencies: + '@tiptap/pm': 3.20.0 + + '@tiptap/extension-blockquote@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + + '@tiptap/extension-bold@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + + '@tiptap/extension-bubble-menu@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)': + dependencies: + '@floating-ui/dom': 1.6.12 + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + '@tiptap/pm': 3.20.0 + optional: true + + '@tiptap/extension-bullet-list@3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/extension-list': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + + '@tiptap/extension-code-block@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + '@tiptap/pm': 3.20.0 + + '@tiptap/extension-code@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + + '@tiptap/extension-document@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + + '@tiptap/extension-dropcursor@3.20.0(@tiptap/extensions@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/extensions': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + + '@tiptap/extension-floating-menu@3.20.0(@floating-ui/dom@1.6.12)(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)': + dependencies: + '@floating-ui/dom': 1.6.12 + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + '@tiptap/pm': 3.20.0 + optional: true + + '@tiptap/extension-gapcursor@3.20.0(@tiptap/extensions@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/extensions': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + + '@tiptap/extension-hard-break@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + + '@tiptap/extension-heading@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + + '@tiptap/extension-horizontal-rule@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + '@tiptap/pm': 3.20.0 + + '@tiptap/extension-italic@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + + '@tiptap/extension-link@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + '@tiptap/pm': 3.20.0 + linkifyjs: 4.3.2 + + '@tiptap/extension-list-item@3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/extension-list': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + + '@tiptap/extension-list-keymap@3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/extension-list': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + + '@tiptap/extension-list@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + '@tiptap/pm': 3.20.0 + + '@tiptap/extension-ordered-list@3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/extension-list': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + + '@tiptap/extension-paragraph@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + + '@tiptap/extension-placeholder@3.20.0(@tiptap/extensions@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/extensions': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + + '@tiptap/extension-strike@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + + '@tiptap/extension-text@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + + '@tiptap/extension-underline@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + + '@tiptap/extensions@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + '@tiptap/pm': 3.20.0 + + '@tiptap/html@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)(happy-dom@20.0.11)': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + '@tiptap/pm': 3.20.0 + happy-dom: 20.0.11 + + '@tiptap/pm@3.20.0': + dependencies: + prosemirror-changeset: 2.4.0 + prosemirror-collab: 1.3.1 + prosemirror-commands: 1.7.1 + prosemirror-dropcursor: 1.8.2 + prosemirror-gapcursor: 1.4.0 + prosemirror-history: 1.5.0 + prosemirror-inputrules: 1.5.1 + prosemirror-keymap: 1.2.3 + prosemirror-markdown: 1.13.4 + prosemirror-menu: 1.2.5 + prosemirror-model: 1.25.4 + prosemirror-schema-basic: 1.2.4 + prosemirror-schema-list: 1.5.1 + prosemirror-state: 1.4.4 + prosemirror-tables: 1.8.5 + prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6) + prosemirror-transform: 1.11.0 + prosemirror-view: 1.41.6 + + '@tiptap/react@3.20.0(@floating-ui/dom@1.6.12)(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + '@tiptap/pm': 3.20.0 + '@types/react': 19.2.13 + '@types/react-dom': 19.2.3(@types/react@19.2.13) + '@types/use-sync-external-store': 0.0.6 + fast-equals: 5.4.0 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + use-sync-external-store: 1.6.0(react@19.0.0) + optionalDependencies: + '@tiptap/extension-bubble-menu': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + '@tiptap/extension-floating-menu': 3.20.0(@floating-ui/dom@1.6.12)(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + transitivePeerDependencies: + - '@floating-ui/dom' + + '@tiptap/starter-kit@3.20.0': + dependencies: + '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0) + '@tiptap/extension-blockquote': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0)) + '@tiptap/extension-bold': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0)) + '@tiptap/extension-bullet-list': 3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)) + '@tiptap/extension-code': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0)) + '@tiptap/extension-code-block': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + '@tiptap/extension-document': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0)) + '@tiptap/extension-dropcursor': 3.20.0(@tiptap/extensions@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)) + '@tiptap/extension-gapcursor': 3.20.0(@tiptap/extensions@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)) + '@tiptap/extension-hard-break': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0)) + '@tiptap/extension-heading': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0)) + '@tiptap/extension-horizontal-rule': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + '@tiptap/extension-italic': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0)) + '@tiptap/extension-link': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + '@tiptap/extension-list': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + '@tiptap/extension-list-item': 3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)) + '@tiptap/extension-list-keymap': 3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)) + '@tiptap/extension-ordered-list': 3.20.0(@tiptap/extension-list@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)) + '@tiptap/extension-paragraph': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0)) + '@tiptap/extension-strike': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0)) + '@tiptap/extension-text': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0)) + '@tiptap/extension-underline': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0)) + '@tiptap/extensions': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0) + '@tiptap/pm': 3.20.0 + '@tootallnate/quickjs-emscripten@0.23.0': {} '@tweenjs/tween.js@23.1.3': {} @@ -13449,10 +14040,19 @@ snapshots: '@types/katex@0.16.7': {} + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 + '@types/mdurl@2.0.0': {} + '@types/mdx@2.0.13': {} '@types/mime-types@2.1.4': {} @@ -13528,6 +14128,8 @@ snapshots: '@types/urijs@1.19.25': {} + '@types/use-sync-external-store@0.0.6': {} + '@types/webpack@5.28.5(@swc/core@1.3.101(@swc/helpers@0.5.15))(esbuild@0.19.11)': dependencies: '@types/node': 22.19.7 @@ -14310,6 +14912,8 @@ snapshots: optionalDependencies: typescript: 5.9.3 + crelt@1.0.6: {} + cross-env@10.1.0: dependencies: '@epic-web/invariant': 1.0.0 @@ -15090,6 +15694,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-equals@5.4.0: {} + fast-fifo@1.3.2: {} fast-glob@3.3.2: @@ -16222,6 +16828,12 @@ snapshots: lines-and-columns@1.2.4: {} + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + + linkifyjs@4.3.2: {} + loader-runner@4.3.1: {} locate-path@5.0.0: @@ -16288,6 +16900,15 @@ snapshots: markdown-extensions@2.0.0: {} + markdown-it@14.1.1: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + markdown-table@3.0.4: {} marked@15.0.12: {} @@ -16511,6 +17132,8 @@ snapshots: mdn-data@2.12.2: {} + mdurl@2.0.0: {} + media-typer@0.3.0: {} merge-descriptors@1.0.1: {} @@ -16864,9 +17487,9 @@ snapshots: minipass: 3.3.6 yallist: 4.0.0 - mintlify@4.2.280(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/node@25.0.6)(@types/react@19.2.13)(acorn@8.11.2)(react-dom@19.0.0(react@19.0.0))(typescript@5.9.3): + mintlify@4.2.280(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/node@25.0.6)(@types/react@19.2.13)(acorn@8.15.0)(react-dom@19.0.0(react@19.0.0))(typescript@5.9.3): dependencies: - '@mintlify/cli': 4.0.884(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/node@25.0.6)(@types/react@19.2.13)(acorn@8.11.2)(react-dom@19.0.0(react@19.0.0))(typescript@5.9.3) + '@mintlify/cli': 4.0.884(@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@types/node@25.0.6)(@types/react@19.2.13)(acorn@8.15.0)(react-dom@19.0.0(react@19.0.0))(typescript@5.9.3) transitivePeerDependencies: - '@radix-ui/react-popover' - '@types/node' @@ -16944,6 +17567,22 @@ snapshots: - acorn - supports-color + next-mdx-remote-client@1.0.7(@types/react@19.2.13)(acorn@8.15.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + dependencies: + '@babel/code-frame': 7.27.1 + '@mdx-js/mdx': 3.1.0(acorn@8.15.0) + '@mdx-js/react': 3.1.0(@types/react@19.2.13)(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + remark-mdx-remove-esm: 1.1.0 + serialize-error: 12.0.0 + vfile: 6.0.3 + vfile-matter: 5.0.0 + transitivePeerDependencies: + - '@types/react' + - acorn + - supports-color + next-safe-action@8.0.11(next@16.1.6(@babel/core@7.26.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: next: 16.1.6(@babel/core@7.26.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -17181,6 +17820,8 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.1.0 + orderedmap@2.1.1: {} + outdent@0.5.0: {} own-keys@1.0.1: @@ -17477,6 +18118,109 @@ snapshots: property-information@7.0.0: {} + prosemirror-changeset@2.4.0: + dependencies: + prosemirror-transform: 1.11.0 + + prosemirror-collab@1.3.1: + dependencies: + prosemirror-state: 1.4.4 + + prosemirror-commands@1.7.1: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + + prosemirror-dropcursor@1.8.2: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + prosemirror-view: 1.41.6 + + prosemirror-gapcursor@1.4.0: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-view: 1.41.6 + + prosemirror-history@1.5.0: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + prosemirror-view: 1.41.6 + rope-sequence: 1.3.4 + + prosemirror-inputrules@1.5.1: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + + prosemirror-keymap@1.2.3: + dependencies: + prosemirror-state: 1.4.4 + w3c-keyname: 2.2.8 + + prosemirror-markdown@1.13.4: + dependencies: + '@types/markdown-it': 14.1.2 + markdown-it: 14.1.1 + prosemirror-model: 1.25.4 + + prosemirror-menu@1.2.5: + dependencies: + crelt: 1.0.6 + prosemirror-commands: 1.7.1 + prosemirror-history: 1.5.0 + prosemirror-state: 1.4.4 + + prosemirror-model@1.25.4: + dependencies: + orderedmap: 2.1.1 + + prosemirror-schema-basic@1.2.4: + dependencies: + prosemirror-model: 1.25.4 + + prosemirror-schema-list@1.5.1: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + + prosemirror-state@1.4.4: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-transform: 1.11.0 + prosemirror-view: 1.41.6 + + prosemirror-tables@1.8.5: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + prosemirror-view: 1.41.6 + + prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6): + dependencies: + '@remirror/core-constants': 3.0.0 + escape-string-regexp: 4.0.0 + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-view: 1.41.6 + + prosemirror-transform@1.11.0: + dependencies: + prosemirror-model: 1.25.4 + + prosemirror-view@1.41.6: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -17508,6 +18252,8 @@ snapshots: end-of-stream: 1.4.4 once: 1.4.0 + punycode.js@2.3.1: {} + punycode@2.3.1: {} puppeteer-core@22.14.0: @@ -17764,6 +18510,16 @@ snapshots: transitivePeerDependencies: - acorn + recma-jsx@1.0.0(acorn@8.15.0): + dependencies: + acorn-jsx: 5.3.2(acorn@8.15.0) + estree-util-to-js: 2.0.0 + recma-parse: 1.0.0 + recma-stringify: 1.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - acorn + recma-parse@1.0.0: dependencies: '@types/estree': 1.0.8 @@ -18098,6 +18854,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.52.2 fsevents: 2.3.3 + rope-sequence@1.3.4: {} + rrweb-cssom@0.8.0: {} run-async@3.0.0: {} @@ -19128,6 +19886,8 @@ snapshots: typescript@5.9.3: {} + uc.micro@2.1.0: {} + ufo@1.5.4: {} uint8array-extras@1.5.0: {} @@ -19419,6 +20179,8 @@ snapshots: - tsx - yaml + w3c-keyname@2.2.8: {} + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0