Feat/advanced text editor capability presets (#23657)
# Email editor for Compose campaigns
## Short version
Campaign bodies are currently plain rich text. This PR turns the
composer into an email editor: a centered email canvas with section,
column, button, divider, image and raw-HTML blocks, each editable
through a settings side panel, rendered to email-safe HTML per recipient
at send time. Modelled on Resend's Broadcast editor.
**Product**
- Email canvas with page/body styling (background, width, padding,
corner radius, border, text colour, alignment)
- Blocks: section, 2/3 columns, button, divider, raw HTML, images —
insertable from a floating left rail or the slash menu
- A **section is a container whose typography cascades to its
contents**, so one part of an email can have its own look
- Block settings panel focuses whatever you select and shows its
effective values
- Per-recipient variables (`{{firstName}}`, `{{lastName}}`,
`{{fullName}}`, `{{email}}`, `{{personId}}`) usable in text,
button/link/image URLs, image labels and raw HTML
- Image upload by drag-drop, paste or file picker
**Technical**
- Presets now declare **capabilities** instead of surfaces forking the
editor; the UI derives itself from loaded extensions
- Editor behavior lives in `twenty-front`; the versioned email-document
schema and structural traversal live in `twenty-shared`; rendering lives
in `twenty-emails` — HTML is produced server-side per recipient
- Section typography cascade is **resolved at render time**, not left to
CSS: react-email hardcodes `fontSize`/`lineHeight` on every paragraph
and Outlook ignores `inherit`
- Logic vendored from Resend (MIT); all controls rebuilt on `twenty-ui`
+ Linaria
**Also fixes:** the unsubscribe footer was being appended *after*
`</html>`, outside the document, where Gmail strips it — legally
significant since unsubscribe is required.
---
## Detailed version
### Product requirements
**Problem.** The Compose campaign body was a single rich-text field.
Marketing email needs layout — banded sections, columns, call-to-action
buttons, images with links — and it needs that layout to survive
Outlook, which means table-based HTML rather than the divs a text editor
produces. It also needs per-recipient personalisation.
**Reference.** Resend's Broadcast editor, chosen because it solves the
same problem (TipTap authoring → react-email output) and is MIT
licensed.
#### What a user can now do
| Area | Capability |
|---|---|
| Canvas | Email renders as a centered page with its own background,
width, padding, corner radius and border |
| Blocks | Section, 2/3 columns, button, divider, raw HTML, image |
| Insertion | Floating left rail (pointer-first) or the `/` slash menu
(keyboard-first) |
| Sections | Own text colour, font size, line height, letter spacing and
alignment, cascading to everything inside |
| Images | Upload by drag-drop, paste or picker; link URL, alt text,
width, spacing, border |
| Raw HTML | Edited as source in the panel, previewed on the canvas with
scripts neutralised |
| Variables | `{{firstName}}`, `{{lastName}}`, `{{fullName}}`,
`{{email}}`, `{{personId}}` in text, button URLs, link hrefs and raw
HTML |
| Settings panel | Follows selection; shows effective values; opens
automatically when a block is clicked |
#### Deliberate product decisions
- **Variables display as literal placeholders**, not prose labels, so
the syntax is copyable into HTML blocks and button URLs by hand.
- **Sections inherit until they override.** The panel shows what
actually renders rather than blank fields, but writes nothing until you
edit — so changing the body text colour still flows into sections.
- **Headings keep their own scale** inside a styled section; only
colour, family and spacing cascade, otherwise every heading would
collapse to body size.
- **Clicking a block opens its settings**, but only on whole-node
selections, so typing inside a section does not reopen a panel you just
closed.
### Technical strategy
#### 1. Capability presets (the foundation)
Per-surface variation previously worked by **forking**: three separate
`useEditor` call sites with hardcoded extension arrays. Inside the
shared tree there was no variation at all — all five surfaces received a
byte-identical extension list, and presets controlled only sizing,
chrome and serialization format. Adding email blocks that way meant
either leaking section/column nodes into the record rich-text field and
workflow email body, or writing a fourth fork.
Now:
- a preset declares a **capability list** (`basicMarks`, `headings`,
`lists`, `links`, `images`, `campaignVariables`, `slashCommand`,
`blocks`, `mentions`)
- capabilities resolve to extensions through a factory registry
- the UI derives itself from the loaded extensions via
`hasEditorExtension` — no capability list is prop-drilled into a menu,
because the `Editor` already knows what it can do
The acceptance test was collapsing the AI chat fork into an `aiChat`
preset with no visible change to that composer. `campaignBody` is the
only preset opting into the shared `EMAIL_DOCUMENT_CAPABILITIES` today.
Workflow email keeps its current field UI, but can opt into the same
canvas, block settings and image uploader later without adding another
schema or renderer.
#### 2. Schema / renderer split
The hard constraint: **our HTML is produced server-side, per recipient,
at send time**, because variables substitute into nodes rather than into
a serialized string. That rules out Resend's
`renderToReactEmail`-on-the-extension pattern.
```
twenty-front TipTap extensions + node views + shared email settings UI
twenty-shared versioned email-document schema + structural traversal
twenty-emails react-email renderers (imported by twenty-server)
twenty-server surface-specific variable resolution, validation, send
```
Logic was **vendored, not depended on** — Resend's TipTap is 3.17
against our 3.4, and their UI is Radix. We copied the schema/serializer
approach and rebuilt every control on `twenty-ui` + Linaria.
#### 3. Section typography cascade
The subtle part, and the one that would have silently shipped broken.
Section typography *looks* like it should cascade via CSS. It does not:
```js
// react-email's Text
style: { fontSize: "14px", lineHeight: "24px", ...style, ...margins }
```
Every paragraph re-declares `fontSize` and `lineHeight`, overriding any
enclosing section. `inherit` is not a fix either — Outlook's Word engine
ignores it.
So the cascade is **resolved in the renderer**: the tree walk threads
the enclosing section's typography down and writes computed values
explicitly onto each text node. Nested sections refine what they
inherit.
Verified against real rendered output:
| | rendered |
|---|---|
| paragraph inside section | `font-size:22px; color:rgb(255,0,0);
letter-spacing:2px` |
| h1 inside section | `font-size:32px` (own scale) + section colour and
spacing |
| paragraph outside | `font-size:14px`, no colour — untouched |
#### 4. Storage
`bodyTemplate` stays serialized TipTap JSON in a `TEXT` column. Block
attributes are ProseMirror node attrs, so richer blocks add keys to JSON
already being serialized — no migration, and it flows into the existing
500 ms debounced draft save unchanged.
Since the feature has not shipped, the legacy HTML-string body path was
removed rather than maintained. That is a tightening, not just a
deletion: `bodyTemplate` is writable through the record API, and the old
fallback would interpolate an arbitrary string and email it as markup. A
body that is neither empty nor a valid TipTap document is now rejected
at the send gate.
#### 5. Image hosting
Inline assets use an `EmailImage` file folder with
`ignoreExpirationToken: true` and immutable cache headers, because
recipients' mail clients never authenticate and may open an email years
later. The shared uploader returns `{ fileId, url }`; the image node
keeps both the durable file identity and its delivery URL so
ownership/lifecycle or URL resolution can evolve later without a
document migration. The server verifies the uploaded bytes and only
accepts GIF, JPEG, PNG and WebP.
This is intentionally separate from workflow/email **attachments**.
Attachments remain private files that the server reads and embeds as
MIME parts at send time; inline images need a durable recipient-facing
URL. A future workflow canvas should reuse `useUploadEmailImage` for
inline content while keeping its existing attachment control unchanged.
Adding the folder requires three registrations — the folder config, the
route guard's `SUPPORTED_FILE_FOLDERS`, and `DIRECT_UPLOAD_FILE_FOLDERS`
in the upload service.
### Bugs fixed along the way
- **Unsubscribe footer was appended after `</html>`**, outside the
document, where Gmail strips it. Legally significant, since an
unsubscribe link is required. Now inserted before `</body>`.
- **Body text colour never reached the email.**
- **`onImageUpload` was declared but never passed** by any production
call site, so drag-drop and paste image upload were inert everywhere
outside Storybook.
- **Message lists were not user-facing**, so members could not be added
from the list page.
- **Image resize wrote an undeclared `width` attribute** that TipTap
silently dropped.
- **The text bubble menu appeared over selected atom blocks** with
nothing to format.
### Review notes / known limitations
**Security posture to check.** Anything in `EmailImage` is readable by
anyone holding the URL, forever. The server now enforces an image-only
MIME allowlist from sniffed bytes, but it cannot determine whether the
image itself is confidential. This remains a deliberate trade-off for
recipient-visible inline assets.
**Test gap.** The section typography cascade has no regression test:
react-email's `render()` hangs under Jest (tried 60s), and
`twenty-emails` has no test target at all. Verified by rendering through
the built package instead. Adding a test target there is worthwhile
follow-up.
**Sending needs configuration.** `EMAILING_DOMAIN_DRIVER` defaults to
`LOG`, which fakes a messageId, reports any domain as verified, and only
logs — a campaign reaches "sent" with nothing delivered. Real sending
needs `AWS_SES`.
**Unrelated platform bug found.** The pinned "Create new record" command
throws on viewless objects like `messageListMember`, because
`recordIndexId` derives from the current view.
**Not done.** Panel chrome from the reference: breadcrumb (`Page style /
Section`), collapsible groups, per-side spacing grid, and a
variable-insert button inside link fields. All presentation over the
same data.
**Deferred.** Drag-to-reorder blocks.
`@tiptap/extension-drag-handle-react@3.4.2` matches our pinned versions
exactly, so no upgrade is needed, but its behaviour around atom node
views (HTML block, image) is unverified and belongs in its own change.
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23657?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
---------
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
export const EMAIL_IMAGE_MIME_TYPES = [
|
||||
'image/gif',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
] as const;
|
||||
@@ -30,6 +30,7 @@ export type { DocumentationPath } from './DocumentationPaths';
|
||||
export { DOCUMENTATION_PATHS } from './DocumentationPaths';
|
||||
export type { DocumentationSupportedLanguage } from './DocumentationSupportedLanguages';
|
||||
export { DOCUMENTATION_SUPPORTED_LANGUAGES } from './DocumentationSupportedLanguages';
|
||||
export { EMAIL_IMAGE_MIME_TYPES } from './EmailImageMimeTypes';
|
||||
export type { EnterpriseInstanceType } from './EnterpriseInstanceType';
|
||||
export { ENTERPRISE_INSTANCE_TYPE } from './EnterpriseInstanceType';
|
||||
export { EXCLUDED_FIELD_NAMES_FROM_AGENT_TOOL_SCHEMA } from './ExcludedFieldNamesFromAgentToolSchema';
|
||||
|
||||
@@ -9,6 +9,7 @@ export enum FileFolder {
|
||||
Dependencies = 'dependencies',
|
||||
Workflow = 'workflow',
|
||||
EmailAttachment = 'email-attachment',
|
||||
EmailImage = 'email-image',
|
||||
AppTarball = 'app-tarball',
|
||||
GeneratedSdkClient = 'generated-sdk-client',
|
||||
Dpa = 'dpa',
|
||||
|
||||
@@ -28,4 +28,5 @@ export enum SidePanelPages {
|
||||
PageLayoutRecordPageWidgetTypeSelect = 'page-layout-record-page-widget-type-select',
|
||||
ComposeEmail = 'compose-email',
|
||||
SendCampaignTest = 'send-campaign-test',
|
||||
EmailBlockSettings = 'email-block-settings',
|
||||
}
|
||||
|
||||
@@ -197,17 +197,28 @@ export { pascalToKebab } from './strings/pascalToKebab';
|
||||
export { stringifySafely } from './strings/stringifySafely';
|
||||
export { uncapitalize } from './strings/uncapitalize';
|
||||
export { getSubdomainSlugFromDisplayName } from './subdomain/getSubdomainSlugFromDisplayName';
|
||||
export type { CanvasTheme } from './tiptap/canvas-theme';
|
||||
export { CANVAS_THEME_DEFAULTS } from './tiptap/canvas-theme';
|
||||
export type { EmailDocumentNode } from './tiptap/email-document-node';
|
||||
export { EMAIL_DOCUMENT_SCHEMA_VERSION } from './tiptap/email-document-schema-version';
|
||||
export type { EmailDocument } from './tiptap/email-document-schema';
|
||||
export { emailDocumentSchema } from './tiptap/email-document-schema';
|
||||
export type { EmailDocumentStringContext } from './tiptap/email-document-string-context';
|
||||
export { isCanvasTheme } from './tiptap/is-canvas-theme';
|
||||
export type { CampaignVariableDefinition } from './tiptap/list-campaign-variables-for-fields';
|
||||
export { listCampaignVariablesForFields } from './tiptap/list-campaign-variables-for-fields';
|
||||
export { parseEmailDocument } from './tiptap/parse-email-document';
|
||||
export { resolveCanvasTheme } from './tiptap/resolve-canvas-theme';
|
||||
export type {
|
||||
TipTapMarkType,
|
||||
TipTapNodeType,
|
||||
LinkMarkAttributes,
|
||||
TipTapMark,
|
||||
} from './tiptap/tiptap-marks';
|
||||
export {
|
||||
TIPTAP_MARK_TYPES,
|
||||
TIPTAP_NODE_TYPES,
|
||||
TIPTAP_MARKS_RENDER_ORDER,
|
||||
} from './tiptap/tiptap-marks';
|
||||
} from './tiptap/tiptap-mark-types';
|
||||
export { TIPTAP_MARK_TYPES } from './tiptap/tiptap-mark-types';
|
||||
export { TIPTAP_MARKS_RENDER_ORDER } from './tiptap/tiptap-marks-render-order';
|
||||
export type { TipTapNodeType } from './tiptap/tiptap-node-types';
|
||||
export { TIPTAP_NODE_TYPES } from './tiptap/tiptap-node-types';
|
||||
export { transformEmailDocumentStrings } from './tiptap/transform-email-document-strings';
|
||||
export type { StringPropertyKeys } from './trim-and-remove-duplicated-whitespaces-from-object-string-properties';
|
||||
export { trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties } from './trim-and-remove-duplicated-whitespaces-from-object-string-properties';
|
||||
export { trimAndRemoveDuplicatedWhitespacesFromString } from './trim-and-remove-duplicated-whitespaces-from-string';
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { FieldMetadataType } from '../../../types/FieldMetadataType';
|
||||
import { listCampaignVariablesForFields } from '../list-campaign-variables-for-fields';
|
||||
|
||||
describe('listCampaignVariablesForFields', () => {
|
||||
it('should expose scalar fields under their own name', () => {
|
||||
expect(
|
||||
listCampaignVariablesForFields([
|
||||
{ name: 'city', label: 'City', type: FieldMetadataType.TEXT },
|
||||
{ name: 'score', label: 'Score', type: FieldMetadataType.NUMBER },
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
name: 'city',
|
||||
label: 'City',
|
||||
fieldName: 'city',
|
||||
fieldType: FieldMetadataType.TEXT,
|
||||
},
|
||||
{
|
||||
name: 'score',
|
||||
label: 'Score',
|
||||
fieldName: 'score',
|
||||
fieldType: FieldMetadataType.NUMBER,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should expand the name composite and skip contact-detail composites', () => {
|
||||
const definitions = listCampaignVariablesForFields([
|
||||
{ name: 'name', label: 'Name', type: FieldMetadataType.FULL_NAME },
|
||||
{ name: 'emails', label: 'Emails', type: FieldMetadataType.EMAILS },
|
||||
{
|
||||
name: 'linkedinLink',
|
||||
label: 'LinkedIn',
|
||||
type: FieldMetadataType.LINKS,
|
||||
},
|
||||
{ name: 'phones', label: 'Phones', type: FieldMetadataType.PHONES },
|
||||
]);
|
||||
|
||||
expect(definitions.map((definition) => definition.name)).toEqual([
|
||||
'name.firstName',
|
||||
'name.lastName',
|
||||
]);
|
||||
|
||||
expect(definitions[0].label).toBe('Name · First name');
|
||||
});
|
||||
|
||||
it('should skip system, inactive and unsupported fields', () => {
|
||||
expect(
|
||||
listCampaignVariablesForFields([
|
||||
{
|
||||
name: 'searchVector',
|
||||
label: 'Search vector',
|
||||
type: FieldMetadataType.TEXT,
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
name: 'oldField',
|
||||
label: 'Old field',
|
||||
type: FieldMetadataType.TEXT,
|
||||
isActive: false,
|
||||
},
|
||||
{
|
||||
name: 'company',
|
||||
label: 'Company',
|
||||
type: FieldMetadataType.RELATION,
|
||||
},
|
||||
{
|
||||
name: 'createdBy',
|
||||
label: 'Created by',
|
||||
type: FieldMetadataType.ACTOR,
|
||||
},
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import { EMAIL_DOCUMENT_SCHEMA_VERSION } from '../email-document-schema-version';
|
||||
import { parseEmailDocument } from '../parse-email-document';
|
||||
|
||||
const paragraph = (text: string) => ({
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text }],
|
||||
});
|
||||
|
||||
describe('parseEmailDocument', () => {
|
||||
it('should accept a full composer document', () => {
|
||||
const document = {
|
||||
type: 'doc',
|
||||
attrs: {
|
||||
schemaVersion: EMAIL_DOCUMENT_SCHEMA_VERSION,
|
||||
canvasTheme: {
|
||||
pageBackground: '#f4f4f5',
|
||||
bodyBackground: '#ffffff',
|
||||
width: '600px',
|
||||
textAlign: 'left',
|
||||
},
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { level: 1 },
|
||||
content: [{ type: 'text', text: 'Hello' }],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'text', text: 'Hi ' },
|
||||
{ type: 'variableTag', attrs: { variable: '{{firstName}}' } },
|
||||
{ type: 'hardBreak' },
|
||||
{
|
||||
type: 'text',
|
||||
text: 'read this',
|
||||
marks: [
|
||||
{ type: 'bold' },
|
||||
{ type: 'link', attrs: { href: 'https://example.com' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
attrs: { style: { padding: '12px', backgroundColor: '#eeeeee' } },
|
||||
content: [
|
||||
paragraph('Inside the section'),
|
||||
{
|
||||
type: 'section',
|
||||
attrs: { style: { padding: '4px' } },
|
||||
content: [paragraph('Nested')],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'columns',
|
||||
attrs: { style: {} },
|
||||
content: [
|
||||
{
|
||||
type: 'column',
|
||||
attrs: { style: {} },
|
||||
content: [paragraph('Left')],
|
||||
},
|
||||
{
|
||||
type: 'column',
|
||||
attrs: { style: {} },
|
||||
content: [paragraph('Right')],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
attrs: {
|
||||
href: 'https://example.com/{{personId}}',
|
||||
style: { color: '#fff' },
|
||||
},
|
||||
content: [{ type: 'text', text: 'Click me' }],
|
||||
},
|
||||
{
|
||||
type: 'bulletList',
|
||||
content: [{ type: 'listItem', content: [paragraph('Item')] }],
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
attrs: {
|
||||
fileId: '3c5bc42f-e6a8-4a56-a0ca-8b36f3e31db6',
|
||||
src: 'https://example.com/a.png',
|
||||
alt: null,
|
||||
width: 300,
|
||||
href: '',
|
||||
},
|
||||
},
|
||||
{ type: 'divider', attrs: { style: { borderTopWidth: '1px' } } },
|
||||
{ type: 'html', attrs: { html: '<p>raw</p>' } },
|
||||
],
|
||||
};
|
||||
|
||||
expect(parseEmailDocument(document)).toEqual({
|
||||
success: true,
|
||||
document: expect.objectContaining({ type: 'doc' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept a document without schemaVersion or theme', () => {
|
||||
const result = parseEmailDocument({
|
||||
type: 'doc',
|
||||
content: [paragraph('Legacy body')],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept an empty document', () => {
|
||||
expect(parseEmailDocument({ type: 'doc' }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('should keep unknown attribute keys', () => {
|
||||
const result = parseEmailDocument({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'image',
|
||||
attrs: { src: 'https://a.png', futureAttribute: 'kept' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
if (result.success) {
|
||||
expect(result.document.content?.[0].attrs?.futureAttribute).toBe('kept');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject an unknown node type', () => {
|
||||
const result = parseEmailDocument({
|
||||
type: 'doc',
|
||||
content: [{ type: 'countdownTimer', attrs: {} }],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: expect.stringContaining('content.0'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject a document from a future schema version', () => {
|
||||
const result = parseEmailDocument({
|
||||
type: 'doc',
|
||||
attrs: { schemaVersion: EMAIL_DOCUMENT_SCHEMA_VERSION + 1 },
|
||||
content: [paragraph('Hello')],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject a heading level outside 1-3', () => {
|
||||
const result = parseEmailDocument({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { level: 4 },
|
||||
content: [{ type: 'text', text: 'Hi' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject columns with fewer than two columns', () => {
|
||||
const result = parseEmailDocument({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'columns',
|
||||
attrs: { style: {} },
|
||||
content: [
|
||||
{
|
||||
type: 'column',
|
||||
attrs: { style: {} },
|
||||
content: [paragraph('Only')],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject an empty section', () => {
|
||||
const result = parseEmailDocument({
|
||||
type: 'doc',
|
||||
content: [{ type: 'section', attrs: { style: {} }, content: [] }],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject an image without src', () => {
|
||||
const result = parseEmailDocument({
|
||||
type: 'doc',
|
||||
content: [{ type: 'image', attrs: { alt: 'no source' } }],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject an invalid uploaded image file id', () => {
|
||||
const result = parseEmailDocument({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'image',
|
||||
attrs: { fileId: 'not-a-uuid', src: 'https://example.com/a.png' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject non-document values', () => {
|
||||
expect(parseEmailDocument(null).success).toBe(false);
|
||||
expect(parseEmailDocument('a string').success).toBe(false);
|
||||
expect(parseEmailDocument({ type: 'paragraph' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import { transformEmailDocumentStrings } from '../transform-email-document-strings';
|
||||
|
||||
describe('transformEmailDocumentStrings', () => {
|
||||
it('should transform every supported string location with its context', () => {
|
||||
const contexts: string[] = [];
|
||||
const document = transformEmailDocumentStrings(
|
||||
{
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'variableTag', attrs: { variable: '{{name}}' } },
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Profile',
|
||||
marks: [{ type: 'link', attrs: { href: '/people/{{id}}' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
attrs: {
|
||||
src: '/images/{{id}}',
|
||||
href: '/people/{{id}}',
|
||||
alt: '{{name}}',
|
||||
title: '{{name}}',
|
||||
},
|
||||
},
|
||||
{ type: 'html', attrs: { html: '<p>{{name}}</p>' } },
|
||||
],
|
||||
},
|
||||
(value, context) => {
|
||||
contexts.push(context);
|
||||
return `[${context}]${value}`;
|
||||
},
|
||||
);
|
||||
|
||||
expect(document.content?.[0].content?.[0].attrs?.variable).toBe(
|
||||
'[text]{{name}}',
|
||||
);
|
||||
expect(
|
||||
(
|
||||
document.content?.[0].content?.[1].marks?.[0] as {
|
||||
attrs: { href: string };
|
||||
}
|
||||
).attrs.href,
|
||||
).toBe('[url]/people/{{id}}');
|
||||
expect(document.content?.[1].attrs).toEqual({
|
||||
src: '[url]/images/{{id}}',
|
||||
href: '[url]/people/{{id}}',
|
||||
alt: '[text]{{name}}',
|
||||
title: '[text]{{name}}',
|
||||
});
|
||||
expect(document.content?.[2].attrs?.html).toBe('[html]<p>{{name}}</p>');
|
||||
expect(contexts).toContain('html');
|
||||
expect(contexts).toContain('text');
|
||||
expect(contexts).toContain('url');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
export type CanvasTheme = {
|
||||
pageBackground: string;
|
||||
pagePadding: string;
|
||||
textAlign: 'left' | 'center' | 'right';
|
||||
bodyBackground: string;
|
||||
textColor: string;
|
||||
width: string;
|
||||
padding: string;
|
||||
cornerRadius: string;
|
||||
borderWidth: string;
|
||||
borderColor: string;
|
||||
};
|
||||
|
||||
export const CANVAS_THEME_DEFAULTS: CanvasTheme = {
|
||||
pageBackground: '#ffffff',
|
||||
pagePadding: '24px',
|
||||
textAlign: 'left',
|
||||
bodyBackground: '',
|
||||
textColor: '#18181b',
|
||||
width: '600px',
|
||||
padding: '24px',
|
||||
cornerRadius: '0px',
|
||||
borderWidth: '0px',
|
||||
borderColor: '',
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export type EmailDocumentNode = {
|
||||
type: string;
|
||||
text?: string;
|
||||
attrs?: Record<string, unknown>;
|
||||
marks?: unknown[];
|
||||
content?: EmailDocumentNode[];
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export const EMAIL_DOCUMENT_SCHEMA_VERSION = 1;
|
||||
@@ -0,0 +1,184 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type EmailDocumentNode } from './email-document-node';
|
||||
import { EMAIL_DOCUMENT_SCHEMA_VERSION } from './email-document-schema-version';
|
||||
import { TIPTAP_MARK_TYPES } from './tiptap-mark-types';
|
||||
import { TIPTAP_NODE_TYPES } from './tiptap-node-types';
|
||||
|
||||
const styleAttributeSchema = z
|
||||
.record(
|
||||
z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z]+$/)
|
||||
.max(40),
|
||||
z.string().max(400),
|
||||
)
|
||||
.optional();
|
||||
|
||||
const markSchema = z.discriminatedUnion('type', [
|
||||
z.looseObject({ type: z.literal(TIPTAP_MARK_TYPES.BOLD) }),
|
||||
z.looseObject({ type: z.literal(TIPTAP_MARK_TYPES.ITALIC) }),
|
||||
z.looseObject({ type: z.literal(TIPTAP_MARK_TYPES.UNDERLINE) }),
|
||||
z.looseObject({ type: z.literal(TIPTAP_MARK_TYPES.STRIKE) }),
|
||||
z.looseObject({
|
||||
type: z.literal(TIPTAP_MARK_TYPES.LINK),
|
||||
attrs: z.looseObject({ href: z.string().max(4_000) }).optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
const textNodeSchema = z.looseObject({
|
||||
type: z.literal(TIPTAP_NODE_TYPES.TEXT),
|
||||
text: z.string().min(1),
|
||||
marks: z.array(markSchema).optional(),
|
||||
});
|
||||
|
||||
const variableTagNodeSchema = z.looseObject({
|
||||
type: z.literal(TIPTAP_NODE_TYPES.VARIABLE_TAG),
|
||||
attrs: z.looseObject({ variable: z.string().nullable() }),
|
||||
});
|
||||
|
||||
const hardBreakNodeSchema = z.looseObject({
|
||||
type: z.literal(TIPTAP_NODE_TYPES.HARD_BREAK),
|
||||
});
|
||||
|
||||
const inlineNodeSchema = z.discriminatedUnion('type', [
|
||||
textNodeSchema,
|
||||
variableTagNodeSchema,
|
||||
hardBreakNodeSchema,
|
||||
]);
|
||||
|
||||
const blockContentSchema = z.array(
|
||||
z.lazy((): z.ZodType<EmailDocumentNode> => blockNodeSchema),
|
||||
);
|
||||
|
||||
const paragraphNodeSchema = z.looseObject({
|
||||
type: z.literal(TIPTAP_NODE_TYPES.PARAGRAPH),
|
||||
attrs: z.looseObject({}).optional(),
|
||||
content: z.array(inlineNodeSchema).optional(),
|
||||
});
|
||||
|
||||
const headingNodeSchema = z.looseObject({
|
||||
type: z.literal(TIPTAP_NODE_TYPES.HEADING),
|
||||
attrs: z.looseObject({
|
||||
level: z.union([z.literal(1), z.literal(2), z.literal(3)]),
|
||||
}),
|
||||
content: z.array(inlineNodeSchema).optional(),
|
||||
});
|
||||
|
||||
const listItemNodeSchema = z.looseObject({
|
||||
type: z.literal(TIPTAP_NODE_TYPES.LIST_ITEM),
|
||||
attrs: z.looseObject({}).optional(),
|
||||
content: blockContentSchema.min(1),
|
||||
});
|
||||
|
||||
const bulletListNodeSchema = z.looseObject({
|
||||
type: z.literal(TIPTAP_NODE_TYPES.BULLET_LIST),
|
||||
attrs: z.looseObject({}).optional(),
|
||||
content: z.array(listItemNodeSchema).min(1),
|
||||
});
|
||||
|
||||
const orderedListNodeSchema = z.looseObject({
|
||||
type: z.literal(TIPTAP_NODE_TYPES.ORDERED_LIST),
|
||||
attrs: z.looseObject({}).optional(),
|
||||
content: z.array(listItemNodeSchema).min(1),
|
||||
});
|
||||
|
||||
const imageNodeSchema = z.looseObject({
|
||||
type: z.literal(TIPTAP_NODE_TYPES.IMAGE),
|
||||
attrs: z.looseObject({
|
||||
fileId: z.uuid().nullable().optional(),
|
||||
src: z.string().max(4_000),
|
||||
alt: z.string().nullable().optional(),
|
||||
title: z.string().nullable().optional(),
|
||||
align: z.string().nullable().optional(),
|
||||
width: z.union([z.string(), z.number()]).nullable().optional(),
|
||||
href: z.string().max(4_000).nullable().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const sectionNodeSchema = z.looseObject({
|
||||
type: z.literal(TIPTAP_NODE_TYPES.SECTION),
|
||||
attrs: z.looseObject({ style: styleAttributeSchema }),
|
||||
content: blockContentSchema.min(1),
|
||||
});
|
||||
|
||||
const columnNodeSchema = z.looseObject({
|
||||
type: z.literal(TIPTAP_NODE_TYPES.COLUMN),
|
||||
attrs: z.looseObject({ style: styleAttributeSchema }),
|
||||
content: blockContentSchema.min(1),
|
||||
});
|
||||
|
||||
const columnsNodeSchema = z.looseObject({
|
||||
type: z.literal(TIPTAP_NODE_TYPES.COLUMNS),
|
||||
attrs: z.looseObject({ style: styleAttributeSchema }),
|
||||
content: z.array(columnNodeSchema).min(2).max(4),
|
||||
});
|
||||
|
||||
const buttonNodeSchema = z.looseObject({
|
||||
type: z.literal(TIPTAP_NODE_TYPES.BUTTON),
|
||||
attrs: z.looseObject({
|
||||
href: z.string().max(4_000).nullable(),
|
||||
style: styleAttributeSchema,
|
||||
}),
|
||||
content: z
|
||||
.array(
|
||||
z.looseObject({
|
||||
type: z.literal(TIPTAP_NODE_TYPES.TEXT),
|
||||
text: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const dividerNodeSchema = z.looseObject({
|
||||
type: z.literal(TIPTAP_NODE_TYPES.DIVIDER),
|
||||
attrs: z.looseObject({ style: styleAttributeSchema }),
|
||||
});
|
||||
|
||||
const htmlNodeSchema = z.looseObject({
|
||||
type: z.literal(TIPTAP_NODE_TYPES.HTML),
|
||||
attrs: z.looseObject({ html: z.string().max(100_000) }),
|
||||
});
|
||||
|
||||
const blockNodeSchema = z.discriminatedUnion('type', [
|
||||
paragraphNodeSchema,
|
||||
headingNodeSchema,
|
||||
bulletListNodeSchema,
|
||||
orderedListNodeSchema,
|
||||
imageNodeSchema,
|
||||
sectionNodeSchema,
|
||||
columnsNodeSchema,
|
||||
buttonNodeSchema,
|
||||
dividerNodeSchema,
|
||||
htmlNodeSchema,
|
||||
]);
|
||||
|
||||
const canvasThemeAttributeSchema = z.looseObject({
|
||||
pageBackground: z.string().optional(),
|
||||
pagePadding: z.string().optional(),
|
||||
textAlign: z.enum(['left', 'center', 'right']).optional(),
|
||||
bodyBackground: z.string().optional(),
|
||||
textColor: z.string().optional(),
|
||||
width: z.string().optional(),
|
||||
padding: z.string().optional(),
|
||||
cornerRadius: z.string().optional(),
|
||||
borderWidth: z.string().optional(),
|
||||
borderColor: z.string().optional(),
|
||||
});
|
||||
|
||||
export const emailDocumentSchema = z.looseObject({
|
||||
type: z.literal('doc'),
|
||||
attrs: z
|
||||
.looseObject({
|
||||
schemaVersion: z
|
||||
.int()
|
||||
.min(1)
|
||||
.max(EMAIL_DOCUMENT_SCHEMA_VERSION)
|
||||
.optional(),
|
||||
canvasTheme: canvasThemeAttributeSchema.nullable().optional(),
|
||||
})
|
||||
.optional(),
|
||||
content: blockContentSchema.optional(),
|
||||
});
|
||||
|
||||
export type EmailDocument = z.infer<typeof emailDocumentSchema>;
|
||||
@@ -0,0 +1 @@
|
||||
export type EmailDocumentStringContext = 'html' | 'text' | 'url';
|
||||
@@ -1 +1,13 @@
|
||||
export * from './tiptap-marks';
|
||||
export * from './canvas-theme';
|
||||
export * from './email-document-node';
|
||||
export * from './email-document-schema';
|
||||
export * from './email-document-schema-version';
|
||||
export * from './email-document-string-context';
|
||||
export * from './is-canvas-theme';
|
||||
export * from './list-campaign-variables-for-fields';
|
||||
export * from './parse-email-document';
|
||||
export * from './resolve-canvas-theme';
|
||||
export * from './tiptap-mark-types';
|
||||
export * from './tiptap-marks-render-order';
|
||||
export * from './tiptap-node-types';
|
||||
export * from './transform-email-document-strings';
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { type CanvasTheme } from './canvas-theme';
|
||||
|
||||
export const isCanvasTheme = (value: unknown): value is Partial<CanvasTheme> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
@@ -0,0 +1,79 @@
|
||||
import { FieldMetadataType } from '@/types';
|
||||
|
||||
export type CampaignVariableDefinition = {
|
||||
name: string;
|
||||
label: string;
|
||||
fieldName: string;
|
||||
fieldType: FieldMetadataType;
|
||||
subFieldName?: string;
|
||||
};
|
||||
|
||||
type CampaignVariableEligibleField = {
|
||||
name: string;
|
||||
label: string;
|
||||
type: FieldMetadataType;
|
||||
isSystem?: boolean | null;
|
||||
isActive?: boolean | null;
|
||||
};
|
||||
|
||||
const SCALAR_CAMPAIGN_VARIABLE_FIELD_TYPES: FieldMetadataType[] = [
|
||||
FieldMetadataType.TEXT,
|
||||
FieldMetadataType.NUMBER,
|
||||
FieldMetadataType.BOOLEAN,
|
||||
FieldMetadataType.DATE,
|
||||
FieldMetadataType.DATE_TIME,
|
||||
FieldMetadataType.SELECT,
|
||||
FieldMetadataType.RATING,
|
||||
];
|
||||
|
||||
const COMPOSITE_CAMPAIGN_VARIABLE_SUBFIELDS: Partial<
|
||||
Record<FieldMetadataType, { subFieldName: string; subFieldLabel: string }[]>
|
||||
> = {
|
||||
[FieldMetadataType.FULL_NAME]: [
|
||||
{ subFieldName: 'firstName', subFieldLabel: 'First name' },
|
||||
{ subFieldName: 'lastName', subFieldLabel: 'Last name' },
|
||||
],
|
||||
};
|
||||
|
||||
export const listCampaignVariablesForFields = (
|
||||
fields: CampaignVariableEligibleField[],
|
||||
): CampaignVariableDefinition[] => {
|
||||
const definitions: CampaignVariableDefinition[] = [];
|
||||
|
||||
for (const field of fields) {
|
||||
if (field.isSystem === true || field.isActive === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (SCALAR_CAMPAIGN_VARIABLE_FIELD_TYPES.includes(field.type)) {
|
||||
definitions.push({
|
||||
name: field.name,
|
||||
label: field.label,
|
||||
fieldName: field.name,
|
||||
fieldType: field.type,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const subFields = COMPOSITE_CAMPAIGN_VARIABLE_SUBFIELDS[field.type];
|
||||
|
||||
if (!subFields) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const { subFieldName, subFieldLabel } of subFields) {
|
||||
definitions.push({
|
||||
name: `${field.name}.${subFieldName}`,
|
||||
label:
|
||||
subFields.length === 1
|
||||
? field.label
|
||||
: `${field.label} · ${subFieldLabel}`,
|
||||
fieldName: field.name,
|
||||
fieldType: field.type,
|
||||
subFieldName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return definitions;
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
type EmailDocument,
|
||||
emailDocumentSchema,
|
||||
} from './email-document-schema';
|
||||
|
||||
export const parseEmailDocument = (
|
||||
value: unknown,
|
||||
):
|
||||
| { success: true; document: EmailDocument }
|
||||
| { success: false; error: string } => {
|
||||
const result = emailDocumentSchema.safeParse(value);
|
||||
|
||||
if (result.success) {
|
||||
return { success: true, document: result.data };
|
||||
}
|
||||
|
||||
const issues = result.error.issues
|
||||
.slice(0, 10)
|
||||
.map((issue) => `${issue.path.join('.')}: ${issue.message}`)
|
||||
.join('; ');
|
||||
|
||||
return { success: false, error: issues };
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { CANVAS_THEME_DEFAULTS, type CanvasTheme } from './canvas-theme';
|
||||
import { isCanvasTheme } from './is-canvas-theme';
|
||||
|
||||
export const resolveCanvasTheme = (value: unknown): CanvasTheme | null =>
|
||||
isCanvasTheme(value) ? { ...CANVAS_THEME_DEFAULTS, ...value } : null;
|
||||
@@ -0,0 +1,21 @@
|
||||
export const TIPTAP_MARK_TYPES = {
|
||||
BOLD: 'bold',
|
||||
ITALIC: 'italic',
|
||||
UNDERLINE: 'underline',
|
||||
STRIKE: 'strike',
|
||||
LINK: 'link',
|
||||
} as const;
|
||||
|
||||
export type TipTapMarkType =
|
||||
(typeof TIPTAP_MARK_TYPES)[keyof typeof TIPTAP_MARK_TYPES];
|
||||
|
||||
export interface LinkMarkAttributes {
|
||||
href?: string;
|
||||
target?: string;
|
||||
rel?: string;
|
||||
}
|
||||
|
||||
export interface TipTapMark {
|
||||
type: TipTapMarkType;
|
||||
attrs?: LinkMarkAttributes | Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { TIPTAP_MARK_TYPES, type TipTapMarkType } from './tiptap-mark-types';
|
||||
|
||||
export const TIPTAP_MARKS_RENDER_ORDER: readonly TipTapMarkType[] = [
|
||||
TIPTAP_MARK_TYPES.UNDERLINE,
|
||||
TIPTAP_MARK_TYPES.BOLD,
|
||||
TIPTAP_MARK_TYPES.ITALIC,
|
||||
TIPTAP_MARK_TYPES.STRIKE,
|
||||
TIPTAP_MARK_TYPES.LINK,
|
||||
] as const;
|
||||
@@ -1,48 +0,0 @@
|
||||
// Shared TipTap types for consistency between frontend and email renderer
|
||||
|
||||
export const TIPTAP_MARK_TYPES = {
|
||||
BOLD: 'bold',
|
||||
ITALIC: 'italic',
|
||||
UNDERLINE: 'underline',
|
||||
STRIKE: 'strike',
|
||||
LINK: 'link',
|
||||
} as const;
|
||||
|
||||
export const TIPTAP_NODE_TYPES = {
|
||||
PARAGRAPH: 'paragraph',
|
||||
TEXT: 'text',
|
||||
HEADING: 'heading',
|
||||
VARIABLE_TAG: 'variableTag',
|
||||
IMAGE: 'image',
|
||||
BULLET_LIST: 'bulletList',
|
||||
ORDERED_LIST: 'orderedList',
|
||||
LIST_ITEM: 'listItem',
|
||||
HARD_BREAK: 'hardBreak',
|
||||
} as const;
|
||||
|
||||
export type TipTapMarkType =
|
||||
(typeof TIPTAP_MARK_TYPES)[keyof typeof TIPTAP_MARK_TYPES];
|
||||
export type TipTapNodeType =
|
||||
(typeof TIPTAP_NODE_TYPES)[keyof typeof TIPTAP_NODE_TYPES];
|
||||
|
||||
// Order for mark rendering (inner to outer)
|
||||
export const TIPTAP_MARKS_RENDER_ORDER: readonly TipTapMarkType[] = [
|
||||
TIPTAP_MARK_TYPES.UNDERLINE,
|
||||
TIPTAP_MARK_TYPES.BOLD,
|
||||
TIPTAP_MARK_TYPES.ITALIC,
|
||||
TIPTAP_MARK_TYPES.STRIKE,
|
||||
TIPTAP_MARK_TYPES.LINK,
|
||||
] as const;
|
||||
|
||||
// Link mark attributes interface
|
||||
export interface LinkMarkAttributes {
|
||||
href?: string;
|
||||
target?: string;
|
||||
rel?: string;
|
||||
}
|
||||
|
||||
// Generic mark interface
|
||||
export interface TipTapMark {
|
||||
type: TipTapMarkType;
|
||||
attrs?: LinkMarkAttributes | Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export const TIPTAP_NODE_TYPES = {
|
||||
PARAGRAPH: 'paragraph',
|
||||
TEXT: 'text',
|
||||
HEADING: 'heading',
|
||||
VARIABLE_TAG: 'variableTag',
|
||||
IMAGE: 'image',
|
||||
BULLET_LIST: 'bulletList',
|
||||
ORDERED_LIST: 'orderedList',
|
||||
LIST_ITEM: 'listItem',
|
||||
HARD_BREAK: 'hardBreak',
|
||||
SECTION: 'section',
|
||||
COLUMNS: 'columns',
|
||||
COLUMN: 'column',
|
||||
BUTTON: 'button',
|
||||
DIVIDER: 'divider',
|
||||
HTML: 'html',
|
||||
} as const;
|
||||
|
||||
export type TipTapNodeType =
|
||||
(typeof TIPTAP_NODE_TYPES)[keyof typeof TIPTAP_NODE_TYPES];
|
||||
@@ -0,0 +1,104 @@
|
||||
import { type EmailDocumentNode } from './email-document-node';
|
||||
import { type EmailDocumentStringContext } from './email-document-string-context';
|
||||
import { TIPTAP_MARK_TYPES } from './tiptap-mark-types';
|
||||
import { TIPTAP_NODE_TYPES } from './tiptap-node-types';
|
||||
|
||||
type StringTransformer = (
|
||||
value: string,
|
||||
context: EmailDocumentStringContext,
|
||||
) => string;
|
||||
|
||||
const transformAttribute = (
|
||||
attributes: Record<string, unknown> | undefined,
|
||||
attributeName: string,
|
||||
context: EmailDocumentStringContext,
|
||||
transform: StringTransformer,
|
||||
): Record<string, unknown> | undefined => {
|
||||
const value = attributes?.[attributeName];
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return attributes;
|
||||
}
|
||||
|
||||
return {
|
||||
...attributes,
|
||||
[attributeName]: transform(value, context),
|
||||
};
|
||||
};
|
||||
|
||||
export const transformEmailDocumentStrings = <TNode extends EmailDocumentNode>(
|
||||
node: TNode,
|
||||
transform: StringTransformer,
|
||||
): TNode => {
|
||||
let attributes = node.attrs;
|
||||
|
||||
if (node.type === TIPTAP_NODE_TYPES.VARIABLE_TAG) {
|
||||
attributes = transformAttribute(attributes, 'variable', 'text', transform);
|
||||
}
|
||||
|
||||
if (node.type === TIPTAP_NODE_TYPES.BUTTON) {
|
||||
attributes = transformAttribute(attributes, 'href', 'url', transform);
|
||||
}
|
||||
|
||||
if (node.type === TIPTAP_NODE_TYPES.IMAGE) {
|
||||
for (const attributeName of ['src', 'href']) {
|
||||
attributes = transformAttribute(
|
||||
attributes,
|
||||
attributeName,
|
||||
'url',
|
||||
transform,
|
||||
);
|
||||
}
|
||||
|
||||
for (const attributeName of ['alt', 'title']) {
|
||||
attributes = transformAttribute(
|
||||
attributes,
|
||||
attributeName,
|
||||
'text',
|
||||
transform,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type === TIPTAP_NODE_TYPES.HTML) {
|
||||
attributes = transformAttribute(attributes, 'html', 'html', transform);
|
||||
}
|
||||
|
||||
const marks = node.marks?.map((mark) => {
|
||||
if (
|
||||
typeof mark !== 'object' ||
|
||||
mark === null ||
|
||||
!('type' in mark) ||
|
||||
mark.type !== TIPTAP_MARK_TYPES.LINK ||
|
||||
!('attrs' in mark) ||
|
||||
typeof mark.attrs !== 'object' ||
|
||||
mark.attrs === null
|
||||
) {
|
||||
return mark;
|
||||
}
|
||||
|
||||
return {
|
||||
...mark,
|
||||
attrs: transformAttribute(
|
||||
mark.attrs as Record<string, unknown>,
|
||||
'href',
|
||||
'url',
|
||||
transform,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...node,
|
||||
...(typeof node.text === 'string' && {
|
||||
text: transform(node.text, 'text'),
|
||||
}),
|
||||
...(attributes && { attrs: attributes }),
|
||||
...(marks && { marks }),
|
||||
...(node.content && {
|
||||
content: node.content.map((childNode) =>
|
||||
transformEmailDocumentStrings(childNode, transform),
|
||||
),
|
||||
}),
|
||||
} as TNode;
|
||||
};
|
||||
Reference in New Issue
Block a user