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} + + {/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 0000000000..b8379f91ae Binary files /dev/null and b/packages/stacks-docs/src/docs/public/email/templates/email-template-newsletter.png differ diff --git a/packages/stacks-docs/src/docs/public/email/templates/email-template-promotional.png b/packages/stacks-docs/src/docs/public/email/templates/email-template-promotional.png new file mode 100644 index 0000000000..551f73ec69 Binary files /dev/null and b/packages/stacks-docs/src/docs/public/email/templates/email-template-promotional.png differ 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 0000000000..f71af8f428 Binary files /dev/null and b/packages/stacks-docs/src/docs/public/email/templates/email-template-transactional.png differ diff --git a/packages/stacks-docs/src/docs/public/email/templates/newsletter.md b/packages/stacks-docs/src/docs/public/email/templates/newsletter.md new file mode 100644 index 0000000000..950a6909fd --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/templates/newsletter.md @@ -0,0 +1,128 @@ +--- +title: Newsletter +description: A newsletter is a recurring pieces of comms that may contain various items and call to actions. +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 + Choose the headline variant that fits the newsletter type. Keep headline copy concise and update visuals across editions where appropriate. + + +
Footer1All emails end with a simple branded footer. + +
Text block (2 variants)0-1 + Optional text block for supporting copy. Keep content concise and include links/CTA only where needed. +
Secondary content0-1 + Optional secondary module for additional but unrelated content. +
DividersAs needed + Use visual dividers to separate repeated simple-card style sections. +
CTA cards0-2 + Optional graphic-led card modules combining short copy and a CTA. +
Link cards0-4 + Optional cards for highlighting resources without additional asset-heavy context. +
Secondary information (3 variants)0-1 + Optional secondary information block with variant styles for different contexts. +
Quote0-1 + Optional quote block for extra context or color, limited to one per email. +
Highlights0-1 + Optional text-and-illustration highlight section for a strong ending block. +
+
+ +**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 0000000000..c91e85e821 Binary files /dev/null and b/packages/stacks-docs/static/social/instagram.png differ diff --git a/packages/stacks-docs/static/social/linkedin.png b/packages/stacks-docs/static/social/linkedin.png new file mode 100644 index 0000000000..86be31d706 Binary files /dev/null and b/packages/stacks-docs/static/social/linkedin.png differ diff --git a/packages/stacks-docs/static/social/threads.png b/packages/stacks-docs/static/social/threads.png new file mode 100644 index 0000000000..c9c391aaab Binary files /dev/null and b/packages/stacks-docs/static/social/threads.png differ diff --git a/packages/stacks-docs/static/social/x.png b/packages/stacks-docs/static/social/x.png new file mode 100644 index 0000000000..5e99928f52 Binary files /dev/null and b/packages/stacks-docs/static/social/x.png differ diff --git a/packages/stacks-docs/static/social/youtube.png b/packages/stacks-docs/static/social/youtube.png new file mode 100644 index 0000000000..59e9fac0e2 Binary files /dev/null and b/packages/stacks-docs/static/social/youtube.png differ diff --git a/packages/stacks-docs/static/stack-overflow-business-logo.png b/packages/stacks-docs/static/stack-overflow-business-logo.png new file mode 100644 index 0000000000..1f04de3b59 Binary files /dev/null and b/packages/stacks-docs/static/stack-overflow-business-logo.png differ diff --git a/packages/stacks-docs/static/stack-overflow-logo-off-white.png b/packages/stacks-docs/static/stack-overflow-logo-off-white.png new file mode 100644 index 0000000000..df97c6cf0f Binary files /dev/null and b/packages/stacks-docs/static/stack-overflow-logo-off-white.png differ diff --git a/packages/stacks-docs/static/stack-overflow-logo.png b/packages/stacks-docs/static/stack-overflow-logo.png new file mode 100644 index 0000000000..08f8098795 Binary files /dev/null and b/packages/stacks-docs/static/stack-overflow-logo.png differ diff --git a/packages/stacks-docs/svelte.config.js b/packages/stacks-docs/svelte.config.js index 0091192d7b..986e768265 100644 --- a/packages/stacks-docs/svelte.config.js +++ b/packages/stacks-docs/svelte.config.js @@ -4,7 +4,6 @@ import adapter from "@sveltejs/adapter-netlify"; import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; import rehypeSlug from "rehype-slug"; -import rehypeSectionize from "@hbsnow/rehype-sectionize"; import rehypeAutolinkHeadings from "rehype-autolink-headings"; import extractToc from "@stefanprobst/rehype-extract-toc"; import hljs from "highlight.js"; @@ -37,11 +36,14 @@ const config = { rehypeSlug, extractToc, exposeToc, - rehypeSectionize, + markHeadingsInsideGrid, + sectionizeTopLevelHeadings, [ rehypeAutolinkHeadings, { behavior: "append", + test: (node) => + 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 0000000000..2bbd4bf221 Binary files /dev/null and b/packages/stacks-email/static/email/hero/1200x630.png differ 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 0000000000..c91e85e821 Binary files /dev/null and b/packages/stacks-email/static/email/social/instagram.png differ diff --git a/packages/stacks-email/static/email/social/linkedin.png b/packages/stacks-email/static/email/social/linkedin.png new file mode 100644 index 0000000000..86be31d706 Binary files /dev/null and b/packages/stacks-email/static/email/social/linkedin.png differ 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 0000000000..c9c391aaab Binary files /dev/null and b/packages/stacks-email/static/email/social/threads.png differ diff --git a/packages/stacks-email/static/email/social/x.png b/packages/stacks-email/static/email/social/x.png new file mode 100644 index 0000000000..5e99928f52 Binary files /dev/null and b/packages/stacks-email/static/email/social/x.png differ diff --git a/packages/stacks-email/static/email/social/youtube.png b/packages/stacks-email/static/email/social/youtube.png new file mode 100644 index 0000000000..59e9fac0e2 Binary files /dev/null and b/packages/stacks-email/static/email/social/youtube.png differ diff --git a/packages/stacks-email/static/email/spots/SpotLock.png b/packages/stacks-email/static/email/spots/SpotLock.png new file mode 100644 index 0000000000..bd3d34e28f Binary files /dev/null and b/packages/stacks-email/static/email/spots/SpotLock.png differ diff --git a/packages/stacks-email/static/email/stack-overflow-business-logo.png b/packages/stacks-email/static/email/stack-overflow-business-logo.png new file mode 100644 index 0000000000..1f04de3b59 Binary files /dev/null and b/packages/stacks-email/static/email/stack-overflow-business-logo.png differ diff --git a/packages/stacks-email/static/email/stack-overflow-logo-off-white.png b/packages/stacks-email/static/email/stack-overflow-logo-off-white.png new file mode 100644 index 0000000000..df97c6cf0f Binary files /dev/null and b/packages/stacks-email/static/email/stack-overflow-logo-off-white.png differ diff --git a/packages/stacks-email/static/email/stack-overflow-logo.png b/packages/stacks-email/static/email/stack-overflow-logo.png new file mode 100644 index 0000000000..08f8098795 Binary files /dev/null and b/packages/stacks-email/static/email/stack-overflow-logo.png differ diff --git a/packages/stacks-email/svelte.config.js b/packages/stacks-email/svelte.config.js new file mode 100644 index 0000000000..31342d3cfa --- /dev/null +++ b/packages/stacks-email/svelte.config.js @@ -0,0 +1,34 @@ +import { mdsvex } from "mdsvex"; +import adapter from "@sveltejs/adapter-auto"; +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; +import hljs from "highlight.js"; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: [ + vitePreprocess(), + mdsvex({ + extension: ".md", + highlight: { + highlighter: (code, lang) => { + 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()], +});