From dfc6659e4fcb52694f1b6b52661c7fa84990e7f8 Mon Sep 17 00:00:00 2001 From: David Longworth Date: Mon, 1 Jun 2026 13:49:34 +0100 Subject: [PATCH 01/30] Add email docs and stacks-email templates --- .prettierignore | 2 +- package-lock.json | 1843 +++++++++++++++-- packages/stacks-docs/README.md | 7 + packages/stacks-docs/_redirects | 3 - packages/stacks-docs/package.json | 1 + packages/stacks-docs/src/app.css | 14 +- .../src/components/EmailOptionsTable.svelte | 88 + .../src/components/StacksEmailEmbed.svelte | 401 ++++ .../docs/public/email/components/button.md | 27 + .../src/docs/public/email/components/cards.md | 26 + .../email/components/component-button.svg | 1 + .../email/components/component-cards.svg | 1 + .../email/components/component-dividers.svg | 1 + .../email/components/component-footer.svg | 1 + .../email/components/component-graphic.svg | 1 + .../email/components/component-header.svg | 1 + .../email/components/component-headline.svg | 8 + .../email/components/component-link.svg | 1 + .../email/components/component-preview.svg | 1 + .../email/components/component-spacers.svg | 1 + .../email/components/component-subtitle.svg | 1 + .../email/components/component-text.svg | 1 + .../email/components/component-title.svg | 1 + .../docs/public/email/components/dividers.md | 26 + .../docs/public/email/components/footer.md | 33 + .../docs/public/email/components/graphic.md | 27 + .../docs/public/email/components/header.md | 35 + .../docs/public/email/components/headline.md | 25 + .../docs/public/email/components/preview.md | 26 + .../docs/public/email/components/spacers.md | 23 + .../docs/public/email/components/subtitle.md | 26 + .../src/docs/public/email/components/text.md | 24 + .../src/docs/public/email/components/title.md | 23 + .../src/docs/public/email/overview.md | 279 +++ .../templates/email-template-newsletter.png | Bin 0 -> 42877 bytes .../templates/email-template-promotional.png | Bin 0 -> 34016 bytes .../email-template-transactional.png | Bin 0 -> 50358 bytes .../docs/public/email/templates/newsletter.md | 128 ++ .../public/email/templates/promotional.md | 128 ++ .../public/email/templates/transactional.md | 95 + .../[[section]]/[subsection]/+page.svelte | 14 +- .../src/routes/api/email/catalog/+server.ts | 6 + .../src/routes/api/email/compile/+server.ts | 66 + packages/stacks-docs/src/structure.yaml | 69 +- packages/stacks-docs/static/email | 1 + .../static/images/heros/email-authoring.svg | 6 + .../static/images/heros/email-components.svg | 36 + .../static/images/heros/email-overview.svg | 14 + .../static/images/heros/email-types.svg | 1 + .../stacks-docs/static/social/instagram.png | Bin 0 -> 906 bytes .../stacks-docs/static/social/linkedin.png | Bin 0 -> 607 bytes .../stacks-docs/static/social/threads.png | Bin 0 -> 1111 bytes packages/stacks-docs/static/social/x.png | Bin 0 -> 904 bytes .../stacks-docs/static/social/youtube.png | Bin 0 -> 573 bytes .../static/stack-overflow-business-logo.png | Bin 0 -> 6616 bytes .../static/stack-overflow-logo-off-white.png | Bin 0 -> 4461 bytes .../static/stack-overflow-logo.png | Bin 0 -> 5012 bytes packages/stacks-docs/svelte.config.js | 134 +- packages/stacks-email/README.md | 96 + packages/stacks-email/components/button.ts | 150 ++ packages/stacks-email/components/footer.ts | 425 ++++ packages/stacks-email/components/graphic.ts | 220 ++ packages/stacks-email/components/header.ts | 196 ++ packages/stacks-email/components/headline.ts | 192 ++ packages/stacks-email/components/index.ts | 11 + packages/stacks-email/components/preview.ts | 85 + packages/stacks-email/components/section.ts | 51 + packages/stacks-email/components/spacer.ts | 39 + packages/stacks-email/components/spacers.ts | 76 + packages/stacks-email/components/text.ts | 210 ++ packages/stacks-email/components/title.ts | 143 ++ packages/stacks-email/eslint.config.js | 29 + packages/stacks-email/mjml-config.ts | 246 +++ packages/stacks-email/mjml-json.ts | 19 + packages/stacks-email/package.json | 46 + packages/stacks-email/registry.ts | 35 + packages/stacks-email/src/app.css | 1 + packages/stacks-email/src/app.d.ts | 12 + packages/stacks-email/src/app.html | 11 + .../src/components/TemplateSidebar.svelte | 59 + .../src/lib/highlight/highlight.ts | 29 + .../stacks-email/src/lib/pipeline/compile.ts | 165 ++ .../stacks-email/src/lib/pipeline/template.ts | 92 + .../src/lib/pipeline/transform.ts | 12 + .../stacks-email/src/lib/public/catalog.ts | 15 + .../stacks-email/src/lib/public/compile.ts | 63 + .../stacks-email/src/lib/public/components.ts | 165 ++ packages/stacks-email/src/lib/public/index.ts | 53 + .../stacks-email/src/lib/public/templates.ts | 291 +++ .../stacks-email/src/lib/public/validation.ts | 38 + .../stacks-email/src/routes/+layout.svelte | 7 + packages/stacks-email/src/routes/+layout.ts | 1 + .../stacks-email/src/routes/+page.server.ts | 7 + packages/stacks-email/src/routes/+page.svelte | 22 + .../src/routes/api/compile/+server.ts | 231 +++ .../src/routes/emails/[slug]/+page.server.ts | 73 + .../src/routes/emails/[slug]/+page.svelte | 167 ++ .../stacks-email/src/types/json2mjml.d.ts | 4 + .../stacks-email/src/types/stacks-icons.d.ts | 1 + .../static/email/hero/1200x630.png | Bin 0 -> 24010 bytes .../static/email/social/instagram.png | Bin 0 -> 906 bytes .../static/email/social/linkedin.png | Bin 0 -> 607 bytes .../static/email/social/threads.png | Bin 0 -> 1111 bytes .../stacks-email/static/email/social/x.png | Bin 0 -> 904 bytes .../static/email/social/youtube.png | Bin 0 -> 573 bytes .../static/email/spots/SpotLock.png | Bin 0 -> 2398 bytes .../email/stack-overflow-business-logo.png | Bin 0 -> 6616 bytes .../email/stack-overflow-logo-off-white.png | Bin 0 -> 4461 bytes .../static/email/stack-overflow-logo.png | Bin 0 -> 5012 bytes packages/stacks-email/svelte.config.js | 34 + .../stacks-email/templates/transactional.ts | 138 ++ packages/stacks-email/tokens.ts | 161 ++ packages/stacks-email/tsconfig.json | 14 + packages/stacks-email/types.ts | 86 + packages/stacks-email/variants.ts | 44 + packages/stacks-email/vite.config.ts | 6 + 116 files changed, 7709 insertions(+), 239 deletions(-) create mode 100644 packages/stacks-docs/src/components/EmailOptionsTable.svelte create mode 100644 packages/stacks-docs/src/components/StacksEmailEmbed.svelte create mode 100644 packages/stacks-docs/src/docs/public/email/components/button.md create mode 100644 packages/stacks-docs/src/docs/public/email/components/cards.md create mode 100644 packages/stacks-docs/src/docs/public/email/components/component-button.svg create mode 100644 packages/stacks-docs/src/docs/public/email/components/component-cards.svg create mode 100644 packages/stacks-docs/src/docs/public/email/components/component-dividers.svg create mode 100644 packages/stacks-docs/src/docs/public/email/components/component-footer.svg create mode 100644 packages/stacks-docs/src/docs/public/email/components/component-graphic.svg create mode 100644 packages/stacks-docs/src/docs/public/email/components/component-header.svg create mode 100644 packages/stacks-docs/src/docs/public/email/components/component-headline.svg create mode 100644 packages/stacks-docs/src/docs/public/email/components/component-link.svg create mode 100644 packages/stacks-docs/src/docs/public/email/components/component-preview.svg create mode 100644 packages/stacks-docs/src/docs/public/email/components/component-spacers.svg create mode 100644 packages/stacks-docs/src/docs/public/email/components/component-subtitle.svg create mode 100644 packages/stacks-docs/src/docs/public/email/components/component-text.svg create mode 100644 packages/stacks-docs/src/docs/public/email/components/component-title.svg create mode 100644 packages/stacks-docs/src/docs/public/email/components/dividers.md create mode 100644 packages/stacks-docs/src/docs/public/email/components/footer.md create mode 100644 packages/stacks-docs/src/docs/public/email/components/graphic.md create mode 100644 packages/stacks-docs/src/docs/public/email/components/header.md create mode 100644 packages/stacks-docs/src/docs/public/email/components/headline.md create mode 100644 packages/stacks-docs/src/docs/public/email/components/preview.md create mode 100644 packages/stacks-docs/src/docs/public/email/components/spacers.md create mode 100644 packages/stacks-docs/src/docs/public/email/components/subtitle.md create mode 100644 packages/stacks-docs/src/docs/public/email/components/text.md create mode 100644 packages/stacks-docs/src/docs/public/email/components/title.md create mode 100644 packages/stacks-docs/src/docs/public/email/overview.md create mode 100644 packages/stacks-docs/src/docs/public/email/templates/email-template-newsletter.png create mode 100644 packages/stacks-docs/src/docs/public/email/templates/email-template-promotional.png create mode 100644 packages/stacks-docs/src/docs/public/email/templates/email-template-transactional.png create mode 100644 packages/stacks-docs/src/docs/public/email/templates/newsletter.md create mode 100644 packages/stacks-docs/src/docs/public/email/templates/promotional.md create mode 100644 packages/stacks-docs/src/docs/public/email/templates/transactional.md create mode 100644 packages/stacks-docs/src/routes/api/email/catalog/+server.ts create mode 100644 packages/stacks-docs/src/routes/api/email/compile/+server.ts create mode 120000 packages/stacks-docs/static/email create mode 100644 packages/stacks-docs/static/images/heros/email-authoring.svg create mode 100644 packages/stacks-docs/static/images/heros/email-components.svg create mode 100644 packages/stacks-docs/static/images/heros/email-overview.svg create mode 100644 packages/stacks-docs/static/images/heros/email-types.svg create mode 100644 packages/stacks-docs/static/social/instagram.png create mode 100644 packages/stacks-docs/static/social/linkedin.png create mode 100644 packages/stacks-docs/static/social/threads.png create mode 100644 packages/stacks-docs/static/social/x.png create mode 100644 packages/stacks-docs/static/social/youtube.png create mode 100644 packages/stacks-docs/static/stack-overflow-business-logo.png create mode 100644 packages/stacks-docs/static/stack-overflow-logo-off-white.png create mode 100644 packages/stacks-docs/static/stack-overflow-logo.png create mode 100644 packages/stacks-email/README.md create mode 100644 packages/stacks-email/components/button.ts create mode 100644 packages/stacks-email/components/footer.ts create mode 100644 packages/stacks-email/components/graphic.ts create mode 100644 packages/stacks-email/components/header.ts create mode 100644 packages/stacks-email/components/headline.ts create mode 100644 packages/stacks-email/components/index.ts create mode 100644 packages/stacks-email/components/preview.ts create mode 100644 packages/stacks-email/components/section.ts create mode 100644 packages/stacks-email/components/spacer.ts create mode 100644 packages/stacks-email/components/spacers.ts create mode 100644 packages/stacks-email/components/text.ts create mode 100644 packages/stacks-email/components/title.ts create mode 100644 packages/stacks-email/eslint.config.js create mode 100644 packages/stacks-email/mjml-config.ts create mode 100644 packages/stacks-email/mjml-json.ts create mode 100644 packages/stacks-email/package.json create mode 100644 packages/stacks-email/registry.ts create mode 100644 packages/stacks-email/src/app.css create mode 100644 packages/stacks-email/src/app.d.ts create mode 100644 packages/stacks-email/src/app.html create mode 100644 packages/stacks-email/src/components/TemplateSidebar.svelte create mode 100644 packages/stacks-email/src/lib/highlight/highlight.ts create mode 100644 packages/stacks-email/src/lib/pipeline/compile.ts create mode 100644 packages/stacks-email/src/lib/pipeline/template.ts create mode 100644 packages/stacks-email/src/lib/pipeline/transform.ts create mode 100644 packages/stacks-email/src/lib/public/catalog.ts create mode 100644 packages/stacks-email/src/lib/public/compile.ts create mode 100644 packages/stacks-email/src/lib/public/components.ts create mode 100644 packages/stacks-email/src/lib/public/index.ts create mode 100644 packages/stacks-email/src/lib/public/templates.ts create mode 100644 packages/stacks-email/src/lib/public/validation.ts create mode 100644 packages/stacks-email/src/routes/+layout.svelte create mode 100644 packages/stacks-email/src/routes/+layout.ts create mode 100644 packages/stacks-email/src/routes/+page.server.ts create mode 100644 packages/stacks-email/src/routes/+page.svelte create mode 100644 packages/stacks-email/src/routes/api/compile/+server.ts create mode 100644 packages/stacks-email/src/routes/emails/[slug]/+page.server.ts create mode 100644 packages/stacks-email/src/routes/emails/[slug]/+page.svelte create mode 100644 packages/stacks-email/src/types/json2mjml.d.ts create mode 100644 packages/stacks-email/src/types/stacks-icons.d.ts create mode 100644 packages/stacks-email/static/email/hero/1200x630.png create mode 100644 packages/stacks-email/static/email/social/instagram.png create mode 100644 packages/stacks-email/static/email/social/linkedin.png create mode 100644 packages/stacks-email/static/email/social/threads.png create mode 100644 packages/stacks-email/static/email/social/x.png create mode 100644 packages/stacks-email/static/email/social/youtube.png create mode 100644 packages/stacks-email/static/email/spots/SpotLock.png create mode 100644 packages/stacks-email/static/email/stack-overflow-business-logo.png create mode 100644 packages/stacks-email/static/email/stack-overflow-logo-off-white.png create mode 100644 packages/stacks-email/static/email/stack-overflow-logo.png create mode 100644 packages/stacks-email/svelte.config.js create mode 100644 packages/stacks-email/templates/transactional.ts create mode 100644 packages/stacks-email/tokens.ts create mode 100644 packages/stacks-email/tsconfig.json create mode 100644 packages/stacks-email/types.ts create mode 100644 packages/stacks-email/variants.ts create mode 100644 packages/stacks-email/vite.config.ts diff --git a/.prettierignore b/.prettierignore index 9b4a518cde..8504d59126 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,6 @@ .vscode/ dist/ -**/.svelte-kit +.svelte-kit/ # TODO: prettier breaks less files containing namespaced mixins with whitespaces # BEFORE PRETTIER: #stacks-internals #responsify('.w25', { width: 25% !important; }); # AFTER PRETTIER: #stacks-internals #responsify('.w25', { width: 25% !important; });; diff --git a/package-lock.json b/package-lock.json index 484b147a88..dcc58034f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -131,7 +131,6 @@ }, "node_modules/@babel/runtime": { "version": "7.28.4", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1403,6 +1402,50 @@ } } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "license": "MIT", @@ -1428,7 +1471,7 @@ }, "node_modules/@jridgewell/source-map": { "version": "0.3.11", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -1667,6 +1710,12 @@ "node": ">= 8" } }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "license": "MIT" + }, "node_modules/@open-wc/dedupe-mixin": { "version": "2.0.1", "dev": true, @@ -2355,6 +2404,16 @@ "win32" ] }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@pkgr/core": { "version": "0.2.9", "dev": true, @@ -2508,6 +2567,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2521,6 +2581,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2532,6 +2593,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2545,6 +2607,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2558,6 +2621,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2571,6 +2635,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2584,6 +2649,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2597,6 +2663,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2610,6 +2677,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2623,6 +2691,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2636,6 +2705,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2649,6 +2719,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2662,6 +2733,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2675,6 +2747,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2688,6 +2761,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2701,6 +2775,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2714,6 +2789,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2727,6 +2803,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2740,6 +2817,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2753,6 +2831,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2766,6 +2845,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2779,6 +2859,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2878,6 +2959,10 @@ "version": "6.7.0", "license": "MIT" }, + "node_modules/@stackoverflow/stacks-email": { + "resolved": "packages/stacks-email", + "link": true + }, "node_modules/@stackoverflow/stacks-icons": { "version": "7.0.0-beta.24", "resolved": "https://registry.npmjs.org/@stackoverflow/stacks-icons/-/stacks-icons-7.0.0-beta.24.tgz", @@ -3184,6 +3269,16 @@ "acorn": "^8.9.0" } }, + "node_modules/@sveltejs/adapter-auto": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-6.1.1.tgz", + "integrity": "sha512-cBNt4jgH4KuaNO5gRSB2CZKkGtz+OCZ8lPjRQGjhvVUD4akotnj2weUia6imLl2v07K3IgsQRyM36909miSwoQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, "node_modules/@sveltejs/adapter-netlify": { "version": "5.2.4", "dev": true, @@ -3704,6 +3799,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mjml": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/mjml/-/mjml-5.0.0.tgz", + "integrity": "sha512-z6P0yjEOVOz6cZVyD3vkPSifzVH0fa9M8BFM8Jl9HtpeFkBJyCZHRLPx1uFnkZijgAYmtO/JfSbp4EF1fNwsXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mjml-core": "*" + } + }, + "node_modules/@types/mjml-core": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/mjml-core/-/mjml-core-5.0.0.tgz", + "integrity": "sha512-E1Rho2ZfVEqZekQoESDuPAw7C3MrzdUvS6YAiEPGdhQQqAchMXfdChXlSi6ly9YhZgUP026ujrRlEGJn9o/zAg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mkdirp": { "version": "1.0.2", "dev": true, @@ -3719,7 +3831,7 @@ }, "node_modules/@types/node": { "version": "24.9.1", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -4753,6 +4865,15 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/accepts": { "version": "1.3.8", "dev": true, @@ -4857,7 +4978,6 @@ }, "node_modules/ansi-colors": { "version": "4.1.3", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4890,7 +5010,6 @@ }, "node_modules/ansi-regex": { "version": "5.0.1", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4898,7 +5017,6 @@ }, "node_modules/ansi-styles": { "version": "4.3.0", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -4912,7 +5030,6 @@ }, "node_modules/anymatch": { "version": "3.1.3", - "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -4926,7 +5043,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -5051,7 +5167,6 @@ }, "node_modules/balanced-match": { "version": "1.0.2", - "dev": true, "license": "MIT" }, "node_modules/bare-events": { @@ -5172,7 +5287,6 @@ }, "node_modules/binary-extensions": { "version": "2.3.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5183,12 +5297,10 @@ }, "node_modules/boolbase": { "version": "1.0.0", - "dev": true, "license": "ISC" }, "node_modules/brace-expansion": { "version": "2.0.2", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -5196,7 +5308,6 @@ }, "node_modules/braces": { "version": "3.0.3", - "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -5247,7 +5358,7 @@ }, "node_modules/buffer-from": { "version": "1.1.2", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/bundle-name": { @@ -5331,6 +5442,16 @@ "node": ">=6" } }, + "node_modules/camel-case": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-3.0.0.tgz", + "integrity": "sha512-+MbKztAYHXPr1jNTSKQF52VpcFjwY5RkR7fxksV8Doo4KAYc5Fl4UJRgthBbTmEx8C54DqahhbLJkDwjI3PI/w==", + "license": "MIT", + "dependencies": { + "no-case": "^2.2.0", + "upper-case": "^1.1.1" + } + }, "node_modules/camelcase": { "version": "6.3.0", "dev": true, @@ -5465,9 +5586,70 @@ "node": ">= 16" } }, + "node_modules/cheerio": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", + "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "htmlparser2": "^8.0.1", + "parse5": "^7.0.0", + "parse5-htmlparser2-tree-adapter": "^7.0.0" + }, + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cheerio/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/chokidar": { "version": "3.6.0", - "dev": true, "license": "MIT", "dependencies": { "anymatch": "~3.1.2", @@ -5536,6 +5718,27 @@ "devtools-protocol": "*" } }, + "node_modules/clean-css": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.4.tgz", + "integrity": "sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==", + "license": "MIT", + "dependencies": { + "source-map": "~0.6.0" + }, + "engines": { + "node": ">= 4.0" + } + }, + "node_modules/clean-css/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/cli-cursor": { "version": "3.1.0", "dev": true, @@ -5549,7 +5752,6 @@ }, "node_modules/cliui": { "version": "8.0.1", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -5562,12 +5764,10 @@ }, "node_modules/cliui/node_modules/emoji-regex": { "version": "8.0.0", - "dev": true, "license": "MIT" }, "node_modules/cliui/node_modules/string-width": { "version": "4.2.3", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -5580,7 +5780,6 @@ }, "node_modules/cliui/node_modules/wrap-ansi": { "version": "7.0.0", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -5659,7 +5858,6 @@ }, "node_modules/color-convert": { "version": "2.0.1", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -5670,7 +5868,6 @@ }, "node_modules/color-name": { "version": "1.1.4", - "dev": true, "license": "MIT" }, "node_modules/colord": { @@ -5749,6 +5946,12 @@ "node": ">=12.17" } }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, "node_modules/commondir": { "version": "1.0.1", "dev": true, @@ -5812,6 +6015,16 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "dev": true, @@ -5858,7 +6071,7 @@ }, "node_modules/copy-anything": { "version": "2.0.6", - "devOptional": true, + "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -5895,7 +6108,6 @@ }, "node_modules/cross-spawn": { "version": "7.0.6", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -5961,7 +6173,6 @@ }, "node_modules/css-select": { "version": "5.2.2", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0", @@ -5988,7 +6199,6 @@ }, "node_modules/css-what": { "version": "6.2.2", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">= 6" @@ -6317,6 +6527,12 @@ "node": ">=8" } }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "license": "MIT" + }, "node_modules/devalue": { "version": "5.8.1", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", @@ -6365,7 +6581,6 @@ }, "node_modules/dom-serializer": { "version": "2.0.0", - "dev": true, "license": "MIT", "dependencies": { "domelementtype": "^2.3.0", @@ -6376,20 +6591,8 @@ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" } }, - "node_modules/dom-serializer/node_modules/entities": { - "version": "4.5.0", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/domelementtype": { "version": "2.3.0", - "dev": true, "funding": [ { "type": "github", @@ -6400,7 +6603,6 @@ }, "node_modules/domhandler": { "version": "5.0.3", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "domelementtype": "^2.3.0" @@ -6414,7 +6616,6 @@ }, "node_modules/domutils": { "version": "3.2.2", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "dom-serializer": "^2.0.0", @@ -6446,6 +6647,39 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/editorconfig": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", + "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "^9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/ee-first": { "version": "1.1.1", "dev": true, @@ -6456,6 +6690,12 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, "node_modules/end-of-stream": { "version": "1.4.5", "dev": true, @@ -6490,6 +6730,18 @@ "node": ">=8.6" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "dev": true, @@ -6605,12 +6857,23 @@ }, "node_modules/escalade": { "version": "3.2.0", - "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/escape-goat": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-3.0.0.tgz", + "integrity": "sha512-w3PwNZJwRxlp47QGzhuEBldEqVHHhh8/tIPcl6ecf2Bou99cdAt0knihBV0Ecc7CGxYduXVBDheH1K2oADRlvw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/escape-html": { "version": "1.0.3", "dev": true, @@ -7254,7 +7517,6 @@ }, "node_modules/fill-range": { "version": "7.1.1", - "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -7319,6 +7581,22 @@ "dev": true, "license": "ISC" }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/fresh": { "version": "0.5.2", "dev": true, @@ -7377,7 +7655,6 @@ }, "node_modules/get-caller-file": { "version": "2.0.5", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -7457,9 +7734,29 @@ "version": "2.0.0", "license": "ISC" }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "5.1.2", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -7558,7 +7855,7 @@ }, "node_modules/graceful-fs": { "version": "4.2.11", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/has-flag": { @@ -7814,6 +8111,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, "node_modules/highlight.js": { "version": "11.11.1", "license": "BSD-3-Clause", @@ -7831,6 +8137,27 @@ "dev": true, "license": "MIT" }, + "node_modules/html-minifier": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-minifier/-/html-minifier-4.0.0.tgz", + "integrity": "sha512-aoGxanpFPLg7MkIl/DDFYtb0iWz7jMFGqFhvEDZga6/4QTjneiD8I/NXL1x5aaoCp7FSIT6h/OhykDdPsbtMig==", + "license": "MIT", + "dependencies": { + "camel-case": "^3.0.0", + "clean-css": "^4.2.1", + "commander": "^2.19.0", + "he": "^1.2.0", + "param-case": "^2.1.1", + "relateurl": "^0.2.7", + "uglify-js": "^3.5.1" + }, + "bin": { + "html-minifier": "cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/html-tags": { "version": "5.1.0", "dev": true, @@ -7850,6 +8177,25 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, "node_modules/http-assert": { "version": "1.5.0", "dev": true, @@ -7974,6 +8320,7 @@ }, "node_modules/image-size": { "version": "0.5.5", + "dev": true, "license": "MIT", "optional": true, "peer": true, @@ -8087,7 +8434,6 @@ }, "node_modules/ini": { "version": "1.3.8", - "dev": true, "license": "ISC" }, "node_modules/internal-ip": { @@ -8148,7 +8494,6 @@ }, "node_modules/is-binary-path": { "version": "2.1.0", - "dev": true, "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -8208,7 +8553,6 @@ }, "node_modules/is-extglob": { "version": "2.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8216,7 +8560,6 @@ }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8242,7 +8585,6 @@ }, "node_modules/is-glob": { "version": "4.0.3", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -8300,7 +8642,6 @@ }, "node_modules/is-number": { "version": "7.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -8386,7 +8727,7 @@ }, "node_modules/is-what": { "version": "3.14.1", - "devOptional": true, + "dev": true, "license": "MIT", "peer": true }, @@ -8424,7 +8765,6 @@ }, "node_modules/isexe": { "version": "2.0.0", - "dev": true, "license": "ISC" }, "node_modules/isobject": { @@ -8468,6 +8808,21 @@ "node": ">=8" } }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jest-worker": { "version": "27.5.1", "dev": true, @@ -8497,7 +8852,7 @@ }, "node_modules/jiti": { "version": "2.6.1", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -8510,6 +8865,33 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", + "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "dev": true, @@ -8546,6 +8928,18 @@ "dev": true, "license": "MIT" }, + "node_modules/json2mjml": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/json2mjml/-/json2mjml-1.0.3.tgz", + "integrity": "sha512-dxQZZiQKSUSWVE46D3vf8NDjd/mgN+ejy/vR2DXz8/QUUMHGlMm3WeCqPw/5LoCKt+dH382q7QXbl9ZxDCUecw==", + "license": "MIT", + "dependencies": { + "commander": "^2.11.0" + }, + "bin": { + "json2mjml": "lib/cli.js" + } + }, "node_modules/jsonfile": { "version": "4.0.0", "dev": true, @@ -8554,6 +8948,34 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/juice": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/juice/-/juice-10.0.1.tgz", + "integrity": "sha512-ZhJT1soxJCkOiO55/mz8yeBKTAJhRzX9WBO+16ZTqNTONnnVlUPyVBIzQ7lDRjaBdTbid+bAnyIon/GM3yp4cA==", + "license": "MIT", + "dependencies": { + "cheerio": "1.0.0-rc.12", + "commander": "^6.1.0", + "mensch": "^0.3.4", + "slick": "^1.12.2", + "web-resource-inliner": "^6.0.1" + }, + "bin": { + "juice": "bin/juice" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/juice/node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/keygrip": { "version": "1.1.0", "dev": true, @@ -8712,7 +9134,7 @@ }, "node_modules/less": { "version": "4.5.1", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "peer": true, @@ -8764,6 +9186,7 @@ }, "node_modules/less/node_modules/errno": { "version": "0.1.8", + "dev": true, "license": "MIT", "optional": true, "peer": true, @@ -8776,6 +9199,7 @@ }, "node_modules/less/node_modules/make-dir": { "version": "2.1.0", + "dev": true, "license": "MIT", "optional": true, "peer": true, @@ -8789,6 +9213,7 @@ }, "node_modules/less/node_modules/mime": { "version": "1.6.0", + "dev": true, "license": "MIT", "optional": true, "peer": true, @@ -8801,6 +9226,7 @@ }, "node_modules/less/node_modules/semver": { "version": "5.7.2", + "dev": true, "license": "ISC", "optional": true, "peer": true, @@ -8810,6 +9236,7 @@ }, "node_modules/less/node_modules/source-map": { "version": "0.6.1", + "dev": true, "license": "BSD-3-Clause", "optional": true, "peer": true, @@ -8939,6 +9366,12 @@ "node": ">=8" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/lodash.camelcase": { "version": "4.3.0", "dev": true, @@ -9024,6 +9457,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lower-case": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-1.1.4.tgz", + "integrity": "sha512-2Fgx1Ycm599x+WGpIYwJOvsjmXFzTSc34IwDWALRA/8AopUKAVPwfJ+h5+f85BCp0PWmmJcWzEpxOpoXycMpdA==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "8.0.5", "dev": true, @@ -9076,16 +9515,6 @@ "markdown-it": "bin/markdown-it.mjs" } }, - "node_modules/markdown-it/node_modules/entities": { - "version": "4.5.0", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/marky": { "version": "1.3.0", "dev": true, @@ -9177,6 +9606,12 @@ "node": ">= 0.6" } }, + "node_modules/mensch": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/mensch/-/mensch-0.3.4.tgz", + "integrity": "sha512-IAeFvcOnV9V0Yk+bFhYR07O3yNina9ANIN5MoXBKYJ/RLYPurd2d0yw14MDhpr9/momp0WofT1bPUh3hkzdi/g==", + "license": "MIT" + }, "node_modules/meow": { "version": "14.0.0", "dev": true, @@ -9226,6 +9661,18 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/mime-db": { "version": "1.52.0", "dev": true, @@ -9286,7 +9733,6 @@ "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.2" @@ -9298,180 +9744,635 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mitt": { "version": "3.0.1", "dev": true, "license": "MIT" }, - "node_modules/mkdirp": { - "version": "1.0.4", - "dev": true, + "node_modules/mjml": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml/-/mjml-4.18.0.tgz", + "integrity": "sha512-rQM4aqFRrNvV1k733e8hJSopBjZvoSdBpRYzNTMAN+As0jqJsO5eN0wTT2IFtfe4PREzzu5b06RkPiUQdd0IIg==", "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" + "dependencies": { + "@babel/runtime": "^7.28.4", + "mjml-cli": "4.18.0", + "mjml-core": "4.18.0", + "mjml-migrate": "4.18.0", + "mjml-preset-core": "4.18.0", + "mjml-validator": "4.18.0" }, - "engines": { - "node": ">=10" + "bin": { + "mjml": "bin/mjml" } }, - "node_modules/mri": { - "version": "1.2.0", - "dev": true, + "node_modules/mjml-accordion": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-accordion/-/mjml-accordion-4.18.0.tgz", + "integrity": "sha512-9PUmy2JxIOGgAaVHvgVYX21nVAo3o/+wJckTTF/YTLGAqB+nm+44buxRzaXxVk7qXRwbCNfE8c8mlGVNh7vB1g==", "license": "MIT", - "engines": { - "node": ">=4" + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" } }, - "node_modules/mrmime": { - "version": "2.0.1", - "devOptional": true, + "node_modules/mjml-body": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-body/-/mjml-body-4.18.0.tgz", + "integrity": "sha512-34AwX70/7NkRIajPsa5j6NySRiNrlLatTKhiLwTVFiVtrEFlfCcbeMNmdVixI3Ldvs8209ZC6euaAnXDRyR1zw==", "license": "MIT", - "engines": { - "node": ">=10" + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" } }, - "node_modules/ms": { - "version": "2.1.3", - "dev": true, - "license": "MIT" - }, - "node_modules/nanocolors": { - "version": "0.2.13", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "devOptional": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/mjml-button": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-button/-/mjml-button-4.18.0.tgz", + "integrity": "sha512-ZsWMI0j7EcFCMqbqdVwMWhmsVc03FhmypWXokKopGhwySn4IAB4AOURonRmFrO7k6sDeQ+iJ9QtTu7jA+S8wmg==", "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" } }, - "node_modules/nanostores": { - "version": "1.1.1", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/mjml-carousel": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-carousel/-/mjml-carousel-4.18.0.tgz", + "integrity": "sha512-wY4g1CHCOoVSZuar7CLFon/qkPbICu71IT+6pa4BDwkAiaAMAemZPyy+a+iIUgdc8kHgSuHGsGf6PQzBSMWRZA==", "license": "MIT", - "engines": { - "node": "^20.0.0 || >=22.0.0" + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" } }, - "node_modules/natural-compare": { - "version": "1.4.0", - "dev": true, - "license": "MIT" - }, - "node_modules/needle": { - "version": "3.3.1", + "node_modules/mjml-cli": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-cli/-/mjml-cli-4.18.0.tgz", + "integrity": "sha512-N6CnA4o/q/VRnGPxTzvVnjAEcF7WUVVQGYfS9SPAp0qwyf7RysMmewdS9yN8GwXwZV6L2sKdn+3ANNi2FNsJ7w==", "license": "MIT", - "optional": true, - "peer": true, "dependencies": { - "iconv-lite": "^0.6.3", - "sax": "^1.2.4" + "@babel/runtime": "^7.28.4", + "chokidar": "^3.0.0", + "glob": "^10.3.10", + "html-minifier": "^4.0.0", + "js-beautify": "^1.6.14", + "lodash": "^4.17.21", + "minimatch": "^9.0.3", + "mjml-core": "4.18.0", + "mjml-migrate": "4.18.0", + "mjml-parser-xml": "4.18.0", + "mjml-validator": "4.18.0", + "yargs": "^17.7.2" }, "bin": { - "needle": "bin/needle" - }, - "engines": { - "node": ">= 4.4.x" + "mjml-cli": "bin/mjml" } }, - "node_modules/needle/node_modules/iconv-lite": { - "version": "0.6.3", + "node_modules/mjml-column": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-column/-/mjml-column-4.18.0.tgz", + "integrity": "sha512-0QZ1whxbHUmJaRT8tW+wmr3fWZ/kpsHKAd24c7Z/N1Otm/U2G0T/FFEFJ6cB25X6ZN0K40QZ8L9gdLfiSVuRbA==", "license": "MIT", - "optional": true, - "peer": true, "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" } }, - "node_modules/negotiator": { - "version": "0.6.3", - "dev": true, + "node_modules/mjml-core": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.18.0.tgz", + "integrity": "sha512-yey72LszXvIo5p0R6DB+YU8er/nP2wPsqpLKQCB0H8vG0WRT1sbSUvnCUOkKGn7subuyWDTdzHKbQO3XYIOmvg==", "license": "MIT", - "engines": { - "node": ">= 0.6" + "dependencies": { + "@babel/runtime": "^7.28.4", + "cheerio": "1.0.0-rc.12", + "detect-node": "^2.0.4", + "html-minifier": "^4.0.0", + "js-beautify": "^1.6.14", + "juice": "^10.0.0", + "lodash": "^4.17.21", + "mjml-migrate": "4.18.0", + "mjml-parser-xml": "4.18.0", + "mjml-validator": "4.18.0" } }, - "node_modules/neo-async": { - "version": "2.6.2", - "dev": true, - "license": "MIT" - }, - "node_modules/netmask": { - "version": "2.0.2", - "dev": true, + "node_modules/mjml-divider": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-divider/-/mjml-divider-4.18.0.tgz", + "integrity": "sha512-FmGUVJqi4RYroh7y85vDx0aUKZgECkxHtMQ4pkLGQbZ2g93/Qt0Ek88DVCNJ5XwUAQQkE/TvrGMLHp3CIqpQ9Q==", "license": "MIT", - "engines": { - "node": ">= 0.4.0" + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" } }, - "node_modules/node-fetch": { - "version": "2.7.0", - "dev": true, + "node_modules/mjml-group": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-group/-/mjml-group-4.18.0.tgz", + "integrity": "sha512-28ABkXsKljBqj7XCC8GkQ94xz8HEU2XTyD+9LTlkDafzGp/MGJb8DcLh/7IkxCwqkQWyeMiDNLf1djsQ909Vxw==", "license": "MIT", "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" } }, - "node_modules/node-fetch/node_modules/tr46": { - "version": "0.0.3", - "dev": true, - "license": "MIT" + "node_modules/mjml-head": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-head/-/mjml-head-4.18.0.tgz", + "integrity": "sha512-DS0adpIAsVMDIk2DOsHzjg+RNjQU0fF8jiVP9BmdRHVGrLPmpL9wIHZk2KvsKvZe7VaXXBijFt3DZ5/CQ/+D7Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } }, - "node_modules/node-fetch/node_modules/webidl-conversions": { - "version": "3.0.1", - "dev": true, - "license": "BSD-2-Clause" + "node_modules/mjml-head-attributes": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-head-attributes/-/mjml-head-attributes-4.18.0.tgz", + "integrity": "sha512-nLzix1wrMnojE0RPGhk4iKqSRwHKjie2EPzgKT7CDzfqN+Ref03E5Q19x3cQTLgxvq3C3CnvCQBfnhoS3Eakug==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } }, - "node_modules/node-fetch/node_modules/whatwg-url": { - "version": "5.0.0", - "dev": true, + "node_modules/mjml-head-breakpoint": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-head-breakpoint/-/mjml-head-breakpoint-4.18.0.tgz", + "integrity": "sha512-k6rwff+7i+vTQYJ/CjBfE20qNqPaW60IRH2x2oEPuCzmwDmoVWOcplJIuotSqIAdfwF9hLkICknisp1BpczVlQ==", "license": "MIT", "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" } }, - "node_modules/node-releases": { + "node_modules/mjml-head-font": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-head-font/-/mjml-head-font-4.18.0.tgz", + "integrity": "sha512-ao8HB5nf+Dmxw4GO6lMMOlnj1lNZONai0GC9RobrZgPlghZw6hpURWGpkON7pQcy6XnOHwYwkV7Go/npzA2i7w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-head-html-attributes": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-head-html-attributes/-/mjml-head-html-attributes-4.18.0.tgz", + "integrity": "sha512-xaQE1rthe0RrNotwEr71X1tE+QQ489Yc0ynMm3oNMrohDI/TaCeazx8GAHPMM7VLduDA8D4A5wkZ6PuEvlJu4w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-head-preview": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-head-preview/-/mjml-head-preview-4.18.0.tgz", + "integrity": "sha512-2JvYqhbLyU/+Te6/1AXxzTNoHYCDYhXOVZP7wMvU4t7K34pXqyRUNO405atyHUY1MRafrl6RJ8cIx0x5vUX7PA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-head-style": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-head-style/-/mjml-head-style-4.18.0.tgz", + "integrity": "sha512-nEwDHkAqY3Fm7QWeAZc/a7MakZpXh6THfrE8/AWrfpgzTHrD/wihNUc09ztNpr6z/K1+JWgQfSF2BRc+X3P46g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-head-title": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-head-title/-/mjml-head-title-4.18.0.tgz", + "integrity": "sha512-0Hm8o50rPMUQLSCOOa4D4pz9NajmCDccLvBYE4fwKdeUXjSJ6bwAYeMpveel8oNZMDUVJ4Hx+PskisEGHMHM2w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-hero": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-hero/-/mjml-hero-4.18.0.tgz", + "integrity": "sha512-rujm0ROM4QGWw77vnl3NaVaCKXrT4xTSHeAnkHKiY5AuRf6HPTgEtutq5pdel/y6Q9GrmxvN3HRESum7tpJCJw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-image": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-image/-/mjml-image-4.18.0.tgz", + "integrity": "sha512-e09NkoYwvzMcTv7V6H5doWD6Te2E1y2EvOLQJoXKVdQpDwyBWGdfnZke0scJGdA58HLAB+0mLYogpLwmfLaP5Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-migrate": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.18.0.tgz", + "integrity": "sha512-qfNCgW9zhJIsbPyXFA5RT/WY4mlje3N0WhHHOsHc0nY89Q01DenyslUy9nLLGXwi4K5FHS58oCjwWbMhwDcj1w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "js-beautify": "^1.6.14", + "lodash": "^4.17.21", + "mjml-core": "4.18.0", + "mjml-parser-xml": "4.18.0", + "yargs": "^17.7.2" + }, + "bin": { + "migrate": "lib/cli.js" + } + }, + "node_modules/mjml-navbar": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-navbar/-/mjml-navbar-4.18.0.tgz", + "integrity": "sha512-uho/MS2tfNAe+V9u2X7NoCco34MDbdp30ETA8009Qo1VCP/D8lZ+s69WGRPu6hvN/Y2pzBgZly++CMg3qFZqBQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-parser-xml": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.18.0.tgz", + "integrity": "sha512-sHSsZg4afY1heThuJzxa1Kvfh/QzB7/9P5fFUHeVnnxb07ZTXnhXWA6YbobdND5/l9+5yjN5/UgqDZm3tIT4Uw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "detect-node": "2.1.0", + "htmlparser2": "^9.1.0", + "lodash": "^4.17.21" + } + }, + "node_modules/mjml-parser-xml/node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/mjml-preset-core": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-preset-core/-/mjml-preset-core-4.18.0.tgz", + "integrity": "sha512-x3l8vMVtsaqM/jauMeZIN7HFD2t5A28J4U0o4849yIlRxiWguLFV5l3BL8Byol+YLkoLuT9PjaZs9RYv+FGfeg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "mjml-accordion": "4.18.0", + "mjml-body": "4.18.0", + "mjml-button": "4.18.0", + "mjml-carousel": "4.18.0", + "mjml-column": "4.18.0", + "mjml-divider": "4.18.0", + "mjml-group": "4.18.0", + "mjml-head": "4.18.0", + "mjml-head-attributes": "4.18.0", + "mjml-head-breakpoint": "4.18.0", + "mjml-head-font": "4.18.0", + "mjml-head-html-attributes": "4.18.0", + "mjml-head-preview": "4.18.0", + "mjml-head-style": "4.18.0", + "mjml-head-title": "4.18.0", + "mjml-hero": "4.18.0", + "mjml-image": "4.18.0", + "mjml-navbar": "4.18.0", + "mjml-raw": "4.18.0", + "mjml-section": "4.18.0", + "mjml-social": "4.18.0", + "mjml-spacer": "4.18.0", + "mjml-table": "4.18.0", + "mjml-text": "4.18.0", + "mjml-wrapper": "4.18.0" + } + }, + "node_modules/mjml-raw": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-raw/-/mjml-raw-4.18.0.tgz", + "integrity": "sha512-F/kViAwXm3ccPP52kw++/mHQbcYbYYxC8JH15TZxH8GLVZkX5CGKgcBrHhDK7WoIlfEIsVRZ6IZdlHjH8vgyxw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-section": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-section/-/mjml-section-4.18.0.tgz", + "integrity": "sha512-bB8My9zvIEkTOxej+TrjEeaeRT0lsypGeRADtdrRZXeqUClkkuCnCXlsNKSLGT8ZRqjUqWRc5z8ubDOvGk2+Gg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-social": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-social/-/mjml-social-4.18.0.tgz", + "integrity": "sha512-iAQc9g59L6L3VHDd55BxeIvk/zHkxflxmvuyYyOOvpmmKAvUBC//ULfpxiiM4yupofsThqFfrO+wc8d4kTRkbQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-spacer": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-spacer/-/mjml-spacer-4.18.0.tgz", + "integrity": "sha512-FK/0f5IBiONgaRpwNBs7G8EbLdAbmYqcIfHR8O8tP4LipAChLQKHO9vX3vrRMGLBZZNTESLObcFSVWmA40Mfpw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-table": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-table/-/mjml-table-4.18.0.tgz", + "integrity": "sha512-vJysCPUL3CHcsQDAFpW+skzBtY0RYsmMBYswI4WX0B05GLKlOjXqpYOwcmAupWeGoBVL5r/t28ynu2PqnOlN3w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-text": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-text/-/mjml-text-4.18.0.tgz", + "integrity": "sha512-hBLmF3JgveUKktKQFWHqHAr7qr92j1CxAvq7mtpDUgiWgyPFzqRX8mUsFYgZ7DmRxG4UE+Kzpt8/YFd9+E98lw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0" + } + }, + "node_modules/mjml-validator": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.18.0.tgz", + "integrity": "sha512-JmpWAsNTUlAxJOz2zHYfF8Vod8OzM3Qp5JXtrVw5tivZQzq88ZfqVGuqsas51z0pp1/ilfD4lC17YGfGwKGyhA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" + } + }, + "node_modules/mjml-wrapper": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/mjml-wrapper/-/mjml-wrapper-4.18.0.tgz", + "integrity": "sha512-TZeOvLjIhXEK60rjWNiYhEYNlv5GKYahE+96ifcT5OGkWkRA0DsQDfp+6VI32OS5VxsfKq2h/UdERPlQijjpAQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.18.0", + "mjml-section": "4.18.0" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/nanocolors": { + "version": "0.2.13", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nanostores": { + "version": "1.1.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/needle": { + "version": "3.3.1", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "iconv-lite": "^0.6.3", + "sax": "^1.2.4" + }, + "bin": { + "needle": "bin/needle" + }, + "engines": { + "node": ">= 4.4.x" + } + }, + "node_modules/needle/node_modules/iconv-lite": { + "version": "0.6.3", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "dev": true, + "license": "MIT" + }, + "node_modules/netmask": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/no-case": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-2.3.2.tgz", + "integrity": "sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ==", + "license": "MIT", + "dependencies": { + "lower-case": "^1.1.1" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/node-releases": { "version": "2.0.27", "dev": true, "license": "MIT" }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9501,7 +10402,6 @@ }, "node_modules/nth-check": { "version": "2.1.1", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0" @@ -9814,6 +10714,12 @@ "node": ">= 14" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, "node_modules/package-manager-detector": { "version": "0.2.11", "dev": true, @@ -9822,6 +10728,15 @@ "quansync": "^0.2.7" } }, + "node_modules/param-case": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/param-case/-/param-case-2.1.1.tgz", + "integrity": "sha512-eQE845L6ot89sk2N8liD8HAuH4ca6Vvr7VWAWwt7+kvvG5aBcPmmphQ68JsEG2qa9n1TykS2DLeMt363AAH8/w==", + "license": "MIT", + "dependencies": { + "no-case": "^2.2.0" + } + }, "node_modules/parent-module": { "version": "1.0.1", "dev": true, @@ -9852,7 +10767,7 @@ }, "node_modules/parse-node-version": { "version": "1.0.1", - "devOptional": true, + "dev": true, "license": "MIT", "peer": true, "engines": { @@ -9863,6 +10778,43 @@ "version": "6.0.1", "license": "MIT" }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "dev": true, @@ -9889,7 +10841,6 @@ }, "node_modules/path-key": { "version": "3.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -9900,6 +10851,28 @@ "dev": true, "license": "MIT" }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/path-type": { "version": "4.0.0", "dev": true, @@ -9946,7 +10919,7 @@ }, "node_modules/pify": { "version": "4.0.1", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -10908,6 +11881,12 @@ "prosemirror-transform": "^1.1.0" } }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "license": "ISC" + }, "node_modules/proxy-agent": { "version": "6.5.0", "dev": true, @@ -10941,6 +11920,7 @@ }, "node_modules/prr": { "version": "1.0.1", + "dev": true, "license": "MIT", "optional": true, "peer": true @@ -11151,7 +12131,6 @@ }, "node_modules/readdirp": { "version": "3.6.0", - "dev": true, "license": "MIT", "dependencies": { "picomatch": "^2.2.1" @@ -11164,7 +12143,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -11405,9 +12383,17 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/relateurl": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", + "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/require-directory": { "version": "2.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -12379,14 +13365,14 @@ }, "node_modules/safer-buffer": { "version": "2.1.2", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/sax": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", - "devOptional": true, + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" @@ -12453,7 +13439,6 @@ }, "node_modules/semver": { "version": "7.7.3", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -12485,7 +13470,6 @@ }, "node_modules/shebang-command": { "version": "2.0.0", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -12496,7 +13480,6 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -12588,7 +13571,6 @@ }, "node_modules/signal-exit": { "version": "4.1.0", - "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -12658,6 +13640,15 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/slick": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/slick/-/slick-1.12.2.tgz", + "integrity": "sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A==", + "license": "MIT (http://mootools.net/license.txt)", + "engines": { + "node": "*" + } + }, "node_modules/smart-buffer": { "version": "4.2.0", "dev": true, @@ -12711,7 +13702,7 @@ }, "node_modules/source-map-support": { "version": "0.5.21", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", @@ -12720,7 +13711,7 @@ }, "node_modules/source-map-support/node_modules/source-map": { "version": "0.6.1", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -12865,6 +13856,71 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/stringify-entities": { "version": "4.0.4", "license": "MIT", @@ -12879,7 +13935,19 @@ }, "node_modules/strip-ansi": { "version": "6.0.1", - "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -13632,7 +14700,7 @@ }, "node_modules/terser": { "version": "5.44.0", - "devOptional": true, + "dev": true, "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", @@ -13708,11 +14776,6 @@ } } }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "devOptional": true, - "license": "MIT" - }, "node_modules/text-decoder": { "version": "1.2.3", "dev": true, @@ -13772,7 +14835,6 @@ }, "node_modules/to-regex-range": { "version": "5.0.1", - "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -13864,7 +14926,7 @@ }, "node_modules/tslib": { "version": "2.8.1", - "devOptional": true, + "dev": true, "license": "0BSD" }, "node_modules/tsscmp": { @@ -13931,7 +14993,7 @@ }, "node_modules/typescript": { "version": "5.9.3", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -14000,9 +15062,21 @@ "version": "2.1.0", "license": "MIT" }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "license": "BSD-2-Clause", + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/undici-types": { "version": "7.16.0", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/unicorn-magic": { @@ -14164,6 +15238,12 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/upper-case": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/upper-case/-/upper-case-1.1.3.tgz", + "integrity": "sha512-WRbjgmYzgXkCV7zNVpy5YgrHgbBv126rMALQQMrmzOVC4GM2waQ9x7xtm8VU+1yF2kWyPzI9zbZ48n4vSxwfSA==", + "license": "MIT" + }, "node_modules/uri-js": { "version": "4.4.1", "dev": true, @@ -14198,6 +15278,15 @@ "node": ">=10.12.0" } }, + "node_modules/valid-data-url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/valid-data-url/-/valid-data-url-3.0.1.tgz", + "integrity": "sha512-jOWVmzVceKlVVdwjNSenT4PbGghU0SBIizAev8ofZVgivk/TVHXSbNL8LP6M3spZvkR9/QolkyJavGSX5Cs0UA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/vary": { "version": "1.1.2", "dev": true, @@ -14351,6 +15440,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14367,6 +15457,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14383,6 +15474,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14399,6 +15491,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14413,6 +15506,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14429,6 +15523,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14445,6 +15540,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14461,6 +15557,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14477,6 +15574,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14493,6 +15591,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14509,6 +15608,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14525,6 +15625,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14541,6 +15642,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14557,6 +15659,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14573,6 +15676,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14589,6 +15693,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14605,6 +15710,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14621,6 +15727,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14637,6 +15744,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14653,6 +15761,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14669,6 +15778,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14685,6 +15795,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14701,6 +15812,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14717,6 +15829,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14733,6 +15846,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14749,6 +15863,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15015,6 +16130,120 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/web-resource-inliner": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/web-resource-inliner/-/web-resource-inliner-6.0.1.tgz", + "integrity": "sha512-kfqDxt5dTB1JhqsCUQVFDj0rmY+4HLwGQIsLPbyrsN9y9WV/1oFDSx3BQ4GfCv9X+jVeQ7rouTqwK53rA/7t8A==", + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.1", + "escape-goat": "^3.0.0", + "htmlparser2": "^5.0.0", + "mime": "^2.4.6", + "node-fetch": "^2.6.0", + "valid-data-url": "^3.0.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/web-resource-inliner/node_modules/dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/dom-serializer/node_modules/domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/domhandler": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-3.3.0.tgz", + "integrity": "sha512-J1C5rIANUbuYK+FuFL98650rihynUOEzRLxW+90bKZRWB6A1X1Tf82GxR1qAWLyfNPRvjqfip3Q5tdYlmAa9lA==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.0.1" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/domutils/node_modules/domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/htmlparser2": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-5.0.1.tgz", + "integrity": "sha512-vKZZra6CSe9qsJzh0BjBGXo8dvzNsq/oGvsjfRdOrrryfeD9UOBEEQdeoqCRmKZchF5h2zOBMQ6YuQ0uRUmdbQ==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^3.3.0", + "domutils": "^2.4.2", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/fb55/htmlparser2?sponsor=1" + } + }, "node_modules/webdriver-bidi-protocol": { "version": "0.3.8", "dev": true, @@ -15205,7 +16434,6 @@ }, "node_modules/which": { "version": "2.0.2", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -15253,6 +16481,100 @@ "node": ">=12.17" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "dev": true, @@ -15320,7 +16642,6 @@ }, "node_modules/y18n": { "version": "5.0.8", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -15343,7 +16664,6 @@ }, "node_modules/yargs": { "version": "17.7.2", - "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -15360,7 +16680,6 @@ }, "node_modules/yargs-parser": { "version": "21.1.1", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -15368,12 +16687,10 @@ }, "node_modules/yargs/node_modules/emoji-regex": { "version": "8.0.0", - "dev": true, "license": "MIT" }, "node_modules/yargs/node_modules/string-width": { "version": "4.2.3", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -15448,6 +16765,7 @@ "@hbsnow/rehype-sectionize": "^1.0.7", "@stackoverflow/stacks": "*", "@stackoverflow/stacks-editor": "*", + "@stackoverflow/stacks-email": "*", "@stackoverflow/stacks-icons": "*", "@stackoverflow/stacks-icons-legacy": "*", "@stackoverflow/stacks-svelte": "*", @@ -15727,6 +17045,93 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "packages/stacks-email": { + "name": "@stackoverflow/stacks-email", + "version": "0.0.1", + "dependencies": { + "@stackoverflow/stacks": "*", + "@stackoverflow/stacks-svelte": "*", + "highlight.js": "^11.11.1", + "json2mjml": "^1.0.3", + "markdown-it": "^14.1.0", + "mjml": "^4.17.1", + "zod": "^4.1.12" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^6.1.0", + "@sveltejs/kit": "^2.48.5", + "@sveltejs/vite-plugin-svelte": "^6.2.1", + "@types/mjml": "^5.0.0", + "eslint": "^9.39.2", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-svelte": "^3.14.1", + "globals": "^17.0.0", + "mdsvex": "^0.12.3", + "prettier": "^3.8.3", + "prettier-plugin-svelte": "^3.4.0", + "svelte": "^5.55.9", + "svelte-check": "^4.3.5", + "typescript": "^5.9.3", + "typescript-eslint": "^8.53.0", + "vite": "^7.3.3" + } + }, + "packages/stacks-email/node_modules/eslint-plugin-svelte": { + "version": "3.18.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.18.0.tgz", + "integrity": "sha512-vc3P37lrDronWDb2kPXiG8sqkuiMqitGXSSaflb7Y+jpDgNoAzW8i7tdqyJKpcLZmFIqZCD+je2oZRf9qyRyBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.6.1", + "@jridgewell/sourcemap-codec": "^1.5.0", + "esutils": "^2.0.3", + "globals": "^16.0.0", + "known-css-properties": "^0.37.0", + "postcss": "^8.4.49", + "postcss-load-config": "^3.1.4", + "postcss-safe-parser": "^7.0.0", + "semver": "^7.6.3", + "svelte-eslint-parser": "^1.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "eslint": "^8.57.1 || ^9.0.0 || ^10.0.0", + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } + } + }, + "packages/stacks-email/node_modules/eslint-plugin-svelte/node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/stacks-email/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "packages/stacks-svelte": { "name": "@stackoverflow/stacks-svelte", "version": "1.0.0-beta.32", diff --git a/packages/stacks-docs/README.md b/packages/stacks-docs/README.md index 7c31b252e2..6b3501b7dc 100644 --- a/packages/stacks-docs/README.md +++ b/packages/stacks-docs/README.md @@ -161,3 +161,10 @@ The private docs follow the same structure and conventions as public docs. Mark 3. **Add any images or assets** to the same directory as your markdown file 4. **Reference assets** using relative paths (e.g., `./image.svg`) in your markdown + +## Email compile API auth (optional) + +`POST /api/email/compile` supports an optional shared Bearer token. + +- If `STACKS_EMAIL_AUTH_TOKEN` is not set: auth is disabled. +- If `STACKS_EMAIL_AUTH_TOKEN` is set: the request must include `Authorization: Bearer `. diff --git a/packages/stacks-docs/_redirects b/packages/stacks-docs/_redirects index 25c51278eb..37ada0fa19 100644 --- a/packages/stacks-docs/_redirects +++ b/packages/stacks-docs/_redirects @@ -2,9 +2,6 @@ https://alpha.stackoverflow.design/* https://stackoverflow.design/:splat 301 https://beta.stackoverflow.design/* https://stackoverflow.design/:splat 302 -# Email section → v2 docs -/email/* https://v2.stackoverflow.design/email/:splat 302 - # Redirects from v2 urls /product /system/develop/using-stacks/ 302 /base/* /system/base/:splat 302 diff --git a/packages/stacks-docs/package.json b/packages/stacks-docs/package.json index ec738643b8..a6aeb39788 100644 --- a/packages/stacks-docs/package.json +++ b/packages/stacks-docs/package.json @@ -25,6 +25,7 @@ "dependencies": { "@docsearch/css": "^4.3.2", "@docsearch/js": "^4.3.2", + "@stackoverflow/stacks-email": "*", "@hbsnow/rehype-sectionize": "^1.0.7", "@stackoverflow/stacks": "*", "@stackoverflow/stacks-editor": "*", diff --git a/packages/stacks-docs/src/app.css b/packages/stacks-docs/src/app.css index 7d3f7fc100..e79257d309 100644 --- a/packages/stacks-docs/src/app.css +++ b/packages/stacks-docs/src/app.css @@ -307,7 +307,7 @@ body { .ff-stack-sans-headline, .ff-stack-sans-headline-notch { - font-family: "Stack Sans Headline"; + font-family: "Stack Sans Headline" !important; } .ff-stack-sans-headline-notch { font-feature-settings: "ss01" 1 !important; @@ -550,6 +550,18 @@ h1 { padding: var(--su16); } +/* Docs section card – used with a thumbnail to link to other areas in the docs i.e, for index pages */ + +.docs-index-card { + border: var(--su1) solid var(--black-225); +} +.docs-index-card .docs-heading { + margin: 0 var(--su16) var(--su4); +} +.docs-index-card .docs-heading + p.docs-copy { + margin: 0 var(--su16) var(--su16); +} + /* Notices in doc content get breathing room below */ .docs .s-notice { margin-bottom: var(--su16); diff --git a/packages/stacks-docs/src/components/EmailOptionsTable.svelte b/packages/stacks-docs/src/components/EmailOptionsTable.svelte new file mode 100644 index 0000000000..df120d7dbd --- /dev/null +++ b/packages/stacks-docs/src/components/EmailOptionsTable.svelte @@ -0,0 +1,88 @@ + + +
+ + + + + + + + + + + {#each resolvedRows as row (row.argument)} + + + + + + + {/each} + +
ArgumentTypeDefaultDescription
{row.argument}{row.type} + {#if hasDefaultValue(row.defaultValue)} + {#if row.defaultValueCode === false} + {row.defaultValue} + {:else} + {row.defaultValue} + {/if} + {/if} + {row.description}
+
diff --git a/packages/stacks-docs/src/components/StacksEmailEmbed.svelte b/packages/stacks-docs/src/components/StacksEmailEmbed.svelte new file mode 100644 index 0000000000..dde89b5281 --- /dev/null +++ b/packages/stacks-docs/src/components/StacksEmailEmbed.svelte @@ -0,0 +1,401 @@ + + +
+
+ + {#each tabOptions as tab (tab.id)} + (activeTab = tab.id)} + /> + {/each} + + + + + {#if activeTab === "preview"} + + {#each viewportOptions as viewport (viewport.id)} + (previewViewport = viewport.id)} + /> + {/each} + + {/if} + + {#if activeTab !== "preview" && (compiled || activeTab === "usage")} + + {/if} +
+ + {#if loading} +

Compiling…

+ {:else if errorMessage} +

{errorMessage}

+ {:else if compiled} + {#if activeTab === "preview"} + + {:else} + {@html highlightedCodeBlock} + {/if} + + {#if showTokens && catalogItem && catalogItem.tokens.length > 0} +
    + {#each catalogItem.tokens as token (token.token)} +
  • + [[{token.token}]] — {token.description} +
  • + {/each} +
+ {/if} + {/if} +
+ + diff --git a/packages/stacks-docs/src/docs/public/email/components/button.md b/packages/stacks-docs/src/docs/public/email/components/button.md new file mode 100644 index 0000000000..338a88f0e3 --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/components/button.md @@ -0,0 +1,27 @@ +--- +title: Button +description: Reusable CTA primitive used across text and card blocks. +--- + + + +## Variants + +### Filled + + + +### Tonal + + + +### Inverted + + + +## Options + + diff --git a/packages/stacks-docs/src/docs/public/email/components/cards.md b/packages/stacks-docs/src/docs/public/email/components/cards.md new file mode 100644 index 0000000000..00bcb2e235 --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/components/cards.md @@ -0,0 +1,26 @@ +--- +title: Cards +description: Multi-card content block documentation is in progress. +--- + + + +## Variants + +Coming soon. + +## Options + + diff --git a/packages/stacks-docs/src/docs/public/email/components/component-button.svg b/packages/stacks-docs/src/docs/public/email/components/component-button.svg new file mode 100644 index 0000000000..99235f9bcf --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/components/component-button.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/stacks-docs/src/docs/public/email/components/component-cards.svg b/packages/stacks-docs/src/docs/public/email/components/component-cards.svg new file mode 100644 index 0000000000..12c58f8932 --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/components/component-cards.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/stacks-docs/src/docs/public/email/components/component-dividers.svg b/packages/stacks-docs/src/docs/public/email/components/component-dividers.svg new file mode 100644 index 0000000000..0890dddbc6 --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/components/component-dividers.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/stacks-docs/src/docs/public/email/components/component-footer.svg b/packages/stacks-docs/src/docs/public/email/components/component-footer.svg new file mode 100644 index 0000000000..aa8bdca855 --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/components/component-footer.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/stacks-docs/src/docs/public/email/components/component-graphic.svg b/packages/stacks-docs/src/docs/public/email/components/component-graphic.svg new file mode 100644 index 0000000000..cee269442e --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/components/component-graphic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/stacks-docs/src/docs/public/email/components/component-header.svg b/packages/stacks-docs/src/docs/public/email/components/component-header.svg new file mode 100644 index 0000000000..2040c8809f --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/components/component-header.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/stacks-docs/src/docs/public/email/components/component-headline.svg b/packages/stacks-docs/src/docs/public/email/components/component-headline.svg new file mode 100644 index 0000000000..e806138fd4 --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/components/component-headline.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/stacks-docs/src/docs/public/email/components/component-link.svg b/packages/stacks-docs/src/docs/public/email/components/component-link.svg new file mode 100644 index 0000000000..9aaee84af8 --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/components/component-link.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/stacks-docs/src/docs/public/email/components/component-preview.svg b/packages/stacks-docs/src/docs/public/email/components/component-preview.svg new file mode 100644 index 0000000000..1353bd6a8a --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/components/component-preview.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/stacks-docs/src/docs/public/email/components/component-spacers.svg b/packages/stacks-docs/src/docs/public/email/components/component-spacers.svg new file mode 100644 index 0000000000..9915a4de39 --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/components/component-spacers.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/stacks-docs/src/docs/public/email/components/component-subtitle.svg b/packages/stacks-docs/src/docs/public/email/components/component-subtitle.svg new file mode 100644 index 0000000000..498ba7d05f --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/components/component-subtitle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/stacks-docs/src/docs/public/email/components/component-text.svg b/packages/stacks-docs/src/docs/public/email/components/component-text.svg new file mode 100644 index 0000000000..65b3b10787 --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/components/component-text.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/stacks-docs/src/docs/public/email/components/component-title.svg b/packages/stacks-docs/src/docs/public/email/components/component-title.svg new file mode 100644 index 0000000000..81fdfad4e7 --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/components/component-title.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/stacks-docs/src/docs/public/email/components/dividers.md b/packages/stacks-docs/src/docs/public/email/components/dividers.md new file mode 100644 index 0000000000..17a04d49be --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/components/dividers.md @@ -0,0 +1,26 @@ +--- +title: Dividers +description: Divider block documentation is in progress. +--- + + + +## Variants + +Coming soon. + +## Options + + diff --git a/packages/stacks-docs/src/docs/public/email/components/footer.md b/packages/stacks-docs/src/docs/public/email/components/footer.md new file mode 100644 index 0000000000..fac75d8135 --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/components/footer.md @@ -0,0 +1,33 @@ +--- +title: Footer +description: Footer scaffolds for dark and light email shells. +--- + + + +## Variants + +### Default + +No reason copy shown. + + + +### Reason + +Includes recipient reason copy. + + + +### Social + +Includes reason copy plus social links. + + + +## Options + + diff --git a/packages/stacks-docs/src/docs/public/email/components/graphic.md b/packages/stacks-docs/src/docs/public/email/components/graphic.md new file mode 100644 index 0000000000..eb84574bd9 --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/components/graphic.md @@ -0,0 +1,27 @@ +--- +title: Graphic +description: Image block variants for spot and hero placements. +--- + + + +## Variants + +### Spot + +140x140 left-aligned PNG placeholder. + + + +### Hero + +1200x630 full-width placeholder with left/right container padding. + + + +## Options + + diff --git a/packages/stacks-docs/src/docs/public/email/components/header.md b/packages/stacks-docs/src/docs/public/email/components/header.md new file mode 100644 index 0000000000..8cbb9dce59 --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/components/header.md @@ -0,0 +1,35 @@ +--- +title: Header +description: Brand strip and utility-nav email header scaffolds. +--- + + + +## Variants + +### Transactional + + + +### Brand + + + +### Center + + + +### Inverted + + + +### Stack Overflow Business + + + +## Options + + diff --git a/packages/stacks-docs/src/docs/public/email/components/headline.md b/packages/stacks-docs/src/docs/public/email/components/headline.md new file mode 100644 index 0000000000..c62a49957f --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/components/headline.md @@ -0,0 +1,25 @@ +--- +title: Headline +description: Large headline block with default and highlighted background treatments. +--- + + + +## Variants + +### Default + + + +### Highlight + +Inline highlighted `` wrapper + + + +## Options + + diff --git a/packages/stacks-docs/src/docs/public/email/components/preview.md b/packages/stacks-docs/src/docs/public/email/components/preview.md new file mode 100644 index 0000000000..a1685e6f79 --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/components/preview.md @@ -0,0 +1,26 @@ +--- +title: Preview Text +description: Template compile option for controlling inbox preheader content. +--- + +Preview text is a template-level option, not a standalone visual component. + +When compiling any template, pass `PREVIEW_TEXT` in `props` to inject `` content: + +```ts +import { compileEmailRenderable } from "@stackoverflow/stacks-email"; + +const compiled = compileEmailRenderable({ + kind: "template", + slug: "transactional", + target: "preview", + props: { + PREVIEW_TEXT: "Reset your password in one click.", + }, +}); + +console.log(compiled.renderedMjml); +// Includes: Reset your password in one click. +``` + +Pass `PREVIEW_TEXT: ""` to omit the preview tag. diff --git a/packages/stacks-docs/src/docs/public/email/components/spacers.md b/packages/stacks-docs/src/docs/public/email/components/spacers.md new file mode 100644 index 0000000000..98314b7396 --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/components/spacers.md @@ -0,0 +1,23 @@ +--- +title: Spacers +description: Vertical spacing primitive built from `mj-spacer` wrapped in a section. +--- + + + +## Variants + +### Medium + + + +### Large + + + +## Options + + diff --git a/packages/stacks-docs/src/docs/public/email/components/subtitle.md b/packages/stacks-docs/src/docs/public/email/components/subtitle.md new file mode 100644 index 0000000000..a77106a42b --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/components/subtitle.md @@ -0,0 +1,26 @@ +--- +title: Subtitle +description: Subtitle component documentation is in progress. +--- + + + +## Variants + +Coming soon. + +## Options + + diff --git a/packages/stacks-docs/src/docs/public/email/components/text.md b/packages/stacks-docs/src/docs/public/email/components/text.md new file mode 100644 index 0000000000..802eb9a625 --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/components/text.md @@ -0,0 +1,24 @@ +--- +title: Text +description: Body copy plus alert, quote, and highlight component examples. +updated: 2026-05-31 +--- + + + +## Variants + +### Default + + + +### Center + + + +## Options + + diff --git a/packages/stacks-docs/src/docs/public/email/components/title.md b/packages/stacks-docs/src/docs/public/email/components/title.md new file mode 100644 index 0000000000..58f814134b --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/components/title.md @@ -0,0 +1,23 @@ +--- +title: Title +description: Section title block with default and inverted background treatments. +--- + + + +## Variants + +### Default + + + +### Invert + + + +## Options + + diff --git a/packages/stacks-docs/src/docs/public/email/overview.md b/packages/stacks-docs/src/docs/public/email/overview.md new file mode 100644 index 0000000000..f67e73c74b --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/overview.md @@ -0,0 +1,279 @@ +--- +title: Email +description: Patterns and guidelines for creating and sending emails to Stack Overflow users & customers. +figma: https://www.figma.com/design/1uHwOvLiVtTwqv9vGegp8a/ +updated: 2026-06-01 +--- + + + +## Introduction + +Emails are a great opportunity to showcase the brand’s personality to loyal users or perspective customers who may otherwise only interact with the product itself. + +From transactional updates to editorial moments, every email is a chance to strengthen familiarity with the brand and build a more connected experience. + +These guidelines are designed to provide a foundation for what those emails can be, while leaving room to evolve and expand over time. + +## Creating emails + +Our documentation is built from components built with [MJML](https://mjml.io/) – an open-source email framework that abstracts away the need to manually code email HTML. [Read the full documentation](https://documentation.mjml.io). + +## Templates + +We have a range of email templates, each serving a distinct purpose while demonstrating how communication can scale from functional to expressive. + +
+ + Functional + + + — + + + Expressive + +
+ + + + +[![Transactional email template preview](./templates/email-template-transactional.png)](./templates/transactional) + +### [Transactional](./templates/transactional) + +A transactional email is functional. It is triggered by an event and usually is a short single message and call to action. + + + + +[![Newsletter email template preview](./templates/email-template-newsletter.png)](./templates/newsletter) + +### [Newsletter](./templates/newsletter) + +A newsletter is a recurring pieces of comms that may contain various items and call to actions. + + + + +[![Promotional email template preview](./templates/email-template-promotional.png)](./templates/promotional) + +### [Promotional](./templates/promotional) + +Typically single-message communications - short, punchy, and to the point — designed to quickly capture attention and drive engagement. + + + + + +## Components + +Each email is built from reusable component blocks. The set below is the canonical starting library. + + + + +[![Header component preview](./components/component-header.svg)](./components/header) + +### [Header](./components/header) + +Top brand strip and utility nav variations. + + + + +[![Footer component preview](./components/component-footer.svg)](./components/footer) + +### [Footer](./components/footer) + +Legal metadata and recipient preference links. + + + + +[![Title component preview](./components/component-title.svg)](./components/title) + +### [Title](./components/title) + +Section title treatments. + + + + +[![Headline component preview](./components/component-headline.svg)](./components/headline) + +### [Headline](./components/headline) + +Large hero headline treatments. + + + + +[![Button component preview](./components/component-button.svg)](./components/button) + +### [Button](./components/button) + +Reusable CTA primitive used across blocks. + + + + +[![Subtitle component preview](./components/component-subtitle.svg)](./components/subtitle) + +### [Subtitle](./components/subtitle) + +Supporting labels and secondary lines. + + + + +[![Text component preview](./components/component-text.svg)](./components/text) + +### [Text](./components/text) + +Body copy plus alert, quote, and highlight component examples. + + + + +[![Cards component preview](./components/component-cards.svg)](./components/cards) + +### [Cards](./components/cards) + +Simple, link, and CTA card layouts. + + + + + +[![Graphic component preview](./components/component-graphic.svg)](./components/graphic) + +### [Graphic](./components/graphic) + +Standalone illustration placeholder block. + + + + +[![Dividers component preview](./components/component-dividers.svg)](./components/dividers) + +### [Dividers](./components/dividers) + +Subtle and strong horizontal separators. + + + + +[![Spacers component preview](./components/component-spacers.svg)](./components/spacers) + +### [Spacers](./components/spacers) + +Preset vertical rhythm utilities. + + + + + +## Usage + + +

Warning: This functionality is experimental and may change. + + +If you are running the `@stackoverflow/stacks-email` package, you can compose and render email markup by POSTing a JSON block list to the compile API. + +### POST /api/compile + +**Paramaters** + +- `template`: currently supports `"transactional"`. +- `target`: one of `"preview"`, `"dotnet"`, or `"braze"`. +- `blocks`: ordered array of block definitions. +- `previewText`: optional template preheader/inbox snippet text. + +**Example request:** + +```json +{ + "template": "transactional", + "target": "preview", + "previewText": "Reset your password in one click.", + "blocks": [ + { + "type": "headline", + "variant": "highlight", + "props": { + "textContent": "Reset your password", + "textHighlight": true + } + }, + { + "type": "text", + "variant": "body", + "props": { + "textContent": "Hi [[FIRST_NAME]], click below to continue." + } + }, + { + "type": "button", + "variant": "primary", + "props": { + "href": "[[CTA_URL]]", + "text": "Reset password" + } + } + ] +} +``` + +
+ +**Response** + +Successful responses include compiled `html`, final `mjml`, `renderedMjml`, compile `errors`, and metadata such as `template`, `target`, and `blockCount`. + +
+ +## Target clients + +[Litmus](https://www.litmus.com/) publishes a regularly updated list of [email clients and their observed market share](https://www.litmus.com/email-client-market-share), you can use this as a rough guideline when testing and making decisons about compatability. + +| Client | Share (%) | +| -------------- | --------- | +| Apple | 45.51 | +| Gmail | 23.54 | +| Outlook | 5.67 | +| Yahoo Mail | 2.06 | +| Google Android | 1.34 | +| Outlook.com | 0.40 | +| Thunderbird | 0.17 | +| Orange.fr | 0.08 | +| Bell Email | 0.02 | +| Samsung Mail | 0.02 | + +

+ +## Other resources + +### [Email gallery](https://email.stackoverflow.design/) + +[email.stackoverflow.design](https://email.stackoverflow.design/) + +Our own gallery of email designs. [These templates](https://github.com/StackExchange/Stacks/tree/main/packages/stacks-email/templates) are used to build the examples in this section lives along side the [email components](https://github.com/StackExchange/Stacks/tree/main/packages/stacks-email/components) so feel free to add your templates back in to serve as inspiration for others. + +### [Can I email?](https://www.caniemail.com/) + +[caniemail.com](https://www.caniemail.com/) + +On occasion you may need to hard code some elements of email, if straying from the components here or implementing something ourside of the scope of MJML. ‘Can I email’ is a great resource for dealing with the eccentricities of email development. + +### [Really Good Emails](https://reallygoodemails.com/) + +[reallygoodemails.com](https://reallygoodemails.com/) + +A constantly evolving gallery of email designs from across the web. Espeically useful for designers or product managers planning out a new email. diff --git a/packages/stacks-docs/src/docs/public/email/templates/email-template-newsletter.png b/packages/stacks-docs/src/docs/public/email/templates/email-template-newsletter.png new file mode 100644 index 0000000000000000000000000000000000000000..b8379f91aec5b43e0c4c351ab5cde4a5347596cd GIT binary patch literal 42877 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¨V+iz5lNasd=<*H_ppMEZ=l{myDDovIgCn|- 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 literal 0 HcmV?d00001 diff --git a/packages/stacks-docs/src/docs/public/email/templates/email-template-transactional.png b/packages/stacks-docs/src/docs/public/email/templates/email-template-transactional.png new file mode 100644 index 0000000000000000000000000000000000000000..f71af8f428e519ebe7b4f62f19e1e52486de20c7 GIT binary patch literal 50358 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 + import { Icon } from "@stackoverflow/stacks-svelte"; + import { IconCheckFillCircle } from "@stackoverflow/stacks-icons/icons"; + + +## Requirements + +See the overview below outlining the required components, their variations, and any optional add-ons that may be needed. + +

+ +**Coming soon** diff --git a/packages/stacks-docs/src/docs/public/email/templates/promotional.md b/packages/stacks-docs/src/docs/public/email/templates/promotional.md new file mode 100644 index 0000000000..20693665bd --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/templates/promotional.md @@ -0,0 +1,128 @@ +--- +title: Promotional +description: Typically single-message communications - short, punchy, and to the point — designed to quickly capture attention and drive engagement. +updated: 2026-06-01 +--- + + + +## Requirements + +See the overview below outlining the required components, their variations, and any optional add-ons that may be needed. + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ComponentQuantityDescriptionRequired
Header component1 + Use the Stack Overflow wordmark and icon on every email. For B2B-focused sends, use the Stack Overflow Business header. + + +
Headline components (5 variants)1 + Select the headline variant that best matches campaign content. Keep copy concise and impactful; this often carries the primary message. + + +
Footer1End all emails with a simple, branded footer. + +
Text block (2 variants)0-1 + Optional supporting copy block. Keep content concise, include links where relevant, and focus on one clear CTA. +
Secondary content0-1 + Optional secondary module for additional, unrelated content when needed. +
DividersAs needed + Use visual dividers to separate repeated simple-card style blocks. +
CTA cards0-2 + Optional graphic-led cards combining short copy and a clear CTA. +
Link cards0-4 + Optional cards for highlighting resources without extra assets or long context. +
Secondary information (3 variants)0-1 + Optional secondary information block with variant styles for different contexts. +
Quote0-1 + Optional quote block for additional context or color, limited to one per email. +
Highlights0-1 + Optional text-plus-illustration block for an eye-catching end section. +
+
+ +**Coming soon** diff --git a/packages/stacks-docs/src/docs/public/email/templates/transactional.md b/packages/stacks-docs/src/docs/public/email/templates/transactional.md new file mode 100644 index 0000000000..d360eb90a0 --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/templates/transactional.md @@ -0,0 +1,95 @@ +--- +title: Transactional +description: A transactional email is functional. It is triggered by an event and usually is a short single message and call to action. +updated: 2026-06-01 +--- + + + +## Requirements + +See the overview below outlining the required components, their variations, and any optional add-ons that may be needed. + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ComponentQuantityDescriptionRequired
Headerx 1Use the Stack Overflow wordmark and icon at the top of every email. + +
Headline (2 variants)x 1 + Keep headline copy ideally under 50 characters and make the core message clear without relying on body text. + + +
Text block + primary CTAx 1 + Body copy can be longer but should stay concise, include links where needed, and focus on one clear CTA per transactional email. + + +
Footerx 1End all emails with a simple branded footer and utility links. + +
Illustrationx 1 + Optional branded illustration for additional context. Use no more than one. +
Alertx 1 + Optional secondary message not directly tied to the primary communication. Keep copy under 150 characters where possible and use no more than one. +
+
+ +## Short + + + +## Long + + diff --git a/packages/stacks-docs/src/routes/[category]/[[section]]/[subsection]/+page.svelte b/packages/stacks-docs/src/routes/[category]/[[section]]/[subsection]/+page.svelte index 5e26e3c260..59bcfaed35 100644 --- a/packages/stacks-docs/src/routes/[category]/[[section]]/[subsection]/+page.svelte +++ b/packages/stacks-docs/src/routes/[category]/[[section]]/[subsection]/+page.svelte @@ -13,8 +13,16 @@ day: 'numeric' })); - const pageTitle = $derived(data.active.title ? `${data.active.title} - Stack Overflow Design System` : 'Stack Overflow Design System'); - const pageDescription = $derived(data?.metadata?.description || `Documentation for ${data.active.title} in the Stack Overflow Design System`); + const activeTitle = $derived.by(() => { + const metadataTitle = + typeof data?.metadata?.title === 'string' ? data.metadata.title : undefined; + return data?.active?.title ?? metadataTitle ?? 'Documentation'; + }); + const pageTitle = $derived(`${activeTitle} - Stack Overflow Design System`); + const pageDescription = $derived( + data?.metadata?.description || + `Documentation for ${activeTitle} in the Stack Overflow Design System` + ); async function copyPageUrl() { await navigator.clipboard.writeText(page.url.href); @@ -85,7 +93,7 @@ {/if}

- {data.active.title} + {activeTitle}

{#if data?.metadata?.description} diff --git a/packages/stacks-docs/src/routes/api/email/catalog/+server.ts b/packages/stacks-docs/src/routes/api/email/catalog/+server.ts new file mode 100644 index 0000000000..51fe5db41e --- /dev/null +++ b/packages/stacks-docs/src/routes/api/email/catalog/+server.ts @@ -0,0 +1,6 @@ +import { json } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; + +import { getEmailCatalog } from "@stackoverflow/stacks-email"; + +export const GET: RequestHandler = async () => json(getEmailCatalog()); diff --git a/packages/stacks-docs/src/routes/api/email/compile/+server.ts b/packages/stacks-docs/src/routes/api/email/compile/+server.ts new file mode 100644 index 0000000000..f7268face9 --- /dev/null +++ b/packages/stacks-docs/src/routes/api/email/compile/+server.ts @@ -0,0 +1,66 @@ +import { json } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; +import { env } from "$env/dynamic/private"; + +import { + compileEmailRenderable, + compileEmailRenderableInputSchema, +} from "@stackoverflow/stacks-email"; + +const hasValidBearerToken = (request: Request): boolean => { + const expectedToken = env.STACKS_EMAIL_AUTH_TOKEN?.trim(); + if (!expectedToken) { + return true; + } + + const authorization = request.headers.get("authorization"); + if (!authorization) { + return false; + } + + const [scheme, token] = authorization.trim().split(/\s+/, 2); + return scheme?.toLowerCase() === "bearer" && token === expectedToken; +}; + +export const POST: RequestHandler = async ({ request }) => { + if (!hasValidBearerToken(request)) { + return json({ error: "Unauthorized." }, { status: 401 }); + } + + let body: unknown; + + try { + body = await request.json(); + } catch { + return json( + { error: "Request body must be valid JSON." }, + { status: 400 } + ); + } + + const parsed = compileEmailRenderableInputSchema.safeParse(body); + if (!parsed.success) { + return json( + { + error: parsed.error.issues.map((issue) => issue.message).join(" "), + }, + { status: 400 } + ); + } + + try { + const compiled = compileEmailRenderable(parsed.data); + + return json(compiled); + } catch (error) { + return json( + { + error: + error instanceof Error + ? error.message + : "Failed to compile email renderable.", + }, + { status: 404 } + ); + } +}; diff --git a/packages/stacks-docs/src/structure.yaml b/packages/stacks-docs/src/structure.yaml index 3dd553d43b..28eec5aa13 100644 --- a/packages/stacks-docs/src/structure.yaml +++ b/packages/stacks-docs/src/structure.yaml @@ -321,18 +321,63 @@ navigation: - title: "Vote" slug: "vote" - # - title: "Email" - # slug: "email" - # description: "Patterns and guidelines for creating and sending emails to Stack Overflow users." - # items: - # - title: "Account" - # slug: "account" + - title: "Email" + slug: "email" + description: "Patterns and tooling for composing tokenized MJML emails." + items: + - title: "Overview" + slug: "overview" + image: "/images/heros/email-overview.svg" + + - title: "Templates" + slug: "templates" + image: "/images/heros/email-types.svg" + items: + - title: "Transactional" + slug: "transactional" + + - title: "Newsletter" + slug: "newsletter" + + - title: "Promotional" + slug: "promotional" + + - title: "Components" + slug: "components" + image: "/images/heros/email-authoring.svg" + items: + - title: "Header" + slug: "header" + + - title: "Footer" + slug: "footer" - # - title: "Transactional" - # slug: "tranactional" + - title: "Headline" + slug: "headline" - # - title: "Marketing" - # slug: "marketing" + - title: "Title" + slug: "title" + + - title: "Subtitle" + slug: "subtitle" + + - title: "Text" + slug: "text" + + - title: "Cards" + slug: "cards" + + - title: "Graphic" + slug: "graphic" + + - title: "Dividers" + slug: "dividers" + + - title: "Spacers" + slug: "spacers" + + - title: "Button" + slug: "button" - title: "Handbook" slug: "handbook" @@ -366,6 +411,10 @@ navigation: slug: "icons" image: "/images/heros/product.svg" + - title: "Email gallery" + slug: "emails" + externalUrl: https://email.stackoverflow.design + - title: "Trademark guidelines" slug: "trademarks" image: "/images/heros/strategy.svg" diff --git a/packages/stacks-docs/static/email b/packages/stacks-docs/static/email new file mode 120000 index 0000000000..cb3a3d36a4 --- /dev/null +++ b/packages/stacks-docs/static/email @@ -0,0 +1 @@ +../../stacks-email/static/email \ No newline at end of file diff --git a/packages/stacks-docs/static/images/heros/email-authoring.svg b/packages/stacks-docs/static/images/heros/email-authoring.svg new file mode 100644 index 0000000000..a2e8cdfb4f --- /dev/null +++ b/packages/stacks-docs/static/images/heros/email-authoring.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/stacks-docs/static/images/heros/email-components.svg b/packages/stacks-docs/static/images/heros/email-components.svg new file mode 100644 index 0000000000..b908f5b815 --- /dev/null +++ b/packages/stacks-docs/static/images/heros/email-components.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/stacks-docs/static/images/heros/email-overview.svg b/packages/stacks-docs/static/images/heros/email-overview.svg new file mode 100644 index 0000000000..27504f8eb4 --- /dev/null +++ b/packages/stacks-docs/static/images/heros/email-overview.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/packages/stacks-docs/static/images/heros/email-types.svg b/packages/stacks-docs/static/images/heros/email-types.svg new file mode 100644 index 0000000000..2827983609 --- /dev/null +++ b/packages/stacks-docs/static/images/heros/email-types.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/stacks-docs/static/social/instagram.png b/packages/stacks-docs/static/social/instagram.png new file mode 100644 index 0000000000000000000000000000000000000000..c91e85e8215304c3a91022869e4d648d8a741807 GIT binary patch literal 906 zcmV;519kj~P)P`@6f|3cK6Eqv7nV`)C z&7*dps3*kP<%&%+!v=f%iu4CvP+v83Mif?FqCw~H6wh${8UpvBfyG_Ul6t-ke;LWeK?zoa5 z9Y>opvMf>$>-BmLOKf<#*h*Z-yO-s1Io5qUoz9Hjav>uFv8hAHdt1ke4?-~eU&M7d zZCM(!oxBKtf=m*Af-*BR0d($lU+5fE5!9VCbncNqIaa8o9$Vi^GXZpNNLJ1^iff5Y zg-%%Ha^*Z3lshQ{y!%9lCCw+~^35t;WdxlNzluz9u#B?%;oX$KnIsVy_Y{a=f^hE7 zx())R`zU`COX~hN6sj+zv)-ztnX;9t#)x(G5+)_fOcxWR13AVk`9m4?hyoBw@T^0E zg+?uy8ubu=GA+NL00a?~J!-=oZxSW`Ul-c6hBcQ z&Pg-h$V$NGo@QXxRS3<|ZQ0rEg%Cj)qBmtYd1PR%p6mN2tu9H5c zOI(&zS)sA#x*}7-%8ykia!bca0KIdJVP%ojIXx&Uf%w(b*kxEr8W62<5JAlP6|>Hd z{c`G6_8|(5H1j+u#4{rKvzy*riE2C~0=(92ZeD2}nOnrV5UcFQjpJ@J~WX&X@S(&Udjw#F_hschCpEZpSeCL!kJ2&+Jp|Br5i;5`m| zasVfS1KpOUX^2CM9AFg)h7j~u{y6WWU_Qwag+{&G?Xokz$Fzo2BPhlbP24skY0+hA zP4J6z_m}Fk>^74(pm0X8y{gLy=ac+6mcY9dBkd@tC_g6GuNr3wWkpbLGk9OihJAJ% zeKJNXdREnLde%Lbl3d3_`VWC+>2|vln~#hxBKd9e<5Y1is7OXOzuE+1txb`}RCWcV ze1+>mfgyrlw&&C$Q0^%)`GQo{Bb{Ru)|0{2`ZlUXAO!U}HV$i%n5-wg~ zRlLBec!5>nY!+I0Rn7ATt;}?25o5(z;l5qe4002ovPDHLkV1nF)|EvH2 literal 0 HcmV?d00001 diff --git a/packages/stacks-docs/static/social/threads.png b/packages/stacks-docs/static/social/threads.png new file mode 100644 index 0000000000000000000000000000000000000000..c9c391aaab100d6dbbd0104c55da0230b24a1cf1 GIT binary patch literal 1111 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 + node?.data?.disableHeadingAnchor !== true, properties: { className: ["docs-heading-anchor"], ariaHidden: "true", @@ -79,6 +81,134 @@ function exposeToc() { }; } +const VOID_TAGS = new Set([ + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "param", + "source", + "track", + "wbr", +]); + +const RAW_TAG_PATTERN = + /<\/?([A-Za-z][A-Za-z0-9:._-]*)(?:(?:\s[^<>]*)?)\/?\s*>/g; + +function headingRank(node) { + if (node?.type !== "element" || !/^h[1-6]$/.test(node.tagName)) { + return null; + } + return Number.parseInt(node.tagName.slice(1), 10); +} + +function updateRawTagStack(raw, stack) { + RAW_TAG_PATTERN.lastIndex = 0; + + for (const match of raw.matchAll(RAW_TAG_PATTERN)) { + const fullMatch = match[0]; + const tagName = match[1]; + const isClosing = fullMatch.startsWith(""); + const isVoid = VOID_TAGS.has(tagName.toLowerCase()); + + if (isClosing) { + const lastIndex = stack.lastIndexOf(tagName); + if (lastIndex !== -1) { + stack.splice(lastIndex, 1); + } + continue; + } + + if (!isSelfClosing && !isVoid) { + stack.push(tagName); + } + } +} + +function isInsideTag(rawTagStack, tagName) { + const target = tagName.toLowerCase(); + return rawTagStack.some((openTag) => openTag.toLowerCase() === target); +} + +// Mark headings inside blocks so autolink anchors can skip them. +function markHeadingsInsideGrid() { + return function (tree) { + const rawTagStack = []; + + for (const node of tree.children) { + if (node.type === "raw") { + updateRawTagStack(node.value, rawTagStack); + } + + const rank = headingRank(node); + if (rank !== null && isInsideTag(rawTagStack, "grid")) { + node.data = { ...node.data, disableHeadingAnchor: true }; + } + } + }; +} + +// Sectionize top-level markdown headings, but skip headings rendered inside raw +// blocks/components (e.g. markdown inside slots). +function sectionizeTopLevelHeadings() { + const createSection = (rank, headingNode = null) => { + const headingId = headingNode?.properties?.id; + + return { + type: "element", + tagName: "section", + properties: { + className: ["heading"], + dataHeadingRank: rank, + ...(typeof headingId === "string" + ? { ariaLabelledby: headingId } + : {}), + }, + children: headingNode ? [headingNode] : [], + }; + }; + + return function (tree) { + const rootWrapper = createSection(0); + const wrapperStack = [rootWrapper]; + const rawTagStack = []; + + const currentWrapper = () => wrapperStack[wrapperStack.length - 1]; + const currentRank = () => currentWrapper().properties.dataHeadingRank; + + for (const node of tree.children) { + if (node.type === "raw") { + updateRawTagStack(node.value, rawTagStack); + } + + const rank = headingRank(node); + const shouldSectionize = rank !== null && rawTagStack.length === 0; + + if (!shouldSectionize) { + currentWrapper().children.push(node); + continue; + } + + while (rank <= currentRank()) { + wrapperStack.pop(); + } + + const section = createSection(rank, node); + currentWrapper().children.push(section); + wrapperStack.push(section); + } + + tree.children = rootWrapper.children; + }; +} + // Custom plugin to add individual docs-* classes to generated elements. // These replace the old .doc X descendant-selector pattern so each element // carries its own class and does not depend on a parent .doc wrapper. diff --git a/packages/stacks-email/README.md b/packages/stacks-email/README.md new file mode 100644 index 0000000000..c253887b51 --- /dev/null +++ b/packages/stacks-email/README.md @@ -0,0 +1,96 @@ +# Stacks Email + +Stack Overflow’s MJML powered email compile engine with a tokenized component library and template library + +- Primary docs/UI lives in `@stackoverflow/stacks-docs` under [`src/docs/public/email`](https://github.com/StackExchange/Stacks/tree/main/packages/stacks-docs/src/docs/public/email) or available on [stackoverflow.design/email](https://stackoverflow.design/email/). +- Full email preview gallery is available at [email.stackoverflow.design](https://email.stackoverflow.design). + +## Token placeholders + +Template authors should use the neutral placeholder syntax: + +- `[[FIRST_NAME]]` +- `[[CTA_URL]]` +- `[[UNSUBSCRIBE_URL]]` +- `[[COMPANY_NAME]]` + +During compilation, placeholders are transformed by target: + +- `preview` -> concrete example values +- `dotnet` -> Razor expressions +- `braze` -> Liquid expressions + +## Component render standard + +Component partials are compiled inside a shared MJML wrapper so they inherit global classes/styles from `mjml-config.ts`: + +```mjml + + + + + + + + +``` + +For component compiles, marker comments are injected via `mj-raw`: + +```mjml + + + + + +``` + +The pipeline extracts HTML between markers as `componentHtml` for copy/paste use, while keeping full `html` for previews. + +## Public API usage + +```ts +import { + getEmailCatalog, + compileEmailRenderable, +} from "@stackoverflow/stacks-email"; + +const catalog = getEmailCatalog(); + +const compiled = compileEmailRenderable({ + kind: "component", + slug: "button", + target: "preview", +}); + +// Full document (for iframe preview) +const fullHtml = compiled.html; + +// Extracted component fragment (for component copy/paste) +if (compiled.kind === "component") { + const fragmentHtml = compiled.componentHtml; +} +``` + +## Run local sandbox + +```bash +npm install +npm run dev -w @stackoverflow/stacks-email +``` + +## API auth (optional) + +`POST /api/compile` supports an optional shared Bearer token. + +- If `STACKS_EMAIL_AUTH_TOKEN` is not set: auth is disabled. +- If `STACKS_EMAIL_AUTH_TOKEN` is set: the request must include `Authorization: Bearer `. + +Example: + +```bash +curl -X POST http://localhost:5173/api/compile \ + -H "Authorization: Bearer $STACKS_EMAIL_AUTH_TOKEN" \ + -H "Content-Type: application/json" \ + --data '{"template":"transactional","target":"preview","blocks":[{"type":"headline"}]}' +``` diff --git a/packages/stacks-email/components/button.ts b/packages/stacks-email/components/button.ts new file mode 100644 index 0000000000..4af61d7b96 --- /dev/null +++ b/packages/stacks-email/components/button.ts @@ -0,0 +1,150 @@ +import type { EmailComponentMeta, MjmlNode } from "../types"; +import { z } from "zod/v4"; + +import { tokens } from "../tokens"; +import { Section } from "./section"; + +const buttonOptionsSchema = z.object({ + align: z.string().optional(), + className: z.string().optional(), + cssClass: z.string().optional(), + href: z.string().optional(), + text: z.string().optional(), +}); + +type ButtonOptions = z.input; + +const buttonOptionRows: NonNullable = [ + { + argument: "variant", + type: '"primary" | "secondary" | "inverted"', + description: "Selects the button style baseline.", + }, + { + argument: "options.align", + type: "string", + defaultValue: "From selected variant", + defaultValueCode: false, + description: "Maps to the MJML align attribute.", + }, + { + argument: "options.className", + type: "string", + defaultValue: "From selected variant", + defaultValueCode: false, + description: "Button mj-class override.", + }, + { + argument: "options.cssClass", + type: "string", + defaultValue: "From selected variant", + defaultValueCode: false, + description: "Raw CSS class applied to the button node.", + }, + { + argument: "options.href", + type: "string", + defaultValue: "[[BUTTON_URL]]", + defaultValueCode: true, + description: "Target link URL.", + }, + { + argument: "options.text", + type: "string", + defaultValue: "[[BUTTON_LABEL]]", + defaultValueCode: true, + description: "Button label content.", + }, +]; + +export const meta: EmailComponentMeta = { + slug: "button", + defaultVariant: "primary", + htmlExtraction: { + targetTag: "mj-button", + }, + variants: [ + { + id: "primary", + props: { + BUTTON_CLASS: "button", + BUTTON_HOVER_CLASS: "button-hover", + BUTTON_TEXT: "Filled button", + ALIGNMENT: "left", + }, + }, + { + id: "secondary", + props: { + BUTTON_CLASS: "button button__tonal", + BUTTON_HOVER_CLASS: "button-hover", + BUTTON_TEXT: "Tonal button", + ALIGNMENT: "left", + }, + }, + { + id: "inverted", + props: { + BUTTON_CLASS: "button button__inverted", + BUTTON_HOVER_CLASS: "button-hover-inverted", + BUTTON_TEXT: "Inverted button", + ALIGNMENT: "left", + }, + }, + ], + tokens: [ + { + token: "BUTTON_LABEL", + description: "The text displayed for the button.", + }, + { + token: "BUTTON_URL", + description: "Destination URL for the button.", + }, + ], + options: buttonOptionRows, +}; + +export type ButtonVariantId = "primary" | "secondary" | "inverted"; + +const getButtonVariantProps = (variant: ButtonVariantId) => + meta.variants.find((entry) => entry.id === variant)?.props ?? + meta.variants[0].props; + +export const Button = ( + variant: ButtonVariantId, + options: ButtonOptions = {} +): MjmlNode => { + const parsedOptions = buttonOptionsSchema.safeParse(options); + const normalizedOptions = parsedOptions.success ? parsedOptions.data : options; + const variantDefaults = getButtonVariantProps(variant); + + return { + tagName: "mj-button", + attributes: { + "mj-class": normalizedOptions.className ?? variantDefaults.BUTTON_CLASS, + "css-class": + normalizedOptions.cssClass ?? variantDefaults.BUTTON_HOVER_CLASS, + "href": normalizedOptions.href ?? "[[BUTTON_URL]]", + "align": normalizedOptions.align ?? variantDefaults.ALIGNMENT, + "padding": `0px ${tokens.layout.containerXPadding}`, + }, + content: normalizedOptions.text ?? "[[BUTTON_LABEL]]", + }; +}; + +export const source: MjmlNode[] = [ + Section([ + Button("primary", { + className: "{{BUTTON_CLASS}}", + cssClass: "{{BUTTON_HOVER_CLASS}}", + href: "[[BUTTON_URL]]", + align: "{{ALIGNMENT}}", + text: "[[BUTTON_LABEL]]", + }), + ]), +]; + +export const definition = { meta, source } as const; + +export default definition; diff --git a/packages/stacks-email/components/footer.ts b/packages/stacks-email/components/footer.ts new file mode 100644 index 0000000000..b055e03a83 --- /dev/null +++ b/packages/stacks-email/components/footer.ts @@ -0,0 +1,425 @@ +import { Header, type HeaderVariantId } from "./header"; +import type { EmailComponentMeta, MjmlNode } from "../types"; +import { z } from "zod/v4"; + +import { tokens } from "../tokens"; + +export type FooterVariantId = "default" | "reason" | "social"; + +const footerOptionsSchema = z.object({ + wrapperClass: z.string().optional(), + textClass: z.string().optional(), + linkClass: z.string().optional(), + headerVariant: z + .enum(["transactional", "brand", "brand-center", "inverted", "business"]) + .optional(), + logoSrc: z.string().optional(), + logoAlt: z.string().optional(), + logoUrl: z.string().optional(), + logoWidth: z.string().optional(), + unsubscribeUrl: z.string().optional(), + settingsUrl: z.string().optional(), + contactUrl: z.string().optional(), + privacyUrl: z.string().optional(), + addressHtml: z.string().optional(), + reasonText: z.string().optional(), + reasonPadding: z.string().optional(), + socialClass: z.string().optional(), + showSocialIcons: z.union([z.boolean(), z.string()]).optional(), + socialIconBasePath: z.string().optional(), +}); + +type FooterOptions = z.input; + +const footerOptionRows: NonNullable = [ + { + argument: "variant", + type: '"default" | "reason" | "social"', + description: "Selects reason/social behavior and baseline classes.", + }, + { + argument: "options.wrapperClass", + type: "string", + defaultValue: "From selected variant", + defaultValueCode: false, + description: "Wrapper mj-class on mj-wrapper.", + }, + { + argument: "options.textClass", + type: "string", + defaultValue: "From selected variant", + defaultValueCode: false, + description: "Body text class for footer copy rows.", + }, + { + argument: "options.linkClass", + type: "string", + defaultValue: "From selected variant", + defaultValueCode: false, + description: "Link class for unsubscribe/settings/contact/privacy links.", + }, + { + argument: "options.headerVariant", + type: "HeaderVariantId", + defaultValue: "inverted", + defaultValueCode: true, + description: "Variant passed through to nested Header.", + }, + { + argument: "options.unsubscribeUrl", + type: "string", + defaultValue: "[[UNSUBSCRIBE_URL]]", + defaultValueCode: true, + description: "Recipient-specific unsubscribe destination.", + }, + { + argument: "options.settingsUrl", + type: "string", + defaultValue: "From selected variant", + defaultValueCode: false, + description: "Email settings URL override.", + }, + { + argument: "options.contactUrl", + type: "string", + defaultValue: "From selected variant", + defaultValueCode: false, + description: "Contact URL override.", + }, + { + argument: "options.privacyUrl", + type: "string", + defaultValue: "From selected variant", + defaultValueCode: false, + description: "Privacy policy URL override.", + }, + { + argument: "options.reasonText", + type: "string", + defaultValue: "From selected variant", + defaultValueCode: false, + description: "Optional recipient-reason copy block.", + }, + { + argument: "options.showSocialIcons", + type: "boolean | string", + defaultValue: "From selected variant", + defaultValueCode: false, + description: "Shows or hides social icon row.", + }, + { + argument: "options.socialIconBasePath", + type: "string", + defaultValue: "/email/social", + defaultValueCode: true, + description: "Base path for social icon image assets.", + }, + { + argument: "options.addressHtml", + type: "string", + defaultValue: "14 Wall Street, 20th Floor, New York, NY 10005", + defaultValueCode: true, + description: "Footer address line HTML/text.", + }, +]; + +export const meta: EmailComponentMeta = { + slug: "footer", + defaultVariant: "default", + variants: [ + { + id: "default", + props: { + FOOTER_WRAPPER_CLASS: "bg-invert", + FOOTER_TEXT_CLASS: "fc-text-footer", + FOOTER_LINK_CLASS: "footer-link fc-text-footer", + FOOTER_REASON_TEXT: "", + FOOTER_SOCIAL_ICONS: "false", + FOOTER_SOCIAL_CLASS: "footer-social-hidden", + FOOTER_SOCIAL_ICON_BASE_PATH: "/email/social", + SETTINGS_URL: + "https://stackoverflow.com/users/email/settings/current", + CONTACT_URL: "https://stackoverflow.com/company/contact", + PRIVACY_URL: "https://stackoverflow.com/legal/privacy-policy", + }, + }, + { + id: "reason", + props: { + FOOTER_WRAPPER_CLASS: "bg-invert", + FOOTER_TEXT_CLASS: "fc-text-footer", + FOOTER_LINK_CLASS: "footer-link fc-text-footer", + FOOTER_REASON_TEXT: + "You’re receiving this email because [[FOOTER_REASON]]", + FOOTER_SOCIAL_ICONS: "false", + FOOTER_SOCIAL_CLASS: "footer-social-hidden", + FOOTER_SOCIAL_ICON_BASE_PATH: "/email/social", + SETTINGS_URL: + "https://stackoverflow.com/users/email/settings/current", + CONTACT_URL: "https://stackoverflow.com/company/contact", + PRIVACY_URL: "https://stackoverflow.com/legal/privacy-policy", + }, + }, + { + id: "social", + props: { + FOOTER_WRAPPER_CLASS: "bg-invert", + FOOTER_TEXT_CLASS: "fc-text-footer", + FOOTER_LINK_CLASS: "footer-link fc-text-footer", + FOOTER_REASON_TEXT: + "You’re receiving this email because [[FOOTER_REASON]]", + FOOTER_SOCIAL_ICONS: "true", + FOOTER_SOCIAL_CLASS: "footer-social-visible", + FOOTER_SOCIAL_ICON_BASE_PATH: "/email/social", + SETTINGS_URL: + "https://stackoverflow.com/users/email/settings/current", + CONTACT_URL: "https://stackoverflow.com/company/contact", + PRIVACY_URL: "https://stackoverflow.com/legal/privacy-policy", + }, + }, + ], + tokens: [ + { + token: "UNSUBSCRIBE_URL", + description: "Recipient-specific unsubscribe destination", + }, + { + token: "FOOTER_REASON", + description: "Recipient-specific reason for receiving the email", + }, + ], + options: footerOptionRows, +}; + +const getFooterVariantProps = (variant: FooterVariantId) => + meta.variants.find((entry) => entry.id === variant)?.props ?? + meta.variants[0].props; + +export const Footer = ( + variant: FooterVariantId, + options: FooterOptions = {} +): MjmlNode => { + const parsedOptions = footerOptionsSchema.safeParse(options); + const normalizedOptions = parsedOptions.success ? parsedOptions.data : options; + const defaults = getFooterVariantProps(variant); + + const wrapperClass = + normalizedOptions.wrapperClass ?? defaults.FOOTER_WRAPPER_CLASS; + const textClass = normalizedOptions.textClass ?? defaults.FOOTER_TEXT_CLASS; + const linkClass = normalizedOptions.linkClass ?? defaults.FOOTER_LINK_CLASS; + + const unsubscribeUrl = normalizedOptions.unsubscribeUrl ?? "[[UNSUBSCRIBE_URL]]"; + const settingsUrl = normalizedOptions.settingsUrl ?? defaults.SETTINGS_URL; + const contactUrl = normalizedOptions.contactUrl ?? defaults.CONTACT_URL; + const privacyUrl = normalizedOptions.privacyUrl ?? defaults.PRIVACY_URL; + const reasonText = normalizedOptions.reasonText ?? defaults.FOOTER_REASON_TEXT; + const hasReasonText = reasonText.trim().length > 0; + const socialClass = normalizedOptions.socialClass ?? defaults.FOOTER_SOCIAL_CLASS; + const socialFlag = + normalizedOptions.showSocialIcons ?? defaults.FOOTER_SOCIAL_ICONS; + const showSocialIcons = String(socialFlag).trim().toLowerCase() === "true"; + const socialIconBasePath = + normalizedOptions.socialIconBasePath ?? + defaults.FOOTER_SOCIAL_ICON_BASE_PATH; + const addressHtml = + normalizedOptions.addressHtml ?? + "14 Wall Street, 20th Floor, New York, NY 10005"; + + const children: MjmlNode[] = [ + Header(normalizedOptions.headerVariant ?? "inverted", { + logoSrc: normalizedOptions.logoSrc, + logoAlt: normalizedOptions.logoAlt, + logoUrl: normalizedOptions.logoUrl, + logoWidth: normalizedOptions.logoWidth, + }), + ]; + + if (showSocialIcons) { + children.push({ + tagName: "mj-section", + attributes: { + "css-class": socialClass, + }, + children: [ + { + tagName: "mj-column", + children: [ + { + tagName: "mj-social", + attributes: { + align: "left", + "padding-top": "0px", + "padding-bottom": "0px", + "padding-left": "15px", + "icon-padding": "0 5px 0 5px", + "font-size": "13px", + "icon-size": "20px", + mode: "horizontal", + }, + children: [ + { + tagName: "mj-social-element", + attributes: { + src: `${socialIconBasePath}/linkedin.png`, + href: "https://linkedin.com/company/stack-overflow/", + }, + }, + { + tagName: "mj-social-element", + attributes: { + src: `${socialIconBasePath}/x.png`, + href: "https://x.com/stackoverflow/", + }, + }, + { + tagName: "mj-social-element", + attributes: { + src: `${socialIconBasePath}/threads.png`, + href: "https://www.threads.net/@thestackoverflow", + }, + }, + { + tagName: "mj-social-element", + attributes: { + src: `${socialIconBasePath}/instagram.png`, + href: "https://www.instagram.com/thestackoverflow/", + }, + }, + { + tagName: "mj-social-element", + attributes: { + src: `${socialIconBasePath}/youtube.png`, + href: "https://www.youtube.com/c/StackOverflowOfficial", + }, + }, + ], + }, + ], + }, + ], + }); + } + + children.push({ + tagName: "mj-section", + attributes: { + "padding-top": "40px", + "padding-bottom": "40px", + "padding-left": tokens.layout.containerXPadding, + "padding-right": tokens.layout.containerXPadding, + }, + children: [ + { + tagName: "mj-column", + children: [ + ...(hasReasonText + ? [ + { + tagName: "mj-text", + attributes: { + "font-size": "14px", + "mj-class": textClass, + "padding-bottom": "40px", + }, + content: reasonText, + } satisfies MjmlNode, + ] + : []), + { + tagName: "mj-text", + attributes: { + "font-size": "14px", + "padding-bottom": "8px", + }, + content: + `
Unsubscribe ` + + `Edit email settings ` + + `Contact us ` + + `Privacy `, + }, + { + tagName: "mj-text", + attributes: { + "font-size": "14px", + "mj-class": textClass, + }, + content: addressHtml, + }, + ], + }, + ], + }); + + children.push({ + tagName: "mj-section", + attributes: { + "padding-left": tokens.layout.containerXPadding, + "padding-right": tokens.layout.containerXPadding, + "padding-bottom": tokens.layout.containerYPadding, + }, + children: [ + { + tagName: "mj-group", + children: [ + { + tagName: "mj-column", + children: [ + { + tagName: "mj-text", + attributes: { + "font-size": "14px", + "mj-class": textClass, + }, + content: "© Stack Exchange Inc.", + }, + ], + }, + { + tagName: "mj-column", + children: [ + { + tagName: "mj-text", + attributes: { + "font-size": "14px", + "mj-class": textClass, + align: "right", + }, + content: "All rights reserved", + }, + ], + }, + ], + }, + ], + }); + + return { + tagName: "mj-wrapper", + attributes: { + "mj-class": wrapperClass, + "padding-top": tokens.layout.containerYPadding, + }, + children, + }; +}; + +export const source: MjmlNode[] = [ + Footer("default", { + wrapperClass: "{{FOOTER_WRAPPER_CLASS}}", + textClass: "{{FOOTER_TEXT_CLASS}}", + linkClass: "{{FOOTER_LINK_CLASS}}", + headerVariant: "inverted", + unsubscribeUrl: "[[UNSUBSCRIBE_URL]]", + settingsUrl: "{{SETTINGS_URL}}", + contactUrl: "{{CONTACT_URL}}", + privacyUrl: "{{PRIVACY_URL}}", + reasonText: "{{FOOTER_REASON_TEXT}}", + socialClass: "{{FOOTER_SOCIAL_CLASS}}", + showSocialIcons: true, + socialIconBasePath: "{{FOOTER_SOCIAL_ICON_BASE_PATH}}", + }), +]; + +export const definition = { meta, source } as const; + +export default definition; diff --git a/packages/stacks-email/components/graphic.ts b/packages/stacks-email/components/graphic.ts new file mode 100644 index 0000000000..a452151a8f --- /dev/null +++ b/packages/stacks-email/components/graphic.ts @@ -0,0 +1,220 @@ +import type { EmailComponentMeta, MjmlNode } from "../types"; +import { z } from "zod/v4"; + +import { tokens } from "../tokens"; +import { Section } from "./section"; + +export type GraphicVariantId = "spot" | "hero"; + +const graphicOptionsSchema = z.object({ + sectionClass: z.string().optional(), + imageSrc: z.string().optional(), + imageAlt: z.string().optional(), + imageWidth: z.string().optional(), + imageHeight: z.string().optional(), + imageAlign: z.string().optional(), + imagePaddingTop: z.string().optional(), + imagePaddingBottom: z.string().optional(), + imagePaddingLeft: z.string().optional(), + imagePaddingRight: z.string().optional(), +}); + +type GraphicOptions = z.input; + +const graphicVariantDefaults: Record< + GraphicVariantId, + { + GRAPHIC_SECTION_CLASS: string; + GRAPHIC_IMAGE_SRC: string; + GRAPHIC_IMAGE_ALT: string; + GRAPHIC_IMAGE_WIDTH: string; + GRAPHIC_IMAGE_HEIGHT: string; + GRAPHIC_IMAGE_ALIGN: string; + GRAPHIC_IMAGE_PADDING_TOP: string; + GRAPHIC_IMAGE_PADDING_BOTTOM: string; + GRAPHIC_IMAGE_PADDING_LEFT: string; + GRAPHIC_IMAGE_PADDING_RIGHT: string; + } +> = { + spot: { + GRAPHIC_SECTION_CLASS: "bg-block", + GRAPHIC_IMAGE_SRC: "/email/spots/SpotLock.png", + GRAPHIC_IMAGE_ALT: "Spot placeholder image", + GRAPHIC_IMAGE_WIDTH: "140px", + GRAPHIC_IMAGE_HEIGHT: "140px", + GRAPHIC_IMAGE_ALIGN: "left", + GRAPHIC_IMAGE_PADDING_TOP: "0px", + GRAPHIC_IMAGE_PADDING_BOTTOM: "0px", + GRAPHIC_IMAGE_PADDING_LEFT: tokens.layout.containerXPadding, + GRAPHIC_IMAGE_PADDING_RIGHT: tokens.layout.containerXPadding, + }, + hero: { + GRAPHIC_SECTION_CLASS: "bg-block", + GRAPHIC_IMAGE_SRC: "/email/hero/1200x630.png", + GRAPHIC_IMAGE_ALT: "Hero placeholder image", + GRAPHIC_IMAGE_WIDTH: "1200px", + GRAPHIC_IMAGE_HEIGHT: "auto", + GRAPHIC_IMAGE_ALIGN: "center", + GRAPHIC_IMAGE_PADDING_TOP: "0px", + GRAPHIC_IMAGE_PADDING_BOTTOM: "0px", + GRAPHIC_IMAGE_PADDING_LEFT: tokens.layout.containerXPadding, + GRAPHIC_IMAGE_PADDING_RIGHT: tokens.layout.containerXPadding, + }, +}; + +const graphicOptionRows: NonNullable = [ + { + argument: "variant", + type: '"spot" | "hero"', + description: "Selects baseline size, alignment, and source asset.", + }, + { + argument: "options.sectionClass", + type: "string", + defaultValue: "bg-block", + defaultValueCode: true, + description: "Section class applied to the wrapper section.", + }, + { + argument: "options.imageSrc", + type: "string", + defaultValue: "From selected variant", + defaultValueCode: false, + description: "Image URL/path override.", + }, + { + argument: "options.imageAlt", + type: "string", + defaultValue: "From selected variant", + defaultValueCode: false, + description: "Accessible image alt text.", + }, + { + argument: "options.imageWidth", + type: "string", + defaultValue: "From selected variant", + defaultValueCode: false, + description: 'Rendered width (for example "140px").', + }, + { + argument: "options.imageHeight", + type: "string", + defaultValue: "From selected variant", + defaultValueCode: false, + description: 'Rendered height (for example "630px").', + }, + { + argument: "options.imageAlign", + type: "string", + defaultValue: "From selected variant", + defaultValueCode: false, + description: "MJML image alignment value.", + }, + { + argument: "options.imagePaddingTop", + type: "string", + defaultValue: "From selected variant", + defaultValueCode: false, + description: "Top padding override.", + }, + { + argument: "options.imagePaddingBottom", + type: "string", + defaultValue: "From selected variant", + defaultValueCode: false, + description: "Bottom padding override.", + }, + { + argument: "options.imagePaddingLeft", + type: "string", + defaultValue: "From selected variant", + defaultValueCode: false, + description: "Left padding override.", + }, + { + argument: "options.imagePaddingRight", + type: "string", + defaultValue: "From selected variant", + defaultValueCode: false, + description: "Right padding override.", + }, +]; + +export const meta: EmailComponentMeta = { + slug: "graphic", + defaultVariant: "spot", + variants: [ + { + id: "spot", + props: graphicVariantDefaults.spot, + }, + { + id: "hero", + props: graphicVariantDefaults.hero, + }, + ], + tokens: [], + options: graphicOptionRows, +}; + +const getGraphicVariantProps = (variant: GraphicVariantId) => + meta.variants.find((entry) => entry.id === variant)?.props ?? + meta.variants[0].props; + +export const Graphic = ( + variant: GraphicVariantId, + options: GraphicOptions = {} +): MjmlNode => { + const parsedOptions = graphicOptionsSchema.safeParse(options); + const normalizedOptions = parsedOptions.success ? parsedOptions.data : options; + const defaults = getGraphicVariantProps(variant); + + return Section( + [ + { + tagName: "mj-image", + attributes: { + src: normalizedOptions.imageSrc ?? defaults.GRAPHIC_IMAGE_SRC, + alt: normalizedOptions.imageAlt ?? defaults.GRAPHIC_IMAGE_ALT, + width: normalizedOptions.imageWidth ?? defaults.GRAPHIC_IMAGE_WIDTH, + height: normalizedOptions.imageHeight ?? defaults.GRAPHIC_IMAGE_HEIGHT, + align: normalizedOptions.imageAlign ?? defaults.GRAPHIC_IMAGE_ALIGN, + "padding-top": + normalizedOptions.imagePaddingTop ?? + defaults.GRAPHIC_IMAGE_PADDING_TOP, + "padding-bottom": + normalizedOptions.imagePaddingBottom ?? + defaults.GRAPHIC_IMAGE_PADDING_BOTTOM, + "padding-left": + normalizedOptions.imagePaddingLeft ?? + defaults.GRAPHIC_IMAGE_PADDING_LEFT, + "padding-right": + normalizedOptions.imagePaddingRight ?? + defaults.GRAPHIC_IMAGE_PADDING_RIGHT, + }, + }, + ], + { + sectionClass: normalizedOptions.sectionClass ?? defaults.GRAPHIC_SECTION_CLASS, + } + ); +}; + +export const source: MjmlNode[] = [ + Graphic("spot", { + sectionClass: "{{GRAPHIC_SECTION_CLASS}}", + imageSrc: "{{GRAPHIC_IMAGE_SRC}}", + imageAlt: "{{GRAPHIC_IMAGE_ALT}}", + imageWidth: "{{GRAPHIC_IMAGE_WIDTH}}", + imageHeight: "{{GRAPHIC_IMAGE_HEIGHT}}", + imageAlign: "{{GRAPHIC_IMAGE_ALIGN}}", + imagePaddingTop: "{{GRAPHIC_IMAGE_PADDING_TOP}}", + imagePaddingBottom: "{{GRAPHIC_IMAGE_PADDING_BOTTOM}}", + imagePaddingLeft: "{{GRAPHIC_IMAGE_PADDING_LEFT}}", + imagePaddingRight: "{{GRAPHIC_IMAGE_PADDING_RIGHT}}", + }), +]; + +export const definition = { meta, source } as const; + +export default definition; diff --git a/packages/stacks-email/components/header.ts b/packages/stacks-email/components/header.ts new file mode 100644 index 0000000000..0bb55ef1ed --- /dev/null +++ b/packages/stacks-email/components/header.ts @@ -0,0 +1,196 @@ +import type { EmailComponentMeta, MjmlNode } from "../types"; +import { z } from "zod/v4"; + +export type HeaderVariantId = "transactional" | "brand" | "brand-center" | "inverted" | "business"; + +const headerOptionsSchema = z.object({ + sectionClass: z.string().optional(), + logoSrc: z.string().optional(), + logoAlt: z.string().optional(), + logoUrl: z.string().optional(), + logoWidth: z.string().optional(), + logoAlign: z.string().optional(), +}); + +type HeaderOptions = z.input; + +type HeaderVariantProps = { + HEADER_SECTION_CLASS: string; + HEADER_LOGO_SRC: string; + HEADER_LOGO_ALT: string; + HEADER_LOGO_URL: string; + HEADER_LOGO_WIDTH: string; + HEADER_LOGO_ALIGN: string; +}; + +const sharedVariantProps: Omit = { + HEADER_LOGO_SRC: "/email/stack-overflow-logo.png", + HEADER_LOGO_ALT: "Stack Overflow", + HEADER_LOGO_URL: "https://stackoverflow.com/", + HEADER_LOGO_WIDTH: "158px", + HEADER_LOGO_ALIGN: "left", +}; + +const headerVariantDefaults: Record = { + transactional: { + ...sharedVariantProps, + HEADER_SECTION_CLASS: "bg-block", + }, + brand: { + ...sharedVariantProps, + HEADER_SECTION_CLASS: "bg-brand", + }, + "brand-center": { + ...sharedVariantProps, + HEADER_SECTION_CLASS: "bg-brand", + HEADER_LOGO_ALIGN: "center", + }, + inverted: { + ...sharedVariantProps, + HEADER_SECTION_CLASS: "bg-invert", + HEADER_LOGO_SRC: "/email/stack-overflow-logo-off-white.png", + }, + business: { + ...sharedVariantProps, + HEADER_SECTION_CLASS: "bg-invert", + HEADER_LOGO_SRC: "/email/stack-overflow-business-logo.png", + HEADER_LOGO_URL: "https://stackoverflow.co/", + }, +}; + +const headerOptionRows: NonNullable = [ + { + argument: "variant", + type: '"transactional" | "brand" | "brand-center" | "inverted" | "business"', + description: "Selects section background and logo defaults.", + }, + { + argument: "options.sectionClass", + type: "string", + defaultValue: "From selected variant", + defaultValueCode: false, + description: "Header section mj-class override.", + }, + { + argument: "options.logoSrc", + type: "string", + defaultValue: "From selected variant", + defaultValueCode: false, + description: "Logo image source path/URL override.", + }, + { + argument: "options.logoAlt", + type: "string", + defaultValue: "Stack Overflow", + defaultValueCode: true, + description: "Logo alt text.", + }, + { + argument: "options.logoUrl", + type: "string", + defaultValue: "From selected variant", + defaultValueCode: false, + description: "Logo link destination.", + }, + { + argument: "options.logoWidth", + type: "string", + defaultValue: "158px", + defaultValueCode: true, + description: "Logo width override.", + }, + { + argument: "options.logoAlign", + type: "string", + defaultValue: "From selected variant", + defaultValueCode: false, + description: "MJML image alignment override.", + }, +]; + +export const Header = ( + variant: HeaderVariantId, + options: HeaderOptions = {} +): MjmlNode => { + const parsedOptions = headerOptionsSchema.safeParse(options); + const normalizedOptions = parsedOptions.success ? parsedOptions.data : options; + const variantDefaults = headerVariantDefaults[variant]; + + return { + tagName: "mj-section", + attributes: { + "mj-class": + normalizedOptions.sectionClass ?? variantDefaults.HEADER_SECTION_CLASS, + "padding-top": "20px", + "padding-bottom": "20px", + "padding-left": "24px", + "padding-right": "24px", + }, + children: [ + { + tagName: "mj-column", + children: [ + { + tagName: "mj-image", + attributes: { + src: normalizedOptions.logoSrc ?? variantDefaults.HEADER_LOGO_SRC, + alt: normalizedOptions.logoAlt ?? variantDefaults.HEADER_LOGO_ALT, + width: + normalizedOptions.logoWidth ?? + variantDefaults.HEADER_LOGO_WIDTH, + href: normalizedOptions.logoUrl ?? variantDefaults.HEADER_LOGO_URL, + align: normalizedOptions.logoAlign ?? variantDefaults.HEADER_LOGO_ALIGN, + padding: "0px", + }, + }, + ], + }, + ], + }; +}; + +export const meta: EmailComponentMeta = { + slug: "header", + defaultVariant: "transactional", + variants: [ + { + id: "transactional", + props: headerVariantDefaults.transactional, + }, + { + id: "brand", + props: headerVariantDefaults.brand, + }, + { + id: "brand-center", + props: headerVariantDefaults["brand-center"], + }, + { + id: "inverted", + props: headerVariantDefaults.inverted, + }, + { + id: "business", + props: headerVariantDefaults.business, + }, + ], + tokens: [], + options: headerOptionRows, +}; + +export const source: MjmlNode[] = [ + { + ...Header("transactional", { + sectionClass: "{{HEADER_SECTION_CLASS}}", + logoSrc: "{{HEADER_LOGO_SRC}}", + logoAlt: "{{HEADER_LOGO_ALT}}", + logoWidth: "{{HEADER_LOGO_WIDTH}}", + logoUrl: "{{HEADER_LOGO_URL}}", + logoAlign: "{{HEADER_LOGO_ALIGN}}", + }), + }, +]; + +export const definition = { meta, source } as const; + +export default definition; diff --git a/packages/stacks-email/components/headline.ts b/packages/stacks-email/components/headline.ts new file mode 100644 index 0000000000..e75e356c33 --- /dev/null +++ b/packages/stacks-email/components/headline.ts @@ -0,0 +1,192 @@ +import type { EmailComponentMeta, MjmlNode } from "../types"; +import { z } from "zod/v4"; + +import { tokens } from "../tokens"; +import { Section } from "./section"; + +export type HeadlineVariantId = "default" | "highlight"; + +const headlineOptionsSchema = z.object({ + sectionClass: z.string().optional(), + textClass: z.string().optional(), + textAlign: z.string().optional(), + textContent: z.string().optional(), + textHighlight: z.union([z.boolean(), z.string()]).optional(), + textHighlightStart: z.string().optional(), + textHighlightEnd: z.string().optional(), +}); + +type HeadlineOptions = z.input; + +const headlineOptionRows: NonNullable = [ + { + argument: "variant", + type: '"default" | "highlight"', + description: "Selects baseline highlight wrapper behavior.", + }, + { + argument: "options.sectionClass", + type: "string", + defaultValue: "bg-block", + defaultValueCode: true, + description: "Section class for the headline row.", + }, + { + argument: "options.textClass", + type: "string", + defaultValue: "s-email-text-headline", + defaultValueCode: true, + description: "Text styling class for the headline node.", + }, + { + argument: "options.textAlign", + type: "string", + defaultValue: "left", + defaultValueCode: true, + description: "MJML text alignment.", + }, + { + argument: "options.textContent", + type: "string", + defaultValue: "Please verify your email address", + defaultValueCode: true, + description: "Headline copy content.", + }, + { + argument: "options.textHighlight", + type: "boolean | string", + defaultValue: "Variant behavior when omitted", + defaultValueCode: false, + description: + "When true, forces inline highlighted output; when false, disables highlighting.", + }, + { + argument: "options.textHighlightStart", + type: "string", + defaultValue: "From selected variant", + defaultValueCode: false, + description: "Optional opening wrapper around headline content.", + }, + { + argument: "options.textHighlightEnd", + type: "string", + defaultValue: "From selected variant", + defaultValueCode: false, + description: "Optional closing wrapper around headline content.", + }, +]; + +const headlineVariantDefaults: Record< + HeadlineVariantId, + { + HEADLINE_SECTION_CLASS: string; + HEADLINE_TEXT_CLASS: string; + HEADLINE_TEXT_ALIGN: string; + HEADLINE_TEXT_CONTENT: string; + HEADLINE_TEXT_HIGHLIGHT_START: string; + HEADLINE_TEXT_HIGHLIGHT_END: string; + } +> = { + default: { + HEADLINE_SECTION_CLASS: "bg-block", + HEADLINE_TEXT_CLASS: "s-email-text-headline", + HEADLINE_TEXT_ALIGN: "left", + HEADLINE_TEXT_CONTENT: "Please verify your email address", + HEADLINE_TEXT_HIGHLIGHT_START: "", + HEADLINE_TEXT_HIGHLIGHT_END: "", + }, + highlight: { + HEADLINE_SECTION_CLASS: "bg-block", + HEADLINE_TEXT_CLASS: "s-email-text-headline", + HEADLINE_TEXT_ALIGN: "left", + HEADLINE_TEXT_CONTENT: "Please verify your email address", + HEADLINE_TEXT_HIGHLIGHT_START: ``, + HEADLINE_TEXT_HIGHLIGHT_END: "", + }, +}; + +const getHeadlineVariantProps = (variant: HeadlineVariantId) => + headlineVariantDefaults[variant] ?? headlineVariantDefaults.default; + +const withHighlightedText = (text: string) => + `${text}`; + +export const Headline = ( + variant: HeadlineVariantId, + options: HeadlineOptions = {} +): MjmlNode => { + const parsedOptions = headlineOptionsSchema.safeParse(options); + const normalizedOptions = parsedOptions.success ? parsedOptions.data : options; + const defaults = getHeadlineVariantProps(variant); + const textContent = + normalizedOptions.textContent ?? defaults.HEADLINE_TEXT_CONTENT; + const textHighlightStart = + normalizedOptions.textHighlightStart ?? + defaults.HEADLINE_TEXT_HIGHLIGHT_START; + const textHighlightEnd = + normalizedOptions.textHighlightEnd ?? defaults.HEADLINE_TEXT_HIGHLIGHT_END; + const textHighlightFlag = normalizedOptions.textHighlight; + const textHighlight = + textHighlightFlag === undefined + ? null + : String(textHighlightFlag).trim().toLowerCase() === "true"; + const renderedTextContent = + textHighlight === null + ? `${textHighlightStart}${textContent}${textHighlightEnd}` + : textHighlight + ? withHighlightedText(textContent) + : textContent; + + return Section( + [ + { + tagName: "mj-text", + attributes: { + "mj-class": + normalizedOptions.textClass ?? defaults.HEADLINE_TEXT_CLASS, + align: normalizedOptions.textAlign ?? defaults.HEADLINE_TEXT_ALIGN, + "padding-top": tokens.layout.containerYPadding, + "padding-bottom": tokens.layout.containerYPadding, + "padding-left": tokens.layout.containerXPadding, + "padding-right": tokens.layout.containerXPadding, + }, + content: renderedTextContent, + }, + ], + { + sectionClass: + normalizedOptions.sectionClass ?? defaults.HEADLINE_SECTION_CLASS, + } + ); +}; + +export const meta: EmailComponentMeta = { + slug: "headline", + defaultVariant: "default", + variants: [ + { + id: "default", + props: headlineVariantDefaults.default, + }, + { + id: "highlight", + props: headlineVariantDefaults.highlight, + }, + ], + tokens: [], + options: headlineOptionRows, +}; + +export const source: MjmlNode[] = [ + Headline("default", { + sectionClass: "{{HEADLINE_SECTION_CLASS}}", + textClass: "{{HEADLINE_TEXT_CLASS}}", + textAlign: "{{HEADLINE_TEXT_ALIGN}}", + textContent: "{{HEADLINE_TEXT_CONTENT}}", + textHighlightStart: "{{HEADLINE_TEXT_HIGHLIGHT_START}}", + textHighlightEnd: "{{HEADLINE_TEXT_HIGHLIGHT_END}}", + }), +]; + +export const definition = { meta, source } as const; +export default definition; diff --git a/packages/stacks-email/components/index.ts b/packages/stacks-email/components/index.ts new file mode 100644 index 0000000000..63aaf00dc0 --- /dev/null +++ b/packages/stacks-email/components/index.ts @@ -0,0 +1,11 @@ +export { default as button } from "./button"; +export { default as footer } from "./footer"; +export { default as graphic } from "./graphic"; +export { default as headline } from "./headline"; +export { default as header } from "./header"; +export { default as preview } from "./preview"; +export { default as spacers } from "./spacers"; +export { default as text } from "./text"; +export { default as title } from "./title"; +export { Section } from "./section"; +export { Spacer } from "./spacer"; diff --git a/packages/stacks-email/components/preview.ts b/packages/stacks-email/components/preview.ts new file mode 100644 index 0000000000..263c61317d --- /dev/null +++ b/packages/stacks-email/components/preview.ts @@ -0,0 +1,85 @@ +import type { EmailComponentMeta, MjmlNode } from "../types"; +import { z } from "zod/v4"; + +export type PreviewVariantId = "default"; + +const previewOptionsSchema = z.object({ + textContent: z.string().optional(), +}); + +type PreviewOptions = z.input; + +const previewOptionRows: NonNullable = [ + { + argument: "variant", + type: '"default"', + description: "Baseline preview text output.", + }, + { + argument: "options.textContent", + type: "string", + defaultValue: "[[PREVIEW_TEXT]]", + defaultValueCode: true, + description: + "Hidden inbox preview snippet shown by supporting email clients.", + }, +]; + +const previewVariantDefaults: Record< + PreviewVariantId, + { + PREVIEW_TEXT_CONTENT: string; + } +> = { + default: { + PREVIEW_TEXT_CONTENT: "[[PREVIEW_TEXT]]", + }, +}; + +const getPreviewVariantProps = (variant: PreviewVariantId) => + previewVariantDefaults[variant] ?? previewVariantDefaults.default; + +export const Preview = ( + variant: PreviewVariantId = "default", + options: PreviewOptions = {} +): MjmlNode => { + const parsedOptions = previewOptionsSchema.safeParse(options); + const normalizedOptions = parsedOptions.success ? parsedOptions.data : options; + const defaults = getPreviewVariantProps(variant); + + return { + tagName: "mj-preview", + content: normalizedOptions.textContent ?? defaults.PREVIEW_TEXT_CONTENT, + }; +}; + +export const meta: EmailComponentMeta = { + slug: "preview", + defaultVariant: "default", + htmlExtraction: { + targetTag: "mj-preview", + }, + variants: [ + { + id: "default", + props: previewVariantDefaults.default, + }, + ], + tokens: [ + { + token: "PREVIEW_TEXT", + description: "Inbox preview snippet shown next to subject lines.", + }, + ], + options: previewOptionRows, +}; + +export const source: MjmlNode[] = [ + Preview("default", { + textContent: "{{PREVIEW_TEXT_CONTENT}}", + }), +]; + +export const definition = { meta, source } as const; + +export default definition; diff --git a/packages/stacks-email/components/section.ts b/packages/stacks-email/components/section.ts new file mode 100644 index 0000000000..30ca234500 --- /dev/null +++ b/packages/stacks-email/components/section.ts @@ -0,0 +1,51 @@ +import type { MjmlNode } from "../types"; +import { z } from "zod/v4"; + +const mjmlAttributeSchema = z.union([z.string(), z.number(), z.boolean()]); + +const sectionOptionsSchema = z.object({ + sectionClass: z.string().optional(), + sectionAttributes: z.record(z.string(), mjmlAttributeSchema).optional(), + columnClass: z.string().optional(), + columnAttributes: z.record(z.string(), mjmlAttributeSchema).optional(), +}); + +type SectionOptions = z.input; + +export const Section = ( + children: MjmlNode[], + options: SectionOptions = {} +): MjmlNode => { + const parsedOptions = sectionOptionsSchema.safeParse(options); + const normalizedOptions = parsedOptions.success ? parsedOptions.data : options; + + const sectionAttributes: NonNullable = { + ...(normalizedOptions.sectionClass + ? { "mj-class": normalizedOptions.sectionClass } + : {}), + ...(normalizedOptions.sectionAttributes ?? {}), + }; + + const columnAttributes: NonNullable = { + ...(normalizedOptions.columnClass + ? { "mj-class": normalizedOptions.columnClass } + : {}), + ...(normalizedOptions.columnAttributes ?? {}), + }; + + return { + tagName: "mj-section", + attributes: Object.keys(sectionAttributes).length + ? sectionAttributes + : undefined, + children: [ + { + tagName: "mj-column", + attributes: Object.keys(columnAttributes).length + ? columnAttributes + : undefined, + children, + }, + ], + }; +}; diff --git a/packages/stacks-email/components/spacer.ts b/packages/stacks-email/components/spacer.ts new file mode 100644 index 0000000000..a4bfa647dc --- /dev/null +++ b/packages/stacks-email/components/spacer.ts @@ -0,0 +1,39 @@ +import type { MjmlNode } from "../types"; +import { z } from "zod/v4"; +import { Section } from "./section"; + +const spacerHeights = { + medium: "20px", + large: "40px", +} as const; + +export type SpacerSize = keyof typeof spacerHeights; + +const spacerOptionsSchema = z.object({ + sectionClass: z.string().optional(), + height: z.string().optional(), +}); + +type SpacerOptions = z.input; + +export const Spacer = ( + size: SpacerSize = "medium", + options: SpacerOptions = {} +): MjmlNode => { + const parsedOptions = spacerOptionsSchema.safeParse(options); + const normalizedOptions = parsedOptions.success ? parsedOptions.data : options; + + return Section( + [ + { + tagName: "mj-spacer", + attributes: { + height: normalizedOptions.height ?? spacerHeights[size], + }, + }, + ], + { + sectionClass: normalizedOptions.sectionClass ?? "bg-block", + } + ); +}; diff --git a/packages/stacks-email/components/spacers.ts b/packages/stacks-email/components/spacers.ts new file mode 100644 index 0000000000..311463b0bb --- /dev/null +++ b/packages/stacks-email/components/spacers.ts @@ -0,0 +1,76 @@ +import type { EmailComponentMeta, MjmlNode } from "../types"; + +import { Spacer } from "./spacer"; + +export type SpacersVariantId = "medium" | "large"; + +type SpacerVariantDefaults = { + SPACER_SECTION_CLASS: string; + SPACER_HEIGHT: string; +}; + +const spacerVariantDefaults: Record = { + medium: { + SPACER_SECTION_CLASS: "bg-block", + SPACER_HEIGHT: "20px", + }, + large: { + SPACER_SECTION_CLASS: "bg-block", + SPACER_HEIGHT: "40px", + }, +}; + +const spacerOptionRows: NonNullable = [ + { + argument: "size", + type: '"medium" | "large"', + defaultValue: "medium", + defaultValueCode: true, + description: "Preset height token applied to the underlying mj-spacer.", + }, + { + argument: "options.sectionClass", + type: "string", + defaultValue: "bg-block", + defaultValueCode: true, + description: "Applied to the wrapper section (mj-class).", + }, + { + argument: "options.height", + type: "string", + defaultValue: "From size preset", + defaultValueCode: false, + description: 'Explicit spacer height override, for example "64px".', + }, +]; + +export const meta: EmailComponentMeta = { + slug: "spacers", + defaultVariant: "medium", + htmlExtraction: { + targetTag: "mj-spacer", + }, + variants: [ + { + id: "medium", + props: spacerVariantDefaults.medium, + }, + { + id: "large", + props: spacerVariantDefaults.large, + }, + ], + tokens: [], + options: spacerOptionRows, +}; + +export const source: MjmlNode[] = [ + Spacer("medium", { + sectionClass: "{{SPACER_SECTION_CLASS}}", + height: "{{SPACER_HEIGHT}}", + }), +]; + +export const definition = { meta, source } as const; + +export default definition; diff --git a/packages/stacks-email/components/text.ts b/packages/stacks-email/components/text.ts new file mode 100644 index 0000000000..5c1c99ec6f --- /dev/null +++ b/packages/stacks-email/components/text.ts @@ -0,0 +1,210 @@ +import type { EmailComponentMeta, MjmlNode } from "../types"; +import MarkdownIt from "markdown-it"; +import { z } from "zod/v4"; + +import { tokens } from "../tokens"; +import { Section } from "./section"; + +export type TextVariantId = "body" | "centered"; + +const textOptionsSchema = z.object({ + columnClass: z.string().optional(), + sectionClass: z.string().optional(), + textAlign: z.string().optional(), + textClass: z.string().optional(), + textContent: z.string().optional(), +}); + +type TextOptions = z.input; + +const BODY_PARAGRAPH_MARGIN = "0 0 16px"; +const TEMPLATE_PROP_PATTERN = /^\{\{[A-Z0-9_]+\}\}$/; + +const markdown = new MarkdownIt({ + html: false, + breaks: true, + linkify: true, + typographer: true, +}); + +markdown.renderer.rules.link_open = (tokenList, index, options, env, self) => { + tokenList[index].attrJoin("class", "link"); + return self.renderToken(tokenList, index, options); +}; + +markdown.renderer.rules.paragraph_open = ( + tokenList, + index, + options, + env, + self +) => { + const hasAnotherParagraph = tokenList + .slice(index + 1) + .some((token) => token.type === "paragraph_open"); + + tokenList[index].attrSet( + "style", + `margin:${hasAnotherParagraph ? BODY_PARAGRAPH_MARGIN : "0"};` + ); + + return self.renderToken(tokenList, index, options); +}; + +const renderMarkdown = (value: string) => markdown.render(value.trim()).trim(); + +const looksLikeHtml = (value: string) => /<\/?[a-z][\s\S]*>/i.test(value); + +const renderTextContent = (value: string | undefined) => { + const content = value?.trim() ?? ""; + if (!content) { + return ""; + } + + if (TEMPLATE_PROP_PATTERN.test(content)) { + return content; + } + + if (looksLikeHtml(content)) { + return content; + } + + return renderMarkdown(content); +}; + +const textOptionRows: NonNullable = [ + { + argument: "variant", + type: '"body" | "centered"', + description: "Selects layout and alignment defaults.", + }, + { + argument: "options.columnClass", + type: "string", + defaultValue: "From selected variant", + defaultValueCode: false, + description: "Column mj-class override.", + }, + { + argument: "options.sectionClass", + type: "string", + description: "Optional section mj-class override.", + }, + { + argument: "options.textAlign", + type: "string", + defaultValue: "From selected variant", + defaultValueCode: false, + description: "MJML text alignment override.", + }, + { + argument: "options.textClass", + type: "string", + defaultValue: "s-email-text-body", + defaultValueCode: true, + description: "Text mj-class override.", + }, + { + argument: "options.textContent", + type: "string", + defaultValue: "From selected variant", + defaultValueCode: false, + description: "Raw content string before markdown/HTML handling.", + }, +]; + +export const meta: EmailComponentMeta = { + slug: "text", + defaultVariant: "body", + variants: [ + { + id: "body", + props: { + TEXT_COLUMN_CLASS: "bg-block", + TEXT_CLASS: "s-email-text-body", + TEXT_ALIGN: "left", + TEXT_CONTENT: renderTextContent( + ` +Dear [[FIRST_NAME]], + +The entire [software development lifecycle](https://stackoverflow.com) has been dramatically changed by AI, introducing a new model for team organization and leadership. + +AI has accelerated coding, allowing developers to dedicate more time to complex and creative tasks. **Simultaneously**, it enables teams to clear bottlenecks of repetitive tasks [through automation](https://stackoverflow.com), allowing leaders to create more agile teams and focus on higher-level strategic problems. + +Ultimately, it is really AI’s ability to automate the __"work around the work"__ that is proving to be transformative for organizations. + ` + ), + }, + }, + { + id: "centered", + props: { + TEXT_COLUMN_CLASS: "bg-block", + TEXT_CLASS: "s-email-text-body", + TEXT_CONTENT: renderTextContent( + ` + A starting point for more simple transactional emails with a single, center-aligned message. It can [contain links](https://stackoverflow.com) or **rich text**. + ` + ), + TEXT_ALIGN: "center", + }, + }, + ], + tokens: [ + { + token: "FIRST_NAME", + description: "Recipient first name for personalized body copy.", + }, + ], + options: textOptionRows, +}; + +const getTextVariantProps = (variant: TextVariantId) => + meta.variants.find((entry) => entry.id === variant)?.props ?? + meta.variants[0].props; + +export const Text = ( + variant: TextVariantId, + options: TextOptions = {} +): MjmlNode => { + const parsedOptions = textOptionsSchema.safeParse(options); + const normalizedOptions = parsedOptions.success ? parsedOptions.data : options; + const defaults = getTextVariantProps(variant); + + return Section( + [ + { + tagName: "mj-text", + attributes: { + "mj-class": normalizedOptions.textClass ?? defaults.TEXT_CLASS, + align: normalizedOptions.textAlign ?? defaults.TEXT_ALIGN, + "padding-top": tokens.layout.containerYPadding, + "padding-bottom": tokens.layout.containerYPadding, + "padding-left": tokens.layout.containerXPadding, + "padding-right": tokens.layout.containerXPadding, + }, + content: renderTextContent( + normalizedOptions.textContent ?? defaults.TEXT_CONTENT + ), + }, + ], + { + sectionClass: normalizedOptions.sectionClass, + columnClass: + normalizedOptions.columnClass ?? defaults.TEXT_COLUMN_CLASS, + } + ); +}; + +export const source: MjmlNode[] = [ + Text("body", { + columnClass: "{{TEXT_COLUMN_CLASS}}", + textClass: "{{TEXT_CLASS}}", + textAlign: "{{TEXT_ALIGN}}", + textContent: "{{TEXT_CONTENT}}", + }), +]; + +export const definition = { meta, source } as const; + +export default definition; diff --git a/packages/stacks-email/components/title.ts b/packages/stacks-email/components/title.ts new file mode 100644 index 0000000000..f0fe326a74 --- /dev/null +++ b/packages/stacks-email/components/title.ts @@ -0,0 +1,143 @@ +import type { EmailComponentMeta, MjmlNode } from "../types"; +import { z } from "zod/v4"; + +import { tokens } from "../tokens"; +import { Section } from "./section"; + +export type TitleVariantId = "default" | "invert"; + +const titleOptionsSchema = z.object({ + sectionClass: z.string().optional(), + textClass: z.string().optional(), + textAlign: z.string().optional(), + textContent: z.string().optional(), +}); + +type TitleOptions = z.input; + +const titleVariantDefaults: Record< + TitleVariantId, + { + TITLE_SECTION_CLASS: string; + TITLE_TEXT_CLASS: string; + TITLE_TEXT_ALIGN: string; + TITLE_TEXT_CONTENT: string; + } +> = { + default: { + TITLE_SECTION_CLASS: "bg-block", + TITLE_TEXT_CLASS: "s-email-text-title", + TITLE_TEXT_ALIGN: "left", + TITLE_TEXT_CONTENT: "Featured", + }, + invert: { + TITLE_SECTION_CLASS: "bg-invert", + TITLE_TEXT_CLASS: "s-email-text-title fc-text-invert", + TITLE_TEXT_ALIGN: "left", + TITLE_TEXT_CONTENT: "Featured", + }, +}; + +const titleOptionRows: NonNullable = [ + { + argument: "variant", + type: '"default" | "invert"', + description: "Selects baseline section and text styling.", + }, + { + argument: "options.sectionClass", + type: "string", + defaultValue: "From selected variant", + defaultValueCode: false, + description: "Section mj-class override.", + }, + { + argument: "options.textClass", + type: "string", + defaultValue: "From selected variant", + defaultValueCode: false, + description: "Title text mj-class override.", + }, + { + argument: "options.textAlign", + type: "string", + defaultValue: "left", + defaultValueCode: true, + description: "MJML text alignment override.", + }, + { + argument: "options.textContent", + type: "string", + defaultValue: "Featured", + defaultValueCode: true, + description: "Title copy content.", + }, +]; + +const getTitleVariantProps = (variant: TitleVariantId) => + titleVariantDefaults[variant] ?? titleVariantDefaults.default; + +export const Title = ( + variant: TitleVariantId, + options: TitleOptions = {} +): MjmlNode => { + const parsedOptions = titleOptionsSchema.safeParse(options); + const normalizedOptions = parsedOptions.success ? parsedOptions.data : options; + + return Section( + [ + { + tagName: "mj-text", + attributes: { + "mj-class": + normalizedOptions.textClass ?? + getTitleVariantProps(variant).TITLE_TEXT_CLASS, + align: + normalizedOptions.textAlign ?? + getTitleVariantProps(variant).TITLE_TEXT_ALIGN, + "padding-top": tokens.layout.containerYPadding, + "padding-bottom": tokens.layout.containerYPadding, + "padding-left": tokens.layout.containerXPadding, + "padding-right": tokens.layout.containerXPadding, + }, + content: + normalizedOptions.textContent ?? + getTitleVariantProps(variant).TITLE_TEXT_CONTENT, + }, + ], + { + sectionClass: + normalizedOptions.sectionClass ?? + getTitleVariantProps(variant).TITLE_SECTION_CLASS, + } + ); +}; + +export const meta: EmailComponentMeta = { + slug: "title", + defaultVariant: "default", + variants: [ + { + id: "default", + props: titleVariantDefaults.default, + }, + { + id: "invert", + props: titleVariantDefaults.invert, + }, + ], + tokens: [], + options: titleOptionRows, +}; + +export const source: MjmlNode[] = [ + Title("default", { + sectionClass: "{{TITLE_SECTION_CLASS}}", + textClass: "{{TITLE_TEXT_CLASS}}", + textAlign: "{{TITLE_TEXT_ALIGN}}", + textContent: "{{TITLE_TEXT_CONTENT}}", + }), +]; + +export const definition = { meta, source } as const; +export default definition; diff --git a/packages/stacks-email/eslint.config.js b/packages/stacks-email/eslint.config.js new file mode 100644 index 0000000000..f60aa92137 --- /dev/null +++ b/packages/stacks-email/eslint.config.js @@ -0,0 +1,29 @@ +import js from "@eslint/js"; +import globals from "globals"; +import svelte from "eslint-plugin-svelte"; +import tseslint from "typescript-eslint"; +import prettier from "eslint-config-prettier"; + +export default tseslint.config( + js.configs.recommended, + ...tseslint.configs.recommended, + ...svelte.configs["flat/recommended"], + prettier, + ...svelte.configs["flat/prettier"], + { + languageOptions: { + globals: { + ...globals.browser, + ...globals.node, + }, + }, + }, + { + files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"], + languageOptions: { + parserOptions: { + parser: tseslint.parser, + }, + }, + } +); diff --git a/packages/stacks-email/mjml-config.ts b/packages/stacks-email/mjml-config.ts new file mode 100644 index 0000000000..d162f3263f --- /dev/null +++ b/packages/stacks-email/mjml-config.ts @@ -0,0 +1,246 @@ +import { mjmlJsonToString } from "./mjml-json"; +import { tokens } from "./tokens"; +import type { MjmlNode } from "./types"; + +const { color, font, spacing, layout, border } = tokens; +const fontWeightSemibold = "600"; + +export const BODY_PARAGRAPH_MARGIN = "0 0 16px"; +export const BODY_LIST_MARGIN = "0 0 16px 24px"; +export const BODY_LIST_PADDING = "0"; +export const BODY_LIST_ITEM_MARGIN = "0 0 8px"; + +const createMjClass = ( + name: string, + attributes: Record +): MjmlNode => ({ + tagName: "mj-class", + attributes: { + name, + ...attributes, + }, +}); + +const generatedBackgroundClasses = color.backgroundClasses.map( + ({ name, value }) => + createMjClass(`bg-${name}`, { + "background-color": value, + }) +); + +const generatedFontClasses = color.fontClasses.map(({ name, value }) => + createMjClass(`fc-${name}`, { + color: value, + }) +); + +const attributesChildren: MjmlNode[] = [ + { + tagName: "mj-all", + attributes: { + "font-family": font.family, + "color": color.text, + "font-size": "16px", + "line-height": "120%", + }, + }, + { + tagName: "mj-body", + attributes: { + "width": layout.maxWidth, + "background-color": color.background, + }, + }, + { + tagName: "mj-text", + attributes: { + "padding": "0px", + }, + }, + { + tagName: "mj-image", + attributes: { + "padding": "0px", + }, + }, + { + tagName: "mj-button", + attributes: { + "padding": "0px", + }, + }, + { + tagName: "mj-table", + attributes: { + "padding": "0px", + }, + }, + { + tagName: "mj-column", + attributes: { + "padding": "0px", + }, + }, + { + tagName: "mj-section", + attributes: { + "padding": "0px", + }, + }, + { + tagName: "mj-wrapper", + attributes: { + "padding": "0px", + }, + }, + + // Utility classes generated from design-token arrays. + ...generatedBackgroundClasses, + ...generatedFontClasses, + + createMjClass("s-email-text-title", { + "color": color.text, + "font-family": "Stack Sans Notch, Arial, Helvetica, sans-serif", + "font-size": "24px", + "font-weight": font.weightNormal, + "line-height": "100%", + "padding": "0", + }), + createMjClass("s-email-text-headline", { + "color": color.text, + "font-family": "Stack Sans Notch, Arial, Helvetica, sans-serif", + "font-size": "36px", + "font-weight": font.weightNormal, + "line-height": "100%", + "padding": "0", + }), + + createMjClass("s-email-text-subtitle", { + "color": color.text, + "font-size": "14px", + "font-weight": font.weightBold, + "line-height": "120%", + "padding": "0", + }), + createMjClass("s-email-text-secondary-information", { + "color": color.textMuted, + "font-size": "12px", + "font-weight": font.weightNormal, + "line-height": "120%", + "padding": "0", + }), + createMjClass("s-email-text-body", { + "color": color.text, + "font-size": "16px", + "font-weight": font.weightNormal, + "line-height": "120%", + }), + createMjClass("s-email-text-caption", { + "color": color.textMuted, + "font-size": "14px", + "font-weight": font.weightNormal, + "line-height": "120%", + }), + createMjClass("s-email-text-alert", { + "color": color.text, + "font-size": "16px", + "font-weight": fontWeightSemibold, + "line-height": "120%", + "padding": "0", + }), + + createMjClass("button", { + "background-color": color.brandDark, + "color": "#ffffff", + "border-radius": border.radius, + "font-size": "14px", + "font-weight": font.weightBold, + "line-height": "120%", + "inner-padding": `12px 18px`, + }), + createMjClass("button__tonal", { + "background-color": color.brandOffWhite, + "color": color.text, + }), + createMjClass("button__inverted", { + "background-color": color.background, + "color": color.text, + }), +]; + +const linkStyles = ` +a.link { + color: ${color.link}; + text-decoration: underline; + font-size: 16px; + line-height: normal; + font-weight: ${font.weightNormal}; +} +a.footer-link { + color: ${color.textFooter}; + text-decoration: underline; + font-size: 14px; + line-height: 120%; + margin-right: 10px; +} +`.trim(); + +// Head-only Progressive enhancement: hover states only apply in clients that support head CSS and :hover. +const hoverStyles = ` +a.link:hover { + color: ${color.linkHover} !important; +} +a.footer-link:hover, +a.footer-link-light:hover, +a.footer-link:hover { + opacity: 0.85 !important; +} +.button-hover a:hover { + background-color: #47484d !important; + color: #fff !important; +} +.button-hover-inverted a:hover { + background-color: ${color.brandOffWhite} !important; +} + +.footer-social-hidden { + display: none !important; + max-height: 0 !important; + overflow: hidden !important; + mso-hide: all !important; +} +`.trim(); + +export const mjmlConfigNodes: MjmlNode[] = [ + { + tagName: "mj-font", + attributes: { + name: "Stack Sans Headline", + href: "https://fonts.googleapis.com/css2?family=Stack+Sans+Headline:wght@400;600&display=swap", + }, + }, + { + tagName: "mj-font", + attributes: { + name: "Stack Sans Notch", + href: "https://fonts.googleapis.com/css2?family=Stack+Sans+Notch:wght@400&display=swap", + }, + }, + { + tagName: "mj-attributes", + children: attributesChildren, + }, + { + tagName: "mj-style", + attributes: { + inline: "inline", + }, + content: linkStyles, + }, + { + tagName: "mj-style", + content: hoverStyles, + }, +]; + +export const mjmlConfig = mjmlJsonToString(mjmlConfigNodes); diff --git a/packages/stacks-email/mjml-json.ts b/packages/stacks-email/mjml-json.ts new file mode 100644 index 0000000000..5f793f4dcf --- /dev/null +++ b/packages/stacks-email/mjml-json.ts @@ -0,0 +1,19 @@ +import json2mjmlModule from "json2mjml"; + +import type { MjmlNode } from "./types"; + +export const mjmlJsonToString = (source: MjmlNode | MjmlNode[]) => { + const json2mjml = + typeof json2mjmlModule === "function" + ? json2mjmlModule + : (json2mjmlModule as { default?: (node: unknown) => string }) + .default; + + if (typeof json2mjml !== "function") { + throw new Error("json2mjml export is not callable"); + } + + const nodes = Array.isArray(source) ? source : [source]; + + return nodes.map((node) => json2mjml(node as never)).join("\n"); +}; diff --git a/packages/stacks-email/package.json b/packages/stacks-email/package.json new file mode 100644 index 0000000000..b109044e8d --- /dev/null +++ b/packages/stacks-email/package.json @@ -0,0 +1,46 @@ +{ + "name": "@stackoverflow/stacks-email", + "version": "0.0.1", + "private": true, + "type": "module", + "exports": { + ".": "./src/lib/public/index.ts" + }, + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "lint": "npm run check && eslint .", + "format": "prettier --write ." + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^6.1.0", + "@sveltejs/kit": "^2.48.5", + "@sveltejs/vite-plugin-svelte": "^6.2.1", + "@types/mjml": "^5.0.0", + "eslint": "^9.39.2", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-svelte": "^3.14.1", + "globals": "^17.0.0", + "mdsvex": "^0.12.3", + "prettier": "^3.8.3", + "prettier-plugin-svelte": "^3.4.0", + "svelte": "^5.55.9", + "svelte-check": "^4.3.5", + "typescript": "^5.9.3", + "typescript-eslint": "^8.53.0", + "vite": "^7.3.3" + }, + "dependencies": { + "@stackoverflow/stacks": "*", + "@stackoverflow/stacks-svelte": "*", + "highlight.js": "^11.11.1", + "json2mjml": "^1.0.3", + "markdown-it": "^14.1.0", + "mjml": "^4.17.1", + "zod": "^4.1.12" + } +} diff --git a/packages/stacks-email/registry.ts b/packages/stacks-email/registry.ts new file mode 100644 index 0000000000..ac3122d835 --- /dev/null +++ b/packages/stacks-email/registry.ts @@ -0,0 +1,35 @@ +import button from "./components/button"; +import footer from "./components/footer"; +import graphic from "./components/graphic"; +import headline from "./components/headline"; +import header from "./components/header"; +import spacers from "./components/spacers"; +import text from "./components/text"; +import title from "./components/title"; + +import transactional from "./templates/transactional"; + +export const componentDefinitions = [ + button, + footer, + graphic, + headline, + header, + spacers, + text, + title, +] as const; + +export const templateDefinitions = [transactional] as const; + +export { + button, + footer, + graphic, + headline, + header, + spacers, + text, + title, + transactional, +}; diff --git a/packages/stacks-email/src/app.css b/packages/stacks-email/src/app.css new file mode 100644 index 0000000000..6c79ac5b9b --- /dev/null +++ b/packages/stacks-email/src/app.css @@ -0,0 +1 @@ +@import "@stackoverflow/stacks/dist/css/stacks.css"; diff --git a/packages/stacks-email/src/app.d.ts b/packages/stacks-email/src/app.d.ts new file mode 100644 index 0000000000..5b77823d77 --- /dev/null +++ b/packages/stacks-email/src/app.d.ts @@ -0,0 +1,12 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/packages/stacks-email/src/app.html b/packages/stacks-email/src/app.html new file mode 100644 index 0000000000..1966776910 --- /dev/null +++ b/packages/stacks-email/src/app.html @@ -0,0 +1,11 @@ + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/packages/stacks-email/src/components/TemplateSidebar.svelte b/packages/stacks-email/src/components/TemplateSidebar.svelte new file mode 100644 index 0000000000..d9beb9b6bf --- /dev/null +++ b/packages/stacks-email/src/components/TemplateSidebar.svelte @@ -0,0 +1,59 @@ + + + diff --git a/packages/stacks-email/src/lib/highlight/highlight.ts b/packages/stacks-email/src/lib/highlight/highlight.ts new file mode 100644 index 0000000000..73aa2664ca --- /dev/null +++ b/packages/stacks-email/src/lib/highlight/highlight.ts @@ -0,0 +1,29 @@ +import hljs from "highlight.js"; + +type SupportedLanguage = "html" | "xml"; + +const escapeHtml = (input: string) => + input + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); + +const resolveLanguage = (language: SupportedLanguage) => + hljs.getLanguage(language) ? language : "plaintext"; + +export const highlightCode = async ( + code: string, + language: SupportedLanguage +) => { + try { + const resolvedLanguage = resolveLanguage(language); + const highlighted = hljs.highlight(code, { + language: resolvedLanguage, + }).value; + + return `
${highlighted}
`; + } catch { + const escaped = escapeHtml(code); + return `
${escaped}
`; + } +}; diff --git a/packages/stacks-email/src/lib/pipeline/compile.ts b/packages/stacks-email/src/lib/pipeline/compile.ts new file mode 100644 index 0000000000..141c844be0 --- /dev/null +++ b/packages/stacks-email/src/lib/pipeline/compile.ts @@ -0,0 +1,165 @@ +import mjml2html from "mjml"; + +import { + applyTemplateProps, + extractComponentHtml, + extractTagMarkup, + wrapComponentWithMarkers, + wrapTagWithMarkers, +} from "./template"; + +import { targets, tokens, type CompileTarget } from "../../../tokens"; +import { mjmlConfig } from "../../../mjml-config"; +import { transformTokens } from "./transform"; + +type MjmlCompileResult = { + html: string; + errors: { + line: number | undefined; + message: string; + tagName?: string; + }[]; +}; + +const mjml2htmlSync = mjml2html as unknown as ( + mjml: string, + options?: { + validationLevel?: "strict" | "soft" | "skip"; + keepComments?: boolean; + minify?: boolean; + } +) => MjmlCompileResult; + +const mjmlTagPattern = /]/i; +const mjHeadOpenPattern = //i; +const mjHeadClosePattern = /<\/mj-head>/i; +const mjmlOpenTagPattern = /]*>/i; + +const escapePreviewText = (value: string) => + value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); + +const buildPreviewHeadNode = (previewText: string | undefined) => { + const normalizedPreviewText = previewText?.trim(); + if (!normalizedPreviewText) { + return ""; + } + + return `${escapePreviewText(normalizedPreviewText)}\n`; +}; + +const wrapInDocument = (mjmlSource: string, previewText: string | undefined) => ` + + + ${buildPreviewHeadNode(previewText)}${mjmlConfig} + + + + ${mjmlSource} + + + +`; + +const injectConfigIntoDocument = ( + documentSource: string, + previewText: string | undefined +) => { + const previewHeadNode = buildPreviewHeadNode(previewText); + + if (mjHeadOpenPattern.test(documentSource)) { + return documentSource.replace( + mjHeadClosePattern, + `${previewHeadNode}${mjmlConfig}\n` + ); + } + + if (mjmlOpenTagPattern.test(documentSource)) { + return documentSource.replace( + mjmlOpenTagPattern, + (openTag) => + `${openTag}\n${previewHeadNode}${mjmlConfig}` + ); + } + + return wrapInDocument(documentSource, previewText); +}; + +export type CompileMjmlInput = { + mjml: string; + target: CompileTarget; + props?: Record; + previewText?: string; + extractComponentName?: string; + extractComponentTag?: string; +}; + +export type CompileMjmlOutput = { + html: string; + componentHtml: string | null; + componentMjml: string | null; + mjml: string; + renderedMjml: string; + errors: { + line: number | undefined; + message: string; + tagName?: string; + }[]; +}; + +export const compileMjml = ({ + mjml, + target, + props = {}, + previewText, + extractComponentName, + extractComponentTag, +}: CompileMjmlInput): CompileMjmlOutput => { + const renderedMjml = applyTemplateProps(mjml, props); + const mjmlForCompile = extractComponentName + ? extractComponentTag + ? wrapTagWithMarkers( + renderedMjml, + extractComponentName, + extractComponentTag + ) + : wrapComponentWithMarkers(renderedMjml, extractComponentName) + : renderedMjml; + + const fullMjml = mjmlTagPattern.test(mjmlForCompile) + ? injectConfigIntoDocument(mjmlForCompile, previewText) + : wrapInDocument(mjmlForCompile, previewText); + + const compileResult = mjml2htmlSync(fullMjml, { + validationLevel: "soft", + keepComments: true, + minify: false, + }); + + const replacements = targets[target].tokens; + const targetRenderedMjml = transformTokens(renderedMjml, replacements); + const html = transformTokens(compileResult.html, replacements); + const componentMjml = extractComponentName + ? extractComponentTag + ? extractTagMarkup(targetRenderedMjml, extractComponentTag) + : targetRenderedMjml.trim() + : null; + const componentHtml = extractComponentName + ? extractComponentHtml(html, extractComponentName) + : null; + + return { + html, + componentMjml, + componentHtml, + mjml: fullMjml, + renderedMjml: targetRenderedMjml, + errors: compileResult.errors.map((issue) => ({ + line: issue.line, + message: issue.message, + tagName: issue.tagName, + })), + }; +}; diff --git a/packages/stacks-email/src/lib/pipeline/template.ts b/packages/stacks-email/src/lib/pipeline/template.ts new file mode 100644 index 0000000000..83abe92c19 --- /dev/null +++ b/packages/stacks-email/src/lib/pipeline/template.ts @@ -0,0 +1,92 @@ +const markerStart = (name: string) => ``; +const markerEnd = (name: string) => ``; + +const escapeRegExp = (value: string) => + value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +const buildPairTagPattern = (tagName: string) => + new RegExp( + `(<${tagName}\\b[^>]*>[\\s\\S]*?<\\/${tagName}>)`, + "i" + ); + +const buildSelfClosingTagPattern = (tagName: string) => + new RegExp(`(<${tagName}\\b[^>]*/>)`, "i"); + +const buildMarkerPattern = (name: string) => + new RegExp( + `${escapeRegExp(markerStart(name))}[\\s\\S]*?${escapeRegExp( + markerEnd(name) + )}`, + "i" + ); + +export const applyTemplateProps = ( + source: string, + props: Record +) => + Object.entries(props).reduce( + (next, [key, value]) => + next.replaceAll(`{{${key}}}`, String(value ?? "")), + source + ); + +export const wrapComponentWithMarkers = (mjml: string, name: string) => ` +${markerStart(name)} +${mjml} +${markerEnd(name)} +`; + +export const wrapTagWithMarkers = ( + mjml: string, + name: string, + tagName: string +) => { + const pairPattern = buildPairTagPattern(tagName); + const selfClosingPattern = buildSelfClosingTagPattern(tagName); + + if (pairPattern.test(mjml)) { + return mjml.replace( + pairPattern, + `${markerStart(name)}\n$1\n${markerEnd( + name + )}` + ); + } + + if (selfClosingPattern.test(mjml)) { + return mjml.replace( + selfClosingPattern, + `${markerStart(name)}\n$1\n${markerEnd( + name + )}` + ); + } + + return wrapComponentWithMarkers(mjml, name); +}; + +export const extractBetweenMarkers = (source: string, name: string) => { + const pattern = buildMarkerPattern(name); + const match = source.match(pattern); + + if (!match) { + return null; + } + + return match[0] + .replace(markerStart(name), "") + .replace(markerEnd(name), "") + .trim(); +}; + +export const extractComponentHtml = (html: string, name: string) => + extractBetweenMarkers(html, name); + +export const extractTagMarkup = (source: string, tagName: string) => { + const pairPattern = buildPairTagPattern(tagName); + const selfClosingPattern = buildSelfClosingTagPattern(tagName); + const match = source.match(pairPattern) ?? source.match(selfClosingPattern); + + return match?.[0]?.trim() ?? null; +}; diff --git a/packages/stacks-email/src/lib/pipeline/transform.ts b/packages/stacks-email/src/lib/pipeline/transform.ts new file mode 100644 index 0000000000..716303099c --- /dev/null +++ b/packages/stacks-email/src/lib/pipeline/transform.ts @@ -0,0 +1,12 @@ +const tokenPattern = /\[\[([A-Z0-9_]+)\]\]/g; + +export const transformTokens = ( + input: string, + replacements: Record +) => + input.replace(tokenPattern, (match, token: string) => { + if (token in replacements) { + return replacements[token]; + } + return match; + }); diff --git a/packages/stacks-email/src/lib/public/catalog.ts b/packages/stacks-email/src/lib/public/catalog.ts new file mode 100644 index 0000000000..a08b6d9731 --- /dev/null +++ b/packages/stacks-email/src/lib/public/catalog.ts @@ -0,0 +1,15 @@ +import { + listEmailComponents, + type EmailComponentCatalogItem, +} from "./components"; +import { listEmailTemplates, type EmailTemplateCatalogItem } from "./templates"; + +export type EmailCatalog = { + components: EmailComponentCatalogItem[]; + templates: EmailTemplateCatalogItem[]; +}; + +export const getEmailCatalog = (): EmailCatalog => ({ + components: listEmailComponents(), + templates: listEmailTemplates(), +}); diff --git a/packages/stacks-email/src/lib/public/compile.ts b/packages/stacks-email/src/lib/public/compile.ts new file mode 100644 index 0000000000..274157aa0f --- /dev/null +++ b/packages/stacks-email/src/lib/public/compile.ts @@ -0,0 +1,63 @@ +import { + compileEmailComponent, + getEmailComponentMeta, + type CompileComponentOutput, +} from "./components"; +import { + compileEmailTemplate, + getEmailTemplateMeta, + type CompileTemplateOutput, +} from "./templates"; +import type { CompileTarget } from "../../../tokens"; +import { compileEmailRenderableInputSchema } from "./validation"; + +export type EmailRenderableKind = "component" | "template"; + +export type CompileEmailRenderableInput = { + kind: EmailRenderableKind; + slug: string; + target: CompileTarget; + props?: Record; +}; + +export type CompileEmailRenderableOutput = + | CompileComponentOutput + | CompileTemplateOutput; + +export const compileEmailRenderable = ({ + kind, + slug, + target, + props = {}, +}: CompileEmailRenderableInput): CompileEmailRenderableOutput => { + const parsedInput = compileEmailRenderableInputSchema.parse({ + kind, + slug, + target, + props, + }); + + if (parsedInput.kind === "component") { + return compileEmailComponent({ + slug: parsedInput.slug, + target: parsedInput.target, + }); + } + + return compileEmailTemplate({ + slug: parsedInput.slug, + target: parsedInput.target, + props: parsedInput.props, + }); +}; + +export const getEmailRenderableMeta = ( + kind: EmailRenderableKind, + slug: string +) => { + if (kind === "component") { + return getEmailComponentMeta(slug); + } + + return getEmailTemplateMeta(slug); +}; diff --git a/packages/stacks-email/src/lib/public/components.ts b/packages/stacks-email/src/lib/public/components.ts new file mode 100644 index 0000000000..742b1f6742 --- /dev/null +++ b/packages/stacks-email/src/lib/public/components.ts @@ -0,0 +1,165 @@ +import { mjmlJsonToString } from "../../../mjml-json"; +import { componentDefinitions } from "../../../registry"; +import { renderVariantSource } from "../../../variants"; +import type { CompileTarget } from "../../../tokens"; +import type { + ComponentCategory, + EmailComponentMeta, + ComponentOptionReference, +} from "../../../types"; +import { compileMjml, type CompileMjmlOutput } from "../pipeline/compile"; +import { compileComponentInputSchema } from "./validation"; + +type ExpandedComponentRecord = { + slug: string; + name: string; + description: string; + category: ComponentCategory; + tokens: NonNullable; + options: NonNullable; + source: string; + htmlExtractionTag?: string; +}; + +export type EmailComponentCatalogItem = { + slug: string; + name: string; + description: string; + category: ComponentCategory; + tokens: NonNullable; + options: NonNullable; +}; + +export type CompileComponentInput = { + slug: string; + target: CompileTarget; +}; + +export type CompileComponentOutput = Omit< + CompileMjmlOutput, + "componentHtml" | "componentMjml" +> & { + kind: "component"; + slug: string; + componentHtml: string; + componentMjml: string; + meta: EmailComponentCatalogItem; +}; + +const DEFAULT_COMPONENT_CATEGORY: ComponentCategory = "Primitive"; + +const toLabel = (value: string) => + value + .split(/[-_]/g) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); + +const expandComponentRecords = (): ExpandedComponentRecord[] => + componentDefinitions + .map((definition) => ({ + meta: definition.meta, + source: mjmlJsonToString(definition.source), + })) + .flatMap((record) => { + const { meta } = record; + + return meta.variants.map((variant, index) => { + const isDefault = + variant.id === meta.defaultVariant || + (!meta.defaultVariant && index === 0); + const slug = isDefault + ? meta.slug + : `${meta.slug}-${variant.id}`; + const baseName = meta.name ?? toLabel(meta.slug); + const baseDescription = meta.description ?? ""; + const variantName = variant.name ?? toLabel(variant.id); + const variantDescription = variant.description ?? baseDescription; + + return { + slug, + name: + meta.variants.length > 1 + ? `${baseName} — ${variantName}` + : baseName, + description: + meta.variants.length > 1 + ? variantDescription + : baseDescription, + category: meta.category ?? DEFAULT_COMPONENT_CATEGORY, + tokens: meta.tokens ?? [], + options: meta.options ?? [], + source: renderVariantSource(record, variant), + htmlExtractionTag: meta.htmlExtraction?.targetTag, + }; + }); + }) + .sort((a, b) => a.name.localeCompare(b.name)); + +const expandedComponentRecords = expandComponentRecords(); + +const componentBySlug = new Map( + expandedComponentRecords.map((record) => [record.slug, record] as const) +); + +const toCatalogItem = ( + record: ExpandedComponentRecord +): EmailComponentCatalogItem => ({ + slug: record.slug, + name: record.name, + description: record.description, + category: record.category, + tokens: record.tokens, + options: record.options, +}); + +export const listEmailComponents = () => + expandedComponentRecords.map((record) => toCatalogItem(record)); + +export const getEmailComponentMeta = (slug: string) => { + const record = componentBySlug.get(slug); + if (!record) { + return null; + } + return toCatalogItem(record); +}; + +export const getEmailComponentOptions = ( + slug: string +): ComponentOptionReference[] | null => { + const record = componentBySlug.get(slug); + if (!record) { + return null; + } + + return record.options; +}; + +export const compileEmailComponent = ({ + slug, + target, +}: CompileComponentInput): CompileComponentOutput => { + const parsedInput = compileComponentInputSchema.parse({ slug, target }); + const record = componentBySlug.get(parsedInput.slug); + + if (!record) { + throw new Error(`Unknown email component slug: ${parsedInput.slug}`); + } + + const result = compileMjml({ + mjml: record.source, + target: parsedInput.target, + props: {}, + extractComponentName: record.slug, + extractComponentTag: record.htmlExtractionTag, + }); + + return { + ...result, + kind: "component", + slug: parsedInput.slug, + componentHtml: result.componentHtml ?? result.html, + componentMjml: result.componentMjml ?? result.renderedMjml, + meta: toCatalogItem(record), + }; +}; diff --git a/packages/stacks-email/src/lib/public/index.ts b/packages/stacks-email/src/lib/public/index.ts new file mode 100644 index 0000000000..f201277498 --- /dev/null +++ b/packages/stacks-email/src/lib/public/index.ts @@ -0,0 +1,53 @@ +export { + tokens, + targets, + targetNames, + isCompileTarget, + type Tokens, + type CompileTarget, +} from "../../../tokens"; +export { + transformTokens, +} from "../pipeline/transform"; +export { + compileMjml, + type CompileMjmlInput, + type CompileMjmlOutput, +} from "../pipeline/compile"; + +export { + listEmailComponents, + getEmailComponentMeta, + getEmailComponentOptions, + compileEmailComponent, + type EmailComponentCatalogItem, + type CompileComponentInput, + type CompileComponentOutput, +} from "./components"; + +export { + listEmailTemplates, + getEmailTemplateMeta, + compileEmailTemplate, + type EmailTemplateCatalogItem, + type CompileTemplateInput, + type CompileTemplateOutput, +} from "./templates"; + +export { getEmailCatalog, type EmailCatalog } from "./catalog"; + +export { + compileEmailRenderable, + getEmailRenderableMeta, + type EmailRenderableKind, + type CompileEmailRenderableInput, + type CompileEmailRenderableOutput, +} from "./compile"; + +export { + compileTargetSchema, + emailRenderableKindSchema, + compileComponentInputSchema, + compileTemplateInputSchema, + compileEmailRenderableInputSchema, +} from "./validation"; diff --git a/packages/stacks-email/src/lib/public/templates.ts b/packages/stacks-email/src/lib/public/templates.ts new file mode 100644 index 0000000000..672f220ecb --- /dev/null +++ b/packages/stacks-email/src/lib/public/templates.ts @@ -0,0 +1,291 @@ +import { mjmlJsonToString } from "../../../mjml-json"; +import { + BODY_LIST_ITEM_MARGIN, + BODY_LIST_MARGIN, + BODY_LIST_PADDING, + BODY_PARAGRAPH_MARGIN, +} from "../../../mjml-config"; +import { templateDefinitions } from "../../../registry"; +import { renderTemplateVariantSource } from "../../../variants"; +import type { CompileTarget } from "../../../tokens"; +import type { + EmailTemplateCategory, + EmailTemplateMeta, + EmailTemplateModule, + EmailTemplateVariant, +} from "../../../types"; +import MarkdownIt from "markdown-it"; +import { compileMjml, type CompileMjmlOutput } from "../pipeline/compile"; +import { compileTemplateInputSchema } from "./validation"; + +type ExpandedTemplateRecord = { + slug: string; + name: string; + description: string; + category: EmailTemplateCategory; + tokens: NonNullable; + variantProps: Record; + variant: EmailTemplateVariant; + template: EmailTemplateModule; +}; + +export type EmailTemplateCatalogItem = { + slug: string; + name: string; + description: string; + category: EmailTemplateCategory; + tokens: NonNullable; +}; + +export type CompileTemplateInput = { + slug: string; + target: CompileTarget; + props?: Record; +}; + +export type CompileTemplateOutput = CompileMjmlOutput & { + kind: "template"; + slug: string; + meta: EmailTemplateCatalogItem; +}; + +const DEFAULT_TEMPLATE_CATEGORY: EmailTemplateCategory = "Transactional"; +const DEFAULT_TEMPLATE_PREVIEW_TEXT = "Stack Overflow update"; + +const TEMPLATE_PREVIEW_TOKEN: NonNullable[number] = { + token: "PREVIEW_TEXT", + description: + "Inbox preheader text inserted into `` for all template compiles.", +}; + +const withSharedTemplateTokens = ( + tokens: NonNullable +) => { + const uniqueByToken = new Map(); + + for (const token of [TEMPLATE_PREVIEW_TOKEN, ...tokens]) { + if (!uniqueByToken.has(token.token)) { + uniqueByToken.set(token.token, token); + } + } + + return [...uniqueByToken.values()]; +}; + +const markdown = new MarkdownIt({ + html: false, + breaks: true, + linkify: true, + typographer: true, +}); + +markdown.renderer.rules.link_open = (tokenList, index, options, env, self) => { + tokenList[index].attrJoin("class", "link"); + return self.renderToken(tokenList, index, options); +}; + +markdown.renderer.rules.paragraph_open = ( + tokenList, + index, + options, + env, + self +) => { + const hasAnotherParagraph = tokenList + .slice(index + 1) + .some((token) => token.type === "paragraph_open"); + + tokenList[index].attrSet( + "style", + `margin:${hasAnotherParagraph ? BODY_PARAGRAPH_MARGIN : "0"};` + ); + + return self.renderToken(tokenList, index, options); +}; + +markdown.renderer.rules.bullet_list_open = ( + tokenList, + index, + options, + env, + self +) => { + tokenList[index].attrSet( + "style", + `margin:${BODY_LIST_MARGIN};padding:${BODY_LIST_PADDING};` + ); + + return self.renderToken(tokenList, index, options); +}; + +markdown.renderer.rules.list_item_open = ( + tokenList, + index, + options, + env, + self +) => { + const hasAnotherListItem = tokenList + .slice(index + 1) + .some((token) => token.type === "list_item_open"); + + tokenList[index].attrSet( + "style", + `margin:${hasAnotherListItem ? BODY_LIST_ITEM_MARGIN : "0"};` + ); + + return self.renderToken(tokenList, index, options); +}; + +const renderBodyMarkdown = (value: string) => { + const content = value.trim(); + + if (!content) { + return ""; + } + + return markdown.render(content).trim(); +}; + +const resolveTemplateCompileProps = ( + record: ExpandedTemplateRecord, + inputProps: Record +) => { + const compileProps = { ...inputProps }; + const defaultBodyMarkdown = record.variantProps.BODY_DEFAULT_MARKDOWN; + + if (typeof compileProps.BODY_MARKDOWN === "string") { + compileProps.BODY_CONTENT = renderBodyMarkdown(compileProps.BODY_MARKDOWN); + } else if (typeof compileProps.BODY_CONTENT !== "string") { + compileProps.BODY_CONTENT = renderBodyMarkdown(defaultBodyMarkdown ?? ""); + } + + if (typeof compileProps.PREVIEW_TEXT !== "string") { + compileProps.PREVIEW_TEXT = + record.description.trim() || + record.name.trim() || + DEFAULT_TEMPLATE_PREVIEW_TEXT; + } + + return compileProps; +}; + +const renderTemplateSource = ( + record: ExpandedTemplateRecord, + compileProps: Record +) => { + const variant: EmailTemplateVariant = { + ...record.variant, + props: { + ...record.variant.props, + ...compileProps, + }, + }; + const source = mjmlJsonToString(record.template.document(variant)); + return renderTemplateVariantSource( + { + meta: record.template.meta, + source, + }, + variant + ); +}; + +const toLabel = (value: string) => + value + .split(/[-_]/g) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); + +const expandTemplateRecords = (): ExpandedTemplateRecord[] => + templateDefinitions + .flatMap((definition) => { + const { meta } = definition; + return meta.variants.map((variant, index) => { + const isDefault = + variant.id === meta.defaultVariant || + (!meta.defaultVariant && index === 0); + const slug = isDefault + ? meta.slug + : `${meta.slug}-${variant.id}`; + const baseName = meta.name ?? toLabel(meta.slug); + const baseDescription = meta.description ?? ""; + const variantName = variant.name ?? toLabel(variant.id); + const variantDescription = variant.description ?? baseDescription; + + return { + slug, + name: + meta.variants.length > 1 + ? variantName + : baseName, + description: + meta.variants.length > 1 + ? variantDescription + : baseDescription, + category: meta.category ?? DEFAULT_TEMPLATE_CATEGORY, + tokens: meta.tokens ?? [], + variantProps: variant.props, + variant, + template: definition, + }; + }); + }) + .sort((a, b) => a.name.localeCompare(b.name)); + +const expandedTemplateRecords = expandTemplateRecords(); + +const templateBySlug = new Map( + expandedTemplateRecords.map((record) => [record.slug, record] as const) +); + +const toCatalogItem = ( + record: ExpandedTemplateRecord +): EmailTemplateCatalogItem => ({ + slug: record.slug, + name: record.name, + description: record.description, + category: record.category, + tokens: withSharedTemplateTokens(record.tokens), +}); + +export const listEmailTemplates = () => + expandedTemplateRecords.map((record) => toCatalogItem(record)); + +export const getEmailTemplateMeta = (slug: string) => { + const record = templateBySlug.get(slug); + if (!record) { + return null; + } + return toCatalogItem(record); +}; + +export const compileEmailTemplate = ({ + slug, + target, + props = {}, +}: CompileTemplateInput): CompileTemplateOutput => { + const parsedInput = compileTemplateInputSchema.parse({ slug, target, props }); + const record = templateBySlug.get(parsedInput.slug); + + if (!record) { + throw new Error(`Unknown email template slug: ${parsedInput.slug}`); + } + + const compileProps = resolveTemplateCompileProps(record, parsedInput.props ?? {}); + const source = renderTemplateSource(record, compileProps); + const result = compileMjml({ + mjml: source, + target: parsedInput.target, + props: {}, + previewText: compileProps.PREVIEW_TEXT, + }); + + return { + ...result, + kind: "template", + slug: parsedInput.slug, + meta: toCatalogItem(record), + }; +}; diff --git a/packages/stacks-email/src/lib/public/validation.ts b/packages/stacks-email/src/lib/public/validation.ts new file mode 100644 index 0000000000..6f7c4ca91e --- /dev/null +++ b/packages/stacks-email/src/lib/public/validation.ts @@ -0,0 +1,38 @@ +import { z } from "zod/v4"; + +import type { CompileTarget } from "../../../tokens"; + +const compileTargetValues = ["preview", "dotnet", "braze"] as const satisfies readonly CompileTarget[]; + +const slugSchema = z + .string({ error: "`slug` must be a non-empty string." }) + .trim() + .min(1, { error: "`slug` must be a non-empty string." }); + +export const compileTargetSchema = z.enum(compileTargetValues, { + error: "`target` must be one of `preview`, `dotnet`, or `braze`.", +}); + +export const emailRenderableKindSchema = z.enum(["component", "template"], { + error: "`kind` must be `component` or `template`.", +}); + +export const compileComponentInputSchema = z.object({ + slug: slugSchema, + target: compileTargetSchema, +}); + +const compilePropsSchema = z.record(z.string(), z.string()).optional(); + +export const compileTemplateInputSchema = z.object({ + slug: slugSchema, + target: compileTargetSchema, + props: compilePropsSchema, +}); + +export const compileEmailRenderableInputSchema = z.object({ + kind: emailRenderableKindSchema, + slug: slugSchema, + target: compileTargetSchema, + props: compilePropsSchema, +}); diff --git a/packages/stacks-email/src/routes/+layout.svelte b/packages/stacks-email/src/routes/+layout.svelte new file mode 100644 index 0000000000..5225fcff98 --- /dev/null +++ b/packages/stacks-email/src/routes/+layout.svelte @@ -0,0 +1,7 @@ + + +{@render children()} diff --git a/packages/stacks-email/src/routes/+layout.ts b/packages/stacks-email/src/routes/+layout.ts new file mode 100644 index 0000000000..d43d0cd2a5 --- /dev/null +++ b/packages/stacks-email/src/routes/+layout.ts @@ -0,0 +1 @@ +export const prerender = false; diff --git a/packages/stacks-email/src/routes/+page.server.ts b/packages/stacks-email/src/routes/+page.server.ts new file mode 100644 index 0000000000..77ebd7fcf8 --- /dev/null +++ b/packages/stacks-email/src/routes/+page.server.ts @@ -0,0 +1,7 @@ +import type { PageServerLoad } from "./$types"; + +import { listEmailTemplates } from "$lib/public/templates"; + +export const load: PageServerLoad = async () => ({ + templates: listEmailTemplates(), +}); diff --git a/packages/stacks-email/src/routes/+page.svelte b/packages/stacks-email/src/routes/+page.svelte new file mode 100644 index 0000000000..d0f01956ea --- /dev/null +++ b/packages/stacks-email/src/routes/+page.svelte @@ -0,0 +1,22 @@ + + +
+ + +
+
+
+ +

Email template gallery

+

Choose a template from the sidebar to open preview, MJML, and compiled HTML output.

+
+
+
+
diff --git a/packages/stacks-email/src/routes/api/compile/+server.ts b/packages/stacks-email/src/routes/api/compile/+server.ts new file mode 100644 index 0000000000..83b355f982 --- /dev/null +++ b/packages/stacks-email/src/routes/api/compile/+server.ts @@ -0,0 +1,231 @@ +import { json } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; +import { z } from "zod/v4"; +import { env } from "$env/dynamic/private"; + +import { compileMjml } from "$lib/pipeline/compile"; +import { compileTargetSchema } from "$lib/public/validation"; +import { Button } from "../../../../components/button"; +import { Footer } from "../../../../components/footer"; +import { Headline } from "../../../../components/headline"; +import { Header } from "../../../../components/header"; +import { Text } from "../../../../components/text"; +import { Title } from "../../../../components/title"; +import { Section } from "../../../../components/section"; +import { Spacer } from "../../../../components/spacer"; +import { mjmlJsonToString } from "../../../../mjml-json"; +import { tokens } from "../../../../tokens"; +import type { MjmlNode } from "../../../../types"; + +const headlineBlockSchema = z.object({ + type: z.literal("headline"), + variant: z.enum(["default", "highlight"]).optional().default("default"), + props: z + .object({ + sectionClass: z.string().optional(), + textClass: z.string().optional(), + textAlign: z.string().optional(), + textContent: z.string().optional(), + textHighlight: z.union([z.boolean(), z.string()]).optional(), + }) + .optional(), +}); + +const textBlockSchema = z.object({ + type: z.literal("text"), + variant: z.enum(["body", "centered"]).optional().default("body"), + props: z + .object({ + columnClass: z.string().optional(), + sectionClass: z.string().optional(), + textAlign: z.string().optional(), + textClass: z.string().optional(), + textContent: z.string().optional(), + }) + .optional(), +}); + +const buttonBlockSchema = z.object({ + type: z.literal("button"), + variant: z + .enum(["primary", "secondary", "inverted"]) + .optional() + .default("primary"), + props: z + .object({ + sectionClass: z.string().optional(), + align: z.string().optional(), + className: z.string().optional(), + cssClass: z.string().optional(), + href: z.string().optional(), + text: z.string().optional(), + }) + .optional(), +}); + +const titleBlockSchema = z.object({ + type: z.literal("title"), + variant: z.enum(["default", "invert"]).optional().default("default"), + props: z + .object({ + sectionClass: z.string().optional(), + textClass: z.string().optional(), + textAlign: z.string().optional(), + textContent: z.string().optional(), + }) + .optional(), +}); + +const spacerBlockSchema = z.object({ + type: z.literal("spacer"), + size: z.enum(["medium", "large"]).optional().default("medium"), + props: z + .object({ + sectionClass: z.string().optional(), + height: z.string().optional(), + }) + .optional(), +}); + +const composeBlockSchema = z.discriminatedUnion("type", [ + headlineBlockSchema, + textBlockSchema, + buttonBlockSchema, + titleBlockSchema, + spacerBlockSchema, +]); + +const composeRequestSchema = z.object({ + template: z.literal("transactional"), + target: compileTargetSchema, + previewText: z.string().optional(), + blocks: z + .array(composeBlockSchema) + .min(1, { error: "`blocks` must contain at least one block." }), +}); + +type ComposeBlock = z.infer; + +const hasValidBearerToken = (request: Request): boolean => { + const expectedToken = env.STACKS_EMAIL_AUTH_TOKEN?.trim(); + if (!expectedToken) { + return true; + } + + const authorization = request.headers.get("authorization"); + if (!authorization) { + return false; + } + + const [scheme, token] = authorization.trim().split(/\s+/, 2); + return scheme?.toLowerCase() === "bearer" && token === expectedToken; +}; + +const renderTransactionalBlock = (block: ComposeBlock): MjmlNode => { + switch (block.type) { + case "headline": + return Headline(block.variant, block.props ?? {}); + case "text": + return Text(block.variant, block.props ?? {}); + case "button": { + const sectionClass = block.props?.sectionClass ?? "bg-block"; + const buttonProps = { + align: block.props?.align, + className: block.props?.className, + cssClass: block.props?.cssClass, + href: block.props?.href, + text: block.props?.text, + }; + return Section([Button(block.variant, buttonProps)], { + sectionClass, + }); + } + case "title": + return Title(block.variant, block.props ?? {}); + case "spacer": + return Spacer(block.size, block.props ?? {}); + } +}; + +const buildTransactionalDocument = (blocks: ComposeBlock[]): MjmlNode => ({ + tagName: "mjml", + children: [ + { + tagName: "mj-body", + attributes: { + "background-color": tokens.color.bodyBackground, + }, + children: [ + Spacer("large", { + sectionClass: "bg-page", + }), + Header("transactional"), + ...blocks.map((block) => renderTransactionalBlock(block)), + Spacer("medium", { + sectionClass: "bg-block", + }), + Footer("default", { + unsubscribeUrl: "[[UNSUBSCRIBE_URL]]", + }), + Spacer("large", { + sectionClass: "bg-page", + }), + ], + }, + ], +}); + +export const POST: RequestHandler = async ({ request }) => { + if (!hasValidBearerToken(request)) { + return json({ error: "Unauthorized." }, { status: 401 }); + } + + let body: unknown; + + try { + body = await request.json(); + } catch { + return json( + { error: "Request body must be valid JSON." }, + { status: 400 } + ); + } + + const parsed = composeRequestSchema.safeParse(body); + if (!parsed.success) { + return json( + { + error: parsed.error.issues.map((issue) => issue.message).join(" "), + }, + { status: 400 } + ); + } + + try { + const document = buildTransactionalDocument(parsed.data.blocks); + const mjml = mjmlJsonToString(document); + const compiled = compileMjml({ + mjml, + target: parsed.data.target, + props: {}, + previewText: parsed.data.previewText, + }); + + return json({ + ...compiled, + template: parsed.data.template, + target: parsed.data.target, + blockCount: parsed.data.blocks.length, + }); + } catch (error) { + return json( + { + error: + error instanceof Error + ? error.message + : "Failed to compose and compile transactional email.", + }, + { status: 500 } + ); + } +}; diff --git a/packages/stacks-email/src/routes/emails/[slug]/+page.server.ts b/packages/stacks-email/src/routes/emails/[slug]/+page.server.ts new file mode 100644 index 0000000000..d5eeca142f --- /dev/null +++ b/packages/stacks-email/src/routes/emails/[slug]/+page.server.ts @@ -0,0 +1,73 @@ +import { error } from "@sveltejs/kit"; +import type { PageServerLoad } from "./$types"; + +import { highlightCode } from "$lib/highlight/highlight"; +import { targetNames, type CompileTarget } from "../../../../tokens"; +import { + compileEmailTemplate, + getEmailTemplateMeta, + listEmailTemplates, +} from "$lib/public/templates"; + +export const load: PageServerLoad = async ({ params, url }) => { + const template = getEmailTemplateMeta(params.slug); + + if (!template) { + throw error(404, "Template not found"); + } + + const requestedTarget = url.searchParams.get("target"); + const target = ( + requestedTarget && + targetNames.includes(requestedTarget as CompileTarget) + ? requestedTarget + : "preview" + ) as CompileTarget; + + const compiledByTargetEntries = await Promise.all( + targetNames.map(async (compileTarget) => { + const compiled = compileEmailTemplate({ + slug: params.slug, + target: compileTarget, + }); + + const highlightedHtml = await highlightCode(compiled.html, "html"); + + return [ + compileTarget, + { + html: compiled.html, + highlightedHtml, + errors: compiled.errors, + }, + ] as const; + }) + ); + + const renderedMjml = compileEmailTemplate({ + slug: params.slug, + target: "preview", + }).renderedMjml; + + const highlightedMjml = await highlightCode(renderedMjml, "xml"); + + return { + templates: listEmailTemplates(), + template, + target, + renderedMjml, + highlightedMjml, + compiledByTarget: Object.fromEntries(compiledByTargetEntries) as Record< + CompileTarget, + { + html: string; + highlightedHtml: string; + errors: { + line: number | undefined; + message: string; + tagName?: string; + }[]; + } + >, + }; +}; diff --git a/packages/stacks-email/src/routes/emails/[slug]/+page.svelte b/packages/stacks-email/src/routes/emails/[slug]/+page.svelte new file mode 100644 index 0000000000..53bb89b026 --- /dev/null +++ b/packages/stacks-email/src/routes/emails/[slug]/+page.svelte @@ -0,0 +1,167 @@ + + + + +
+ + +
+
+ + {#each tabOptions as tab (tab.id)} + (activeTab = tab.id)} + /> + {/each} + + +
+ + + +
+
+ +
+ {#if activeCompiled.errors.length > 0} + + MJML reported {activeCompiled.errors.length} issue(s) for {activeTarget}. + + {/if} + +
+ {#if activeTab !== "preview"} +
+ {@html highlightedCode} +
+ {/if} + + +
+
+
+
diff --git a/packages/stacks-email/src/types/json2mjml.d.ts b/packages/stacks-email/src/types/json2mjml.d.ts new file mode 100644 index 0000000000..579f6b5e7d --- /dev/null +++ b/packages/stacks-email/src/types/json2mjml.d.ts @@ -0,0 +1,4 @@ +declare module "json2mjml" { + const json2mjml: (node: unknown) => string; + export default json2mjml; +} diff --git a/packages/stacks-email/src/types/stacks-icons.d.ts b/packages/stacks-email/src/types/stacks-icons.d.ts new file mode 100644 index 0000000000..273bb934f7 --- /dev/null +++ b/packages/stacks-email/src/types/stacks-icons.d.ts @@ -0,0 +1 @@ +declare module "@stackoverflow/stacks-icons"; diff --git a/packages/stacks-email/static/email/hero/1200x630.png b/packages/stacks-email/static/email/hero/1200x630.png new file mode 100644 index 0000000000000000000000000000000000000000..2bbd4bf221b03f54352bf5ff54c2f0a3574b9f4b GIT binary patch literal 24010 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 z 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 IN56#s0x?g$t^fc4 literal 0 HcmV?d00001 diff --git a/packages/stacks-email/static/email/social/instagram.png b/packages/stacks-email/static/email/social/instagram.png new file mode 100644 index 0000000000000000000000000000000000000000..c91e85e8215304c3a91022869e4d648d8a741807 GIT binary patch literal 906 zcmV;519kj~P)P`@6f|3cK6Eqv7nV`)C z&7*dps3*kP<%&%+!v=f%iu4CvP+v83Mif?FqCw~H6wh${8UpvBfyG_Ul6t-ke;LWeK?zoa5 z9Y>opvMf>$>-BmLOKf<#*h*Z-yO-s1Io5qUoz9Hjav>uFv8hAHdt1ke4?-~eU&M7d zZCM(!oxBKtf=m*Af-*BR0d($lU+5fE5!9VCbncNqIaa8o9$Vi^GXZpNNLJ1^iff5Y zg-%%Ha^*Z3lshQ{y!%9lCCw+~^35t;WdxlNzluz9u#B?%;oX$KnIsVy_Y{a=f^hE7 zx())R`zU`COX~hN6sj+zv)-ztnX;9t#)x(G5+)_fOcxWR13AVk`9m4?hyoBw@T^0E zg+?uy8ubu=GA+NL00a?~J!-=oZxSW`Ul-c6hBcQ z&Pg-h$V$NGo@QXxRS3<|ZQ0rEg%Cj)qBmtYd1PR%p6mN2tu9H5c zOI(&zS)sA#x*}7-%8ykia!bca0KIdJVP%ojIXx&Uf%w(b*kxEr8W62<5JAlP6|>Hd z{c`G6_8|(5H1j+u#4{rKvzy*riE2C~0=(92ZeD2}nOnrV5UcFQjpJ@J~WX&X@S(&Udjw#F_hschCpEZpSeCL!kJ2&+Jp|Br5i;5`m| zasVfS1KpOUX^2CM9AFg)h7j~u{y6WWU_Qwag+{&G?Xokz$Fzo2BPhlbP24skY0+hA zP4J6z_m}Fk>^74(pm0X8y{gLy=ac+6mcY9dBkd@tC_g6GuNr3wWkpbLGk9OihJAJ% zeKJNXdREnLde%Lbl3d3_`VWC+>2|vln~#hxBKd9e<5Y1is7OXOzuE+1txb`}RCWcV ze1+>mfgyrlw&&C$Q0^%)`GQo{Bb{Ru)|0{2`ZlUXAO!U}HV$i%n5-wg~ zRlLBec!5>nY!+I0Rn7ATt;}?25o5(z;l5qe4002ovPDHLkV1nF)|EvH2 literal 0 HcmV?d00001 diff --git a/packages/stacks-email/static/email/social/threads.png b/packages/stacks-email/static/email/social/threads.png new file mode 100644 index 0000000000000000000000000000000000000000..c9c391aaab100d6dbbd0104c55da0230b24a1cf1 GIT binary patch literal 1111 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 { + const language = hljs.getLanguage(lang) + ? lang + : "plaintext"; + const highlighted = hljs.highlight(code, { + language, + }).value; + const escaped = highlighted + .replace(/\{/g, "{") + .replace(/\}/g, "}"); + return `
${escaped}
`; + }, + }, + }), + ], + kit: { + adapter: adapter(), + }, + extensions: [".svelte", ".md"], +}; + +export default config; diff --git a/packages/stacks-email/templates/transactional.ts b/packages/stacks-email/templates/transactional.ts new file mode 100644 index 0000000000..9d7fc881f3 --- /dev/null +++ b/packages/stacks-email/templates/transactional.ts @@ -0,0 +1,138 @@ +import { Button } from "../components/button"; +import { Footer } from "../components/footer"; +import { Headline } from "../components/headline"; +import { Header } from "../components/header"; +import { Section } from "../components/section"; +import { Spacer } from "../components/spacer"; +import { Text } from "../components/text"; +import { Graphic } from "../components/graphic"; + +import { tokens } from "../tokens"; + +import type { EmailTemplateMeta, EmailTemplateModule, MjmlNode } from "../types"; + +export const meta: EmailTemplateMeta = { + slug: "transactional", + defaultVariant: "short", + variants: [ + { + id: "short", + props: { + HEADLINE_TEXT: "Reset your password", + BODY_DEFAULT_MARKDOWN: ` +**Hi [[FIRST_NAME]]**. We received a request to reset your password. Use the button below to choose a new password. + `, + CTA_TEXT: "Reset password", + }, + }, + { + id: "long", + props: { + HEADLINE_TEXT: "Privacy Policy Update", + BODY_DEFAULT_MARKDOWN: ` +We're writing to let you know that we've updated our Privacy Policy, effective **1 January 1970**. + +As part of our ongoing commitment to transparency and data protection, we've made several changes to how we collect, use, and store your personal information. Here's a summary of what's changed: + +**What's new:** +- **Data retention periods** – We've clarified how long we keep your data and the criteria used to determine retention timelines. +- **Third-party sharing** – We've updated our list of trusted partners with whom we may share aggregated, anonymized data to improve our services. +- **Your rights** – We've expanded the section outlining your rights under applicable privacy laws, including the right to access, correct, and delete your data. + +These changes do not affect how we use your data for core service delivery. Your continued use of Stack Overflow after **1 January 1970** constitutes acceptance of the updated policy. + +You can review the full Privacy Policy at any time here: + `, + CTA_TEXT: "View privacy policy", + GRAPHIC_PATH: "/email/spots/SpotLock.png", + }, + }, + ], + tokens: [ + { + token: "FIRST_NAME", + description: "Recipient first name used in the short greeting.", + }, + { + token: "CTA_URL", + description: + "Primary call-to-action URL (password reset for short; policy link for long).", + }, + { + token: "UNSUBSCRIBE_URL", + description: "Recipient-specific unsubscribe URL.", + }, + { + token: "GRAPHIC_PATH", + description: + "Optional spot graphic path for the long variant. Leave empty to hide the graphic.", + }, + ], +}; + +export const document = (variant = meta.variants[0]): MjmlNode => { + const isLongVariant = variant.id === "long"; + const headlineVariant = isLongVariant ? "default" : "highlight"; + + const graphicPath = variant.props.GRAPHIC_PATH?.trim(); + const graphicBlock = isLongVariant + ? graphicPath + ? [ + Graphic("spot", { + imageSrc: graphicPath, + }), + ] + : [] + : []; + + return { + tagName: "mjml", + children: [ + { + tagName: "mj-body", + attributes: { + "background-color": tokens.color.bodyBackground, + }, + children: [ + Spacer("large", { + sectionClass: "bg-page", + }), + Header("transactional"), + Headline(headlineVariant, { + textContent: "{{HEADLINE_TEXT}}", + }), + ...graphicBlock, + Text("body", { + textContent: "{{BODY_CONTENT}}", + }), + Section( + [ + Button("primary", { + href: "[[CTA_URL]]", + align: "left", + text: "{{CTA_TEXT}}", + }), + ], + { + sectionClass: "bg-block", + } + ), + Spacer("large"), + Footer("default", { + unsubscribeUrl: "[[UNSUBSCRIBE_URL]]", + }), + Spacer("large", { + sectionClass: "bg-page", + }), + ], + }, + ], + }; +}; + +const template: EmailTemplateModule = { + meta, + document, +}; + +export default template; diff --git a/packages/stacks-email/tokens.ts b/packages/stacks-email/tokens.ts new file mode 100644 index 0000000000..d8be5db718 --- /dev/null +++ b/packages/stacks-email/tokens.ts @@ -0,0 +1,161 @@ +/** + * Color tokens used for all email component palettes, state surfaces, and links. + */ +const color = { + bodyBackground: "#eee", + background: "#fff", + blockBackground: "#fff", + accent: "#ffcc01", + border: "#d6d9dc", + brand: "#FF5E00", + brandDark: "#201C1D", + brandOffWhite: "#eeeeee", + text: "#211d1e", + textMuted: "#6B6B6B", + textInvert: "#ffffff", + textFooter: "#cdc8c2", + link: "#0000ef", + linkHover: "#5074ef", +} as const; + +/** + * Utility-class background color tokens (bg-[color]). + */ +const backgroundClasses = [ + { name: "brand", value: color.brand }, + { name: "invert", value: color.brandDark }, + { name: "accent", value: color.accent }, + { name: "block", value: color.blockBackground }, + { name: "page", value: color.bodyBackground }, +] as const; + +/** + * Utility-class font color tokens (fc-[color]). + */ +const fontClasses = [ + { name: "text", value: color.text }, + { name: "text-muted", value: color.textMuted }, + { name: "text-invert", value: color.textInvert }, + { name: "text-footer", value: color.textFooter }, +] as const; + +/** + * Typography tokens for all email copy scales and weights. + */ +const font = { + family: "Arial, Helvetica, sans-serif", + sizeBase: "16px", + sizeSm: "14px", + sizeLg: "20px", + sizeXl: "24px", + sizeXxl: "32px", + weightNormal: "400", + weightBold: "700", + lineHeightBase: "1.5", + lineHeightTight: "1.25", +} as const; + +/** + * Spacing tokens for layout rhythm, padding, and separation. + */ +const spacing = { + xs: "4px", + sm: "8px", + md: "16px", + lg: "24px", + xl: "40px", + xxl: "56px", +} as const; + +/** + * Layout tokens for widths and global content padding. + */ +const layout = { + maxWidth: "600px", + containerXPadding: "24px", + containerYPadding: "20px", + logoWidth: "120px", + heroImageWidth: "552px", + socialIconSize: "20px", + cardImageWidth: "252px", + illustrationWidth: "552px", +} as const; + +/** + * Border tokens for shared radius and divider styles. + */ +const border = { + radius: "1000px", + radiusLg: "8px", + style: `1px solid ${color.border}`, + sectionDivider: `1px solid ${color.border}`, +} as const; + +export const tokens = { + color: { + ...color, + backgroundClasses, + fontClasses, + }, + font, + spacing, + layout, + border, +} as const; + +export type Tokens = typeof tokens; + +/** + * Compile targets and token substitutions for each downstream renderer. + */ +export const targets = { + preview: { + tokens: { + FIRST_NAME: "Jane", + BUTTON_LABEL: "Learn more", + BUTTON_URL: "https://example.com", + LINK_URL: "https://example.com/read-more", + PREVIEW_TEXT: "You have a new update from Stack Overflow.", + CARD_ONE_URL: "https://example.com/story-one", + CARD_TWO_URL: "https://example.com/story-two", + FOOTER_REASON: "you subscribed to Stack Overflow updates.", + UNSUBSCRIBE_URL: "https://example.com/unsubscribe", + COMPANY_NAME: "Acme Corp", + }, + }, + dotnet: { + tokens: { + FIRST_NAME: "@Model.FirstName", + BUTTON_LABEL: "@Model.ButtonLabel", + BUTTON_URL: "@Model.ButtonText", + LINK_URL: "@Model.LinkUrl", + PREVIEW_TEXT: "@Model.PreviewText", + CARD_ONE_URL: "@Model.CardOneUrl", + CARD_TWO_URL: "@Model.CardTwoUrl", + FOOTER_REASON: "@Model.FooterReason", + UNSUBSCRIBE_URL: "@Model.UnsubscribeUrl", + COMPANY_NAME: "@Model.CompanyName", + }, + }, + braze: { + tokens: { + FIRST_NAME: "{{${first_name}}}", + BUTTON_LABEL: "{{custom_attribute.${cta_label}}}", + BUTTON_URL: "{{custom_attribute.${cta_url}}}", + LINK_URL: "{{custom_attribute.${link_url}}}", + PREVIEW_TEXT: "{{custom_attribute.${preview_text}}}", + CARD_ONE_URL: "{{custom_attribute.${card_one_url}}}", + CARD_TWO_URL: "{{custom_attribute.${card_two_url}}}", + FOOTER_REASON: "{{custom_attribute.${footer_reason}}}", + UNSUBSCRIBE_URL: "{{${unsubscribe_url}}}", + COMPANY_NAME: "{{custom_attribute.${company_name}}}", + }, + }, +} as const; + +export type CompileTarget = keyof typeof targets; + +export const targetNames = Object.keys(targets) as CompileTarget[]; + +export const isCompileTarget = (value: string): value is CompileTarget => + targetNames.includes(value as CompileTarget); diff --git a/packages/stacks-email/tsconfig.json b/packages/stacks-email/tsconfig.json new file mode 100644 index 0000000000..104691d2d5 --- /dev/null +++ b/packages/stacks-email/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/packages/stacks-email/types.ts b/packages/stacks-email/types.ts new file mode 100644 index 0000000000..a30420ca5d --- /dev/null +++ b/packages/stacks-email/types.ts @@ -0,0 +1,86 @@ +export type MjmlAttributeValue = string | number | boolean; + +export type MjmlNode = { + tagName: string; + attributes?: Record; + children?: MjmlNode[]; + content?: string; +}; + +export type VariantProps = Record; + +export type EmailVariant = { + id: string; + name?: string; + description?: string; + props: VariantProps; +}; + +export type EmailTokenReference = { + token: string; + description: string; +}; + +export type ComponentCategory = + | "Structure" + | "Content" + | "Layout" + | "Primitive" + | "Interactive"; + +export type ComponentVariant = EmailVariant; +export type ComponentTokenReference = EmailTokenReference; +export type ComponentOptionReference = { + argument: string; + type: string; + defaultValue?: string; + defaultValueCode?: boolean; + description: string; +}; + +export type EmailComponentMeta = { + slug: string; + name?: string; + description?: string; + category?: ComponentCategory; + variants: ComponentVariant[]; + defaultVariant?: string; + tokens?: ComponentTokenReference[]; + options?: ComponentOptionReference[]; + htmlExtraction?: { + targetTag: string; + }; +}; + +export type EmailComponentRecord = { + meta: EmailComponentMeta; + source: string; +}; + +export type EmailTemplateCategory = + | "Transactional" + | "Marketing" + | "Onboarding"; + +export type EmailTemplateVariant = EmailVariant; +export type EmailTemplateTokenReference = EmailTokenReference; + +export type EmailTemplateMeta = { + slug: string; + name?: string; + description?: string; + category?: EmailTemplateCategory; + variants: EmailTemplateVariant[]; + defaultVariant?: string; + tokens?: EmailTemplateTokenReference[]; +}; + +export type EmailTemplateRecord = { + meta: EmailTemplateMeta; + source: string; +}; + +export type EmailTemplateModule = { + meta: EmailTemplateMeta; + document: (variant?: EmailTemplateVariant) => MjmlNode; +}; diff --git a/packages/stacks-email/variants.ts b/packages/stacks-email/variants.ts new file mode 100644 index 0000000000..8b038f9ae9 --- /dev/null +++ b/packages/stacks-email/variants.ts @@ -0,0 +1,44 @@ +import { applyTemplateProps } from "./src/lib/pipeline/template"; +import type { + ComponentVariant, + EmailComponentMeta, + EmailComponentRecord, + EmailTemplateMeta, + EmailTemplateRecord, + EmailTemplateVariant, + EmailVariant, +} from "./types"; + +const getBaseVariantById = ( + variants: TVariant[], + defaultVariant: string | undefined, + variantId: string | null | undefined +) => + variants.find((variant) => variant.id === variantId) ?? + variants.find((variant) => variant.id === defaultVariant) ?? + variants[0]; + +export const getVariantById = ( + meta: EmailComponentMeta, + variantId: string | null | undefined +) => getBaseVariantById(meta.variants, meta.defaultVariant, variantId); + +export const getTemplateVariantById = ( + meta: EmailTemplateMeta, + variantId: string | null | undefined +) => getBaseVariantById(meta.variants, meta.defaultVariant, variantId); + +const renderSource = ( + source: string, + variant: ComponentVariant | EmailTemplateVariant +) => applyTemplateProps(source, variant.props); + +export const renderVariantSource = ( + record: EmailComponentRecord, + variant: ComponentVariant +) => renderSource(record.source, variant); + +export const renderTemplateVariantSource = ( + record: EmailTemplateRecord, + variant: EmailTemplateVariant +) => renderSource(record.source, variant); diff --git a/packages/stacks-email/vite.config.ts b/packages/stacks-email/vite.config.ts new file mode 100644 index 0000000000..4a79a4b1d8 --- /dev/null +++ b/packages/stacks-email/vite.config.ts @@ -0,0 +1,6 @@ +import { sveltekit } from "@sveltejs/kit/vite"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [sveltekit()], +}); From 97dad67c2e889fbe83c2dea519062a6da9a66b1a Mon Sep 17 00:00:00 2001 From: David Longworth Date: Tue, 2 Jun 2026 18:41:18 +0100 Subject: [PATCH 02/30] refactor(stacks-email): modernize email engine --- package-lock.json | 1 + packages/stacks-email/README.md | 157 +++-- packages/stacks-email/components/button.ts | 195 ++---- packages/stacks-email/components/footer.ts | 646 ++++++++---------- packages/stacks-email/components/graphic.ts | 318 +++------ packages/stacks-email/components/header.ts | 251 +++---- packages/stacks-email/components/headline.ts | 256 ++----- packages/stacks-email/components/index.ts | 4 +- packages/stacks-email/components/preview.ts | 96 +-- packages/stacks-email/components/spacer.ts | 84 ++- packages/stacks-email/components/spacers.ts | 76 --- packages/stacks-email/components/text.ts | 219 +++--- packages/stacks-email/components/title.ts | 203 ++---- packages/stacks-email/eslint.config.js | 10 + packages/stacks-email/mjml-config.ts | 246 ------- packages/stacks-email/mjml-json.ts | 19 - packages/stacks-email/package.json | 4 +- packages/stacks-email/registry.ts | 35 - .../src/lib/{public => api}/catalog.ts | 0 .../src/lib/{public => api}/compile.ts | 4 +- .../stacks-email/src/lib/api/components.ts | 135 ++++ packages/stacks-email/src/lib/api/index.ts | 16 + packages/stacks-email/src/lib/api/records.ts | 100 +++ .../validation.ts => api/request-schemas.ts} | 7 +- .../src/lib/api/static-artifacts.ts | 283 ++++++++ .../stacks-email/src/lib/api/templates.ts | 173 +++++ .../src/lib/{highlight => }/highlight.ts | 21 +- packages/stacks-email/src/lib/markdown.ts | 78 +++ packages/stacks-email/src/lib/mjml/config.ts | 268 ++++++++ .../section.ts => src/lib/mjml/index.ts} | 7 +- packages/stacks-email/src/lib/mjml/json.ts | 7 + .../stacks-email/src/lib/pipeline/compile.ts | 44 +- .../stacks-email/src/lib/pipeline/template.ts | 216 ++++-- .../stacks-email/src/lib/public/components.ts | 165 ----- packages/stacks-email/src/lib/public/index.ts | 53 -- .../stacks-email/src/lib/public/templates.ts | 291 -------- packages/stacks-email/src/lib/registry.ts | 35 + .../stacks-email/src/lib/schema/component.ts | 121 ++++ packages/stacks-email/src/lib/schema/index.ts | 8 + .../stacks-email/src/lib/schema/metadata.ts | 40 ++ .../stacks-email/src/lib/schema/normalize.ts | 38 ++ .../stacks-email/src/lib/schema/options.ts | 126 ++++ .../stacks-email/src/lib/schema/template.ts | 99 +++ .../stacks-email/src/lib/sveltekit/index.ts | 46 ++ packages/stacks-email/{ => src/lib}/tokens.ts | 37 +- packages/stacks-email/src/lib/types.ts | 54 ++ .../stacks-email/src/routes/+page.server.ts | 2 +- packages/stacks-email/src/routes/+page.svelte | 2 +- .../src/routes/api/compile/+server.ts | 271 ++++---- .../src/routes/emails/[slug]/+page.server.ts | 6 +- .../src/routes/emails/[slug]/+page.svelte | 34 +- .../src/types/mjml-parser-xml.d.ts | 5 + .../{components => ui}/TemplateSidebar.svelte | 2 +- packages/stacks-email/svelte.config.js | 37 +- .../stacks-email/templates/transactional.ts | 212 +++--- packages/stacks-email/tsconfig.eslint.json | 17 + packages/stacks-email/tsconfig.json | 1 + packages/stacks-email/types.ts | 86 --- packages/stacks-email/variants.ts | 44 -- packages/stacks-email/vite.config.ts | 3 + 60 files changed, 3136 insertions(+), 2878 deletions(-) delete mode 100644 packages/stacks-email/components/spacers.ts delete mode 100644 packages/stacks-email/mjml-config.ts delete mode 100644 packages/stacks-email/mjml-json.ts delete mode 100644 packages/stacks-email/registry.ts rename packages/stacks-email/src/lib/{public => api}/catalog.ts (100%) rename packages/stacks-email/src/lib/{public => api}/compile.ts (92%) create mode 100644 packages/stacks-email/src/lib/api/components.ts create mode 100644 packages/stacks-email/src/lib/api/index.ts create mode 100644 packages/stacks-email/src/lib/api/records.ts rename packages/stacks-email/src/lib/{public/validation.ts => api/request-schemas.ts} (75%) create mode 100644 packages/stacks-email/src/lib/api/static-artifacts.ts create mode 100644 packages/stacks-email/src/lib/api/templates.ts rename packages/stacks-email/src/lib/{highlight => }/highlight.ts (56%) create mode 100644 packages/stacks-email/src/lib/markdown.ts create mode 100644 packages/stacks-email/src/lib/mjml/config.ts rename packages/stacks-email/{components/section.ts => src/lib/mjml/index.ts} (94%) create mode 100644 packages/stacks-email/src/lib/mjml/json.ts delete mode 100644 packages/stacks-email/src/lib/public/components.ts delete mode 100644 packages/stacks-email/src/lib/public/index.ts delete mode 100644 packages/stacks-email/src/lib/public/templates.ts create mode 100644 packages/stacks-email/src/lib/registry.ts create mode 100644 packages/stacks-email/src/lib/schema/component.ts create mode 100644 packages/stacks-email/src/lib/schema/index.ts create mode 100644 packages/stacks-email/src/lib/schema/metadata.ts create mode 100644 packages/stacks-email/src/lib/schema/normalize.ts create mode 100644 packages/stacks-email/src/lib/schema/options.ts create mode 100644 packages/stacks-email/src/lib/schema/template.ts create mode 100644 packages/stacks-email/src/lib/sveltekit/index.ts rename packages/stacks-email/{ => src/lib}/tokens.ts (83%) create mode 100644 packages/stacks-email/src/lib/types.ts create mode 100644 packages/stacks-email/src/types/mjml-parser-xml.d.ts rename packages/stacks-email/src/{components => ui}/TemplateSidebar.svelte (96%) create mode 100644 packages/stacks-email/tsconfig.eslint.json delete mode 100644 packages/stacks-email/types.ts delete mode 100644 packages/stacks-email/variants.ts diff --git a/package-lock.json b/package-lock.json index dcc58034f7..c471ebf129 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17055,6 +17055,7 @@ "json2mjml": "^1.0.3", "markdown-it": "^14.1.0", "mjml": "^4.17.1", + "mjml-parser-xml": "^4.18.0", "zod": "^4.1.12" }, "devDependencies": { diff --git a/packages/stacks-email/README.md b/packages/stacks-email/README.md index c253887b51..7bbf6c4fa1 100644 --- a/packages/stacks-email/README.md +++ b/packages/stacks-email/README.md @@ -1,77 +1,124 @@ # Stacks Email -Stack Overflow’s MJML powered email compile engine with a tokenized component library and template library +Stack Overflow’s [MJML](https://mjml.io/) powered email compile engine with a tokenized component library and template library - Primary docs/UI lives in `@stackoverflow/stacks-docs` under [`src/docs/public/email`](https://github.com/StackExchange/Stacks/tree/main/packages/stacks-docs/src/docs/public/email) or available on [stackoverflow.design/email](https://stackoverflow.design/email/). - Full email preview gallery is available at [email.stackoverflow.design](https://email.stackoverflow.design). -## Token placeholders +## Component schema definitions -Template authors should use the neutral placeholder syntax: +New components should use the schema helpers in `src/lib/schema.ts` so rendering, +defaults, validation, and generated docs stay in one place. -- `[[FIRST_NAME]]` -- `[[CTA_URL]]` -- `[[UNSUBSCRIBE_URL]]` -- `[[COMPANY_NAME]]` - -During compilation, placeholders are transformed by target: - -- `preview` -> concrete example values -- `dotnet` -> Razor expressions -- `braze` -> Liquid expressions - -## Component render standard - -Component partials are compiled inside a shared MJML wrapper so they inherit global classes/styles from `mjml-config.ts`: - -```mjml - - - - - - - - -``` - -For component compiles, marker comments are injected via `mj-raw`: - -```mjml - - - - - +```ts +import { + defineEmailComponent, + defineOption, + defineOptions, + mjmlAlignOptions, +} from "../src/lib/schema"; + +const headline = defineEmailComponent({ + slug: "headline", + variants: { + highlight: { + highlight: true, + }, + }, + optionsSchema: defineOptions([ + defineOption({ + name: "textClass", + type: "string", + initialValue: "s-email-text-headline", + description: "Text styling class for the headline node.", + }), + defineOption({ + name: "highlight", + type: "boolean", + initialValue: false, + description: "Applies inline highlighted text styling.", + }), + defineOption({ + name: "textAlign", + type: "enum", + values: mjmlAlignOptions, + initialValue: "left", + description: "MJML text alignment.", + }), + ]), + render: ({ options }) => { + // options.textClass is string + // options.highlight is boolean + }, +}); ``` -The pipeline extracts HTML between markers as `componentHtml` for copy/paste use, while keeping full `html` for previews. - -## Public API usage +- `defineOption` is the Sanity-style field declaration for component options. +- `initialValue` is the single source of truth for runtime defaults and the + generated options table default. +- Use `type: "enum"` with a shared `values` tuple for constrained string values + like MJML alignment. The generated schema validates the value and TypeScript + infers the literal union. +- `optional: true` should only be used when omitted is meaningfully different + from the initial value. +- The `default` variant is implied from schema defaults. Add entries in + `variants` only for named variants that override option defaults. Variant + values are plain option overrides. +- `defineOptions([...])` builds the underlying Zod schema, so API validation and + render typing are derived from the option declarations. + +## Template schema definitions + +Templates should also export a single definition. The public template catalog, +preview text, and compiled MJML are derived from that default export. ```ts -import { - getEmailCatalog, - compileEmailRenderable, -} from "@stackoverflow/stacks-email"; +import { z } from "zod/v4"; -const catalog = getEmailCatalog(); +import { defineEmailTemplate, emailOption } from "../src/lib/schema"; -const compiled = compileEmailRenderable({ - kind: "component", - slug: "button", - target: "preview", +const transactionalPropsSchema = z.object({ + headlineText: emailOption(z.string(), { + type: "string", + defaultValue: "From selected variant", + renderDefaultValueAsCode: false, + description: "Headline copy rendered near the top of the email.", + }), }); -// Full document (for iframe preview) -const fullHtml = compiled.html; +const transactional = defineEmailTemplate({ + slug: "transactional", + defaultVariant: "short", + variants: { + short: { + name: "Short", + defaults: { + headlineText: "Reset your password", + }, + }, + }, + propsSchema: transactionalPropsSchema, + preview: ({ props }) => ({ + previewText: props.headlineText, + }), + renderDocument: ({ props }) => ({ + tagName: "mjml", + children: [ + // MJML document tree + ], + }), +}); -// Extracted component fragment (for component copy/paste) -if (compiled.kind === "component") { - const fragmentHtml = compiled.componentHtml; -} +export default transactional; ``` +- Do not export separate `meta` or `document` values from template files. +- Template variants keep the richer `{ name, description, defaults }` shape + because template variants need display metadata as well as prop defaults. +- `renderDocument` returns the full MJML document tree; the compile pipeline + handles MJML serialization, shared config injection, token transformation, and + HTML output. + ## Run local sandbox ```bash diff --git a/packages/stacks-email/components/button.ts b/packages/stacks-email/components/button.ts index 4af61d7b96..f3364dbb00 100644 --- a/packages/stacks-email/components/button.ts +++ b/packages/stacks-email/components/button.ts @@ -1,97 +1,38 @@ -import type { EmailComponentMeta, MjmlNode } from "../types"; -import { z } from "zod/v4"; +import { + defineEmailComponent, + defineOption, + defineOptions, + mjmlAlignOptions, +} from "../src/lib/schema"; +import { tokens } from "../src/lib/tokens"; +import type { MjmlNode } from "../src/lib/types"; -import { tokens } from "../tokens"; -import { Section } from "./section"; - -const buttonOptionsSchema = z.object({ - align: z.string().optional(), - className: z.string().optional(), - cssClass: z.string().optional(), - href: z.string().optional(), - text: z.string().optional(), -}); - -type ButtonOptions = z.input; - -const buttonOptionRows: NonNullable = [ - { - argument: "variant", - type: '"primary" | "secondary" | "inverted"', - description: "Selects the button style baseline.", - }, - { - argument: "options.align", - type: "string", - defaultValue: "From selected variant", - defaultValueCode: false, - description: "Maps to the MJML align attribute.", - }, - { - argument: "options.className", - type: "string", - defaultValue: "From selected variant", - defaultValueCode: false, - description: "Button mj-class override.", - }, - { - argument: "options.cssClass", - type: "string", - defaultValue: "From selected variant", - defaultValueCode: false, - description: "Raw CSS class applied to the button node.", - }, - { - argument: "options.href", - type: "string", - defaultValue: "[[BUTTON_URL]]", - defaultValueCode: true, - description: "Target link URL.", - }, - { - argument: "options.text", - type: "string", - defaultValue: "[[BUTTON_LABEL]]", - defaultValueCode: true, - description: "Button label content.", - }, -]; - -export const meta: EmailComponentMeta = { +const button = defineEmailComponent({ slug: "button", defaultVariant: "primary", htmlExtraction: { targetTag: "mj-button", }, - variants: [ - { - id: "primary", - props: { - BUTTON_CLASS: "button", - BUTTON_HOVER_CLASS: "button-hover", - BUTTON_TEXT: "Filled button", - ALIGNMENT: "left", - }, + variants: { + primary: { + className: "button", + cssClass: "button-hover", + text: "Filled button", + align: "left", }, - { - id: "secondary", - props: { - BUTTON_CLASS: "button button__tonal", - BUTTON_HOVER_CLASS: "button-hover", - BUTTON_TEXT: "Tonal button", - ALIGNMENT: "left", - }, + secondary: { + className: "button button__tonal", + cssClass: "button-hover", + text: "Tonal button", + align: "left", }, - { - id: "inverted", - props: { - BUTTON_CLASS: "button button__inverted", - BUTTON_HOVER_CLASS: "button-hover-inverted", - BUTTON_TEXT: "Inverted button", - ALIGNMENT: "left", - }, + inverted: { + className: "button button__inverted", + cssClass: "button-hover-inverted", + text: "Inverted button", + align: "left", }, - ], + }, tokens: [ { token: "BUTTON_LABEL", @@ -102,49 +43,51 @@ export const meta: EmailComponentMeta = { description: "Destination URL for the button.", }, ], - options: buttonOptionRows, -}; - -export type ButtonVariantId = "primary" | "secondary" | "inverted"; - -const getButtonVariantProps = (variant: ButtonVariantId) => - meta.variants.find((entry) => entry.id === variant)?.props ?? - meta.variants[0].props; - -export const Button = ( - variant: ButtonVariantId, - options: ButtonOptions = {} -): MjmlNode => { - const parsedOptions = buttonOptionsSchema.safeParse(options); - const normalizedOptions = parsedOptions.success ? parsedOptions.data : options; - const variantDefaults = getButtonVariantProps(variant); - - return { + optionsSchema: defineOptions([ + defineOption({ + name: "align", + type: "enum", + values: mjmlAlignOptions, + initialValue: "left", + description: "Maps to the MJML align attribute.", + }), + defineOption({ + name: "className", + type: "string", + initialValue: "button", + description: "Button mj-class override.", + }), + defineOption({ + name: "cssClass", + type: "string", + initialValue: "button-hover", + description: "Raw CSS class applied to the button node.", + }), + defineOption({ + name: "href", + type: "string", + initialValue: "[[BUTTON_URL]]", + description: "Target link URL.", + }), + defineOption({ + name: "text", + type: "string", + initialValue: "[[BUTTON_LABEL]]", + description: "Button label content.", + }), + ]), + render: ({ options }): MjmlNode => ({ tagName: "mj-button", attributes: { - "mj-class": normalizedOptions.className ?? variantDefaults.BUTTON_CLASS, - "css-class": - normalizedOptions.cssClass ?? variantDefaults.BUTTON_HOVER_CLASS, - "href": normalizedOptions.href ?? "[[BUTTON_URL]]", - "align": normalizedOptions.align ?? variantDefaults.ALIGNMENT, + "mj-class": options.className, + "css-class": options.cssClass, + "href": options.href, + "align": options.align, "padding": `0px ${tokens.layout.containerXPadding}`, }, - content: normalizedOptions.text ?? "[[BUTTON_LABEL]]", - }; -}; - -export const source: MjmlNode[] = [ - Section([ - Button("primary", { - className: "{{BUTTON_CLASS}}", - cssClass: "{{BUTTON_HOVER_CLASS}}", - href: "[[BUTTON_URL]]", - align: "{{ALIGNMENT}}", - text: "[[BUTTON_LABEL]]", - }), - ]), -]; - -export const definition = { meta, source } as const; + content: options.text, + }), +}); -export default definition; +export const Button = button.component; +export default button; diff --git a/packages/stacks-email/components/footer.ts b/packages/stacks-email/components/footer.ts index b055e03a83..200dc9f5c9 100644 --- a/packages/stacks-email/components/footer.ts +++ b/packages/stacks-email/components/footer.ts @@ -1,183 +1,25 @@ -import { Header, type HeaderVariantId } from "./header"; -import type { EmailComponentMeta, MjmlNode } from "../types"; -import { z } from "zod/v4"; +import { + defineEmailComponent, + defineOption, + defineOptions, +} from "../src/lib/schema"; +import { tokens } from "../src/lib/tokens"; +import type { MjmlNode } from "../src/lib/types"; +import header from "./header"; -import { tokens } from "../tokens"; +const reasonText = "You’re receiving this email because [[FOOTER_REASON]]"; -export type FooterVariantId = "default" | "reason" | "social"; - -const footerOptionsSchema = z.object({ - wrapperClass: z.string().optional(), - textClass: z.string().optional(), - linkClass: z.string().optional(), - headerVariant: z - .enum(["transactional", "brand", "brand-center", "inverted", "business"]) - .optional(), - logoSrc: z.string().optional(), - logoAlt: z.string().optional(), - logoUrl: z.string().optional(), - logoWidth: z.string().optional(), - unsubscribeUrl: z.string().optional(), - settingsUrl: z.string().optional(), - contactUrl: z.string().optional(), - privacyUrl: z.string().optional(), - addressHtml: z.string().optional(), - reasonText: z.string().optional(), - reasonPadding: z.string().optional(), - socialClass: z.string().optional(), - showSocialIcons: z.union([z.boolean(), z.string()]).optional(), - socialIconBasePath: z.string().optional(), -}); - -type FooterOptions = z.input; - -const footerOptionRows: NonNullable = [ - { - argument: "variant", - type: '"default" | "reason" | "social"', - description: "Selects reason/social behavior and baseline classes.", - }, - { - argument: "options.wrapperClass", - type: "string", - defaultValue: "From selected variant", - defaultValueCode: false, - description: "Wrapper mj-class on mj-wrapper.", - }, - { - argument: "options.textClass", - type: "string", - defaultValue: "From selected variant", - defaultValueCode: false, - description: "Body text class for footer copy rows.", - }, - { - argument: "options.linkClass", - type: "string", - defaultValue: "From selected variant", - defaultValueCode: false, - description: "Link class for unsubscribe/settings/contact/privacy links.", - }, - { - argument: "options.headerVariant", - type: "HeaderVariantId", - defaultValue: "inverted", - defaultValueCode: true, - description: "Variant passed through to nested Header.", - }, - { - argument: "options.unsubscribeUrl", - type: "string", - defaultValue: "[[UNSUBSCRIBE_URL]]", - defaultValueCode: true, - description: "Recipient-specific unsubscribe destination.", - }, - { - argument: "options.settingsUrl", - type: "string", - defaultValue: "From selected variant", - defaultValueCode: false, - description: "Email settings URL override.", - }, - { - argument: "options.contactUrl", - type: "string", - defaultValue: "From selected variant", - defaultValueCode: false, - description: "Contact URL override.", - }, - { - argument: "options.privacyUrl", - type: "string", - defaultValue: "From selected variant", - defaultValueCode: false, - description: "Privacy policy URL override.", - }, - { - argument: "options.reasonText", - type: "string", - defaultValue: "From selected variant", - defaultValueCode: false, - description: "Optional recipient-reason copy block.", - }, - { - argument: "options.showSocialIcons", - type: "boolean | string", - defaultValue: "From selected variant", - defaultValueCode: false, - description: "Shows or hides social icon row.", - }, - { - argument: "options.socialIconBasePath", - type: "string", - defaultValue: "/email/social", - defaultValueCode: true, - description: "Base path for social icon image assets.", - }, - { - argument: "options.addressHtml", - type: "string", - defaultValue: "14 Wall Street, 20th Floor, New York, NY 10005", - defaultValueCode: true, - description: "Footer address line HTML/text.", - }, -]; - -export const meta: EmailComponentMeta = { +const footer = defineEmailComponent({ slug: "footer", - defaultVariant: "default", - variants: [ - { - id: "default", - props: { - FOOTER_WRAPPER_CLASS: "bg-invert", - FOOTER_TEXT_CLASS: "fc-text-footer", - FOOTER_LINK_CLASS: "footer-link fc-text-footer", - FOOTER_REASON_TEXT: "", - FOOTER_SOCIAL_ICONS: "false", - FOOTER_SOCIAL_CLASS: "footer-social-hidden", - FOOTER_SOCIAL_ICON_BASE_PATH: "/email/social", - SETTINGS_URL: - "https://stackoverflow.com/users/email/settings/current", - CONTACT_URL: "https://stackoverflow.com/company/contact", - PRIVACY_URL: "https://stackoverflow.com/legal/privacy-policy", - }, + variants: { + reason: { + reasonText, }, - { - id: "reason", - props: { - FOOTER_WRAPPER_CLASS: "bg-invert", - FOOTER_TEXT_CLASS: "fc-text-footer", - FOOTER_LINK_CLASS: "footer-link fc-text-footer", - FOOTER_REASON_TEXT: - "You’re receiving this email because [[FOOTER_REASON]]", - FOOTER_SOCIAL_ICONS: "false", - FOOTER_SOCIAL_CLASS: "footer-social-hidden", - FOOTER_SOCIAL_ICON_BASE_PATH: "/email/social", - SETTINGS_URL: - "https://stackoverflow.com/users/email/settings/current", - CONTACT_URL: "https://stackoverflow.com/company/contact", - PRIVACY_URL: "https://stackoverflow.com/legal/privacy-policy", - }, + social: { + reasonText, + showSocialIcons: true, }, - { - id: "social", - props: { - FOOTER_WRAPPER_CLASS: "bg-invert", - FOOTER_TEXT_CLASS: "fc-text-footer", - FOOTER_LINK_CLASS: "footer-link fc-text-footer", - FOOTER_REASON_TEXT: - "You’re receiving this email because [[FOOTER_REASON]]", - FOOTER_SOCIAL_ICONS: "true", - FOOTER_SOCIAL_CLASS: "footer-social-visible", - FOOTER_SOCIAL_ICON_BASE_PATH: "/email/social", - SETTINGS_URL: - "https://stackoverflow.com/users/email/settings/current", - CONTACT_URL: "https://stackoverflow.com/company/contact", - PRIVACY_URL: "https://stackoverflow.com/legal/privacy-policy", - }, - }, - ], + }, tokens: [ { token: "UNSUBSCRIBE_URL", @@ -188,109 +30,268 @@ export const meta: EmailComponentMeta = { description: "Recipient-specific reason for receiving the email", }, ], - options: footerOptionRows, -}; - -const getFooterVariantProps = (variant: FooterVariantId) => - meta.variants.find((entry) => entry.id === variant)?.props ?? - meta.variants[0].props; - -export const Footer = ( - variant: FooterVariantId, - options: FooterOptions = {} -): MjmlNode => { - const parsedOptions = footerOptionsSchema.safeParse(options); - const normalizedOptions = parsedOptions.success ? parsedOptions.data : options; - const defaults = getFooterVariantProps(variant); - - const wrapperClass = - normalizedOptions.wrapperClass ?? defaults.FOOTER_WRAPPER_CLASS; - const textClass = normalizedOptions.textClass ?? defaults.FOOTER_TEXT_CLASS; - const linkClass = normalizedOptions.linkClass ?? defaults.FOOTER_LINK_CLASS; + optionsSchema: defineOptions([ + defineOption({ + name: "wrapperClass", + type: "string", + initialValue: "bg-invert", + description: "Wrapper mj-class on mj-wrapper.", + }), + defineOption({ + name: "textClass", + type: "string", + initialValue: "fc-text-footer", + description: "Body text class for footer copy rows.", + }), + defineOption({ + name: "linkClass", + type: "string", + initialValue: "footer-link fc-text-footer", + description: + "Link class for unsubscribe/settings/contact/privacy links.", + }), + defineOption({ + name: "headerVariant", + type: "string", + initialValue: "inverted", + description: "Variant passed through to nested Header.", + }), + defineOption({ + name: "logoSrc", + type: "string", + optional: true, + description: "Nested header logo image source override.", + }), + defineOption({ + name: "logoAlt", + type: "string", + optional: true, + description: "Nested header logo alt text override.", + }), + defineOption({ + name: "logoUrl", + type: "string", + optional: true, + description: "Nested header logo link override.", + }), + defineOption({ + name: "logoWidth", + type: "string", + optional: true, + description: "Nested header logo width override.", + }), + defineOption({ + name: "unsubscribeUrl", + type: "string", + initialValue: "[[UNSUBSCRIBE_URL]]", + description: "Recipient-specific unsubscribe destination.", + }), + defineOption({ + name: "settingsUrl", + type: "string", + initialValue: tokens.links.emailSettings, + description: "Email settings URL override.", + }), + defineOption({ + name: "contactUrl", + type: "string", + initialValue: tokens.links.contact, + description: "Contact URL override.", + }), + defineOption({ + name: "privacyUrl", + type: "string", + initialValue: tokens.links.privacy, + description: "Privacy policy URL override.", + }), + defineOption({ + name: "addressHtml", + type: "string", + initialValue: "14 Wall Street, 20th Floor, New York, NY 10005", + description: "Footer address line HTML/text.", + }), + defineOption({ + name: "reasonText", + type: "string", + initialValue: "", + description: "Optional recipient-reason copy block.", + }), + defineOption({ + name: "showSocialIcons", + type: "boolean", + initialValue: false, + description: "Shows or hides social icon row.", + }), + defineOption({ + name: "socialIconBasePath", + type: "string", + initialValue: "/email/social", + description: "Base path for social icon image assets.", + }), + ]), + render: ({ options }): MjmlNode => { + const hasReasonText = options.reasonText.trim().length > 0; - const unsubscribeUrl = normalizedOptions.unsubscribeUrl ?? "[[UNSUBSCRIBE_URL]]"; - const settingsUrl = normalizedOptions.settingsUrl ?? defaults.SETTINGS_URL; - const contactUrl = normalizedOptions.contactUrl ?? defaults.CONTACT_URL; - const privacyUrl = normalizedOptions.privacyUrl ?? defaults.PRIVACY_URL; - const reasonText = normalizedOptions.reasonText ?? defaults.FOOTER_REASON_TEXT; - const hasReasonText = reasonText.trim().length > 0; - const socialClass = normalizedOptions.socialClass ?? defaults.FOOTER_SOCIAL_CLASS; - const socialFlag = - normalizedOptions.showSocialIcons ?? defaults.FOOTER_SOCIAL_ICONS; - const showSocialIcons = String(socialFlag).trim().toLowerCase() === "true"; - const socialIconBasePath = - normalizedOptions.socialIconBasePath ?? - defaults.FOOTER_SOCIAL_ICON_BASE_PATH; - const addressHtml = - normalizedOptions.addressHtml ?? - "14 Wall Street, 20th Floor, New York, NY 10005"; + const children: MjmlNode[] = [ + header.component( + options.headerVariant as keyof typeof header.variants & string, + { + logoSrc: options.logoSrc, + logoAlt: options.logoAlt, + logoUrl: options.logoUrl, + logoWidth: options.logoWidth, + } + ) as MjmlNode, + ]; - const children: MjmlNode[] = [ - Header(normalizedOptions.headerVariant ?? "inverted", { - logoSrc: normalizedOptions.logoSrc, - logoAlt: normalizedOptions.logoAlt, - logoUrl: normalizedOptions.logoUrl, - logoWidth: normalizedOptions.logoWidth, - }), - ]; + if (options.showSocialIcons) { + children.push({ + tagName: "mj-section", + children: [ + { + tagName: "mj-column", + children: [ + { + tagName: "mj-social", + attributes: { + "align": "left", + "padding-top": "0px", + "padding-bottom": "0px", + "padding-left": "15px", + "icon-padding": "0 5px 0 5px", + "font-size": "13px", + "icon-size": "20px", + "mode": "horizontal", + }, + children: [ + { + tagName: "mj-social-element", + attributes: { + src: `${options.socialIconBasePath}/linkedin.png`, + href: tokens.links.social.linkedin, + }, + }, + { + tagName: "mj-social-element", + attributes: { + src: `${options.socialIconBasePath}/x.png`, + href: tokens.links.social.x, + }, + }, + { + tagName: "mj-social-element", + attributes: { + src: `${options.socialIconBasePath}/threads.png`, + href: tokens.links.social.threads, + }, + }, + { + tagName: "mj-social-element", + attributes: { + src: `${options.socialIconBasePath}/instagram.png`, + href: tokens.links.social.instagram, + }, + }, + { + tagName: "mj-social-element", + attributes: { + src: `${options.socialIconBasePath}/youtube.png`, + href: tokens.links.social.youtube, + }, + }, + ], + }, + ], + }, + ], + }); + } - if (showSocialIcons) { children.push({ tagName: "mj-section", attributes: { - "css-class": socialClass, + "padding-top": "40px", + "padding-bottom": "40px", + "padding-left": tokens.layout.containerXPadding, + "padding-right": tokens.layout.containerXPadding, }, children: [ { tagName: "mj-column", children: [ + ...(hasReasonText + ? [ + { + tagName: "mj-text", + attributes: { + "font-size": "14px", + "mj-class": options.textClass, + "padding-bottom": "40px", + }, + content: options.reasonText, + } satisfies MjmlNode, + ] + : []), + { + tagName: "mj-text", + attributes: { + "font-size": "14px", + "padding-bottom": "8px", + }, + content: + `Unsubscribe ` + + `Edit email settings ` + + `Contact us ` + + `Privacy `, + }, { - tagName: "mj-social", + tagName: "mj-text", attributes: { - align: "left", - "padding-top": "0px", - "padding-bottom": "0px", - "padding-left": "15px", - "icon-padding": "0 5px 0 5px", - "font-size": "13px", - "icon-size": "20px", - mode: "horizontal", + "font-size": "14px", + "mj-class": options.textClass, }, + content: options.addressHtml, + }, + ], + }, + ], + }); + + children.push({ + tagName: "mj-section", + attributes: { + "padding-left": tokens.layout.containerXPadding, + "padding-right": tokens.layout.containerXPadding, + "padding-bottom": tokens.layout.containerYPadding, + }, + children: [ + { + tagName: "mj-group", + children: [ + { + tagName: "mj-column", children: [ { - tagName: "mj-social-element", - attributes: { - src: `${socialIconBasePath}/linkedin.png`, - href: "https://linkedin.com/company/stack-overflow/", - }, - }, - { - tagName: "mj-social-element", + tagName: "mj-text", attributes: { - src: `${socialIconBasePath}/x.png`, - href: "https://x.com/stackoverflow/", - }, - }, - { - tagName: "mj-social-element", - attributes: { - src: `${socialIconBasePath}/threads.png`, - href: "https://www.threads.net/@thestackoverflow", - }, - }, - { - tagName: "mj-social-element", - attributes: { - src: `${socialIconBasePath}/instagram.png`, - href: "https://www.instagram.com/thestackoverflow/", + "font-size": "14px", + "mj-class": options.textClass, }, + content: "© Stack Exchange Inc.", }, + ], + }, + { + tagName: "mj-column", + children: [ { - tagName: "mj-social-element", + tagName: "mj-text", attributes: { - src: `${socialIconBasePath}/youtube.png`, - href: "https://www.youtube.com/c/StackOverflowOfficial", + "font-size": "14px", + "mj-class": options.textClass, + "align": "right", }, + content: "All rights reserved", }, ], }, @@ -298,128 +299,17 @@ export const Footer = ( }, ], }); - } - - children.push({ - tagName: "mj-section", - attributes: { - "padding-top": "40px", - "padding-bottom": "40px", - "padding-left": tokens.layout.containerXPadding, - "padding-right": tokens.layout.containerXPadding, - }, - children: [ - { - tagName: "mj-column", - children: [ - ...(hasReasonText - ? [ - { - tagName: "mj-text", - attributes: { - "font-size": "14px", - "mj-class": textClass, - "padding-bottom": "40px", - }, - content: reasonText, - } satisfies MjmlNode, - ] - : []), - { - tagName: "mj-text", - attributes: { - "font-size": "14px", - "padding-bottom": "8px", - }, - content: - `Unsubscribe ` + - `Edit email settings ` + - `Contact us ` + - `Privacy `, - }, - { - tagName: "mj-text", - attributes: { - "font-size": "14px", - "mj-class": textClass, - }, - content: addressHtml, - }, - ], - }, - ], - }); - children.push({ - tagName: "mj-section", - attributes: { - "padding-left": tokens.layout.containerXPadding, - "padding-right": tokens.layout.containerXPadding, - "padding-bottom": tokens.layout.containerYPadding, - }, - children: [ - { - tagName: "mj-group", - children: [ - { - tagName: "mj-column", - children: [ - { - tagName: "mj-text", - attributes: { - "font-size": "14px", - "mj-class": textClass, - }, - content: "© Stack Exchange Inc.", - }, - ], - }, - { - tagName: "mj-column", - children: [ - { - tagName: "mj-text", - attributes: { - "font-size": "14px", - "mj-class": textClass, - align: "right", - }, - content: "All rights reserved", - }, - ], - }, - ], + return { + tagName: "mj-wrapper", + attributes: { + "mj-class": options.wrapperClass, + "padding-top": tokens.layout.containerYPadding, }, - ], - }); - - return { - tagName: "mj-wrapper", - attributes: { - "mj-class": wrapperClass, - "padding-top": tokens.layout.containerYPadding, - }, - children, - }; -}; - -export const source: MjmlNode[] = [ - Footer("default", { - wrapperClass: "{{FOOTER_WRAPPER_CLASS}}", - textClass: "{{FOOTER_TEXT_CLASS}}", - linkClass: "{{FOOTER_LINK_CLASS}}", - headerVariant: "inverted", - unsubscribeUrl: "[[UNSUBSCRIBE_URL]]", - settingsUrl: "{{SETTINGS_URL}}", - contactUrl: "{{CONTACT_URL}}", - privacyUrl: "{{PRIVACY_URL}}", - reasonText: "{{FOOTER_REASON_TEXT}}", - socialClass: "{{FOOTER_SOCIAL_CLASS}}", - showSocialIcons: true, - socialIconBasePath: "{{FOOTER_SOCIAL_ICON_BASE_PATH}}", - }), -]; - -export const definition = { meta, source } as const; + children, + }; + }, +}); -export default definition; +export const Footer = footer.component; +export default footer; diff --git a/packages/stacks-email/components/graphic.ts b/packages/stacks-email/components/graphic.ts index a452151a8f..df80a0127a 100644 --- a/packages/stacks-email/components/graphic.ts +++ b/packages/stacks-email/components/graphic.ts @@ -1,220 +1,112 @@ -import type { EmailComponentMeta, MjmlNode } from "../types"; -import { z } from "zod/v4"; +import { + defineEmailComponent, + defineOption, + defineOptions, + mjmlAlignOptions, +} from "../src/lib/schema"; +import { tokens } from "../src/lib/tokens"; +import type { MjmlNode } from "../src/lib/types"; +import { Section } from "../src/lib/mjml"; -import { tokens } from "../tokens"; -import { Section } from "./section"; - -export type GraphicVariantId = "spot" | "hero"; - -const graphicOptionsSchema = z.object({ - sectionClass: z.string().optional(), - imageSrc: z.string().optional(), - imageAlt: z.string().optional(), - imageWidth: z.string().optional(), - imageHeight: z.string().optional(), - imageAlign: z.string().optional(), - imagePaddingTop: z.string().optional(), - imagePaddingBottom: z.string().optional(), - imagePaddingLeft: z.string().optional(), - imagePaddingRight: z.string().optional(), -}); - -type GraphicOptions = z.input; - -const graphicVariantDefaults: Record< - GraphicVariantId, - { - GRAPHIC_SECTION_CLASS: string; - GRAPHIC_IMAGE_SRC: string; - GRAPHIC_IMAGE_ALT: string; - GRAPHIC_IMAGE_WIDTH: string; - GRAPHIC_IMAGE_HEIGHT: string; - GRAPHIC_IMAGE_ALIGN: string; - GRAPHIC_IMAGE_PADDING_TOP: string; - GRAPHIC_IMAGE_PADDING_BOTTOM: string; - GRAPHIC_IMAGE_PADDING_LEFT: string; - GRAPHIC_IMAGE_PADDING_RIGHT: string; - } -> = { - spot: { - GRAPHIC_SECTION_CLASS: "bg-block", - GRAPHIC_IMAGE_SRC: "/email/spots/SpotLock.png", - GRAPHIC_IMAGE_ALT: "Spot placeholder image", - GRAPHIC_IMAGE_WIDTH: "140px", - GRAPHIC_IMAGE_HEIGHT: "140px", - GRAPHIC_IMAGE_ALIGN: "left", - GRAPHIC_IMAGE_PADDING_TOP: "0px", - GRAPHIC_IMAGE_PADDING_BOTTOM: "0px", - GRAPHIC_IMAGE_PADDING_LEFT: tokens.layout.containerXPadding, - GRAPHIC_IMAGE_PADDING_RIGHT: tokens.layout.containerXPadding, - }, - hero: { - GRAPHIC_SECTION_CLASS: "bg-block", - GRAPHIC_IMAGE_SRC: "/email/hero/1200x630.png", - GRAPHIC_IMAGE_ALT: "Hero placeholder image", - GRAPHIC_IMAGE_WIDTH: "1200px", - GRAPHIC_IMAGE_HEIGHT: "auto", - GRAPHIC_IMAGE_ALIGN: "center", - GRAPHIC_IMAGE_PADDING_TOP: "0px", - GRAPHIC_IMAGE_PADDING_BOTTOM: "0px", - GRAPHIC_IMAGE_PADDING_LEFT: tokens.layout.containerXPadding, - GRAPHIC_IMAGE_PADDING_RIGHT: tokens.layout.containerXPadding, - }, -}; - -const graphicOptionRows: NonNullable = [ - { - argument: "variant", - type: '"spot" | "hero"', - description: "Selects baseline size, alignment, and source asset.", - }, - { - argument: "options.sectionClass", - type: "string", - defaultValue: "bg-block", - defaultValueCode: true, - description: "Section class applied to the wrapper section.", - }, - { - argument: "options.imageSrc", - type: "string", - defaultValue: "From selected variant", - defaultValueCode: false, - description: "Image URL/path override.", - }, - { - argument: "options.imageAlt", - type: "string", - defaultValue: "From selected variant", - defaultValueCode: false, - description: "Accessible image alt text.", - }, - { - argument: "options.imageWidth", - type: "string", - defaultValue: "From selected variant", - defaultValueCode: false, - description: 'Rendered width (for example "140px").', - }, - { - argument: "options.imageHeight", - type: "string", - defaultValue: "From selected variant", - defaultValueCode: false, - description: 'Rendered height (for example "630px").', - }, - { - argument: "options.imageAlign", - type: "string", - defaultValue: "From selected variant", - defaultValueCode: false, - description: "MJML image alignment value.", - }, - { - argument: "options.imagePaddingTop", - type: "string", - defaultValue: "From selected variant", - defaultValueCode: false, - description: "Top padding override.", - }, - { - argument: "options.imagePaddingBottom", - type: "string", - defaultValue: "From selected variant", - defaultValueCode: false, - description: "Bottom padding override.", - }, - { - argument: "options.imagePaddingLeft", - type: "string", - defaultValue: "From selected variant", - defaultValueCode: false, - description: "Left padding override.", - }, - { - argument: "options.imagePaddingRight", - type: "string", - defaultValue: "From selected variant", - defaultValueCode: false, - description: "Right padding override.", - }, -]; - -export const meta: EmailComponentMeta = { +const graphic = defineEmailComponent({ slug: "graphic", defaultVariant: "spot", - variants: [ - { - id: "spot", - props: graphicVariantDefaults.spot, - }, - { - id: "hero", - props: graphicVariantDefaults.hero, + variants: { + hero: { + imageSrc: "/email/hero/1200x630.png", + imageAlt: "Hero placeholder image", + imageWidth: "1200px", + imageHeight: "auto", + imageAlign: "center", }, - ], + }, tokens: [], - options: graphicOptionRows, -}; - -const getGraphicVariantProps = (variant: GraphicVariantId) => - meta.variants.find((entry) => entry.id === variant)?.props ?? - meta.variants[0].props; - -export const Graphic = ( - variant: GraphicVariantId, - options: GraphicOptions = {} -): MjmlNode => { - const parsedOptions = graphicOptionsSchema.safeParse(options); - const normalizedOptions = parsedOptions.success ? parsedOptions.data : options; - const defaults = getGraphicVariantProps(variant); - - return Section( - [ - { - tagName: "mj-image", - attributes: { - src: normalizedOptions.imageSrc ?? defaults.GRAPHIC_IMAGE_SRC, - alt: normalizedOptions.imageAlt ?? defaults.GRAPHIC_IMAGE_ALT, - width: normalizedOptions.imageWidth ?? defaults.GRAPHIC_IMAGE_WIDTH, - height: normalizedOptions.imageHeight ?? defaults.GRAPHIC_IMAGE_HEIGHT, - align: normalizedOptions.imageAlign ?? defaults.GRAPHIC_IMAGE_ALIGN, - "padding-top": - normalizedOptions.imagePaddingTop ?? - defaults.GRAPHIC_IMAGE_PADDING_TOP, - "padding-bottom": - normalizedOptions.imagePaddingBottom ?? - defaults.GRAPHIC_IMAGE_PADDING_BOTTOM, - "padding-left": - normalizedOptions.imagePaddingLeft ?? - defaults.GRAPHIC_IMAGE_PADDING_LEFT, - "padding-right": - normalizedOptions.imagePaddingRight ?? - defaults.GRAPHIC_IMAGE_PADDING_RIGHT, + optionsSchema: defineOptions([ + defineOption({ + name: "sectionClass", + type: "string", + initialValue: "bg-block", + description: "Section class applied to the wrapper section.", + }), + defineOption({ + name: "imageSrc", + type: "string", + initialValue: "/email/spots/SpotLock.png", + description: "Image URL/path override.", + }), + defineOption({ + name: "imageAlt", + type: "string", + initialValue: "Spot placeholder image", + description: "Accessible image alt text.", + }), + defineOption({ + name: "imageWidth", + type: "string", + initialValue: "140px", + description: 'Rendered width, for example "140px".', + }), + defineOption({ + name: "imageHeight", + type: "string", + initialValue: "140px", + description: 'Rendered height, for example "630px".', + }), + defineOption({ + name: "imageAlign", + type: "enum", + values: mjmlAlignOptions, + initialValue: "left", + description: "MJML image alignment value.", + }), + defineOption({ + name: "imagePaddingTop", + type: "string", + initialValue: "0px", + description: "Top padding override.", + }), + defineOption({ + name: "imagePaddingBottom", + type: "string", + initialValue: "0px", + description: "Bottom padding override.", + }), + defineOption({ + name: "imagePaddingLeft", + type: "string", + initialValue: tokens.layout.containerXPadding, + description: "Left padding override.", + }), + defineOption({ + name: "imagePaddingRight", + type: "string", + initialValue: tokens.layout.containerXPadding, + description: "Right padding override.", + }), + ]), + render: ({ options }): MjmlNode => + Section( + [ + { + tagName: "mj-image", + attributes: { + "src": options.imageSrc, + "alt": options.imageAlt, + "width": options.imageWidth, + "height": options.imageHeight, + "align": options.imageAlign, + "padding-top": options.imagePaddingTop, + "padding-bottom": options.imagePaddingBottom, + "padding-left": options.imagePaddingLeft, + "padding-right": options.imagePaddingRight, + }, }, - }, - ], - { - sectionClass: normalizedOptions.sectionClass ?? defaults.GRAPHIC_SECTION_CLASS, - } - ); -}; - -export const source: MjmlNode[] = [ - Graphic("spot", { - sectionClass: "{{GRAPHIC_SECTION_CLASS}}", - imageSrc: "{{GRAPHIC_IMAGE_SRC}}", - imageAlt: "{{GRAPHIC_IMAGE_ALT}}", - imageWidth: "{{GRAPHIC_IMAGE_WIDTH}}", - imageHeight: "{{GRAPHIC_IMAGE_HEIGHT}}", - imageAlign: "{{GRAPHIC_IMAGE_ALIGN}}", - imagePaddingTop: "{{GRAPHIC_IMAGE_PADDING_TOP}}", - imagePaddingBottom: "{{GRAPHIC_IMAGE_PADDING_BOTTOM}}", - imagePaddingLeft: "{{GRAPHIC_IMAGE_PADDING_LEFT}}", - imagePaddingRight: "{{GRAPHIC_IMAGE_PADDING_RIGHT}}", - }), -]; - -export const definition = { meta, source } as const; + ], + { + sectionClass: options.sectionClass, + } + ), +}); -export default definition; +export const Graphic = graphic.component; +export default graphic; diff --git a/packages/stacks-email/components/header.ts b/packages/stacks-email/components/header.ts index 0bb55ef1ed..c6938bb590 100644 --- a/packages/stacks-email/components/header.ts +++ b/packages/stacks-email/components/header.ts @@ -1,126 +1,76 @@ -import type { EmailComponentMeta, MjmlNode } from "../types"; -import { z } from "zod/v4"; +import { + defineEmailComponent, + defineOption, + defineOptions, + mjmlAlignOptions, +} from "../src/lib/schema"; +import type { MjmlNode } from "../src/lib/types"; -export type HeaderVariantId = "transactional" | "brand" | "brand-center" | "inverted" | "business"; - -const headerOptionsSchema = z.object({ - sectionClass: z.string().optional(), - logoSrc: z.string().optional(), - logoAlt: z.string().optional(), - logoUrl: z.string().optional(), - logoWidth: z.string().optional(), - logoAlign: z.string().optional(), -}); - -type HeaderOptions = z.input; - -type HeaderVariantProps = { - HEADER_SECTION_CLASS: string; - HEADER_LOGO_SRC: string; - HEADER_LOGO_ALT: string; - HEADER_LOGO_URL: string; - HEADER_LOGO_WIDTH: string; - HEADER_LOGO_ALIGN: string; -}; - -const sharedVariantProps: Omit = { - HEADER_LOGO_SRC: "/email/stack-overflow-logo.png", - HEADER_LOGO_ALT: "Stack Overflow", - HEADER_LOGO_URL: "https://stackoverflow.com/", - HEADER_LOGO_WIDTH: "158px", - HEADER_LOGO_ALIGN: "left", -}; - -const headerVariantDefaults: Record = { - transactional: { - ...sharedVariantProps, - HEADER_SECTION_CLASS: "bg-block", - }, - brand: { - ...sharedVariantProps, - HEADER_SECTION_CLASS: "bg-brand", - }, - "brand-center": { - ...sharedVariantProps, - HEADER_SECTION_CLASS: "bg-brand", - HEADER_LOGO_ALIGN: "center", - }, - inverted: { - ...sharedVariantProps, - HEADER_SECTION_CLASS: "bg-invert", - HEADER_LOGO_SRC: "/email/stack-overflow-logo-off-white.png", - }, - business: { - ...sharedVariantProps, - HEADER_SECTION_CLASS: "bg-invert", - HEADER_LOGO_SRC: "/email/stack-overflow-business-logo.png", - HEADER_LOGO_URL: "https://stackoverflow.co/", - }, -}; - -const headerOptionRows: NonNullable = [ - { - argument: "variant", - type: '"transactional" | "brand" | "brand-center" | "inverted" | "business"', - description: "Selects section background and logo defaults.", - }, - { - argument: "options.sectionClass", - type: "string", - defaultValue: "From selected variant", - defaultValueCode: false, - description: "Header section mj-class override.", - }, - { - argument: "options.logoSrc", - type: "string", - defaultValue: "From selected variant", - defaultValueCode: false, - description: "Logo image source path/URL override.", - }, - { - argument: "options.logoAlt", - type: "string", - defaultValue: "Stack Overflow", - defaultValueCode: true, - description: "Logo alt text.", - }, - { - argument: "options.logoUrl", - type: "string", - defaultValue: "From selected variant", - defaultValueCode: false, - description: "Logo link destination.", - }, - { - argument: "options.logoWidth", - type: "string", - defaultValue: "158px", - defaultValueCode: true, - description: "Logo width override.", - }, - { - argument: "options.logoAlign", - type: "string", - defaultValue: "From selected variant", - defaultValueCode: false, - description: "MJML image alignment override.", +const header = defineEmailComponent({ + slug: "header", + defaultVariant: "transactional", + variants: { + "brand": { + sectionClass: "bg-brand", + }, + "brand-center": { + sectionClass: "bg-brand", + logoAlign: "center", + }, + "inverted": { + sectionClass: "bg-invert", + logoSrc: "/email/stack-overflow-logo-off-white.png", + }, + "business": { + sectionClass: "bg-invert", + logoSrc: "/email/stack-overflow-business-logo.png", + logoUrl: "https://stackoverflow.co/", + }, }, -]; - -export const Header = ( - variant: HeaderVariantId, - options: HeaderOptions = {} -): MjmlNode => { - const parsedOptions = headerOptionsSchema.safeParse(options); - const normalizedOptions = parsedOptions.success ? parsedOptions.data : options; - const variantDefaults = headerVariantDefaults[variant]; - - return { + tokens: [], + optionsSchema: defineOptions([ + defineOption({ + name: "sectionClass", + type: "string", + initialValue: "bg-block", + description: "Header section mj-class override.", + }), + defineOption({ + name: "logoSrc", + type: "string", + initialValue: "/email/stack-overflow-logo.png", + description: "Logo image source path/URL override.", + }), + defineOption({ + name: "logoAlt", + type: "string", + initialValue: "Stack Overflow", + description: "Logo alt text.", + }), + defineOption({ + name: "logoUrl", + type: "string", + initialValue: "https://stackoverflow.com/", + description: "Logo link destination.", + }), + defineOption({ + name: "logoWidth", + type: "string", + initialValue: "158px", + description: "Logo width override.", + }), + defineOption({ + name: "logoAlign", + type: "enum", + values: mjmlAlignOptions, + initialValue: "left", + description: "MJML image alignment override.", + }), + ]), + render: ({ options }): MjmlNode => ({ tagName: "mj-section", attributes: { - "mj-class": - normalizedOptions.sectionClass ?? variantDefaults.HEADER_SECTION_CLASS, + "mj-class": options.sectionClass, "padding-top": "20px", "padding-bottom": "20px", "padding-left": "24px", @@ -133,64 +83,19 @@ export const Header = ( { tagName: "mj-image", attributes: { - src: normalizedOptions.logoSrc ?? variantDefaults.HEADER_LOGO_SRC, - alt: normalizedOptions.logoAlt ?? variantDefaults.HEADER_LOGO_ALT, - width: - normalizedOptions.logoWidth ?? - variantDefaults.HEADER_LOGO_WIDTH, - href: normalizedOptions.logoUrl ?? variantDefaults.HEADER_LOGO_URL, - align: normalizedOptions.logoAlign ?? variantDefaults.HEADER_LOGO_ALIGN, + src: options.logoSrc, + alt: options.logoAlt, + width: options.logoWidth, + href: options.logoUrl, + align: options.logoAlign, padding: "0px", }, }, ], }, ], - }; -}; - -export const meta: EmailComponentMeta = { - slug: "header", - defaultVariant: "transactional", - variants: [ - { - id: "transactional", - props: headerVariantDefaults.transactional, - }, - { - id: "brand", - props: headerVariantDefaults.brand, - }, - { - id: "brand-center", - props: headerVariantDefaults["brand-center"], - }, - { - id: "inverted", - props: headerVariantDefaults.inverted, - }, - { - id: "business", - props: headerVariantDefaults.business, - }, - ], - tokens: [], - options: headerOptionRows, -}; - -export const source: MjmlNode[] = [ - { - ...Header("transactional", { - sectionClass: "{{HEADER_SECTION_CLASS}}", - logoSrc: "{{HEADER_LOGO_SRC}}", - logoAlt: "{{HEADER_LOGO_ALT}}", - logoWidth: "{{HEADER_LOGO_WIDTH}}", - logoUrl: "{{HEADER_LOGO_URL}}", - logoAlign: "{{HEADER_LOGO_ALIGN}}", - }), - }, -]; - -export const definition = { meta, source } as const; + }), +}); -export default definition; +export const Header = header.component; +export default header; diff --git a/packages/stacks-email/components/headline.ts b/packages/stacks-email/components/headline.ts index e75e356c33..2c907a883d 100644 --- a/packages/stacks-email/components/headline.ts +++ b/packages/stacks-email/components/headline.ts @@ -1,192 +1,82 @@ -import type { EmailComponentMeta, MjmlNode } from "../types"; -import { z } from "zod/v4"; +import type { MjmlNode } from "../src/lib/types"; -import { tokens } from "../tokens"; -import { Section } from "./section"; - -export type HeadlineVariantId = "default" | "highlight"; - -const headlineOptionsSchema = z.object({ - sectionClass: z.string().optional(), - textClass: z.string().optional(), - textAlign: z.string().optional(), - textContent: z.string().optional(), - textHighlight: z.union([z.boolean(), z.string()]).optional(), - textHighlightStart: z.string().optional(), - textHighlightEnd: z.string().optional(), -}); - -type HeadlineOptions = z.input; - -const headlineOptionRows: NonNullable = [ - { - argument: "variant", - type: '"default" | "highlight"', - description: "Selects baseline highlight wrapper behavior.", - }, - { - argument: "options.sectionClass", - type: "string", - defaultValue: "bg-block", - defaultValueCode: true, - description: "Section class for the headline row.", - }, - { - argument: "options.textClass", - type: "string", - defaultValue: "s-email-text-headline", - defaultValueCode: true, - description: "Text styling class for the headline node.", - }, - { - argument: "options.textAlign", - type: "string", - defaultValue: "left", - defaultValueCode: true, - description: "MJML text alignment.", - }, - { - argument: "options.textContent", - type: "string", - defaultValue: "Please verify your email address", - defaultValueCode: true, - description: "Headline copy content.", - }, - { - argument: "options.textHighlight", - type: "boolean | string", - defaultValue: "Variant behavior when omitted", - defaultValueCode: false, - description: - "When true, forces inline highlighted output; when false, disables highlighting.", - }, - { - argument: "options.textHighlightStart", - type: "string", - defaultValue: "From selected variant", - defaultValueCode: false, - description: "Optional opening wrapper around headline content.", - }, - { - argument: "options.textHighlightEnd", - type: "string", - defaultValue: "From selected variant", - defaultValueCode: false, - description: "Optional closing wrapper around headline content.", - }, -]; - -const headlineVariantDefaults: Record< - HeadlineVariantId, - { - HEADLINE_SECTION_CLASS: string; - HEADLINE_TEXT_CLASS: string; - HEADLINE_TEXT_ALIGN: string; - HEADLINE_TEXT_CONTENT: string; - HEADLINE_TEXT_HIGHLIGHT_START: string; - HEADLINE_TEXT_HIGHLIGHT_END: string; - } -> = { - default: { - HEADLINE_SECTION_CLASS: "bg-block", - HEADLINE_TEXT_CLASS: "s-email-text-headline", - HEADLINE_TEXT_ALIGN: "left", - HEADLINE_TEXT_CONTENT: "Please verify your email address", - HEADLINE_TEXT_HIGHLIGHT_START: "", - HEADLINE_TEXT_HIGHLIGHT_END: "", - }, - highlight: { - HEADLINE_SECTION_CLASS: "bg-block", - HEADLINE_TEXT_CLASS: "s-email-text-headline", - HEADLINE_TEXT_ALIGN: "left", - HEADLINE_TEXT_CONTENT: "Please verify your email address", - HEADLINE_TEXT_HIGHLIGHT_START: ``, - HEADLINE_TEXT_HIGHLIGHT_END: "", - }, -}; - -const getHeadlineVariantProps = (variant: HeadlineVariantId) => - headlineVariantDefaults[variant] ?? headlineVariantDefaults.default; +import { + defineEmailComponent, + defineOption, + defineOptions, + mjmlAlignOptions, +} from "../src/lib/schema"; +import { tokens } from "../src/lib/tokens"; +import { Section } from "../src/lib/mjml"; const withHighlightedText = (text: string) => `${text}`; -export const Headline = ( - variant: HeadlineVariantId, - options: HeadlineOptions = {} -): MjmlNode => { - const parsedOptions = headlineOptionsSchema.safeParse(options); - const normalizedOptions = parsedOptions.success ? parsedOptions.data : options; - const defaults = getHeadlineVariantProps(variant); - const textContent = - normalizedOptions.textContent ?? defaults.HEADLINE_TEXT_CONTENT; - const textHighlightStart = - normalizedOptions.textHighlightStart ?? - defaults.HEADLINE_TEXT_HIGHLIGHT_START; - const textHighlightEnd = - normalizedOptions.textHighlightEnd ?? defaults.HEADLINE_TEXT_HIGHLIGHT_END; - const textHighlightFlag = normalizedOptions.textHighlight; - const textHighlight = - textHighlightFlag === undefined - ? null - : String(textHighlightFlag).trim().toLowerCase() === "true"; - const renderedTextContent = - textHighlight === null - ? `${textHighlightStart}${textContent}${textHighlightEnd}` - : textHighlight - ? withHighlightedText(textContent) - : textContent; - - return Section( - [ - { - tagName: "mj-text", - attributes: { - "mj-class": - normalizedOptions.textClass ?? defaults.HEADLINE_TEXT_CLASS, - align: normalizedOptions.textAlign ?? defaults.HEADLINE_TEXT_ALIGN, - "padding-top": tokens.layout.containerYPadding, - "padding-bottom": tokens.layout.containerYPadding, - "padding-left": tokens.layout.containerXPadding, - "padding-right": tokens.layout.containerXPadding, - }, - content: renderedTextContent, - }, - ], - { - sectionClass: - normalizedOptions.sectionClass ?? defaults.HEADLINE_SECTION_CLASS, - } - ); -}; - -export const meta: EmailComponentMeta = { +const headline = defineEmailComponent({ slug: "headline", - defaultVariant: "default", - variants: [ - { - id: "default", - props: headlineVariantDefaults.default, + variants: { + highlight: { + highlight: true, }, - { - id: "highlight", - props: headlineVariantDefaults.highlight, - }, - ], + }, + optionsSchema: defineOptions([ + defineOption({ + name: "sectionClass", + type: "string", + initialValue: "bg-block", + description: "Section class for the headline row.", + }), + defineOption({ + name: "textClass", + type: "string", + initialValue: "s-email-text-headline", + description: "Text styling class for the headline node.", + }), + defineOption({ + name: "textAlign", + type: "enum", + values: mjmlAlignOptions, + initialValue: "left", + description: "MJML text alignment.", + }), + defineOption({ + name: "textContent", + type: "string", + initialValue: "Please verify your email address", + description: "Headline copy content.", + }), + defineOption({ + name: "highlight", + type: "boolean", + initialValue: false, + description: + "When true, forces inline highlighted output; when false, disables highlighting.", + }), + ]), tokens: [], - options: headlineOptionRows, -}; - -export const source: MjmlNode[] = [ - Headline("default", { - sectionClass: "{{HEADLINE_SECTION_CLASS}}", - textClass: "{{HEADLINE_TEXT_CLASS}}", - textAlign: "{{HEADLINE_TEXT_ALIGN}}", - textContent: "{{HEADLINE_TEXT_CONTENT}}", - textHighlightStart: "{{HEADLINE_TEXT_HIGHLIGHT_START}}", - textHighlightEnd: "{{HEADLINE_TEXT_HIGHLIGHT_END}}", - }), -]; + render: ({ options }): MjmlNode => + Section( + [ + { + tagName: "mj-text", + attributes: { + "mj-class": options.textClass, + "align": options.textAlign, + "padding-top": tokens.layout.containerYPadding, + "padding-bottom": tokens.layout.containerYPadding, + "padding-left": tokens.layout.containerXPadding, + "padding-right": tokens.layout.containerXPadding, + }, + content: options.highlight + ? withHighlightedText(options.textContent) + : options.textContent, + }, + ], + { + sectionClass: options.sectionClass, + } + ), +}); -export const definition = { meta, source } as const; -export default definition; +export const Headline = headline.component; +export default headline; diff --git a/packages/stacks-email/components/index.ts b/packages/stacks-email/components/index.ts index 63aaf00dc0..ce85e98b64 100644 --- a/packages/stacks-email/components/index.ts +++ b/packages/stacks-email/components/index.ts @@ -4,8 +4,6 @@ export { default as graphic } from "./graphic"; export { default as headline } from "./headline"; export { default as header } from "./header"; export { default as preview } from "./preview"; -export { default as spacers } from "./spacers"; +export { default as spacer } from "./spacer"; export { default as text } from "./text"; export { default as title } from "./title"; -export { Section } from "./section"; -export { Spacer } from "./spacer"; diff --git a/packages/stacks-email/components/preview.ts b/packages/stacks-email/components/preview.ts index 263c61317d..51b794f454 100644 --- a/packages/stacks-email/components/preview.ts +++ b/packages/stacks-email/components/preview.ts @@ -1,85 +1,35 @@ -import type { EmailComponentMeta, MjmlNode } from "../types"; -import { z } from "zod/v4"; - -export type PreviewVariantId = "default"; - -const previewOptionsSchema = z.object({ - textContent: z.string().optional(), -}); - -type PreviewOptions = z.input; - -const previewOptionRows: NonNullable = [ - { - argument: "variant", - type: '"default"', - description: "Baseline preview text output.", - }, - { - argument: "options.textContent", - type: "string", - defaultValue: "[[PREVIEW_TEXT]]", - defaultValueCode: true, - description: - "Hidden inbox preview snippet shown by supporting email clients.", - }, -]; - -const previewVariantDefaults: Record< - PreviewVariantId, - { - PREVIEW_TEXT_CONTENT: string; - } -> = { - default: { - PREVIEW_TEXT_CONTENT: "[[PREVIEW_TEXT]]", - }, -}; - -const getPreviewVariantProps = (variant: PreviewVariantId) => - previewVariantDefaults[variant] ?? previewVariantDefaults.default; - -export const Preview = ( - variant: PreviewVariantId = "default", - options: PreviewOptions = {} -): MjmlNode => { - const parsedOptions = previewOptionsSchema.safeParse(options); - const normalizedOptions = parsedOptions.success ? parsedOptions.data : options; - const defaults = getPreviewVariantProps(variant); - - return { - tagName: "mj-preview", - content: normalizedOptions.textContent ?? defaults.PREVIEW_TEXT_CONTENT, - }; -}; - -export const meta: EmailComponentMeta = { +import { + defineEmailComponent, + defineOption, + defineOptions, +} from "../src/lib/schema"; +import type { MjmlNode } from "../src/lib/types"; + +const preview = defineEmailComponent({ slug: "preview", - defaultVariant: "default", htmlExtraction: { targetTag: "mj-preview", }, - variants: [ - { - id: "default", - props: previewVariantDefaults.default, - }, - ], tokens: [ { token: "PREVIEW_TEXT", description: "Inbox preview snippet shown next to subject lines.", }, ], - options: previewOptionRows, -}; - -export const source: MjmlNode[] = [ - Preview("default", { - textContent: "{{PREVIEW_TEXT_CONTENT}}", + optionsSchema: defineOptions([ + defineOption({ + name: "textContent", + type: "string", + initialValue: "[[PREVIEW_TEXT]]", + description: + "Hidden inbox preview snippet shown by supporting email clients.", + }), + ]), + render: ({ options }): MjmlNode => ({ + tagName: "mj-preview", + content: options.textContent, }), -]; - -export const definition = { meta, source } as const; +}); -export default definition; +export const Preview = preview.component; +export default preview; diff --git a/packages/stacks-email/components/spacer.ts b/packages/stacks-email/components/spacer.ts index a4bfa647dc..50ea64eb23 100644 --- a/packages/stacks-email/components/spacer.ts +++ b/packages/stacks-email/components/spacer.ts @@ -1,39 +1,51 @@ -import type { MjmlNode } from "../types"; -import { z } from "zod/v4"; -import { Section } from "./section"; +import { + defineEmailComponent, + defineOption, + defineOptions, +} from "../src/lib/schema"; +import type { MjmlNode } from "../src/lib/types"; +import { Section } from "../src/lib/mjml"; -const spacerHeights = { - medium: "20px", - large: "40px", -} as const; - -export type SpacerSize = keyof typeof spacerHeights; - -const spacerOptionsSchema = z.object({ - sectionClass: z.string().optional(), - height: z.string().optional(), +const spacer = defineEmailComponent({ + slug: "spacer", + defaultVariant: "medium", + htmlExtraction: { + targetTag: "mj-spacer", + }, + variants: { + large: { + height: "40px", + }, + }, + optionsSchema: defineOptions([ + defineOption({ + name: "sectionClass", + type: "string", + initialValue: "bg-block", + description: "Applied to the wrapper section.", + }), + defineOption({ + name: "height", + type: "string", + initialValue: "20px", + description: 'Spacer height, for example "64px".', + }), + ]), + render: ({ options }): MjmlNode => + Section( + [ + { + tagName: "mj-spacer", + attributes: { + height: options.height, + }, + }, + ], + { + sectionClass: options.sectionClass, + } + ), }); -type SpacerOptions = z.input; - -export const Spacer = ( - size: SpacerSize = "medium", - options: SpacerOptions = {} -): MjmlNode => { - const parsedOptions = spacerOptionsSchema.safeParse(options); - const normalizedOptions = parsedOptions.success ? parsedOptions.data : options; - - return Section( - [ - { - tagName: "mj-spacer", - attributes: { - height: normalizedOptions.height ?? spacerHeights[size], - }, - }, - ], - { - sectionClass: normalizedOptions.sectionClass ?? "bg-block", - } - ); -}; +export const Spacer = spacer.component; +export default spacer; diff --git a/packages/stacks-email/components/spacers.ts b/packages/stacks-email/components/spacers.ts deleted file mode 100644 index 311463b0bb..0000000000 --- a/packages/stacks-email/components/spacers.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { EmailComponentMeta, MjmlNode } from "../types"; - -import { Spacer } from "./spacer"; - -export type SpacersVariantId = "medium" | "large"; - -type SpacerVariantDefaults = { - SPACER_SECTION_CLASS: string; - SPACER_HEIGHT: string; -}; - -const spacerVariantDefaults: Record = { - medium: { - SPACER_SECTION_CLASS: "bg-block", - SPACER_HEIGHT: "20px", - }, - large: { - SPACER_SECTION_CLASS: "bg-block", - SPACER_HEIGHT: "40px", - }, -}; - -const spacerOptionRows: NonNullable = [ - { - argument: "size", - type: '"medium" | "large"', - defaultValue: "medium", - defaultValueCode: true, - description: "Preset height token applied to the underlying mj-spacer.", - }, - { - argument: "options.sectionClass", - type: "string", - defaultValue: "bg-block", - defaultValueCode: true, - description: "Applied to the wrapper section (mj-class).", - }, - { - argument: "options.height", - type: "string", - defaultValue: "From size preset", - defaultValueCode: false, - description: 'Explicit spacer height override, for example "64px".', - }, -]; - -export const meta: EmailComponentMeta = { - slug: "spacers", - defaultVariant: "medium", - htmlExtraction: { - targetTag: "mj-spacer", - }, - variants: [ - { - id: "medium", - props: spacerVariantDefaults.medium, - }, - { - id: "large", - props: spacerVariantDefaults.large, - }, - ], - tokens: [], - options: spacerOptionRows, -}; - -export const source: MjmlNode[] = [ - Spacer("medium", { - sectionClass: "{{SPACER_SECTION_CLASS}}", - height: "{{SPACER_HEIGHT}}", - }), -]; - -export const definition = { meta, source } as const; - -export default definition; diff --git a/packages/stacks-email/components/text.ts b/packages/stacks-email/components/text.ts index 5c1c99ec6f..7977fdf3ae 100644 --- a/packages/stacks-email/components/text.ts +++ b/packages/stacks-email/components/text.ts @@ -1,23 +1,15 @@ -import type { EmailComponentMeta, MjmlNode } from "../types"; import MarkdownIt from "markdown-it"; -import { z } from "zod/v4"; -import { tokens } from "../tokens"; -import { Section } from "./section"; +import { + defineEmailComponent, + defineOption, + defineOptions, + mjmlAlignOptions, +} from "../src/lib/schema"; +import { tokens } from "../src/lib/tokens"; +import type { MjmlNode } from "../src/lib/types"; +import { Section } from "../src/lib/mjml"; -export type TextVariantId = "body" | "centered"; - -const textOptionsSchema = z.object({ - columnClass: z.string().optional(), - sectionClass: z.string().optional(), - textAlign: z.string().optional(), - textClass: z.string().optional(), - textContent: z.string().optional(), -}); - -type TextOptions = z.input; - -const BODY_PARAGRAPH_MARGIN = "0 0 16px"; const TEMPLATE_PROP_PATTERN = /^\{\{[A-Z0-9_]+\}\}$/; const markdown = new MarkdownIt({ @@ -45,7 +37,7 @@ markdown.renderer.rules.paragraph_open = ( tokenList[index].attrSet( "style", - `margin:${hasAnotherParagraph ? BODY_PARAGRAPH_MARGIN : "0"};` + `margin:${hasAnotherParagraph ? tokens.body.paragraphMargin : "0"};` ); return self.renderToken(tokenList, index, options); @@ -72,59 +64,7 @@ const renderTextContent = (value: string | undefined) => { return renderMarkdown(content); }; -const textOptionRows: NonNullable = [ - { - argument: "variant", - type: '"body" | "centered"', - description: "Selects layout and alignment defaults.", - }, - { - argument: "options.columnClass", - type: "string", - defaultValue: "From selected variant", - defaultValueCode: false, - description: "Column mj-class override.", - }, - { - argument: "options.sectionClass", - type: "string", - description: "Optional section mj-class override.", - }, - { - argument: "options.textAlign", - type: "string", - defaultValue: "From selected variant", - defaultValueCode: false, - description: "MJML text alignment override.", - }, - { - argument: "options.textClass", - type: "string", - defaultValue: "s-email-text-body", - defaultValueCode: true, - description: "Text mj-class override.", - }, - { - argument: "options.textContent", - type: "string", - defaultValue: "From selected variant", - defaultValueCode: false, - description: "Raw content string before markdown/HTML handling.", - }, -]; - -export const meta: EmailComponentMeta = { - slug: "text", - defaultVariant: "body", - variants: [ - { - id: "body", - props: { - TEXT_COLUMN_CLASS: "bg-block", - TEXT_CLASS: "s-email-text-body", - TEXT_ALIGN: "left", - TEXT_CONTENT: renderTextContent( - ` +const bodyContent = renderTextContent(` Dear [[FIRST_NAME]], The entire [software development lifecycle](https://stackoverflow.com) has been dramatically changed by AI, introducing a new model for team organization and leadership. @@ -132,79 +72,82 @@ The entire [software development lifecycle](https://stackoverflow.com) has been AI has accelerated coding, allowing developers to dedicate more time to complex and creative tasks. **Simultaneously**, it enables teams to clear bottlenecks of repetitive tasks [through automation](https://stackoverflow.com), allowing leaders to create more agile teams and focus on higher-level strategic problems. Ultimately, it is really AI’s ability to automate the __"work around the work"__ that is proving to be transformative for organizations. - ` - ), - }, - }, - { - id: "centered", - props: { - TEXT_COLUMN_CLASS: "bg-block", - TEXT_CLASS: "s-email-text-body", - TEXT_CONTENT: renderTextContent( - ` - A starting point for more simple transactional emails with a single, center-aligned message. It can [contain links](https://stackoverflow.com) or **rich text**. - ` - ), - TEXT_ALIGN: "center", - }, +`); + +const centeredContent = renderTextContent(` +A starting point for more simple transactional emails with a single, center-aligned message. It can [contain links](https://stackoverflow.com) or **rich text**. +`); + +const text = defineEmailComponent({ + slug: "text", + defaultVariant: "body", + variants: { + centered: { + textAlign: "center", + textContent: centeredContent, }, - ], + }, tokens: [ { token: "FIRST_NAME", description: "Recipient first name for personalized body copy.", }, ], - options: textOptionRows, -}; - -const getTextVariantProps = (variant: TextVariantId) => - meta.variants.find((entry) => entry.id === variant)?.props ?? - meta.variants[0].props; - -export const Text = ( - variant: TextVariantId, - options: TextOptions = {} -): MjmlNode => { - const parsedOptions = textOptionsSchema.safeParse(options); - const normalizedOptions = parsedOptions.success ? parsedOptions.data : options; - const defaults = getTextVariantProps(variant); - - return Section( - [ - { - tagName: "mj-text", - attributes: { - "mj-class": normalizedOptions.textClass ?? defaults.TEXT_CLASS, - align: normalizedOptions.textAlign ?? defaults.TEXT_ALIGN, - "padding-top": tokens.layout.containerYPadding, - "padding-bottom": tokens.layout.containerYPadding, - "padding-left": tokens.layout.containerXPadding, - "padding-right": tokens.layout.containerXPadding, + optionsSchema: defineOptions([ + defineOption({ + name: "columnClass", + type: "string", + initialValue: "bg-block", + description: "Column mj-class override.", + }), + defineOption({ + name: "sectionClass", + type: "string", + optional: true, + description: "Optional section mj-class override.", + }), + defineOption({ + name: "textAlign", + type: "enum", + values: mjmlAlignOptions, + initialValue: "left", + description: "MJML text alignment override.", + }), + defineOption({ + name: "textClass", + type: "string", + initialValue: "s-email-text-body", + description: "Text mj-class override.", + }), + defineOption({ + name: "textContent", + type: "string", + initialValue: bodyContent, + description: "Raw content string before markdown/HTML handling.", + }), + ]), + render: ({ options }): MjmlNode => + Section( + [ + { + tagName: "mj-text", + attributes: { + "mj-class": options.textClass, + "align": options.textAlign, + "padding-top": tokens.layout.containerYPadding, + "padding-bottom": tokens.layout.containerYPadding, + "padding-left": tokens.layout.containerXPadding, + "padding-right": tokens.layout.containerXPadding, + }, + content: renderTextContent(options.textContent), }, - content: renderTextContent( - normalizedOptions.textContent ?? defaults.TEXT_CONTENT - ), - }, - ], - { - sectionClass: normalizedOptions.sectionClass, - columnClass: - normalizedOptions.columnClass ?? defaults.TEXT_COLUMN_CLASS, - } - ); -}; - -export const source: MjmlNode[] = [ - Text("body", { - columnClass: "{{TEXT_COLUMN_CLASS}}", - textClass: "{{TEXT_CLASS}}", - textAlign: "{{TEXT_ALIGN}}", - textContent: "{{TEXT_CONTENT}}", - }), -]; - -export const definition = { meta, source } as const; + ], + { + sectionClass: options.sectionClass, + columnClass: options.columnClass, + } + ), +}); -export default definition; +export const Text = text.component; +export default text; diff --git a/packages/stacks-email/components/title.ts b/packages/stacks-email/components/title.ts index f0fe326a74..9809226f5e 100644 --- a/packages/stacks-email/components/title.ts +++ b/packages/stacks-email/components/title.ts @@ -1,143 +1,70 @@ -import type { EmailComponentMeta, MjmlNode } from "../types"; -import { z } from "zod/v4"; +import { + defineEmailComponent, + defineOption, + defineOptions, + mjmlAlignOptions, +} from "../src/lib/schema"; +import { tokens } from "../src/lib/tokens"; +import type { MjmlNode } from "../src/lib/types"; +import { Section } from "../src/lib/mjml"; -import { tokens } from "../tokens"; -import { Section } from "./section"; - -export type TitleVariantId = "default" | "invert"; - -const titleOptionsSchema = z.object({ - sectionClass: z.string().optional(), - textClass: z.string().optional(), - textAlign: z.string().optional(), - textContent: z.string().optional(), -}); - -type TitleOptions = z.input; - -const titleVariantDefaults: Record< - TitleVariantId, - { - TITLE_SECTION_CLASS: string; - TITLE_TEXT_CLASS: string; - TITLE_TEXT_ALIGN: string; - TITLE_TEXT_CONTENT: string; - } -> = { - default: { - TITLE_SECTION_CLASS: "bg-block", - TITLE_TEXT_CLASS: "s-email-text-title", - TITLE_TEXT_ALIGN: "left", - TITLE_TEXT_CONTENT: "Featured", - }, - invert: { - TITLE_SECTION_CLASS: "bg-invert", - TITLE_TEXT_CLASS: "s-email-text-title fc-text-invert", - TITLE_TEXT_ALIGN: "left", - TITLE_TEXT_CONTENT: "Featured", - }, -}; - -const titleOptionRows: NonNullable = [ - { - argument: "variant", - type: '"default" | "invert"', - description: "Selects baseline section and text styling.", - }, - { - argument: "options.sectionClass", - type: "string", - defaultValue: "From selected variant", - defaultValueCode: false, - description: "Section mj-class override.", - }, - { - argument: "options.textClass", - type: "string", - defaultValue: "From selected variant", - defaultValueCode: false, - description: "Title text mj-class override.", - }, - { - argument: "options.textAlign", - type: "string", - defaultValue: "left", - defaultValueCode: true, - description: "MJML text alignment override.", - }, - { - argument: "options.textContent", - type: "string", - defaultValue: "Featured", - defaultValueCode: true, - description: "Title copy content.", - }, -]; - -const getTitleVariantProps = (variant: TitleVariantId) => - titleVariantDefaults[variant] ?? titleVariantDefaults.default; - -export const Title = ( - variant: TitleVariantId, - options: TitleOptions = {} -): MjmlNode => { - const parsedOptions = titleOptionsSchema.safeParse(options); - const normalizedOptions = parsedOptions.success ? parsedOptions.data : options; - - return Section( - [ - { - tagName: "mj-text", - attributes: { - "mj-class": - normalizedOptions.textClass ?? - getTitleVariantProps(variant).TITLE_TEXT_CLASS, - align: - normalizedOptions.textAlign ?? - getTitleVariantProps(variant).TITLE_TEXT_ALIGN, - "padding-top": tokens.layout.containerYPadding, - "padding-bottom": tokens.layout.containerYPadding, - "padding-left": tokens.layout.containerXPadding, - "padding-right": tokens.layout.containerXPadding, - }, - content: - normalizedOptions.textContent ?? - getTitleVariantProps(variant).TITLE_TEXT_CONTENT, - }, - ], - { - sectionClass: - normalizedOptions.sectionClass ?? - getTitleVariantProps(variant).TITLE_SECTION_CLASS, - } - ); -}; - -export const meta: EmailComponentMeta = { +const title = defineEmailComponent({ slug: "title", - defaultVariant: "default", - variants: [ - { - id: "default", - props: titleVariantDefaults.default, + variants: { + invert: { + sectionClass: "bg-invert", + textClass: "s-email-text-title fc-text-invert", }, - { - id: "invert", - props: titleVariantDefaults.invert, - }, - ], + }, + optionsSchema: defineOptions([ + defineOption({ + name: "sectionClass", + type: "string", + initialValue: "bg-block", + description: "Section mj-class override.", + }), + defineOption({ + name: "textClass", + type: "string", + initialValue: "s-email-text-title", + description: "Title text mj-class override.", + }), + defineOption({ + name: "textAlign", + type: "enum", + values: mjmlAlignOptions, + initialValue: "left", + description: "MJML text alignment override.", + }), + defineOption({ + name: "textContent", + type: "string", + initialValue: "Featured", + description: "Title copy content.", + }), + ]), tokens: [], - options: titleOptionRows, -}; - -export const source: MjmlNode[] = [ - Title("default", { - sectionClass: "{{TITLE_SECTION_CLASS}}", - textClass: "{{TITLE_TEXT_CLASS}}", - textAlign: "{{TITLE_TEXT_ALIGN}}", - textContent: "{{TITLE_TEXT_CONTENT}}", - }), -]; + render: ({ options }): MjmlNode => + Section( + [ + { + tagName: "mj-text", + attributes: { + "mj-class": options.textClass, + "align": options.textAlign, + "padding-top": tokens.layout.containerYPadding, + "padding-bottom": tokens.layout.containerYPadding, + "padding-left": tokens.layout.containerXPadding, + "padding-right": tokens.layout.containerXPadding, + }, + content: options.textContent, + }, + ], + { + sectionClass: options.sectionClass, + } + ), +}); -export const definition = { meta, source } as const; -export default definition; +export const Title = title.component; +export default title; diff --git a/packages/stacks-email/eslint.config.js b/packages/stacks-email/eslint.config.js index f60aa92137..0f270d00b0 100644 --- a/packages/stacks-email/eslint.config.js +++ b/packages/stacks-email/eslint.config.js @@ -5,6 +5,9 @@ import tseslint from "typescript-eslint"; import prettier from "eslint-config-prettier"; export default tseslint.config( + { + ignores: [".svelte-kit/**"], + }, js.configs.recommended, ...tseslint.configs.recommended, ...svelte.configs["flat/recommended"], @@ -16,6 +19,10 @@ export default tseslint.config( ...globals.browser, ...globals.node, }, + parserOptions: { + tsconfigRootDir: import.meta.dirname, + project: "./tsconfig.eslint.json", + }, }, }, { @@ -23,6 +30,9 @@ export default tseslint.config( languageOptions: { parserOptions: { parser: tseslint.parser, + tsconfigRootDir: import.meta.dirname, + project: "./tsconfig.eslint.json", + extraFileExtensions: [".svelte"], }, }, } diff --git a/packages/stacks-email/mjml-config.ts b/packages/stacks-email/mjml-config.ts deleted file mode 100644 index d162f3263f..0000000000 --- a/packages/stacks-email/mjml-config.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { mjmlJsonToString } from "./mjml-json"; -import { tokens } from "./tokens"; -import type { MjmlNode } from "./types"; - -const { color, font, spacing, layout, border } = tokens; -const fontWeightSemibold = "600"; - -export const BODY_PARAGRAPH_MARGIN = "0 0 16px"; -export const BODY_LIST_MARGIN = "0 0 16px 24px"; -export const BODY_LIST_PADDING = "0"; -export const BODY_LIST_ITEM_MARGIN = "0 0 8px"; - -const createMjClass = ( - name: string, - attributes: Record -): MjmlNode => ({ - tagName: "mj-class", - attributes: { - name, - ...attributes, - }, -}); - -const generatedBackgroundClasses = color.backgroundClasses.map( - ({ name, value }) => - createMjClass(`bg-${name}`, { - "background-color": value, - }) -); - -const generatedFontClasses = color.fontClasses.map(({ name, value }) => - createMjClass(`fc-${name}`, { - color: value, - }) -); - -const attributesChildren: MjmlNode[] = [ - { - tagName: "mj-all", - attributes: { - "font-family": font.family, - "color": color.text, - "font-size": "16px", - "line-height": "120%", - }, - }, - { - tagName: "mj-body", - attributes: { - "width": layout.maxWidth, - "background-color": color.background, - }, - }, - { - tagName: "mj-text", - attributes: { - "padding": "0px", - }, - }, - { - tagName: "mj-image", - attributes: { - "padding": "0px", - }, - }, - { - tagName: "mj-button", - attributes: { - "padding": "0px", - }, - }, - { - tagName: "mj-table", - attributes: { - "padding": "0px", - }, - }, - { - tagName: "mj-column", - attributes: { - "padding": "0px", - }, - }, - { - tagName: "mj-section", - attributes: { - "padding": "0px", - }, - }, - { - tagName: "mj-wrapper", - attributes: { - "padding": "0px", - }, - }, - - // Utility classes generated from design-token arrays. - ...generatedBackgroundClasses, - ...generatedFontClasses, - - createMjClass("s-email-text-title", { - "color": color.text, - "font-family": "Stack Sans Notch, Arial, Helvetica, sans-serif", - "font-size": "24px", - "font-weight": font.weightNormal, - "line-height": "100%", - "padding": "0", - }), - createMjClass("s-email-text-headline", { - "color": color.text, - "font-family": "Stack Sans Notch, Arial, Helvetica, sans-serif", - "font-size": "36px", - "font-weight": font.weightNormal, - "line-height": "100%", - "padding": "0", - }), - - createMjClass("s-email-text-subtitle", { - "color": color.text, - "font-size": "14px", - "font-weight": font.weightBold, - "line-height": "120%", - "padding": "0", - }), - createMjClass("s-email-text-secondary-information", { - "color": color.textMuted, - "font-size": "12px", - "font-weight": font.weightNormal, - "line-height": "120%", - "padding": "0", - }), - createMjClass("s-email-text-body", { - "color": color.text, - "font-size": "16px", - "font-weight": font.weightNormal, - "line-height": "120%", - }), - createMjClass("s-email-text-caption", { - "color": color.textMuted, - "font-size": "14px", - "font-weight": font.weightNormal, - "line-height": "120%", - }), - createMjClass("s-email-text-alert", { - "color": color.text, - "font-size": "16px", - "font-weight": fontWeightSemibold, - "line-height": "120%", - "padding": "0", - }), - - createMjClass("button", { - "background-color": color.brandDark, - "color": "#ffffff", - "border-radius": border.radius, - "font-size": "14px", - "font-weight": font.weightBold, - "line-height": "120%", - "inner-padding": `12px 18px`, - }), - createMjClass("button__tonal", { - "background-color": color.brandOffWhite, - "color": color.text, - }), - createMjClass("button__inverted", { - "background-color": color.background, - "color": color.text, - }), -]; - -const linkStyles = ` -a.link { - color: ${color.link}; - text-decoration: underline; - font-size: 16px; - line-height: normal; - font-weight: ${font.weightNormal}; -} -a.footer-link { - color: ${color.textFooter}; - text-decoration: underline; - font-size: 14px; - line-height: 120%; - margin-right: 10px; -} -`.trim(); - -// Head-only Progressive enhancement: hover states only apply in clients that support head CSS and :hover. -const hoverStyles = ` -a.link:hover { - color: ${color.linkHover} !important; -} -a.footer-link:hover, -a.footer-link-light:hover, -a.footer-link:hover { - opacity: 0.85 !important; -} -.button-hover a:hover { - background-color: #47484d !important; - color: #fff !important; -} -.button-hover-inverted a:hover { - background-color: ${color.brandOffWhite} !important; -} - -.footer-social-hidden { - display: none !important; - max-height: 0 !important; - overflow: hidden !important; - mso-hide: all !important; -} -`.trim(); - -export const mjmlConfigNodes: MjmlNode[] = [ - { - tagName: "mj-font", - attributes: { - name: "Stack Sans Headline", - href: "https://fonts.googleapis.com/css2?family=Stack+Sans+Headline:wght@400;600&display=swap", - }, - }, - { - tagName: "mj-font", - attributes: { - name: "Stack Sans Notch", - href: "https://fonts.googleapis.com/css2?family=Stack+Sans+Notch:wght@400&display=swap", - }, - }, - { - tagName: "mj-attributes", - children: attributesChildren, - }, - { - tagName: "mj-style", - attributes: { - inline: "inline", - }, - content: linkStyles, - }, - { - tagName: "mj-style", - content: hoverStyles, - }, -]; - -export const mjmlConfig = mjmlJsonToString(mjmlConfigNodes); diff --git a/packages/stacks-email/mjml-json.ts b/packages/stacks-email/mjml-json.ts deleted file mode 100644 index 5f793f4dcf..0000000000 --- a/packages/stacks-email/mjml-json.ts +++ /dev/null @@ -1,19 +0,0 @@ -import json2mjmlModule from "json2mjml"; - -import type { MjmlNode } from "./types"; - -export const mjmlJsonToString = (source: MjmlNode | MjmlNode[]) => { - const json2mjml = - typeof json2mjmlModule === "function" - ? json2mjmlModule - : (json2mjmlModule as { default?: (node: unknown) => string }) - .default; - - if (typeof json2mjml !== "function") { - throw new Error("json2mjml export is not callable"); - } - - const nodes = Array.isArray(source) ? source : [source]; - - return nodes.map((node) => json2mjml(node as never)).join("\n"); -}; diff --git a/packages/stacks-email/package.json b/packages/stacks-email/package.json index b109044e8d..200356d76d 100644 --- a/packages/stacks-email/package.json +++ b/packages/stacks-email/package.json @@ -4,7 +4,8 @@ "private": true, "type": "module", "exports": { - ".": "./src/lib/public/index.ts" + ".": "./src/lib/api/index.ts", + "./sveltekit": "./src/lib/sveltekit/index.ts" }, "scripts": { "dev": "vite dev", @@ -41,6 +42,7 @@ "json2mjml": "^1.0.3", "markdown-it": "^14.1.0", "mjml": "^4.17.1", + "mjml-parser-xml": "^4.18.0", "zod": "^4.1.12" } } diff --git a/packages/stacks-email/registry.ts b/packages/stacks-email/registry.ts deleted file mode 100644 index ac3122d835..0000000000 --- a/packages/stacks-email/registry.ts +++ /dev/null @@ -1,35 +0,0 @@ -import button from "./components/button"; -import footer from "./components/footer"; -import graphic from "./components/graphic"; -import headline from "./components/headline"; -import header from "./components/header"; -import spacers from "./components/spacers"; -import text from "./components/text"; -import title from "./components/title"; - -import transactional from "./templates/transactional"; - -export const componentDefinitions = [ - button, - footer, - graphic, - headline, - header, - spacers, - text, - title, -] as const; - -export const templateDefinitions = [transactional] as const; - -export { - button, - footer, - graphic, - headline, - header, - spacers, - text, - title, - transactional, -}; diff --git a/packages/stacks-email/src/lib/public/catalog.ts b/packages/stacks-email/src/lib/api/catalog.ts similarity index 100% rename from packages/stacks-email/src/lib/public/catalog.ts rename to packages/stacks-email/src/lib/api/catalog.ts diff --git a/packages/stacks-email/src/lib/public/compile.ts b/packages/stacks-email/src/lib/api/compile.ts similarity index 92% rename from packages/stacks-email/src/lib/public/compile.ts rename to packages/stacks-email/src/lib/api/compile.ts index 274157aa0f..5486ffa832 100644 --- a/packages/stacks-email/src/lib/public/compile.ts +++ b/packages/stacks-email/src/lib/api/compile.ts @@ -8,8 +8,8 @@ import { getEmailTemplateMeta, type CompileTemplateOutput, } from "./templates"; -import type { CompileTarget } from "../../../tokens"; -import { compileEmailRenderableInputSchema } from "./validation"; +import type { CompileTarget } from "../tokens"; +import { compileEmailRenderableInputSchema } from "./request-schemas"; export type EmailRenderableKind = "component" | "template"; diff --git a/packages/stacks-email/src/lib/api/components.ts b/packages/stacks-email/src/lib/api/components.ts new file mode 100644 index 0000000000..c4570ae023 --- /dev/null +++ b/packages/stacks-email/src/lib/api/components.ts @@ -0,0 +1,135 @@ +import { mjmlJsonToString } from "../mjml/json"; +import { componentDefinitions } from "../registry"; +import type { CompileTarget } from "../tokens"; +import type { EmailComponentMeta, ComponentOptionReference } from "../types"; +import { compileMjml, type CompileMjmlOutput } from "../pipeline/compile"; +import { compileComponentInputSchema } from "./request-schemas"; +import { expandVariantRecords } from "./records"; + +type ExpandedComponentRecord = { + catalog: EmailComponentCatalogItem; + source: string; + htmlExtractionTag?: string; +}; + +export type EmailComponentCatalogItem = { + slug: string; + name: string; + description: string; + category: string; + tokens: NonNullable; + options: NonNullable; +}; + +export type CompileComponentInput = { + slug: string; + target: CompileTarget; +}; + +export type CompileComponentOutput = Omit< + CompileMjmlOutput, + "componentHtml" | "componentMjml" +> & { + kind: "component"; + slug: string; + componentHtml: string; + componentMjml: string; + meta: EmailComponentCatalogItem; +}; + +const DEFAULT_COMPONENT_CATEGORY = "Primitive"; + +const toNodeList = (value: T | T[]) => + Array.isArray(value) ? value : [value]; + +const expandedComponentRecords = expandVariantRecords({ + definitions: componentDefinitions, + defaultCategory: DEFAULT_COMPONENT_CATEGORY, + getMeta: (definition): EmailComponentMeta => definition.meta, + formatName: ({ baseName, variantName, hasMultipleVariants }) => + hasMultipleVariants ? `${baseName} — ${variantName}` : baseName, + createRecord: ({ + definition, + meta, + variant, + slug, + name, + description, + category, + tokens, + }): ExpandedComponentRecord => { + const rendered = definition.component( + variant.id as keyof typeof definition.variants & string + ); + + return { + catalog: { + slug, + name, + description, + category, + tokens, + options: meta.options ?? [], + }, + source: mjmlJsonToString(toNodeList(rendered)), + htmlExtractionTag: meta.htmlExtraction?.targetTag, + }; + }, +}); + +const componentBySlug = new Map( + expandedComponentRecords.map( + (record) => [record.catalog.slug, record] as const + ) +); + +export const listEmailComponents = () => + expandedComponentRecords.map((record) => record.catalog); + +export const getEmailComponentMeta = (slug: string) => { + const record = componentBySlug.get(slug); + if (!record) { + return null; + } + return record.catalog; +}; + +export const getEmailComponentOptions = ( + slug: string +): ComponentOptionReference[] | null => { + const record = componentBySlug.get(slug); + if (!record) { + return null; + } + + return record.catalog.options; +}; + +export const compileEmailComponent = ({ + slug, + target, +}: CompileComponentInput): CompileComponentOutput => { + const parsedInput = compileComponentInputSchema.parse({ slug, target }); + const record = componentBySlug.get(parsedInput.slug); + + if (!record) { + throw new Error(`Unknown email component slug: ${parsedInput.slug}`); + } + + const result = compileMjml({ + mjml: record.source, + target: parsedInput.target, + props: {}, + extractComponentName: record.catalog.slug, + extractComponentTag: record.htmlExtractionTag, + }); + + return { + ...result, + kind: "component", + slug: parsedInput.slug, + componentHtml: result.componentHtml ?? result.html, + componentMjml: result.componentMjml ?? result.renderedMjml, + meta: record.catalog, + }; +}; diff --git a/packages/stacks-email/src/lib/api/index.ts b/packages/stacks-email/src/lib/api/index.ts new file mode 100644 index 0000000000..fd7f7cccf1 --- /dev/null +++ b/packages/stacks-email/src/lib/api/index.ts @@ -0,0 +1,16 @@ +export { + tokens, + targets, + targetNames, + isCompileTarget, + type Tokens, + type CompileTarget, +} from "../tokens"; + +export * from "../pipeline/compile"; +export * from "../pipeline/transform"; +export * from "./catalog"; +export * from "./compile"; +export * from "./components"; +export * from "./static-artifacts"; +export * from "./templates"; diff --git a/packages/stacks-email/src/lib/api/records.ts b/packages/stacks-email/src/lib/api/records.ts new file mode 100644 index 0000000000..150081c32d --- /dev/null +++ b/packages/stacks-email/src/lib/api/records.ts @@ -0,0 +1,100 @@ +import type { EmailTokenReference, EmailVariant } from "../types"; + +type VariantMeta = { + slug: string; + name?: string; + description?: string; + category?: string; + variants: readonly EmailVariant[]; + defaultVariant?: string; + tokens?: readonly EmailTokenReference[]; +}; + +type ExpandedVariantInput = { + definition: TDefinition; + meta: TMeta; + variant: TMeta["variants"][number]; + slug: string; + baseName: string; + variantName: string; + name: string; + description: string; + category: NonNullable; + tokens: EmailTokenReference[]; + hasMultipleVariants: boolean; +}; + +type ExpandVariantRecordsInput< + TDefinition, + TMeta extends VariantMeta, + TRecord, +> = { + definitions: readonly TDefinition[]; + defaultCategory: NonNullable; + getMeta: (definition: TDefinition) => TMeta; + formatName: (input: { + baseName: string; + variantName: string; + hasMultipleVariants: boolean; + }) => string; + createRecord: (input: ExpandedVariantInput) => TRecord; +}; + +export const toLabel = (value: string) => + value + .split(/[-_]/g) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); + +export const expandVariantRecords = < + TDefinition, + TMeta extends VariantMeta, + TRecord, +>({ + definitions, + defaultCategory, + getMeta, + formatName, + createRecord, +}: ExpandVariantRecordsInput) => + definitions + .flatMap((definition) => { + const meta = getMeta(definition); + const baseName = meta.name ?? toLabel(meta.slug); + const baseDescription = meta.description ?? ""; + const hasMultipleVariants = meta.variants.length > 1; + + return meta.variants.map((variant, index) => { + const isDefault = + variant.id === meta.defaultVariant || + (!meta.defaultVariant && index === 0); + const slug = isDefault + ? meta.slug + : `${meta.slug}-${variant.id}`; + const variantName = variant.name ?? toLabel(variant.id); + const description = hasMultipleVariants + ? (variant.description ?? baseDescription) + : baseDescription; + + return { + definition, + meta, + variant, + slug, + baseName, + variantName, + name: formatName({ + baseName, + variantName, + hasMultipleVariants, + }), + description, + category: meta.category ?? defaultCategory, + tokens: [...(meta.tokens ?? [])], + hasMultipleVariants, + }; + }); + }) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((input) => createRecord(input)); diff --git a/packages/stacks-email/src/lib/public/validation.ts b/packages/stacks-email/src/lib/api/request-schemas.ts similarity index 75% rename from packages/stacks-email/src/lib/public/validation.ts rename to packages/stacks-email/src/lib/api/request-schemas.ts index 6f7c4ca91e..4645acaa55 100644 --- a/packages/stacks-email/src/lib/public/validation.ts +++ b/packages/stacks-email/src/lib/api/request-schemas.ts @@ -1,8 +1,9 @@ import { z } from "zod/v4"; -import type { CompileTarget } from "../../../tokens"; +import { targetNames, type CompileTarget } from "../tokens"; -const compileTargetValues = ["preview", "dotnet", "braze"] as const satisfies readonly CompileTarget[]; +const compileTargetValues = targetNames as [CompileTarget, ...CompileTarget[]]; +const compileTargetList = compileTargetValues.map((target) => `\`${target}\``); const slugSchema = z .string({ error: "`slug` must be a non-empty string." }) @@ -10,7 +11,7 @@ const slugSchema = z .min(1, { error: "`slug` must be a non-empty string." }); export const compileTargetSchema = z.enum(compileTargetValues, { - error: "`target` must be one of `preview`, `dotnet`, or `braze`.", + error: `\`target\` must be one of ${compileTargetList.join(", ")}.`, }); export const emailRenderableKindSchema = z.enum(["component", "template"], { diff --git a/packages/stacks-email/src/lib/api/static-artifacts.ts b/packages/stacks-email/src/lib/api/static-artifacts.ts new file mode 100644 index 0000000000..be8804414f --- /dev/null +++ b/packages/stacks-email/src/lib/api/static-artifacts.ts @@ -0,0 +1,283 @@ +import { compileEmailRenderable, type EmailRenderableKind } from "./compile"; +import { getEmailCatalog, type EmailCatalog } from "./catalog"; +import { + isCompileTarget, + targetNames, + targets, + type CompileTarget, +} from "../tokens"; +import { transformTokens } from "../pipeline/transform"; + +export type StaticEmailManifestRecord = { + kind: EmailRenderableKind; + slug: string; + target: CompileTarget; + meta: EmailCatalog[`${EmailRenderableKind}s`][number]; + errors: ReturnType["errors"]; + files: { + documentHtml: string; + documentMjml: string; + displayHtml: string; + displayMjml: string; + }; +}; + +export type StaticEmailManifest = { + generatedAt: string; + basePath: string; + records: Record; + catalog: EmailCatalog; +}; + +type EmailArtifactKind = + | "documentHtml" + | "documentMjml" + | "displayHtml" + | "displayMjml"; + +type EmailArtifactFileExtension = "html" | "mjml"; +type EmailArtifactFileVariant = "fragment" | "full"; + +type ParsedEmailArtifactFile = { + target: CompileTarget; + variant?: EmailArtifactFileVariant; + extension: EmailArtifactFileExtension; +}; + +export type StaticEmailArtifactRouteEntry = { + kind: EmailRenderableKind; + slug: string; + file: string; +}; + +export type StaticEmailArtifactsOptions = { + basePath?: string; +}; + +export const defaultStaticEmailArtifactsBasePath = "/email/compiled"; + +const renderableKinds = ["component", "template"] as const; + +const manifestCacheByBasePath = new Map(); + +const normalizeBasePath = (basePath = defaultStaticEmailArtifactsBasePath) => + basePath.endsWith("/") ? basePath.slice(0, -1) : basePath; + +const toRecordKey = ( + kind: EmailRenderableKind, + slug: string, + target: CompileTarget +) => `${kind}:${slug}:${target}`; + +const toUrlPath = ( + basePath: string, + kind: EmailRenderableKind, + slug: string, + file: string +) => `${basePath}/${kind}/${slug}/${file}`; + +const getCatalogItems = (catalog: EmailCatalog, kind: EmailRenderableKind) => + kind === "component" ? catalog.components : catalog.templates; + +const isEmailArtifactFileExtension = ( + value: string +): value is EmailArtifactFileExtension => value === "html" || value === "mjml"; + +const isEmailArtifactFileVariant = ( + value: string +): value is EmailArtifactFileVariant => + value === "fragment" || value === "full"; + +const parseEmailArtifactFile = ( + file: string +): ParsedEmailArtifactFile | null => { + const parts = file.split("."); + if (parts.length !== 2 && parts.length !== 3) { + return null; + } + + const [target, maybeVariantOrExtension, maybeExtension] = parts; + if (!isCompileTarget(target)) { + return null; + } + + if (parts.length === 2) { + return isEmailArtifactFileExtension(maybeVariantOrExtension) + ? { target, extension: maybeVariantOrExtension } + : null; + } + + if ( + !maybeExtension || + !isEmailArtifactFileVariant(maybeVariantOrExtension) || + !isEmailArtifactFileExtension(maybeExtension) + ) { + return null; + } + + return { + target, + variant: maybeVariantOrExtension, + extension: maybeExtension, + }; +}; + +const getCompiledRecord = ( + kind: EmailRenderableKind, + slug: string, + target: CompileTarget +) => { + const compiled = compileEmailRenderable({ kind, slug, target }); + const documentMjml = transformTokens(compiled.mjml, targets[target].tokens); + + return { + compiled, + documentMjml, + displayHtml: + compiled.kind === "component" && compiled.componentHtml + ? compiled.componentHtml + : compiled.html, + displayMjml: + compiled.kind === "component" && compiled.componentMjml + ? compiled.componentMjml + : compiled.renderedMjml, + }; +}; + +const createManifestRecord = ( + basePath: string, + kind: EmailRenderableKind, + slug: string, + target: CompileTarget +): StaticEmailManifestRecord => { + const { compiled } = getCompiledRecord(kind, slug, target); + const documentHtmlFile = `${target}.html`; + const documentMjmlFile = `${target}.full.mjml`; + const displayHtmlFile = + kind === "component" ? `${target}.fragment.html` : documentHtmlFile; + const displayMjmlFile = `${target}.mjml`; + + return { + kind, + slug, + target, + meta: compiled.meta, + errors: compiled.errors, + files: { + documentHtml: toUrlPath(basePath, kind, slug, documentHtmlFile), + documentMjml: toUrlPath(basePath, kind, slug, documentMjmlFile), + displayHtml: toUrlPath(basePath, kind, slug, displayHtmlFile), + displayMjml: toUrlPath(basePath, kind, slug, displayMjmlFile), + }, + }; +}; + +export const getStaticEmailManifest = ( + options: StaticEmailArtifactsOptions = {} +): StaticEmailManifest => { + const basePath = normalizeBasePath(options.basePath); + const cachedManifest = manifestCacheByBasePath.get(basePath); + + if (cachedManifest) { + return cachedManifest; + } + + const catalog = getEmailCatalog(); + const records: StaticEmailManifest["records"] = {}; + + for (const kind of renderableKinds) { + for (const item of getCatalogItems(catalog, kind)) { + for (const target of targetNames) { + records[toRecordKey(kind, item.slug, target)] = + createManifestRecord(basePath, kind, item.slug, target); + } + } + } + + const manifest = { + generatedAt: new Date().toISOString(), + basePath, + records, + catalog, + }; + + manifestCacheByBasePath.set(basePath, manifest); + + return manifest; +}; + +export const getStaticEmailArtifactEntries = + (): StaticEmailArtifactRouteEntry[] => { + const catalog = getEmailCatalog(); + const entries: StaticEmailArtifactRouteEntry[] = []; + + for (const kind of renderableKinds) { + for (const item of getCatalogItems(catalog, kind)) { + for (const target of targetNames) { + entries.push( + { kind, slug: item.slug, file: `${target}.html` }, + { kind, slug: item.slug, file: `${target}.full.mjml` }, + { kind, slug: item.slug, file: `${target}.mjml` } + ); + + if (kind === "component") { + entries.push({ + kind, + slug: item.slug, + file: `${target}.fragment.html`, + }); + } + } + } + } + + return entries; + }; + +export const getStaticEmailArtifact = ( + kind: string, + slug: string, + file: string +) => { + if (kind !== "component" && kind !== "template") { + throw new Error(`Unknown email kind: ${kind}`); + } + + const parsedFile = parseEmailArtifactFile(file); + if (!parsedFile) { + throw new Error(`Unknown email artifact file: ${file}`); + } + + const { compiled, displayHtml, displayMjml, documentMjml } = + getCompiledRecord(kind, slug, parsedFile.target); + + let artifactKind: EmailArtifactKind; + if (parsedFile.extension === "html") { + artifactKind = + parsedFile.variant === "fragment" ? "displayHtml" : "documentHtml"; + } else { + artifactKind = + parsedFile.variant === "full" ? "documentMjml" : "displayMjml"; + } + + if (kind !== "component" && artifactKind === "displayHtml") { + throw new Error( + `Templates do not have fragment HTML artifacts: ${file}` + ); + } + + const contentsByKind: Record = { + documentHtml: compiled.html, + documentMjml, + displayHtml, + displayMjml, + }; + + return { + contents: contentsByKind[artifactKind], + contentType: + parsedFile.extension === "html" + ? "text/html; charset=utf-8" + : "text/plain; charset=utf-8", + }; +}; diff --git a/packages/stacks-email/src/lib/api/templates.ts b/packages/stacks-email/src/lib/api/templates.ts new file mode 100644 index 0000000000..4060b891ff --- /dev/null +++ b/packages/stacks-email/src/lib/api/templates.ts @@ -0,0 +1,173 @@ +import { mjmlJsonToString } from "../mjml/json"; +import { templateDefinitions } from "../registry"; +import type { CompileTarget } from "../tokens"; +import type { EmailTemplateMeta } from "../types"; +import { compileMjml, type CompileMjmlOutput } from "../pipeline/compile"; +import { compileTemplateInputSchema } from "./request-schemas"; +import { expandVariantRecords } from "./records"; +import { normalizeEmailOptions } from "../schema"; + +type ExpandedTemplateRecord = { + catalog: EmailTemplateCatalogItem; + renderSource: (props: Record) => string; + resolvePreviewText: (props: Record) => string; +}; + +export type EmailTemplateCatalogItem = { + slug: string; + name: string; + description: string; + category: string; + tokens: NonNullable; +}; + +export type CompileTemplateInput = { + slug: string; + target: CompileTarget; + props?: Record; +}; + +export type CompileTemplateOutput = CompileMjmlOutput & { + kind: "template"; + slug: string; + meta: EmailTemplateCatalogItem; +}; + +const DEFAULT_TEMPLATE_CATEGORY = "Transactional"; +const DEFAULT_TEMPLATE_PREVIEW_TEXT = "Stack Overflow update"; + +const TEMPLATE_PREVIEW_TOKEN: NonNullable[number] = + { + token: "PREVIEW_TEXT", + description: + "Inbox preheader text inserted into `` for all template compiles.", + }; + +const withSharedTemplateTokens = ( + tokens: NonNullable +) => { + const uniqueByToken = new Map(); + + for (const token of [TEMPLATE_PREVIEW_TOKEN, ...tokens]) { + if (!uniqueByToken.has(token.token)) { + uniqueByToken.set(token.token, token); + } + } + + return [...uniqueByToken.values()]; +}; + +const expandedTemplateRecords = expandVariantRecords({ + definitions: templateDefinitions, + defaultCategory: DEFAULT_TEMPLATE_CATEGORY, + getMeta: (definition): EmailTemplateMeta => definition.meta, + formatName: ({ baseName, variantName, hasMultipleVariants }) => + hasMultipleVariants ? variantName : baseName, + createRecord: ({ + definition, + variant, + slug, + name, + description, + category, + tokens, + }): ExpandedTemplateRecord => { + const catalog = { + slug, + name, + description, + category, + tokens: withSharedTemplateTokens(tokens), + }; + const variantId = variant.id as keyof typeof definition.variants & + string; + type DefinitionProps = Parameters< + typeof definition.renderDocument + >[0]["props"]; + const defaults = + definition.variants[variantId] ?? + definition.variants[definition.defaultVariant] ?? + {}; + const resolveProps = (inputProps: Record) => + normalizeEmailOptions( + definition.propsSchema, + defaults as Partial, + inputProps as Partial + ); + + return { + catalog, + renderSource: (inputProps) => + mjmlJsonToString( + definition.renderDocument({ + variant: variantId, + props: resolveProps(inputProps), + }) + ), + resolvePreviewText: (inputProps) => { + const props = resolveProps(inputProps); + const preview = definition.preview?.({ + variant: variantId, + props, + }); + + return ( + preview?.previewText?.trim() || + catalog.description.trim() || + catalog.name.trim() || + DEFAULT_TEMPLATE_PREVIEW_TEXT + ); + }, + }; + }, +}); + +const templateBySlug = new Map( + expandedTemplateRecords.map( + (record) => [record.catalog.slug, record] as const + ) +); + +export const listEmailTemplates = () => + expandedTemplateRecords.map((record) => record.catalog); + +export const getEmailTemplateMeta = (slug: string) => { + const record = templateBySlug.get(slug); + if (!record) { + return null; + } + return record.catalog; +}; + +export const compileEmailTemplate = ({ + slug, + target, + props = {}, +}: CompileTemplateInput): CompileTemplateOutput => { + const parsedInput = compileTemplateInputSchema.parse({ + slug, + target, + props, + }); + const record = templateBySlug.get(parsedInput.slug); + + if (!record) { + throw new Error(`Unknown email template slug: ${parsedInput.slug}`); + } + + const inputProps = parsedInput.props ?? {}; + const source = record.renderSource(inputProps); + const result = compileMjml({ + mjml: source, + target: parsedInput.target, + props: {}, + previewText: record.resolvePreviewText(inputProps), + }); + + return { + ...result, + kind: "template", + slug: parsedInput.slug, + meta: record.catalog, + }; +}; diff --git a/packages/stacks-email/src/lib/highlight/highlight.ts b/packages/stacks-email/src/lib/highlight.ts similarity index 56% rename from packages/stacks-email/src/lib/highlight/highlight.ts rename to packages/stacks-email/src/lib/highlight.ts index 73aa2664ca..90c9557efe 100644 --- a/packages/stacks-email/src/lib/highlight/highlight.ts +++ b/packages/stacks-email/src/lib/highlight.ts @@ -1,27 +1,34 @@ -import hljs from "highlight.js"; +import type { LanguageFn } from "highlight.js"; +import hljs from "highlight.js/lib/core"; +import hljsXml from "highlight.js/lib/languages/xml"; type SupportedLanguage = "html" | "xml"; +const registerLanguage = (language: SupportedLanguage, grammar: LanguageFn) => { + if (!hljs.getLanguage(language)) { + hljs.registerLanguage(language, grammar); + } +}; + +registerLanguage("html", hljsXml); +registerLanguage("xml", hljsXml); + const escapeHtml = (input: string) => input .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">"); -const resolveLanguage = (language: SupportedLanguage) => - hljs.getLanguage(language) ? language : "plaintext"; - export const highlightCode = async ( code: string, language: SupportedLanguage ) => { try { - const resolvedLanguage = resolveLanguage(language); const highlighted = hljs.highlight(code, { - language: resolvedLanguage, + language, }).value; - return `
${highlighted}
`; + return `
${highlighted}
`; } catch { const escaped = escapeHtml(code); return `
${escaped}
`; diff --git a/packages/stacks-email/src/lib/markdown.ts b/packages/stacks-email/src/lib/markdown.ts new file mode 100644 index 0000000000..63532e772e --- /dev/null +++ b/packages/stacks-email/src/lib/markdown.ts @@ -0,0 +1,78 @@ +import MarkdownIt from "markdown-it"; + +import { tokens } from "./tokens"; + +const markdown = new MarkdownIt({ + html: false, + breaks: true, + linkify: true, + typographer: true, +}); + +markdown.renderer.rules.link_open = (tokenList, index, options, env, self) => { + tokenList[index].attrJoin("class", "link"); + return self.renderToken(tokenList, index, options); +}; + +markdown.renderer.rules.paragraph_open = ( + tokenList, + index, + options, + env, + self +) => { + const hasAnotherParagraph = tokenList + .slice(index + 1) + .some((token) => token.type === "paragraph_open"); + + tokenList[index].attrSet( + "style", + `margin:${hasAnotherParagraph ? tokens.body.paragraphMargin : "0"};` + ); + + return self.renderToken(tokenList, index, options); +}; + +markdown.renderer.rules.bullet_list_open = ( + tokenList, + index, + options, + env, + self +) => { + tokenList[index].attrSet( + "style", + `margin:${tokens.body.listMargin};padding:${tokens.body.listPadding};` + ); + + return self.renderToken(tokenList, index, options); +}; + +markdown.renderer.rules.list_item_open = ( + tokenList, + index, + options, + env, + self +) => { + const hasAnotherListItem = tokenList + .slice(index + 1) + .some((token) => token.type === "list_item_open"); + + tokenList[index].attrSet( + "style", + `margin:${hasAnotherListItem ? tokens.body.listItemMargin : "0"};` + ); + + return self.renderToken(tokenList, index, options); +}; + +export const renderEmailBodyMarkdown = (value: string) => { + const content = value.trim(); + + if (!content) { + return ""; + } + + return markdown.render(content).trim(); +}; diff --git a/packages/stacks-email/src/lib/mjml/config.ts b/packages/stacks-email/src/lib/mjml/config.ts new file mode 100644 index 0000000000..98854cae62 --- /dev/null +++ b/packages/stacks-email/src/lib/mjml/config.ts @@ -0,0 +1,268 @@ +import { mjmlJsonToString } from "./json"; +import { tokens } from "../tokens"; +import type { MjmlNode } from "../types"; + +const { color, layout, border } = tokens; + +const font = { + family: "Arial, Helvetica, sans-serif", + weightNormal: "400", + weightSemibold: "600", + weightBold: "700", +} as const; + +const attributesChildren: MjmlNode[] = [ + { + tagName: "mj-all", + attributes: { + "font-family": font.family, + "color": color.text, + "font-size": "16px", + "line-height": "120%", + }, + }, + { + tagName: "mj-body", + attributes: { + "width": layout.maxWidth, + "background-color": color.background, + }, + }, + { + tagName: "mj-text", + attributes: { + padding: "0px", + }, + }, + { + tagName: "mj-image", + attributes: { + padding: "0px", + }, + }, + { + tagName: "mj-button", + attributes: { + padding: "0px", + }, + }, + { + tagName: "mj-table", + attributes: { + padding: "0px", + }, + }, + { + tagName: "mj-column", + attributes: { + padding: "0px", + }, + }, + { + tagName: "mj-section", + attributes: { + padding: "0px", + }, + }, + { + tagName: "mj-wrapper", + attributes: { + padding: "0px", + }, + }, + + // Utility classes generated from design-token arrays. + ...color.backgroundClasses.map(({ name, value }) => ({ + tagName: "mj-class", + attributes: { + "name": `bg-${name}`, + "background-color": value, + }, + })), + ...color.fontClasses.map(({ name, value }) => ({ + tagName: "mj-class", + attributes: { + name: `fc-${name}`, + color: value, + }, + })), + + { + tagName: "mj-class", + attributes: { + "name": "s-email-text-title", + "color": color.text, + "font-family": "Stack Sans Notch, Arial, Helvetica, sans-serif", + "font-size": "24px", + "font-weight": font.weightNormal, + "line-height": "100%", + "padding": "0", + }, + }, + { + tagName: "mj-class", + attributes: { + "name": "s-email-text-headline", + "color": color.text, + "font-family": "Stack Sans Notch, Arial, Helvetica, sans-serif", + "font-size": "36px", + "font-weight": font.weightNormal, + "line-height": "100%", + "padding": "0", + }, + }, + + { + tagName: "mj-class", + attributes: { + "name": "s-email-text-subtitle", + "color": color.text, + "font-size": "14px", + "font-weight": font.weightBold, + "line-height": "120%", + "padding": "0", + }, + }, + { + tagName: "mj-class", + attributes: { + "name": "s-email-text-secondary-information", + "color": color.textMuted, + "font-size": "12px", + "font-weight": font.weightNormal, + "line-height": "120%", + "padding": "0", + }, + }, + { + tagName: "mj-class", + attributes: { + "name": "s-email-text-body", + "color": color.text, + "font-size": "16px", + "font-weight": font.weightNormal, + "line-height": "120%", + }, + }, + { + tagName: "mj-class", + attributes: { + "name": "s-email-text-caption", + "color": color.textMuted, + "font-size": "14px", + "font-weight": font.weightNormal, + "line-height": "120%", + }, + }, + { + tagName: "mj-class", + attributes: { + "name": "s-email-text-alert", + "color": color.text, + "font-size": "16px", + "font-weight": font.weightSemibold, + "line-height": "120%", + "padding": "0", + }, + }, + + { + tagName: "mj-class", + attributes: { + "name": "button", + "background-color": color.brandDark, + "color": "#ffffff", + "border-radius": border.radius, + "font-size": "14px", + "font-weight": font.weightBold, + "line-height": "120%", + "inner-padding": `12px 18px`, + }, + }, + { + tagName: "mj-class", + attributes: { + "name": "button__tonal", + "background-color": color.brandOffWhite, + "color": color.text, + }, + }, + { + tagName: "mj-class", + attributes: { + "name": "button__inverted", + "background-color": color.background, + "color": color.text, + }, + }, +]; + +const linkStyles = ` +a.link { + color: ${color.link}; + text-decoration: underline; + font-size: 16px; + line-height: normal; + font-weight: ${font.weightNormal}; +} +a.footer-link { + color: ${color.textFooter}; + text-decoration: underline; + font-size: 14px; + line-height: 120%; + margin-right: 10px; +} +`.trim(); + +// Head-only Progressive enhancement: hover states only apply in clients that support head CSS and :hover. +const hoverStyles = ` +a.link:hover { + color: ${color.linkHover} !important; +} +a.footer-link:hover, +a.footer-link-light:hover, +a.footer-link:hover { + opacity: 0.85 !important; +} +.button-hover a:hover { + background-color: #47484d !important; + color: #fff !important; +} +.button-hover-inverted a:hover { + background-color: ${color.brandOffWhite} !important; +} +`.trim(); + +export const mjmlConfigNodes: MjmlNode[] = [ + { + tagName: "mj-font", + attributes: { + name: "Stack Sans Headline", + href: "https://fonts.googleapis.com/css2?family=Stack+Sans+Headline:wght@400;600&display=swap", + }, + }, + { + tagName: "mj-font", + attributes: { + name: "Stack Sans Notch", + href: "https://fonts.googleapis.com/css2?family=Stack+Sans+Notch:wght@400&display=swap", + }, + }, + { + tagName: "mj-attributes", + children: attributesChildren, + }, + { + tagName: "mj-style", + attributes: { + inline: "inline", + }, + content: linkStyles, + }, + { + tagName: "mj-style", + content: hoverStyles, + }, +]; + +export const mjmlConfig = mjmlJsonToString(mjmlConfigNodes); diff --git a/packages/stacks-email/components/section.ts b/packages/stacks-email/src/lib/mjml/index.ts similarity index 94% rename from packages/stacks-email/components/section.ts rename to packages/stacks-email/src/lib/mjml/index.ts index 30ca234500..c643d3b60b 100644 --- a/packages/stacks-email/components/section.ts +++ b/packages/stacks-email/src/lib/mjml/index.ts @@ -1,6 +1,7 @@ -import type { MjmlNode } from "../types"; import { z } from "zod/v4"; +import type { MjmlNode } from "../types"; + const mjmlAttributeSchema = z.union([z.string(), z.number(), z.boolean()]); const sectionOptionsSchema = z.object({ @@ -17,7 +18,9 @@ export const Section = ( options: SectionOptions = {} ): MjmlNode => { const parsedOptions = sectionOptionsSchema.safeParse(options); - const normalizedOptions = parsedOptions.success ? parsedOptions.data : options; + const normalizedOptions = parsedOptions.success + ? parsedOptions.data + : options; const sectionAttributes: NonNullable = { ...(normalizedOptions.sectionClass diff --git a/packages/stacks-email/src/lib/mjml/json.ts b/packages/stacks-email/src/lib/mjml/json.ts new file mode 100644 index 0000000000..c964be9326 --- /dev/null +++ b/packages/stacks-email/src/lib/mjml/json.ts @@ -0,0 +1,7 @@ +import json2mjml from "json2mjml"; +import type { MjmlNode } from "../types"; + +export const mjmlJsonToString = (source: MjmlNode | MjmlNode[]) => { + const nodes = Array.isArray(source) ? source : [source]; + return nodes.map((node) => json2mjml(node as never)).join("\n"); +}; diff --git a/packages/stacks-email/src/lib/pipeline/compile.ts b/packages/stacks-email/src/lib/pipeline/compile.ts index 141c844be0..f3613e015a 100644 --- a/packages/stacks-email/src/lib/pipeline/compile.ts +++ b/packages/stacks-email/src/lib/pipeline/compile.ts @@ -2,14 +2,16 @@ import mjml2html from "mjml"; import { applyTemplateProps, - extractComponentHtml, + extractBetweenMarkers, extractTagMarkup, + hasMjmlDocument, + injectHeadContent, wrapComponentWithMarkers, wrapTagWithMarkers, } from "./template"; -import { targets, tokens, type CompileTarget } from "../../../tokens"; -import { mjmlConfig } from "../../../mjml-config"; +import { mjmlConfig } from "../mjml/config"; +import { targets, tokens, type CompileTarget } from "../tokens"; import { transformTokens } from "./transform"; type MjmlCompileResult = { @@ -30,11 +32,6 @@ const mjml2htmlSync = mjml2html as unknown as ( } ) => MjmlCompileResult; -const mjmlTagPattern = /]/i; -const mjHeadOpenPattern = //i; -const mjHeadClosePattern = /<\/mj-head>/i; -const mjmlOpenTagPattern = /]*>/i; - const escapePreviewText = (value: string) => value .replaceAll("&", "&") @@ -50,7 +47,10 @@ const buildPreviewHeadNode = (previewText: string | undefined) => { return `${escapePreviewText(normalizedPreviewText)}\n`; }; -const wrapInDocument = (mjmlSource: string, previewText: string | undefined) => ` +const wrapInDocument = ( + mjmlSource: string, + previewText: string | undefined +) => ` ${buildPreviewHeadNode(previewText)}${mjmlConfig} @@ -67,24 +67,12 @@ const injectConfigIntoDocument = ( documentSource: string, previewText: string | undefined ) => { - const previewHeadNode = buildPreviewHeadNode(previewText); - - if (mjHeadOpenPattern.test(documentSource)) { - return documentSource.replace( - mjHeadClosePattern, - `${previewHeadNode}${mjmlConfig}\n` - ); - } - - if (mjmlOpenTagPattern.test(documentSource)) { - return documentSource.replace( - mjmlOpenTagPattern, - (openTag) => - `${openTag}\n${previewHeadNode}${mjmlConfig}` - ); - } + const headContent = `${buildPreviewHeadNode(previewText)}${mjmlConfig}`; - return wrapInDocument(documentSource, previewText); + return ( + injectHeadContent(documentSource, headContent) ?? + wrapInDocument(documentSource, previewText) + ); }; export type CompileMjmlInput = { @@ -128,7 +116,7 @@ export const compileMjml = ({ : wrapComponentWithMarkers(renderedMjml, extractComponentName) : renderedMjml; - const fullMjml = mjmlTagPattern.test(mjmlForCompile) + const fullMjml = hasMjmlDocument(mjmlForCompile) ? injectConfigIntoDocument(mjmlForCompile, previewText) : wrapInDocument(mjmlForCompile, previewText); @@ -147,7 +135,7 @@ export const compileMjml = ({ : targetRenderedMjml.trim() : null; const componentHtml = extractComponentName - ? extractComponentHtml(html, extractComponentName) + ? extractBetweenMarkers(html, extractComponentName) : null; return { diff --git a/packages/stacks-email/src/lib/pipeline/template.ts b/packages/stacks-email/src/lib/pipeline/template.ts index 83abe92c19..38c297967e 100644 --- a/packages/stacks-email/src/lib/pipeline/template.ts +++ b/packages/stacks-email/src/lib/pipeline/template.ts @@ -1,25 +1,152 @@ +import parseMjmlSource from "mjml-parser-xml"; + +import { mjmlJsonToString } from "../mjml/json"; +import type { MjmlNode } from "../types"; + +type ParsedMjmlNode = MjmlNode & { + attributes?: MjmlNode["attributes"]; + children?: ParsedMjmlNode[]; +}; + const markerStart = (name: string) => ``; const markerEnd = (name: string) => ``; -const escapeRegExp = (value: string) => - value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +const isMjmlNode = (value: unknown): value is ParsedMjmlNode => + typeof value === "object" && + value !== null && + "tagName" in value && + typeof (value as { tagName?: unknown }).tagName === "string"; -const buildPairTagPattern = (tagName: string) => - new RegExp( - `(<${tagName}\\b[^>]*>[\\s\\S]*?<\\/${tagName}>)`, - "i" - ); +const parseMjml = (source: string) => { + try { + const parsed = parseMjmlSource(source); -const buildSelfClosingTagPattern = (tagName: string) => - new RegExp(`(<${tagName}\\b[^>]*/>)`, "i"); + return isMjmlNode(parsed) ? parsed : null; + } catch { + return null; + } +}; -const buildMarkerPattern = (name: string) => - new RegExp( - `${escapeRegExp(markerStart(name))}[\\s\\S]*?${escapeRegExp( - markerEnd(name) - )}`, - "i" - ); +const isTag = (node: ParsedMjmlNode, tagName: string) => + node.tagName.toLowerCase() === tagName.toLowerCase(); + +const rawMarkerNode = (content: string): MjmlNode => ({ + tagName: "mj-raw", + content, +}); + +const serializeMjml = (source: MjmlNode | MjmlNode[]) => + mjmlJsonToString(source).trim(); + +const findFirstTag = ( + node: ParsedMjmlNode, + tagName: string +): ParsedMjmlNode | null => { + if (isTag(node, tagName)) { + return node; + } + + for (const child of node.children ?? []) { + const match = findFirstTag(child, tagName); + if (match) { + return match; + } + } + + return null; +}; + +const wrapFirstTagNode = ( + node: ParsedMjmlNode, + name: string, + tagName: string +): { nodes: MjmlNode[]; found: boolean } => { + if (isTag(node, tagName)) { + return { + nodes: [ + rawMarkerNode(markerStart(name)), + node, + rawMarkerNode(markerEnd(name)), + ], + found: true, + }; + } + + let found = false; + const children = (node.children ?? []).flatMap((child) => { + if (found) { + return [child]; + } + + const wrapped = wrapFirstTagNode(child, name, tagName); + found = wrapped.found; + + return wrapped.nodes; + }); + + return { + nodes: [ + { + ...node, + children, + }, + ], + found, + }; +}; + +const parseHeadChildren = (source: string) => { + const head = parseMjml(`${source}`); + + return head?.children ?? []; +}; + +export const hasMjmlDocument = (source: string) => { + const root = parseMjml(source); + + return root ? isTag(root, "mjml") : false; +}; + +export const injectHeadContent = (source: string, headContent: string) => { + const root = parseMjml(source); + if (!root || !isTag(root, "mjml")) { + return null; + } + + const headChildren = parseHeadChildren(headContent); + if (headChildren.length === 0) { + return serializeMjml(root); + } + + const children = root.children ?? []; + const headIndex = children.findIndex((child) => isTag(child, "mj-head")); + const nextChildren = + headIndex === -1 + ? [ + { + tagName: "mj-head", + attributes: {}, + children: headChildren, + }, + ...children, + ] + : children.map((child, index) => + index === headIndex + ? { + ...child, + children: [ + ...(child.children ?? []), + ...headChildren, + ], + } + : child + ); + + return serializeMjml({ + ...root, + children: nextChildren, + }); +}; export const applyTemplateProps = ( source: string, @@ -42,51 +169,42 @@ export const wrapTagWithMarkers = ( name: string, tagName: string ) => { - const pairPattern = buildPairTagPattern(tagName); - const selfClosingPattern = buildSelfClosingTagPattern(tagName); - - if (pairPattern.test(mjml)) { - return mjml.replace( - pairPattern, - `${markerStart(name)}\n$1\n${markerEnd( - name - )}` - ); + const root = parseMjml(mjml); + if (!root) { + return wrapComponentWithMarkers(mjml, name); } - if (selfClosingPattern.test(mjml)) { - return mjml.replace( - selfClosingPattern, - `${markerStart(name)}\n$1\n${markerEnd( - name - )}` - ); - } + const wrapped = wrapFirstTagNode(root, name, tagName); - return wrapComponentWithMarkers(mjml, name); + return wrapped.found + ? serializeMjml(wrapped.nodes) + : wrapComponentWithMarkers(mjml, name); }; export const extractBetweenMarkers = (source: string, name: string) => { - const pattern = buildMarkerPattern(name); - const match = source.match(pattern); + const startMarker = markerStart(name); + const endMarker = markerEnd(name); + const start = source.indexOf(startMarker); + if (start === -1) { + return null; + } - if (!match) { + const contentStart = start + startMarker.length; + const end = source.indexOf(endMarker, contentStart); + if (end === -1) { return null; } - return match[0] - .replace(markerStart(name), "") - .replace(markerEnd(name), "") - .trim(); + return source.slice(contentStart, end).trim(); }; -export const extractComponentHtml = (html: string, name: string) => - extractBetweenMarkers(html, name); - export const extractTagMarkup = (source: string, tagName: string) => { - const pairPattern = buildPairTagPattern(tagName); - const selfClosingPattern = buildSelfClosingTagPattern(tagName); - const match = source.match(pairPattern) ?? source.match(selfClosingPattern); + const root = parseMjml(source); + if (!root) { + return null; + } + + const tag = findFirstTag(root, tagName); - return match?.[0]?.trim() ?? null; + return tag ? serializeMjml(tag) : null; }; diff --git a/packages/stacks-email/src/lib/public/components.ts b/packages/stacks-email/src/lib/public/components.ts deleted file mode 100644 index 742b1f6742..0000000000 --- a/packages/stacks-email/src/lib/public/components.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { mjmlJsonToString } from "../../../mjml-json"; -import { componentDefinitions } from "../../../registry"; -import { renderVariantSource } from "../../../variants"; -import type { CompileTarget } from "../../../tokens"; -import type { - ComponentCategory, - EmailComponentMeta, - ComponentOptionReference, -} from "../../../types"; -import { compileMjml, type CompileMjmlOutput } from "../pipeline/compile"; -import { compileComponentInputSchema } from "./validation"; - -type ExpandedComponentRecord = { - slug: string; - name: string; - description: string; - category: ComponentCategory; - tokens: NonNullable; - options: NonNullable; - source: string; - htmlExtractionTag?: string; -}; - -export type EmailComponentCatalogItem = { - slug: string; - name: string; - description: string; - category: ComponentCategory; - tokens: NonNullable; - options: NonNullable; -}; - -export type CompileComponentInput = { - slug: string; - target: CompileTarget; -}; - -export type CompileComponentOutput = Omit< - CompileMjmlOutput, - "componentHtml" | "componentMjml" -> & { - kind: "component"; - slug: string; - componentHtml: string; - componentMjml: string; - meta: EmailComponentCatalogItem; -}; - -const DEFAULT_COMPONENT_CATEGORY: ComponentCategory = "Primitive"; - -const toLabel = (value: string) => - value - .split(/[-_]/g) - .filter(Boolean) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); - -const expandComponentRecords = (): ExpandedComponentRecord[] => - componentDefinitions - .map((definition) => ({ - meta: definition.meta, - source: mjmlJsonToString(definition.source), - })) - .flatMap((record) => { - const { meta } = record; - - return meta.variants.map((variant, index) => { - const isDefault = - variant.id === meta.defaultVariant || - (!meta.defaultVariant && index === 0); - const slug = isDefault - ? meta.slug - : `${meta.slug}-${variant.id}`; - const baseName = meta.name ?? toLabel(meta.slug); - const baseDescription = meta.description ?? ""; - const variantName = variant.name ?? toLabel(variant.id); - const variantDescription = variant.description ?? baseDescription; - - return { - slug, - name: - meta.variants.length > 1 - ? `${baseName} — ${variantName}` - : baseName, - description: - meta.variants.length > 1 - ? variantDescription - : baseDescription, - category: meta.category ?? DEFAULT_COMPONENT_CATEGORY, - tokens: meta.tokens ?? [], - options: meta.options ?? [], - source: renderVariantSource(record, variant), - htmlExtractionTag: meta.htmlExtraction?.targetTag, - }; - }); - }) - .sort((a, b) => a.name.localeCompare(b.name)); - -const expandedComponentRecords = expandComponentRecords(); - -const componentBySlug = new Map( - expandedComponentRecords.map((record) => [record.slug, record] as const) -); - -const toCatalogItem = ( - record: ExpandedComponentRecord -): EmailComponentCatalogItem => ({ - slug: record.slug, - name: record.name, - description: record.description, - category: record.category, - tokens: record.tokens, - options: record.options, -}); - -export const listEmailComponents = () => - expandedComponentRecords.map((record) => toCatalogItem(record)); - -export const getEmailComponentMeta = (slug: string) => { - const record = componentBySlug.get(slug); - if (!record) { - return null; - } - return toCatalogItem(record); -}; - -export const getEmailComponentOptions = ( - slug: string -): ComponentOptionReference[] | null => { - const record = componentBySlug.get(slug); - if (!record) { - return null; - } - - return record.options; -}; - -export const compileEmailComponent = ({ - slug, - target, -}: CompileComponentInput): CompileComponentOutput => { - const parsedInput = compileComponentInputSchema.parse({ slug, target }); - const record = componentBySlug.get(parsedInput.slug); - - if (!record) { - throw new Error(`Unknown email component slug: ${parsedInput.slug}`); - } - - const result = compileMjml({ - mjml: record.source, - target: parsedInput.target, - props: {}, - extractComponentName: record.slug, - extractComponentTag: record.htmlExtractionTag, - }); - - return { - ...result, - kind: "component", - slug: parsedInput.slug, - componentHtml: result.componentHtml ?? result.html, - componentMjml: result.componentMjml ?? result.renderedMjml, - meta: toCatalogItem(record), - }; -}; diff --git a/packages/stacks-email/src/lib/public/index.ts b/packages/stacks-email/src/lib/public/index.ts deleted file mode 100644 index f201277498..0000000000 --- a/packages/stacks-email/src/lib/public/index.ts +++ /dev/null @@ -1,53 +0,0 @@ -export { - tokens, - targets, - targetNames, - isCompileTarget, - type Tokens, - type CompileTarget, -} from "../../../tokens"; -export { - transformTokens, -} from "../pipeline/transform"; -export { - compileMjml, - type CompileMjmlInput, - type CompileMjmlOutput, -} from "../pipeline/compile"; - -export { - listEmailComponents, - getEmailComponentMeta, - getEmailComponentOptions, - compileEmailComponent, - type EmailComponentCatalogItem, - type CompileComponentInput, - type CompileComponentOutput, -} from "./components"; - -export { - listEmailTemplates, - getEmailTemplateMeta, - compileEmailTemplate, - type EmailTemplateCatalogItem, - type CompileTemplateInput, - type CompileTemplateOutput, -} from "./templates"; - -export { getEmailCatalog, type EmailCatalog } from "./catalog"; - -export { - compileEmailRenderable, - getEmailRenderableMeta, - type EmailRenderableKind, - type CompileEmailRenderableInput, - type CompileEmailRenderableOutput, -} from "./compile"; - -export { - compileTargetSchema, - emailRenderableKindSchema, - compileComponentInputSchema, - compileTemplateInputSchema, - compileEmailRenderableInputSchema, -} from "./validation"; diff --git a/packages/stacks-email/src/lib/public/templates.ts b/packages/stacks-email/src/lib/public/templates.ts deleted file mode 100644 index 672f220ecb..0000000000 --- a/packages/stacks-email/src/lib/public/templates.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { mjmlJsonToString } from "../../../mjml-json"; -import { - BODY_LIST_ITEM_MARGIN, - BODY_LIST_MARGIN, - BODY_LIST_PADDING, - BODY_PARAGRAPH_MARGIN, -} from "../../../mjml-config"; -import { templateDefinitions } from "../../../registry"; -import { renderTemplateVariantSource } from "../../../variants"; -import type { CompileTarget } from "../../../tokens"; -import type { - EmailTemplateCategory, - EmailTemplateMeta, - EmailTemplateModule, - EmailTemplateVariant, -} from "../../../types"; -import MarkdownIt from "markdown-it"; -import { compileMjml, type CompileMjmlOutput } from "../pipeline/compile"; -import { compileTemplateInputSchema } from "./validation"; - -type ExpandedTemplateRecord = { - slug: string; - name: string; - description: string; - category: EmailTemplateCategory; - tokens: NonNullable; - variantProps: Record; - variant: EmailTemplateVariant; - template: EmailTemplateModule; -}; - -export type EmailTemplateCatalogItem = { - slug: string; - name: string; - description: string; - category: EmailTemplateCategory; - tokens: NonNullable; -}; - -export type CompileTemplateInput = { - slug: string; - target: CompileTarget; - props?: Record; -}; - -export type CompileTemplateOutput = CompileMjmlOutput & { - kind: "template"; - slug: string; - meta: EmailTemplateCatalogItem; -}; - -const DEFAULT_TEMPLATE_CATEGORY: EmailTemplateCategory = "Transactional"; -const DEFAULT_TEMPLATE_PREVIEW_TEXT = "Stack Overflow update"; - -const TEMPLATE_PREVIEW_TOKEN: NonNullable[number] = { - token: "PREVIEW_TEXT", - description: - "Inbox preheader text inserted into `` for all template compiles.", -}; - -const withSharedTemplateTokens = ( - tokens: NonNullable -) => { - const uniqueByToken = new Map(); - - for (const token of [TEMPLATE_PREVIEW_TOKEN, ...tokens]) { - if (!uniqueByToken.has(token.token)) { - uniqueByToken.set(token.token, token); - } - } - - return [...uniqueByToken.values()]; -}; - -const markdown = new MarkdownIt({ - html: false, - breaks: true, - linkify: true, - typographer: true, -}); - -markdown.renderer.rules.link_open = (tokenList, index, options, env, self) => { - tokenList[index].attrJoin("class", "link"); - return self.renderToken(tokenList, index, options); -}; - -markdown.renderer.rules.paragraph_open = ( - tokenList, - index, - options, - env, - self -) => { - const hasAnotherParagraph = tokenList - .slice(index + 1) - .some((token) => token.type === "paragraph_open"); - - tokenList[index].attrSet( - "style", - `margin:${hasAnotherParagraph ? BODY_PARAGRAPH_MARGIN : "0"};` - ); - - return self.renderToken(tokenList, index, options); -}; - -markdown.renderer.rules.bullet_list_open = ( - tokenList, - index, - options, - env, - self -) => { - tokenList[index].attrSet( - "style", - `margin:${BODY_LIST_MARGIN};padding:${BODY_LIST_PADDING};` - ); - - return self.renderToken(tokenList, index, options); -}; - -markdown.renderer.rules.list_item_open = ( - tokenList, - index, - options, - env, - self -) => { - const hasAnotherListItem = tokenList - .slice(index + 1) - .some((token) => token.type === "list_item_open"); - - tokenList[index].attrSet( - "style", - `margin:${hasAnotherListItem ? BODY_LIST_ITEM_MARGIN : "0"};` - ); - - return self.renderToken(tokenList, index, options); -}; - -const renderBodyMarkdown = (value: string) => { - const content = value.trim(); - - if (!content) { - return ""; - } - - return markdown.render(content).trim(); -}; - -const resolveTemplateCompileProps = ( - record: ExpandedTemplateRecord, - inputProps: Record -) => { - const compileProps = { ...inputProps }; - const defaultBodyMarkdown = record.variantProps.BODY_DEFAULT_MARKDOWN; - - if (typeof compileProps.BODY_MARKDOWN === "string") { - compileProps.BODY_CONTENT = renderBodyMarkdown(compileProps.BODY_MARKDOWN); - } else if (typeof compileProps.BODY_CONTENT !== "string") { - compileProps.BODY_CONTENT = renderBodyMarkdown(defaultBodyMarkdown ?? ""); - } - - if (typeof compileProps.PREVIEW_TEXT !== "string") { - compileProps.PREVIEW_TEXT = - record.description.trim() || - record.name.trim() || - DEFAULT_TEMPLATE_PREVIEW_TEXT; - } - - return compileProps; -}; - -const renderTemplateSource = ( - record: ExpandedTemplateRecord, - compileProps: Record -) => { - const variant: EmailTemplateVariant = { - ...record.variant, - props: { - ...record.variant.props, - ...compileProps, - }, - }; - const source = mjmlJsonToString(record.template.document(variant)); - return renderTemplateVariantSource( - { - meta: record.template.meta, - source, - }, - variant - ); -}; - -const toLabel = (value: string) => - value - .split(/[-_]/g) - .filter(Boolean) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); - -const expandTemplateRecords = (): ExpandedTemplateRecord[] => - templateDefinitions - .flatMap((definition) => { - const { meta } = definition; - return meta.variants.map((variant, index) => { - const isDefault = - variant.id === meta.defaultVariant || - (!meta.defaultVariant && index === 0); - const slug = isDefault - ? meta.slug - : `${meta.slug}-${variant.id}`; - const baseName = meta.name ?? toLabel(meta.slug); - const baseDescription = meta.description ?? ""; - const variantName = variant.name ?? toLabel(variant.id); - const variantDescription = variant.description ?? baseDescription; - - return { - slug, - name: - meta.variants.length > 1 - ? variantName - : baseName, - description: - meta.variants.length > 1 - ? variantDescription - : baseDescription, - category: meta.category ?? DEFAULT_TEMPLATE_CATEGORY, - tokens: meta.tokens ?? [], - variantProps: variant.props, - variant, - template: definition, - }; - }); - }) - .sort((a, b) => a.name.localeCompare(b.name)); - -const expandedTemplateRecords = expandTemplateRecords(); - -const templateBySlug = new Map( - expandedTemplateRecords.map((record) => [record.slug, record] as const) -); - -const toCatalogItem = ( - record: ExpandedTemplateRecord -): EmailTemplateCatalogItem => ({ - slug: record.slug, - name: record.name, - description: record.description, - category: record.category, - tokens: withSharedTemplateTokens(record.tokens), -}); - -export const listEmailTemplates = () => - expandedTemplateRecords.map((record) => toCatalogItem(record)); - -export const getEmailTemplateMeta = (slug: string) => { - const record = templateBySlug.get(slug); - if (!record) { - return null; - } - return toCatalogItem(record); -}; - -export const compileEmailTemplate = ({ - slug, - target, - props = {}, -}: CompileTemplateInput): CompileTemplateOutput => { - const parsedInput = compileTemplateInputSchema.parse({ slug, target, props }); - const record = templateBySlug.get(parsedInput.slug); - - if (!record) { - throw new Error(`Unknown email template slug: ${parsedInput.slug}`); - } - - const compileProps = resolveTemplateCompileProps(record, parsedInput.props ?? {}); - const source = renderTemplateSource(record, compileProps); - const result = compileMjml({ - mjml: source, - target: parsedInput.target, - props: {}, - previewText: compileProps.PREVIEW_TEXT, - }); - - return { - ...result, - kind: "template", - slug: parsedInput.slug, - meta: toCatalogItem(record), - }; -}; diff --git a/packages/stacks-email/src/lib/registry.ts b/packages/stacks-email/src/lib/registry.ts new file mode 100644 index 0000000000..bb7d279a24 --- /dev/null +++ b/packages/stacks-email/src/lib/registry.ts @@ -0,0 +1,35 @@ +import button from "../../components/button"; +import footer from "../../components/footer"; +import graphic from "../../components/graphic"; +import headline from "../../components/headline"; +import header from "../../components/header"; +import spacer from "../../components/spacer"; +import text from "../../components/text"; +import title from "../../components/title"; + +import transactional from "../../templates/transactional"; + +export const componentDefinitions = [ + button, + footer, + graphic, + headline, + header, + spacer, + text, + title, +] as const; + +export const templateDefinitions = [transactional] as const; + +export { + button, + footer, + graphic, + headline, + header, + spacer, + text, + title, + transactional, +}; diff --git a/packages/stacks-email/src/lib/schema/component.ts b/packages/stacks-email/src/lib/schema/component.ts new file mode 100644 index 0000000000..fff66e73bd --- /dev/null +++ b/packages/stacks-email/src/lib/schema/component.ts @@ -0,0 +1,121 @@ +import { z } from "zod/v4"; + +import type { + EmailComponentMeta, + EmailTokenReference, + MjmlNode, +} from "../types"; +import { getSchemaOptionRows } from "./metadata"; +import { + getDefinedEntries, + getSchemaDefaults, + normalizeEmailOptions, + stringifyVariantProps, +} from "./normalize"; + +type ComponentVariantMap> = Record< + string, + Partial +>; + +export type EmailComponentDefinition< + TOptions extends Record = Record, + TReturn extends MjmlNode | MjmlNode[] = MjmlNode | MjmlNode[], +> = { + slug: string; + name?: string; + description?: string; + category?: string; + defaultVariant: string; + variants: ComponentVariantMap; + variantOption?: { description: string }; + tokens?: EmailTokenReference[]; + htmlExtraction?: EmailComponentMeta["htmlExtraction"]; + render: (input: { variant: string; options: TOptions }) => TReturn; + meta: EmailComponentMeta; + optionsSchema: z.ZodObject; + component: (variant: string, options?: Partial) => TReturn; +}; + +export const defineEmailComponent = < + TOptionsSchema extends z.ZodObject, + TVariants extends ComponentVariantMap< + z.output & Record + >, + TReturn extends MjmlNode | MjmlNode[], +>(definition: { + slug: string; + name?: string; + description?: string; + category?: string; + defaultVariant?: string; + variants?: TVariants; + optionsSchema: TOptionsSchema; + variantOption?: { description: string }; + tokens?: EmailTokenReference[]; + htmlExtraction?: EmailComponentMeta["htmlExtraction"]; + render: (input: { + variant: string; + options: z.output & Record; + }) => TReturn; +}): EmailComponentDefinition< + z.output & Record, + TReturn +> => { + type TOptions = z.output & Record; + const { optionsSchema } = definition; + const schemaDefaults = getSchemaDefaults(optionsSchema); + const defaultVariant = definition.defaultVariant ?? "default"; + const variants: ComponentVariantMap = { + [defaultVariant]: {}, + ...(definition.variants ?? {}), + }; + + const getVariantDefaults = (variant: Partial): TOptions => + ({ ...schemaDefaults, ...getDefinedEntries(variant) }) as TOptions; + + const meta: EmailComponentMeta = { + slug: definition.slug, + name: definition.name, + description: definition.description, + category: definition.category, + defaultVariant, + variants: Object.entries(variants).map(([id, defaults]) => ({ + id, + props: stringifyVariantProps(getVariantDefaults(defaults)), + })), + tokens: definition.tokens, + options: [ + { + argument: "variant", + type: Object.keys(variants) + .map((variant) => `"${variant}"`) + .join(" | "), + description: + definition.variantOption?.description ?? + "Selects the variant.", + }, + ...getSchemaOptionRows(optionsSchema), + ], + htmlExtraction: definition.htmlExtraction, + }; + + const component = (variant: string, options: Partial = {}) => { + const variantDefaults = + variants[variant] ?? variants[defaultVariant] ?? variants.default; + const normalizedOptions = normalizeEmailOptions( + optionsSchema, + getVariantDefaults(variantDefaults), + options + ); + return definition.render({ variant, options: normalizedOptions }); + }; + + return { + ...definition, + defaultVariant, + variants, + meta, + component, + }; +}; diff --git a/packages/stacks-email/src/lib/schema/index.ts b/packages/stacks-email/src/lib/schema/index.ts new file mode 100644 index 0000000000..18c6813ce7 --- /dev/null +++ b/packages/stacks-email/src/lib/schema/index.ts @@ -0,0 +1,8 @@ +export { emailOption, getSchemaOptionRows } from "./metadata"; +export { defineOption, defineOptions, mjmlAlignOptions } from "./options"; +export { normalizeEmailOptions } from "./normalize"; +export { + defineEmailComponent, + type EmailComponentDefinition, +} from "./component"; +export { defineEmailTemplate, type EmailTemplateDefinition } from "./template"; diff --git a/packages/stacks-email/src/lib/schema/metadata.ts b/packages/stacks-email/src/lib/schema/metadata.ts new file mode 100644 index 0000000000..e3bb322ce1 --- /dev/null +++ b/packages/stacks-email/src/lib/schema/metadata.ts @@ -0,0 +1,40 @@ +import { z } from "zod/v4"; + +import type { ComponentOptionReference } from "../types"; + +export type EmailOptionMetadata = { + type: string; + description: string; + defaultValue?: string; + renderDefaultValueAsCode?: boolean; +}; + +export const emailOption = ( + schema: TSchema, + metadata: EmailOptionMetadata +): TSchema => schema.meta({ emailOption: metadata }) as TSchema; + +const getOptionMetadata = ( + schema: z.ZodType +): EmailOptionMetadata | undefined => + (schema.meta() as { emailOption?: EmailOptionMetadata } | undefined) + ?.emailOption; + +export const getSchemaOptionRows = ( + schema: z.ZodObject +): ComponentOptionReference[] => + Object.entries(schema.shape as Record).flatMap( + ([key, fieldSchema]) => { + const metadata = getOptionMetadata(fieldSchema); + if (!metadata) return []; + return [ + { + argument: `options.${key}`, + type: metadata.type, + defaultValue: metadata.defaultValue, + renderDefaultValueAsCode: metadata.renderDefaultValueAsCode, + description: metadata.description, + }, + ]; + } + ); diff --git a/packages/stacks-email/src/lib/schema/normalize.ts b/packages/stacks-email/src/lib/schema/normalize.ts new file mode 100644 index 0000000000..065e25678c --- /dev/null +++ b/packages/stacks-email/src/lib/schema/normalize.ts @@ -0,0 +1,38 @@ +import { z } from "zod/v4"; + +import type { VariantProps } from "../types"; + +export const stringifyVariantProps = ( + defaults: Record +): VariantProps => + Object.fromEntries( + Object.entries(defaults) + .filter(([, value]) => value !== undefined && value !== null) + .map(([key, value]) => [key, String(value)]) + ); + +export const getDefinedEntries = >( + values: Partial +): Partial => + Object.fromEntries( + Object.entries(values).filter(([, value]) => value !== undefined) + ) as Partial; + +export const getSchemaDefaults = >( + schema: z.ZodObject +): Partial => { + const parsed = schema.safeParse({}); + return parsed.success + ? getDefinedEntries(parsed.data as Partial) + : ({} as Partial); +}; + +export const normalizeEmailOptions = >( + schema: z.ZodObject, + defaults: Partial, + options: Partial = {} +): TOptions => + schema.parse({ + ...getDefinedEntries(defaults), + ...getDefinedEntries(options), + }) as TOptions; diff --git a/packages/stacks-email/src/lib/schema/options.ts b/packages/stacks-email/src/lib/schema/options.ts new file mode 100644 index 0000000000..697fa2d06a --- /dev/null +++ b/packages/stacks-email/src/lib/schema/options.ts @@ -0,0 +1,126 @@ +import { z } from "zod/v4"; + +import { emailOption } from "./metadata"; + +export const mjmlAlignOptions = ["left", "center", "right"] as const; + +type PrimitiveType = "string" | "boolean"; +type EnumValues = readonly [string, ...string[]]; +type PrimitiveValue = T extends "boolean" + ? boolean + : string; + +type OptionOutput< + TType extends PrimitiveType, + TOptional, + TInitialValue, +> = TInitialValue extends PrimitiveValue + ? PrimitiveValue + : TOptional extends true + ? PrimitiveValue | undefined + : PrimitiveValue; + +type EnumOutput< + TValues extends EnumValues, + TOptional, + TInitialValue, +> = TInitialValue extends TValues[number] + ? TValues[number] + : TOptional extends true + ? TValues[number] | undefined + : TValues[number]; + +type OptionDefinition = { + name: TName; + schema: z.ZodType; +}; + +export function defineOption< + const TName extends string, + const TType extends PrimitiveType, + const TOptional extends boolean | undefined = undefined, + const TInitialValue extends PrimitiveValue | undefined = undefined, +>(input: { + name: TName; + type: TType; + initialValue?: TInitialValue; + optional?: TOptional; + description: string; +}): OptionDefinition>; +export function defineOption< + const TName extends string, + const TValues extends EnumValues, + const TOptional extends boolean | undefined = undefined, + const TInitialValue extends TValues[number] | undefined = undefined, +>(input: { + name: TName; + type: "enum"; + values: TValues; + initialValue?: TInitialValue; + optional?: TOptional; + description: string; +}): OptionDefinition>; + +export function defineOption(input: { + name: string; + type: PrimitiveType | "enum"; + values?: EnumValues; + initialValue?: string | boolean; + optional?: boolean; + description: string; +}): OptionDefinition { + const base: z.ZodType = + input.type === "boolean" + ? z.boolean() + : input.type === "enum" + ? z.enum(input.values as EnumValues) + : z.string(); + const metadataType = + input.type === "enum" + ? (input.values as EnumValues) + .map((value) => `"${value}"`) + .join(" | ") + : input.type; + + const schema = + input.initialValue !== undefined + ? (base as z.ZodAny).default(input.initialValue) + : input.optional + ? base.optional() + : base; + + return { + name: input.name, + schema: emailOption(schema, { + type: metadataType, + defaultValue: + input.initialValue === undefined + ? undefined + : String(input.initialValue), + renderDefaultValueAsCode: + input.initialValue === undefined ? undefined : true, + description: input.description, + }), + }; +} + +export const defineOptions = < + const TDefinitions extends readonly OptionDefinition[], +>( + definitions: TDefinitions +) => + z.object( + Object.fromEntries( + definitions.map((definition) => [ + definition.name, + definition.schema, + ]) + ) as { + [K in TDefinitions[number] as K["name"]]: K extends OptionDefinition< + K["name"], + infer TOutput + > + ? z.ZodType + : never; + } + ); diff --git a/packages/stacks-email/src/lib/schema/template.ts b/packages/stacks-email/src/lib/schema/template.ts new file mode 100644 index 0000000000..0d67537650 --- /dev/null +++ b/packages/stacks-email/src/lib/schema/template.ts @@ -0,0 +1,99 @@ +import { z } from "zod/v4"; + +import type { + EmailTemplateMeta, + EmailTokenReference, + MjmlNode, +} from "../types"; +import { + getDefinedEntries, + getSchemaDefaults, + stringifyVariantProps, +} from "./normalize"; + +type TemplateVariantMap> = Record< + string, + Partial +>; + +export type EmailTemplateDefinition< + TProps extends Record = Record, +> = { + slug: string; + name?: string; + description?: string; + category?: string; + defaultVariant: string; + variants: TemplateVariantMap; + propsSchema: z.ZodObject; + tokens?: EmailTokenReference[]; + preview?: (input: { variant: string; props: TProps }) => { + name?: string; + description?: string; + previewText?: string; + }; + renderDocument: (input: { variant: string; props: TProps }) => MjmlNode; + meta: EmailTemplateMeta; +}; + +export const defineEmailTemplate = < + TPropsSchema extends z.ZodObject, + TVariants extends TemplateVariantMap< + z.output & Record + >, +>(definition: { + slug: string; + name?: string; + description?: string; + category?: string; + defaultVariant?: string; + variants?: TVariants; + propsSchema: TPropsSchema; + tokens?: EmailTokenReference[]; + preview?: (input: { + variant: string; + props: z.output & Record; + }) => { + name?: string; + description?: string; + previewText?: string; + }; + renderDocument: (input: { + variant: string; + props: z.output & Record; + }) => MjmlNode; +}): EmailTemplateDefinition< + z.output & Record +> => { + type TProps = z.output & Record; + const { propsSchema } = definition; + const schemaDefaults = getSchemaDefaults(propsSchema); + const defaultVariant = definition.defaultVariant ?? "default"; + const variants: TemplateVariantMap = { + [defaultVariant]: {}, + ...(definition.variants ?? {}), + }; + + const getVariantDefaults = (variant: Partial): TProps => + ({ ...schemaDefaults, ...getDefinedEntries(variant) }) as TProps; + + const meta: EmailTemplateMeta = { + slug: definition.slug, + name: definition.name, + description: definition.description, + category: definition.category, + defaultVariant, + variants: Object.entries(variants).map(([id, defaults]) => ({ + id, + props: stringifyVariantProps(getVariantDefaults(defaults)), + })), + tokens: definition.tokens, + }; + + return { + ...definition, + defaultVariant, + variants, + meta, + }; +}; diff --git a/packages/stacks-email/src/lib/sveltekit/index.ts b/packages/stacks-email/src/lib/sveltekit/index.ts new file mode 100644 index 0000000000..d2b225a78b --- /dev/null +++ b/packages/stacks-email/src/lib/sveltekit/index.ts @@ -0,0 +1,46 @@ +import { error, json } from "@sveltejs/kit"; + +import { + getStaticEmailArtifact, + getStaticEmailArtifactEntries, + getStaticEmailManifest, +} from "../api/static-artifacts"; + +type StaticEmailArtifactRouteEvent = { + params: { + kind: string; + slug: string; + file: string; + }; +}; + +export const staticEmailCompiledManifestGET = () => + json(getStaticEmailManifest()); + +export const staticEmailCompiledArtifactEntries = () => + getStaticEmailArtifactEntries(); + +export const staticEmailCompiledArtifactGET = ({ + params, +}: StaticEmailArtifactRouteEvent) => { + try { + const artifact = getStaticEmailArtifact( + params.kind, + params.slug, + params.file + ); + + return new Response(artifact.contents, { + headers: { + "content-type": artifact.contentType, + }, + }); + } catch (reason) { + throw error( + 404, + reason instanceof Error + ? reason.message + : "Unknown static email artifact." + ); + } +}; diff --git a/packages/stacks-email/tokens.ts b/packages/stacks-email/src/lib/tokens.ts similarity index 83% rename from packages/stacks-email/tokens.ts rename to packages/stacks-email/src/lib/tokens.ts index d8be5db718..352e2cd6f0 100644 --- a/packages/stacks-email/tokens.ts +++ b/packages/stacks-email/src/lib/tokens.ts @@ -40,19 +40,13 @@ const fontClasses = [ ] as const; /** - * Typography tokens for all email copy scales and weights. + * Body content rhythm tokens for generated HTML copy. */ -const font = { - family: "Arial, Helvetica, sans-serif", - sizeBase: "16px", - sizeSm: "14px", - sizeLg: "20px", - sizeXl: "24px", - sizeXxl: "32px", - weightNormal: "400", - weightBold: "700", - lineHeightBase: "1.5", - lineHeightTight: "1.25", +const body = { + paragraphMargin: "0 0 16px", + listMargin: "0 0 16px 24px", + listPadding: "0", + listItemMargin: "0 0 8px", } as const; /** @@ -91,16 +85,33 @@ const border = { sectionDivider: `1px solid ${color.border}`, } as const; +/** + * Canonical Stack Overflow destinations used by shared email components. + */ +const links = { + emailSettings: "https://stackoverflow.com/users/email/settings/current", + contact: "https://stackoverflow.com/company/contact", + privacy: "https://stackoverflow.com/legal/privacy-policy", + social: { + linkedin: "https://linkedin.com/company/stack-overflow/", + x: "https://x.com/stackoverflow/", + threads: "https://www.threads.net/@thestackoverflow", + instagram: "https://www.instagram.com/thestackoverflow/", + youtube: "https://www.youtube.com/c/StackOverflowOfficial", + }, +} as const; + export const tokens = { color: { ...color, backgroundClasses, fontClasses, }, - font, + body, spacing, layout, border, + links, } as const; export type Tokens = typeof tokens; diff --git a/packages/stacks-email/src/lib/types.ts b/packages/stacks-email/src/lib/types.ts new file mode 100644 index 0000000000..b0a21c31d8 --- /dev/null +++ b/packages/stacks-email/src/lib/types.ts @@ -0,0 +1,54 @@ +export type MjmlAttributeValue = string | number | boolean; + +export type MjmlNode = { + tagName: string; + attributes?: Record; + children?: MjmlNode[]; + content?: string; +}; + +export type VariantProps = Record; + +export type EmailVariant = { + id: string; + name?: string; + description?: string; + props: VariantProps; +}; + +export type EmailTokenReference = { + token: string; + description: string; +}; + +export type ComponentOptionReference = { + argument: string; + type: string; + defaultValue?: string; + renderDefaultValueAsCode?: boolean; + description: string; +}; + +export type EmailComponentMeta = { + slug: string; + name?: string; + description?: string; + category?: string; + variants: EmailVariant[]; + defaultVariant?: string; + tokens?: EmailTokenReference[]; + options?: ComponentOptionReference[]; + htmlExtraction?: { + targetTag: string; + }; +}; + +export type EmailTemplateMeta = { + slug: string; + name?: string; + description?: string; + category?: string; + variants: EmailVariant[]; + defaultVariant?: string; + tokens?: EmailTokenReference[]; +}; diff --git a/packages/stacks-email/src/routes/+page.server.ts b/packages/stacks-email/src/routes/+page.server.ts index 77ebd7fcf8..973d98b540 100644 --- a/packages/stacks-email/src/routes/+page.server.ts +++ b/packages/stacks-email/src/routes/+page.server.ts @@ -1,6 +1,6 @@ import type { PageServerLoad } from "./$types"; -import { listEmailTemplates } from "$lib/public/templates"; +import { listEmailTemplates } from "$lib/api/templates"; export const load: PageServerLoad = async () => ({ templates: listEmailTemplates(), diff --git a/packages/stacks-email/src/routes/+page.svelte b/packages/stacks-email/src/routes/+page.svelte index d0f01956ea..3b14b0cf94 100644 --- a/packages/stacks-email/src/routes/+page.svelte +++ b/packages/stacks-email/src/routes/+page.svelte @@ -2,7 +2,7 @@ import { Icon } from "@stackoverflow/stacks-svelte"; import { SpotPuzzle } from "@stackoverflow/stacks-icons"; - import TemplateSidebar from "../components/TemplateSidebar.svelte"; + import TemplateSidebar from "../ui/TemplateSidebar.svelte"; let { data }: { data: import("./$types").PageData } = $props(); diff --git a/packages/stacks-email/src/routes/api/compile/+server.ts b/packages/stacks-email/src/routes/api/compile/+server.ts index 83b355f982..b6f4fd43a6 100644 --- a/packages/stacks-email/src/routes/api/compile/+server.ts +++ b/packages/stacks-email/src/routes/api/compile/+server.ts @@ -4,96 +4,143 @@ import { z } from "zod/v4"; import { env } from "$env/dynamic/private"; import { compileMjml } from "$lib/pipeline/compile"; -import { compileTargetSchema } from "$lib/public/validation"; -import { Button } from "../../../../components/button"; -import { Footer } from "../../../../components/footer"; -import { Headline } from "../../../../components/headline"; -import { Header } from "../../../../components/header"; -import { Text } from "../../../../components/text"; -import { Title } from "../../../../components/title"; -import { Section } from "../../../../components/section"; -import { Spacer } from "../../../../components/spacer"; -import { mjmlJsonToString } from "../../../../mjml-json"; -import { tokens } from "../../../../tokens"; -import type { MjmlNode } from "../../../../types"; - -const headlineBlockSchema = z.object({ - type: z.literal("headline"), - variant: z.enum(["default", "highlight"]).optional().default("default"), - props: z - .object({ - sectionClass: z.string().optional(), - textClass: z.string().optional(), - textAlign: z.string().optional(), - textContent: z.string().optional(), - textHighlight: z.union([z.boolean(), z.string()]).optional(), - }) - .optional(), -}); +import { compileTargetSchema } from "$lib/api/request-schemas"; +import button from "../../../../components/button"; +import footer from "../../../../components/footer"; +import headline from "../../../../components/headline"; +import header from "../../../../components/header"; +import spacer from "../../../../components/spacer"; +import text from "../../../../components/text"; +import title from "../../../../components/title"; +import { Section } from "$lib/mjml"; +import { mjmlJsonToString } from "$lib/mjml/json"; +import { tokens } from "$lib/tokens"; +import type { EmailComponentMeta, MjmlNode } from "$lib/types"; -const textBlockSchema = z.object({ - type: z.literal("text"), - variant: z.enum(["body", "centered"]).optional().default("body"), - props: z - .object({ - columnClass: z.string().optional(), - sectionClass: z.string().optional(), - textAlign: z.string().optional(), - textClass: z.string().optional(), - textContent: z.string().optional(), - }) - .optional(), -}); +const getVariantIds = (meta: EmailComponentMeta) => + meta.variants.map((variant) => variant.id) as [string, ...string[]]; -const buttonBlockSchema = z.object({ - type: z.literal("button"), - variant: z - .enum(["primary", "secondary", "inverted"]) +const componentVariantSchema = (meta: EmailComponentMeta) => + z + .enum(getVariantIds(meta)) .optional() - .default("primary"), - props: z - .object({ - sectionClass: z.string().optional(), - align: z.string().optional(), - className: z.string().optional(), - cssClass: z.string().optional(), - href: z.string().optional(), - text: z.string().optional(), - }) - .optional(), -}); + .default(meta.defaultVariant ?? meta.variants[0].id); -const titleBlockSchema = z.object({ - type: z.literal("title"), - variant: z.enum(["default", "invert"]).optional().default("default"), - props: z - .object({ - sectionClass: z.string().optional(), - textClass: z.string().optional(), - textAlign: z.string().optional(), - textContent: z.string().optional(), - }) - .optional(), -}); +type RenderableBlock = { + variant?: string; + size?: string; + props?: unknown; +}; -const spacerBlockSchema = z.object({ - type: z.literal("spacer"), - size: z.enum(["medium", "large"]).optional().default("medium"), - props: z - .object({ - sectionClass: z.string().optional(), - height: z.string().optional(), - }) - .optional(), -}); +type BlockDefinition = { + type: string; + schema: z.ZodObject; + render: (block: RenderableBlock) => MjmlNode; +}; + +const blockDefinitions = { + headline: { + type: "headline", + schema: z.object({ + type: z.literal("headline"), + variant: componentVariantSchema(headline.meta), + props: headline.optionsSchema.optional(), + }), + render: (block) => + headline.component( + block.variant as keyof typeof headline.variants & string, + (block.props ?? {}) as z.output + ) as MjmlNode, + }, + text: { + type: "text", + schema: z.object({ + type: z.literal("text"), + variant: componentVariantSchema(text.meta), + props: text.optionsSchema.optional(), + }), + render: (block) => + text.component( + block.variant as keyof typeof text.variants & string, + (block.props ?? {}) as z.output + ) as MjmlNode, + }, + button: { + type: "button", + schema: z.object({ + type: z.literal("button"), + variant: componentVariantSchema(button.meta), + props: button.optionsSchema + .extend({ + sectionClass: z.string().optional(), + }) + .optional(), + }), + render: (block) => { + const props = (block.props ?? {}) as z.output< + typeof button.optionsSchema + > & { + sectionClass?: string; + }; + const sectionClass = props.sectionClass ?? "bg-block"; + const buttonProps: z.output = { + align: props.align, + className: props.className, + cssClass: props.cssClass, + href: props.href, + text: props.text, + }; + return Section( + [ + button.component( + block.variant as keyof typeof button.variants & string, + buttonProps + ) as MjmlNode, + ], + { + sectionClass, + } + ); + }, + }, + title: { + type: "title", + schema: z.object({ + type: z.literal("title"), + variant: componentVariantSchema(title.meta), + props: title.optionsSchema.optional(), + }), + render: (block) => + title.component( + block.variant as keyof typeof title.variants & string, + (block.props ?? {}) as z.output + ) as MjmlNode, + }, + spacer: { + type: "spacer", + schema: z.object({ + type: z.literal("spacer"), + size: z.enum(["medium", "large"]).optional().default("medium"), + props: spacer.optionsSchema.optional(), + }), + render: (block) => + spacer.component( + block.size as keyof typeof spacer.variants & string, + (block.props ?? {}) as z.output + ) as MjmlNode, + }, +} satisfies Record; + +const blockDefinitionList = Object.values(blockDefinitions); -const composeBlockSchema = z.discriminatedUnion("type", [ - headlineBlockSchema, - textBlockSchema, - buttonBlockSchema, - titleBlockSchema, - spacerBlockSchema, -]); +const composeBlockSchema = z.discriminatedUnion( + "type", + blockDefinitionList.map((definition) => definition.schema) as [ + (typeof blockDefinitionList)[number]["schema"], + (typeof blockDefinitionList)[number]["schema"], + ...(typeof blockDefinitionList)[number]["schema"][], + ] +); const composeRequestSchema = z.object({ template: z.literal("transactional"), @@ -105,6 +152,16 @@ const composeRequestSchema = z.object({ }); type ComposeBlock = z.infer; +type ComposeBlockType = ComposeBlock["type"]; + +const blockRenderers = Object.fromEntries( + blockDefinitionList.map((definition) => [ + definition.type as ComposeBlockType, + definition.render, + ]) +) as { + [Type in ComposeBlockType]: (block: RenderableBlock) => MjmlNode; +}; const hasValidBearerToken = (request: Request): boolean => { const expectedToken = env.STACKS_EMAIL_AUTH_TOKEN?.trim(); @@ -122,29 +179,7 @@ const hasValidBearerToken = (request: Request): boolean => { }; const renderTransactionalBlock = (block: ComposeBlock): MjmlNode => { - switch (block.type) { - case "headline": - return Headline(block.variant, block.props ?? {}); - case "text": - return Text(block.variant, block.props ?? {}); - case "button": { - const sectionClass = block.props?.sectionClass ?? "bg-block"; - const buttonProps = { - align: block.props?.align, - className: block.props?.className, - cssClass: block.props?.cssClass, - href: block.props?.href, - text: block.props?.text, - }; - return Section([Button(block.variant, buttonProps)], { - sectionClass, - }); - } - case "title": - return Title(block.variant, block.props ?? {}); - case "spacer": - return Spacer(block.size, block.props ?? {}); - } + return blockRenderers[block.type](block); }; const buildTransactionalDocument = (blocks: ComposeBlock[]): MjmlNode => ({ @@ -156,20 +191,20 @@ const buildTransactionalDocument = (blocks: ComposeBlock[]): MjmlNode => ({ "background-color": tokens.color.bodyBackground, }, children: [ - Spacer("large", { + spacer.component("large", { sectionClass: "bg-page", - }), - Header("transactional"), + }) as MjmlNode, + header.component("transactional") as MjmlNode, ...blocks.map((block) => renderTransactionalBlock(block)), - Spacer("medium", { + spacer.component("medium", { sectionClass: "bg-block", - }), - Footer("default", { + }) as MjmlNode, + footer.component("default", { unsubscribeUrl: "[[UNSUBSCRIBE_URL]]", - }), - Spacer("large", { + }) as MjmlNode, + spacer.component("large", { sectionClass: "bg-page", - }), + }) as MjmlNode, ], }, ], @@ -195,7 +230,9 @@ export const POST: RequestHandler = async ({ request }) => { if (!parsed.success) { return json( { - error: parsed.error.issues.map((issue) => issue.message).join(" "), + error: parsed.error.issues + .map((issue) => issue.message) + .join(" "), }, { status: 400 } ); diff --git a/packages/stacks-email/src/routes/emails/[slug]/+page.server.ts b/packages/stacks-email/src/routes/emails/[slug]/+page.server.ts index d5eeca142f..69cb7c2e5f 100644 --- a/packages/stacks-email/src/routes/emails/[slug]/+page.server.ts +++ b/packages/stacks-email/src/routes/emails/[slug]/+page.server.ts @@ -1,13 +1,13 @@ import { error } from "@sveltejs/kit"; import type { PageServerLoad } from "./$types"; -import { highlightCode } from "$lib/highlight/highlight"; -import { targetNames, type CompileTarget } from "../../../../tokens"; +import { highlightCode } from "$lib/highlight"; +import { targetNames, type CompileTarget } from "$lib/tokens"; import { compileEmailTemplate, getEmailTemplateMeta, listEmailTemplates, -} from "$lib/public/templates"; +} from "$lib/api/templates"; export const load: PageServerLoad = async ({ params, url }) => { const template = getEmailTemplateMeta(params.slug); diff --git a/packages/stacks-email/src/routes/emails/[slug]/+page.svelte b/packages/stacks-email/src/routes/emails/[slug]/+page.svelte index 53bb89b026..80c42235ac 100644 --- a/packages/stacks-email/src/routes/emails/[slug]/+page.svelte +++ b/packages/stacks-email/src/routes/emails/[slug]/+page.svelte @@ -1,5 +1,6 @@ @@ -144,8 +151,9 @@ {#if activeTab !== "preview"}
+ {@html highlightedCode}
{/if} diff --git a/packages/stacks-email/src/types/mjml-parser-xml.d.ts b/packages/stacks-email/src/types/mjml-parser-xml.d.ts new file mode 100644 index 0000000000..99a3854716 --- /dev/null +++ b/packages/stacks-email/src/types/mjml-parser-xml.d.ts @@ -0,0 +1,5 @@ +declare module "mjml-parser-xml" { + const parseMjmlSource: (source: string) => unknown; + + export default parseMjmlSource; +} diff --git a/packages/stacks-email/src/components/TemplateSidebar.svelte b/packages/stacks-email/src/ui/TemplateSidebar.svelte similarity index 96% rename from packages/stacks-email/src/components/TemplateSidebar.svelte rename to packages/stacks-email/src/ui/TemplateSidebar.svelte index d9beb9b6bf..78b4fbdcf7 100644 --- a/packages/stacks-email/src/components/TemplateSidebar.svelte +++ b/packages/stacks-email/src/ui/TemplateSidebar.svelte @@ -1,7 +1,7 @@ + + - -## Variants - -Coming soon. - -## Options - - diff --git a/packages/stacks-docs/src/structure.yaml b/packages/stacks-docs/src/structure.yaml index a8521cafb3..cf9021122c 100644 --- a/packages/stacks-docs/src/structure.yaml +++ b/packages/stacks-docs/src/structure.yaml @@ -370,9 +370,6 @@ navigation: - title: "Graphic" slug: "graphic" - - title: "Dividers" - slug: "dividers" - - title: "Spacer" slug: "spacer" From 4565a76241d19a5b2cd23d1c422d5934aa7bcc28 Mon Sep 17 00:00:00 2001 From: David Longworth Date: Wed, 3 Jun 2026 00:41:43 +0100 Subject: [PATCH 20/30] no need for explicity default --- packages/stacks-email/components/button.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/stacks-email/components/button.ts b/packages/stacks-email/components/button.ts index f3364dbb00..66516d1a0e 100644 --- a/packages/stacks-email/components/button.ts +++ b/packages/stacks-email/components/button.ts @@ -9,17 +9,10 @@ import type { MjmlNode } from "../src/lib/types"; const button = defineEmailComponent({ slug: "button", - defaultVariant: "primary", htmlExtraction: { targetTag: "mj-button", }, variants: { - primary: { - className: "button", - cssClass: "button-hover", - text: "Filled button", - align: "left", - }, secondary: { className: "button button__tonal", cssClass: "button-hover", From 9a435bcc8393169e21449155add5209bdbb62c58 Mon Sep 17 00:00:00 2001 From: David Longworth Date: Wed, 3 Jun 2026 00:42:18 +0100 Subject: [PATCH 21/30] add card --- .../src/docs/public/email/components/cards.md | 41 +++- .../docs/public/email/components/headline.md | 21 +- .../src/docs/public/email/components/text.md | 4 + packages/stacks-email/components/card.ts | 227 ++++++++++++++++++ packages/stacks-email/components/headline.ts | 104 ++++++-- packages/stacks-email/components/quote.ts | 0 packages/stacks-email/components/text.ts | 36 ++- packages/stacks-email/src/lib/mjml/index.ts | 43 ++++ packages/stacks-email/src/lib/registry.ts | 3 + packages/stacks-email/src/lib/tokens.ts | 10 +- .../static/email/hero/200x200.png | Bin 0 -> 1707 bytes 11 files changed, 437 insertions(+), 52 deletions(-) create mode 100644 packages/stacks-email/components/card.ts create mode 100644 packages/stacks-email/components/quote.ts create mode 100644 packages/stacks-email/static/email/hero/200x200.png diff --git a/packages/stacks-docs/src/docs/public/email/components/cards.md b/packages/stacks-docs/src/docs/public/email/components/cards.md index acf1d46558..341517e61a 100644 --- a/packages/stacks-docs/src/docs/public/email/components/cards.md +++ b/packages/stacks-docs/src/docs/public/email/components/cards.md @@ -1,26 +1,41 @@ --- title: Cards -description: Multi-card content block documentation is in progress. +description: Content cards with an optional image, a selectable background surface, and vertical or horizontal layouts. --- ## Variants -Coming soon. +### Vertical + +The default layout — the image is stacked above the content. + + + +### Horizontal + +A 1:1 image beside the content. `horizontal-left` places the image first; +`horizontal-right` places it after the content. Horizontal layouts assume a +square (1:1) image. + + + +## Backgrounds + +The `background` option selects the card surface. `light-blue` is the default; +`off-white`, `white`, and `off-black` are also available. On `off-black` the body +copy is rendered in the inverted (light) text color. + +## Image + +`imageSrc` is optional — leave it empty to render a text-only card. `imageAspect` +documents the source asset's ratio (`16:9` or `1:1`); MJML can't crop, so the +ratio is determined by the asset itself, and horizontal layouts expect `1:1`. ## Options - + diff --git a/packages/stacks-docs/src/docs/public/email/components/headline.md b/packages/stacks-docs/src/docs/public/email/components/headline.md index c62a49957f..ae4129fa7f 100644 --- a/packages/stacks-docs/src/docs/public/email/components/headline.md +++ b/packages/stacks-docs/src/docs/public/email/components/headline.md @@ -14,9 +14,28 @@ description: Large headline block with default and highlighted background treatm +### Inverted + + + ### Highlight -Inline highlighted `` wrapper +Wraps the headline text in a highlighted background that reads as one continuous +block across multiple lines. Best for short, art-directed headlines. + +Line breaks are author-controlled with `\n` in `textContent` and each line gets +its own highlighted span (joined with `
`). + +Vertical padding sits only on the +outer edges — the first line keeps its top padding, the last line keeps its +bottom padding, and interior lines get horizontal padding only — so the lines +join into a single block rather than separate pills. + +Horizontal padding is +constant so the left/right edges align. + +A single line with no `\n` is padded on +all sides. diff --git a/packages/stacks-docs/src/docs/public/email/components/text.md b/packages/stacks-docs/src/docs/public/email/components/text.md index 802eb9a625..efe7bbd342 100644 --- a/packages/stacks-docs/src/docs/public/email/components/text.md +++ b/packages/stacks-docs/src/docs/public/email/components/text.md @@ -19,6 +19,10 @@ updated: 2026-05-31 +### Contained + + + ## Options diff --git a/packages/stacks-email/components/card.ts b/packages/stacks-email/components/card.ts new file mode 100644 index 0000000000..c53365f057 --- /dev/null +++ b/packages/stacks-email/components/card.ts @@ -0,0 +1,227 @@ +import { + defineEmailComponent, + defineOption, + defineOptions, +} from "../src/lib/schema"; +import { tokens } from "../src/lib/tokens"; +import type { MjmlNode } from "../src/lib/types"; +import { Button } from "./button"; +import { textNode } from "./text"; + +const { lg, md, sm } = tokens.spacing; + +const imageNode = ( + imageSrc: string, + imageAlt: string, + href: string +): MjmlNode => ({ + tagName: "mj-image", + attributes: { + src: imageSrc, + alt: imageAlt, + "fluid-on-mobile": true, + ...(href.trim() !== "" ? { href } : {}), + }, +}); + +const card = defineEmailComponent({ + slug: "card", + defaultVariant: "vertical", + variants: { + "horizontal-left": { + layout: "horizontal-left", + imageSrc: "/email/hero/200x200.png", + background: "bg-light-blue", + }, + "horizontal-right": { + layout: "horizontal-right", + imageSrc: "/email/hero/200x200.png", + background: "bg-light-blue", + }, + }, + tokens: [ + { + token: "CARD_URL", + description: "Link applied to the card image and button.", + }, + ], + optionsSchema: defineOptions([ + defineOption({ + name: "background", + type: "enum", + values: ["bg-card", "bg-block", "bg-light-blue"], + initialValue: "bg-block", + description: "Card surface color.", + }), + defineOption({ + name: "layout", + type: "enum", + values: ["vertical", "horizontal-left", "horizontal-right"], + initialValue: "vertical", + description: + "Card layout. `vertical` stacks the image above the content; `horizontal-left`/`horizontal-right` place a 1:1 image beside it.", + }), + defineOption({ + name: "imageSrc", + type: "string", + initialValue: "/email/hero/1200x630.png", + description: "Optional image URL/path. Empty hides the image.", + }), + defineOption({ + name: "imageAlt", + type: "string", + initialValue: "Card image", + description: "Accessible image alt text.", + }), + defineOption({ + name: "href", + type: "string", + initialValue: "[[CARD_URL]]", + description: "Link applied to the image and button.", + }), + defineOption({ + name: "titleContent", + type: "string", + initialValue: "Card title", + description: + "Optional heading rendered above the body. Empty hides it.", + }), + defineOption({ + name: "textContent", + type: "string", + initialValue: "Card body copy.", + description: "Body copy.", + }), + defineOption({ + name: "ctaText", + type: "string", + initialValue: "Learn more", + description: "Call-to-action label. Empty hides the CTA.", + }), + defineOption({ + name: "ctaStyle", + type: "enum", + values: ["plain", "button", "none"], + initialValue: "plain", + description: + "Call-to-action style: `plain` text link (default), `button`, or `none`.", + }), + ]), + render: ({ options }): MjmlNode => { + const hasImage = options.imageSrc.trim() !== ""; + const horizontal = options.layout !== "vertical"; + + const sectionAttributes: NonNullable = { + "mj-class": options.background, + }; + + const image = hasImage + ? imageNode(options.imageSrc, options.imageAlt, options.href) + : null; + + // Ordered content; each node's padding is assigned by position so the + // block is inset by `lg` with a consistent vertical rhythm. + type ContentItem = { + kind: "title" | "body" | "cta"; + build: (padding: string) => MjmlNode; + }; + const items: ContentItem[] = []; + if (options.titleContent.trim() !== "") { + items.push({ + kind: "title", + build: (p) => + textNode(options.titleContent, { + "font-weight": "600", + "padding": p, + }), + }); + } + items.push({ + kind: "body", + build: (p) => + textNode(options.textContent, { + "mj-class": "s-email-text-body", + "color": options.layout === "vertical" + ? tokens.color.textMuted + : null, + "padding": p, + }), + }); + if (options.ctaStyle !== "none" && options.ctaText.trim() !== "") { + items.push({ + kind: "cta", + // `button` reuses the Button leaf (keeps its own padding); + // `plain` is a classic text link rendered through the Text leaf. + build: (p) => + options.ctaStyle === "button" + ? Button("default", { + text: options.ctaText, + href: options.href, + align: "left", + }) + : textNode( + `${options.ctaText}`, + { "mj-class": "s-email-text-body", "padding": p } + ), + }); + } + + const content = items.map((item, index) => { + const top = index === 0 ? lg : "0px"; + const bottom = + index === items.length - 1 + ? lg + : item.kind === "title" + ? sm + : md; + return item.build(`${top} ${lg} ${bottom}`); + }); + + if (horizontal) { + // 1:1 image beside the content as two columns. + const imageColumn: MjmlNode = { + tagName: "mj-column", + attributes: { width: "40%" }, + children: image ? [image] : [], + }; + const contentColumn: MjmlNode = { + tagName: "mj-column", + attributes: { + width: hasImage ? "60%" : "100%", + padding: "0px", + }, + children: content, + }; + + return { + tagName: "mj-section", + attributes: sectionAttributes, + children: + options.layout === "horizontal-right" + ? [contentColumn, imageColumn] + : [imageColumn, contentColumn], + }; + } + + // Vertical (default): full-bleed image stacked above the padded content. + return { + tagName: "mj-section", + attributes: sectionAttributes, + children: [ + { + tagName: "mj-column", + attributes: { + padding: + options.layout === "vertical" + ? `0px 0px ${tokens.layout.containerYPadding} 0px` + : "0px", + }, + children: [...(image ? [image] : []), ...content], + }, + ], + }; + }, +}); + +export const Card = card.component; +export default card; diff --git a/packages/stacks-email/components/headline.ts b/packages/stacks-email/components/headline.ts index 2c907a883d..adafb3d7fd 100644 --- a/packages/stacks-email/components/headline.ts +++ b/packages/stacks-email/components/headline.ts @@ -9,8 +9,26 @@ import { import { tokens } from "../src/lib/tokens"; import { Section } from "../src/lib/mjml"; -const withHighlightedText = (text: string) => - `${text}`; +const highlightPadding = (index: number, count: number) => { + // Single line + if (count === 1) return "8px 12px"; + // First line in a group + if (index === 0) return "8px 12px 2px"; + // Last line in a group + if (index === count - 1) return "0 12px 8px"; + // Middle line + return "0 12px"; +}; + +const withHighlightedText = (text: string) => { + const lines = text.split("\n").filter((line) => line.trim() !== ""); + return lines + .map( + (line, index) => + `${line}` + ) + .join("
"); +}; const headline = defineEmailComponent({ slug: "headline", @@ -18,6 +36,10 @@ const headline = defineEmailComponent({ highlight: { highlight: true, }, + invert: { + sectionClass: "bg-invert", + textClass: "s-email-text-headline fc-text-invert", + }, }, optionsSchema: defineOptions([ defineOption({ @@ -42,9 +64,22 @@ const headline = defineEmailComponent({ defineOption({ name: "textContent", type: "string", - initialValue: "Please verify your email address", + initialValue: "Please verify\n your email address", description: "Headline copy content.", }), + defineOption({ + name: "eyebrow", + type: "string", + initialValue: "", + description: + "Optional 14px label rendered above the headline. Empty hides it.", + }), + defineOption({ + name: "eyebrowClass", + type: "string", + initialValue: "s-email-text-subtitle", + description: "Text styling class for the eyebrow node.", + }), defineOption({ name: "highlight", type: "boolean", @@ -54,28 +89,47 @@ const headline = defineEmailComponent({ }), ]), tokens: [], - render: ({ options }): MjmlNode => - Section( - [ - { - tagName: "mj-text", - attributes: { - "mj-class": options.textClass, - "align": options.textAlign, - "padding-top": tokens.layout.containerYPadding, - "padding-bottom": tokens.layout.containerYPadding, - "padding-left": tokens.layout.containerXPadding, - "padding-right": tokens.layout.containerXPadding, - }, - content: options.highlight - ? withHighlightedText(options.textContent) - : options.textContent, - }, - ], - { - sectionClass: options.sectionClass, - } - ), + render: ({ options }): MjmlNode => { + const hasEyebrow = options.eyebrow.trim() !== ""; + + // 14px eyebrow label above the headline; its size comes from a token and + // its styling class is configurable. + const eyebrowNode: MjmlNode = { + tagName: "mj-text", + attributes: { + "mj-class": options.eyebrowClass, + "align": options.textAlign, + "font-size": tokens.font.eyebrowSize, + "padding-top": tokens.layout.containerYPadding, + "padding-bottom": "0px", + "padding-left": tokens.layout.containerXPadding, + "padding-right": tokens.layout.containerXPadding, + }, + content: options.eyebrow, + }; + + const headlineNode: MjmlNode = { + tagName: "mj-text", + attributes: { + "mj-class": options.textClass, + "align": options.textAlign, + // When the eyebrow is present it provides the top padding. + "padding-top": hasEyebrow + ? tokens.spacing.xs + : tokens.layout.containerYPadding, + "padding-bottom": tokens.layout.containerYPadding, + "padding-left": tokens.layout.containerXPadding, + "padding-right": tokens.layout.containerXPadding, + }, + content: options.highlight + ? withHighlightedText(options.textContent) + : options.textContent, + }; + + return Section([...(hasEyebrow ? [eyebrowNode] : []), headlineNode], { + sectionClass: options.sectionClass, + }); + }, }); export const Headline = headline.component; diff --git a/packages/stacks-email/components/quote.ts b/packages/stacks-email/components/quote.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/stacks-email/components/text.ts b/packages/stacks-email/components/text.ts index 90109f1f50..f986800d63 100644 --- a/packages/stacks-email/components/text.ts +++ b/packages/stacks-email/components/text.ts @@ -26,6 +26,19 @@ const renderTextContent = (value: string | undefined) => { return renderEmailBodyMarkdown(content); }; +// The bare mj-text leaf. `Text` wraps it in a Section for standalone use; other +// components (e.g. Card) compose it directly inside their own column. Content is +// passed already-processed — callers run `renderTextContent` if they want +// markdown handling. +export const textNode = ( + content: string, + attributes: NonNullable +): MjmlNode => ({ + tagName: "mj-text", + attributes, + content, +}); + const bodyContent = ` Dear [[FIRST_NAME]], @@ -48,6 +61,9 @@ const text = defineEmailComponent({ textAlign: "center", textContent: centeredContent, }, + contained: { + sectionClass: "bg-light-blue", + }, }, tokens: [ { @@ -91,18 +107,14 @@ const text = defineEmailComponent({ render: ({ options }): MjmlNode => Section( [ - { - tagName: "mj-text", - attributes: { - "mj-class": options.textClass, - "align": options.textAlign, - "padding-top": tokens.layout.containerYPadding, - "padding-bottom": tokens.layout.containerYPadding, - "padding-left": tokens.layout.containerXPadding, - "padding-right": tokens.layout.containerXPadding, - }, - content: renderTextContent(options.textContent), - }, + textNode(renderTextContent(options.textContent), { + "mj-class": options.textClass, + "align": options.textAlign, + "padding-top": tokens.layout.containerYPadding, + "padding-bottom": tokens.layout.containerYPadding, + "padding-left": tokens.layout.containerXPadding, + "padding-right": tokens.layout.containerXPadding, + }), ], { sectionClass: options.sectionClass, diff --git a/packages/stacks-email/src/lib/mjml/index.ts b/packages/stacks-email/src/lib/mjml/index.ts index c643d3b60b..635e017a40 100644 --- a/packages/stacks-email/src/lib/mjml/index.ts +++ b/packages/stacks-email/src/lib/mjml/index.ts @@ -52,3 +52,46 @@ export const Section = ( ], }; }; + +/** + * Two-column layout sugar. Like `Section`, but renders exactly two `mj-column`s + * side by side inside one `mj-section`. `columnClass`/`columnAttributes` apply to + * both columns. + */ +export const Columns = ( + columns: [MjmlNode[], MjmlNode[]], + options: SectionOptions = {} +): MjmlNode => { + const parsedOptions = sectionOptionsSchema.safeParse(options); + const normalizedOptions = parsedOptions.success + ? parsedOptions.data + : options; + + const sectionAttributes: NonNullable = { + ...(normalizedOptions.sectionClass + ? { "mj-class": normalizedOptions.sectionClass } + : {}), + ...(normalizedOptions.sectionAttributes ?? {}), + }; + + const columnAttributes: NonNullable = { + ...(normalizedOptions.columnClass + ? { "mj-class": normalizedOptions.columnClass } + : {}), + ...(normalizedOptions.columnAttributes ?? {}), + }; + + return { + tagName: "mj-section", + attributes: Object.keys(sectionAttributes).length + ? sectionAttributes + : undefined, + children: columns.map((children) => ({ + tagName: "mj-column", + attributes: Object.keys(columnAttributes).length + ? columnAttributes + : undefined, + children, + })), + }; +}; diff --git a/packages/stacks-email/src/lib/registry.ts b/packages/stacks-email/src/lib/registry.ts index bb7d279a24..27c900e62a 100644 --- a/packages/stacks-email/src/lib/registry.ts +++ b/packages/stacks-email/src/lib/registry.ts @@ -1,4 +1,5 @@ import button from "../../components/button"; +import card from "../../components/card"; import footer from "../../components/footer"; import graphic from "../../components/graphic"; import headline from "../../components/headline"; @@ -11,6 +12,7 @@ import transactional from "../../templates/transactional"; export const componentDefinitions = [ button, + card, footer, graphic, headline, @@ -24,6 +26,7 @@ export const templateDefinitions = [transactional] as const; export { button, + card, footer, graphic, headline, diff --git a/packages/stacks-email/src/lib/tokens.ts b/packages/stacks-email/src/lib/tokens.ts index 124d420f14..9362d19a70 100644 --- a/packages/stacks-email/src/lib/tokens.ts +++ b/packages/stacks-email/src/lib/tokens.ts @@ -11,11 +11,13 @@ const color = { brandDark: "#201C1D", brandOffWhite: "#eeeeee", text: "#211d1e", - textMuted: "#6B6B6B", + textMuted: "#636261", textInvert: "#ffffff", textFooter: "#cdc8c2", link: "#0000ef", linkHover: "#5074ef", + lightBlue: "#d8e1ed", + cardOffWhite: "#f0efee", } as const; /** @@ -27,6 +29,8 @@ const backgroundClasses = [ { name: "accent", value: color.accent }, { name: "block", value: color.blockBackground }, { name: "page", value: color.bodyBackground }, + { name: "card", value: color.cardOffWhite }, + { name: "light-blue", value: color.lightBlue }, ] as const; /** @@ -47,6 +51,7 @@ const font = { weightNormal: "400", weightSemibold: "600", weightBold: "700", + eyebrowSize: "14px", } as const; /** @@ -140,6 +145,7 @@ export const targets = { PREVIEW_TEXT: "You have a new update from Stack Overflow.", CARD_ONE_URL: "https://example.com/story-one", CARD_TWO_URL: "https://example.com/story-two", + CARD_URL: "https://example.com/card", FOOTER_REASON: "you subscribed to Stack Overflow updates.", UNSUBSCRIBE_URL: "https://example.com/unsubscribe", COMPANY_NAME: "Acme Corp", @@ -154,6 +160,7 @@ export const targets = { PREVIEW_TEXT: "@Model.PreviewText", CARD_ONE_URL: "@Model.CardOneUrl", CARD_TWO_URL: "@Model.CardTwoUrl", + CARD_URL: "@Model.CardUrl", FOOTER_REASON: "@Model.FooterReason", UNSUBSCRIBE_URL: "@Model.UnsubscribeUrl", COMPANY_NAME: "@Model.CompanyName", @@ -168,6 +175,7 @@ export const targets = { PREVIEW_TEXT: "{{custom_attribute.${preview_text}}}", CARD_ONE_URL: "{{custom_attribute.${card_one_url}}}", CARD_TWO_URL: "{{custom_attribute.${card_two_url}}}", + CARD_URL: "{{custom_attribute.${card_url}}}", FOOTER_REASON: "{{custom_attribute.${footer_reason}}}", UNSUBSCRIBE_URL: "{{${unsubscribe_url}}}", COMPANY_NAME: "{{custom_attribute.${company_name}}}", diff --git a/packages/stacks-email/static/email/hero/200x200.png b/packages/stacks-email/static/email/hero/200x200.png new file mode 100644 index 0000000000000000000000000000000000000000..20bd4c4941089bd27f99e48dd46301bb8a2ac962 GIT binary patch literal 1707 zcmb7^2~d+q6vsaxkb@&PgycJOd_hj>3|2+7Q33*T2SL#S9)O@05R4WP2zOPEKy5+L zDq2NAE-wh8fMNlsR0}FvrPS7rg3^jgMc`{-YH68qI^B6YZ|D7XX8-%RG~M+Vy*M);789sE_VML`Rm+-3DR)sP4Sh|K^I1sJk1 zYa6(z9nY+hC>t&NcNr1E0P!*KW}}Yne7O9cRMlfWlwnA00*DBJ*alz_WMKhIr7$jq zZG-|lOfVk<5fDNI5Ih|)20}svVvs>58Dx<`5=Tg4VN@?Tic z)|m{~nT*h3Q^5p{)h{s=Vm@D$P7Z2Y8x-sDET=uW%f zPHS|hUH77Ncu}}i0gZ`L*ch25B(pFCgdqV83gCGVXm1PISwl;N=8MJrMIye71y63l zGvgy^Qsc|l(ZZ|J!fVpXvna6^AR+-CA5lu_RrN?ltt7n`FFA&!N{uyFk%CsK`U;-c zCOuV$5NiM;8~_E-3XG=+fL#@+O91BO4e$AYk>J2eSip*RU~QhyiLmE@AZ*Ts1RRLZ zg*fI2j}P2^PK1m12E~RB)A~pg$zYr%hS&l^NGxWjKQ73#4uT{ae9 zEY8aW2i!?_6b5${20sOo1_MbM4fx4YFs%;%`Vh$KKkyv@=q0*2%M`nGo;{FUwASZc z9~?=Ze5hKHnW=FUi*Ln7m#Q?5Or?@`J4TrkBpQ6~^lVA@@DBxJ)xTCv9kvk-1ny^O zpU~WqQO!z0N2TrGQ9acy44RX-3y9+NFzIb*#t@mDw{+sHOpw%z4P!*buQiK#0 zMfEPdtIPr|n&xxCa=I35JZv!*T$udyesfiC(6;-U;(a5YZ_Qpj=?K2PDr75n)Fqfa zt?i?z?ANQJ&QCNfaj@w0uI{r3RCPM@-ay?fLOX&h1XE$@t%-wroddE7wBJpDCquZ7s%S(oemyvlai=Mt^W zwXn&E@=sN>;$)0}$$c*f@*V2;cKpPDhug!66HPHc9(S2oU#zH+c1b#mRKtzkrVd9u z)pd56y3^9Gbd?|4UcI19kyH@Ya?keU2Oxrc>{7pm*saRi)SL`TwhHfA@L+ABph+>OmWRROps3Wrw}}CaD|9eej3kN9!hv zbf=b0tBX-REef`CJnI^qPTIY!^H|#P$mSQ3o@80B1)scVs%1w=h5S?du_@mh%0B8` zgU#A$j`Q9Fa^ga4_NMf^ddMH4{pF@Z6;=awCFD2b*=uVfa;tuyX;;dy|F%TS3VXMN zc}D`f7u>k0il2y$+jMSt5FB;2ui+vA-3L!BwZ!-K<;6RL^GUfDj+~i)) JXO@Sm{sbu^Ex`Z) literal 0 HcmV?d00001 From 1d0ff886156bc28c0f0e19423c2fbe417b0e3040 Mon Sep 17 00:00:00 2001 From: David Longworth Date: Wed, 3 Jun 2026 00:51:21 +0100 Subject: [PATCH 22/30] text node adjust --- .../stacks-docs/src/docs/public/email/components/text.md | 4 ---- packages/stacks-email/components/card.ts | 4 ++-- packages/stacks-email/components/text.ts | 7 ++----- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/packages/stacks-docs/src/docs/public/email/components/text.md b/packages/stacks-docs/src/docs/public/email/components/text.md index efe7bbd342..802eb9a625 100644 --- a/packages/stacks-docs/src/docs/public/email/components/text.md +++ b/packages/stacks-docs/src/docs/public/email/components/text.md @@ -19,10 +19,6 @@ updated: 2026-05-31 -### Contained - - - ## Options diff --git a/packages/stacks-email/components/card.ts b/packages/stacks-email/components/card.ts index c53365f057..92e5813368 100644 --- a/packages/stacks-email/components/card.ts +++ b/packages/stacks-email/components/card.ts @@ -89,8 +89,8 @@ const card = defineEmailComponent({ defineOption({ name: "textContent", type: "string", - initialValue: "Card body copy.", - description: "Body copy.", + initialValue: "Card body copy **goes here**.", + description: "Body copy. Accepts Markdown.", }), defineOption({ name: "ctaText", diff --git a/packages/stacks-email/components/text.ts b/packages/stacks-email/components/text.ts index f986800d63..815be010a4 100644 --- a/packages/stacks-email/components/text.ts +++ b/packages/stacks-email/components/text.ts @@ -36,7 +36,7 @@ export const textNode = ( ): MjmlNode => ({ tagName: "mj-text", attributes, - content, + content: renderTextContent(content), }); const bodyContent = ` @@ -61,9 +61,6 @@ const text = defineEmailComponent({ textAlign: "center", textContent: centeredContent, }, - contained: { - sectionClass: "bg-light-blue", - }, }, tokens: [ { @@ -107,7 +104,7 @@ const text = defineEmailComponent({ render: ({ options }): MjmlNode => Section( [ - textNode(renderTextContent(options.textContent), { + textNode(options.textContent, { "mj-class": options.textClass, "align": options.textAlign, "padding-top": tokens.layout.containerYPadding, From e8f41b8e06cf26f3765dee60369cc6152d2d8fd6 Mon Sep 17 00:00:00 2001 From: David Longworth Date: Wed, 3 Jun 2026 01:15:50 +0100 Subject: [PATCH 23/30] link card with arrow variant --- .../src/docs/public/email/components/cards.md | 13 +- packages/stacks-email/components/card.ts | 123 ++++++++++++------ 2 files changed, 89 insertions(+), 47 deletions(-) diff --git a/packages/stacks-docs/src/docs/public/email/components/cards.md b/packages/stacks-docs/src/docs/public/email/components/cards.md index 341517e61a..d5e1f6530d 100644 --- a/packages/stacks-docs/src/docs/public/email/components/cards.md +++ b/packages/stacks-docs/src/docs/public/email/components/cards.md @@ -24,17 +24,12 @@ square (1:1) image. -## Backgrounds +### Link -The `background` option selects the card surface. `light-blue` is the default; -`off-white`, `white`, and `off-black` are also available. On `off-black` the body -copy is rendered in the inverted (light) text color. +A compact link row — title and an arrow CTA only, with no image or body. The +title is 18px (not bold) and the CTA uses the off-black arrow square. -## Image - -`imageSrc` is optional — leave it empty to render a text-only card. `imageAspect` -documents the source asset's ratio (`16:9` or `1:1`); MJML can't crop, so the -ratio is determined by the asset itself, and horizontal layouts expect `1:1`. + ## Options diff --git a/packages/stacks-email/components/card.ts b/packages/stacks-email/components/card.ts index 92e5813368..944f944ecd 100644 --- a/packages/stacks-email/components/card.ts +++ b/packages/stacks-email/components/card.ts @@ -17,13 +17,32 @@ const imageNode = ( ): MjmlNode => ({ tagName: "mj-image", attributes: { - src: imageSrc, - alt: imageAlt, + "src": imageSrc, + "alt": imageAlt, "fluid-on-mobile": true, ...(href.trim() !== "" ? { href } : {}), }, }); +// Off-black 40x40 square, no label, with a right-arrow glyph. +const arrowNode = (href: string, padding: string): MjmlNode => ({ + tagName: "mj-button", + attributes: { + "css-class": "button-hover", + "href": href, + "background-color": tokens.color.brandDark, + "color": tokens.color.textInvert, + "width": "40px", + "height": "40px", + "inner-padding": "0px", + "border-radius": "0px", + "align": "right", + "font-size": "18px", + "line-height": "40px", + }, + content: "→", +}); + const card = defineEmailComponent({ slug: "card", defaultVariant: "vertical", @@ -38,6 +57,14 @@ const card = defineEmailComponent({ imageSrc: "/email/hero/200x200.png", background: "bg-light-blue", }, + // Compact link row: title + arrow only, no image or body. + "link": { + imageSrc: "", + textContent: "", + ctaStyle: "arrow", + titleSize: "18px", + titleWeight: "400", + }, }, tokens: [ { @@ -50,7 +77,7 @@ const card = defineEmailComponent({ name: "background", type: "enum", values: ["bg-card", "bg-block", "bg-light-blue"], - initialValue: "bg-block", + initialValue: "bg-card", description: "Card surface color.", }), defineOption({ @@ -86,6 +113,18 @@ const card = defineEmailComponent({ description: "Optional heading rendered above the body. Empty hides it.", }), + defineOption({ + name: "titleSize", + type: "string", + initialValue: "", + description: 'Optional title font-size override, e.g. "18px".', + }), + defineOption({ + name: "titleWeight", + type: "string", + initialValue: "600", + description: "Title font-weight.", + }), defineOption({ name: "textContent", type: "string", @@ -101,57 +140,55 @@ const card = defineEmailComponent({ defineOption({ name: "ctaStyle", type: "enum", - values: ["plain", "button", "none"], + values: ["plain", "button", "arrow", "none"], initialValue: "plain", description: - "Call-to-action style: `plain` text link (default), `button`, or `none`.", + "Call-to-action style: `plain` text link (default), `button`, `arrow` (off-black icon square), or `none`.", }), ]), render: ({ options }): MjmlNode => { const hasImage = options.imageSrc.trim() !== ""; const horizontal = options.layout !== "vertical"; - const sectionAttributes: NonNullable = { - "mj-class": options.background, - }; - const image = hasImage ? imageNode(options.imageSrc, options.imageAlt, options.href) : null; - // Ordered content; each node's padding is assigned by position so the - // block is inset by `lg` with a consistent vertical rhythm. - type ContentItem = { - kind: "title" | "body" | "cta"; - build: (padding: string) => MjmlNode; - }; - const items: ContentItem[] = []; + const items = []; + if (options.titleContent.trim() !== "") { items.push({ kind: "title", build: (p) => textNode(options.titleContent, { - "font-weight": "600", + ...(options.titleSize + ? { "font-size": options.titleSize } + : {}), + "font-weight": options.titleWeight, "padding": p, }), }); } - items.push({ - kind: "body", - build: (p) => - textNode(options.textContent, { - "mj-class": "s-email-text-body", - "color": options.layout === "vertical" - ? tokens.color.textMuted - : null, - "padding": p, - }), - }); - if (options.ctaStyle !== "none" && options.ctaText.trim() !== "") { + if (options.textContent.trim() !== "") { + items.push({ + kind: "body", + build: (p) => + textNode(options.textContent, { + "mj-class": "s-email-text-body", + ...(options.layout === "vertical" + ? { color: tokens.color.textMuted } + : {}), + "padding": p, + }), + }); + } + const showCta = + options.ctaStyle !== "none" && + (options.ctaStyle === "arrow" || options.ctaText.trim() !== ""); + + if (showCta) { items.push({ kind: "cta", - // `button` reuses the Button leaf (keeps its own padding); - // `plain` is a classic text link rendered through the Text leaf. build: (p) => options.ctaStyle === "button" ? Button("default", { @@ -159,10 +196,15 @@ const card = defineEmailComponent({ href: options.href, align: "left", }) - : textNode( - `${options.ctaText}`, - { "mj-class": "s-email-text-body", "padding": p } - ), + : options.ctaStyle === "arrow" + ? arrowNode(options.href) + : textNode( + `${options.ctaText}`, + { + "mj-class": "s-email-text-body", + "padding": p, + } + ), }); } @@ -195,7 +237,9 @@ const card = defineEmailComponent({ return { tagName: "mj-section", - attributes: sectionAttributes, + attributes: { + "mj-class": options.background, + }, children: options.layout === "horizontal-right" ? [contentColumn, imageColumn] @@ -206,14 +250,17 @@ const card = defineEmailComponent({ // Vertical (default): full-bleed image stacked above the padded content. return { tagName: "mj-section", - attributes: sectionAttributes, + attributes: { + "mj-class": "bg-block", + }, children: [ { tagName: "mj-column", attributes: { + "inner-background-color": tokens.color.cardOffWhite, padding: options.layout === "vertical" - ? `0px 0px ${tokens.layout.containerYPadding} 0px` + ? `0px ${tokens.layout.containerXPadding} ${tokens.layout.containerYPadding}` : "0px", }, children: [...(image ? [image] : []), ...content], From 4cf2c1a570eb680a068fc394c2a293fca4e684e4 Mon Sep 17 00:00:00 2001 From: David Longworth Date: Wed, 3 Jun 2026 14:41:59 +0100 Subject: [PATCH 24/30] callout component --- .../docs/public/email/components/callout.md | 29 ++++ .../src/docs/public/email/components/cards.md | 7 + .../email/components/component-callout.svg | 7 + .../src/docs/public/email/overview.md | 6 +- packages/stacks-docs/src/structure.yaml | 3 + packages/stacks-email/components/callout.ts | 156 ++++++++++++++++++ packages/stacks-email/components/card.ts | 93 +++++++++-- packages/stacks-email/src/lib/registry.ts | 3 + packages/stacks-email/src/lib/tokens.ts | 2 + .../stacks-email/static/email/icons/help.png | Bin 0 -> 1912 bytes 10 files changed, 286 insertions(+), 20 deletions(-) create mode 100644 packages/stacks-docs/src/docs/public/email/components/callout.md create mode 100644 packages/stacks-docs/src/docs/public/email/components/component-callout.svg create mode 100644 packages/stacks-email/components/callout.ts create mode 100644 packages/stacks-email/static/email/icons/help.png diff --git a/packages/stacks-docs/src/docs/public/email/components/callout.md b/packages/stacks-docs/src/docs/public/email/components/callout.md new file mode 100644 index 0000000000..e667eb6498 --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/components/callout.md @@ -0,0 +1,29 @@ +--- +title: Callout +description: A padded callout box with an off-white surface and an optional 40x40 icon. +--- + + + +Body copy in a padded box on an off-white surface — the same styling as +[Text](text), with container padding and an `inner-background-color`. It can +optionally show an icon in a left column. + +## Variants + +### Default + + + +### With icon + +A 32x32 icon in a left column beside the copy. + + + +## Options + + diff --git a/packages/stacks-docs/src/docs/public/email/components/cards.md b/packages/stacks-docs/src/docs/public/email/components/cards.md index d5e1f6530d..0a9e5bd780 100644 --- a/packages/stacks-docs/src/docs/public/email/components/cards.md +++ b/packages/stacks-docs/src/docs/public/email/components/cards.md @@ -31,6 +31,13 @@ title is 18px (not bold) and the CTA uses the off-black arrow square. +### Link (inverted) + +The link row on an off-black surface: a dark-grey inner background, white title, +and an off-white arrow square. + + + ## Options diff --git a/packages/stacks-docs/src/docs/public/email/components/component-callout.svg b/packages/stacks-docs/src/docs/public/email/components/component-callout.svg new file mode 100644 index 0000000000..632e84d27b --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/components/component-callout.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/stacks-docs/src/docs/public/email/overview.md b/packages/stacks-docs/src/docs/public/email/overview.md index 7139761328..b457e15e90 100644 --- a/packages/stacks-docs/src/docs/public/email/overview.md +++ b/packages/stacks-docs/src/docs/public/email/overview.md @@ -160,11 +160,11 @@ Standalone illustration placeholder block. -[![Dividers component preview](./components/component-dividers.svg)](./components/dividers) +[![Callout component preview](./components/component-callout.svg)](./components/callout) -### [Dividers](./components/dividers) +### [Callout](./components/callout) -Subtle and strong horizontal separators. +Indented and visually distinct box for alerts or important information. diff --git a/packages/stacks-docs/src/structure.yaml b/packages/stacks-docs/src/structure.yaml index cf9021122c..e2e2a7bec9 100644 --- a/packages/stacks-docs/src/structure.yaml +++ b/packages/stacks-docs/src/structure.yaml @@ -367,6 +367,9 @@ navigation: - title: "Cards" slug: "cards" + - title: "Callout" + slug: "callout" + - title: "Graphic" slug: "graphic" diff --git a/packages/stacks-email/components/callout.ts b/packages/stacks-email/components/callout.ts new file mode 100644 index 0000000000..d8826be73a --- /dev/null +++ b/packages/stacks-email/components/callout.ts @@ -0,0 +1,156 @@ +import { + defineEmailComponent, + defineOption, + defineOptions, +} from "../src/lib/schema"; +import { tokens } from "../src/lib/tokens"; +import type { MjmlNode } from "../src/lib/types"; +import { Section } from "../src/lib/mjml"; +import { textNode } from "./text"; + +const { containerXPadding, containerYPadding } = tokens.layout; + +const iconNode = (src: string, alt: string): MjmlNode => ({ + tagName: "mj-image", + attributes: { + src: src, + alt: alt, + width: "32px", + height: "32px", + align: "left", + padding: "0px", + }, +}); + +const callout = defineEmailComponent({ + slug: "callout", + variants: { + icon: { + iconSrc: "/email/icons/help.png", + iconAlt: "Callout icon", + }, + }, + tokens: [], + optionsSchema: defineOptions([ + defineOption({ + name: "sectionClass", + type: "string", + initialValue: "bg-block", + description: "Optional section mj-class override.", + }), + defineOption({ + name: "innerBackground", + type: "string", + initialValue: tokens.color.cardOffWhite, + description: "Callout background color.", + }), + defineOption({ + name: "textClass", + type: "string", + initialValue: "s-email-text-body", + description: "Text mj-class override.", + }), + defineOption({ + name: "textContent", + type: "string", + initialValue: "Callout copy goes here.", + description: "Callout body copy. Accepts Markdown.", + }), + defineOption({ + name: "iconSrc", + type: "string", + initialValue: "", + description: + "Optional 32x32 icon shown in a left column. Empty hides it.", + }), + defineOption({ + name: "iconAlt", + type: "string", + initialValue: "", + description: "Accessible icon alt text.", + }), + ]), + render: ({ options }): MjmlNode => { + const hasIcon = options.iconSrc.trim() !== ""; + + // Text() with container padding; when an icon is present the left + // padding is reduced to a gap from the icon column. + const body = textNode(options.textContent, { + "mj-class": options.textClass, + "padding-top": containerYPadding, + "padding-bottom": containerYPadding, + "padding-left": hasIcon ? tokens.spacing.md : containerXPadding, + "padding-right": containerXPadding, + }); + + // The section is the outer surface (default white); its X padding insets + // the bg-card callout column to leave a side gutter. + const sectionPadding = { + "padding-top": "0px", + "padding-bottom": "0px", + "padding-left": containerXPadding, + "padding-right": containerXPadding, + }; + + if (!hasIcon) { + return Section([body], { + sectionClass: options.sectionClass, + sectionAttributes: sectionPadding, + columnAttributes: { + "inner-background-color": options.innerBackground, + }, + }); + } + + // Icon variant: an icon in a left column and the copy on the right. + // The background lives on the `mj-group` (not the columns): the group + // fills to the height of the tallest column, so the surface stays + // consistent — per-column backgrounds would each size to their own + // content. The group also keeps the columns side-by-side on mobile. + return { + tagName: "mj-section", + attributes: { + ...(options.sectionClass + ? { "mj-class": options.sectionClass } + : {}), + ...sectionPadding, + }, + children: [ + { + tagName: "mj-group", + attributes: { + "background-color": options.innerBackground, + "width": "100%", + }, + children: [ + { + tagName: "mj-column", + attributes: { + // Widths sum to 100% so the columns fill the group. + "width": "10%", + "vertical-align": "top", + "padding-top": "12px", + "padding-bottom": "12px", + "padding-left": containerXPadding, + }, + children: [ + iconNode(options.iconSrc, options.iconAlt), + ], + }, + { + tagName: "mj-column", + attributes: { + "width": "90%", + "vertical-align": "top", + }, + children: [body], + }, + ], + }, + ], + }; + }, +}); + +export const Callout = callout.component; +export default callout; diff --git a/packages/stacks-email/components/card.ts b/packages/stacks-email/components/card.ts index 944f944ecd..5a76284503 100644 --- a/packages/stacks-email/components/card.ts +++ b/packages/stacks-email/components/card.ts @@ -24,14 +24,19 @@ const imageNode = ( }, }); -// Off-black 40x40 square, no label, with a right-arrow glyph. -const arrowNode = (href: string, padding: string): MjmlNode => ({ +// 40x40 square, no label, with a right-arrow glyph. +const arrowNode = ( + href: string, + background: string, + color: string, + cssClass: string +): MjmlNode => ({ tagName: "mj-button", attributes: { - "css-class": "button-hover", + "css-class": cssClass, "href": href, - "background-color": tokens.color.brandDark, - "color": tokens.color.textInvert, + "background-color": background, + "color": color, "width": "40px", "height": "40px", "inner-padding": "0px", @@ -65,6 +70,21 @@ const card = defineEmailComponent({ titleSize: "18px", titleWeight: "400", }, + // Inverted link row: off-black surface, dark-grey inner, white title, + // off-white arrow square. + "link-inverted": { + imageSrc: "", + textContent: "", + ctaStyle: "arrow", + titleSize: "18px", + titleWeight: "400", + background: "bg-invert", + innerBackground: tokens.color.invertSurface, + titleColor: tokens.color.textInvert, + arrowBackground: tokens.color.brandOffWhite, + arrowColor: tokens.color.brandDark, + arrowCssClass: "button-hover-inverted", + }, }, tokens: [ { @@ -76,9 +96,15 @@ const card = defineEmailComponent({ defineOption({ name: "background", type: "enum", - values: ["bg-card", "bg-block", "bg-light-blue"], - initialValue: "bg-card", - description: "Card surface color.", + values: ["bg-card", "bg-block", "bg-light-blue", "bg-invert"], + initialValue: "bg-block", + description: "Card surface (section) color.", + }), + defineOption({ + name: "innerBackground", + type: "string", + initialValue: tokens.color.cardOffWhite, + description: "Inner column background color.", }), defineOption({ name: "layout", @@ -125,6 +151,12 @@ const card = defineEmailComponent({ initialValue: "600", description: "Title font-weight.", }), + defineOption({ + name: "titleColor", + type: "string", + initialValue: "", + description: "Optional title color override.", + }), defineOption({ name: "textContent", type: "string", @@ -143,7 +175,25 @@ const card = defineEmailComponent({ values: ["plain", "button", "arrow", "none"], initialValue: "plain", description: - "Call-to-action style: `plain` text link (default), `button`, `arrow` (off-black icon square), or `none`.", + "Call-to-action style: `plain` text link (default), `button`, `arrow` (icon square), or `none`.", + }), + defineOption({ + name: "arrowBackground", + type: "string", + initialValue: tokens.color.brandDark, + description: "Background color of the arrow CTA square.", + }), + defineOption({ + name: "arrowColor", + type: "string", + initialValue: tokens.color.textInvert, + description: "Glyph color of the arrow CTA square.", + }), + defineOption({ + name: "arrowCssClass", + type: "string", + initialValue: "button-hover", + description: "Hover CSS class for the arrow CTA square.", }), ]), render: ({ options }): MjmlNode => { @@ -154,7 +204,11 @@ const card = defineEmailComponent({ ? imageNode(options.imageSrc, options.imageAlt, options.href) : null; - const items = []; + type ContentItem = { + kind: "title" | "body" | "cta"; + build: (padding: string) => MjmlNode; + }; + const items: ContentItem[] = []; if (options.titleContent.trim() !== "") { items.push({ @@ -164,6 +218,9 @@ const card = defineEmailComponent({ ...(options.titleSize ? { "font-size": options.titleSize } : {}), + ...(options.titleColor + ? { color: options.titleColor } + : {}), "font-weight": options.titleWeight, "padding": p, }), @@ -197,7 +254,12 @@ const card = defineEmailComponent({ align: "left", }) : options.ctaStyle === "arrow" - ? arrowNode(options.href) + ? arrowNode( + options.href, + options.arrowBackground, + options.arrowColor, + options.arrowCssClass + ) : textNode( `${options.ctaText}`, { @@ -251,17 +313,14 @@ const card = defineEmailComponent({ return { tagName: "mj-section", attributes: { - "mj-class": "bg-block", + "mj-class": options.background, }, children: [ { tagName: "mj-column", attributes: { - "inner-background-color": tokens.color.cardOffWhite, - padding: - options.layout === "vertical" - ? `0px ${tokens.layout.containerXPadding} ${tokens.layout.containerYPadding}` - : "0px", + "inner-background-color": options.innerBackground, + "padding": `0px ${tokens.layout.containerXPadding} ${tokens.layout.containerYPadding}`, }, children: [...(image ? [image] : []), ...content], }, diff --git a/packages/stacks-email/src/lib/registry.ts b/packages/stacks-email/src/lib/registry.ts index 27c900e62a..31d31124ec 100644 --- a/packages/stacks-email/src/lib/registry.ts +++ b/packages/stacks-email/src/lib/registry.ts @@ -1,4 +1,5 @@ import button from "../../components/button"; +import callout from "../../components/callout"; import card from "../../components/card"; import footer from "../../components/footer"; import graphic from "../../components/graphic"; @@ -12,6 +13,7 @@ import transactional from "../../templates/transactional"; export const componentDefinitions = [ button, + callout, card, footer, graphic, @@ -26,6 +28,7 @@ export const templateDefinitions = [transactional] as const; export { button, + callout, card, footer, graphic, diff --git a/packages/stacks-email/src/lib/tokens.ts b/packages/stacks-email/src/lib/tokens.ts index 9362d19a70..8df7b75842 100644 --- a/packages/stacks-email/src/lib/tokens.ts +++ b/packages/stacks-email/src/lib/tokens.ts @@ -18,6 +18,8 @@ const color = { linkHover: "#5074ef", lightBlue: "#d8e1ed", cardOffWhite: "#f0efee", + // Inner surface for inverted (off-black) cards. + invertSurface: "#4a4a4a", } as const; /** diff --git a/packages/stacks-email/static/email/icons/help.png b/packages/stacks-email/static/email/icons/help.png new file mode 100644 index 0000000000000000000000000000000000000000..6c37e920cb975da49a5a45ec2f3dbf6a940e21f6 GIT binary patch literal 1912 zcmV-;2Z#8HP)Z7KUwMINB%J*={>bnQEa-1v~-J zrCaFI4Lkwl2?AxRHC?(Lo*)iCbq1=O!`RA|}AKMz6pJcyt_AFz|vh?Zd>i!wv zJO1Aw&@m{bJxG!e2%*5>1F&=ejP&r$wA7G6jMrlPYmCFX2Jj*XU=+vk7&=5J1c*sb z(tVs&h(AME3=xj@hgTOg%jpIeOhBEk6aqHce+B!I51eKU$ZOAq7w`xAHppdvnilnuoXK6&5UY^rZ62{0-= zuH@tkJ70AhP&fx@b)G@sZM!I2D*|0u^ z+LJ*m0klui_(*=!v_8jUZVy}r)zn{bwD2;?{ia%|{-%ypPv1>4R4xfE>mP#y@D#y` z4f`$;M2*A(aj!(Vi7X#gsJ`ec2Z|g4UoHVKcO24uwBcKv@E~=QR(<)fcCT6~cUek9IrZ|M*cQ zLSJTtbd?UECWJFaG`e7tmLzhH0QOmVAca$K0@aG z#Y`4DOCO-|Sjd$UJ4i|Xdqx0ZAC&p0JVDVMnbJH57Z9?ejAIJs#n8AvZd$`m>6(WL zn*7hab4_rQc+cwaNV@@cjSU?15e8W!Qi=pnrD34iKOI4u%NDzV`yp<@RrM$& zGX-7P0xAJrq@v~_H1d=1J$8+aWVFZ04*?wkg~j&Z1(ADh#5#E%eA#wgjH=}7ixE&A zXoY8qJXr~tx0_o94^r4C&&9s;$Wu`X#+v(`5Flgww%7m}xkPxi?~)GQT=%oN&Ir&s z4o}?>pq$G^5V*hy?`CTXSQu8y`{U`K1p+{0^fYun>Lb)%l~Jd?R;xmIvQLH9oE5^Wac;d z8`MF)e-4AF3uFZxe=0Wue`P9Z`4-47L`vgHx(ja=x(FA~9v8ORoE^Zf)b?y2-YS@8 z?2MG+5hcglxD5Py zOOW$*AzOW_-cd7K3ISPdPozE!!%lsJmXO&Q`s7ai+@MAZIHSoM*b1hsHZW}knU(;f z6z~+)>iXiTu8tsU7d5d3nbp>Y)6DL0;O}W2B*KPtb+vYe=D8qh0#bIO+^EQE+e>7M zMM&kxMws0A2(JW6K|~P*_H3y=Ynj~m;o*Pa1k6ejVQSjAViT28+_CdAtBe0V!kne zQrbN;v3oKIn+!0cyMg;V)McyGzR^}mlVjI<<;6dlNs=RLl($<%++cf=PiBf}Bn|B# zYU`tnRtT6Pt1;~Ov$=M^MroCRDe`Z9WYJX!iy;!Bx*#}-JNSyU69S6JzB3w#5*%5D y(i3O6_S0EZXtJW?FA=7fUyHs9llwdVtN0fy@ Date: Wed, 3 Jun 2026 18:18:43 +0100 Subject: [PATCH 25/30] callout and subtitle --- .../docs/public/email/components/callout.md | 6 +- .../docs/public/email/components/subtitle.md | 27 +++--- packages/stacks-email/components/callout.ts | 15 ++-- packages/stacks-email/components/subtitle.ts | 85 ++++++++++++++++++ packages/stacks-email/src/lib/registry.ts | 3 + packages/stacks-email/src/lib/tokens.ts | 6 +- .../stacks-email/static/email/icons/help.png | Bin 1912 -> 1960 bytes 7 files changed, 113 insertions(+), 29 deletions(-) create mode 100644 packages/stacks-email/components/subtitle.ts diff --git a/packages/stacks-docs/src/docs/public/email/components/callout.md b/packages/stacks-docs/src/docs/public/email/components/callout.md index e667eb6498..a3928cebd6 100644 --- a/packages/stacks-docs/src/docs/public/email/components/callout.md +++ b/packages/stacks-docs/src/docs/public/email/components/callout.md @@ -1,6 +1,6 @@ --- title: Callout -description: A padded callout box with an off-white surface and an optional 40x40 icon. +description: A padded callout box with an optional icon. --- -Body copy in a padded box on an off-white surface — the same styling as -[Text](text), with container padding and an `inner-background-color`. It can -optionally show an icon in a left column. - ## Variants ### Default diff --git a/packages/stacks-docs/src/docs/public/email/components/subtitle.md b/packages/stacks-docs/src/docs/public/email/components/subtitle.md index f486bea4d7..deafb163ee 100644 --- a/packages/stacks-docs/src/docs/public/email/components/subtitle.md +++ b/packages/stacks-docs/src/docs/public/email/components/subtitle.md @@ -1,26 +1,27 @@ --- title: Subtitle -description: Subtitle component documentation is in progress. +description: A small heading level with a colored square marker, in medium and small weights. --- ## Variants -Coming soon. +### Medium + +The default — 16px, bold. + + + +### Small + +14px, normal weight. + + ## Options - + diff --git a/packages/stacks-email/components/callout.ts b/packages/stacks-email/components/callout.ts index d8826be73a..1b6e77c97e 100644 --- a/packages/stacks-email/components/callout.ts +++ b/packages/stacks-email/components/callout.ts @@ -15,8 +15,7 @@ const iconNode = (src: string, alt: string): MjmlNode => ({ attributes: { src: src, alt: alt, - width: "32px", - height: "32px", + width: "40px", align: "left", padding: "0px", }, @@ -61,12 +60,12 @@ const callout = defineEmailComponent({ type: "string", initialValue: "", description: - "Optional 32x32 icon shown in a left column. Empty hides it.", + "Optional 40x40 icon shown in a left column. Empty hides it.", }), defineOption({ name: "iconAlt", type: "string", - initialValue: "", + initialValue: "Information", description: "Accessible icon alt text.", }), ]), @@ -77,8 +76,8 @@ const callout = defineEmailComponent({ // padding is reduced to a gap from the icon column. const body = textNode(options.textContent, { "mj-class": options.textClass, - "padding-top": containerYPadding, - "padding-bottom": containerYPadding, + "padding-top": "16px", + "padding-bottom": "16px", "padding-left": hasIcon ? tokens.spacing.md : containerXPadding, "padding-right": containerXPadding, }); @@ -127,7 +126,7 @@ const callout = defineEmailComponent({ tagName: "mj-column", attributes: { // Widths sum to 100% so the columns fill the group. - "width": "10%", + "width": "12%", "vertical-align": "top", "padding-top": "12px", "padding-bottom": "12px", @@ -140,7 +139,7 @@ const callout = defineEmailComponent({ { tagName: "mj-column", attributes: { - "width": "90%", + "width": "88%", "vertical-align": "top", }, children: [body], diff --git a/packages/stacks-email/components/subtitle.ts b/packages/stacks-email/components/subtitle.ts new file mode 100644 index 0000000000..dc25269eb1 --- /dev/null +++ b/packages/stacks-email/components/subtitle.ts @@ -0,0 +1,85 @@ +import { + defineEmailComponent, + defineOption, + defineOptions, + mjmlAlignOptions, +} from "../src/lib/schema"; +import { tokens } from "../src/lib/tokens"; +import type { MjmlNode } from "../src/lib/types"; +import { Section } from "../src/lib/mjml"; + +const squareMarker = (color: string) => + ``; + +const subtitle = defineEmailComponent({ + slug: "subtitle", + defaultVariant: "medium", + variants: { + small: { + fontSize: "14px", + fontWeight: tokens.font.weightNormal, + squareColor: tokens.color.brandYellow, + }, + }, + optionsSchema: defineOptions([ + defineOption({ + name: "sectionClass", + type: "string", + initialValue: "bg-block", + description: "Section mj-class override.", + }), + defineOption({ + name: "textAlign", + type: "enum", + values: mjmlAlignOptions, + initialValue: "left", + description: "MJML text alignment override.", + }), + defineOption({ + name: "fontSize", + type: "string", + initialValue: "16px", + description: "Subtitle font size.", + }), + defineOption({ + name: "fontWeight", + type: "string", + initialValue: tokens.font.weightBold, + description: "Subtitle font weight.", + }), + defineOption({ + name: "squareColor", + type: "string", + initialValue: tokens.color.brand, + description: "Background color of the square marker.", + }), + defineOption({ + name: "textContent", + type: "string", + initialValue: "Subtitle", + description: "Subtitle copy content.", + }), + ]), + render: ({ options }): MjmlNode => + Section( + [ + { + tagName: "mj-text", + attributes: { + "align": options.textAlign, + "font-size": options.fontSize, + "font-weight": options.fontWeight, + "padding-left": tokens.layout.containerXPadding, + "padding-right": tokens.layout.containerXPadding, + }, + content: `${squareMarker(options.squareColor)}${options.textContent}`, + }, + ], + { + sectionClass: options.sectionClass, + } + ), +}); + +export const Subtitle = subtitle.component; +export default subtitle; diff --git a/packages/stacks-email/src/lib/registry.ts b/packages/stacks-email/src/lib/registry.ts index 31d31124ec..a959aad25e 100644 --- a/packages/stacks-email/src/lib/registry.ts +++ b/packages/stacks-email/src/lib/registry.ts @@ -6,6 +6,7 @@ import graphic from "../../components/graphic"; import headline from "../../components/headline"; import header from "../../components/header"; import spacer from "../../components/spacer"; +import subtitle from "../../components/subtitle"; import text from "../../components/text"; import title from "../../components/title"; @@ -20,6 +21,7 @@ export const componentDefinitions = [ headline, header, spacer, + subtitle, text, title, ] as const; @@ -35,6 +37,7 @@ export { headline, header, spacer, + subtitle, text, title, transactional, diff --git a/packages/stacks-email/src/lib/tokens.ts b/packages/stacks-email/src/lib/tokens.ts index 8df7b75842..c1e50faedb 100644 --- a/packages/stacks-email/src/lib/tokens.ts +++ b/packages/stacks-email/src/lib/tokens.ts @@ -10,6 +10,7 @@ const color = { brand: "#FF5E00", brandDark: "#201C1D", brandOffWhite: "#eeeeee", + brandYellow: "#ffcc01", text: "#211d1e", textMuted: "#636261", textInvert: "#ffffff", @@ -17,8 +18,7 @@ const color = { link: "#0000ef", linkHover: "#5074ef", lightBlue: "#d8e1ed", - cardOffWhite: "#f0efee", - // Inner surface for inverted (off-black) cards. + surface: "#f0efee", invertSurface: "#4a4a4a", } as const; @@ -31,7 +31,7 @@ const backgroundClasses = [ { name: "accent", value: color.accent }, { name: "block", value: color.blockBackground }, { name: "page", value: color.bodyBackground }, - { name: "card", value: color.cardOffWhite }, + { name: "card", value: color.surface }, { name: "light-blue", value: color.lightBlue }, ] as const; diff --git a/packages/stacks-email/static/email/icons/help.png b/packages/stacks-email/static/email/icons/help.png index 6c37e920cb975da49a5a45ec2f3dbf6a940e21f6..3756e7a84c9fc1160f30cac1be7b51ced8a18abb 100644 GIT binary patch delta 1905 zcmV-%2afpo4yX?yiBL{Q4GJ0x0000DNk~Le0000`0000`2nGNE0FDvzt&t&4e+MH; zL_t(|0qvZl01_cBzq)T_P{y*owH}bHXiwJUFqJds{!cf=;-L^=;-L^=;&x0 z0xO16+Jh{Mfe;D|-U7=9z(@~Ue?}KBWRT*q6n`3Hv#$Yy1R@xvX*z}#vJwG>Xp{T^ zRf+M}80wMWQ~U;Dn4p*`v`ni4(k&VTWM81ZXk{b-;YoUzCeRYC2-t#fi*QqC;eH!J zHYy;RDuqe*71|Q+IRwtpv`?Be?ZyP8+uH}g*f9?Ny`>lP2_hhTEW3t0e;+{@WZ5_D4ODvpAt^q8-PPBJ3mCCma^085-$`PqQ%uMi{1D)(O+* z#FO`y(UQ~5?9>BkpQ7`c{jRy&Il3wP;DB|!>=zR9EXQs2hS&J+!*oltg-e3@nK_ZfM(W(BlPnUutdz_0Kb4c z?|bX(J@o(nFu53Az<+z|YY+J2&a+drxAx|0{5Vd*0*4+*)Hj8_&m~wFJ#SgeYyyKr zIRuzdv^7!B*!My!0mb;CH8W;yy7FG+7`$Qo@Y*)VBA&Ovf364e%*S&ad!LxaD-q$} zj~KTS{*SvFB6VL*OgHHO3=zo;q8SS(xs^p&3dlaG$KVW{AUhT3c(&$n-Z$fZCBCRV z(3;+?F)`S{(`o9#4`6|@fJN7il@mV%*4Y&?5izDW72c!oZjMX;m{hzll2fn&bCH^( zR%UG811A&Je?0iZx_I!6(DHNnB~%fHIQ2^VCahZ=B0;MO1L-%yUg-r6f8#MOQYFeU2FA0wjet}+7HP(h zhLGW2h2O~IaJP(#>dFviJc(GCuqt31r4(XDd=jzuUgJc2l$_^?xHyQr{)LjA@>M7>}zqq1;Ffp+t$UZ^rmJ z`h}?|Ou#>}dPozUlwiCnCo8YAf0PqE>A8a1e`FsN1)zT^C!%n}w5J=JaB;&#IWfui z;I*R5u=DCIi($gj4`f%GV+ZhBVVbrxatRh#Ev%QCvE!n&_A|Entm?d2AMx=yWpzE4 zLy}bwEHz`tMd=lzYqRxU6NgZ)WI3?k-+@;`(fg=+#=f$`TEL>VInkUNHX0jyqVO~I zf5idq)y8OgA%COUD=_7`JczCH~JxIVpF${Rs@_<>@jKhYfst{Zc3{H zPMLkzfh<~&p&p4?YKWrKbOqmHN3;?F=PABtVIWF~Wfg2s_;A{YsR+RoNmRaSu r6}^a%v7@7-qobpvqobpvqdoouIa5MJ>%Ql_00000NkvXXu0mjfLVlF! delta 1857 zcmV-H2fp~I5BLrtiBL{Q4GJ0x0000DNk~Le0000$0000$2nGNE0IF$m-jN|re+KhO zL_t(|0qtA8R^&DmzOtPbhHYUu+9%lAZaYkwYN1R8JOR+9TjE^y%vA{u$sq{@)7^zhBJ)Q~}p*JAu@jKjJHfAAs*U=+vk7&=5J1c*sb(tVs&h(AME3=xj@h zgTOg%jpIeOhBEr(zDC+)C@y*Q$I>z|%anyUaa)|`2;Loxo@5eDm<>_C|fH5V!tssQ=fVrldpmx=P3>2`x~Q#63fHpeINKA-^AqQfB2{4jdkq;V-E?M zwz|plJs;mXPhObKB>9ZnYU&|MLu%>`5*VSGd%1|2TbR7;TUx8ylR+y1v`^9aNPg3_ zKF4Ej4_pS-)L(G4@G{B$rdp`}rjArk-%T=9E(tB`AAm6>JJ4u-IK*>4f`$;M2*A(aj!(Vi7X#gsJ` zec2Z|g4UoHVKcO24uwBcKv@E~=QR(<)fcCT6~cUek9IrZfB*PVB|=|jgmje-peBSf zMl`x$l9nWLjsW&qc_4*Ta01z~+~eNjLGf(nb_Ei>3tG|52NOaQaCcn2xKaF-6Of5U zsI>pVYwX<#Maq~yP{JkZ?&iq&FtHF$QVLCgu?Wr8(l(e$z+|F|7ay!kFoO|lzL#Et z4Zd$SvWT|~e+?kU&V)Tt1`pF`C|H7C2*PeGf<@p0PAfBrauC=hiigEa7CK8Gpz&D9 zl@dEhN&b6A0AU}L`KLTV(Hxo5JO>vLvZIV+3g*SoxIb=M!%pd%hY6be&%AR@aFck? z>hMUr0d|cI9P|+eStI2dn*~ye1W=`6pxHkiL7K}Jf4hPEA#TA{^(Z7W1zp$zDgj-j zqUIqq@{{mAc8!f>w8zO00UZH_#rEI@k$Y~$I(Z&^*>+uws^sd65l|gyg=dL8SqYf8 zn_C4BQrIWY#lG{%Q&9=Vn){s)AY=Qs*Z>*1M0mCDk`CTn_p`aq2+%nWPu&opoXbTJ zxWEYSe`ae6SQu8y`{U`K1p+{0^fYun>Lb)%l~Jd?R;xmIvQLH9oE5^Wac;d8`MF)e-4AF z3uFZxe=0Wue`P9Z`4-47L`vgHx(ja=x(FA~e;yaM*_<7~uGIEyAKogMX6%fV;t?gs z+qex3Nd*_swLY`m7xBS1-ebyZ&TZ+E1Q@qLjN_|yHe}FKKFhBuZDH|eAEyjA=i*%zA=DOf7(4W zv3oKIn+!0cyMg;V)McyGzR^}mlVjI<<;6dlNs=RLl($<%++cf=PiBf}Bn|B#YU`tn zRtT6Pt1;~Ov$=M^MroCRDe`Z9WYJX!iy;!Bx*#}-JNSyU69S6JzB3w#5*%5D(i3O6 v_S0EZXtJW?FA=7fUyHs9llwdV7pwReEAm1_wvikj00000NkvXXu0mjfoN8kX From 75bbc4981994fc483e7467fc69a79bc8494526ad Mon Sep 17 00:00:00 2001 From: David Longworth Date: Wed, 3 Jun 2026 18:19:44 +0100 Subject: [PATCH 26/30] fix color name --- packages/stacks-email/components/callout.ts | 2 +- packages/stacks-email/components/card.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/stacks-email/components/callout.ts b/packages/stacks-email/components/callout.ts index 1b6e77c97e..731fe7262a 100644 --- a/packages/stacks-email/components/callout.ts +++ b/packages/stacks-email/components/callout.ts @@ -40,7 +40,7 @@ const callout = defineEmailComponent({ defineOption({ name: "innerBackground", type: "string", - initialValue: tokens.color.cardOffWhite, + initialValue: tokens.color.surface, description: "Callout background color.", }), defineOption({ diff --git a/packages/stacks-email/components/card.ts b/packages/stacks-email/components/card.ts index 5a76284503..45a4289eec 100644 --- a/packages/stacks-email/components/card.ts +++ b/packages/stacks-email/components/card.ts @@ -103,7 +103,7 @@ const card = defineEmailComponent({ defineOption({ name: "innerBackground", type: "string", - initialValue: tokens.color.cardOffWhite, + initialValue: tokens.color.surface, description: "Inner column background color.", }), defineOption({ From 2335fa00a32451cdf68b707e33829d42596f15e2 Mon Sep 17 00:00:00 2001 From: David Longworth Date: Wed, 3 Jun 2026 18:20:46 +0100 Subject: [PATCH 27/30] remove unused --- packages/stacks-email/components/callout.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stacks-email/components/callout.ts b/packages/stacks-email/components/callout.ts index 731fe7262a..be45b34128 100644 --- a/packages/stacks-email/components/callout.ts +++ b/packages/stacks-email/components/callout.ts @@ -8,7 +8,7 @@ import type { MjmlNode } from "../src/lib/types"; import { Section } from "../src/lib/mjml"; import { textNode } from "./text"; -const { containerXPadding, containerYPadding } = tokens.layout; +const { containerXPadding } = tokens.layout; const iconNode = (src: string, alt: string): MjmlNode => ({ tagName: "mj-image", From 09d4fdffa428c28950f6d8c012993ca56d8837d2 Mon Sep 17 00:00:00 2001 From: David Longworth Date: Thu, 4 Jun 2026 12:11:35 +0100 Subject: [PATCH 28/30] newsletter --- .../docs/public/email/templates/newsletter.md | 5 +- packages/stacks-email/components/card.ts | 34 +++-- packages/stacks-email/components/headline.ts | 5 +- packages/stacks-email/components/text.ts | 7 - .../stacks-email/src/lib/api/templates.ts | 21 +-- packages/stacks-email/src/lib/mjml/config.ts | 3 +- packages/stacks-email/src/lib/registry.ts | 4 +- packages/stacks-email/src/lib/tokens.ts | 6 +- .../static/email/strip/strip-business.png | Bin 0 -> 2602 bytes .../static/email/strip/strip-divider.png | Bin 0 -> 11703 bytes .../static/email/strip/strip-overflow.png | Bin 0 -> 26203 bytes .../static/email/strip/strip-webinar.png | Bin 0 -> 16943 bytes packages/stacks-email/templates/newsletter.ts | 134 ++++++++++++++++++ 13 files changed, 180 insertions(+), 39 deletions(-) create mode 100644 packages/stacks-email/static/email/strip/strip-business.png create mode 100644 packages/stacks-email/static/email/strip/strip-divider.png create mode 100644 packages/stacks-email/static/email/strip/strip-overflow.png create mode 100644 packages/stacks-email/static/email/strip/strip-webinar.png create mode 100644 packages/stacks-email/templates/newsletter.ts diff --git a/packages/stacks-docs/src/docs/public/email/templates/newsletter.md b/packages/stacks-docs/src/docs/public/email/templates/newsletter.md index 950a6909fd..c841fcb644 100644 --- a/packages/stacks-docs/src/docs/public/email/templates/newsletter.md +++ b/packages/stacks-docs/src/docs/public/email/templates/newsletter.md @@ -7,6 +7,7 @@ updated: 2026-06-01 ## Requirements @@ -125,4 +126,6 @@ See the overview below outlining the required components, their variations, and -**Coming soon** +## Preview + + diff --git a/packages/stacks-email/components/card.ts b/packages/stacks-email/components/card.ts index 45a4289eec..0b06cd3dd4 100644 --- a/packages/stacks-email/components/card.ts +++ b/packages/stacks-email/components/card.ts @@ -172,10 +172,10 @@ const card = defineEmailComponent({ defineOption({ name: "ctaStyle", type: "enum", - values: ["plain", "button", "arrow", "none"], + values: ["plain", "button", "arrow", "title", "none"], initialValue: "plain", description: - "Call-to-action style: `plain` text link (default), `button`, `arrow` (icon square), or `none`.", + "Call-to-action style: `plain` text link (default), `button`, `arrow` (icon square), `title` (links the title instead of a separate CTA), or `none`.", }), defineOption({ name: "arrowBackground", @@ -214,16 +214,23 @@ const card = defineEmailComponent({ items.push({ kind: "title", build: (p) => - textNode(options.titleContent, { - ...(options.titleSize - ? { "font-size": options.titleSize } - : {}), - ...(options.titleColor - ? { color: options.titleColor } - : {}), - "font-weight": options.titleWeight, - "padding": p, - }), + textNode( + // `title` CTA style links the title itself instead of a + // separate CTA below. + options.ctaStyle === "title" + ? `${options.titleContent}` + : options.titleContent, + { + ...(options.titleSize + ? { "font-size": options.titleSize } + : {}), + ...(options.titleColor + ? { color: options.titleColor } + : {}), + "font-weight": options.titleWeight, + "padding": p, + } + ), }); } if (options.textContent.trim() !== "") { @@ -241,6 +248,7 @@ const card = defineEmailComponent({ } const showCta = options.ctaStyle !== "none" && + options.ctaStyle !== "title" && (options.ctaStyle === "arrow" || options.ctaText.trim() !== ""); if (showCta) { @@ -314,13 +322,13 @@ const card = defineEmailComponent({ tagName: "mj-section", attributes: { "mj-class": options.background, + "padding": `0px ${tokens.layout.containerXPadding} ${tokens.layout.containerYPadding}`, }, children: [ { tagName: "mj-column", attributes: { "inner-background-color": options.innerBackground, - "padding": `0px ${tokens.layout.containerXPadding} ${tokens.layout.containerYPadding}`, }, children: [...(image ? [image] : []), ...content], }, diff --git a/packages/stacks-email/components/headline.ts b/packages/stacks-email/components/headline.ts index adafb3d7fd..aafab78284 100644 --- a/packages/stacks-email/components/headline.ts +++ b/packages/stacks-email/components/headline.ts @@ -77,7 +77,7 @@ const headline = defineEmailComponent({ defineOption({ name: "eyebrowClass", type: "string", - initialValue: "s-email-text-subtitle", + initialValue: "s-email-text-subtitle fc-text-muted", description: "Text styling class for the eyebrow node.", }), defineOption({ @@ -100,8 +100,9 @@ const headline = defineEmailComponent({ "mj-class": options.eyebrowClass, "align": options.textAlign, "font-size": tokens.font.eyebrowSize, + "font-weight": "normal", "padding-top": tokens.layout.containerYPadding, - "padding-bottom": "0px", + "padding-bottom": "5px", "padding-left": tokens.layout.containerXPadding, "padding-right": tokens.layout.containerXPadding, }, diff --git a/packages/stacks-email/components/text.ts b/packages/stacks-email/components/text.ts index 815be010a4..7f0dbffdde 100644 --- a/packages/stacks-email/components/text.ts +++ b/packages/stacks-email/components/text.ts @@ -16,9 +16,6 @@ const looksLikeHtml = (value: string) => /<\/?[a-z][\s\S]*>/i.test(value); const renderTextContent = (value: string | undefined) => { const content = value?.trim() ?? ""; - // A bare template placeholder or already-rendered HTML passes through as-is. - // Markdown rendering (html:false) would otherwise escape existing tags, and - // `renderEmailBodyMarkdown` already returns "" for empty input. if (TEMPLATE_PROP_PATTERN.test(content) || looksLikeHtml(content)) { return content; } @@ -26,10 +23,6 @@ const renderTextContent = (value: string | undefined) => { return renderEmailBodyMarkdown(content); }; -// The bare mj-text leaf. `Text` wraps it in a Section for standalone use; other -// components (e.g. Card) compose it directly inside their own column. Content is -// passed already-processed — callers run `renderTextContent` if they want -// markdown handling. export const textNode = ( content: string, attributes: NonNullable diff --git a/packages/stacks-email/src/lib/api/templates.ts b/packages/stacks-email/src/lib/api/templates.ts index aee634d531..49b4fb5f8c 100644 --- a/packages/stacks-email/src/lib/api/templates.ts +++ b/packages/stacks-email/src/lib/api/templates.ts @@ -4,7 +4,7 @@ import type { EmailTemplateMeta, MjmlNode } from "../types"; import { compileMjml, type CompileMjmlOutput } from "../pipeline/compile"; import { compileTemplateInputSchema } from "./request-schemas"; import { expandVariantRecords } from "./records"; -import { normalizeEmailOptions } from "../schema"; +import { normalizeEmailOptions, type EmailTemplateDefinition } from "../schema"; type ExpandedTemplateRecord = { catalog: EmailTemplateCatalogItem; @@ -78,18 +78,19 @@ const expandedTemplateRecords = expandVariantRecords({ category, tokens: withSharedTemplateTokens(tokens), }; - const variantId = variant.id as keyof typeof definition.variants & - string; + // Each template has its own props shape, so `definition` is a union + // across the registered templates. Treat it as the base definition + // (props = Record) for this generic machinery. + const def = definition as EmailTemplateDefinition; + const variantId = variant.id as keyof typeof def.variants & string; type DefinitionProps = Parameters< - typeof definition.renderDocument + typeof def.renderDocument >[0]["props"]; const defaults = - definition.variants[variantId] ?? - definition.variants[definition.defaultVariant] ?? - {}; + def.variants[variantId] ?? def.variants[def.defaultVariant] ?? {}; const resolveProps = (inputProps: Record) => normalizeEmailOptions( - definition.propsSchema, + def.propsSchema, defaults as Partial, inputProps as Partial ); @@ -97,13 +98,13 @@ const expandedTemplateRecords = expandVariantRecords({ return { catalog, renderDocument: (inputProps) => - definition.renderDocument({ + def.renderDocument({ variant: variantId, props: resolveProps(inputProps), }), resolvePreviewText: (inputProps) => { const props = resolveProps(inputProps); - const preview = definition.preview?.({ + const preview = def.preview?.({ variant: variantId, props, }); diff --git a/packages/stacks-email/src/lib/mjml/config.ts b/packages/stacks-email/src/lib/mjml/config.ts index 29896c74a6..88613de6e2 100644 --- a/packages/stacks-email/src/lib/mjml/config.ts +++ b/packages/stacks-email/src/lib/mjml/config.ts @@ -109,9 +109,8 @@ const attributesChildren: MjmlNode[] = [ tagName: "mj-class", attributes: { "name": "s-email-text-subtitle", - "color": color.text, + "color": color.textMuted, "font-size": "14px", - "font-weight": font.weightBold, "line-height": "120%", "padding": "0", }, diff --git a/packages/stacks-email/src/lib/registry.ts b/packages/stacks-email/src/lib/registry.ts index a959aad25e..1b10d1d22e 100644 --- a/packages/stacks-email/src/lib/registry.ts +++ b/packages/stacks-email/src/lib/registry.ts @@ -10,6 +10,7 @@ import subtitle from "../../components/subtitle"; import text from "../../components/text"; import title from "../../components/title"; +import newsletter from "../../templates/newsletter"; import transactional from "../../templates/transactional"; export const componentDefinitions = [ @@ -26,7 +27,7 @@ export const componentDefinitions = [ title, ] as const; -export const templateDefinitions = [transactional] as const; +export const templateDefinitions = [newsletter, transactional] as const; export { button, @@ -40,5 +41,6 @@ export { subtitle, text, title, + newsletter, transactional, }; diff --git a/packages/stacks-email/src/lib/tokens.ts b/packages/stacks-email/src/lib/tokens.ts index c1e50faedb..d68818aec5 100644 --- a/packages/stacks-email/src/lib/tokens.ts +++ b/packages/stacks-email/src/lib/tokens.ts @@ -2,7 +2,7 @@ * Color tokens used for all email component palettes, state surfaces, and links. */ const color = { - bodyBackground: "#eee", + bodyBackground: "#f9f8f8", background: "#fff", blockBackground: "#fff", accent: "#ffcc01", @@ -15,10 +15,10 @@ const color = { textMuted: "#636261", textInvert: "#ffffff", textFooter: "#cdc8c2", - link: "#0000ef", + link: "#2445b4", linkHover: "#5074ef", lightBlue: "#d8e1ed", - surface: "#f0efee", + surface: "#eee", invertSurface: "#4a4a4a", } as const; diff --git a/packages/stacks-email/static/email/strip/strip-business.png b/packages/stacks-email/static/email/strip/strip-business.png new file mode 100644 index 0000000000000000000000000000000000000000..d5aa9f13e675d89e64bd532c69d0b66cac4912a6 GIT binary patch literal 2602 zcmeAS@N?(olHy`uVBq!ia0y~yVA;UHz$n4N1{CoL<9`jLI14-?iy0WiR6&^0Gf3qF zP>``W$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBK3|DzL`iUdT1k0gQ7VI5W_oVoyp7Y6 zfkrL$ba4!+V0?RLfA@TL*#jT@pLz3payp)Jd>B;pKx9)jM^j0`R_BvhiVqYbcI_~o z9WCo(KE0=@yZM2d6YI}^jOk*`xrZE`lFkSF39o1~(a z&SnO>m?4o#3`AuPKdPG%=&y4vyZ`^YxM$1nwGU7Kvze0z_U0VH`uZQ&-ps!*weQW; zhb6^M96*&0vl>8A2&d{3<}$wDv(tLbuCMpFi$C}4^8sZGesM;mA9%g~UwOpVJ9SD4 zKr0SNK5P*HN;Q};at{e=%TWe~&xhaJ&EkG;FWL9T#=`vc+pUZY4U^Rp7!NFzzWzNe z`R~bsCz~s_)>PL56Vnn)MHZmP6nqab4K^y*a8A|s^7|5DcW>`6x5(!Ox(^aVyUX+W zR=>LwFLy2*l*;GS`Eh_!gNHCMlMe+dnuo#QH~Vz{`Ra!!HuuW`Jto2t!BFtRfBSzU z@&7vwWp;l4_o*OGnt{P06Q9?j_xyYHzb(|7|J>Q4>f8rAZX8a(=?n~kc~N^lKVv_< z>U`X+>G|vVWyGt0=u`u9!GU0WrVW+Q!|G08kTS6R`MMpFyi=YZOxpK<@^p5FeeY@- zQ8GSiF@T%LJcTm?l$w^1OX+^&*W+K!XIEuXFz3y~&2JQehWYE()xX}Gcy;dg>Fu9y z9Vpz`etuIsP~7I}z!Wl|!h(TeLc!x-_hN3^NQ6QBDj05NKWFXg|MFq9C>wGO85jgU zyt_NQ_@lk_;fd_>Z{*L-=VPe33$k40&i(%fZQ?j0;*2)S`EH1+9% zS}6@Jw|Cpm+xlCde>IB^h~H8FW}hMZ_w8+=OkxL$KeWf&&6}?GC);Avow(Wa|NpuT zED1k^0zG{GV0oOSz5c!r1s0n&*5v`U&SSSPWCzlNv5bdSJD@}XEC5eEYcGEI=lJ1O zPruz_Q1=E^Ne_z6*S$Y`?J7|4>~%fff=zP-Ko!*lV0A_s^-Xi8;_T1s|4e%e)W_iI L>gTe~DWM4f$%J1c literal 0 HcmV?d00001 diff --git a/packages/stacks-email/static/email/strip/strip-divider.png b/packages/stacks-email/static/email/strip/strip-divider.png new file mode 100644 index 0000000000000000000000000000000000000000..f8d713cc685d5c0c9e2e7841180a752e63f08ea9 GIT binary patch literal 11703 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 literal 0 HcmV?d00001 diff --git a/packages/stacks-email/static/email/strip/strip-overflow.png b/packages/stacks-email/static/email/strip/strip-overflow.png new file mode 100644 index 0000000000000000000000000000000000000000..512d022f12b05de9ffec67853c146e3c4d216bad GIT binary patch literal 26203 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 U@wN;2-!CVO^l#`@>!PCn4`+iSkpKVy literal 0 HcmV?d00001 diff --git a/packages/stacks-email/static/email/strip/strip-webinar.png b/packages/stacks-email/static/email/strip/strip-webinar.png new file mode 100644 index 0000000000000000000000000000000000000000..db59a144deb4d909852222b1b77e4bf70c63d8e5 GIT binary patch literal 16943 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 H-RJ)U8I}oY literal 0 HcmV?d00001 diff --git a/packages/stacks-email/templates/newsletter.ts b/packages/stacks-email/templates/newsletter.ts new file mode 100644 index 0000000000..72ee8d8966 --- /dev/null +++ b/packages/stacks-email/templates/newsletter.ts @@ -0,0 +1,134 @@ +import { Card } from "../components/card"; +import { Footer } from "../components/footer"; +import { Graphic } from "../components/graphic"; +import { Header } from "../components/header"; +import { Title } from "../components/title"; +import { Headline } from "../components/headline"; +import { Text } from "../components/text"; + +import { + defineEmailTemplate, + defineOption, + defineOptions, +} from "../src/lib/schema"; +import { tokens } from "../src/lib/tokens"; +import type { MjmlNode } from "../src/lib/types"; + +// `Card()` returns a full `mj-section`, which can't nest inside an `mj-column`. +// To place two cards side by side we lift each card's inner `mj-column` out and +// recompose them into one section. (A dedicated `cardColumn` export on the card +// component would be the cleaner long-term fix.) +const twoColumnCards = (left: MjmlNode, right: MjmlNode): MjmlNode => { + // Symmetric horizontal padding on each column (it sits outside the column's + // `inner-background-color`, so the section shows through as a gutter). Keeping + // both sides equal means the cards stay aligned when they stack to full width + // on mobile — asymmetric padding would zig-zag. Section X padding + the column + // padding sums to a single card's outer gutter (16 + 8 = 24). + const column = (card: MjmlNode): MjmlNode => { + const col = (card.children ?? [])[0] ?? { + tagName: "mj-column", + children: [], + }; + return { + ...col, + attributes: { + ...col.attributes, + "width": "50%", + "padding-left": tokens.spacing.sm, + "padding-right": tokens.spacing.sm, + }, + }; + }; + + return { + tagName: "mj-section", + attributes: { + "mj-class": "bg-block", + "padding": `0px ${tokens.spacing.md} ${tokens.layout.containerYPadding}`, + }, + children: [column(left), column(right)], + }; +}; + +const headlinePlaceholder = + "Lorem ipsum dolor sit amet consectetur. Dolor vitae dapibus a netus mattis est"; + +const bodyPlaceholder = `The entire software development lifecycle has been dramatically changed by AI, introducing a new model for team organization and leadership. + +AI has accelerated coding, allowing developers to dedicate more time to complex and creative tasks. Simultaneously, it enables teams to clear bottlenecks of repetitive tasks through automation, allowing leaders to create more agile teams and focus on higher-level strategic problems. + +Ultimately, it is really AI’s ability to automate the “work around the work” that is proving to be transformative for organizations.`; + +const newsletter = defineEmailTemplate({ + slug: "newsletter", + propsSchema: defineOptions([ + defineOption({ + name: "previewText", + type: "string", + initialValue: "The Overflow", + description: "Inbox preheader text inserted into ``.", + }), + ]), + tokens: [], + preview: ({ props }) => ({ previewText: props.previewText }), + renderDocument: (): MjmlNode => ({ + tagName: "mjml", + children: [ + { + tagName: "mj-body", + attributes: { + "background-color": tokens.color.bodyBackground, + }, + children: [ + Header("brand"), + Headline("default", { + sectionClass: "bg-card", + eyebrow: "The Overflow", + textContent: headlinePlaceholder, + }), + Text("body", { + textClass: "s-email-text-subtitle", + columnClass: "bg-card", + textContent: "01 January 1970", + }), + Graphic("strip", { + imageSrc: "/email/strip/strip-overflow.png" + }), + Text("body", { textContent: bodyPlaceholder }), + Title("default", { textContent: "Featured" }), + twoColumnCards( + Card("vertical", { + ctaStyle: "title", + titleContent: "Vibe coding without code knowledge", + textContent: "With AI, being able to code has never been easier. But is it any good? Here is what vibe coding is like for someone without technical skills." + }), + Card("vertical", { + ctaStyle: "title", + titleContent: "What is cloud computing and why use it?", + textContent: "In this No Dumb Questions, we’re joined by tech lead for the infrastructure team, to learn about the cloud, compute, and data centers." + }) + ), + Title("default", { + textContent: "Interesting Questions", + }), + Card("vertical", { imageSrc: "", ctaStyle: "title" }), + Card("vertical", { imageSrc: "", ctaStyle: "title" }), + Graphic("strip", { + imageSrc: "/email/strip/strip-divider.png" + }), + Title("default", { + textContent: "Links from around the web", + }), + Card("vertical", { imageSrc: "", ctaStyle: "title" }), + Card("vertical", { imageSrc: "", ctaStyle: "title" }), + Card("horizontal-left", { + ctaStyle: "button", + }), + Footer("social"), + ], + }, + ], + }), +}); + +export default newsletter; From eb233033549467be48cfb1ec007432bd597cc1e6 Mon Sep 17 00:00:00 2001 From: David Longworth Date: Thu, 4 Jun 2026 14:20:35 +0100 Subject: [PATCH 29/30] add promo style --- packages/stacks-email/components/card.ts | 4 +- packages/stacks-email/components/graphic.ts | 5 +- packages/stacks-email/package.json | 1 - .../stacks-email/src/lib/api/templates.ts | 7 +- packages/stacks-email/src/lib/mjml/json.ts | 59 +++++++----- packages/stacks-email/src/lib/registry.ts | 8 +- .../stacks-email/src/types/json2mjml.d.ts | 4 - .../src/ui/TemplateSidebar.svelte | 33 +++++-- .../static/email/promos/helix.png | Bin 0 -> 42754 bytes .../static/email/promos/rotor-1.png | Bin 0 -> 37913 bytes .../static/email/promos/rotor.png | Bin 0 -> 32894 bytes .../static/email/promos/tumble.png | Bin 0 -> 23277 bytes .../static/email/promos/ziggurat.png | Bin 0 -> 31010 bytes .../static/email/strip/strip-divider.png | Bin 11703 -> 25802 bytes packages/stacks-email/templates/newsletter.ts | 12 +-- .../stacks-email/templates/promotional.ts | 85 ++++++++++++++++++ packages/stacks-email/vite.config.ts | 3 - 17 files changed, 166 insertions(+), 55 deletions(-) delete mode 100644 packages/stacks-email/src/types/json2mjml.d.ts create mode 100644 packages/stacks-email/static/email/promos/helix.png create mode 100644 packages/stacks-email/static/email/promos/rotor-1.png create mode 100644 packages/stacks-email/static/email/promos/rotor.png create mode 100644 packages/stacks-email/static/email/promos/tumble.png create mode 100644 packages/stacks-email/static/email/promos/ziggurat.png create mode 100644 packages/stacks-email/templates/promotional.ts diff --git a/packages/stacks-email/components/card.ts b/packages/stacks-email/components/card.ts index 0b06cd3dd4..a16f9f0c8f 100644 --- a/packages/stacks-email/components/card.ts +++ b/packages/stacks-email/components/card.ts @@ -293,13 +293,13 @@ const card = defineEmailComponent({ // 1:1 image beside the content as two columns. const imageColumn: MjmlNode = { tagName: "mj-column", - attributes: { width: "40%" }, + attributes: { width: "33%" }, children: image ? [image] : [], }; const contentColumn: MjmlNode = { tagName: "mj-column", attributes: { - width: hasImage ? "60%" : "100%", + width: hasImage ? "66%" : "100%", padding: "0px", }, children: content, diff --git a/packages/stacks-email/components/graphic.ts b/packages/stacks-email/components/graphic.ts index fab83e34bf..d69bcbf418 100644 --- a/packages/stacks-email/components/graphic.ts +++ b/packages/stacks-email/components/graphic.ts @@ -16,16 +16,13 @@ const graphic = defineEmailComponent({ imageSrc: "/email/hero/1200x630.png", imageAlt: "Hero placeholder image", imageWidth: "1200px", - imageHeight: "auto", imageAlign: "center", }, strip: { imageSrc: "/email/strip/600x140.png", imageAlt: "Strip placeholder image", imageWidth: "600px", - imageHeight: "140px", imageAlign: "center", - // Full-bleed imagePaddingLeft: "0px", imagePaddingRight: "0px", }, @@ -59,7 +56,7 @@ const graphic = defineEmailComponent({ defineOption({ name: "imageHeight", type: "string", - initialValue: "140px", + initialValue: "auto", description: 'Rendered height, for example "630px".', }), defineOption({ diff --git a/packages/stacks-email/package.json b/packages/stacks-email/package.json index 1e526c572a..dad6091293 100644 --- a/packages/stacks-email/package.json +++ b/packages/stacks-email/package.json @@ -39,7 +39,6 @@ "@stackoverflow/stacks": "*", "@stackoverflow/stacks-svelte": "*", "highlight.js": "^11.11.1", - "json2mjml": "^1.0.3", "markdown-it": "^14.1.0", "mjml": "^4.17.1", "zod": "^4.1.12" diff --git a/packages/stacks-email/src/lib/api/templates.ts b/packages/stacks-email/src/lib/api/templates.ts index 49b4fb5f8c..69ce776b19 100644 --- a/packages/stacks-email/src/lib/api/templates.ts +++ b/packages/stacks-email/src/lib/api/templates.ts @@ -68,14 +68,17 @@ const expandedTemplateRecords = expandVariantRecords({ slug, name, description, - category, + baseName, tokens, }): ExpandedTemplateRecord => { const catalog = { slug, name, description, - category, + // Group by the base template (e.g. "Transactional", "Newsletter") + // so each template's variants nest under it, rather than lumping + // every template under one default category. + category: baseName, tokens: withSharedTemplateTokens(tokens), }; // Each template has its own props shape, so `definition` is a union diff --git a/packages/stacks-email/src/lib/mjml/json.ts b/packages/stacks-email/src/lib/mjml/json.ts index c1eed232ab..af5e550c1d 100644 --- a/packages/stacks-email/src/lib/mjml/json.ts +++ b/packages/stacks-email/src/lib/mjml/json.ts @@ -1,30 +1,45 @@ -import * as json2mjmlModule from "json2mjml"; +import type { MjmlNode, MjmlAttributeValue } from "../types"; -import type { MjmlNode } from "../types"; - -type Json2Mjml = (node: unknown) => string; +const serializeAttributes = ( + attributes: Record | undefined +) => { + if (!attributes) { + return ""; + } + return Object.entries(attributes) + .map( + ([key, value]) => + ` ${key}="${String(value).replaceAll('"', """)}"` + ) + .join(""); +}; -// json2mjml is a old CJS module; depending on the bundler's interop the callable -// can land directly on the namespace or nested under one or more `default`s. +const serializeNode = (node: MjmlNode, depth: number): string => { + const indent = " ".repeat(depth); + const open = `<${node.tagName}${serializeAttributes(node.attributes)}>`; + const close = ``; + const children = node.children ?? []; + const content = node.content ?? ""; -const resolveJson2Mjml = (value: unknown): Json2Mjml => { - let candidate = value; - while ( - candidate && - typeof candidate === "object" && - "default" in candidate - ) { - candidate = (candidate as { default: unknown }).default; + // Leaf: keep content verbatim — mj-text holds raw HTML and mj-style holds CSS. + if (children.length === 0) { + return `${indent}${open}${content}${close}`; } - if (typeof candidate !== "function") { - throw new Error("json2mjml export is not callable"); - } - return candidate as Json2Mjml; -}; -const json2mjml = resolveJson2Mjml(json2mjmlModule); + const inner = children + .map((child) => serializeNode(child, depth + 1)) + .join("\n"); + const body = content ? `${content}\n${inner}` : inner; + return `${indent}${open}\n${body}\n${indent}${close}`; +}; -export const mjmlJsonToString = (source: MjmlNode | MjmlNode[]) => { +/** + * Serialize an MjmlNode tree (or list) to an MJML string. A small in-house + * serializer (replacing the CJS `json2mjml` dependency): node content is emitted + * verbatim so inline HTML inside `mj-text` and CSS inside `mj-style` are + * preserved, and the tree is indented for readability. + */ +export const mjmlJsonToString = (source: MjmlNode | MjmlNode[]): string => { const nodes = Array.isArray(source) ? source : [source]; - return nodes.map((node) => json2mjml(node)).join("\n"); + return nodes.map((node) => serializeNode(node, 0)).join("\n"); }; diff --git a/packages/stacks-email/src/lib/registry.ts b/packages/stacks-email/src/lib/registry.ts index 1b10d1d22e..418328141f 100644 --- a/packages/stacks-email/src/lib/registry.ts +++ b/packages/stacks-email/src/lib/registry.ts @@ -11,6 +11,7 @@ import text from "../../components/text"; import title from "../../components/title"; import newsletter from "../../templates/newsletter"; +import promotional from "../../templates/promotional"; import transactional from "../../templates/transactional"; export const componentDefinitions = [ @@ -27,7 +28,11 @@ export const componentDefinitions = [ title, ] as const; -export const templateDefinitions = [newsletter, transactional] as const; +export const templateDefinitions = [ + newsletter, + promotional, + transactional, +] as const; export { button, @@ -42,5 +47,6 @@ export { text, title, newsletter, + promotional, transactional, }; diff --git a/packages/stacks-email/src/types/json2mjml.d.ts b/packages/stacks-email/src/types/json2mjml.d.ts deleted file mode 100644 index 579f6b5e7d..0000000000 --- a/packages/stacks-email/src/types/json2mjml.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module "json2mjml" { - const json2mjml: (node: unknown) => string; - export default json2mjml; -} diff --git a/packages/stacks-email/src/ui/TemplateSidebar.svelte b/packages/stacks-email/src/ui/TemplateSidebar.svelte index 78b4fbdcf7..a04104e3a7 100644 --- a/packages/stacks-email/src/ui/TemplateSidebar.svelte +++ b/packages/stacks-email/src/ui/TemplateSidebar.svelte @@ -13,33 +13,52 @@ activeSlug?: string; } = $props(); + // Preferred ordering only; any other category is appended so a new + // template is never silently dropped from the sidebar. const categoryOrder: TemplateCategory[] = [ "Transactional", + "Newsletter", + "Promotional", "Marketing", "Onboarding", ]; - const templateGroups = $derived.by(() => - categoryOrder + const templateGroups = $derived.by(() => { + const present = [...new Set(templates.map((t) => t.category))]; + const ordered = [ + ...categoryOrder.filter((category) => present.includes(category)), + ...present + .filter((category) => !categoryOrder.includes(category)) + .sort(), + ]; + return ordered .map((category) => ({ category, - items: templates.filter((template) => template.category === category), + items: templates.filter( + (template) => template.category === category + ), })) - .filter((group) => group.items.length > 0), - ); + .filter((group) => group.items.length > 0); + });