diff --git a/docs/pages/docs/editor-api/_meta.json b/docs/pages/docs/editor-api/_meta.json index 4f2961a73b..af00c8deec 100644 --- a/docs/pages/docs/editor-api/_meta.json +++ b/docs/pages/docs/editor-api/_meta.json @@ -7,6 +7,7 @@ "export-to-pdf": "", "export-to-docx": "", "export-to-odt": "", + "export-to-email": "", "events": "", "methods": "" } diff --git a/docs/pages/docs/editor-api/export-to-email.mdx b/docs/pages/docs/editor-api/export-to-email.mdx new file mode 100644 index 0000000000..3786bdd334 --- /dev/null +++ b/docs/pages/docs/editor-api/export-to-email.mdx @@ -0,0 +1,108 @@ +--- +title: Export to email +description: Export BlockNote documents to an email. +imageTitle: Export to email +path: /docs/export-to-email +--- + +import { Example } from "@/components/example"; +import { Callout } from "nextra/components"; + +# Exporting blocks to email + +Leveraging the [React Email](https://react.email/) library, it's possible to export BlockNote documents to email, completely client-side. + + + This feature is provided by the `@blocknote/xl-email-exporter`. `xl-` packages + are fully open source, but released under a copyleft license. A commercial + license for usage in closed source, proprietary products comes as part of the + [Business subscription](/pricing). + + +First, install the `@blocknote/xl-email-exporter` packages: + +```bash +npm install @blocknote/xl-email-exporter +``` + +Then, create an instance of the `ReactEmailExporter` class. This exposes the following methods: + +```typescript +import { + ReactEmailExporter, + reactEmailDefaultSchemaMappings, +} from "@blocknote/xl-email-exporter"; + +// Create the exporter +const exporter = new ReactEmailExporter(editor.schema, reactEmailDefaultSchemaMappings); + +// Convert the blocks to a react-email document +const html = await exporter.toReactEmailDocument(editor.document); + +// Use react-email to write to file: +await ReactEmail.render(html, `filename.html`); +``` + +See the [full example](/examples/interoperability/converting-blocks-to-react-email) with live React Email preview below: + + + +### Customizing the Email + +`toReactEmailDocument` takes an optional `options` parameter, which allows you to customize: + + - **preview**: Set the preview text for the email (can be a string or an array of strings) + - **header**: Add content to the top of the email (must be a React-Email compatible component) + - **footer**: Add content to the bottom of the email (must be a React-Email compatible component) + - **head**: Inject elements into the [Head element](https://react.email/docs/components/head) + +Example usage: + +```tsx +import { Text } from "@react-email/components"; +const html = await exporter.toReactEmailDocument(editor.document, { + preview: "This is a preview of the email content", + header: Header, + footer: Footer, + head: My email, +}); +``` + +### Custom mappings / custom schemas + +The `ReactEmailExporter` constructor takes a `schema` and `mappings` parameter. +A _mapping_ defines how to convert a BlockNote schema element (a Block, Inline Content, or Style) to a React-Email element. +If you're using a [custom schema](/docs/custom-schemas) in your editor, or if you want to overwrite how default BlockNote elements are converted to React Email, you can pass your own `mappings`: + +For example, use the following code in case your schema has an `extraBlock` type: + +```typescript +import { ReactEmailExporter, reactEmailDefaultSchemaMappings } from "@blocknote/xl-email-exporter"; +import { Text } from "@react-email/components"; + +new ReactEmailExporter(schema, { + blockMapping: { + ...reactEmailDefaultSchemaMappings.blockMapping, + myCustomBlock: (block, exporter) => { + return My custom block; + }, + }, + inlineContentMapping: reactEmailDefaultSchemaMappings.inlineContentMapping, + styleMapping: reactEmailDefaultSchemaMappings.styleMapping, +}); +``` + +### Exporter options + +The `ReactEmailExporter` constructor takes an optional `options` parameter. +While conversion happens on the client-side, the default setup uses two server based resources: + +```typescript +const defaultOptions = { + // a function to resolve external resources in order to avoid CORS issues + // by default, this calls a BlockNote hosted server-side proxy to resolve files + resolveFileUrl: corsProxyResolveFileUrl, + // the colors to use in the email + colors: COLORS_DEFAULT, // defaults from @blocknote/core +}; +``` diff --git a/examples/05-interoperability/08-converting-blocks-to-react-email/.bnexample.json b/examples/05-interoperability/08-converting-blocks-to-react-email/.bnexample.json new file mode 100644 index 0000000000..4292fc5c25 --- /dev/null +++ b/examples/05-interoperability/08-converting-blocks-to-react-email/.bnexample.json @@ -0,0 +1,11 @@ +{ + "playground": true, + "docs": true, + "author": "jmarbutt", + "tags": [""], + "dependencies": { + "@blocknote/xl-email-exporter": "latest", + "@react-email/render": "^1.1.2" + }, + "pro": true +} diff --git a/examples/05-interoperability/08-converting-blocks-to-react-email/App.tsx b/examples/05-interoperability/08-converting-blocks-to-react-email/App.tsx new file mode 100644 index 0000000000..c74855be0e --- /dev/null +++ b/examples/05-interoperability/08-converting-blocks-to-react-email/App.tsx @@ -0,0 +1,358 @@ +import { + BlockNoteSchema, + combineByGroup, + filterSuggestionItems, + withPageBreak, +} from "@blocknote/core"; +import "@blocknote/core/fonts/inter.css"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { + SuggestionMenuController, + getDefaultReactSlashMenuItems, + useCreateBlockNote, +} from "@blocknote/react"; +import { + ReactEmailExporter, + reactEmailDefaultSchemaMappings, +} from "@blocknote/xl-email-exporter"; +import { useEffect, useMemo, useState } from "react"; + +import "./styles.css"; + +export default function App() { + // Stores the editor's contents as HTML. + const [emailDocument, setEmailDocument] = useState(); + + // Creates a new editor instance with some initial content. + const editor = useCreateBlockNote({ + schema: withPageBreak(BlockNoteSchema.create()), + tables: { + splitCells: true, + cellBackgroundColor: true, + cellTextColor: true, + headers: true, + }, + initialContent: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "Welcome to this ", + styles: { + italic: true, + }, + }, + { + type: "text", + text: "demo!", + styles: { + italic: true, + bold: true, + }, + }, + ], + children: [ + { + type: "paragraph", + content: "Hello World nested", + children: [ + { + type: "paragraph", + content: "Hello World double nested", + }, + ], + }, + ], + }, + { + type: "paragraph", + content: [ + { + type: "text", + text: "This paragraph has a background color", + styles: { bold: true }, + }, + ], + props: { + backgroundColor: "red", + }, + }, + { + type: "paragraph", + content: [ + { + type: "text", + text: "This one too, but it's blue", + styles: { italic: true }, + }, + ], + props: { + backgroundColor: "blue", + }, + }, + { + type: "paragraph", + content: "Paragraph", + }, + { + type: "heading", + content: "Heading", + }, + { + type: "heading", + content: "Heading right", + props: { + textAlignment: "right", + }, + }, + { + type: "paragraph", + content: + "justified paragraph. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", + + props: { + textAlignment: "justify", + }, + }, + { + type: "bulletListItem", + content: + "Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", + children: [ + { + type: "bulletListItem", + content: + "Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", + }, + { + type: "bulletListItem", + content: + "Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", + props: { + textAlignment: "right", + }, + }, + { + type: "numberedListItem", + content: "Numbered List Item 1", + }, + { + type: "numberedListItem", + content: "Numbered List Item 2", + children: [ + { + type: "numberedListItem", + content: "Numbered List Item Nested 1", + }, + { + type: "numberedListItem", + content: "Numbered List Item Nested 2", + }, + { + type: "numberedListItem", + content: "Numbered List Item Nested funky right", + props: { + textAlignment: "right", + backgroundColor: "red", + textColor: "blue", + }, + }, + { + type: "numberedListItem", + content: "Numbered List Item Nested funky center", + props: { + textAlignment: "center", + backgroundColor: "red", + textColor: "blue", + }, + }, + ], + }, + ], + }, + { + type: "numberedListItem", + content: "Numbered List Item", + }, + { + type: "checkListItem", + content: "Check List Item", + }, + { + type: "table", + content: { + type: "tableContent", + rows: [ + { + cells: ["Table Cell", "Table Cell", "Table Cell"], + }, + { + cells: ["Table Cell", "Table Cell", "Table Cell"], + }, + { + cells: ["Table Cell", "Table Cell", "Table Cell"], + }, + ], + }, + }, + { + type: "pageBreak", + }, + { + type: "file", + }, + { + type: "image", + props: { + url: "https://interactive-examples.mdn.mozilla.net/media/cc0-images/grapefruit-slice-332-332.jpg", + caption: + "From https://interactive-examples.mdn.mozilla.net/media/cc0-images/grapefruit-slice-332-332.jpg", + }, + }, + { + type: "image", + props: { + previewWidth: 200, + url: "https://interactive-examples.mdn.mozilla.net/media/cc0-images/grapefruit-slice-332-332.jpg", + textAlignment: "right", + }, + }, + { + type: "video", + props: { + url: "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm", + caption: + "From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm", + }, + }, + { + type: "audio", + props: { + url: "https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", + caption: + "From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", + }, + }, + { + type: "paragraph", + }, + { + type: "paragraph", + content: [ + { + type: "text", + text: "Inline Content:", + styles: { bold: true }, + }, + ], + }, + { + type: "paragraph", + content: [ + { + type: "text", + text: "Styled Text", + styles: { + bold: true, + italic: true, + textColor: "red", + backgroundColor: "blue", + }, + }, + { + type: "text", + text: " ", + styles: {}, + }, + { + type: "link", + content: "Link", + href: "https://www.blocknotejs.org", + }, + ], + }, + { + type: "table", + content: { + type: "tableContent", + rows: [ + { + cells: ["Table Cell 1", "Table Cell 2", "Table Cell 3"], + }, + { + cells: [ + "Table Cell 4", + [ + { + type: "text", + text: "Table Cell Bold 5", + styles: { + bold: true, + }, + }, + ], + "Table Cell 6", + ], + }, + { + cells: ["Table Cell 7", "Table Cell 8", "Table Cell 9"], + }, + ], + }, + }, + { + type: "codeBlock", + props: { + language: "javascript", + }, + content: `const helloWorld = (message) => { + console.log("Hello World", message); +};`, + }, + ], + }); + + const onChange = async () => { + if (!editor || !editor.document) { + return; + } + const exporter = new ReactEmailExporter( + editor.schema, + reactEmailDefaultSchemaMappings, + ); + const emailHtml = await exporter.toReactEmailDocument(editor.document); + + setEmailDocument(emailHtml); + }; + + useEffect(() => { + onChange(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const slashMenuItems = useMemo(() => { + return combineByGroup(getDefaultReactSlashMenuItems(editor)); + }, [editor]); + + // Renders the editor instance, and its contents as HTML below. + return ( +
+
+ + + filterSuggestionItems(slashMenuItems, query) + } + /> + +
+
+
+ ); +} diff --git a/examples/05-interoperability/08-converting-blocks-to-react-email/README.md b/examples/05-interoperability/08-converting-blocks-to-react-email/README.md new file mode 100644 index 0000000000..39cad64a26 --- /dev/null +++ b/examples/05-interoperability/08-converting-blocks-to-react-email/README.md @@ -0,0 +1,5 @@ +# Exporting documents to React Email + +This example exports the current document (all blocks) as a React Email document. + +**Try it out:** Edit the document and the preview will update. diff --git a/examples/05-interoperability/08-converting-blocks-to-react-email/index.html b/examples/05-interoperability/08-converting-blocks-to-react-email/index.html new file mode 100644 index 0000000000..b412d92142 --- /dev/null +++ b/examples/05-interoperability/08-converting-blocks-to-react-email/index.html @@ -0,0 +1,14 @@ + + + + + + Exporting documents to React Email + + +
+ + + diff --git a/examples/05-interoperability/08-converting-blocks-to-react-email/main.tsx b/examples/05-interoperability/08-converting-blocks-to-react-email/main.tsx new file mode 100644 index 0000000000..6284417d60 --- /dev/null +++ b/examples/05-interoperability/08-converting-blocks-to-react-email/main.tsx @@ -0,0 +1,11 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App.jsx"; + +const root = createRoot(document.getElementById("root")!); +root.render( + + + +); diff --git a/examples/05-interoperability/08-converting-blocks-to-react-email/package.json b/examples/05-interoperability/08-converting-blocks-to-react-email/package.json new file mode 100644 index 0000000000..39fce8c55c --- /dev/null +++ b/examples/05-interoperability/08-converting-blocks-to-react-email/package.json @@ -0,0 +1,29 @@ +{ + "name": "@blocknote/example-interoperability-converting-blocks-to-react-email", + "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "private": true, + "version": "0.12.4", + "scripts": { + "start": "vite", + "dev": "vite", + "build:prod": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@blocknote/core": "latest", + "@blocknote/react": "latest", + "@blocknote/ariakit": "latest", + "@blocknote/mantine": "latest", + "@blocknote/shadcn": "latest", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "@blocknote/xl-email-exporter": "latest", + "@react-email/render": "^1.1.2" + }, + "devDependencies": { + "@types/react": "^18.0.25", + "@types/react-dom": "^18.0.9", + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.3.4" + } +} diff --git a/examples/05-interoperability/08-converting-blocks-to-react-email/styles.css b/examples/05-interoperability/08-converting-blocks-to-react-email/styles.css new file mode 100644 index 0000000000..fca66829c2 --- /dev/null +++ b/examples/05-interoperability/08-converting-blocks-to-react-email/styles.css @@ -0,0 +1,30 @@ +.wrapper { + display: flex; + flex-direction: row; + height: 100%; +} + +@media (max-width: 800px) { + .wrapper { + flex-direction: column; + } + + .editor { + max-height: 500px; + overflow-y: scroll; + } +} + +.wrapper > div { + flex: 1; +} + +.email { + min-height: 500px; + display: flex; + align-items: stretch; +} + +.editor.bordered { + border: 1px solid gray; +} diff --git a/examples/05-interoperability/08-converting-blocks-to-react-email/tsconfig.json b/examples/05-interoperability/08-converting-blocks-to-react-email/tsconfig.json new file mode 100644 index 0000000000..dbe3e6f62d --- /dev/null +++ b/examples/05-interoperability/08-converting-blocks-to-react-email/tsconfig.json @@ -0,0 +1,36 @@ +{ + "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": [ + "DOM", + "DOM.Iterable", + "ESNext" + ], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "composite": true + }, + "include": [ + "." + ], + "__ADD_FOR_LOCAL_DEV_references": [ + { + "path": "../../../packages/core/" + }, + { + "path": "../../../packages/react/" + } + ] +} \ No newline at end of file diff --git a/examples/05-interoperability/08-converting-blocks-to-react-email/vite.config.ts b/examples/05-interoperability/08-converting-blocks-to-react-email/vite.config.ts new file mode 100644 index 0000000000..f62ab20bc2 --- /dev/null +++ b/examples/05-interoperability/08-converting-blocks-to-react-email/vite.config.ts @@ -0,0 +1,32 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import react from "@vitejs/plugin-react"; +import * as fs from "fs"; +import * as path from "path"; +import { defineConfig } from "vite"; +// import eslintPlugin from "vite-plugin-eslint"; +// https://vitejs.dev/config/ +export default defineConfig((conf) => ({ + plugins: [react()], + optimizeDeps: {}, + build: { + sourcemap: true, + }, + resolve: { + alias: + conf.command === "build" || + !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + ? {} + : ({ + // Comment out the lines below to load a built version of blocknote + // or, keep as is to load live from sources with live reload working + "@blocknote/core": path.resolve( + __dirname, + "../../packages/core/src/" + ), + "@blocknote/react": path.resolve( + __dirname, + "../../packages/react/src/" + ), + } as any), + }, +})); diff --git a/packages/xl-email-exporter/.gitignore b/packages/xl-email-exporter/.gitignore new file mode 100644 index 0000000000..54f07af58b --- /dev/null +++ b/packages/xl-email-exporter/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? \ No newline at end of file diff --git a/packages/xl-email-exporter/package.json b/packages/xl-email-exporter/package.json new file mode 100644 index 0000000000..626507c9fa --- /dev/null +++ b/packages/xl-email-exporter/package.json @@ -0,0 +1,87 @@ +{ + "name": "@blocknote/xl-email-exporter", + "homepage": "https://github.com/TypeCellOS/BlockNote", + "private": false, + "sideEffects": false, + "repository": { + "type": "git", + "url": "git+https://github.com/TypeCellOS/BlockNote.git", + "directory": "packages/xl-email-exporter" + }, + "license": "AGPL-3.0 OR PROPRIETARY", + "version": "0.31.3", + "files": [ + "dist", + "types", + "src" + ], + "keywords": [ + "react", + "javascript", + "editor", + "typescript", + "prosemirror", + "wysiwyg", + "rich-text-editor", + "notion", + "yjs", + "block-based", + "tiptap" + ], + "description": "A \"Notion-style\" block-based extensible text editor built on top of Prosemirror and Tiptap.", + "type": "module", + "source": "src/index.ts", + "types": "./types/src/index.d.ts", + "main": "./dist/blocknote-xl-email-exporter.umd.cjs", + "module": "./dist/blocknote-xl-email-exporter.js", + "exports": { + ".": { + "types": "./types/src/index.d.ts", + "import": "./dist/blocknote-xl-email-exporter.js", + "require": "./dist/blocknote-xl-email-exporter.umd.cjs" + }, + "./style.css": { + "import": "./dist/style.css", + "require": "./dist/style.css" + } + }, + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint src --max-warnings 0", + "test": "vitest --run", + "test-watch": "vitest watch", + "email": "email dev" + }, + "dependencies": { + "@blocknote/core": "0.31.3", + "@blocknote/react": "0.31.3", + "buffer": "^6.0.3", + "react": "^18", + "react-dom": "^18", + "react-email": "^4.0.16", + "@react-email/components": "^0.1.0", + "@react-email/render": "^1.1.2" + }, + "devDependencies": { + "@types/jsdom": "^21.1.7", + "@types/react": "^18.0.25", + "@types/react-dom": "^18.0.9", + "eslint": "^8.10.0", + "rollup-plugin-webpack-stats": "^0.2.2", + "typescript": "^5.0.4", + "vite": "^5.3.4", + "vite-plugin-eslint": "^1.8.1", + "vitest": "^2.0.3" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || >= 19.0.0-rc", + "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc" + }, + "eslintConfig": { + "extends": [ + "../../.eslintrc.json" + ] + }, + "gitHead": "37614ab348dcc7faa830a9a88437b37197a2162d" +} diff --git a/packages/xl-email-exporter/src/index.ts b/packages/xl-email-exporter/src/index.ts new file mode 100644 index 0000000000..0bb826bc42 --- /dev/null +++ b/packages/xl-email-exporter/src/index.ts @@ -0,0 +1 @@ +export * from "./react-email/index.js"; diff --git a/packages/xl-email-exporter/src/react-email/__snapshots__/reactEmailExporter.test.tsx.snap b/packages/xl-email-exporter/src/react-email/__snapshots__/reactEmailExporter.test.tsx.snap new file mode 100644 index 0000000000..ca755fccf5 --- /dev/null +++ b/packages/xl-email-exporter/src/react-email/__snapshots__/reactEmailExporter.test.tsx.snap @@ -0,0 +1,35 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`react email exporter > should export a document (HTML snapshot) > __snapshots__/reactEmailExporter 1`] = `"

