@comet/mail-react 9.0.0-canary-20260707121405 → 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/blocks/richText/common.d.ts +66 -0
- package/lib/blocks/richText/common.js +1 -0
- package/lib/blocks/richText/createRichTextBlock.d.ts +30 -0
- package/lib/blocks/richText/createRichTextBlock.js +81 -0
- package/lib/blocks/richText/createRichTextRenderers.d.ts +17 -0
- package/lib/blocks/richText/createRichTextRenderers.js +83 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.js +1 -0
- package/package.json +5 -4
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { TextStyles, VariantName } from "../../theme/themeTypes.js";
|
|
3
|
+
import type { PropsWithData } from "../helpers/PropsWithData.js";
|
|
4
|
+
export interface RichTextBlockData {
|
|
5
|
+
/** Draft.js raw content state (`{ blocks, entityMap }`), as produced by the CMS RichText block. */
|
|
6
|
+
draftContent: unknown;
|
|
7
|
+
}
|
|
8
|
+
export type RichTextBlockProps = PropsWithData<RichTextBlockData>;
|
|
9
|
+
/**
|
|
10
|
+
* Styling for all draft blocks of one type, applied on top of the base theme text styles.
|
|
11
|
+
*
|
|
12
|
+
* Style props accept plain values only. For responsive styling, use a theme
|
|
13
|
+
* variant, or set a `className` and register responsive CSS via `registerStyles`.
|
|
14
|
+
*/
|
|
15
|
+
export type RichTextBlockTypeProps = Omit<TextStyles, "bottomSpacing"> & {
|
|
16
|
+
/**
|
|
17
|
+
* The text component's variant to apply, as defined in the theme.
|
|
18
|
+
*
|
|
19
|
+
* @defaultValue The theme's `text.defaultVariant`, when set
|
|
20
|
+
*/
|
|
21
|
+
variant?: VariantName;
|
|
22
|
+
className?: string;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Resolves the href of one link block type from the link block's props.
|
|
26
|
+
*
|
|
27
|
+
* Return `undefined` to render the linked text without a link.
|
|
28
|
+
*/
|
|
29
|
+
export type RichTextLinkHrefResolver<TProps = unknown> = (props: TProps) => string | undefined;
|
|
30
|
+
/**
|
|
31
|
+
* Renders the text spanned by one draft-js inline style (e.g. `BOLD`).
|
|
32
|
+
*
|
|
33
|
+
* `key` must be set on the returned element's root.
|
|
34
|
+
*/
|
|
35
|
+
export type RichTextInlineRenderer = (children: ReactNode, options: {
|
|
36
|
+
key: string;
|
|
37
|
+
}) => ReactNode;
|
|
38
|
+
export interface CreateRichTextBlockOptions<TLinkTypes extends Record<string, unknown> = Record<string, unknown>> {
|
|
39
|
+
/**
|
|
40
|
+
* Maps draft block types (e.g. `"header-one"`, `"paragraph-standard"`) to the
|
|
41
|
+
* styling of the text component that renders them.
|
|
42
|
+
*
|
|
43
|
+
* Unmapped block types render with the base theme text styles.
|
|
44
|
+
*/
|
|
45
|
+
blockTypes?: Record<string, RichTextBlockTypeProps>;
|
|
46
|
+
/**
|
|
47
|
+
* Maps the application's link block types within `LINK` entities to a
|
|
48
|
+
* resolver returning the link's href.
|
|
49
|
+
*
|
|
50
|
+
* Merged on top of the built-in `external` link type. Link types without
|
|
51
|
+
* a resolver render their text without a link.
|
|
52
|
+
*/
|
|
53
|
+
linkTypes?: {
|
|
54
|
+
[TLinkType in keyof TLinkTypes]: RichTextLinkHrefResolver<TLinkTypes[TLinkType]>;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Maps draft-js inline style names to renderers, keyed by the style name as
|
|
58
|
+
* it appears in the content's `inlineStyleRanges`.
|
|
59
|
+
*
|
|
60
|
+
* Merged on top of the built-in styles (`BOLD`, `ITALIC`, `SUB`, `SUP`,
|
|
61
|
+
* `STRIKETHROUGH`): use it to override a built-in style, or to render a
|
|
62
|
+
* custom inline style the application defines in its RTE (e.g. `HIGHLIGHT`),
|
|
63
|
+
* which has no built-in renderer.
|
|
64
|
+
*/
|
|
65
|
+
inline?: Record<string, RichTextInlineRenderer>;
|
|
66
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { CreateRichTextBlockOptions, RichTextBlockProps } from "./common.js";
|
|
3
|
+
/**
|
|
4
|
+
* Creates a pair of rich-text block components that render CMS RichText block
|
|
5
|
+
* data (draft-js raw content) as themed text.
|
|
6
|
+
*
|
|
7
|
+
* Call the factory once per configuration — at the top level of a file, not
|
|
8
|
+
* inside a component — and reuse the returned components. Call it again for
|
|
9
|
+
* differently-configured rich-text blocks (e.g. a generic and a headline-only
|
|
10
|
+
* one).
|
|
11
|
+
*
|
|
12
|
+
* `MjmlRichTextBlock` renders each draft block as `MjmlText` and must be
|
|
13
|
+
* placed within an `MjmlColumn`. `HtmlRichTextBlock` renders each draft block
|
|
14
|
+
* as `HtmlText` for raw-HTML contexts (e.g. inside `MjmlRaw`).
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* export const { MjmlRichTextBlock, HtmlRichTextBlock } = createRichTextBlock({
|
|
18
|
+
* blockTypes: {
|
|
19
|
+
* "header-one": { variant: "heading1" },
|
|
20
|
+
* "paragraph-standard": { variant: "body" },
|
|
21
|
+
* },
|
|
22
|
+
* });
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export declare function createRichTextBlock<TLinkTypes extends Record<string, unknown> = Record<string, unknown>>(options?: CreateRichTextBlockOptions<TLinkTypes>): {
|
|
26
|
+
/** Renders CMS RichText block data (draft-js raw content) as one `MjmlText` per draft block. Must be placed within an `MjmlColumn`. */
|
|
27
|
+
MjmlRichTextBlock: (props: RichTextBlockProps) => ReactNode;
|
|
28
|
+
/** Renders CMS RichText block data (draft-js raw content) as one `HtmlText` div per draft block, for raw-HTML contexts such as `MjmlRaw`. */
|
|
29
|
+
HtmlRichTextBlock: (props: RichTextBlockProps) => ReactNode;
|
|
30
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import clsx from "clsx";
|
|
3
|
+
import redraftImport from "redraft";
|
|
4
|
+
import { HtmlText } from "../../components/text/HtmlText.js";
|
|
5
|
+
import { MjmlText } from "../../components/text/MjmlText.js";
|
|
6
|
+
import { builtInLinkTypes, createRichTextRenderers } from "./createRichTextRenderers.js";
|
|
7
|
+
// redraft is CommonJS-only: under native ESM the default import is the whole
|
|
8
|
+
// `module.exports` object, under bundlers it is the render function itself.
|
|
9
|
+
const redraft = typeof redraftImport === "function" ? redraftImport : redraftImport.default;
|
|
10
|
+
function MjmlBlockText({ bottomSpacing, variant, className, fontWeight, children, ...styleProps }) {
|
|
11
|
+
return (_jsx(MjmlText, { variant: variant, bottomSpacing: bottomSpacing, className: clsx("richTextBlock__text", className), ...(fontWeight !== undefined && { fontWeight: String(fontWeight) }), ...styleProps, children: children }));
|
|
12
|
+
}
|
|
13
|
+
function HtmlBlockText({ bottomSpacing, variant, className, children, ...styleProps }) {
|
|
14
|
+
return (_jsx(HtmlText, { element: "div", variant: variant, bottomSpacing: bottomSpacing, className: clsx("richTextBlock__text", className), style: styleProps, children: children }));
|
|
15
|
+
}
|
|
16
|
+
function isDraftBlock(block) {
|
|
17
|
+
if (typeof block !== "object" || block === null || !("key" in block) || !("text" in block)) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
const { key, text } = block;
|
|
21
|
+
return typeof key === "string" && typeof text === "string";
|
|
22
|
+
}
|
|
23
|
+
function isDraftContent(draftContent) {
|
|
24
|
+
if (typeof draftContent !== "object" || draftContent === null || !("blocks" in draftContent)) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
const { blocks } = draftContent;
|
|
28
|
+
return Array.isArray(blocks) && blocks.every(isDraftBlock);
|
|
29
|
+
}
|
|
30
|
+
function renderRichTextContent({ draftContent, blockTypes, linkTypes, inline, blockTextComponent }) {
|
|
31
|
+
if (!isDraftContent(draftContent)) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const blocksWithText = draftContent.blocks.filter((block) => block.text !== "");
|
|
35
|
+
const lastBlockKey = blocksWithText[blocksWithText.length - 1]?.key;
|
|
36
|
+
if (lastBlockKey === undefined) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
const renderers = createRichTextRenderers({ blockTypes, linkTypes, inline, blockTextComponent, lastBlockKey });
|
|
40
|
+
return redraft({ ...draftContent, blocks: blocksWithText }, renderers);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Creates a pair of rich-text block components that render CMS RichText block
|
|
44
|
+
* data (draft-js raw content) as themed text.
|
|
45
|
+
*
|
|
46
|
+
* Call the factory once per configuration — at the top level of a file, not
|
|
47
|
+
* inside a component — and reuse the returned components. Call it again for
|
|
48
|
+
* differently-configured rich-text blocks (e.g. a generic and a headline-only
|
|
49
|
+
* one).
|
|
50
|
+
*
|
|
51
|
+
* `MjmlRichTextBlock` renders each draft block as `MjmlText` and must be
|
|
52
|
+
* placed within an `MjmlColumn`. `HtmlRichTextBlock` renders each draft block
|
|
53
|
+
* as `HtmlText` for raw-HTML contexts (e.g. inside `MjmlRaw`).
|
|
54
|
+
*
|
|
55
|
+
* ```ts
|
|
56
|
+
* export const { MjmlRichTextBlock, HtmlRichTextBlock } = createRichTextBlock({
|
|
57
|
+
* blockTypes: {
|
|
58
|
+
* "header-one": { variant: "heading1" },
|
|
59
|
+
* "paragraph-standard": { variant: "body" },
|
|
60
|
+
* },
|
|
61
|
+
* });
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
export function createRichTextBlock(options = {}) {
|
|
65
|
+
const blockTypes = options.blockTypes ?? {};
|
|
66
|
+
const linkTypes = {
|
|
67
|
+
...builtInLinkTypes,
|
|
68
|
+
// Consumers declare each resolver's props via the typed map, but at
|
|
69
|
+
// runtime they are unknown draft-js data. This one contained cast lets
|
|
70
|
+
// consumers work typed without casting in their own resolvers.
|
|
71
|
+
...options.linkTypes,
|
|
72
|
+
};
|
|
73
|
+
const inline = options.inline ?? {};
|
|
74
|
+
function MjmlRichTextBlock({ data }) {
|
|
75
|
+
return renderRichTextContent({ draftContent: data.draftContent, blockTypes, linkTypes, inline, blockTextComponent: MjmlBlockText });
|
|
76
|
+
}
|
|
77
|
+
function HtmlRichTextBlock({ data }) {
|
|
78
|
+
return renderRichTextContent({ draftContent: data.draftContent, blockTypes, linkTypes, inline, blockTextComponent: HtmlBlockText });
|
|
79
|
+
}
|
|
80
|
+
return { MjmlRichTextBlock, HtmlRichTextBlock };
|
|
81
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ComponentType, PropsWithChildren } from "react";
|
|
2
|
+
import type { Renderers } from "redraft";
|
|
3
|
+
import type { RichTextBlockTypeProps, RichTextInlineRenderer, RichTextLinkHrefResolver } from "./common.js";
|
|
4
|
+
export type BlockTextProps = PropsWithChildren<RichTextBlockTypeProps & {
|
|
5
|
+
/** Whether the theme's spacing below the text applies — set for every draft block except the last. */
|
|
6
|
+
bottomSpacing: boolean;
|
|
7
|
+
}>;
|
|
8
|
+
export declare const builtInLinkTypes: Record<string, RichTextLinkHrefResolver>;
|
|
9
|
+
interface CreateRichTextRenderersOptions {
|
|
10
|
+
blockTypes: Record<string, RichTextBlockTypeProps>;
|
|
11
|
+
linkTypes: Record<string, RichTextLinkHrefResolver>;
|
|
12
|
+
inline: Record<string, RichTextInlineRenderer>;
|
|
13
|
+
blockTextComponent: ComponentType<BlockTextProps>;
|
|
14
|
+
lastBlockKey: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function createRichTextRenderers({ blockTypes, linkTypes, inline, blockTextComponent, lastBlockKey, }: CreateRichTextRenderersOptions): Renderers;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { HtmlInlineLink } from "../../components/inlineLink/HtmlInlineLink.js";
|
|
3
|
+
const inlineStyleRenderers = {
|
|
4
|
+
// The explicit styles back up the semantic tags in rendering engines that don't apply their default styling.
|
|
5
|
+
BOLD: (children, { key }) => (_jsx("strong", { style: { fontWeight: "bold" }, children: children }, key)),
|
|
6
|
+
ITALIC: (children, { key }) => (_jsx("em", { style: { fontStyle: "italic" }, children: children }, key)),
|
|
7
|
+
SUB: (children, { key }) => _jsx("sub", { children: children }, key),
|
|
8
|
+
SUP: (children, { key }) => _jsx("sup", { children: children }, key),
|
|
9
|
+
STRIKETHROUGH: (children, { key }) => _jsx("s", { children: children }, key),
|
|
10
|
+
};
|
|
11
|
+
function resolveExternalLinkHref(props) {
|
|
12
|
+
if (typeof props !== "object" || props === null || !("targetUrl" in props)) {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
const { targetUrl } = props;
|
|
16
|
+
return typeof targetUrl === "string" ? targetUrl : undefined;
|
|
17
|
+
}
|
|
18
|
+
export const builtInLinkTypes = {
|
|
19
|
+
external: resolveExternalLinkHref,
|
|
20
|
+
};
|
|
21
|
+
function getLinkBlock(entityData) {
|
|
22
|
+
if (typeof entityData !== "object" || entityData === null || !("block" in entityData)) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
const { block } = entityData;
|
|
26
|
+
if (typeof block !== "object" || block === null || !("type" in block) || !("props" in block)) {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
const { type, props } = block;
|
|
30
|
+
return typeof type === "string" ? { type, props } : undefined;
|
|
31
|
+
}
|
|
32
|
+
function renderWithLineBreaks(node) {
|
|
33
|
+
if (typeof node === "string" && node.includes("\n")) {
|
|
34
|
+
const lines = node.split("\n");
|
|
35
|
+
return lines.flatMap((line, index) => (index === 0 ? [line] : [_jsx("br", {}, index), line]));
|
|
36
|
+
}
|
|
37
|
+
if (Array.isArray(node)) {
|
|
38
|
+
return node.map((child) => renderWithLineBreaks(child));
|
|
39
|
+
}
|
|
40
|
+
return node;
|
|
41
|
+
}
|
|
42
|
+
function createTextBlockRenderFn({ blockTextComponent: BlockText, blockTypeProps, lastBlockKey }) {
|
|
43
|
+
return (children, { keys }) => children.map((child, index) => (_jsx(BlockText, { bottomSpacing: keys[index] !== lastBlockKey, ...blockTypeProps, children: renderWithLineBreaks(child) }, keys[index])));
|
|
44
|
+
}
|
|
45
|
+
function createListBlockRenderFn({ listElement: ListElement, blockTextComponent: BlockText, blockTypeProps, lastBlockKey, }) {
|
|
46
|
+
return (children, { keys }) => (_jsx(BlockText, { bottomSpacing: !keys.includes(lastBlockKey), ...blockTypeProps, children: _jsx(ListElement, { className: "richTextBlock__list", style: { marginTop: 0, marginBottom: 0 }, children: children.map((child, index) => (_jsx("li", { className: "richTextBlock__listItem", children: renderWithLineBreaks(child) }, keys[index]))) }) }, keys.join("-")));
|
|
47
|
+
}
|
|
48
|
+
const listBlockTypes = ["unordered-list-item", "ordered-list-item"];
|
|
49
|
+
export function createRichTextRenderers({ blockTypes, linkTypes, inline, blockTextComponent, lastBlockKey, }) {
|
|
50
|
+
const blocks = {};
|
|
51
|
+
// "unstyled" is redraft's blockFallback: registering it makes every block type the caller did not configure render with base theme styles.
|
|
52
|
+
for (const blockType of new Set(["unstyled", ...Object.keys(blockTypes)])) {
|
|
53
|
+
if (listBlockTypes.includes(blockType)) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
blocks[blockType] = createTextBlockRenderFn({ blockTextComponent, blockTypeProps: blockTypes[blockType] ?? {}, lastBlockKey });
|
|
57
|
+
}
|
|
58
|
+
for (const listBlockType of listBlockTypes) {
|
|
59
|
+
blocks[listBlockType] = createListBlockRenderFn({
|
|
60
|
+
listElement: listBlockType === "unordered-list-item" ? "ul" : "ol",
|
|
61
|
+
blockTextComponent,
|
|
62
|
+
blockTypeProps: blockTypes[listBlockType] ?? {},
|
|
63
|
+
lastBlockKey,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
inline: { ...inlineStyleRenderers, ...inline },
|
|
68
|
+
blocks,
|
|
69
|
+
entities: {
|
|
70
|
+
LINK: (children, data, { key }) => {
|
|
71
|
+
const linkBlock = getLinkBlock(data);
|
|
72
|
+
if (linkBlock === undefined) {
|
|
73
|
+
return children;
|
|
74
|
+
}
|
|
75
|
+
const href = linkTypes[linkBlock.type]?.(linkBlock.props);
|
|
76
|
+
if (href === undefined) {
|
|
77
|
+
return children;
|
|
78
|
+
}
|
|
79
|
+
return (_jsx(HtmlInlineLink, { className: "richTextBlock__link", href: href, children: children }, key));
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export type { HtmlPixelImageBlockProps } from "./blocks/pixelImage/HtmlPixelImag
|
|
|
8
8
|
export { HtmlPixelImageBlock } from "./blocks/pixelImage/HtmlPixelImageBlock.js";
|
|
9
9
|
export type { MjmlPixelImageBlockProps } from "./blocks/pixelImage/MjmlPixelImageBlock.js";
|
|
10
10
|
export { MjmlPixelImageBlock } from "./blocks/pixelImage/MjmlPixelImageBlock.js";
|
|
11
|
+
export type { CreateRichTextBlockOptions, RichTextBlockProps } from "./blocks/richText/common.js";
|
|
12
|
+
export { createRichTextBlock } from "./blocks/richText/createRichTextBlock.js";
|
|
11
13
|
export type { HtmlButtonProps } from "./components/button/HtmlButton.js";
|
|
12
14
|
export { HtmlButton } from "./components/button/HtmlButton.js";
|
|
13
15
|
export type { MjmlButtonProps } from "./components/button/MjmlButton.js";
|
package/lib/index.js
CHANGED
|
@@ -4,6 +4,7 @@ export { OneOfBlock } from "./blocks/factories/OneOfBlock.js";
|
|
|
4
4
|
export { OptionalBlock } from "./blocks/factories/OptionalBlock.js";
|
|
5
5
|
export { HtmlPixelImageBlock } from "./blocks/pixelImage/HtmlPixelImageBlock.js";
|
|
6
6
|
export { MjmlPixelImageBlock } from "./blocks/pixelImage/MjmlPixelImageBlock.js";
|
|
7
|
+
export { createRichTextBlock } from "./blocks/richText/createRichTextBlock.js";
|
|
7
8
|
export { HtmlButton } from "./components/button/HtmlButton.js";
|
|
8
9
|
export { MjmlButton } from "./components/button/MjmlButton.js";
|
|
9
10
|
export { HtmlDivider } from "./components/divider/HtmlDivider.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@comet/mail-react",
|
|
3
|
-
"version": "9.0.0
|
|
3
|
+
"version": "9.0.0",
|
|
4
4
|
"description": "Utilities for building HTML emails with React",
|
|
5
5
|
"license": "BSD-2-Clause",
|
|
6
6
|
"type": "module",
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"@faire/mjml-react": "^3.5.4",
|
|
24
24
|
"clsx": "^2.1.1",
|
|
25
25
|
"mjml": "^4.18.0",
|
|
26
|
-
"mjml-browser": "^4.18.0"
|
|
26
|
+
"mjml-browser": "^4.18.0",
|
|
27
|
+
"redraft": "^0.10.2"
|
|
27
28
|
},
|
|
28
29
|
"devDependencies": {
|
|
29
30
|
"@storybook/addon-docs": "^10.3.5",
|
|
@@ -47,8 +48,8 @@
|
|
|
47
48
|
"typescript": "^5.9.3",
|
|
48
49
|
"vite": "^7.3.1",
|
|
49
50
|
"vitest": "^4.1.8",
|
|
50
|
-
"@comet/
|
|
51
|
-
"@comet/
|
|
51
|
+
"@comet/eslint-config": "9.0.0",
|
|
52
|
+
"@comet/cli": "9.0.0"
|
|
52
53
|
},
|
|
53
54
|
"peerDependencies": {
|
|
54
55
|
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|