From 66998fe121a6258097c5583665fb8194cc80b587 Mon Sep 17 00:00:00 2001 From: Lucas Jiang <2862605953@qq.com> Date: Tue, 10 Feb 2026 23:36:37 +0800 Subject: [PATCH 1/7] feat(mjml): add package scaffold and mjml-react types - package.json with makage, scripts, deps/peerDeps - tsconfig + tsconfig.esm, jest.config.js - types/mjml-react.d.ts for untyped dependency Co-authored-by: Cursor --- packages/mjml/jest.config.js | 18 +++++++++++ packages/mjml/package.json | 46 +++++++++++++++++++++++++++++ packages/mjml/tsconfig.esm.json | 7 +++++ packages/mjml/tsconfig.json | 11 +++++++ packages/mjml/types/mjml-react.d.ts | 19 ++++++++++++ 5 files changed, 101 insertions(+) create mode 100644 packages/mjml/jest.config.js create mode 100644 packages/mjml/package.json create mode 100644 packages/mjml/tsconfig.esm.json create mode 100644 packages/mjml/tsconfig.json create mode 100644 packages/mjml/types/mjml-react.d.ts diff --git a/packages/mjml/jest.config.js b/packages/mjml/jest.config.js new file mode 100644 index 000000000..e301e43aa --- /dev/null +++ b/packages/mjml/jest.config.js @@ -0,0 +1,18 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + babelConfig: false, + tsconfig: 'tsconfig.json' + } + ] + }, + transformIgnorePatterns: ['/node_modules/*'], + testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + modulePathIgnorePatterns: ['dist/*'] +}; diff --git a/packages/mjml/package.json b/packages/mjml/package.json new file mode 100644 index 000000000..f735964e0 --- /dev/null +++ b/packages/mjml/package.json @@ -0,0 +1,46 @@ +{ + "name": "@constructive-io/mjml", + "version": "1.0.0", + "author": "Constructive ", + "description": "MJML email HTML templates for Constructive", + "main": "index.js", + "module": "esm/index.js", + "types": "index.d.ts", + "homepage": "https://github.com/constructive-io/constructive", + "license": "MIT", + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "repository": { + "type": "git", + "url": "https://github.com/constructive-io/constructive" + }, + "bugs": { + "url": "https://github.com/constructive-io/constructive/issues" + }, + "scripts": { + "clean": "makage clean", + "prepack": "npm run build", + "build": "makage build", + "build:dev": "makage build --dev", + "lint": "eslint . --fix", + "test": "jest --passWithNoTests", + "test:watch": "jest --watch" + }, + "dependencies": { + "mjml-react": "^1.0.59", + "react": "^18.0.0" + }, + "devDependencies": { + "@types/jest": "^30.0.0", + "@types/react": "^18.0.0", + "makage": "^0.1.12", + "react-dom": "^18.0.0" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + }, + "keywords": ["mjml", "email", "html", "constructive", "templates"] +} diff --git a/packages/mjml/tsconfig.esm.json b/packages/mjml/tsconfig.esm.json new file mode 100644 index 000000000..5aaa4bcd0 --- /dev/null +++ b/packages/mjml/tsconfig.esm.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "outDir": "dist/esm" + } +} diff --git a/packages/mjml/tsconfig.json b/packages/mjml/tsconfig.json new file mode 100644 index 000000000..67ba353fd --- /dev/null +++ b/packages/mjml/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "jsx": "react-jsx", + "typeRoots": ["./types", "./node_modules/@types"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["dist", "node_modules", "**/*.spec.*", "**/*.test.*"] +} diff --git a/packages/mjml/types/mjml-react.d.ts b/packages/mjml/types/mjml-react.d.ts new file mode 100644 index 000000000..aab76603c --- /dev/null +++ b/packages/mjml/types/mjml-react.d.ts @@ -0,0 +1,19 @@ +declare module 'mjml-react' { + import type { ReactNode } from 'react'; + + export function render( + element: ReactNode, + options?: { validationLevel?: 'strict' | 'soft' | 'skip' } + ): { html: string; errors: Array<{ message: string }> }; + + export const Mjml: React.FC<{ children?: ReactNode }>; + export const MjmlHead: React.FC<{ children?: ReactNode }>; + export const MjmlTitle: React.FC<{ children?: ReactNode }>; + export const MjmlPreview: React.FC<{ children?: ReactNode }>; + export const MjmlBody: React.FC<{ backgroundColor?: string; children?: ReactNode }>; + export const MjmlSection: React.FC & { children?: ReactNode }>; + export const MjmlColumn: React.FC & { children?: ReactNode }>; + export const MjmlText: React.FC & { children?: ReactNode }>; + export const MjmlButton: React.FC & { children?: ReactNode }>; + export const MjmlImage: React.FC>; +} From c39b330200ec525e70dd0fb5306db3feededfbd4 Mon Sep 17 00:00:00 2001 From: Lucas Jiang <2862605953@qq.com> Date: Tue, 10 Feb 2026 23:36:43 +0800 Subject: [PATCH 2/7] feat(mjml): add MJML components and generate() - types.ts: GenerateOptions, Header/Footer/ButtonWithMessage props - Header, Footer, ButtonWithMessage TSX components - template.tsx: buildEmailElement for full layout - index.ts: generate(), export components and types Co-authored-by: Cursor --- .../mjml/src/components/ButtonWithMessage.tsx | 81 +++++++++++++++++++ packages/mjml/src/components/Footer.tsx | 60 ++++++++++++++ packages/mjml/src/components/Header.tsx | 43 ++++++++++ packages/mjml/src/index.ts | 25 ++++++ packages/mjml/src/template.tsx | 61 ++++++++++++++ packages/mjml/src/types.ts | 54 +++++++++++++ 6 files changed, 324 insertions(+) create mode 100644 packages/mjml/src/components/ButtonWithMessage.tsx create mode 100644 packages/mjml/src/components/Footer.tsx create mode 100644 packages/mjml/src/components/Header.tsx create mode 100644 packages/mjml/src/index.ts create mode 100644 packages/mjml/src/template.tsx create mode 100644 packages/mjml/src/types.ts diff --git a/packages/mjml/src/components/ButtonWithMessage.tsx b/packages/mjml/src/components/ButtonWithMessage.tsx new file mode 100644 index 000000000..f24243350 --- /dev/null +++ b/packages/mjml/src/components/ButtonWithMessage.tsx @@ -0,0 +1,81 @@ +import { + MjmlButton as Button, + MjmlColumn as Column, + MjmlSection as Section, + MjmlText as Text +} from 'mjml-react'; + +import type { ButtonWithMessageProps } from '../types'; + +const fontFamily = + 'Ubuntu, Helvetica, Arial, sans-serif, Helvetica, Arial, sans-serif'; + +export function ButtonWithMessage({ + link, + message, + subMessage, + linkText, + bodyBgColor = 'white', + bodyTextColor = '#414141', + buttonBgColor = '#414141', + buttonTextColor = 'white' +}: ButtonWithMessageProps = {}) { + return ( + <> +
+ + +

+ + + {message} + + +

+ {subMessage != null ? subMessage : null} +
+ + +

or copy and paste this link into your browser:

+

{link}

+
+
+
+ + ); +} diff --git a/packages/mjml/src/components/Footer.tsx b/packages/mjml/src/components/Footer.tsx new file mode 100644 index 000000000..150087657 --- /dev/null +++ b/packages/mjml/src/components/Footer.tsx @@ -0,0 +1,60 @@ +import { + MjmlColumn as Column, + MjmlSection as Section, + MjmlText as Text +} from 'mjml-react'; + +import type { FooterProps } from '../types'; + +export function Footer({ + companyName = 'Acme, Inc.', + supportEmail = 'support@example.com' +}: FooterProps = {}) { + return ( + <> +
+ + +

Any questions, comments, concerns?

+

+ Contact our support staff at{' '} + + {supportEmail} + +

+
+
+
+
+ + +

{companyName}

+
+
+
+ + ); +} diff --git a/packages/mjml/src/components/Header.tsx b/packages/mjml/src/components/Header.tsx new file mode 100644 index 000000000..65f2cbc25 --- /dev/null +++ b/packages/mjml/src/components/Header.tsx @@ -0,0 +1,43 @@ +import { + MjmlColumn as Column, + MjmlImage as Image, + MjmlSection as Section +} from 'mjml-react'; + +import type { HeaderImageProps, HeaderProps } from '../types'; + +const defaultHeaderImageProps: HeaderImageProps = { + alt: 'logo', + align: 'center', + border: 'none', + width: '182px', + paddingLeft: '0px', + paddingRight: '0px', + paddingBottom: '0px', + paddingTop: '0' +}; + +export function Header({ + website = 'https://mjml.io', + logo = 'https://mjml.io/assets/img/logo-white-small.png', + headerBgColor = 'white', + headerImageProps = defaultHeaderImageProps +}: HeaderProps = {}) { + return ( + <> +
+ + +
+
+ + + +
+ + ); +} diff --git a/packages/mjml/src/index.ts b/packages/mjml/src/index.ts new file mode 100644 index 000000000..6d185793f --- /dev/null +++ b/packages/mjml/src/index.ts @@ -0,0 +1,25 @@ +export { ButtonWithMessage } from './components/ButtonWithMessage'; +export { Footer } from './components/Footer'; +export { Header } from './components/Header'; +export type { + ButtonWithMessageProps, + FooterProps, + GenerateOptions, + HeaderImageProps, + HeaderProps +} from './types'; + +import { render } from 'mjml-react'; + +import { buildEmailElement } from './template'; +import type { GenerateOptions } from './types'; + +/** + * Generate email HTML from MJML template with header, message + button, and footer. + */ +export function generate(options: GenerateOptions = {}): string { + const { html } = render(buildEmailElement(options), { + validationLevel: 'soft' + }); + return html; +} diff --git a/packages/mjml/src/template.tsx b/packages/mjml/src/template.tsx new file mode 100644 index 000000000..2336bfdd6 --- /dev/null +++ b/packages/mjml/src/template.tsx @@ -0,0 +1,61 @@ +import { + Mjml, + MjmlBody as Body, + MjmlHead as Head, + MjmlPreview as Preview, + MjmlTitle as Title +} from 'mjml-react'; + +import { ButtonWithMessage } from './components/ButtonWithMessage'; +import { Footer } from './components/Footer'; +import { Header } from './components/Header'; +import type { GenerateOptions } from './types'; + +export function buildEmailElement(options: GenerateOptions) { + const { + title, + bodyBgColor, + messageBgColor, + messageTextColor, + messageButtonBgColor, + messageButtonTextColor, + companyName, + supportEmail, + website, + logo, + headerBgColor, + headerImageProps, + link, + linkText, + message, + subMessage + } = options; + + return ( + + + {title} + {title} + + +
+ +