Welcome to this demo 🙌!

Hello World nested

Hello World double nested

This paragraph has a background color

Paragraph

Heading

Heading right

justified paragraph. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.


    Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    • Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    • Bullet List Item right. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    1. Numbered List Item 1

    2. Numbered List Item 2

      1. Numbered List Item Nested 1

      2. Numbered List Item Nested 2

      3. Numbered List Item Nested funky right

      4. Numbered List Item Nested funky center

    Numbered List Item

Check List Item

Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
From https://interactive-examples.mdn.mozilla.net/media/cc0-images/grapefruit-slice-332-332.jpg
Open video file

From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm

Open audio file

From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3

audio.mp3

Audio file caption

Inline Content:

Styled Text Link

Table Cell 1Table Cell 2Table Cell 3
Table Cell 4Table Cell Bold 5Table Cell 6
Table Cell 7Table Cell 8Table Cell 9

const helloWorld = (message) => {

console.log("Hello World", message);

};

"`; + +exports[`react email exporter > should export a document with multiple preview lines > __snapshots__/reactEmailExporterWithMultiplePreview 1`] = `"
First preview lineSecond preview line
 ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏

Welcome to this demo 🙌!

Hello World nested

Hello World double nested

This paragraph has a background color

Paragraph

Heading

Heading right

justified paragraph. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.


    Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    • Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    • Bullet List Item right. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    1. Numbered List Item 1

    2. Numbered List Item 2

      1. Numbered List Item Nested 1

      2. Numbered List Item Nested 2

      3. Numbered List Item Nested funky right

      4. Numbered List Item Nested funky center

    Numbered List Item

Check List Item

Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
From https://interactive-examples.mdn.mozilla.net/media/cc0-images/grapefruit-slice-332-332.jpg
Open video file

From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm

Open audio file

From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3

audio.mp3

Audio file caption

Inline Content:

Styled Text Link

Table Cell 1Table Cell 2Table Cell 3
Table Cell 4Table Cell Bold 5Table Cell 6
Table Cell 7Table Cell 8Table Cell 9

const helloWorld = (message) => {

console.log("Hello World", message);

};

"`; + +exports[`react email exporter > should export a document with preview > __snapshots__/reactEmailExporterWithPreview 1`] = `"
This is a preview of the email content
 ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏

