@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.
@@ -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-canary-20260707121405",
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/cli": "9.0.0-canary-20260707121405",
51
- "@comet/eslint-config": "9.0.0-canary-20260707121405"
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",