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:
Marie
2026-08-05 12:38:06 +02:00
committed by GitHub
parent 5effee7754
commit 1d755983ff
243 changed files with 7620 additions and 569 deletions
@@ -1,23 +1,61 @@
import { Body, Head, Html } from 'react-email';
import { type ReactNode } from 'react';
import { Body, Container, Head, Html } from 'react-email';
import { type JSONContent } from '@tiptap/core';
import { type CanvasTheme, resolveCanvasTheme } from 'twenty-shared/utils';
import { mappedNodeContent } from 'src/utils/email-renderer/renderers/render-node';
const BASE_STYLE_RESET = `blockquote,h1,h2,h3,img,li,ol,p,ul{margin-top:0;margin-bottom:0}`;
const themedBody = (theme: CanvasTheme, children: ReactNode) => (
<Body
style={{
backgroundColor: theme.pageBackground,
color: theme.textColor,
margin: 0,
padding: theme.pagePadding,
}}
>
<Container
style={{
backgroundColor: theme.bodyBackground || undefined,
border:
theme.borderWidth !== '' && theme.borderWidth !== '0px'
? `${theme.borderWidth} solid ${theme.borderColor}`
: undefined,
borderRadius: theme.cornerRadius,
color: theme.textColor,
maxWidth: theme.width,
padding: theme.padding,
textAlign: theme.textAlign,
}}
>
{children}
</Container>
</Body>
);
export const reactMarkupFromJSON = (json: JSONContent | string) => {
if (typeof json === 'string') {
return json;
}
const jsxNodes = mappedNodeContent(json);
const canvasTheme = resolveCanvasTheme(json.attrs?.canvasTheme);
return (
<Html>
<Head>
<style
dangerouslySetInnerHTML={{
__html: `blockquote,h1,h2,h3,img,li,ol,p,ul{margin-top:0;margin-bottom:0}`,
__html: BASE_STYLE_RESET,
}}
/>
</Head>
<Body>{jsxNodes}</Body>
{canvasTheme !== null ? (
themedBody(canvasTheme, jsxNodes)
) : (
<Body>{jsxNodes}</Body>
)}
</Html>
);
};
@@ -1,6 +1,8 @@
import { type ReactNode } from 'react';
import { type LinkMarkAttributes, type TipTapMark } from 'twenty-shared/utils';
import { safeUrl } from 'src/utils/email-renderer/utils/safe-url';
export const link = (mark: TipTapMark, children: ReactNode): ReactNode => {
const {
href,
@@ -10,7 +12,7 @@ export const link = (mark: TipTapMark, children: ReactNode): ReactNode => {
return (
<a
href={href}
href={safeUrl(href)}
target={target}
rel={rel}
style={{ textDecoration: 'underline' }}
@@ -1,16 +1,21 @@
import { type JSONContent } from '@tiptap/core';
import { type ReactNode } from 'react';
import { mappedNodeContent } from 'src/utils/email-renderer/renderers/render-node';
import { type InheritedTypography } from 'src/utils/email-renderer/utils/inherited-typography';
export const bulletList = (node: JSONContent): ReactNode => {
export const bulletList = (
node: JSONContent,
inherited: InheritedTypography = {},
): ReactNode => {
return (
<ul
style={{
marginBottom: '16px',
lineHeight: '1.5',
...inherited,
}}
>
{mappedNodeContent(node)}
{mappedNodeContent(node, inherited)}
</ul>
);
};
@@ -0,0 +1,23 @@
import { Button, Column, Row } from 'react-email';
import { type JSONContent } from '@tiptap/core';
import { type ReactNode } from 'react';
import { blockStyle } from 'src/utils/email-renderer/utils/block-style';
import { safeUrl } from 'src/utils/email-renderer/utils/safe-url';
export const button = (node: JSONContent): ReactNode => {
const label = (node.content ?? [])
.map((childNode) => childNode.text ?? '')
.join('');
const href = node.attrs?.href;
const align = node.attrs?.align ?? 'left';
return (
<Row>
<Column align={align}>
<Button href={safeUrl(href)} style={blockStyle(node.attrs?.style)}>
{label}
</Button>
</Column>
</Row>
);
};
@@ -0,0 +1,44 @@
import { Column, Row } from 'react-email';
import { type JSONContent } from '@tiptap/core';
import { Fragment, type ReactNode } from 'react';
import { mappedNodeContent } from 'src/utils/email-renderer/renderers/render-node';
import { blockStyle } from 'src/utils/email-renderer/utils/block-style';
import {
type InheritedTypography,
mergeInheritedTypography,
} from 'src/utils/email-renderer/utils/inherited-typography';
export const columns = (
node: JSONContent,
inherited: InheritedTypography = {},
): ReactNode => {
const columnNodes = node.content ?? [];
const columnWidth = `${100 / Math.max(columnNodes.length, 1)}%`;
const rowStyle = blockStyle(node.attrs?.style);
const rowTypography = mergeInheritedTypography(inherited, rowStyle);
return (
<Row style={rowStyle}>
{columnNodes.map((column, index) => {
const columnStyle = blockStyle(column.attrs?.style);
return (
<Fragment key={index}>
<Column
style={{
width: columnWidth,
verticalAlign: 'top',
...columnStyle,
}}
>
{mappedNodeContent(
column,
mergeInheritedTypography(rowTypography, columnStyle),
)}
</Column>
</Fragment>
);
})}
</Row>
);
};
@@ -0,0 +1,8 @@
import { Hr } from 'react-email';
import { type JSONContent } from '@tiptap/core';
import { type ReactNode } from 'react';
import { blockStyle } from 'src/utils/email-renderer/utils/block-style';
export const divider = (node: JSONContent): ReactNode => {
return <Hr style={blockStyle(node.attrs?.style)} />;
};
@@ -2,6 +2,7 @@ import { Heading } from 'react-email';
import { type JSONContent } from '@tiptap/core';
import { type ReactNode } from 'react';
import { mappedNodeContent } from 'src/utils/email-renderer/renderers/render-node';
import { type InheritedTypography } from 'src/utils/email-renderer/utils/inherited-typography';
import { isDefined } from 'twenty-shared/utils';
type HeadingLevel = 1 | 2 | 3;
@@ -17,18 +18,23 @@ const HEADING_STYLES: Record<HeadingLevel, HeadingStyle> = {
3: { element: 'h3', fontSize: '16px' },
};
export const heading = (node: JSONContent): ReactNode => {
export const heading = (
node: JSONContent,
inherited: InheritedTypography = {},
): ReactNode => {
const { level } = node?.attrs || {};
if (!isDefined(level) || !HEADING_STYLES[level as HeadingLevel]) {
return null;
}
const content = mappedNodeContent(node);
const content = mappedNodeContent(node, inherited);
const { element, fontSize } = HEADING_STYLES[level as HeadingLevel];
const { fontSize: _inheritedFontSize, ...inheritedWithoutSize } = inherited;
return (
<Heading as={element} style={{ fontSize }}>
<Heading as={element} style={{ fontSize, ...inheritedWithoutSize }}>
{content}
</Heading>
);
@@ -0,0 +1,12 @@
import { type JSONContent } from '@tiptap/core';
import { type ReactNode } from 'react';
export const html = (node: JSONContent): ReactNode => {
const html = node.attrs?.html;
if (typeof html !== 'string' || html === '') {
return null;
}
return <div dangerouslySetInnerHTML={{ __html: html }} />;
};
@@ -1,30 +1,43 @@
import { Column, Row } from 'react-email';
import { Column, Link, Row } from 'react-email';
import { type JSONContent } from '@tiptap/core';
import { type ReactNode } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { safeUrl } from 'src/utils/email-renderer/utils/safe-url';
export const image = (node: JSONContent): ReactNode => {
const { src, alt, align = 'left', width } = node?.attrs || {};
const { src: rawSrc, alt, align = 'left', width, href } = node?.attrs || {};
const src = safeUrl(rawSrc);
if (!isDefined(src)) {
return null;
}
const imageElement = (
<img
src={src}
alt={alt}
style={{
width: isDefined(width) ? width : 'auto',
height: 'auto',
maxWidth: '100%',
outline: 'none',
border: 'none',
textDecoration: 'none',
display: 'block',
}}
/>
);
return (
<Row>
<Column align={align}>
<img
src={src}
alt={alt}
style={{
width: isDefined(width) ? width : 'auto',
height: 'auto',
maxWidth: '100%',
outline: 'none',
border: 'none',
textDecoration: 'none',
display: 'block',
}}
/>
{isDefined(safeUrl(href)) ? (
<Link href={safeUrl(href)} target="_blank">
{imageElement}
</Link>
) : (
imageElement
)}
</Column>
</Row>
);
@@ -1,16 +1,21 @@
import { type JSONContent } from '@tiptap/core';
import { type ReactNode } from 'react';
import { mappedNodeContent } from 'src/utils/email-renderer/renderers/render-node';
import { type InheritedTypography } from 'src/utils/email-renderer/utils/inherited-typography';
export const listItem = (node: JSONContent): ReactNode => {
export const listItem = (
node: JSONContent,
inherited: InheritedTypography = {},
): ReactNode => {
return (
<li
style={{
marginBottom: '8px',
lineHeight: '1.5',
...inherited,
}}
>
{mappedNodeContent(node)}
{mappedNodeContent(node, inherited)}
</li>
);
};
@@ -1,16 +1,21 @@
import { type JSONContent } from '@tiptap/core';
import { type ReactNode } from 'react';
import { mappedNodeContent } from 'src/utils/email-renderer/renderers/render-node';
import { type InheritedTypography } from 'src/utils/email-renderer/utils/inherited-typography';
export const orderedList = (node: JSONContent): ReactNode => {
export const orderedList = (
node: JSONContent,
inherited: InheritedTypography = {},
): ReactNode => {
return (
<ol
style={{
marginBottom: '16px',
lineHeight: '1.5',
...inherited,
}}
>
{mappedNodeContent(node)}
{mappedNodeContent(node, inherited)}
</ol>
);
};
@@ -2,12 +2,18 @@ import { Text } from 'react-email';
import { type JSONContent } from '@tiptap/core';
import { type ReactNode } from 'react';
import { mappedNodeContent } from 'src/utils/email-renderer/renderers/render-node';
import { type InheritedTypography } from 'src/utils/email-renderer/utils/inherited-typography';
export const paragraph = (node: JSONContent): ReactNode => {
const content = mappedNodeContent(node);
export const paragraph = (
node: JSONContent,
inherited: InheritedTypography = {},
): ReactNode => {
const content = mappedNodeContent(node, inherited);
return (
<Text style={{ lineHeight: '1.5', margin: '0', padding: '0' }}>
<Text
style={{ lineHeight: '1.5', margin: '0', padding: '0', ...inherited }}
>
{content.length === 0 ? <>&nbsp;</> : content}
</Text>
);
@@ -0,0 +1,22 @@
import { Section } from 'react-email';
import { type JSONContent } from '@tiptap/core';
import { type ReactNode } from 'react';
import { mappedNodeContent } from 'src/utils/email-renderer/renderers/render-node';
import { blockStyle } from 'src/utils/email-renderer/utils/block-style';
import {
type InheritedTypography,
mergeInheritedTypography,
} from 'src/utils/email-renderer/utils/inherited-typography';
export const section = (
node: JSONContent,
inherited: InheritedTypography = {},
): ReactNode => {
const style = blockStyle(node.attrs?.style);
return (
<Section style={style}>
{mappedNodeContent(node, mergeInheritedTypography(inherited, style))}
</Section>
);
};
@@ -1,7 +1,9 @@
import { type JSONContent } from '@tiptap/core';
import { type JSX, Fragment, type ReactNode } from 'react';
import { TIPTAP_NODE_TYPES, type TipTapNodeType } from 'twenty-shared/utils';
import { bulletList } from '@/utils/email-renderer/nodes/bullet-list';
import { button } from '@/utils/email-renderer/nodes/button';
import { columns } from '@/utils/email-renderer/nodes/columns';
import { divider } from '@/utils/email-renderer/nodes/divider';
import { html } from '@/utils/email-renderer/nodes/html';
import { section } from '@/utils/email-renderer/nodes/section';
import { hardBreak } from '@/utils/email-renderer/nodes/hard-break';
import { heading } from '@/utils/email-renderer/nodes/heading';
import { image } from '@/utils/email-renderer/nodes/image';
@@ -10,6 +12,10 @@ import { orderedList } from '@/utils/email-renderer/nodes/ordered-list';
import { paragraph } from '@/utils/email-renderer/nodes/paragraph';
import { text } from '@/utils/email-renderer/nodes/text';
import { variableTag } from '@/utils/email-renderer/nodes/variable-tag';
import { type JSONContent } from '@tiptap/core';
import { Fragment, type JSX, type ReactNode } from 'react';
import { type InheritedTypography } from 'src/utils/email-renderer/utils/inherited-typography';
import { TIPTAP_NODE_TYPES } from 'twenty-shared/utils';
const NODE_RENDERERS = {
[TIPTAP_NODE_TYPES.PARAGRAPH]: paragraph,
@@ -21,23 +27,34 @@ const NODE_RENDERERS = {
[TIPTAP_NODE_TYPES.ORDERED_LIST]: orderedList,
[TIPTAP_NODE_TYPES.LIST_ITEM]: listItem,
[TIPTAP_NODE_TYPES.HARD_BREAK]: hardBreak,
[TIPTAP_NODE_TYPES.SECTION]: section,
[TIPTAP_NODE_TYPES.COLUMNS]: columns,
[TIPTAP_NODE_TYPES.BUTTON]: button,
[TIPTAP_NODE_TYPES.DIVIDER]: divider,
[TIPTAP_NODE_TYPES.HTML]: html,
};
const renderNode = (node: JSONContent): ReactNode => {
const renderer = NODE_RENDERERS[node.type as TipTapNodeType];
const renderNode = (
node: JSONContent,
inherited: InheritedTypography,
): ReactNode => {
const renderer = NODE_RENDERERS[node.type as keyof typeof NODE_RENDERERS];
if (!renderer) {
return null;
}
return renderer(node);
return renderer(node, inherited);
};
export const mappedNodeContent = (node: JSONContent): JSX.Element[] => {
export const mappedNodeContent = (
node: JSONContent,
inherited: InheritedTypography = {},
): JSX.Element[] => {
const allNodes = node.content || [];
return allNodes
.map((childNode, index) => {
const component = renderNode(childNode);
const component = renderNode(childNode, inherited);
if (!component) {
return null;
}
@@ -0,0 +1,14 @@
import { type CSSProperties } from 'react';
export const blockStyle = (value: unknown): CSSProperties => {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return {};
}
return Object.fromEntries(
Object.entries(value).filter(
(entry): entry is [string, string | number] =>
typeof entry[1] === 'string' || typeof entry[1] === 'number',
),
) as CSSProperties;
};
@@ -0,0 +1,35 @@
import { type CSSProperties } from 'react';
const INHERITED_TYPOGRAPHY_PROPERTIES = [
'color',
'fontSize',
'lineHeight',
'letterSpacing',
'textAlign',
'fontFamily',
'fontWeight',
] as const satisfies readonly (keyof CSSProperties)[];
export type InheritedTypography = Pick<
CSSProperties,
(typeof INHERITED_TYPOGRAPHY_PROPERTIES)[number]
>;
export const pickInheritedTypography = (
style: CSSProperties,
): InheritedTypography =>
INHERITED_TYPOGRAPHY_PROPERTIES.reduce<InheritedTypography>(
(typography, property) =>
style[property] === undefined
? typography
: { ...typography, [property]: style[property] },
{},
);
export const mergeInheritedTypography = (
inherited: InheritedTypography,
style: CSSProperties,
): InheritedTypography => ({
...inherited,
...pickInheritedTypography(style),
});
@@ -0,0 +1,18 @@
const ALLOWED_SCHEMES = ['http:', 'https:', 'mailto:', 'tel:'];
export const safeUrl = (value: unknown): string | undefined => {
if (typeof value !== 'string' || value.trim() === '') {
return undefined;
}
const normalized = value.replace(/[\u0000-\u0020]/g, '');
const schemeMatch = normalized.match(/^([a-zA-Z][a-zA-Z0-9+.-]*:)/);
if (schemeMatch === null) {
return value;
}
return ALLOWED_SCHEMES.includes(schemeMatch[1].toLowerCase())
? value
: undefined;
};