Welcome to this demo 🙌!

Hello World nested

Hello World double nested

This paragraph has a background color

Paragraph

Heading

Heading right

justified paragraph. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.


    Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    • Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    • Bullet List Item right. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    1. Numbered List Item 1

    2. Numbered List Item 2

      1. Numbered List Item Nested 1

      2. Numbered List Item Nested 2

      3. Numbered List Item Nested funky right

      4. Numbered List Item Nested funky center

    Numbered List Item

Check List Item

Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
From https://interactive-examples.mdn.mozilla.net/media/cc0-images/grapefruit-slice-332-332.jpg
Open video file

From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm

Open audio file

From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3

audio.mp3

Audio file caption

Inline Content:

Styled Text Link

Table Cell 1Table Cell 2Table Cell 3
Table Cell 4Table Cell Bold 5Table Cell 6
Table Cell 7Table Cell 8Table Cell 9

const helloWorld = (message) => {

console.log("Hello World", message);

};

"`; + +exports[`react email exporter > should handle document with background colors > __snapshots__/reactEmailExporterBackgroundColor 1`] = `"

Text with background color

"`; + +exports[`react email exporter > should handle document with check list items > __snapshots__/reactEmailExporterCheckList 1`] = `"

Checked item

Unchecked item

"`; + +exports[`react email exporter > should handle document with code blocks > __snapshots__/reactEmailExporterCodeBlock 1`] = `"

const hello = 'world';

console.log(hello);

"`; + +exports[`react email exporter > should handle document with complex nested structure > __snapshots__/reactEmailExporterComplexNested 1`] = `"

Complex Document

This is a paragraph with bold and italic text, plus a link.

    List item with nested content

    Nested paragraph

    1. Nested numbered item

"`; + +exports[`react email exporter > should handle document with headings of different levels > __snapshots__/reactEmailExporterHeadings 1`] = `"

Heading 1

Heading 2

Heading 3

"`; + +exports[`react email exporter > should handle document with links > __snapshots__/reactEmailExporterWithLinks 1`] = `"

Click here

"`; + +exports[`react email exporter > should handle document with mixed content types > __snapshots__/reactEmailExporterMixedContent 1`] = `"

Main Heading

Regular paragraph with bold text

    Numbered list item

"`; + +exports[`react email exporter > should handle document with mixed list types > __snapshots__/reactEmailExporterMixedLists 1`] = `"

    Bullet item 1

    Bullet item 2

    Numbered item 1

    Numbered item 2

"`; + +exports[`react email exporter > should handle document with nested lists > __snapshots__/reactEmailExporterNestedLists 1`] = `"

    Parent item

    • Child item

"`; + +exports[`react email exporter > should handle document with only text blocks > __snapshots__/reactEmailExporterSimpleText 1`] = `"

Simple text content

"`; + +exports[`react email exporter > should handle document with styled text > __snapshots__/reactEmailExporterStyledText 1`] = `"

Bold and italic text

"`; + +exports[`react email exporter > should handle document with text alignment > __snapshots__/reactEmailExporterTextAlignment 1`] = `"

Center aligned text

Right aligned text

"`; + +exports[`react email exporter > should handle document with text colors > __snapshots__/reactEmailExporterTextColor 1`] = `"

Colored text

