@comet/mail-react 8.20.2 → 9.0.0-beta.1
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/README.md +26 -0
- package/lib/client/index.d.ts +1 -0
- package/lib/client/index.js +1 -0
- package/lib/client/renderMailHtml.d.ts +9 -0
- package/lib/client/renderMailHtml.js +7 -0
- package/lib/components/mailRoot/MjmlMailRoot.d.ts +22 -0
- package/lib/components/mailRoot/MjmlMailRoot.js +20 -0
- package/lib/components/mailRoot/__stories__/MjmlMailRoot.stories.d.ts +6 -0
- package/lib/components/mailRoot/__stories__/MjmlMailRoot.stories.js +13 -0
- package/lib/components/section/MjmlSection.d.ts +15 -0
- package/lib/components/section/MjmlSection.js +43 -0
- package/lib/components/section/__stories__/MjmlSection.stories.d.ts +8 -0
- package/lib/components/section/__stories__/MjmlSection.stories.js +24 -0
- package/lib/index.d.ts +11 -1
- package/lib/index.js +8 -1
- package/lib/server/index.d.ts +1 -0
- package/lib/server/index.js +1 -0
- package/lib/server/renderMailHtml.d.ts +9 -0
- package/lib/server/renderMailHtml.js +7 -0
- package/lib/server/renderMailHtml.test.d.ts +1 -0
- package/lib/server/renderMailHtml.test.js +43 -0
- package/lib/storybook/CopyMailHtmlButton.d.ts +4 -0
- package/lib/storybook/CopyMailHtmlButton.js +33 -0
- package/lib/storybook/MailRendererDecorator.d.ts +1 -0
- package/lib/storybook/MailRendererDecorator.js +17 -0
- package/lib/storybook/MjmlWarningsPanel.d.ts +7 -0
- package/lib/storybook/MjmlWarningsPanel.js +33 -0
- package/lib/storybook/UsePublicImageUrlsToggle.d.ts +4 -0
- package/lib/storybook/UsePublicImageUrlsToggle.js +14 -0
- package/lib/storybook/index.d.ts +2 -0
- package/lib/storybook/index.js +9 -0
- package/lib/storybook/manager.d.ts +1 -0
- package/lib/storybook/manager.js +25 -0
- package/lib/storybook/preview.d.ts +5 -0
- package/lib/storybook/preview.js +5 -0
- package/lib/storybook/replaceImagesWithPublicUrl.d.ts +1 -0
- package/lib/storybook/replaceImagesWithPublicUrl.js +15 -0
- package/lib/storybook/replaceImagesWithPublicUrl.test.d.ts +1 -0
- package/lib/storybook/replaceImagesWithPublicUrl.test.js +55 -0
- package/lib/styles/Styles.d.ts +6 -0
- package/lib/styles/Styles.js +16 -0
- package/lib/styles/registerStyles.d.ts +19 -0
- package/lib/styles/registerStyles.js +14 -0
- package/lib/theme/ThemeProvider.d.ts +8 -0
- package/lib/theme/ThemeProvider.js +17 -0
- package/lib/theme/__stories__/ThemeProvider.stories.d.ts +7 -0
- package/lib/theme/__stories__/ThemeProvider.stories.js +23 -0
- package/lib/theme/createBreakpoint.d.ts +7 -0
- package/lib/theme/createBreakpoint.js +11 -0
- package/lib/theme/createTheme.d.ts +18 -0
- package/lib/theme/createTheme.js +24 -0
- package/lib/theme/createTheme.test.d.ts +1 -0
- package/lib/theme/createTheme.test.js +20 -0
- package/lib/theme/defaultTheme.d.ts +2 -0
- package/lib/theme/defaultTheme.js +11 -0
- package/lib/theme/responsiveValue.d.ts +25 -0
- package/lib/theme/responsiveValue.js +23 -0
- package/lib/theme/responsiveValue.test.d.ts +1 -0
- package/lib/theme/responsiveValue.test.js +45 -0
- package/lib/theme/themeTypes.d.ts +47 -0
- package/lib/theme/themeTypes.js +1 -0
- package/lib/utils/css.test.d.ts +1 -0
- package/lib/utils/css.test.js +26 -0
- package/package.json +35 -12
package/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# @comet/mail-react
|
|
2
|
+
|
|
3
|
+
Utilities for building HTML emails with React and MJML.
|
|
4
|
+
|
|
5
|
+
## Spec-driven development with OpenSpec
|
|
6
|
+
|
|
7
|
+
This package uses [OpenSpec](https://github.com/Fission-AI/OpenSpec) for spec-driven development. Specs and change history live in `openspec/` and are committed to the repo.
|
|
8
|
+
|
|
9
|
+
## Getting started
|
|
10
|
+
|
|
11
|
+
- Run `./install.sh` from the project root to install dependencies and configure OpenSpec agent skills
|
|
12
|
+
- Open the `packages/mail-react/` directory directly in the IDE, as OpenSpec does not currently support monorepos
|
|
13
|
+
|
|
14
|
+
## Suggested development workflow
|
|
15
|
+
|
|
16
|
+
- Use `/opsx:explore` to think through ideas before proposing
|
|
17
|
+
- Use `/opsx:propose` to propose a change, then commit the proposal
|
|
18
|
+
- Use `/opsx:apply` to apply the change, then commit the result
|
|
19
|
+
- Use `/opsx:archive` to archive the change and merge delta specs into the main specs, then commit
|
|
20
|
+
|
|
21
|
+
Minor fixes may skip this workflow if they do not create a gap between `openspec/specs/` and the code (e.g. changing a TSDoc comment).
|
|
22
|
+
|
|
23
|
+
## Suggested review workflow
|
|
24
|
+
|
|
25
|
+
- A pull request should propose, apply, and archive a change in one go
|
|
26
|
+
- Reviewers can then focus on the code changes and resulting spec, skimming the archived implementation details
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { renderMailHtml } from "./renderMailHtml.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { renderMailHtml } from "./renderMailHtml.js";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import mjml2html from "mjml-browser";
|
|
2
|
+
import { type ReactElement } from "react";
|
|
3
|
+
type MjmlOptions = Parameters<typeof mjml2html>[1];
|
|
4
|
+
type MjmlWarning = ReturnType<typeof mjml2html>["errors"][number];
|
|
5
|
+
export declare function renderMailHtml(element: ReactElement, options?: MjmlOptions): {
|
|
6
|
+
html: string;
|
|
7
|
+
mjmlWarnings: MjmlWarning[];
|
|
8
|
+
};
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { renderToMjml } from "@faire/mjml-react/utils/renderToMjml.js";
|
|
2
|
+
import mjml2html from "mjml-browser";
|
|
3
|
+
export function renderMailHtml(element, options) {
|
|
4
|
+
const mjmlString = renderToMjml(element);
|
|
5
|
+
const { html, errors: mjmlWarnings } = mjml2html(mjmlString, { validationLevel: "soft", ...options });
|
|
6
|
+
return { html, mjmlWarnings };
|
|
7
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { PropsWithChildren, ReactNode } from "react";
|
|
2
|
+
import type { Theme } from "../../theme/themeTypes.js";
|
|
3
|
+
type MjmlMailRootProps = PropsWithChildren<{
|
|
4
|
+
/**
|
|
5
|
+
* Theme to use for the email. When omitted, the default theme
|
|
6
|
+
* (equivalent to `createTheme()`) is used.
|
|
7
|
+
*/
|
|
8
|
+
theme?: Theme;
|
|
9
|
+
}>;
|
|
10
|
+
/**
|
|
11
|
+
* The root element for email templates. Renders the standard MJML email skeleton
|
|
12
|
+
* (`<Mjml>`, `<MjmlHead>`, `<MjmlBody>`) with `<MjmlAll padding={0} />` as the
|
|
13
|
+
* default attribute so all components start with zero padding.
|
|
14
|
+
*
|
|
15
|
+
* Accepts an optional `theme` prop that controls the body width and responsive
|
|
16
|
+
* breakpoints. The theme is made available to all descendant components via
|
|
17
|
+
* `useTheme()`.
|
|
18
|
+
*
|
|
19
|
+
* Direct children should be section-level components (e.g. `MjmlSection`).
|
|
20
|
+
*/
|
|
21
|
+
export declare function MjmlMailRoot({ theme: themeProp, children }: MjmlMailRootProps): ReactNode;
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Mjml, MjmlAll, MjmlAttributes, MjmlBody, MjmlBreakpoint, MjmlHead } from "@faire/mjml-react";
|
|
3
|
+
import { Styles } from "../../styles/Styles.js";
|
|
4
|
+
import { createTheme } from "../../theme/createTheme.js";
|
|
5
|
+
import { ThemeProvider } from "../../theme/ThemeProvider.js";
|
|
6
|
+
/**
|
|
7
|
+
* The root element for email templates. Renders the standard MJML email skeleton
|
|
8
|
+
* (`<Mjml>`, `<MjmlHead>`, `<MjmlBody>`) with `<MjmlAll padding={0} />` as the
|
|
9
|
+
* default attribute so all components start with zero padding.
|
|
10
|
+
*
|
|
11
|
+
* Accepts an optional `theme` prop that controls the body width and responsive
|
|
12
|
+
* breakpoints. The theme is made available to all descendant components via
|
|
13
|
+
* `useTheme()`.
|
|
14
|
+
*
|
|
15
|
+
* Direct children should be section-level components (e.g. `MjmlSection`).
|
|
16
|
+
*/
|
|
17
|
+
export function MjmlMailRoot({ theme: themeProp, children }) {
|
|
18
|
+
const theme = themeProp ?? createTheme();
|
|
19
|
+
return (_jsx(ThemeProvider, { theme: theme, children: _jsxs(Mjml, { children: [_jsxs(MjmlHead, { children: [_jsx(MjmlAttributes, { children: _jsx(MjmlAll, { padding: "0" }) }), _jsx(MjmlBreakpoint, { width: `${theme.breakpoints.mobile.value}px` }), _jsx(Styles, {})] }), _jsx(MjmlBody, { width: theme.sizes.bodyWidth, children: children })] }) }));
|
|
20
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { MjmlMailRoot } from "../MjmlMailRoot.js";
|
|
3
|
+
type Story = StoryObj<typeof MjmlMailRoot>;
|
|
4
|
+
declare const config: Meta<typeof MjmlMailRoot>;
|
|
5
|
+
export default config;
|
|
6
|
+
export declare const Basic: Story;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { MjmlColumn, MjmlText } from "@faire/mjml-react";
|
|
3
|
+
import { MjmlSection } from "../../section/MjmlSection.js";
|
|
4
|
+
import { MjmlMailRoot } from "../MjmlMailRoot.js";
|
|
5
|
+
const config = {
|
|
6
|
+
title: "Components/MjmlMailRoot",
|
|
7
|
+
component: MjmlMailRoot,
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
};
|
|
10
|
+
export default config;
|
|
11
|
+
export const Basic = {
|
|
12
|
+
render: () => (_jsx(MjmlMailRoot, { children: _jsx(MjmlSection, { children: _jsx(MjmlColumn, { children: _jsx(MjmlText, { children: "Hello from MjmlMailRoot" }) }) }) })),
|
|
13
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type IMjmlGroupProps, type IMjmlSectionProps } from "@faire/mjml-react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
export type MjmlSectionProps = IMjmlSectionProps & {
|
|
4
|
+
/** Applies theme-based content indentation with responsive overrides. */
|
|
5
|
+
indent?: boolean;
|
|
6
|
+
/** When true, child columns remain side-by-side on mobile instead of stacking vertically. */
|
|
7
|
+
disableResponsiveBehavior?: boolean;
|
|
8
|
+
/** Props forwarded to internal sub-components. */
|
|
9
|
+
slotProps?: {
|
|
10
|
+
/** Props passed to the wrapping `MjmlGroup` when `disableResponsiveBehavior` is enabled. */
|
|
11
|
+
group?: Partial<IMjmlGroupProps>;
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
/** A section wrapper for email layouts. Must be a direct child of `MjmlBody`. */
|
|
15
|
+
export declare function MjmlSection({ children, indent, disableResponsiveBehavior, slotProps, className, ...props }: MjmlSectionProps): ReactNode;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { MjmlGroup, MjmlSection as BaseMjmlSection } from "@faire/mjml-react";
|
|
3
|
+
import clsx from "clsx";
|
|
4
|
+
import { registerStyles } from "../../styles/registerStyles.js";
|
|
5
|
+
import { getDefaultFromResponsiveValue, getResponsiveOverrides } from "../../theme/responsiveValue.js";
|
|
6
|
+
import { useOptionalTheme } from "../../theme/ThemeProvider.js";
|
|
7
|
+
import { css } from "../../utils/css.js";
|
|
8
|
+
/** A section wrapper for email layouts. Must be a direct child of `MjmlBody`. */
|
|
9
|
+
export function MjmlSection({ children, indent, disableResponsiveBehavior, slotProps, className, ...props }) {
|
|
10
|
+
const theme = useOptionalTheme();
|
|
11
|
+
const indentProps = indent ? getIndentProps(theme) : {};
|
|
12
|
+
const resolvedClassName = clsx("mjmlSection", indent && "mjmlSection--indented", className);
|
|
13
|
+
return (_jsx(BaseMjmlSection, { className: resolvedClassName, ...indentProps, ...props, children: disableResponsiveBehavior ? _jsx(MjmlGroup, { ...slotProps?.group, children: children }) : _jsx(_Fragment, { children: children }) }));
|
|
14
|
+
}
|
|
15
|
+
function getIndentProps(theme) {
|
|
16
|
+
if (theme === null) {
|
|
17
|
+
throw new Error("The `indent` prop requires being wrapped in a ThemeProvider or MjmlMailRoot.");
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
paddingLeft: getDefaultFromResponsiveValue(theme.sizes.contentIndentation),
|
|
21
|
+
paddingRight: getDefaultFromResponsiveValue(theme.sizes.contentIndentation),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
registerStyles((theme) => {
|
|
25
|
+
const overrides = getResponsiveOverrides(theme.sizes.contentIndentation);
|
|
26
|
+
if (overrides.length === 0)
|
|
27
|
+
return css ``;
|
|
28
|
+
return overrides
|
|
29
|
+
.map((override) => {
|
|
30
|
+
const breakpoint = theme.breakpoints[override.breakpointKey];
|
|
31
|
+
if (!breakpoint)
|
|
32
|
+
return "";
|
|
33
|
+
return css `
|
|
34
|
+
${breakpoint.belowMediaQuery} {
|
|
35
|
+
.mjmlSection--indented > table > tbody > tr > td {
|
|
36
|
+
padding-left: ${override.value}px !important;
|
|
37
|
+
padding-right: ${override.value}px !important;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
`;
|
|
41
|
+
})
|
|
42
|
+
.join("\n");
|
|
43
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { MjmlSection } from "../MjmlSection.js";
|
|
3
|
+
type Story = StoryObj<typeof MjmlSection>;
|
|
4
|
+
declare const config: Meta<typeof MjmlSection>;
|
|
5
|
+
export default config;
|
|
6
|
+
export declare const Primary: Story;
|
|
7
|
+
export declare const Indented: Story;
|
|
8
|
+
export declare const DisabledResponsiveBehavior: Story;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { MjmlColumn, MjmlText } from "@faire/mjml-react";
|
|
3
|
+
import { MjmlSection } from "../MjmlSection.js";
|
|
4
|
+
const config = {
|
|
5
|
+
title: "Components/MjmlSection",
|
|
6
|
+
component: MjmlSection,
|
|
7
|
+
tags: ["autodocs"],
|
|
8
|
+
};
|
|
9
|
+
export default config;
|
|
10
|
+
export const Primary = {
|
|
11
|
+
render: (args) => (_jsx(MjmlSection, { ...args, children: _jsx(MjmlColumn, { children: _jsx(MjmlText, { children: "Section content" }) }) })),
|
|
12
|
+
};
|
|
13
|
+
export const Indented = {
|
|
14
|
+
args: {
|
|
15
|
+
indent: true,
|
|
16
|
+
},
|
|
17
|
+
render: (args) => (_jsx(MjmlSection, { ...args, children: _jsx(MjmlColumn, { children: _jsx(MjmlText, { children: "Indented section content" }) }) })),
|
|
18
|
+
};
|
|
19
|
+
export const DisabledResponsiveBehavior = {
|
|
20
|
+
args: {
|
|
21
|
+
disableResponsiveBehavior: true,
|
|
22
|
+
},
|
|
23
|
+
render: (args) => (_jsxs(MjmlSection, { ...args, children: [_jsx(MjmlColumn, { children: _jsx(MjmlText, { children: "First column" }) }), _jsx(MjmlColumn, { children: _jsx(MjmlText, { children: "Second column" }) })] })),
|
|
24
|
+
};
|
package/lib/index.d.ts
CHANGED
|
@@ -4,7 +4,17 @@ export { OneOfBlock } from "./blocks/factories/OneOfBlock.js";
|
|
|
4
4
|
export { OptionalBlock } from "./blocks/factories/OptionalBlock.js";
|
|
5
5
|
export type { SupportedBlocks } from "./blocks/factories/types.js";
|
|
6
6
|
export type { PropsWithData } from "./blocks/helpers/PropsWithData.js";
|
|
7
|
+
export { MjmlMailRoot } from "./components/mailRoot/MjmlMailRoot.js";
|
|
8
|
+
export type { MjmlSectionProps } from "./components/section/MjmlSection.js";
|
|
9
|
+
export { MjmlSection } from "./components/section/MjmlSection.js";
|
|
10
|
+
export { registerStyles } from "./styles/registerStyles.js";
|
|
11
|
+
export { createBreakpoint } from "./theme/createBreakpoint.js";
|
|
12
|
+
export { createTheme } from "./theme/createTheme.js";
|
|
13
|
+
export type { ResponsiveValue } from "./theme/responsiveValue.js";
|
|
14
|
+
export { getDefaultFromResponsiveValue, getResponsiveOverrides } from "./theme/responsiveValue.js";
|
|
15
|
+
export { ThemeProvider, useTheme } from "./theme/ThemeProvider.js";
|
|
16
|
+
export type { Theme, ThemeBreakpoint, ThemeBreakpoints, ThemeSizes } from "./theme/themeTypes.js";
|
|
7
17
|
export { css } from "./utils/css.js";
|
|
8
|
-
export { Mjml, MjmlAccordion, MjmlAccordionElement, type IMjmlAccordionElementProps as MjmlAccordionElementProps, type IMjmlAccordionProps as MjmlAccordionProps, MjmlAccordionText, type IMjmlAccordionTextProps as MjmlAccordionTextProps, MjmlAccordionTitle, type IMjmlAccordionTitleProps as MjmlAccordionTitleProps, MjmlAll, type IMjmlAllProps as MjmlAllProps, MjmlAttributes, type IMjmlAttributesProps as MjmlAttributesProps, MjmlBody, type IMjmlBodyProps as MjmlBodyProps, MjmlBreakpoint, type IMjmlBreakpointProps as MjmlBreakpointProps, MjmlButton, type IMjmlButtonProps as MjmlButtonProps, MjmlCarousel, MjmlCarouselImage, type IMjmlCarouselImageProps as MjmlCarouselImageProps, type IMjmlCarouselProps as MjmlCarouselProps, MjmlClass, type IMjmlClassProps as MjmlClassProps, MjmlColumn, type IMjmlColumnProps as MjmlColumnProps, MjmlDivider, type IMjmlDividerProps as MjmlDividerProps, MjmlFont, type IMjmlFontProps as MjmlFontProps, MjmlGroup, type IMjmlGroupProps as MjmlGroupProps, MjmlHead, type IMjmlHeadProps as MjmlHeadProps, MjmlHero, type IMjmlHeroProps as MjmlHeroProps, MjmlHtmlAttribute, type IMjmlHtmlAttributeProps as MjmlHtmlAttributeProps, MjmlHtmlAttributes, type IMjmlHtmlAttributesProps as MjmlHtmlAttributesProps, MjmlImage, type IMjmlImageProps as MjmlImageProps, MjmlInclude, type IMjmlIncludeProps as MjmlIncludeProps, MjmlNavbar, MjmlNavbarLink, type IMjmlNavbarLinkProps as MjmlNavbarLinkProps, type IMjmlNavbarProps as MjmlNavbarProps, MjmlPreview, type IMjmlPreviewProps as MjmlPreviewProps, type IMjmlProps as MjmlProps, MjmlRaw, type IMjmlRawProps as MjmlRawProps,
|
|
18
|
+
export { Mjml, MjmlAccordion, MjmlAccordionElement, type IMjmlAccordionElementProps as MjmlAccordionElementProps, type IMjmlAccordionProps as MjmlAccordionProps, MjmlAccordionText, type IMjmlAccordionTextProps as MjmlAccordionTextProps, MjmlAccordionTitle, type IMjmlAccordionTitleProps as MjmlAccordionTitleProps, MjmlAll, type IMjmlAllProps as MjmlAllProps, MjmlAttributes, type IMjmlAttributesProps as MjmlAttributesProps, MjmlBody, type IMjmlBodyProps as MjmlBodyProps, MjmlBreakpoint, type IMjmlBreakpointProps as MjmlBreakpointProps, MjmlButton, type IMjmlButtonProps as MjmlButtonProps, MjmlCarousel, MjmlCarouselImage, type IMjmlCarouselImageProps as MjmlCarouselImageProps, type IMjmlCarouselProps as MjmlCarouselProps, MjmlClass, type IMjmlClassProps as MjmlClassProps, MjmlColumn, type IMjmlColumnProps as MjmlColumnProps, MjmlDivider, type IMjmlDividerProps as MjmlDividerProps, MjmlFont, type IMjmlFontProps as MjmlFontProps, MjmlGroup, type IMjmlGroupProps as MjmlGroupProps, MjmlHead, type IMjmlHeadProps as MjmlHeadProps, MjmlHero, type IMjmlHeroProps as MjmlHeroProps, MjmlHtmlAttribute, type IMjmlHtmlAttributeProps as MjmlHtmlAttributeProps, MjmlHtmlAttributes, type IMjmlHtmlAttributesProps as MjmlHtmlAttributesProps, MjmlImage, type IMjmlImageProps as MjmlImageProps, MjmlInclude, type IMjmlIncludeProps as MjmlIncludeProps, MjmlNavbar, MjmlNavbarLink, type IMjmlNavbarLinkProps as MjmlNavbarLinkProps, type IMjmlNavbarProps as MjmlNavbarProps, MjmlPreview, type IMjmlPreviewProps as MjmlPreviewProps, type IMjmlProps as MjmlProps, MjmlRaw, type IMjmlRawProps as MjmlRawProps, MjmlSelector, type IMjmlSelectorProps as MjmlSelectorProps, MjmlSocial, MjmlSocialElement, type IMjmlSocialElementProps as MjmlSocialElementProps, type IMjmlSocialProps as MjmlSocialProps, MjmlSpacer, type IMjmlSpacerProps as MjmlSpacerProps, MjmlStyle, type IMjmlStyleProps as MjmlStyleProps, MjmlTable, type IMjmlTableProps as MjmlTableProps, MjmlText, type IMjmlTextProps as MjmlTextProps, MjmlTitle, type IMjmlTitleProps as MjmlTitleProps, MjmlWrapper, type IMjmlWrapperProps as MjmlWrapperProps, } from "@faire/mjml-react";
|
|
9
19
|
export { MjmlComment, MjmlConditionalComment, MjmlHtml, MjmlTrackingPixel, MjmlYahooStyle } from "@faire/mjml-react/extensions/index.js";
|
|
10
20
|
export { renderToMjml } from "@faire/mjml-react/utils/renderToMjml.js";
|
package/lib/index.js
CHANGED
|
@@ -2,7 +2,14 @@ export { BlocksBlock } from "./blocks/factories/BlocksBlock.js";
|
|
|
2
2
|
export { ListBlock } from "./blocks/factories/ListBlock.js";
|
|
3
3
|
export { OneOfBlock } from "./blocks/factories/OneOfBlock.js";
|
|
4
4
|
export { OptionalBlock } from "./blocks/factories/OptionalBlock.js";
|
|
5
|
+
export { MjmlMailRoot } from "./components/mailRoot/MjmlMailRoot.js";
|
|
6
|
+
export { MjmlSection } from "./components/section/MjmlSection.js";
|
|
7
|
+
export { registerStyles } from "./styles/registerStyles.js";
|
|
8
|
+
export { createBreakpoint } from "./theme/createBreakpoint.js";
|
|
9
|
+
export { createTheme } from "./theme/createTheme.js";
|
|
10
|
+
export { getDefaultFromResponsiveValue, getResponsiveOverrides } from "./theme/responsiveValue.js";
|
|
11
|
+
export { ThemeProvider, useTheme } from "./theme/ThemeProvider.js";
|
|
5
12
|
export { css } from "./utils/css.js";
|
|
6
|
-
export { Mjml, MjmlAccordion, MjmlAccordionElement, MjmlAccordionText, MjmlAccordionTitle, MjmlAll, MjmlAttributes, MjmlBody, MjmlBreakpoint, MjmlButton, MjmlCarousel, MjmlCarouselImage, MjmlClass, MjmlColumn, MjmlDivider, MjmlFont, MjmlGroup, MjmlHead, MjmlHero, MjmlHtmlAttribute, MjmlHtmlAttributes, MjmlImage, MjmlInclude, MjmlNavbar, MjmlNavbarLink, MjmlPreview, MjmlRaw,
|
|
13
|
+
export { Mjml, MjmlAccordion, MjmlAccordionElement, MjmlAccordionText, MjmlAccordionTitle, MjmlAll, MjmlAttributes, MjmlBody, MjmlBreakpoint, MjmlButton, MjmlCarousel, MjmlCarouselImage, MjmlClass, MjmlColumn, MjmlDivider, MjmlFont, MjmlGroup, MjmlHead, MjmlHero, MjmlHtmlAttribute, MjmlHtmlAttributes, MjmlImage, MjmlInclude, MjmlNavbar, MjmlNavbarLink, MjmlPreview, MjmlRaw, MjmlSelector, MjmlSocial, MjmlSocialElement, MjmlSpacer, MjmlStyle, MjmlTable, MjmlText, MjmlTitle, MjmlWrapper, } from "@faire/mjml-react";
|
|
7
14
|
export { MjmlComment, MjmlConditionalComment, MjmlHtml, MjmlTrackingPixel, MjmlYahooStyle } from "@faire/mjml-react/extensions/index.js";
|
|
8
15
|
export { renderToMjml } from "@faire/mjml-react/utils/renderToMjml.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { renderMailHtml } from "./renderMailHtml.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { renderMailHtml } from "./renderMailHtml.js";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import mjml2html from "mjml";
|
|
2
|
+
import { type ReactElement } from "react";
|
|
3
|
+
type MjmlOptions = Parameters<typeof mjml2html>[1];
|
|
4
|
+
type MjmlWarning = ReturnType<typeof mjml2html>["errors"][number];
|
|
5
|
+
export declare function renderMailHtml(element: ReactElement, options?: MjmlOptions): {
|
|
6
|
+
html: string;
|
|
7
|
+
mjmlWarnings: MjmlWarning[];
|
|
8
|
+
};
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { renderToMjml } from "@faire/mjml-react/utils/renderToMjml.js";
|
|
2
|
+
import mjml2html from "mjml";
|
|
3
|
+
export function renderMailHtml(element, options) {
|
|
4
|
+
const mjmlString = renderToMjml(element);
|
|
5
|
+
const { html, errors: mjmlWarnings } = mjml2html(mjmlString, { validationLevel: "soft", ...options });
|
|
6
|
+
return { html, mjmlWarnings };
|
|
7
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { MjmlColumn, MjmlText } from "@faire/mjml-react";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { MjmlMailRoot } from "../components/mailRoot/MjmlMailRoot.js";
|
|
5
|
+
import { MjmlSection } from "../components/section/MjmlSection.js";
|
|
6
|
+
import { registerStyles } from "../styles/registerStyles.js";
|
|
7
|
+
import { css } from "../utils/css.js";
|
|
8
|
+
import { renderMailHtml } from "./renderMailHtml.js";
|
|
9
|
+
describe("server/renderMailHtml", () => {
|
|
10
|
+
it("produces HTML with a doctype", () => {
|
|
11
|
+
const { html } = renderMailHtml(_jsx(MjmlMailRoot, { children: _jsx(MjmlSection, { children: _jsx(MjmlColumn, { children: _jsx(MjmlText, { children: "Hello" }) }) }) }));
|
|
12
|
+
expect(html.toLowerCase()).toMatch(/^<!doctype html>/);
|
|
13
|
+
});
|
|
14
|
+
it("includes the passed-in text content", () => {
|
|
15
|
+
const textContent = "Welcome to @comet/mail-react";
|
|
16
|
+
const { html } = renderMailHtml(_jsx(MjmlMailRoot, { children: _jsx(MjmlSection, { children: _jsx(MjmlColumn, { children: _jsx(MjmlText, { children: textContent }) }) }) }));
|
|
17
|
+
expect(html).toContain(textContent);
|
|
18
|
+
});
|
|
19
|
+
it("includes registered styles in the rendered HTML", () => {
|
|
20
|
+
registerStyles(css `
|
|
21
|
+
.myComponent {
|
|
22
|
+
color: red;
|
|
23
|
+
}
|
|
24
|
+
`);
|
|
25
|
+
const { html } = renderMailHtml(_jsx(MjmlMailRoot, { children: _jsx(MjmlSection, { children: _jsx(MjmlColumn, { children: _jsx(MjmlText, { children: "Hello" }) }) }) }));
|
|
26
|
+
expect(html).toContain(".myComponent");
|
|
27
|
+
expect(html).toContain("color: red");
|
|
28
|
+
});
|
|
29
|
+
it("includes function-style registered styles resolved with the theme", () => {
|
|
30
|
+
registerStyles((theme) => css `
|
|
31
|
+
.themed {
|
|
32
|
+
width: ${theme.sizes.bodyWidth}px;
|
|
33
|
+
}
|
|
34
|
+
`);
|
|
35
|
+
const { html } = renderMailHtml(_jsx(MjmlMailRoot, { children: _jsx(MjmlSection, { children: _jsx(MjmlColumn, { children: _jsx(MjmlText, { children: "Hello" }) }) }) }));
|
|
36
|
+
expect(html).toContain(".themed");
|
|
37
|
+
expect(html).toContain("width: 600px");
|
|
38
|
+
});
|
|
39
|
+
it("produces no MJML warnings for a valid component tree", () => {
|
|
40
|
+
const { mjmlWarnings } = renderMailHtml(_jsx(MjmlMailRoot, { children: _jsx(MjmlSection, { children: _jsx(MjmlColumn, { children: _jsx(MjmlText, { children: "No warnings expected" }) }) }) }));
|
|
41
|
+
expect(mjmlWarnings).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** @jsxRuntime classic */
|
|
2
|
+
/** @jsx React.createElement */
|
|
3
|
+
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { Button } from "storybook/internal/components";
|
|
5
|
+
import { useChannel } from "storybook/manager-api";
|
|
6
|
+
const RENDER_RESULT_EVENT = "comet-mail-render-result";
|
|
7
|
+
export function CopyMailHtmlButton() {
|
|
8
|
+
const htmlRef = useRef("");
|
|
9
|
+
const [copied, setCopied] = useState(false);
|
|
10
|
+
const copiedTimeoutRef = useRef(undefined);
|
|
11
|
+
useChannel({
|
|
12
|
+
[RENDER_RESULT_EVENT]: ({ html }) => {
|
|
13
|
+
htmlRef.current = html;
|
|
14
|
+
setCopied(false);
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
return () => {
|
|
19
|
+
if (copiedTimeoutRef.current) {
|
|
20
|
+
clearTimeout(copiedTimeoutRef.current);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
}, []);
|
|
24
|
+
const handleClick = useCallback(async () => {
|
|
25
|
+
await navigator.clipboard.writeText(htmlRef.current);
|
|
26
|
+
setCopied(true);
|
|
27
|
+
if (copiedTimeoutRef.current) {
|
|
28
|
+
clearTimeout(copiedTimeoutRef.current);
|
|
29
|
+
}
|
|
30
|
+
copiedTimeoutRef.current = setTimeout(() => setCopied(false), 2000);
|
|
31
|
+
}, []);
|
|
32
|
+
return (React.createElement(Button, { size: "small", onClick: handleClick }, copied ? "Copied to clipboard!" : "Copy Mail HTML"));
|
|
33
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function MailRendererDecorator(Story: () => React.JSX.Element): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useChannel, useGlobals } from "storybook/preview-api";
|
|
3
|
+
import { renderMailHtml } from "../client/renderMailHtml.js";
|
|
4
|
+
import { MjmlMailRoot } from "../components/mailRoot/MjmlMailRoot.js";
|
|
5
|
+
import { replaceImagesWithPublicUrl } from "./replaceImagesWithPublicUrl.js";
|
|
6
|
+
const RENDER_RESULT_EVENT = "comet-mail-render-result";
|
|
7
|
+
export function MailRendererDecorator(Story) {
|
|
8
|
+
const [globals] = useGlobals();
|
|
9
|
+
const emit = useChannel({});
|
|
10
|
+
const { html: rawHtml, mjmlWarnings } = renderMailHtml(_jsx(MjmlMailRoot, { children: _jsx(Story, {}) }));
|
|
11
|
+
for (const warning of mjmlWarnings) {
|
|
12
|
+
console.warn("MJML warning:", warning);
|
|
13
|
+
}
|
|
14
|
+
const html = globals.usePublicImageUrls ? replaceImagesWithPublicUrl(rawHtml) : rawHtml;
|
|
15
|
+
emit(RENDER_RESULT_EVENT, { html, mjmlWarnings });
|
|
16
|
+
return _jsx("div", { dangerouslySetInnerHTML: { __html: html } });
|
|
17
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** @jsxRuntime classic */
|
|
2
|
+
/** @jsx React.createElement */
|
|
3
|
+
import React, { useState } from "react";
|
|
4
|
+
import { AddonPanel, Badge } from "storybook/internal/components";
|
|
5
|
+
import { useChannel } from "storybook/manager-api";
|
|
6
|
+
const RENDER_RESULT_EVENT = "comet-mail-render-result";
|
|
7
|
+
export function MjmlWarningsPanel({ active }) {
|
|
8
|
+
const [renderResult, setRenderResult] = useState(null);
|
|
9
|
+
useChannel({
|
|
10
|
+
[RENDER_RESULT_EVENT]: (result) => {
|
|
11
|
+
setRenderResult(result);
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
return (React.createElement(AddonPanel, { active: active }, renderResult && (React.createElement("div", { style: { padding: 16 } }, renderResult.mjmlWarnings.length === 0 ? (React.createElement("p", { style: { color: "green" } }, "\u2713 No MJML warnings")) : (React.createElement("ul", { style: { margin: 0, padding: 0, listStyle: "none" } }, renderResult.mjmlWarnings.map((warning, index) => (React.createElement("li", { key: index, style: { marginBottom: 8 } },
|
|
15
|
+
React.createElement("strong", null, warning.tagName),
|
|
16
|
+
" (line ",
|
|
17
|
+
warning.line,
|
|
18
|
+
"): ",
|
|
19
|
+
warning.message)))))))));
|
|
20
|
+
}
|
|
21
|
+
export function MjmlWarningsPanelTitle() {
|
|
22
|
+
const [renderResult, setRenderResult] = useState(null);
|
|
23
|
+
useChannel({
|
|
24
|
+
[RENDER_RESULT_EVENT]: (result) => {
|
|
25
|
+
setRenderResult(result);
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
const warningCount = renderResult?.mjmlWarnings.length ?? 0;
|
|
29
|
+
const hasWarnings = warningCount > 0;
|
|
30
|
+
return (React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 6 } },
|
|
31
|
+
React.createElement("span", null, "MJML Warnings"),
|
|
32
|
+
React.createElement(Badge, { compact: true, status: hasWarnings ? "negative" : "positive" }, hasWarnings ? warningCount : "✓")));
|
|
33
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** @jsxRuntime classic */
|
|
2
|
+
/** @jsx React.createElement */
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { IconButton, TooltipNote, WithTooltip } from "storybook/internal/components";
|
|
5
|
+
import { useGlobals } from "storybook/manager-api";
|
|
6
|
+
export function UsePublicImageUrlsToggle() {
|
|
7
|
+
const [globals, updateGlobals] = useGlobals();
|
|
8
|
+
const isActive = Boolean(globals.usePublicImageUrls);
|
|
9
|
+
return (React.createElement(WithTooltip, { tooltip: React.createElement(TooltipNote, { note: "Helpful to test with real images on external devices that cannot access localhost, e.g. Email on Acid." }), trigger: "hover" },
|
|
10
|
+
React.createElement(IconButton, { size: "small", active: isActive, onClick: () => updateGlobals({ usePublicImageUrls: !isActive }) },
|
|
11
|
+
React.createElement("input", { type: "checkbox", checked: isActive, readOnly: true, style: { pointerEvents: "none", marginLeft: 4 } }),
|
|
12
|
+
"Use public image URLs",
|
|
13
|
+
React.createElement("span", { role: "img", "aria-label": "info" }, "\u2139\uFE0F"))));
|
|
14
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { dirname, resolve } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
const directoryOfThisFile = dirname(fileURLToPath(import.meta.url));
|
|
4
|
+
export function managerEntries(existing = []) {
|
|
5
|
+
return [...existing, resolve(directoryOfThisFile, "manager.js")];
|
|
6
|
+
}
|
|
7
|
+
export function previewAnnotations(input = []) {
|
|
8
|
+
return [...input, resolve(directoryOfThisFile, "preview.js")];
|
|
9
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** @jsxRuntime classic */
|
|
2
|
+
/** @jsx React.createElement */
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { addons, types } from "storybook/manager-api";
|
|
5
|
+
import { CopyMailHtmlButton } from "./CopyMailHtmlButton.js";
|
|
6
|
+
import { MjmlWarningsPanel, MjmlWarningsPanelTitle } from "./MjmlWarningsPanel.js";
|
|
7
|
+
import { UsePublicImageUrlsToggle } from "./UsePublicImageUrlsToggle.js";
|
|
8
|
+
const ADDON_ID = "comet-mail-react";
|
|
9
|
+
addons.register(ADDON_ID, () => {
|
|
10
|
+
addons.add(`${ADDON_ID}/copy-html`, {
|
|
11
|
+
type: types.TOOL,
|
|
12
|
+
title: "Copy Mail HTML",
|
|
13
|
+
render: () => React.createElement(CopyMailHtmlButton, null),
|
|
14
|
+
});
|
|
15
|
+
addons.add(`${ADDON_ID}/public-urls`, {
|
|
16
|
+
type: types.TOOL,
|
|
17
|
+
title: "Use public image URLs",
|
|
18
|
+
render: () => React.createElement(UsePublicImageUrlsToggle, null),
|
|
19
|
+
});
|
|
20
|
+
addons.add(`${ADDON_ID}/mjml-warnings`, {
|
|
21
|
+
type: types.PANEL,
|
|
22
|
+
title: () => React.createElement(MjmlWarningsPanelTitle, null),
|
|
23
|
+
render: ({ active }) => React.createElement(MjmlWarningsPanel, { active: Boolean(active) }),
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function replaceImagesWithPublicUrl(html: string): string;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function replaceImagesWithPublicUrl(html) {
|
|
2
|
+
let seedCounter = 0;
|
|
3
|
+
return html.replace(/<img\b[^>]*>/gi, (imgTag) => {
|
|
4
|
+
const widthMatch = imgTag.match(/\bwidth="(\d+)"/);
|
|
5
|
+
const heightMatch = imgTag.match(/\bheight="(\d+)"/);
|
|
6
|
+
if (!widthMatch || !heightMatch) {
|
|
7
|
+
return imgTag;
|
|
8
|
+
}
|
|
9
|
+
const retinaWidth = parseInt(widthMatch[1], 10) * 2;
|
|
10
|
+
const retinaHeight = parseInt(heightMatch[1], 10) * 2;
|
|
11
|
+
const seed = seedCounter++;
|
|
12
|
+
const publicUrl = `https://picsum.photos/seed/${seed}/${retinaWidth}/${retinaHeight}`;
|
|
13
|
+
return imgTag.replace(/\bsrc="[^"]*"/, `src="${publicUrl}"`);
|
|
14
|
+
});
|
|
15
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { replaceImagesWithPublicUrl } from "./replaceImagesWithPublicUrl.js";
|
|
3
|
+
describe("replaceImagesWithPublicUrl", () => {
|
|
4
|
+
it("replaces src with picsum URL using doubled dimensions", () => {
|
|
5
|
+
const html = '<img src="original.jpg" width="300" height="200" />';
|
|
6
|
+
const result = replaceImagesWithPublicUrl(html);
|
|
7
|
+
expect(result).toBe('<img src="https://picsum.photos/seed/0/600/400" width="300" height="200" />');
|
|
8
|
+
});
|
|
9
|
+
it("handles height before width", () => {
|
|
10
|
+
const html = '<img src="original.jpg" height="200" width="300" />';
|
|
11
|
+
const result = replaceImagesWithPublicUrl(html);
|
|
12
|
+
expect(result).toBe('<img src="https://picsum.photos/seed/0/600/400" height="200" width="300" />');
|
|
13
|
+
});
|
|
14
|
+
it("handles attributes before width and height", () => {
|
|
15
|
+
const html = '<img alt="photo" src="original.jpg" width="100" height="50" />';
|
|
16
|
+
const result = replaceImagesWithPublicUrl(html);
|
|
17
|
+
expect(result).toBe('<img alt="photo" src="https://picsum.photos/seed/0/200/100" width="100" height="50" />');
|
|
18
|
+
});
|
|
19
|
+
it("handles attributes between width and height", () => {
|
|
20
|
+
const html = '<img src="original.jpg" width="100" alt="photo" height="50" />';
|
|
21
|
+
const result = replaceImagesWithPublicUrl(html);
|
|
22
|
+
expect(result).toBe('<img src="https://picsum.photos/seed/0/200/100" width="100" alt="photo" height="50" />');
|
|
23
|
+
});
|
|
24
|
+
it("handles attributes after width and height", () => {
|
|
25
|
+
const html = '<img src="original.jpg" width="100" height="50" alt="photo" />';
|
|
26
|
+
const result = replaceImagesWithPublicUrl(html);
|
|
27
|
+
expect(result).toBe('<img src="https://picsum.photos/seed/0/200/100" width="100" height="50" alt="photo" />');
|
|
28
|
+
});
|
|
29
|
+
it("leaves img tags without width untouched", () => {
|
|
30
|
+
const html = '<img src="original.jpg" height="200" />';
|
|
31
|
+
const result = replaceImagesWithPublicUrl(html);
|
|
32
|
+
expect(result).toBe('<img src="original.jpg" height="200" />');
|
|
33
|
+
});
|
|
34
|
+
it("leaves img tags without height untouched", () => {
|
|
35
|
+
const html = '<img src="original.jpg" width="300" />';
|
|
36
|
+
const result = replaceImagesWithPublicUrl(html);
|
|
37
|
+
expect(result).toBe('<img src="original.jpg" width="300" />');
|
|
38
|
+
});
|
|
39
|
+
it("increments seed for each image", () => {
|
|
40
|
+
const html = '<img src="a.jpg" width="100" height="100" /><img src="b.jpg" width="200" height="150" />';
|
|
41
|
+
const result = replaceImagesWithPublicUrl(html);
|
|
42
|
+
expect(result).toBe('<img src="https://picsum.photos/seed/0/200/200" width="100" height="100" />' +
|
|
43
|
+
'<img src="https://picsum.photos/seed/1/400/300" width="200" height="150" />');
|
|
44
|
+
});
|
|
45
|
+
it("returns unchanged html when there are no img tags", () => {
|
|
46
|
+
const html = "<p>Hello world</p>";
|
|
47
|
+
const result = replaceImagesWithPublicUrl(html);
|
|
48
|
+
expect(result).toBe("<p>Hello world</p>");
|
|
49
|
+
});
|
|
50
|
+
it("handles non-self-closing img tags", () => {
|
|
51
|
+
const html = '<img src="original.jpg" width="100" height="50">';
|
|
52
|
+
const result = replaceImagesWithPublicUrl(html);
|
|
53
|
+
expect(result).toBe('<img src="https://picsum.photos/seed/0/200/100" width="100" height="50">');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { MjmlStyle } from "@faire/mjml-react";
|
|
3
|
+
import { useTheme } from "../theme/ThemeProvider.js";
|
|
4
|
+
import { getRegisteredStyles } from "./registerStyles.js";
|
|
5
|
+
/**
|
|
6
|
+
* Internal component that iterates the styles registry and renders one
|
|
7
|
+
* `<MjmlStyle>` element per entry.
|
|
8
|
+
*/
|
|
9
|
+
export function Styles() {
|
|
10
|
+
const theme = useTheme();
|
|
11
|
+
const entries = Array.from(getRegisteredStyles().values());
|
|
12
|
+
return (_jsx(_Fragment, { children: entries.map((entry, index) => {
|
|
13
|
+
const cssString = typeof entry.styles === "function" ? entry.styles(theme) : entry.styles;
|
|
14
|
+
return (_jsx(MjmlStyle, { ...entry.mjmlStyleProps, children: cssString }, index));
|
|
15
|
+
}) }));
|
|
16
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { IMjmlStyleProps } from "@faire/mjml-react";
|
|
2
|
+
import type { Theme } from "../theme/themeTypes.js";
|
|
3
|
+
import type { css } from "../utils/css.js";
|
|
4
|
+
type StylesPayload = ReturnType<typeof css> | ((theme: Theme) => ReturnType<typeof css>);
|
|
5
|
+
type MjmlStyleOptions = Partial<Omit<IMjmlStyleProps, "children">>;
|
|
6
|
+
interface StyleRegistryEntry {
|
|
7
|
+
styles: StylesPayload;
|
|
8
|
+
mjmlStyleProps?: MjmlStyleOptions;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Registers CSS styles that will be rendered in the email head as `<mj-style>` elements.
|
|
12
|
+
*
|
|
13
|
+
* Styles can be static CSS strings (via the `css` tagged template) or functions that
|
|
14
|
+
* receive the theme and return a CSS string.
|
|
15
|
+
*/
|
|
16
|
+
export declare function registerStyles(styles: StylesPayload, mjmlStyleProps?: MjmlStyleOptions): void;
|
|
17
|
+
/** Returns all registered style entries. */
|
|
18
|
+
export declare function getRegisteredStyles(): ReadonlyMap<StylesPayload, StyleRegistryEntry>;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const registry = new Map();
|
|
2
|
+
/**
|
|
3
|
+
* Registers CSS styles that will be rendered in the email head as `<mj-style>` elements.
|
|
4
|
+
*
|
|
5
|
+
* Styles can be static CSS strings (via the `css` tagged template) or functions that
|
|
6
|
+
* receive the theme and return a CSS string.
|
|
7
|
+
*/
|
|
8
|
+
export function registerStyles(styles, mjmlStyleProps) {
|
|
9
|
+
registry.set(styles, { styles, mjmlStyleProps });
|
|
10
|
+
}
|
|
11
|
+
/** Returns all registered style entries. */
|
|
12
|
+
export function getRegisteredStyles() {
|
|
13
|
+
return registry;
|
|
14
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type PropsWithChildren, type ReactNode } from "react";
|
|
2
|
+
import type { Theme } from "./themeTypes.js";
|
|
3
|
+
export declare function ThemeProvider({ theme, children }: PropsWithChildren<{
|
|
4
|
+
theme: Theme;
|
|
5
|
+
}>): ReactNode;
|
|
6
|
+
export declare function useTheme(): Theme;
|
|
7
|
+
/** Returns the current theme or `null` if no `ThemeProvider` is present. Internal use only. */
|
|
8
|
+
export declare function useOptionalTheme(): Theme | null;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useContext } from "react";
|
|
3
|
+
const ThemeContext = createContext(null);
|
|
4
|
+
export function ThemeProvider({ theme, children }) {
|
|
5
|
+
return _jsx(ThemeContext.Provider, { value: theme, children: children });
|
|
6
|
+
}
|
|
7
|
+
export function useTheme() {
|
|
8
|
+
const theme = useContext(ThemeContext);
|
|
9
|
+
if (theme === null) {
|
|
10
|
+
throw new Error("useTheme must be used within a ThemeProvider (or MjmlMailRoot).");
|
|
11
|
+
}
|
|
12
|
+
return theme;
|
|
13
|
+
}
|
|
14
|
+
/** Returns the current theme or `null` if no `ThemeProvider` is present. Internal use only. */
|
|
15
|
+
export function useOptionalTheme() {
|
|
16
|
+
return useContext(ThemeContext);
|
|
17
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { ThemeProvider } from "../ThemeProvider.js";
|
|
3
|
+
type Story = StoryObj<typeof ThemeProvider>;
|
|
4
|
+
declare const config: Meta<typeof ThemeProvider>;
|
|
5
|
+
export default config;
|
|
6
|
+
export declare const Basic: Story;
|
|
7
|
+
export declare const CustomTheme: Story;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { MjmlColumn, MjmlText } from "@faire/mjml-react";
|
|
3
|
+
import { MjmlSection } from "../../components/section/MjmlSection.js";
|
|
4
|
+
import { createBreakpoint } from "../createBreakpoint.js";
|
|
5
|
+
import { createTheme } from "../createTheme.js";
|
|
6
|
+
import { ThemeProvider } from "../ThemeProvider.js";
|
|
7
|
+
const config = {
|
|
8
|
+
title: "Components/ThemeProvider",
|
|
9
|
+
component: ThemeProvider,
|
|
10
|
+
tags: ["autodocs"],
|
|
11
|
+
};
|
|
12
|
+
export default config;
|
|
13
|
+
export const Basic = {
|
|
14
|
+
render: () => (_jsx(MjmlSection, { children: _jsx(MjmlColumn, { children: _jsx(MjmlText, { children: "Using the default theme from the decorator." }) }) })),
|
|
15
|
+
};
|
|
16
|
+
const narrowTheme = createTheme({
|
|
17
|
+
sizes: { bodyWidth: 400 },
|
|
18
|
+
breakpoints: { mobile: createBreakpoint(360) },
|
|
19
|
+
});
|
|
20
|
+
export const CustomTheme = {
|
|
21
|
+
parameters: { theme: narrowTheme },
|
|
22
|
+
render: () => (_jsx(MjmlSection, { children: _jsx(MjmlColumn, { children: _jsx(MjmlText, { children: "This email uses a 400px body width and 360px mobile breakpoint." }) }) })),
|
|
23
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ThemeBreakpoint } from "./themeTypes.js";
|
|
2
|
+
/**
|
|
3
|
+
* Constructs a `ThemeBreakpoint` for use in `createTheme` overrides or
|
|
4
|
+
* `ThemeBreakpoints` module augmentation. Guarantees the `belowMediaQuery`
|
|
5
|
+
* string is always formatted correctly.
|
|
6
|
+
*/
|
|
7
|
+
export declare function createBreakpoint(value: number): ThemeBreakpoint;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Constructs a `ThemeBreakpoint` for use in `createTheme` overrides or
|
|
3
|
+
* `ThemeBreakpoints` module augmentation. Guarantees the `belowMediaQuery`
|
|
4
|
+
* string is always formatted correctly.
|
|
5
|
+
*/
|
|
6
|
+
export function createBreakpoint(value) {
|
|
7
|
+
return {
|
|
8
|
+
value,
|
|
9
|
+
belowMediaQuery: `@media (max-width: ${value - 1}px)`,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Theme, ThemeBreakpoints, ThemeSizes } from "./themeTypes.js";
|
|
2
|
+
type CreateThemeOverrides = {
|
|
3
|
+
sizes?: Partial<ThemeSizes>;
|
|
4
|
+
breakpoints?: Partial<ThemeBreakpoints>;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Creates a complete theme by merging optional overrides onto the default
|
|
8
|
+
* theme values.
|
|
9
|
+
*
|
|
10
|
+
* Breakpoint overrides must be `ThemeBreakpoint` objects constructed via
|
|
11
|
+
* `createBreakpoint`. Arbitrary breakpoint keys from `ThemeBreakpoints`
|
|
12
|
+
* module augmentation are supported and flow through to the result.
|
|
13
|
+
*
|
|
14
|
+
* `breakpoints.default` is always auto-derived from `sizes.bodyWidth` unless
|
|
15
|
+
* explicitly overridden.
|
|
16
|
+
*/
|
|
17
|
+
export declare function createTheme(overrides?: CreateThemeOverrides): Theme;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createBreakpoint } from "./createBreakpoint.js";
|
|
2
|
+
import { defaultTheme } from "./defaultTheme.js";
|
|
3
|
+
/**
|
|
4
|
+
* Creates a complete theme by merging optional overrides onto the default
|
|
5
|
+
* theme values.
|
|
6
|
+
*
|
|
7
|
+
* Breakpoint overrides must be `ThemeBreakpoint` objects constructed via
|
|
8
|
+
* `createBreakpoint`. Arbitrary breakpoint keys from `ThemeBreakpoints`
|
|
9
|
+
* module augmentation are supported and flow through to the result.
|
|
10
|
+
*
|
|
11
|
+
* `breakpoints.default` is always auto-derived from `sizes.bodyWidth` unless
|
|
12
|
+
* explicitly overridden.
|
|
13
|
+
*/
|
|
14
|
+
export function createTheme(overrides) {
|
|
15
|
+
const resolvedSizes = { ...defaultTheme.sizes, ...overrides?.sizes };
|
|
16
|
+
return {
|
|
17
|
+
sizes: resolvedSizes,
|
|
18
|
+
breakpoints: {
|
|
19
|
+
...defaultTheme.breakpoints,
|
|
20
|
+
default: createBreakpoint(resolvedSizes.bodyWidth),
|
|
21
|
+
...overrides?.breakpoints,
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createTheme } from "./createTheme.js";
|
|
3
|
+
import { getDefaultFromResponsiveValue, getResponsiveOverrides } from "./responsiveValue.js";
|
|
4
|
+
describe("createTheme contentIndentation", () => {
|
|
5
|
+
it("has default contentIndentation with responsive mobile value", () => {
|
|
6
|
+
const theme = createTheme();
|
|
7
|
+
expect(getDefaultFromResponsiveValue(theme.sizes.contentIndentation)).toBe(32);
|
|
8
|
+
expect(getResponsiveOverrides(theme.sizes.contentIndentation)).toContainEqual({ breakpointKey: "mobile", value: 16 });
|
|
9
|
+
});
|
|
10
|
+
it("allows overriding contentIndentation with a plain number", () => {
|
|
11
|
+
const theme = createTheme({ sizes: { contentIndentation: 30 } });
|
|
12
|
+
expect(getDefaultFromResponsiveValue(theme.sizes.contentIndentation)).toBe(30);
|
|
13
|
+
expect(getResponsiveOverrides(theme.sizes.contentIndentation)).toEqual([]);
|
|
14
|
+
});
|
|
15
|
+
it("allows overriding contentIndentation with an object", () => {
|
|
16
|
+
const theme = createTheme({ sizes: { contentIndentation: { default: 30, mobile: 15 } } });
|
|
17
|
+
expect(getDefaultFromResponsiveValue(theme.sizes.contentIndentation)).toBe(30);
|
|
18
|
+
expect(getResponsiveOverrides(theme.sizes.contentIndentation)).toContainEqual({ breakpointKey: "mobile", value: 15 });
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createBreakpoint } from "./createBreakpoint.js";
|
|
2
|
+
export const defaultTheme = {
|
|
3
|
+
sizes: {
|
|
4
|
+
bodyWidth: 600,
|
|
5
|
+
contentIndentation: { default: 32, mobile: 16 },
|
|
6
|
+
},
|
|
7
|
+
breakpoints: {
|
|
8
|
+
default: createBreakpoint(600),
|
|
9
|
+
mobile: createBreakpoint(420),
|
|
10
|
+
},
|
|
11
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ThemeBreakpoints } from "./themeTypes.js";
|
|
2
|
+
/**
|
|
3
|
+
* A value that can vary per breakpoint. A plain `T` is shorthand for
|
|
4
|
+
* `{ default: T }` (inline only, no responsive overrides).
|
|
5
|
+
*
|
|
6
|
+
* The object form requires a `default` key and optional keys matching
|
|
7
|
+
* `ThemeBreakpoints` (including keys added via module augmentation).
|
|
8
|
+
*/
|
|
9
|
+
export type ResponsiveValue<T = number> = T | (Partial<Record<keyof ThemeBreakpoints, T>> & {
|
|
10
|
+
default: T;
|
|
11
|
+
});
|
|
12
|
+
/**
|
|
13
|
+
* Returns the default (inline) value from a `ResponsiveValue`.
|
|
14
|
+
* For a plain value, returns it directly. For an object, returns the `default` property.
|
|
15
|
+
*/
|
|
16
|
+
export declare function getDefaultFromResponsiveValue<T = number>(value: ResponsiveValue<T>): T;
|
|
17
|
+
/**
|
|
18
|
+
* Returns the non-default breakpoint overrides from a `ResponsiveValue`.
|
|
19
|
+
* For a plain value, returns an empty array. For an object, returns one
|
|
20
|
+
* entry per breakpoint key (excluding `default`).
|
|
21
|
+
*/
|
|
22
|
+
export declare function getResponsiveOverrides<T = number>(value: ResponsiveValue<T>): Array<{
|
|
23
|
+
breakpointKey: keyof ThemeBreakpoints;
|
|
24
|
+
value: T;
|
|
25
|
+
}>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
function isResponsiveObject(value) {
|
|
2
|
+
return typeof value === "object" && value !== null && "default" in value;
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* Returns the default (inline) value from a `ResponsiveValue`.
|
|
6
|
+
* For a plain value, returns it directly. For an object, returns the `default` property.
|
|
7
|
+
*/
|
|
8
|
+
export function getDefaultFromResponsiveValue(value) {
|
|
9
|
+
return isResponsiveObject(value) ? value.default : value;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Returns the non-default breakpoint overrides from a `ResponsiveValue`.
|
|
13
|
+
* For a plain value, returns an empty array. For an object, returns one
|
|
14
|
+
* entry per breakpoint key (excluding `default`).
|
|
15
|
+
*/
|
|
16
|
+
export function getResponsiveOverrides(value) {
|
|
17
|
+
if (!isResponsiveObject(value)) {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
return Object.entries(value)
|
|
21
|
+
.filter(([key]) => key !== "default")
|
|
22
|
+
.map(([key, val]) => ({ breakpointKey: key, value: val }));
|
|
23
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { getDefaultFromResponsiveValue, getResponsiveOverrides } from "./responsiveValue.js";
|
|
3
|
+
describe("getDefaultFromResponsiveValue", () => {
|
|
4
|
+
it("returns the value when given a plain number", () => {
|
|
5
|
+
expect(getDefaultFromResponsiveValue(20)).toBe(20);
|
|
6
|
+
});
|
|
7
|
+
it("returns the default property from an object", () => {
|
|
8
|
+
expect(getDefaultFromResponsiveValue({ default: 20, mobile: 10 })).toBe(20);
|
|
9
|
+
});
|
|
10
|
+
it("returns the default property when only default is present", () => {
|
|
11
|
+
expect(getDefaultFromResponsiveValue({ default: 42 })).toBe(42);
|
|
12
|
+
});
|
|
13
|
+
it("works with string type", () => {
|
|
14
|
+
expect(getDefaultFromResponsiveValue({ default: "24px", mobile: "20px" })).toBe("24px");
|
|
15
|
+
});
|
|
16
|
+
it("returns a plain string value", () => {
|
|
17
|
+
expect(getDefaultFromResponsiveValue("24px")).toBe("24px");
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
describe("getResponsiveOverrides", () => {
|
|
21
|
+
it("returns an empty array for a plain number", () => {
|
|
22
|
+
expect(getResponsiveOverrides(20)).toEqual([]);
|
|
23
|
+
});
|
|
24
|
+
it("returns an empty array for an object with only default", () => {
|
|
25
|
+
expect(getResponsiveOverrides({ default: 42 })).toEqual([]);
|
|
26
|
+
});
|
|
27
|
+
it("returns overrides for non-default keys", () => {
|
|
28
|
+
const result = getResponsiveOverrides({ default: 20, mobile: 10 });
|
|
29
|
+
expect(result).toEqual([{ breakpointKey: "mobile", value: 10 }]);
|
|
30
|
+
});
|
|
31
|
+
it("returns multiple overrides for multiple breakpoint keys", () => {
|
|
32
|
+
const value = { default: 30, mobile: 10 };
|
|
33
|
+
const result = getResponsiveOverrides(value);
|
|
34
|
+
expect(result).toContainEqual({ breakpointKey: "mobile", value: 10 });
|
|
35
|
+
expect(result).not.toContainEqual(expect.objectContaining({ breakpointKey: "default" }));
|
|
36
|
+
});
|
|
37
|
+
it("never includes the default key in overrides", () => {
|
|
38
|
+
const result = getResponsiveOverrides({ default: 20, mobile: 10 });
|
|
39
|
+
expect(result.every((o) => o.breakpointKey !== "default")).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
it("works with string type", () => {
|
|
42
|
+
const result = getResponsiveOverrides({ default: "24px", mobile: "20px" });
|
|
43
|
+
expect(result).toEqual([{ breakpointKey: "mobile", value: "20px" }]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ResponsiveValue } from "./responsiveValue.js";
|
|
2
|
+
/**
|
|
3
|
+
* A resolved breakpoint with its numeric value and a ready-to-use media query
|
|
4
|
+
* string that targets viewports narrower than this breakpoint.
|
|
5
|
+
*/
|
|
6
|
+
export interface ThemeBreakpoint {
|
|
7
|
+
/** The breakpoint width in pixels. */
|
|
8
|
+
value: number;
|
|
9
|
+
/** A CSS media query matching viewports below this breakpoint, e.g. `"@media (max-width: 599px)"`. */
|
|
10
|
+
belowMediaQuery: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Responsive breakpoints used by the email layout.
|
|
14
|
+
*
|
|
15
|
+
* The `default` breakpoint typically matches the body width, while `mobile`
|
|
16
|
+
* defines the narrow viewport threshold.
|
|
17
|
+
*/
|
|
18
|
+
export interface ThemeBreakpoints {
|
|
19
|
+
/** The default breakpoint, usually equal to `sizes.bodyWidth`. */
|
|
20
|
+
default: ThemeBreakpoint;
|
|
21
|
+
/**
|
|
22
|
+
* The mobile breakpoint — the viewport width below which the layout is
|
|
23
|
+
* considered "mobile".
|
|
24
|
+
*
|
|
25
|
+
* When used with `MjmlMailRoot`, this value also controls the MJML
|
|
26
|
+
* responsive breakpoint (`<mj-breakpoint>`), which determines the viewport
|
|
27
|
+
* width at which columns stack vertically.
|
|
28
|
+
*/
|
|
29
|
+
mobile: ThemeBreakpoint;
|
|
30
|
+
}
|
|
31
|
+
/** Numeric size tokens for the email layout. */
|
|
32
|
+
export interface ThemeSizes {
|
|
33
|
+
/** The width of the email body in pixels. */
|
|
34
|
+
bodyWidth: number;
|
|
35
|
+
/** Content indentation (left/right padding) in pixels, supporting per-breakpoint values. */
|
|
36
|
+
contentIndentation: ResponsiveValue;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The root theme object that holds all design tokens for `@comet/mail-react`.
|
|
40
|
+
*
|
|
41
|
+
* Every sub-interface is declared as an `interface` so consumers can
|
|
42
|
+
* extend the theme at any level via TypeScript module augmentation.
|
|
43
|
+
*/
|
|
44
|
+
export interface Theme {
|
|
45
|
+
sizes: ThemeSizes;
|
|
46
|
+
breakpoints: ThemeBreakpoints;
|
|
47
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { css } from "./css.js";
|
|
3
|
+
describe("css tagged-template helper", () => {
|
|
4
|
+
it("returns static template text exactly", () => {
|
|
5
|
+
const result = css `
|
|
6
|
+
color: red;
|
|
7
|
+
font-size: 16px;
|
|
8
|
+
`;
|
|
9
|
+
expect(result).toBe(`
|
|
10
|
+
color: red;
|
|
11
|
+
font-size: 16px;
|
|
12
|
+
`);
|
|
13
|
+
});
|
|
14
|
+
it("resolves interpolated values correctly", () => {
|
|
15
|
+
const color = "blue";
|
|
16
|
+
const size = 14;
|
|
17
|
+
const result = css `
|
|
18
|
+
color: ${color};
|
|
19
|
+
font-size: ${size}px;
|
|
20
|
+
`;
|
|
21
|
+
expect(result).toBe(`
|
|
22
|
+
color: blue;
|
|
23
|
+
font-size: 14px;
|
|
24
|
+
`);
|
|
25
|
+
});
|
|
26
|
+
});
|
package/package.json
CHANGED
|
@@ -1,35 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@comet/mail-react",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "9.0.0-beta.1",
|
|
4
4
|
"description": "Utilities for building HTML emails with React",
|
|
5
5
|
"license": "BSD-2-Clause",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"exports": {
|
|
8
|
-
".":
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./client": "./lib/client/index.js",
|
|
10
|
+
"./server": "./lib/server/index.js",
|
|
11
|
+
"./storybook": "./lib/storybook/index.js"
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
14
|
"lib/*"
|
|
15
15
|
],
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@faire/mjml-react": "^3.5.3"
|
|
17
|
+
"@faire/mjml-react": "^3.5.3",
|
|
18
|
+
"clsx": "^2.1.1",
|
|
19
|
+
"mjml": "^4.18.0",
|
|
20
|
+
"mjml-browser": "^4.18.0"
|
|
18
21
|
},
|
|
19
22
|
"devDependencies": {
|
|
20
|
-
"@
|
|
23
|
+
"@fission-ai/openspec": "^1.2.0",
|
|
24
|
+
"@storybook/addon-docs": "^10.3.1",
|
|
25
|
+
"@storybook/react-vite": "^10.3.1",
|
|
26
|
+
"@types/mjml": "^4.7.4",
|
|
27
|
+
"@types/mjml-browser": "^4.15.0",
|
|
28
|
+
"@types/react": "^19.2.10",
|
|
29
|
+
"@types/react-dom": "^19.2.3",
|
|
21
30
|
"chokidar-cli": "^3.0.0",
|
|
22
31
|
"eslint": "^9.39.2",
|
|
23
32
|
"npm-run-all2": "^8.0.4",
|
|
24
33
|
"prettier": "^3.6.2",
|
|
25
|
-
"react": "^
|
|
34
|
+
"react": "^19.2.4",
|
|
35
|
+
"react-dom": "^19.2.4",
|
|
26
36
|
"rimraf": "^6.1.2",
|
|
37
|
+
"storybook": "^10.3.1",
|
|
27
38
|
"typescript": "^5.9.3",
|
|
28
|
-
"
|
|
29
|
-
"@comet/
|
|
39
|
+
"vitest": "^4.0.16",
|
|
40
|
+
"@comet/cli": "9.0.0-beta.1",
|
|
41
|
+
"@comet/eslint-config": "9.0.0-beta.1"
|
|
30
42
|
},
|
|
31
43
|
"peerDependencies": {
|
|
32
|
-
"react": "^18.0.0"
|
|
44
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
45
|
+
"storybook": ">=10.0.0"
|
|
46
|
+
},
|
|
47
|
+
"peerDependenciesMeta": {
|
|
48
|
+
"storybook": {
|
|
49
|
+
"optional": true
|
|
50
|
+
}
|
|
33
51
|
},
|
|
34
52
|
"engines": {
|
|
35
53
|
"node": ">=22.0.0"
|
|
@@ -40,6 +58,7 @@
|
|
|
40
58
|
},
|
|
41
59
|
"scripts": {
|
|
42
60
|
"build": "pnpm run clean && pnpm run generate-block-types && tsc",
|
|
61
|
+
"build-storybook": "storybook build",
|
|
43
62
|
"clean": "rimraf lib 'src/**/*.generated.ts'",
|
|
44
63
|
"dev": "pnpm run clean && pnpm generate-block-types && tsc --watch",
|
|
45
64
|
"generate-block-types": "comet generate-block-types",
|
|
@@ -48,6 +67,10 @@
|
|
|
48
67
|
"lint:ci": "pnpm run lint",
|
|
49
68
|
"lint:eslint": "eslint --max-warnings 0 src/ '**/*.json' --no-warn-ignored",
|
|
50
69
|
"lint:prettier": "pnpm exec prettier --check '*.{ts,js,json,md,yml,yaml}'",
|
|
51
|
-
"lint:tsc": "tsc"
|
|
70
|
+
"lint:tsc": "tsc",
|
|
71
|
+
"openspec:install-skills": "openspec init --tools github-copilot,cursor,claude",
|
|
72
|
+
"storybook": "storybook dev -p 6066 --no-open",
|
|
73
|
+
"test": "vitest --run",
|
|
74
|
+
"test:watch": "vitest --watch"
|
|
52
75
|
}
|
|
53
76
|
}
|