"`; + +exports[`react email exporter > should handle empty document > __snapshots__/reactEmailExporterEmpty 1`] = `"
"`; diff --git a/packages/xl-email-exporter/src/react-email/defaultSchema/blocks.tsx b/packages/xl-email-exporter/src/react-email/defaultSchema/blocks.tsx new file mode 100644 index 0000000000..91f2f8ea71 --- /dev/null +++ b/packages/xl-email-exporter/src/react-email/defaultSchema/blocks.tsx @@ -0,0 +1,350 @@ +import { + DefaultBlockSchema, + mapTableCell, + pageBreakSchema, + StyledText, +} from "@blocknote/core"; +import { BlockMapping } from "@blocknote/core/src/exporter/mapping.js"; +import { + CodeBlock, + dracula, + Heading, + Img, + Link, + PrismLanguage, + Text, +} from "@react-email/components"; + +export const reactEmailBlockMappingForDefaultSchema: BlockMapping< + DefaultBlockSchema & typeof pageBreakSchema.blockSchema, + any, + any, + React.ReactElement, + React.ReactElement | React.ReactElement +> = { + paragraph: (block, t) => { + return {t.transformInlineContent(block.content)}; + }, + bulletListItem: (block, t) => { + // Return only the
  • for grouping in the exporter + return {t.transformInlineContent(block.content)}; + }, + toggleListItem: (block, t) => { + // Return only the
  • for grouping in the exporter + return {t.transformInlineContent(block.content)}; + }, + numberedListItem: (block, t, _nestingLevel) => { + // Return only the
  • for grouping in the exporter + return {t.transformInlineContent(block.content)}; + }, + checkListItem: (block, t) => { + // Render a checkbox using inline SVG for better appearance in email + // block.props.checked should be true/false + const checked = block.props?.checked; + const checkboxSvg = checked ? ( + + + + + ) : ( + + + + ); + return ( + + {checkboxSvg} + {t.transformInlineContent(block.content)} + + ); + }, + heading: (block, t) => { + return ( + + {t.transformInlineContent(block.content)} + + ); + }, + + codeBlock: (block) => { + const textContent = (block.content as StyledText[])[0]?.text || ""; + + return ( + + ); + }, + audio: (block) => { + // Audio icon SVG + const icon = ( + + + + ); + const previewWidth = + "previewWidth" in block.props + ? (block.props as any).previewWidth + : undefined; + return ( +
    + + +
    + ); + }, + video: (block) => { + // Video icon SVG + const icon = ( + + + + ); + const previewWidth = + "previewWidth" in block.props + ? (block.props as any).previewWidth + : undefined; + return ( +
    + + +
    + ); + }, + file: (block) => { + // File icon SVG + const icon = ( + + + + ); + const previewWidth = + "previewWidth" in block.props + ? (block.props as any).previewWidth + : undefined; + return ( +
    + + +
    + ); + }, + image: (block) => { + return ( + {block.props.caption} + ); + }, + table: (block, t) => { + // Render table using standard HTML table elements for email compatibility + const table = block.content; + if (!table || typeof table !== "object" || !Array.isArray(table.rows)) { + return Table data not available; + } + const headerRowsCount = (table.headerRows as number) ?? 0; + const headerColsCount = (table.headerCols as number) ?? 0; + + return ( + + + {table.rows.map((row: any, rowIndex: number) => ( + + {row.cells.map((cell: any, colIndex: number) => { + // Use mapTableCell to normalize table cell data into a standard interface + // This handles partial cells, provides default values, and ensures consistent structure + const normalizedCell = mapTableCell(cell); + const isHeaderRow = rowIndex < headerRowsCount; + const isHeaderCol = colIndex < headerColsCount; + const isHeader = isHeaderRow || isHeaderCol; + const CellTag = isHeader ? "th" : "td"; + return ( + 1 && { + colSpan: normalizedCell.props.colspan || 1, + })} + {...((normalizedCell.props.rowspan || 1) > 1 && { + rowSpan: normalizedCell.props.rowspan || 1, + })} + > + {t.transformInlineContent(normalizedCell.content)} + + ); + })} + + ))} + +
    + ); + }, + quote: (block, t) => { + // Render block quote with a left border and subtle background for email compatibility + return ( + + {t.transformInlineContent(block.content)} + + ); + }, + pageBreak: () => { + // In email, a page break can be represented as a horizontal rule + return ( +
    + ); + }, +}; + +// Helper for file-like blocks (audio, video, file) +function FileLink({ + url, + name, + defaultText, + icon, +}: { + url?: string; + name?: string; + defaultText: string; + icon: React.ReactElement; +}) { + return ( + + {icon} + {name || defaultText} + + ); +} + +function Caption({ caption, width }: { caption?: string; width?: number }) { + if (!caption) { + return null; + } + return ( + + {caption} + + ); +} diff --git a/packages/xl-email-exporter/src/react-email/defaultSchema/index.ts b/packages/xl-email-exporter/src/react-email/defaultSchema/index.ts new file mode 100644 index 0000000000..20b049705d --- /dev/null +++ b/packages/xl-email-exporter/src/react-email/defaultSchema/index.ts @@ -0,0 +1,9 @@ +import { reactEmailBlockMappingForDefaultSchema } from "./blocks.js"; +import { reactEmailInlineContentMappingForDefaultSchema } from "./inlinecontent.js"; +import { reactEmailStyleMappingForDefaultSchema } from "./styles.js"; + +export const reactEmailDefaultSchemaMappings = { + blockMapping: reactEmailBlockMappingForDefaultSchema, + inlineContentMapping: reactEmailInlineContentMappingForDefaultSchema, + styleMapping: reactEmailStyleMappingForDefaultSchema, +}; diff --git a/packages/xl-email-exporter/src/react-email/defaultSchema/inlinecontent.tsx b/packages/xl-email-exporter/src/react-email/defaultSchema/inlinecontent.tsx new file mode 100644 index 0000000000..bbb15ae9dd --- /dev/null +++ b/packages/xl-email-exporter/src/react-email/defaultSchema/inlinecontent.tsx @@ -0,0 +1,26 @@ +import { + DefaultInlineContentSchema, + DefaultStyleSchema, +} from "@blocknote/core"; +import { InlineContentMapping } from "@blocknote/core/src/exporter/mapping.js"; +import { Link } from "@react-email/components"; + +export const reactEmailInlineContentMappingForDefaultSchema: InlineContentMapping< + DefaultInlineContentSchema, + DefaultStyleSchema, + React.ReactElement | React.ReactElement, + React.ReactElement +> = { + link: (ic, t) => { + return ( + + {...ic.content.map((content) => { + return t.transformStyledText(content); + })} + + ); + }, + text: (ic, t) => { + return t.transformStyledText(ic); + }, +}; diff --git a/packages/xl-email-exporter/src/react-email/defaultSchema/styles.tsx b/packages/xl-email-exporter/src/react-email/defaultSchema/styles.tsx new file mode 100644 index 0000000000..9b86345139 --- /dev/null +++ b/packages/xl-email-exporter/src/react-email/defaultSchema/styles.tsx @@ -0,0 +1,61 @@ +import { DefaultStyleSchema, StyleMapping } from "@blocknote/core"; +import { CSSProperties } from "react"; + +export const reactEmailStyleMappingForDefaultSchema: StyleMapping< + DefaultStyleSchema, + CSSProperties +> = { + bold: (val) => { + if (!val) { + return {}; + } + return { + fontWeight: "bold", + }; + }, + italic: (val) => { + if (!val) { + return {}; + } + return { + fontStyle: "italic", + }; + }, + underline: (val) => { + if (!val) { + return {}; + } + return { + textDecoration: "underline", // TODO: could conflict with strike + }; + }, + strike: (val) => { + if (!val) { + return {}; + } + return { + textDecoration: "line-through", + }; + }, + backgroundColor: (val) => { + return { + backgroundColor: val, + }; + }, + textColor: (val) => { + if (!val) { + return {}; + } + return { + color: val, + }; + }, + code: (val) => { + if (!val) { + return {}; + } + return { + fontFamily: "Courier", + }; + }, +}; diff --git a/packages/xl-email-exporter/src/react-email/index.ts b/packages/xl-email-exporter/src/react-email/index.ts new file mode 100644 index 0000000000..8412da0065 --- /dev/null +++ b/packages/xl-email-exporter/src/react-email/index.ts @@ -0,0 +1,2 @@ +export * from "./defaultSchema/index.js"; +export * from "./reactEmailExporter.jsx"; diff --git a/packages/xl-email-exporter/src/react-email/reactEmailExporter.test.tsx b/packages/xl-email-exporter/src/react-email/reactEmailExporter.test.tsx new file mode 100644 index 0000000000..648a1b4ad8 --- /dev/null +++ b/packages/xl-email-exporter/src/react-email/reactEmailExporter.test.tsx @@ -0,0 +1,780 @@ +import { describe, it, expect } from "vitest"; +import { ReactEmailExporter } from "./reactEmailExporter.jsx"; +import { reactEmailDefaultSchemaMappings } from "./defaultSchema/index.js"; +import { + BlockNoteSchema, + createBlockSpec, + createInlineContentSpec, + createStyleSpec, + defaultBlockSpecs, + defaultInlineContentSpecs, + defaultStyleSpecs, + PageBreak, +} from "@blocknote/core"; +import { testDocument } from "@shared/testDocument.js"; + +describe("react email exporter", () => { + it("should export a document (HTML snapshot)", async () => { + const exporter = new ReactEmailExporter( + BlockNoteSchema.create(), + reactEmailDefaultSchemaMappings, + ); + + const html = await exporter.toReactEmailDocument(testDocument as any); + expect(html).toMatchSnapshot("__snapshots__/reactEmailExporter"); + }); + + it("typescript: schema with extra block", async () => { + const schema = BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + pageBreak: PageBreak, + extraBlock: createBlockSpec( + { + content: "none", + type: "extraBlock", + propSchema: {}, + }, + {} as any, + ), + }, + }); + + new ReactEmailExporter( + schema, + // @ts-expect-error + reactEmailDefaultSchemaMappings, + ); + + new ReactEmailExporter(schema, { + // @ts-expect-error + blockMapping: reactEmailDefaultSchemaMappings.blockMapping, + inlineContentMapping: + reactEmailDefaultSchemaMappings.inlineContentMapping, + styleMapping: reactEmailDefaultSchemaMappings.styleMapping, + }); + + new ReactEmailExporter(schema, { + blockMapping: { + ...reactEmailDefaultSchemaMappings.blockMapping, + extraBlock: (_b, _t) => { + throw new Error("extraBlock not implemented"); + }, + }, + inlineContentMapping: + reactEmailDefaultSchemaMappings.inlineContentMapping, + styleMapping: reactEmailDefaultSchemaMappings.styleMapping, + }); + }); + + it("typescript: schema with extra inline content", async () => { + const schema = BlockNoteSchema.create({ + inlineContentSpecs: { + ...defaultInlineContentSpecs, + extraInlineContent: createInlineContentSpec( + { + type: "extraInlineContent", + content: "styled", + propSchema: {}, + }, + {} as any, + ), + }, + }); + + new ReactEmailExporter( + schema, + // @ts-expect-error + reactEmailDefaultSchemaMappings, + ); + + new ReactEmailExporter(schema, { + blockMapping: reactEmailDefaultSchemaMappings.blockMapping, + // @ts-expect-error + inlineContentMapping: + reactEmailDefaultSchemaMappings.inlineContentMapping, + styleMapping: reactEmailDefaultSchemaMappings.styleMapping, + }); + + // no error + new ReactEmailExporter(schema, { + blockMapping: reactEmailDefaultSchemaMappings.blockMapping, + styleMapping: reactEmailDefaultSchemaMappings.styleMapping, + inlineContentMapping: { + ...reactEmailDefaultSchemaMappings.inlineContentMapping, + extraInlineContent: () => { + throw new Error("extraInlineContent not implemented"); + }, + }, + }); + }); + + it("typescript: schema with extra style", async () => { + const schema = BlockNoteSchema.create({ + styleSpecs: { + ...defaultStyleSpecs, + extraStyle: createStyleSpec( + { + type: "extraStyle", + propSchema: "boolean", + }, + {} as any, + ), + }, + }); + + new ReactEmailExporter( + schema, + // @ts-expect-error + reactEmailDefaultSchemaMappings, + ); + + new ReactEmailExporter(schema, { + blockMapping: reactEmailDefaultSchemaMappings.blockMapping, + inlineContentMapping: + reactEmailDefaultSchemaMappings.inlineContentMapping, + // @ts-expect-error + styleMapping: reactEmailDefaultSchemaMappings.styleMapping, + }); + + // no error + new ReactEmailExporter(schema, { + blockMapping: reactEmailDefaultSchemaMappings.blockMapping, + inlineContentMapping: + reactEmailDefaultSchemaMappings.inlineContentMapping, + styleMapping: { + ...reactEmailDefaultSchemaMappings.styleMapping, + extraStyle: () => { + throw new Error("extraStyle not implemented"); + }, + }, + }); + }); + + it("should export a document with preview", async () => { + const exporter = new ReactEmailExporter( + BlockNoteSchema.create({ + blockSpecs: { ...defaultBlockSpecs, pageBreak: PageBreak }, + }), + reactEmailDefaultSchemaMappings, + ); + + const html = await exporter.toReactEmailDocument(testDocument as any, { + preview: "This is a preview of the email content", + }); + expect(html).toMatchSnapshot("__snapshots__/reactEmailExporterWithPreview"); + }); + + it("should export a document with multiple preview lines", async () => { + const exporter = new ReactEmailExporter( + BlockNoteSchema.create({ + blockSpecs: { ...defaultBlockSpecs, pageBreak: PageBreak }, + }), + reactEmailDefaultSchemaMappings, + ); + + const html = await exporter.toReactEmailDocument(testDocument as any, { + preview: ["First preview line", "Second preview line"], + }); + expect(html).toMatchSnapshot( + "__snapshots__/reactEmailExporterWithMultiplePreview", + ); + }); + + it("should handle empty document", async () => { + const exporter = new ReactEmailExporter( + BlockNoteSchema.create(), + reactEmailDefaultSchemaMappings, + ); + + const html = await exporter.toReactEmailDocument([]); + expect(html).toMatchSnapshot("__snapshots__/reactEmailExporterEmpty"); + }); + + it("should handle document with only text blocks", async () => { + const exporter = new ReactEmailExporter( + BlockNoteSchema.create(), + reactEmailDefaultSchemaMappings, + ); + + const simpleDocument = [ + { + id: "1", + type: "paragraph", + content: [ + { + type: "text", + text: "Simple text content", + styles: {}, + }, + ], + children: [], + props: {}, + }, + ]; + + const html = await exporter.toReactEmailDocument(simpleDocument as any); + expect(html).toMatchSnapshot("__snapshots__/reactEmailExporterSimpleText"); + }); + + it("should handle document with styled text", async () => { + const exporter = new ReactEmailExporter( + BlockNoteSchema.create(), + reactEmailDefaultSchemaMappings, + ); + + const styledDocument = [ + { + id: "1", + type: "paragraph", + content: [ + { + type: "text", + text: "Bold and italic text", + styles: { + bold: true, + italic: true, + }, + }, + ], + children: [], + props: {}, + }, + ]; + + const html = await exporter.toReactEmailDocument(styledDocument as any); + expect(html).toMatchSnapshot("__snapshots__/reactEmailExporterStyledText"); + }); + + it("should handle document with links", async () => { + const exporter = new ReactEmailExporter( + BlockNoteSchema.create(), + reactEmailDefaultSchemaMappings, + ); + + const linkDocument = [ + { + id: "1", + type: "paragraph", + content: [ + { + type: "link", + href: "https://example.com", + content: [ + { + type: "text", + text: "Click here", + styles: {}, + }, + ], + }, + ], + children: [], + props: {}, + }, + ]; + + const html = await exporter.toReactEmailDocument(linkDocument as any); + expect(html).toMatchSnapshot("__snapshots__/reactEmailExporterWithLinks"); + }); + + it("should handle document with nested lists", async () => { + const exporter = new ReactEmailExporter( + BlockNoteSchema.create(), + reactEmailDefaultSchemaMappings, + ); + + const nestedListDocument = [ + { + id: "1", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Parent item", + styles: {}, + }, + ], + children: [ + { + id: "2", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Child item", + styles: {}, + }, + ], + children: [], + props: {}, + }, + ], + props: {}, + }, + ]; + + const html = await exporter.toReactEmailDocument(nestedListDocument as any); + expect(html).toMatchSnapshot("__snapshots__/reactEmailExporterNestedLists"); + }); + + it("should handle document with mixed content types", async () => { + const exporter = new ReactEmailExporter( + BlockNoteSchema.create(), + reactEmailDefaultSchemaMappings, + ); + + const mixedDocument = [ + { + id: "1", + type: "heading", + content: [ + { + type: "text", + text: "Main Heading", + styles: {}, + }, + ], + children: [], + props: { level: 1 }, + }, + { + id: "2", + type: "paragraph", + content: [ + { + type: "text", + text: "Regular paragraph with ", + styles: {}, + }, + { + type: "text", + text: "bold text", + styles: { bold: true }, + }, + ], + children: [], + props: {}, + }, + { + id: "3", + type: "numberedListItem", + content: [ + { + type: "text", + text: "Numbered list item", + styles: {}, + }, + ], + children: [], + props: {}, + }, + ]; + + const html = await exporter.toReactEmailDocument(mixedDocument as any); + expect(html).toMatchSnapshot( + "__snapshots__/reactEmailExporterMixedContent", + ); + }); + + it("should handle document with text alignment", async () => { + const exporter = new ReactEmailExporter( + BlockNoteSchema.create(), + reactEmailDefaultSchemaMappings, + ); + + const alignedDocument = [ + { + id: "1", + type: "paragraph", + content: [ + { + type: "text", + text: "Center aligned text", + styles: {}, + }, + ], + children: [], + props: { textAlignment: "center" }, + }, + { + id: "2", + type: "paragraph", + content: [ + { + type: "text", + text: "Right aligned text", + styles: {}, + }, + ], + children: [], + props: { textAlignment: "right" }, + }, + ]; + + const html = await exporter.toReactEmailDocument(alignedDocument as any); + expect(html).toMatchSnapshot( + "__snapshots__/reactEmailExporterTextAlignment", + ); + }); + + it("should handle document with background colors", async () => { + const exporter = new ReactEmailExporter( + BlockNoteSchema.create(), + reactEmailDefaultSchemaMappings, + ); + + const coloredDocument = [ + { + id: "1", + type: "paragraph", + content: [ + { + type: "text", + text: "Text with background color", + styles: {}, + }, + ], + children: [], + props: { backgroundColor: "blue" }, + }, + ]; + + const html = await exporter.toReactEmailDocument(coloredDocument as any); + + expect(html).toMatchSnapshot( + "__snapshots__/reactEmailExporterBackgroundColor", + ); + }); + + it("should handle document with text colors", async () => { + const exporter = new ReactEmailExporter( + BlockNoteSchema.create(), + reactEmailDefaultSchemaMappings, + ); + + const coloredDocument = [ + { + id: "1", + type: "paragraph", + content: [ + { + type: "text", + text: "Colored text", + styles: {}, + }, + ], + children: [], + props: { textColor: "red" }, + }, + ]; + + const html = await exporter.toReactEmailDocument(coloredDocument as any); + + expect(html).toMatchSnapshot("__snapshots__/reactEmailExporterTextColor"); + }); + + it("should handle document with code blocks", async () => { + const exporter = new ReactEmailExporter( + BlockNoteSchema.create(), + reactEmailDefaultSchemaMappings, + ); + + const codeDocument = [ + { + id: "1", + type: "codeBlock", + content: [ + { + type: "text", + text: "const hello = 'world';\nconsole.log(hello);", + styles: {}, + }, + ], + children: [], + props: { language: "javascript" }, + }, + ]; + + const html = await exporter.toReactEmailDocument(codeDocument as any); + + expect(html).toMatchSnapshot("__snapshots__/reactEmailExporterCodeBlock"); + }); + + it("should handle document with check list items", async () => { + const exporter = new ReactEmailExporter( + BlockNoteSchema.create(), + reactEmailDefaultSchemaMappings, + ); + + const checkListDocument = [ + { + id: "1", + type: "checkListItem", + content: [ + { + type: "text", + text: "Checked item", + styles: {}, + }, + ], + children: [], + props: { checked: true }, + }, + { + id: "2", + type: "checkListItem", + content: [ + { + type: "text", + text: "Unchecked item", + styles: {}, + }, + ], + children: [], + props: { checked: false }, + }, + ]; + + const html = await exporter.toReactEmailDocument(checkListDocument as any); + + expect(html).toMatchSnapshot("__snapshots__/reactEmailExporterCheckList"); + }); + + it("should handle document with headings of different levels", async () => { + const exporter = new ReactEmailExporter( + BlockNoteSchema.create(), + reactEmailDefaultSchemaMappings, + ); + + const headingDocument = [ + { + id: "1", + type: "heading", + content: [ + { + type: "text", + text: "Heading 1", + styles: {}, + }, + ], + children: [], + props: { level: 1 }, + }, + { + id: "2", + type: "heading", + content: [ + { + type: "text", + text: "Heading 2", + styles: {}, + }, + ], + children: [], + props: { level: 2 }, + }, + { + id: "3", + type: "heading", + content: [ + { + type: "text", + text: "Heading 3", + styles: {}, + }, + ], + children: [], + props: { level: 3 }, + }, + ]; + + const html = await exporter.toReactEmailDocument(headingDocument as any); + + expect(html).toMatchSnapshot("__snapshots__/reactEmailExporterHeadings"); + }); + + it("should handle document with complex nested structure", async () => { + const exporter = new ReactEmailExporter( + BlockNoteSchema.create(), + reactEmailDefaultSchemaMappings, + ); + + const complexDocument = [ + { + id: "1", + type: "heading", + content: [ + { + type: "text", + text: "Complex Document", + styles: { bold: true }, + }, + ], + children: [], + props: { level: 1 }, + }, + { + id: "2", + type: "paragraph", + content: [ + { + type: "text", + text: "This is a paragraph with ", + styles: {}, + }, + { + type: "text", + text: "bold", + styles: { bold: true }, + }, + { + type: "text", + text: " and ", + styles: {}, + }, + { + type: "text", + text: "italic", + styles: { italic: true }, + }, + { + type: "text", + text: " text, plus a ", + styles: {}, + }, + { + type: "link", + href: "https://example.com", + content: [ + { + type: "text", + text: "link", + styles: {}, + }, + ], + }, + { + type: "text", + text: ".", + styles: {}, + }, + ], + children: [], + props: { textAlignment: "center" }, + }, + { + id: "3", + type: "bulletListItem", + content: [ + { + type: "text", + text: "List item with nested content", + styles: {}, + }, + ], + children: [ + { + id: "4", + type: "paragraph", + content: [ + { + type: "text", + text: "Nested paragraph", + styles: {}, + }, + ], + children: [], + props: {}, + }, + { + id: "5", + type: "numberedListItem", + content: [ + { + type: "text", + text: "Nested numbered item", + styles: {}, + }, + ], + children: [], + props: {}, + }, + ], + props: {}, + }, + ]; + + const html = await exporter.toReactEmailDocument(complexDocument as any); + + expect(html).toMatchSnapshot( + "__snapshots__/reactEmailExporterComplexNested", + ); + }); + + it("should handle document with mixed list types", async () => { + const exporter = new ReactEmailExporter( + BlockNoteSchema.create(), + reactEmailDefaultSchemaMappings, + ); + + const mixedListDocument = [ + { + id: "1", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Bullet item 1", + styles: {}, + }, + ], + children: [], + props: {}, + }, + { + id: "2", + type: "bulletListItem", + content: [ + { + type: "text", + text: "Bullet item 2", + styles: {}, + }, + ], + children: [], + props: {}, + }, + { + id: "3", + type: "numberedListItem", + content: [ + { + type: "text", + text: "Numbered item 1", + styles: {}, + }, + ], + children: [], + props: {}, + }, + { + id: "4", + type: "numberedListItem", + content: [ + { + type: "text", + text: "Numbered item 2", + styles: {}, + }, + ], + children: [], + props: {}, + }, + ]; + + const html = await exporter.toReactEmailDocument(mixedListDocument as any); + + expect(html).toMatchSnapshot("__snapshots__/reactEmailExporterMixedLists"); + }); +}); diff --git a/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx b/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx new file mode 100644 index 0000000000..493f5bf20f --- /dev/null +++ b/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx @@ -0,0 +1,334 @@ +import { + Block, + BlockNoteSchema, + BlockSchema, + COLORS_DEFAULT, + DefaultProps, + Exporter, + ExporterOptions, + InlineContentSchema, + StyleSchema, + StyledText, +} from "@blocknote/core"; +import { + Body, + Container, + Head, + Html, + Link, + Preview, + Section, + Tailwind, +} from "@react-email/components"; +import { render as renderEmail } from "@react-email/render"; +import React, { CSSProperties } from "react"; + +export class ReactEmailExporter< + B extends BlockSchema, + S extends StyleSchema, + I extends InlineContentSchema, +> extends Exporter< + B, + I, + S, + React.ReactElement, + React.ReactElement | React.ReactElement, + CSSProperties, + React.ReactElement +> { + public constructor( + public readonly schema: BlockNoteSchema, + mappings: Exporter< + NoInfer, + NoInfer, + NoInfer, + React.ReactElement, + React.ReactElement | React.ReactElement, + CSSProperties, + React.ReactElement + >["mappings"], + options?: Partial, + ) { + const defaults = { + colors: COLORS_DEFAULT, + } satisfies Partial; + + const newOptions = { + ...defaults, + ...options, + }; + super(schema, mappings, newOptions); + } + + public transformStyledText(styledText: StyledText) { + const stylesArray = this.mapStyles(styledText.styles); + const styles = Object.assign({}, ...stylesArray); + return {styledText.text}; + } + + private async renderGroupedListBlocks( + blocks: Block[], + startIndex: number, + nestingLevel: number, + ): Promise<{ element: React.ReactElement; nextIndex: number }> { + const listType = blocks[startIndex].type; + const listItems: React.ReactElement[] = []; + let j = startIndex; + + for ( + let itemIndex = 1; + j < blocks.length && blocks[j].type === listType; + j++, itemIndex++ + ) { + const block = blocks[j]; + const liContent = (await this.mapBlock( + block as any, + nestingLevel, + itemIndex, + )) as any; + let nestedList: React.ReactElement[] = []; + if (block.children && block.children.length > 0) { + nestedList = await this.renderNestedLists( + block.children, + nestingLevel + 1, + block.id, + ); + } + listItems.push( + + {liContent} + {nestedList.length > 0 && nestedList} + , + ); + } + let element: React.ReactElement; + if (listType === "bulletListItem" || listType === "toggleListItem") { + element = ( +
      + {listItems} +
    + ); + } else { + element = ( +
      + {listItems} +
    + ); + } + return { element, nextIndex: j }; + } + + private async renderNestedLists( + children: Block[], + nestingLevel: number, + parentId: string, + ): Promise[]> { + const nestedList: React.ReactElement[] = []; + let i = 0; + while (i < children.length) { + const child = children[i]; + if ( + child.type === "bulletListItem" || + child.type === "numberedListItem" + ) { + // Group consecutive list items of the same type + const listType = child.type; + const listItems: React.ReactElement[] = []; + let j = i; + + for ( + let itemIndex = 1; + j < children.length && children[j].type === listType; + j++, itemIndex++ + ) { + const listItem = children[j]; + const liContent = (await this.mapBlock( + listItem as any, + nestingLevel, + itemIndex, + )) as any; + const style = this.blocknoteDefaultPropsToReactEmailStyle( + listItem.props as any, + ); + let nestedContent: React.ReactElement[] = []; + if (listItem.children && listItem.children.length > 0) { + // If children are list items, render as nested list; otherwise, as normal blocks + if ( + listItem.children[0] && + (listItem.children[0].type === "bulletListItem" || + listItem.children[0].type === "numberedListItem") + ) { + nestedContent = await this.renderNestedLists( + listItem.children, + nestingLevel + 1, + listItem.id, + ); + } else { + nestedContent = await this.transformBlocks( + listItem.children, + nestingLevel + 1, + ); + } + } + listItems.push( +
  • + {liContent} + {nestedContent.length > 0 && ( +
    {nestedContent}
    + )} +
  • , + ); + } + if (listType === "bulletListItem") { + nestedList.push( +
      + {listItems} +
    , + ); + } else { + nestedList.push( +
      + {listItems} +
    , + ); + } + i = j; + } else { + // Non-list child, render as normal with indentation + const childBlocks = await this.transformBlocks([child], nestingLevel); + nestedList.push( +
    + {childBlocks} +
    , + ); + i++; + } + } + return nestedList; + } + + public async transformBlocks( + blocks: Block[], + nestingLevel = 0, + ): Promise[]> { + const ret: React.ReactElement[] = []; + let i = 0; + while (i < blocks.length) { + const b = blocks[i]; + if (b.type === "bulletListItem" || b.type === "numberedListItem") { + const { element, nextIndex } = await this.renderGroupedListBlocks( + blocks, + i, + nestingLevel, + ); + ret.push(element); + i = nextIndex; + continue; + } + // Non-list blocks + const children = await this.transformBlocks(b.children, nestingLevel + 1); + const self = (await this.mapBlock(b as any, nestingLevel, 0)) as any; + const style = this.blocknoteDefaultPropsToReactEmailStyle(b.props as any); + ret.push( + +
    {self}
    + {children.length > 0 && ( +
    {children}
    + )} +
    , + ); + i++; + } + return ret; + } + + public async toReactEmailDocument( + blocks: Block[], + options?: { + /** + * Inject elements into the {@link Head} element + * @see https://react.email/docs/components/head + */ + head?: React.ReactElement; + /** + * Set the preview text for the email + * @see https://react.email/docs/components/preview + */ + preview?: string | string[]; + /** + * Add a header to every page. + * The React component passed must be a React-Email component + * @see https://react.email/components + */ + header?: React.ReactElement; + /** + * Add a footer to every page. + * The React component passed must be a React-Email component + * @see https://react.email/components + */ + footer?: React.ReactElement; + }, + ) { + const transformedBlocks = await this.transformBlocks(blocks); + return renderEmail( + + {options?.head} + + {options?.preview && {options.preview}} + + + {options?.header} + {transformedBlocks} + {options?.footer} + + + + , + ); + } + + protected blocknoteDefaultPropsToReactEmailStyle( + props: Partial, + ): any { + return { + textAlign: props.textAlignment, + backgroundColor: + props.backgroundColor === "default" || !props.backgroundColor + ? undefined + : this.options.colors[ + props.backgroundColor as keyof typeof this.options.colors + ].background, + color: + props.textColor === "default" || !props.textColor + ? undefined + : this.options.colors[ + props.textColor as keyof typeof this.options.colors + ].text, + alignItems: + props.textAlignment === "right" + ? "flex-end" + : props.textAlignment === "center" + ? "center" + : undefined, + }; + } +} diff --git a/packages/xl-email-exporter/src/vite-env.d.ts b/packages/xl-email-exporter/src/vite-env.d.ts new file mode 100644 index 0000000000..b12ff18be4 --- /dev/null +++ b/packages/xl-email-exporter/src/vite-env.d.ts @@ -0,0 +1,11 @@ +/// + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +interface ImportMetaEnv { + // readonly VITE_APP_TITLE: string; + // more env variables... +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/packages/xl-email-exporter/tsconfig.json b/packages/xl-email-exporter/tsconfig.json new file mode 100644 index 0000000000..dcf6b07cb8 --- /dev/null +++ b/packages/xl-email-exporter/tsconfig.json @@ -0,0 +1,38 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ESNext", "DOM"], + "moduleResolution": "Node", + "jsx": "react-jsx", + "strict": true, + "sourceMap": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "noEmit": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "outDir": "dist", + "declaration": true, + "declarationDir": "types", + "composite": true, + "skipLibCheck": true, + "paths": { + "@shared/*": ["../../shared/*"] + } + }, + "include": ["src"], + "references": [ + { + "path": "../core" + }, + { + "path": "../react" + }, + { + "path": "../../shared" + } + ] +} diff --git a/packages/xl-email-exporter/vite.config.ts b/packages/xl-email-exporter/vite.config.ts new file mode 100644 index 0000000000..535e4c78fc --- /dev/null +++ b/packages/xl-email-exporter/vite.config.ts @@ -0,0 +1,65 @@ +import * as path from "path"; +import { webpackStats } from "rollup-plugin-webpack-stats"; +import { defineConfig } from "vite"; +import pkg from "./package.json"; +// import eslintPlugin from "vite-plugin-eslint"; + +const deps = Object.keys(pkg.dependencies); + +// https://vitejs.dev/config/ +export default defineConfig((conf) => ({ + test: { + setupFiles: ["./vitestSetup.ts"], + // assetsInclude: [ + // "**/*.woff", + // "**/*.woff2", + // "**/*.ttf", + // "**/*.otf", + // ], // Add other font extensions if needed + }, + plugins: [webpackStats() as any], + // used so that vitest resolves the core package from the sources instead of the built version + resolve: { + alias: + conf.command === "build" + ? ({ + "@shared": path.resolve(__dirname, "../../shared/"), + } as Record) + : ({ + "@shared": path.resolve(__dirname, "../../shared/"), + // load live from sources with live reload working + "@blocknote/core": path.resolve(__dirname, "../core/src/"), + "@blocknote/react": path.resolve(__dirname, "../react/src/"), + } as Record), + }, + server: { + fs: { + allow: ["../../shared"], // Allows access to `shared/assets` + }, + }, + build: { + // assetsInclude: ["**/*.woff", "**/*.woff2", "**/*.ttf", "**/*.otf"], // Add other font extensions if needed + sourcemap: true, + lib: { + entry: path.resolve(__dirname, "src/index.ts"), + name: "blocknote-xl-email-exporter", + fileName: "blocknote-xl-email-exporter", + }, + rollupOptions: { + // make sure to externalize deps that shouldn't be bundled + // into your library + external: (source: string) => { + if (deps.includes(source)) { + return true; + } + return source.startsWith("prosemirror-"); + }, + output: { + // Provide global variables to use in the UMD build + // for externalized deps + globals: {}, + interop: "compat", // https://rollupjs.org/migration/#changed-defaults + }, + }, + }, +})); diff --git a/packages/xl-email-exporter/viteSetup.ts b/packages/xl-email-exporter/viteSetup.ts new file mode 100644 index 0000000000..a946b5fc3a --- /dev/null +++ b/packages/xl-email-exporter/viteSetup.ts @@ -0,0 +1,10 @@ +import { afterEach, beforeEach } from "vitest"; + +beforeEach(() => { + globalThis.window = globalThis.window || ({} as any); + (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS = {}; +}); + +afterEach(() => { + delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS; +}); diff --git a/packages/xl-email-exporter/vitestSetup.ts b/packages/xl-email-exporter/vitestSetup.ts new file mode 100644 index 0000000000..a946b5fc3a --- /dev/null +++ b/packages/xl-email-exporter/vitestSetup.ts @@ -0,0 +1,10 @@ +import { afterEach, beforeEach } from "vitest"; + +beforeEach(() => { + globalThis.window = globalThis.window || ({} as any); + (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS = {}; +}); + +afterEach(() => { + delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS; +}); diff --git a/playground/package.json b/playground/package.json index 2f33360186..e858426705 100644 --- a/playground/package.json +++ b/playground/package.json @@ -30,6 +30,7 @@ "@blocknote/xl-multi-column": "workspace:^", "@blocknote/xl-odt-exporter": "workspace:^", "@blocknote/xl-pdf-exporter": "workspace:^", + "@blocknote/xl-email-exporter": "workspace:^", "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", "@liveblocks/core": "^2.23.1", diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx index c03c7ad0be..890b5b3cae 100644 --- a/playground/src/examples.gen.tsx +++ b/playground/src/examples.gen.tsx @@ -1018,6 +1018,29 @@ "pathFromRoot": "examples/05-interoperability", "slug": "interoperability" } + }, + { + "projectSlug": "converting-blocks-to-react-email", + "fullSlug": "interoperability/converting-blocks-to-react-email", + "pathFromRoot": "examples/05-interoperability/08-converting-blocks-to-react-email", + "config": { + "playground": true, + "docs": true, + "author": "jmarbutt", + "tags": [ + "" + ], + "dependencies": { + "@blocknote/xl-email-exporter": "latest", + "@react-email/render": "^1.1.2" + } as any, + "pro": true + }, + "title": "Exporting documents to React Email", + "group": { + "pathFromRoot": "examples/05-interoperability", + "slug": "interoperability" + } } ] }, diff --git a/playground/tsconfig.json b/playground/tsconfig.json index 69075ccbe9..5e675e4f64 100644 --- a/playground/tsconfig.json +++ b/playground/tsconfig.json @@ -29,6 +29,7 @@ { "path": "../packages/xl-pdf-exporter/" }, { "path": "../packages/xl-odt-exporter/" }, { "path": "../packages/xl-docx-exporter/" }, - { "path": "../packages/xl-multi-column/" } + { "path": "../packages/xl-multi-column/" }, + { "path": "../packages/xl-email-exporter/" } ] } diff --git a/playground/vite.config.ts b/playground/vite.config.ts index bdf0f7f7fb..c3586999fa 100644 --- a/playground/vite.config.ts +++ b/playground/vite.config.ts @@ -60,6 +60,10 @@ export default defineConfig((conf) => ({ __dirname, "../../liveblocks/packages/liveblocks-react-blocknote/src/", ), + "@blocknote/xl-email-exporter": resolve( + __dirname, + "../packages/xl-email-exporter/src", + ), /* This can be used when developing against a local version of liveblocks: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e13b7def1..c56aa37cb7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2104,6 +2104,49 @@ importers: specifier: ^5.3.4 version: 5.4.15(@types/node@22.14.1)(lightningcss@1.30.1)(terser@5.39.2) + examples/05-interoperability/08-converting-blocks-to-react-email: + dependencies: + '@blocknote/ariakit': + specifier: latest + version: link:../../../packages/ariakit + '@blocknote/core': + specifier: latest + version: link:../../../packages/core + '@blocknote/mantine': + specifier: latest + version: link:../../../packages/mantine + '@blocknote/react': + specifier: latest + version: link:../../../packages/react + '@blocknote/shadcn': + specifier: latest + version: link:../../../packages/shadcn + '@blocknote/xl-email-exporter': + specifier: latest + version: link:../../../packages/xl-email-exporter + '@react-email/render': + specifier: ^1.1.2 + version: 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/react': + specifier: ^18.0.25 + version: 18.3.20 + '@types/react-dom': + specifier: ^18.0.9 + version: 18.3.5(@types/react@18.3.20) + '@vitejs/plugin-react': + specifier: ^4.3.1 + version: 4.4.1(vite@5.4.15(@types/node@22.14.1)(lightningcss@1.30.1)(terser@5.39.2)) + vite: + specifier: ^5.3.4 + version: 5.4.15(@types/node@22.14.1)(lightningcss@1.30.1)(terser@5.39.2) + examples/06-custom-schema/01-alert-block: dependencies: '@blocknote/ariakit': @@ -3990,6 +4033,61 @@ importers: specifier: ^3.6.3 version: 3.6.5 + packages/xl-email-exporter: + dependencies: + '@blocknote/core': + specifier: 0.31.3 + version: link:../core + '@blocknote/react': + specifier: 0.31.3 + version: link:../react + '@react-email/components': + specifier: ^0.1.0 + version: 0.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@react-email/render': + specifier: ^1.1.2 + version: 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + buffer: + specifier: ^6.0.3 + version: 6.0.3 + react: + specifier: ^18 + version: 18.3.1 + react-dom: + specifier: ^18 + version: 18.3.1(react@18.3.1) + react-email: + specifier: ^4.0.16 + version: 4.0.16(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + devDependencies: + '@types/jsdom': + specifier: ^21.1.7 + version: 21.1.7 + '@types/react': + specifier: ^18.0.25 + version: 18.3.20 + '@types/react-dom': + specifier: ^18.0.9 + version: 18.3.5(@types/react@18.3.20) + eslint: + specifier: ^8.10.0 + version: 8.57.1 + rollup-plugin-webpack-stats: + specifier: ^0.2.2 + version: 0.2.6(rollup@4.37.0) + typescript: + specifier: ^5.0.4 + version: 5.8.2 + vite: + specifier: ^5.3.4 + version: 5.4.15(@types/node@22.14.1)(lightningcss@1.30.1)(terser@5.39.2) + vite-plugin-eslint: + specifier: ^1.8.1 + version: 1.8.1(eslint@8.57.1)(vite@5.4.15(@types/node@22.14.1)(lightningcss@1.30.1)(terser@5.39.2)) + vitest: + specifier: ^2.0.3 + version: 2.1.9(@types/node@22.14.1)(@vitest/ui@2.1.9)(jsdom@25.0.1(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.1)(msw@2.7.3(@types/node@22.14.1)(typescript@5.8.2))(terser@5.39.2) + packages/xl-multi-column: dependencies: '@blocknote/core': @@ -4226,6 +4324,9 @@ importers: '@blocknote/xl-docx-exporter': specifier: workspace:^ version: link:../packages/xl-docx-exporter + '@blocknote/xl-email-exporter': + specifier: workspace:^ + version: link:../packages/xl-email-exporter '@blocknote/xl-multi-column': specifier: workspace:^ version: link:../packages/xl-multi-column @@ -6461,6 +6562,14 @@ packages: '@types/node': optional: true + '@isaacs/balanced-match@4.0.1': + resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} + engines: {node: 20 || >=22} + + '@isaacs/brace-expansion@5.0.0': + resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} + engines: {node: 20 || >=22} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -7649,12 +7758,24 @@ packages: peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc + '@react-email/button@0.1.0': + resolution: {integrity: sha512-fg4LtgTu5zXxaRSly9cuv6sHVF/hi1lElbRaIA8EPx5coWOBhCto6rCPfawcXpaN2oER7rNHUrcNBkI+lz5F9A==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + '@react-email/code-block@0.0.12': resolution: {integrity: sha512-Faw3Ij9+/Qwq6moWaeHnV8Hn7ekc/EqyAzPi6yUar21dhcqYugCC4Da1x4d9nA9zC0H9KU3lYVJczh8D3cA+Eg==} engines: {node: '>=18.0.0'} peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc + '@react-email/code-block@0.1.0': + resolution: {integrity: sha512-jSpHFsgqnQXxDIssE4gvmdtFncaFQz5D6e22BnVjcCPk/udK+0A9jRwGFEG8JD2si9ZXBmU4WsuqQEczuZn4ww==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + '@react-email/code-inline@0.0.5': resolution: {integrity: sha512-MmAsOzdJpzsnY2cZoPHFPk6uDO/Ncpb4Kh1hAt9UZc1xOW3fIzpe1Pi9y9p6wwUmpaeeDalJxAxH6/fnTquinA==} engines: {node: '>=18.0.0'} @@ -7673,6 +7794,12 @@ packages: peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc + '@react-email/components@0.1.0': + resolution: {integrity: sha512-Rx0eZk0XuzLKXC5NoMm8xuH72ALVsPYNb/BvcdCJx4EZAoVpQISb4sCqpo9blVYVIazNr4MqWroqFb3ZNrCLMQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + '@react-email/container@0.0.15': resolution: {integrity: sha512-Qo2IQo0ru2kZq47REmHW3iXjAQaKu4tpeq/M8m1zHIVwKduL2vYOBQWbC2oDnMtWPmkBjej6XxgtZByxM6cCFg==} engines: {node: '>=18.0.0'} @@ -7726,12 +7853,24 @@ packages: peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc + '@react-email/markdown@0.0.15': + resolution: {integrity: sha512-UQA9pVm5sbflgtg3EX3FquUP4aMBzmLReLbGJ6DZQZnAskBF36aI56cRykDq1o+1jT+CKIK1CducPYziaXliag==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + '@react-email/preview@0.0.12': resolution: {integrity: sha512-g/H5fa9PQPDK6WUEG7iTlC19sAktI23qyoiJtMLqQiXFCfWeQMhqjLGKeLSKkfzszqmfJCjZtpSiKtBoOdxp3Q==} engines: {node: '>=18.0.0'} peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc + '@react-email/preview@0.0.13': + resolution: {integrity: sha512-F7j9FJ0JN/A4d7yr+aw28p4uX7VLWs7hTHtLo7WRyw4G+Lit6Zucq4UWKRxJC8lpsUdzVmG7aBJnKOT+urqs/w==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + '@react-email/render@1.0.6': resolution: {integrity: sha512-zNueW5Wn/4jNC1c5LFgXzbUdv5Lhms+FWjOvWAhal7gx5YVf0q6dPJ0dnR70+ifo59gcMLwCZEaTS9EEuUhKvQ==} engines: {node: '>=18.0.0'} @@ -7739,6 +7878,13 @@ packages: react: ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^18.0 || ^19.0 || ^19.0.0-rc + '@react-email/render@1.1.2': + resolution: {integrity: sha512-RnRehYN3v9gVlNMehHPHhyp2RQo7+pSkHDtXPvg3s0GbzM9SQMW4Qrf8GRNvtpLC4gsI+Wt0VatNRUFqjvevbw==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^18.0 || ^19.0 || ^19.0.0-rc + '@react-email/row@0.0.12': resolution: {integrity: sha512-HkCdnEjvK3o+n0y0tZKXYhIXUNPDx+2vq1dJTmqappVHXS5tXS6W5JOPZr5j+eoZ8gY3PShI2LWj5rWF7ZEtIQ==} engines: {node: '>=18.0.0'} @@ -7757,12 +7903,24 @@ packages: peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc + '@react-email/tailwind@1.0.5': + resolution: {integrity: sha512-BH00cZSeFfP9HiDASl+sPHi7Hh77W5nzDgdnxtsVr/m3uQD9g180UwxcE3PhOfx0vRdLzQUU8PtmvvDfbztKQg==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + '@react-email/text@0.1.1': resolution: {integrity: sha512-Zo9tSEzkO3fODLVH1yVhzVCiwETfeEL5wU93jXKWo2DHoMuiZ9Iabaso3T0D0UjhrCB1PBMeq2YiejqeToTyIQ==} engines: {node: '>=18.0.0'} peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc + '@react-email/text@0.1.5': + resolution: {integrity: sha512-o5PNHFSE085VMXayxH+SJ1LSOtGsTv+RpNKnTiJDrJUwoBu77G3PlKOsZZQHCNyD28WsQpl9v2WcJLbQudqwPg==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + '@react-pdf/fns@3.1.2': resolution: {integrity: sha512-qTKGUf0iAMGg2+OsUcp9ffKnKi41RukM/zYIWMDJ4hRVYSr89Q7e3wSDW/Koqx3ea3Uy/z3h2y3wPX6Bdfxk6g==} @@ -9654,6 +9812,10 @@ packages: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + cli-spinners@2.6.1: resolution: {integrity: sha512-x/5fWmGMnbKQAaNwN+UZlV79qBLM9JFnJuJ03gIi5whrob0xV0ofNVHy9DhwGdsMJQc2OKv0oGmLzvaqvAVv+g==} engines: {node: '>=6'} @@ -9742,6 +9904,10 @@ packages: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -10843,6 +11009,10 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-east-asian-width@1.3.0: + resolution: {integrity: sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -10924,6 +11094,11 @@ packages: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} hasBin: true + glob@11.0.3: + resolution: {integrity: sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==} + engines: {node: 20 || >=22} + hasBin: true + glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Glob versions prior to v9 are no longer supported @@ -11323,6 +11498,10 @@ packages: resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} engines: {node: '>=8'} + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + is-map@2.0.3: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} @@ -11424,6 +11603,14 @@ packages: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} + is-unicode-supported@1.3.0: + resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} + engines: {node: '>=12'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + is-url@1.2.4: resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==} @@ -11474,6 +11661,10 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jackspeak@4.1.1: + resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==} + engines: {node: 20 || >=22} + jake@10.9.2: resolution: {integrity: sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==} engines: {node: '>=10'} @@ -11783,6 +11974,14 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} + log-symbols@6.0.0: + resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} + engines: {node: '>=18'} + + log-symbols@7.0.1: + resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} + engines: {node: '>=18'} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -11796,6 +11995,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.1.0: + resolution: {integrity: sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==} + engines: {node: 20 || >=22} + lru-cache@4.1.5: resolution: {integrity: sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==} @@ -12163,6 +12366,10 @@ packages: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + mime-match@1.0.2: resolution: {integrity: sha512-VXp/ugGDVh3eCLOBCiHZMYWQaTNUHv2IJrut+yXA6+JbLPXHglHwfS/5A5L0ll+jkCY7fIzRJcH6OIunF+c6Cg==} @@ -12174,6 +12381,10 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime-types@3.0.1: + resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} + engines: {node: '>= 0.6'} + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -12182,6 +12393,10 @@ packages: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + mimic-response@2.1.0: resolution: {integrity: sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==} engines: {node: '>=8'} @@ -12198,6 +12413,10 @@ packages: minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + minimatch@10.0.3: + resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==} + engines: {node: 20 || >=22} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -12552,6 +12771,10 @@ packages: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + oniguruma-parser@0.5.4: resolution: {integrity: sha512-yNxcQ8sKvURiTwP0mV6bLQCYE7NKfKRRWunhbZnXgxSmB1OXa1lHrN3o4DZd+0Si0kU5blidK7BcROO8qv5TZA==} @@ -12582,6 +12805,10 @@ packages: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} + ora@8.2.0: + resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} + engines: {node: '>=18'} + orderedmap@2.1.1: resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} @@ -12691,6 +12918,10 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.0: + resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==} + engines: {node: 20 || >=22} + path-to-regexp@3.3.0: resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==} @@ -13167,6 +13398,11 @@ packages: react: ^0.14.8 || ^15.0.1 || ^16.0.0 || ^17.0.1 || ^18.0.0 react-dom: ^0.14.8 || ^15.0.1 || ^16.0.0 || ^17.0.1 || ^18.0.0 + react-email@4.0.16: + resolution: {integrity: sha512-auhFU+nQxAkKkP6lQhPyGsa9exwfUEzp2BwZnjHokCwphZlg30tu4t1LgdKRwGPYsi7XNGy6asbVLAUhOVpzzg==} + engines: {node: '>=18.0.0'} + hasBin: true + react-email@4.0.7: resolution: {integrity: sha512-XCXlfZLKv9gHd/ZwUEhCpRGc/FJLZGYczeuG1kVR/be2PlkwEB4gjX9ARBbRFv86ncbtpOu/wI6jD6kadRyAKw==} engines: {node: '>=18.0.0'} @@ -13457,6 +13693,10 @@ packages: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + restructure@3.0.2: resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==} @@ -13819,6 +14059,10 @@ packages: std-env@3.8.1: resolution: {integrity: sha512-vj5lIj3Mwf9D79hBkltk5qmkFI+biIKWS2IBxEyEU3AX1tUf7AoL8nSazCOiiqQsGKIq01SClsKEzweu34uwvA==} + stdin-discarder@0.2.2: + resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + engines: {node: '>=18'} + stoppable@1.1.0: resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==} engines: {node: '>=4', npm: '>=6'} @@ -13841,6 +14085,10 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + string.prototype.codepointat@0.2.1: resolution: {integrity: sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==} @@ -14878,6 +15126,10 @@ packages: resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==} engines: {node: '>=18'} + yoctocolors@2.1.1: + resolution: {integrity: sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==} + engines: {node: '>=18'} + yoga-layout@3.2.1: resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} @@ -16334,12 +16586,12 @@ snapshots: '@babel/traverse@7.27.0': dependencies: - '@babel/code-frame': 7.26.2 + '@babel/code-frame': 7.27.1 '@babel/generator': 7.27.0 '@babel/parser': 7.27.0 '@babel/template': 7.27.0 '@babel/types': 7.27.0 - debug: 4.4.0 + debug: 4.4.1 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -17135,6 +17387,12 @@ snapshots: optionalDependencies: '@types/node': 22.14.1 + '@isaacs/balanced-match@4.0.1': {} + + '@isaacs/brace-expansion@5.0.0': + dependencies: + '@isaacs/balanced-match': 4.0.1 + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -18481,11 +18739,20 @@ snapshots: dependencies: react: 18.3.1 + '@react-email/button@0.1.0(react@18.3.1)': + dependencies: + react: 18.3.1 + '@react-email/code-block@0.0.12(react@18.3.1)': dependencies: prismjs: 1.30.0 react: 18.3.1 + '@react-email/code-block@0.1.0(react@18.3.1)': + dependencies: + prismjs: 1.30.0 + react: 18.3.1 + '@react-email/code-inline@0.0.5(react@18.3.1)': dependencies: react: 18.3.1 @@ -18520,6 +18787,32 @@ snapshots: transitivePeerDependencies: - react-dom + '@react-email/components@0.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@react-email/body': 0.0.11(react@18.3.1) + '@react-email/button': 0.1.0(react@18.3.1) + '@react-email/code-block': 0.1.0(react@18.3.1) + '@react-email/code-inline': 0.0.5(react@18.3.1) + '@react-email/column': 0.0.13(react@18.3.1) + '@react-email/container': 0.0.15(react@18.3.1) + '@react-email/font': 0.0.9(react@18.3.1) + '@react-email/head': 0.0.12(react@18.3.1) + '@react-email/heading': 0.0.15(react@18.3.1) + '@react-email/hr': 0.0.11(react@18.3.1) + '@react-email/html': 0.0.11(react@18.3.1) + '@react-email/img': 0.0.11(react@18.3.1) + '@react-email/link': 0.0.12(react@18.3.1) + '@react-email/markdown': 0.0.15(react@18.3.1) + '@react-email/preview': 0.0.13(react@18.3.1) + '@react-email/render': 1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@react-email/row': 0.0.12(react@18.3.1) + '@react-email/section': 0.0.16(react@18.3.1) + '@react-email/tailwind': 1.0.5(react@18.3.1) + '@react-email/text': 0.1.5(react@18.3.1) + react: 18.3.1 + transitivePeerDependencies: + - react-dom + '@react-email/container@0.0.15(react@18.3.1)': dependencies: react: 18.3.1 @@ -18557,10 +18850,19 @@ snapshots: md-to-react-email: 5.0.5(react@18.3.1) react: 18.3.1 + '@react-email/markdown@0.0.15(react@18.3.1)': + dependencies: + md-to-react-email: 5.0.5(react@18.3.1) + react: 18.3.1 + '@react-email/preview@0.0.12(react@18.3.1)': dependencies: react: 18.3.1 + '@react-email/preview@0.0.13(react@18.3.1)': + dependencies: + react: 18.3.1 + '@react-email/render@1.0.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: html-to-text: 9.0.5 @@ -18569,6 +18871,14 @@ snapshots: react-dom: 18.3.1(react@18.3.1) react-promise-suspense: 0.3.4 + '@react-email/render@1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + html-to-text: 9.0.5 + prettier: 3.5.3 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-promise-suspense: 0.3.4 + '@react-email/row@0.0.12(react@18.3.1)': dependencies: react: 18.3.1 @@ -18581,10 +18891,18 @@ snapshots: dependencies: react: 18.3.1 + '@react-email/tailwind@1.0.5(react@18.3.1)': + dependencies: + react: 18.3.1 + '@react-email/text@0.1.1(react@18.3.1)': dependencies: react: 18.3.1 + '@react-email/text@0.1.5(react@18.3.1)': + dependencies: + react: 18.3.1 + '@react-pdf/fns@3.1.2': {} '@react-pdf/font@4.0.2': @@ -19580,7 +19898,7 @@ snapshots: '@types/cors@2.8.17': dependencies: - '@types/node': 20.17.45 + '@types/node': 20.17.50 '@types/d3-scale-chromatic@3.1.0': {} @@ -20916,6 +21234,10 @@ snapshots: dependencies: restore-cursor: 3.1.0 + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + cli-spinners@2.6.1: {} cli-spinners@2.9.2: {} @@ -21004,6 +21326,8 @@ snapshots: commander@11.1.0: {} + commander@13.1.0: {} + commander@2.20.3: {} commander@4.1.1: {} @@ -21562,7 +21886,7 @@ snapshots: engine.io@6.6.4: dependencies: '@types/cors': 2.8.17 - '@types/node': 20.17.45 + '@types/node': 20.17.50 accepts: 1.3.8 base64id: 2.0.0 cookie: 0.7.2 @@ -22350,6 +22674,8 @@ snapshots: get-caller-file@2.0.5: {} + get-east-asian-width@1.3.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -22445,6 +22771,15 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + glob@11.0.3: + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.1.1 + minimatch: 10.0.3 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.0 + glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -22955,6 +23290,8 @@ snapshots: is-interactive@1.0.0: {} + is-interactive@2.0.0: {} + is-map@2.0.3: {} is-mobile@3.1.1: {} @@ -23034,6 +23371,10 @@ snapshots: is-unicode-supported@0.1.0: {} + is-unicode-supported@1.3.0: {} + + is-unicode-supported@2.1.0: {} + is-url@1.2.4: {} is-weakmap@2.0.2: {} @@ -23088,6 +23429,10 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jackspeak@4.1.1: + dependencies: + '@isaacs/cliui': 8.0.2 + jake@10.9.2: dependencies: async: 3.2.6 @@ -23423,6 +23768,16 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 + log-symbols@6.0.0: + dependencies: + chalk: 5.4.1 + is-unicode-supported: 1.3.0 + + log-symbols@7.0.1: + dependencies: + is-unicode-supported: 2.1.0 + yoctocolors: 2.1.1 + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -23433,6 +23788,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.1.0: {} + lru-cache@4.1.5: dependencies: pseudomap: 1.0.2 @@ -24269,6 +24626,8 @@ snapshots: mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-match@1.0.2: dependencies: wildcard: 1.1.2 @@ -24281,10 +24640,16 @@ snapshots: dependencies: mime-db: 1.52.0 + mime-types@3.0.1: + dependencies: + mime-db: 1.54.0 + mimic-fn@2.1.0: {} mimic-fn@4.0.0: {} + mimic-function@5.0.1: {} + mimic-response@2.1.0: optional: true @@ -24311,6 +24676,10 @@ snapshots: minimalistic-assert@1.0.1: {} + minimatch@10.0.3: + dependencies: + '@isaacs/brace-expansion': 5.0.0 + minimatch@3.1.2: dependencies: brace-expansion: 1.1.11 @@ -24761,6 +25130,10 @@ snapshots: dependencies: mimic-fn: 4.0.0 + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + oniguruma-parser@0.5.4: {} oniguruma-to-es@4.1.0: @@ -24817,6 +25190,18 @@ snapshots: strip-ansi: 6.0.1 wcwidth: 1.0.1 + ora@8.2.0: + dependencies: + chalk: 5.4.1 + cli-cursor: 5.0.0 + cli-spinners: 2.9.2 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 6.0.0 + stdin-discarder: 0.2.2 + string-width: 7.2.0 + strip-ansi: 7.1.0 + orderedmap@2.1.1: {} outvariant@1.4.3: {} @@ -24936,6 +25321,11 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 + path-scurry@2.0.0: + dependencies: + lru-cache: 11.1.0 + minipass: 7.1.2 + path-to-regexp@3.3.0: {} path-to-regexp@6.3.0: {} @@ -25352,6 +25742,35 @@ snapshots: react-dom: 18.3.1(react@18.3.1) react-is: 18.1.0 + react-email@4.0.16(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/parser': 7.27.0 + '@babel/traverse': 7.27.0 + chalk: 5.4.1 + chokidar: 4.0.3 + commander: 13.1.0 + debounce: 2.0.0 + esbuild: 0.25.1 + glob: 11.0.3 + log-symbols: 7.0.1 + mime-types: 3.0.1 + next: 15.3.1(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + normalize-path: 3.0.0 + ora: 8.2.0 + socket.io: 4.8.1 + transitivePeerDependencies: + - '@babel/core' + - '@opentelemetry/api' + - '@playwright/test' + - babel-plugin-macros + - babel-plugin-react-compiler + - bufferutil + - react + - react-dom + - sass + - supports-color + - utf-8-validate + react-email@4.0.7(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@babel/parser': 7.24.5 @@ -25763,6 +26182,11 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + restructure@3.0.2: {} retry@0.13.1: {} @@ -26249,6 +26673,8 @@ snapshots: std-env@3.8.1: {} + stdin-discarder@0.2.2: {} + stoppable@1.1.0: {} streamsearch@1.1.0: {} @@ -26269,6 +26695,12 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.1.0 + string-width@7.2.0: + dependencies: + emoji-regex: 10.4.0 + get-east-asian-width: 1.3.0 + strip-ansi: 7.1.0 + string.prototype.codepointat@0.2.1: {} string.prototype.includes@2.0.1: @@ -27541,6 +27973,8 @@ snapshots: yoctocolors-cjs@2.1.2: {} + yoctocolors@2.1.1: {} + yoga-layout@3.2.1: {} yoga-wasm-web@0.3.3: {} diff --git a/vitest.workspace.ts b/vitest.workspace.ts index ff13edcc15..7f6d7604f9 100644 --- a/vitest.workspace.ts +++ b/vitest.workspace.ts @@ -6,6 +6,7 @@ export default defineWorkspace([ "./packages/shadcn/vite.config.ts", "./packages/server-util/vite.config.ts", "./packages/xl-pdf-exporter/vite.config.ts", + "./packages/xl-email-exporter/vite.config.ts", "./packages/xl-ai/vite.config.ts", "./packages/mantine/vite.config.ts", "./packages/core/vite.config.ts",