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..c471ebf129 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,94 @@ "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", + "mjml-parser-xml": "^4.18.0", + "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..d9170adef3 --- /dev/null +++ b/packages/stacks-docs/src/components/EmailOptionsTable.svelte @@ -0,0 +1,94 @@ + + +
+ + + + + + + + + + + {#each resolvedRows as row (row.argument)} + + + + + + + {/each} + +
ArgumentTypeDefaultDescription
{row.argument}{row.type} + {#if hasDefaultValue(row.defaultValue)} + {#if row.renderDefaultValueAsCode === 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..631c9cd1d2 --- /dev/null +++ b/packages/stacks-docs/src/components/StacksEmailEmbed.svelte @@ -0,0 +1,461 @@ + + + + +
+
+ + {#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} +
+ + Loading… +
+ {: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/callout.md b/packages/stacks-docs/src/docs/public/email/components/callout.md new file mode 100644 index 0000000000..a3928cebd6 --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/components/callout.md @@ -0,0 +1,25 @@ +--- +title: Callout +description: A padded callout box with an optional icon. +--- + + + +## Variants + +### Default + + + +### With icon + +A 32x32 icon in a left column beside the copy. + + + +## Options + + diff --git a/packages/stacks-docs/src/docs/public/email/components/cards.md b/packages/stacks-docs/src/docs/public/email/components/cards.md new file mode 100644 index 0000000000..0a9e5bd780 --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/components/cards.md @@ -0,0 +1,43 @@ +--- +title: Cards +description: Content cards with an optional image, a selectable background surface, and vertical or horizontal layouts. +--- + + + +## Variants + +### Vertical + +The default layout — the image is stacked above the content. + + + +### Horizontal + +A 1:1 image beside the content. `horizontal-left` places the image first; +`horizontal-right` places it after the content. Horizontal layouts assume a +square (1:1) image. + + + +### Link + +A compact link row — title and an arrow CTA only, with no image or body. The +title is 18px (not bold) and the CTA uses the off-black arrow square. + + + +### Link (inverted) + +The link row on an off-black surface: a dark-grey inner background, white title, +and an off-white arrow square. + + + +## Options + + diff --git a/packages/stacks-docs/src/docs/public/email/components/component-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-callout.svg b/packages/stacks-docs/src/docs/public/email/components/component-callout.svg new file mode 100644 index 0000000000..632e84d27b --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/components/component-callout.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/stacks-docs/src/docs/public/email/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-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-spacer.svg b/packages/stacks-docs/src/docs/public/email/components/component-spacer.svg new file mode 100644 index 0000000000..9915a4de39 --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/components/component-spacer.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/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..7d7a8c809d --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/components/graphic.md @@ -0,0 +1,33 @@ +--- +title: Graphic +description: Image block variants for spot, hero, and strip placements. +--- + + + +## Variants + +### Spot + +140x140 left-aligned placeholder. + + + +### Hero + +6000x315 constrained with left/right container padding. + + + +### Strip + +600x140 full-bleed banner — spans the full email width with no side 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..ae4129fa7f --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/components/headline.md @@ -0,0 +1,44 @@ +--- +title: Headline +description: Large headline block with default and highlighted background treatments. +--- + + + +## Variants + +### Default + + + +### Inverted + + + +### Highlight + +Wraps the headline text in a highlighted background that reads as one continuous +block across multiple lines. Best for short, art-directed headlines. + +Line breaks are author-controlled with `\n` in `textContent` and each line gets +its own highlighted span (joined with `
`). + +Vertical padding sits only on the +outer edges — the first line keeps its top padding, the last line keeps its +bottom padding, and interior lines get horizontal padding only — so the lines +join into a single block rather than separate pills. + +Horizontal padding is +constant so the left/right edges align. + +A single line with no `\n` is padded on +all sides. + + + +## Options + + diff --git a/packages/stacks-docs/src/docs/public/email/components/spacer.md b/packages/stacks-docs/src/docs/public/email/components/spacer.md new file mode 100644 index 0000000000..c66d477db3 --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/components/spacer.md @@ -0,0 +1,23 @@ +--- +title: Spacer +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..deafb163ee --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/components/subtitle.md @@ -0,0 +1,27 @@ +--- +title: Subtitle +description: A small heading level with a colored square marker, in medium and small weights. +--- + + + +## Variants + +### Medium + +The default — 16px, bold. + + + +### Small + +14px, normal weight. + + + +## 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..b457e15e90 --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/overview.md @@ -0,0 +1,278 @@ +--- +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. + + + + +[![Callout component preview](./components/component-callout.svg)](./components/callout) + +### [Callout](./components/callout) + +Indented and visually distinct box for alerts or important information. + + + + +[![Spacer component preview](./components/component-spacer.svg)](./components/spacer) + +### [Spacer](./components/spacer) + +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" + } + }, + { + "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..e230eb8360 --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/templates/newsletter.md @@ -0,0 +1,138 @@ +--- +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. +
+
+ +## Preview + + + +## Options + +Props accepted when compiling the template, for example `compileEmailTemplate({ slug: "newsletter", props })`. + + 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..7717fd6967 --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/templates/promotional.md @@ -0,0 +1,138 @@ +--- +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. +
+
+ +## Preview + + + +## Options + +Props accepted when compiling the template, for example `compileEmailTemplate({ slug: "promotional", props })`. + + 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..8cabdba604 --- /dev/null +++ b/packages/stacks-docs/src/docs/public/email/templates/transactional.md @@ -0,0 +1,102 @@ +--- +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 + + + +## Options + +Props accepted when compiling the template, for example `compileEmailTemplate({ slug: "transactional", props })`. + + 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/email/compiled/[kind]/[slug]/[file]/+server.ts b/packages/stacks-docs/src/routes/email/compiled/[kind]/[slug]/[file]/+server.ts new file mode 100644 index 0000000000..3655249901 --- /dev/null +++ b/packages/stacks-docs/src/routes/email/compiled/[kind]/[slug]/[file]/+server.ts @@ -0,0 +1,6 @@ +export { + staticEmailCompiledArtifactEntries as entries, + staticEmailCompiledArtifactGET as GET, +} from "@stackoverflow/stacks-email/sveltekit"; + +export const prerender = true; diff --git a/packages/stacks-docs/src/routes/email/compiled/manifest.json/+server.ts b/packages/stacks-docs/src/routes/email/compiled/manifest.json/+server.ts new file mode 100644 index 0000000000..09f4895a18 --- /dev/null +++ b/packages/stacks-docs/src/routes/email/compiled/manifest.json/+server.ts @@ -0,0 +1,3 @@ +export { staticEmailCompiledManifestGET as GET } from "@stackoverflow/stacks-email/sveltekit"; + +export const prerender = true; diff --git a/packages/stacks-docs/src/structure.yaml b/packages/stacks-docs/src/structure.yaml index 3dd553d43b..e2e2a7bec9 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: "Callout" + slug: "callout" + + - title: "Graphic" + slug: "graphic" + + - title: "Spacer" + slug: "spacer" + + - 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..8bc1b29fe6 --- /dev/null +++ b/packages/stacks-email/README.md @@ -0,0 +1,244 @@ +# Stacks Email + +Stack Overflow’s [MJML](https://mjml.io/) powered email compile engine with a tokenized component library and template library + +- Primary docs/UI lives in `@stackoverflow/stacks-docs` under [`src/docs/public/email`](https://github.com/StackExchange/Stacks/tree/main/packages/stacks-docs/src/docs/public/email) or available on [stackoverflow.design/email](https://stackoverflow.design/email/). +- Full email preview gallery is available at [email.stackoverflow.design](https://email.stackoverflow.design). + +## Component schema definitions + +New components should use the schema helpers in `src/lib/schema` so rendering, +defaults, validation, and generated docs stay in one place. + +```ts +import { + defineEmailComponent, + defineOption, + defineOptions, + mjmlAlignOptions, +} from "../src/lib/schema"; + +const headline = defineEmailComponent({ + slug: "headline", + variants: { + highlight: { + highlight: true, + }, + }, + optionsSchema: defineOptions([ + defineOption({ + name: "textClass", + type: "string", + initialValue: "s-email-text-headline", + description: "Text styling class for the headline node.", + }), + defineOption({ + name: "highlight", + type: "boolean", + initialValue: false, + description: "Applies inline highlighted text styling.", + }), + defineOption({ + name: "textAlign", + type: "enum", + values: mjmlAlignOptions, + initialValue: "left", + description: "MJML text alignment.", + }), + ]), + render: ({ options }) => { + // Return a MJML JSON object + // https://documentation.mjml.io/#using-mjml-in-json + // options.textClass is string + // options.highlight is boolean + }, +}); + +export const Headline = headline.component; +``` + +- `defineOption` is the Sanity-style field declaration for component options. +- `initialValue` is the single source of truth for runtime defaults and the + generated options table default. +- Use `type: "enum"` with a shared `values` tuple for constrained string values + like MJML alignment. The generated schema validates the value and TypeScript + infers the literal union. +- `optional: true` should only be used when omitted is meaningfully different + from the initial value. +- The `default` variant is implied from schema defaults. Add entries in + `variants` only for named variants that override option defaults. Variant + values are plain option overrides. +- `defineOptions([...])` builds the underlying [Zod](https://zod.dev/) schema, so API validation and + render typing are derived from the option declarations. +- Export the `.component` as a PascalCase callable alongside the default export, so templates can compose components directly e.g., `Headline("highlight", { ... })` + +## Template schema definitions + +Templates use the same `defineOption` / `defineOptions` helpers as components, +so defaults, types, and generated docs come from a single schema. Variants are +plain prop overrides — the same shape used for component variants. The default +variant’s values live in the schema’s `initialValue`s; named variants list only +what they change. + +```ts +import { + defineEmailTemplate, + defineOption, + defineOptions, +} from "../src/lib/schema"; +import { Headline } from "../components/headline"; + +const transactional = defineEmailTemplate({ + slug: "transactional", + defaultVariant: "short", + variants: { + long: { + headlineText: "Privacy Policy Update", + }, + }, + propsSchema: defineOptions([ + defineOption({ + name: "headlineText", + type: "string", + initialValue: "Reset your password", + description: "Headline copy rendered near the top of the email.", + }), + ]), + preview: ({ props }) => ({ + previewText: props.headlineText, + }), + renderDocument: ({ variant, props }) => ({ + tagName: "mjml", + children: [ + // Compose components via their named PascalCase exports. + Headline(variant === "long" ? "default" : "highlight", { + textContent: props.headlineText, + }), + ], + }), +}); + +export default transactional; +``` + +`renderDocument` returns the full MJML document tree; the compile pipeline +handles MJML serialization, shared config injection, token transformation, and +HTML output. + +## Run local sandbox + +```bash +npm install +npm run dev -w @stackoverflow/stacks-email +``` + +## API + +The compile engine can be used two ways: as a **deployed HTTP endpoint** that +any service can `POST` to, or as a **library imported directly** into another +app. Both paths run the same pipeline — schema validation, shared +config injection, token transformation, and HTML output — so a component or +template behaves identically however you reach it. + +Every compile takes a `target`, which selects the set of token substitutions +applied to the output. Placeholders like `[[FIRST_NAME]]` or `[[UNSUBSCRIBE_URL]]` +are rewritten per target: + +- `preview` — sample values for previewing in a browser or inbox test. +- `dotnet` — Razor model bindings (`@Model.FirstName`), for the .NET mailer. +- `braze` — Braze Liquid (`{{${first_name}}}`), for the Braze platform. + +### As a deployed endpoint + +The package is a SvelteKit app, so `npm run build` produces a deployable server +(via `@sveltejs/adapter-auto`) that exposes `POST /api/compile`. Use this when a +backend in another language or another service needs compiled email HTML without +embedding the engine itself. + +The endpoint composes a transactional email from an ordered list of `blocks`. +Each block names a component `type` (`headline`, `text`, `button`, `title`, +`spacer`), an optional `variant`/`size`, and optional `props`. The header, +footer, and surrounding spacing are added automatically. + +```bash +curl -X POST https://email.stackoverflow.design/api/compile \ + -H "Content-Type: application/json" \ + --data '{ + "template": "transactional", + "target": "braze", + "previewText": "Reset your password", + "blocks": [ + { "type": "headline", "props": { "textContent": "Reset your password" } }, + { "type": "text", "props": { "textContent": "Click below to continue." } }, + { "type": "button", "props": { "text": "Reset", "href": "[[BUTTON_URL]]" } } + ] + }' +``` + +The response is JSON: `{ html, mjml, renderedMjml, errors, template, target, blockCount }`. +Invalid bodies return `400` with a human-readable `error` describing the failed +field; compile failures return `500`. + +#### 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 ` or it returns `401`. + +```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"}]}' +``` + +### As a library in another app + +The package’s main export (`@stackoverflow/stacks-email`) is a framework-agnostic +API surface — no SvelteKit required. Import it directly when you want to compile +in-process: a build script, a CMS, a queue worker, or your own HTTP server. + +```ts +import { + getEmailCatalog, + compileEmailRenderable, + compileEmailTemplate, + compileEmailComponent, +} from "@stackoverflow/stacks-email"; + +// Discover what can be compiled — every component and template, with their +// variants, options, and the tokens each one exposes. +const { components, templates } = getEmailCatalog(); + +// Compile a full template for the Braze target. +const email = compileEmailTemplate({ + slug: "transactional", + target: "braze", + props: { headlineText: "Reset your password" }, +}); + +console.log(email.html); // ready-to-send HTML + +// Compile a single component (e.g. to render docs or a preview tile). +const button = compileEmailComponent({ slug: "button", target: "preview" }); + +console.log(button.componentHtml); // just the component’s markup + +// Or dispatch by kind when the slug is dynamic. +const result = compileEmailRenderable({ + kind: "template", + slug: "transactional", + target: "dotnet", +}); +``` + +Inputs are validated with Zod, so an unknown `slug` or an invalid `target` +throws with a descriptive message. `getEmailRenderableMeta(kind, slug)` returns +the catalog entry (name, description, tokens, options) without compiling, and +the lower-level `compileMjml` / `transformTokens` primitives are exported for +callers that already have their own MJML source. + +A second export, `@stackoverflow/stacks-email/sveltekit`, provides ready-made +route handlers for serving the pre-compiled static email artifacts (used by +`@stackoverflow/stacks-docs`). diff --git a/packages/stacks-email/components/button.ts b/packages/stacks-email/components/button.ts new file mode 100644 index 0000000000..66516d1a0e --- /dev/null +++ b/packages/stacks-email/components/button.ts @@ -0,0 +1,86 @@ +import { + defineEmailComponent, + defineOption, + defineOptions, + mjmlAlignOptions, +} from "../src/lib/schema"; +import { tokens } from "../src/lib/tokens"; +import type { MjmlNode } from "../src/lib/types"; + +const button = defineEmailComponent({ + slug: "button", + htmlExtraction: { + targetTag: "mj-button", + }, + variants: { + secondary: { + className: "button button__tonal", + cssClass: "button-hover", + text: "Tonal button", + align: "left", + }, + inverted: { + className: "button button__inverted", + cssClass: "button-hover-inverted", + text: "Inverted button", + align: "left", + }, + }, + tokens: [ + { + token: "BUTTON_LABEL", + description: "The text displayed for the button.", + }, + { + token: "BUTTON_URL", + description: "Destination URL for the button.", + }, + ], + optionsSchema: defineOptions([ + defineOption({ + name: "align", + type: "enum", + values: mjmlAlignOptions, + initialValue: "left", + description: "Maps to the MJML align attribute.", + }), + defineOption({ + name: "className", + type: "string", + initialValue: "button", + description: "Button mj-class override.", + }), + defineOption({ + name: "cssClass", + type: "string", + initialValue: "button-hover", + description: "Raw CSS class applied to the button node.", + }), + defineOption({ + name: "href", + type: "string", + initialValue: "[[BUTTON_URL]]", + description: "Target link URL.", + }), + defineOption({ + name: "text", + type: "string", + initialValue: "[[BUTTON_LABEL]]", + description: "Button label content.", + }), + ]), + render: ({ options }): MjmlNode => ({ + tagName: "mj-button", + attributes: { + "mj-class": options.className, + "css-class": options.cssClass, + "href": options.href, + "align": options.align, + "padding": `0px ${tokens.layout.containerXPadding}`, + }, + content: options.text, + }), +}); + +export const Button = button.component; +export default button; diff --git a/packages/stacks-email/components/callout.ts b/packages/stacks-email/components/callout.ts new file mode 100644 index 0000000000..be45b34128 --- /dev/null +++ b/packages/stacks-email/components/callout.ts @@ -0,0 +1,155 @@ +import { + defineEmailComponent, + defineOption, + defineOptions, +} from "../src/lib/schema"; +import { tokens } from "../src/lib/tokens"; +import type { MjmlNode } from "../src/lib/types"; +import { Section } from "../src/lib/mjml"; +import { textNode } from "./text"; + +const { containerXPadding } = tokens.layout; + +const iconNode = (src: string, alt: string): MjmlNode => ({ + tagName: "mj-image", + attributes: { + src: src, + alt: alt, + width: "40px", + align: "left", + padding: "0px", + }, +}); + +const callout = defineEmailComponent({ + slug: "callout", + variants: { + icon: { + iconSrc: "/email/icons/help.png", + iconAlt: "Callout icon", + }, + }, + tokens: [], + optionsSchema: defineOptions([ + defineOption({ + name: "sectionClass", + type: "string", + initialValue: "bg-block", + description: "Optional section mj-class override.", + }), + defineOption({ + name: "innerBackground", + type: "string", + initialValue: tokens.color.surface, + description: "Callout background color.", + }), + defineOption({ + name: "textClass", + type: "string", + initialValue: "s-email-text-body", + description: "Text mj-class override.", + }), + defineOption({ + name: "textContent", + type: "string", + initialValue: "Callout copy goes here.", + description: "Callout body copy. Accepts Markdown.", + }), + defineOption({ + name: "iconSrc", + type: "string", + initialValue: "", + description: + "Optional 40x40 icon shown in a left column. Empty hides it.", + }), + defineOption({ + name: "iconAlt", + type: "string", + initialValue: "Information", + description: "Accessible icon alt text.", + }), + ]), + render: ({ options }): MjmlNode => { + const hasIcon = options.iconSrc.trim() !== ""; + + // Text() with container padding; when an icon is present the left + // padding is reduced to a gap from the icon column. + const body = textNode(options.textContent, { + "mj-class": options.textClass, + "padding-top": "16px", + "padding-bottom": "16px", + "padding-left": hasIcon ? tokens.spacing.md : containerXPadding, + "padding-right": containerXPadding, + }); + + // The section is the outer surface (default white); its X padding insets + // the bg-card callout column to leave a side gutter. + const sectionPadding = { + "padding-top": "0px", + "padding-bottom": "0px", + "padding-left": containerXPadding, + "padding-right": containerXPadding, + }; + + if (!hasIcon) { + return Section([body], { + sectionClass: options.sectionClass, + sectionAttributes: sectionPadding, + columnAttributes: { + "inner-background-color": options.innerBackground, + }, + }); + } + + // Icon variant: an icon in a left column and the copy on the right. + // The background lives on the `mj-group` (not the columns): the group + // fills to the height of the tallest column, so the surface stays + // consistent — per-column backgrounds would each size to their own + // content. The group also keeps the columns side-by-side on mobile. + return { + tagName: "mj-section", + attributes: { + ...(options.sectionClass + ? { "mj-class": options.sectionClass } + : {}), + ...sectionPadding, + }, + children: [ + { + tagName: "mj-group", + attributes: { + "background-color": options.innerBackground, + "width": "100%", + }, + children: [ + { + tagName: "mj-column", + attributes: { + // Widths sum to 100% so the columns fill the group. + "width": "12%", + "vertical-align": "top", + "padding-top": "12px", + "padding-bottom": "12px", + "padding-left": containerXPadding, + }, + children: [ + iconNode(options.iconSrc, options.iconAlt), + ], + }, + { + tagName: "mj-column", + attributes: { + "width": "88%", + "vertical-align": "top", + }, + children: [body], + }, + ], + }, + ], + }; + }, +}); + +export const Callout = callout.component; +export default callout; diff --git a/packages/stacks-email/components/card.ts b/packages/stacks-email/components/card.ts new file mode 100644 index 0000000000..a16f9f0c8f --- /dev/null +++ b/packages/stacks-email/components/card.ts @@ -0,0 +1,341 @@ +import { + defineEmailComponent, + defineOption, + defineOptions, +} from "../src/lib/schema"; +import { tokens } from "../src/lib/tokens"; +import type { MjmlNode } from "../src/lib/types"; +import { Button } from "./button"; +import { textNode } from "./text"; + +const { lg, md, sm } = tokens.spacing; + +const imageNode = ( + imageSrc: string, + imageAlt: string, + href: string +): MjmlNode => ({ + tagName: "mj-image", + attributes: { + "src": imageSrc, + "alt": imageAlt, + "fluid-on-mobile": true, + ...(href.trim() !== "" ? { href } : {}), + }, +}); + +// 40x40 square, no label, with a right-arrow glyph. +const arrowNode = ( + href: string, + background: string, + color: string, + cssClass: string +): MjmlNode => ({ + tagName: "mj-button", + attributes: { + "css-class": cssClass, + "href": href, + "background-color": background, + "color": color, + "width": "40px", + "height": "40px", + "inner-padding": "0px", + "border-radius": "0px", + "align": "right", + "font-size": "18px", + "line-height": "40px", + }, + content: "→", +}); + +const card = defineEmailComponent({ + slug: "card", + defaultVariant: "vertical", + variants: { + "horizontal-left": { + layout: "horizontal-left", + imageSrc: "/email/hero/200x200.png", + background: "bg-light-blue", + }, + "horizontal-right": { + layout: "horizontal-right", + imageSrc: "/email/hero/200x200.png", + background: "bg-light-blue", + }, + // Compact link row: title + arrow only, no image or body. + "link": { + imageSrc: "", + textContent: "", + ctaStyle: "arrow", + titleSize: "18px", + titleWeight: "400", + }, + // Inverted link row: off-black surface, dark-grey inner, white title, + // off-white arrow square. + "link-inverted": { + imageSrc: "", + textContent: "", + ctaStyle: "arrow", + titleSize: "18px", + titleWeight: "400", + background: "bg-invert", + innerBackground: tokens.color.invertSurface, + titleColor: tokens.color.textInvert, + arrowBackground: tokens.color.brandOffWhite, + arrowColor: tokens.color.brandDark, + arrowCssClass: "button-hover-inverted", + }, + }, + tokens: [ + { + token: "CARD_URL", + description: "Link applied to the card image and button.", + }, + ], + optionsSchema: defineOptions([ + defineOption({ + name: "background", + type: "enum", + values: ["bg-card", "bg-block", "bg-light-blue", "bg-invert"], + initialValue: "bg-block", + description: "Card surface (section) color.", + }), + defineOption({ + name: "innerBackground", + type: "string", + initialValue: tokens.color.surface, + description: "Inner column background color.", + }), + defineOption({ + name: "layout", + type: "enum", + values: ["vertical", "horizontal-left", "horizontal-right"], + initialValue: "vertical", + description: + "Card layout. `vertical` stacks the image above the content; `horizontal-left`/`horizontal-right` place a 1:1 image beside it.", + }), + defineOption({ + name: "imageSrc", + type: "string", + initialValue: "/email/hero/1200x630.png", + description: "Optional image URL/path. Empty hides the image.", + }), + defineOption({ + name: "imageAlt", + type: "string", + initialValue: "Card image", + description: "Accessible image alt text.", + }), + defineOption({ + name: "href", + type: "string", + initialValue: "[[CARD_URL]]", + description: "Link applied to the image and button.", + }), + defineOption({ + name: "titleContent", + type: "string", + initialValue: "Card title", + description: + "Optional heading rendered above the body. Empty hides it.", + }), + defineOption({ + name: "titleSize", + type: "string", + initialValue: "", + description: 'Optional title font-size override, e.g. "18px".', + }), + defineOption({ + name: "titleWeight", + type: "string", + initialValue: "600", + description: "Title font-weight.", + }), + defineOption({ + name: "titleColor", + type: "string", + initialValue: "", + description: "Optional title color override.", + }), + defineOption({ + name: "textContent", + type: "string", + initialValue: "Card body copy **goes here**.", + description: "Body copy. Accepts Markdown.", + }), + defineOption({ + name: "ctaText", + type: "string", + initialValue: "Learn more", + description: "Call-to-action label. Empty hides the CTA.", + }), + defineOption({ + name: "ctaStyle", + type: "enum", + values: ["plain", "button", "arrow", "title", "none"], + initialValue: "plain", + description: + "Call-to-action style: `plain` text link (default), `button`, `arrow` (icon square), `title` (links the title instead of a separate CTA), or `none`.", + }), + defineOption({ + name: "arrowBackground", + type: "string", + initialValue: tokens.color.brandDark, + description: "Background color of the arrow CTA square.", + }), + defineOption({ + name: "arrowColor", + type: "string", + initialValue: tokens.color.textInvert, + description: "Glyph color of the arrow CTA square.", + }), + defineOption({ + name: "arrowCssClass", + type: "string", + initialValue: "button-hover", + description: "Hover CSS class for the arrow CTA square.", + }), + ]), + render: ({ options }): MjmlNode => { + const hasImage = options.imageSrc.trim() !== ""; + const horizontal = options.layout !== "vertical"; + + const image = hasImage + ? imageNode(options.imageSrc, options.imageAlt, options.href) + : null; + + type ContentItem = { + kind: "title" | "body" | "cta"; + build: (padding: string) => MjmlNode; + }; + const items: ContentItem[] = []; + + if (options.titleContent.trim() !== "") { + items.push({ + kind: "title", + build: (p) => + textNode( + // `title` CTA style links the title itself instead of a + // separate CTA below. + options.ctaStyle === "title" + ? `${options.titleContent}` + : options.titleContent, + { + ...(options.titleSize + ? { "font-size": options.titleSize } + : {}), + ...(options.titleColor + ? { color: options.titleColor } + : {}), + "font-weight": options.titleWeight, + "padding": p, + } + ), + }); + } + if (options.textContent.trim() !== "") { + items.push({ + kind: "body", + build: (p) => + textNode(options.textContent, { + "mj-class": "s-email-text-body", + ...(options.layout === "vertical" + ? { color: tokens.color.textMuted } + : {}), + "padding": p, + }), + }); + } + const showCta = + options.ctaStyle !== "none" && + options.ctaStyle !== "title" && + (options.ctaStyle === "arrow" || options.ctaText.trim() !== ""); + + if (showCta) { + items.push({ + kind: "cta", + build: (p) => + options.ctaStyle === "button" + ? Button("default", { + text: options.ctaText, + href: options.href, + align: "left", + }) + : options.ctaStyle === "arrow" + ? arrowNode( + options.href, + options.arrowBackground, + options.arrowColor, + options.arrowCssClass + ) + : textNode( + `${options.ctaText}`, + { + "mj-class": "s-email-text-body", + "padding": p, + } + ), + }); + } + + const content = items.map((item, index) => { + const top = index === 0 ? lg : "0px"; + const bottom = + index === items.length - 1 + ? lg + : item.kind === "title" + ? sm + : md; + return item.build(`${top} ${lg} ${bottom}`); + }); + + if (horizontal) { + // 1:1 image beside the content as two columns. + const imageColumn: MjmlNode = { + tagName: "mj-column", + attributes: { width: "33%" }, + children: image ? [image] : [], + }; + const contentColumn: MjmlNode = { + tagName: "mj-column", + attributes: { + width: hasImage ? "66%" : "100%", + padding: "0px", + }, + children: content, + }; + + return { + tagName: "mj-section", + attributes: { + "mj-class": options.background, + }, + children: + options.layout === "horizontal-right" + ? [contentColumn, imageColumn] + : [imageColumn, contentColumn], + }; + } + + // Vertical (default): full-bleed image stacked above the padded content. + return { + tagName: "mj-section", + attributes: { + "mj-class": options.background, + "padding": `0px ${tokens.layout.containerXPadding} ${tokens.layout.containerYPadding}`, + }, + children: [ + { + tagName: "mj-column", + attributes: { + "inner-background-color": options.innerBackground, + }, + children: [...(image ? [image] : []), ...content], + }, + ], + }; + }, +}); + +export const Card = card.component; +export default card; diff --git a/packages/stacks-email/components/footer.ts b/packages/stacks-email/components/footer.ts new file mode 100644 index 0000000000..200dc9f5c9 --- /dev/null +++ b/packages/stacks-email/components/footer.ts @@ -0,0 +1,315 @@ +import { + defineEmailComponent, + defineOption, + defineOptions, +} from "../src/lib/schema"; +import { tokens } from "../src/lib/tokens"; +import type { MjmlNode } from "../src/lib/types"; +import header from "./header"; + +const reasonText = "You’re receiving this email because [[FOOTER_REASON]]"; + +const footer = defineEmailComponent({ + slug: "footer", + variants: { + reason: { + reasonText, + }, + social: { + reasonText, + showSocialIcons: true, + }, + }, + tokens: [ + { + token: "UNSUBSCRIBE_URL", + description: "Recipient-specific unsubscribe destination", + }, + { + token: "FOOTER_REASON", + description: "Recipient-specific reason for receiving the email", + }, + ], + optionsSchema: defineOptions([ + defineOption({ + name: "wrapperClass", + type: "string", + initialValue: "bg-invert", + description: "Wrapper mj-class on mj-wrapper.", + }), + defineOption({ + name: "textClass", + type: "string", + initialValue: "fc-text-footer", + description: "Body text class for footer copy rows.", + }), + defineOption({ + name: "linkClass", + type: "string", + initialValue: "footer-link fc-text-footer", + description: + "Link class for unsubscribe/settings/contact/privacy links.", + }), + defineOption({ + name: "headerVariant", + type: "string", + initialValue: "inverted", + description: "Variant passed through to nested Header.", + }), + defineOption({ + name: "logoSrc", + type: "string", + optional: true, + description: "Nested header logo image source override.", + }), + defineOption({ + name: "logoAlt", + type: "string", + optional: true, + description: "Nested header logo alt text override.", + }), + defineOption({ + name: "logoUrl", + type: "string", + optional: true, + description: "Nested header logo link override.", + }), + defineOption({ + name: "logoWidth", + type: "string", + optional: true, + description: "Nested header logo width override.", + }), + defineOption({ + name: "unsubscribeUrl", + type: "string", + initialValue: "[[UNSUBSCRIBE_URL]]", + description: "Recipient-specific unsubscribe destination.", + }), + defineOption({ + name: "settingsUrl", + type: "string", + initialValue: tokens.links.emailSettings, + description: "Email settings URL override.", + }), + defineOption({ + name: "contactUrl", + type: "string", + initialValue: tokens.links.contact, + description: "Contact URL override.", + }), + defineOption({ + name: "privacyUrl", + type: "string", + initialValue: tokens.links.privacy, + description: "Privacy policy URL override.", + }), + defineOption({ + name: "addressHtml", + type: "string", + initialValue: "14 Wall Street, 20th Floor, New York, NY 10005", + description: "Footer address line HTML/text.", + }), + defineOption({ + name: "reasonText", + type: "string", + initialValue: "", + description: "Optional recipient-reason copy block.", + }), + defineOption({ + name: "showSocialIcons", + type: "boolean", + initialValue: false, + description: "Shows or hides social icon row.", + }), + defineOption({ + name: "socialIconBasePath", + type: "string", + initialValue: "/email/social", + description: "Base path for social icon image assets.", + }), + ]), + render: ({ options }): MjmlNode => { + const hasReasonText = options.reasonText.trim().length > 0; + + const children: MjmlNode[] = [ + header.component( + options.headerVariant as keyof typeof header.variants & string, + { + logoSrc: options.logoSrc, + logoAlt: options.logoAlt, + logoUrl: options.logoUrl, + logoWidth: options.logoWidth, + } + ) as MjmlNode, + ]; + + if (options.showSocialIcons) { + children.push({ + tagName: "mj-section", + children: [ + { + tagName: "mj-column", + children: [ + { + tagName: "mj-social", + attributes: { + "align": "left", + "padding-top": "0px", + "padding-bottom": "0px", + "padding-left": "15px", + "icon-padding": "0 5px 0 5px", + "font-size": "13px", + "icon-size": "20px", + "mode": "horizontal", + }, + children: [ + { + tagName: "mj-social-element", + attributes: { + src: `${options.socialIconBasePath}/linkedin.png`, + href: tokens.links.social.linkedin, + }, + }, + { + tagName: "mj-social-element", + attributes: { + src: `${options.socialIconBasePath}/x.png`, + href: tokens.links.social.x, + }, + }, + { + tagName: "mj-social-element", + attributes: { + src: `${options.socialIconBasePath}/threads.png`, + href: tokens.links.social.threads, + }, + }, + { + tagName: "mj-social-element", + attributes: { + src: `${options.socialIconBasePath}/instagram.png`, + href: tokens.links.social.instagram, + }, + }, + { + tagName: "mj-social-element", + attributes: { + src: `${options.socialIconBasePath}/youtube.png`, + href: tokens.links.social.youtube, + }, + }, + ], + }, + ], + }, + ], + }); + } + + 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": options.textClass, + "padding-bottom": "40px", + }, + content: options.reasonText, + } satisfies MjmlNode, + ] + : []), + { + tagName: "mj-text", + attributes: { + "font-size": "14px", + "padding-bottom": "8px", + }, + content: + `Unsubscribe ` + + `Edit email settings ` + + `Contact us ` + + `Privacy `, + }, + { + tagName: "mj-text", + attributes: { + "font-size": "14px", + "mj-class": options.textClass, + }, + content: options.addressHtml, + }, + ], + }, + ], + }); + + children.push({ + tagName: "mj-section", + attributes: { + "padding-left": tokens.layout.containerXPadding, + "padding-right": tokens.layout.containerXPadding, + "padding-bottom": tokens.layout.containerYPadding, + }, + children: [ + { + tagName: "mj-group", + children: [ + { + tagName: "mj-column", + children: [ + { + tagName: "mj-text", + attributes: { + "font-size": "14px", + "mj-class": options.textClass, + }, + content: "© Stack Exchange Inc.", + }, + ], + }, + { + tagName: "mj-column", + children: [ + { + tagName: "mj-text", + attributes: { + "font-size": "14px", + "mj-class": options.textClass, + "align": "right", + }, + content: "All rights reserved", + }, + ], + }, + ], + }, + ], + }); + + return { + tagName: "mj-wrapper", + attributes: { + "mj-class": options.wrapperClass, + "padding-top": tokens.layout.containerYPadding, + }, + children, + }; + }, +}); + +export const Footer = footer.component; +export default footer; diff --git a/packages/stacks-email/components/graphic.ts b/packages/stacks-email/components/graphic.ts new file mode 100644 index 0000000000..d69bcbf418 --- /dev/null +++ b/packages/stacks-email/components/graphic.ts @@ -0,0 +1,119 @@ +import { + defineEmailComponent, + defineOption, + defineOptions, + mjmlAlignOptions, +} from "../src/lib/schema"; +import { tokens } from "../src/lib/tokens"; +import type { MjmlNode } from "../src/lib/types"; +import { Section } from "../src/lib/mjml"; + +const graphic = defineEmailComponent({ + slug: "graphic", + defaultVariant: "spot", + variants: { + hero: { + imageSrc: "/email/hero/1200x630.png", + imageAlt: "Hero placeholder image", + imageWidth: "1200px", + imageAlign: "center", + }, + strip: { + imageSrc: "/email/strip/600x140.png", + imageAlt: "Strip placeholder image", + imageWidth: "600px", + imageAlign: "center", + imagePaddingLeft: "0px", + imagePaddingRight: "0px", + }, + }, + tokens: [], + optionsSchema: defineOptions([ + defineOption({ + name: "sectionClass", + type: "string", + initialValue: "bg-block", + description: "Section class applied to the wrapper section.", + }), + defineOption({ + name: "imageSrc", + type: "string", + initialValue: "/email/spots/SpotLock.png", + description: "Image URL/path override.", + }), + defineOption({ + name: "imageAlt", + type: "string", + initialValue: "Spot placeholder image", + description: "Accessible image alt text.", + }), + defineOption({ + name: "imageWidth", + type: "string", + initialValue: "140px", + description: 'Rendered width, for example "140px".', + }), + defineOption({ + name: "imageHeight", + type: "string", + initialValue: "auto", + description: 'Rendered height, for example "630px".', + }), + defineOption({ + name: "imageAlign", + type: "enum", + values: mjmlAlignOptions, + initialValue: "left", + description: "MJML image alignment value.", + }), + defineOption({ + name: "imagePaddingTop", + type: "string", + initialValue: "0px", + description: "Top padding override.", + }), + defineOption({ + name: "imagePaddingBottom", + type: "string", + initialValue: "0px", + description: "Bottom padding override.", + }), + defineOption({ + name: "imagePaddingLeft", + type: "string", + initialValue: tokens.layout.containerXPadding, + description: "Left padding override.", + }), + defineOption({ + name: "imagePaddingRight", + type: "string", + initialValue: tokens.layout.containerXPadding, + description: "Right padding override.", + }), + ]), + render: ({ options }): MjmlNode => + Section( + [ + { + tagName: "mj-image", + attributes: { + "src": options.imageSrc, + "alt": options.imageAlt, + "width": options.imageWidth, + "height": options.imageHeight, + "align": options.imageAlign, + "padding-top": options.imagePaddingTop, + "padding-bottom": options.imagePaddingBottom, + "padding-left": options.imagePaddingLeft, + "padding-right": options.imagePaddingRight, + }, + }, + ], + { + sectionClass: options.sectionClass, + } + ), +}); + +export const Graphic = graphic.component; +export default graphic; diff --git a/packages/stacks-email/components/header.ts b/packages/stacks-email/components/header.ts new file mode 100644 index 0000000000..c6938bb590 --- /dev/null +++ b/packages/stacks-email/components/header.ts @@ -0,0 +1,101 @@ +import { + defineEmailComponent, + defineOption, + defineOptions, + mjmlAlignOptions, +} from "../src/lib/schema"; +import type { MjmlNode } from "../src/lib/types"; + +const header = defineEmailComponent({ + slug: "header", + defaultVariant: "transactional", + variants: { + "brand": { + sectionClass: "bg-brand", + }, + "brand-center": { + sectionClass: "bg-brand", + logoAlign: "center", + }, + "inverted": { + sectionClass: "bg-invert", + logoSrc: "/email/stack-overflow-logo-off-white.png", + }, + "business": { + sectionClass: "bg-invert", + logoSrc: "/email/stack-overflow-business-logo.png", + logoUrl: "https://stackoverflow.co/", + }, + }, + tokens: [], + optionsSchema: defineOptions([ + defineOption({ + name: "sectionClass", + type: "string", + initialValue: "bg-block", + description: "Header section mj-class override.", + }), + defineOption({ + name: "logoSrc", + type: "string", + initialValue: "/email/stack-overflow-logo.png", + description: "Logo image source path/URL override.", + }), + defineOption({ + name: "logoAlt", + type: "string", + initialValue: "Stack Overflow", + description: "Logo alt text.", + }), + defineOption({ + name: "logoUrl", + type: "string", + initialValue: "https://stackoverflow.com/", + description: "Logo link destination.", + }), + defineOption({ + name: "logoWidth", + type: "string", + initialValue: "158px", + description: "Logo width override.", + }), + defineOption({ + name: "logoAlign", + type: "enum", + values: mjmlAlignOptions, + initialValue: "left", + description: "MJML image alignment override.", + }), + ]), + render: ({ options }): MjmlNode => ({ + tagName: "mj-section", + attributes: { + "mj-class": options.sectionClass, + "padding-top": "20px", + "padding-bottom": "20px", + "padding-left": "24px", + "padding-right": "24px", + }, + children: [ + { + tagName: "mj-column", + children: [ + { + tagName: "mj-image", + attributes: { + src: options.logoSrc, + alt: options.logoAlt, + width: options.logoWidth, + href: options.logoUrl, + align: options.logoAlign, + padding: "0px", + }, + }, + ], + }, + ], + }), +}); + +export const Header = header.component; +export default header; diff --git a/packages/stacks-email/components/headline.ts b/packages/stacks-email/components/headline.ts new file mode 100644 index 0000000000..aafab78284 --- /dev/null +++ b/packages/stacks-email/components/headline.ts @@ -0,0 +1,137 @@ +import type { MjmlNode } from "../src/lib/types"; + +import { + defineEmailComponent, + defineOption, + defineOptions, + mjmlAlignOptions, +} from "../src/lib/schema"; +import { tokens } from "../src/lib/tokens"; +import { Section } from "../src/lib/mjml"; + +const highlightPadding = (index: number, count: number) => { + // Single line + if (count === 1) return "8px 12px"; + // First line in a group + if (index === 0) return "8px 12px 2px"; + // Last line in a group + if (index === count - 1) return "0 12px 8px"; + // Middle line + return "0 12px"; +}; + +const withHighlightedText = (text: string) => { + const lines = text.split("\n").filter((line) => line.trim() !== ""); + return lines + .map( + (line, index) => + `${line}` + ) + .join("
"); +}; + +const headline = defineEmailComponent({ + slug: "headline", + variants: { + highlight: { + highlight: true, + }, + invert: { + sectionClass: "bg-invert", + textClass: "s-email-text-headline fc-text-invert", + }, + }, + optionsSchema: defineOptions([ + defineOption({ + name: "sectionClass", + type: "string", + initialValue: "bg-block", + description: "Section class for the headline row.", + }), + defineOption({ + name: "textClass", + type: "string", + initialValue: "s-email-text-headline", + description: "Text styling class for the headline node.", + }), + defineOption({ + name: "textAlign", + type: "enum", + values: mjmlAlignOptions, + initialValue: "left", + description: "MJML text alignment.", + }), + defineOption({ + name: "textContent", + type: "string", + initialValue: "Please verify\n your email address", + description: "Headline copy content.", + }), + defineOption({ + name: "eyebrow", + type: "string", + initialValue: "", + description: + "Optional 14px label rendered above the headline. Empty hides it.", + }), + defineOption({ + name: "eyebrowClass", + type: "string", + initialValue: "s-email-text-subtitle fc-text-muted", + description: "Text styling class for the eyebrow node.", + }), + defineOption({ + name: "highlight", + type: "boolean", + initialValue: false, + description: + "When true, forces inline highlighted output; when false, disables highlighting.", + }), + ]), + tokens: [], + render: ({ options }): MjmlNode => { + const hasEyebrow = options.eyebrow.trim() !== ""; + + // 14px eyebrow label above the headline; its size comes from a token and + // its styling class is configurable. + const eyebrowNode: MjmlNode = { + tagName: "mj-text", + attributes: { + "mj-class": options.eyebrowClass, + "align": options.textAlign, + "font-size": tokens.font.eyebrowSize, + "font-weight": "normal", + "padding-top": tokens.layout.containerYPadding, + "padding-bottom": "5px", + "padding-left": tokens.layout.containerXPadding, + "padding-right": tokens.layout.containerXPadding, + }, + content: options.eyebrow, + }; + + const headlineNode: MjmlNode = { + tagName: "mj-text", + attributes: { + "mj-class": options.textClass, + "align": options.textAlign, + // When the eyebrow is present it provides the top padding. + "padding-top": hasEyebrow + ? tokens.spacing.xs + : tokens.layout.containerYPadding, + "padding-bottom": tokens.layout.containerYPadding, + "padding-left": tokens.layout.containerXPadding, + "padding-right": tokens.layout.containerXPadding, + }, + content: options.highlight + ? withHighlightedText(options.textContent) + : options.textContent, + }; + + return Section([...(hasEyebrow ? [eyebrowNode] : []), headlineNode], { + sectionClass: options.sectionClass, + }); + }, +}); + +export const Headline = headline.component; +export default headline; diff --git a/packages/stacks-email/components/index.ts b/packages/stacks-email/components/index.ts new file mode 100644 index 0000000000..ce85e98b64 --- /dev/null +++ b/packages/stacks-email/components/index.ts @@ -0,0 +1,9 @@ +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 spacer } from "./spacer"; +export { default as text } from "./text"; +export { default as title } from "./title"; diff --git a/packages/stacks-email/components/preview.ts b/packages/stacks-email/components/preview.ts new file mode 100644 index 0000000000..51b794f454 --- /dev/null +++ b/packages/stacks-email/components/preview.ts @@ -0,0 +1,35 @@ +import { + defineEmailComponent, + defineOption, + defineOptions, +} from "../src/lib/schema"; +import type { MjmlNode } from "../src/lib/types"; + +const preview = defineEmailComponent({ + slug: "preview", + htmlExtraction: { + targetTag: "mj-preview", + }, + tokens: [ + { + token: "PREVIEW_TEXT", + description: "Inbox preview snippet shown next to subject lines.", + }, + ], + optionsSchema: defineOptions([ + defineOption({ + name: "textContent", + type: "string", + initialValue: "[[PREVIEW_TEXT]]", + description: + "Hidden inbox preview snippet shown by supporting email clients.", + }), + ]), + render: ({ options }): MjmlNode => ({ + tagName: "mj-preview", + content: options.textContent, + }), +}); + +export const Preview = preview.component; +export default preview; diff --git a/packages/stacks-email/components/quote.ts b/packages/stacks-email/components/quote.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/stacks-email/components/spacer.ts b/packages/stacks-email/components/spacer.ts new file mode 100644 index 0000000000..50ea64eb23 --- /dev/null +++ b/packages/stacks-email/components/spacer.ts @@ -0,0 +1,51 @@ +import { + defineEmailComponent, + defineOption, + defineOptions, +} from "../src/lib/schema"; +import type { MjmlNode } from "../src/lib/types"; +import { Section } from "../src/lib/mjml"; + +const spacer = defineEmailComponent({ + slug: "spacer", + defaultVariant: "medium", + htmlExtraction: { + targetTag: "mj-spacer", + }, + variants: { + large: { + height: "40px", + }, + }, + optionsSchema: defineOptions([ + defineOption({ + name: "sectionClass", + type: "string", + initialValue: "bg-block", + description: "Applied to the wrapper section.", + }), + defineOption({ + name: "height", + type: "string", + initialValue: "20px", + description: 'Spacer height, for example "64px".', + }), + ]), + render: ({ options }): MjmlNode => + Section( + [ + { + tagName: "mj-spacer", + attributes: { + height: options.height, + }, + }, + ], + { + sectionClass: options.sectionClass, + } + ), +}); + +export const Spacer = spacer.component; +export default spacer; diff --git a/packages/stacks-email/components/subtitle.ts b/packages/stacks-email/components/subtitle.ts new file mode 100644 index 0000000000..dc25269eb1 --- /dev/null +++ b/packages/stacks-email/components/subtitle.ts @@ -0,0 +1,85 @@ +import { + defineEmailComponent, + defineOption, + defineOptions, + mjmlAlignOptions, +} from "../src/lib/schema"; +import { tokens } from "../src/lib/tokens"; +import type { MjmlNode } from "../src/lib/types"; +import { Section } from "../src/lib/mjml"; + +const squareMarker = (color: string) => + ``; + +const subtitle = defineEmailComponent({ + slug: "subtitle", + defaultVariant: "medium", + variants: { + small: { + fontSize: "14px", + fontWeight: tokens.font.weightNormal, + squareColor: tokens.color.brandYellow, + }, + }, + optionsSchema: defineOptions([ + defineOption({ + name: "sectionClass", + type: "string", + initialValue: "bg-block", + description: "Section mj-class override.", + }), + defineOption({ + name: "textAlign", + type: "enum", + values: mjmlAlignOptions, + initialValue: "left", + description: "MJML text alignment override.", + }), + defineOption({ + name: "fontSize", + type: "string", + initialValue: "16px", + description: "Subtitle font size.", + }), + defineOption({ + name: "fontWeight", + type: "string", + initialValue: tokens.font.weightBold, + description: "Subtitle font weight.", + }), + defineOption({ + name: "squareColor", + type: "string", + initialValue: tokens.color.brand, + description: "Background color of the square marker.", + }), + defineOption({ + name: "textContent", + type: "string", + initialValue: "Subtitle", + description: "Subtitle copy content.", + }), + ]), + render: ({ options }): MjmlNode => + Section( + [ + { + tagName: "mj-text", + attributes: { + "align": options.textAlign, + "font-size": options.fontSize, + "font-weight": options.fontWeight, + "padding-left": tokens.layout.containerXPadding, + "padding-right": tokens.layout.containerXPadding, + }, + content: `${squareMarker(options.squareColor)}${options.textContent}`, + }, + ], + { + sectionClass: options.sectionClass, + } + ), +}); + +export const Subtitle = subtitle.component; +export default subtitle; diff --git a/packages/stacks-email/components/text.ts b/packages/stacks-email/components/text.ts new file mode 100644 index 0000000000..7f0dbffdde --- /dev/null +++ b/packages/stacks-email/components/text.ts @@ -0,0 +1,117 @@ +import { + defineEmailComponent, + defineOption, + defineOptions, + mjmlAlignOptions, +} from "../src/lib/schema"; +import { renderEmailBodyMarkdown } from "../src/lib/markdown"; +import { tokens } from "../src/lib/tokens"; +import type { MjmlNode } from "../src/lib/types"; +import { Section } from "../src/lib/mjml"; + +const TEMPLATE_PROP_PATTERN = /^\{\{[A-Z0-9_]+\}\}$/; + +const looksLikeHtml = (value: string) => /<\/?[a-z][\s\S]*>/i.test(value); + +const renderTextContent = (value: string | undefined) => { + const content = value?.trim() ?? ""; + + if (TEMPLATE_PROP_PATTERN.test(content) || looksLikeHtml(content)) { + return content; + } + + return renderEmailBodyMarkdown(content); +}; + +export const textNode = ( + content: string, + attributes: NonNullable +): MjmlNode => ({ + tagName: "mj-text", + attributes, + content: renderTextContent(content), +}); + +const bodyContent = ` +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. +`; + +const centeredContent = ` +A starting point for more simple transactional emails with a single, center-aligned message. It can [contain links](https://stackoverflow.com) or **rich text**. +`; + +const text = defineEmailComponent({ + slug: "text", + defaultVariant: "body", + variants: { + centered: { + textAlign: "center", + textContent: centeredContent, + }, + }, + tokens: [ + { + token: "FIRST_NAME", + description: "Recipient first name for personalized body copy.", + }, + ], + optionsSchema: defineOptions([ + defineOption({ + name: "columnClass", + type: "string", + initialValue: "bg-block", + description: "Column mj-class override.", + }), + defineOption({ + name: "sectionClass", + type: "string", + optional: true, + description: "Optional section mj-class override.", + }), + defineOption({ + name: "textAlign", + type: "enum", + values: mjmlAlignOptions, + initialValue: "left", + description: "MJML text alignment override.", + }), + defineOption({ + name: "textClass", + type: "string", + initialValue: "s-email-text-body", + description: "Text mj-class override.", + }), + defineOption({ + name: "textContent", + type: "string", + initialValue: bodyContent, + description: "Raw content string before markdown/HTML handling.", + }), + ]), + render: ({ options }): MjmlNode => + Section( + [ + textNode(options.textContent, { + "mj-class": options.textClass, + "align": options.textAlign, + "padding-top": tokens.layout.containerYPadding, + "padding-bottom": tokens.layout.containerYPadding, + "padding-left": tokens.layout.containerXPadding, + "padding-right": tokens.layout.containerXPadding, + }), + ], + { + sectionClass: options.sectionClass, + columnClass: options.columnClass, + } + ), +}); + +export const Text = text.component; +export default text; diff --git a/packages/stacks-email/components/title.ts b/packages/stacks-email/components/title.ts new file mode 100644 index 0000000000..9809226f5e --- /dev/null +++ b/packages/stacks-email/components/title.ts @@ -0,0 +1,70 @@ +import { + defineEmailComponent, + defineOption, + defineOptions, + mjmlAlignOptions, +} from "../src/lib/schema"; +import { tokens } from "../src/lib/tokens"; +import type { MjmlNode } from "../src/lib/types"; +import { Section } from "../src/lib/mjml"; + +const title = defineEmailComponent({ + slug: "title", + variants: { + invert: { + sectionClass: "bg-invert", + textClass: "s-email-text-title fc-text-invert", + }, + }, + optionsSchema: defineOptions([ + defineOption({ + name: "sectionClass", + type: "string", + initialValue: "bg-block", + description: "Section mj-class override.", + }), + defineOption({ + name: "textClass", + type: "string", + initialValue: "s-email-text-title", + description: "Title text mj-class override.", + }), + defineOption({ + name: "textAlign", + type: "enum", + values: mjmlAlignOptions, + initialValue: "left", + description: "MJML text alignment override.", + }), + defineOption({ + name: "textContent", + type: "string", + initialValue: "Featured", + description: "Title copy content.", + }), + ]), + tokens: [], + render: ({ options }): MjmlNode => + Section( + [ + { + tagName: "mj-text", + attributes: { + "mj-class": options.textClass, + "align": options.textAlign, + "padding-top": tokens.layout.containerYPadding, + "padding-bottom": tokens.layout.containerYPadding, + "padding-left": tokens.layout.containerXPadding, + "padding-right": tokens.layout.containerXPadding, + }, + content: options.textContent, + }, + ], + { + sectionClass: options.sectionClass, + } + ), +}); + +export const Title = title.component; +export default title; diff --git a/packages/stacks-email/eslint.config.js b/packages/stacks-email/eslint.config.js new file mode 100644 index 0000000000..0f270d00b0 --- /dev/null +++ b/packages/stacks-email/eslint.config.js @@ -0,0 +1,39 @@ +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( + { + ignores: [".svelte-kit/**"], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + ...svelte.configs["flat/recommended"], + prettier, + ...svelte.configs["flat/prettier"], + { + languageOptions: { + globals: { + ...globals.browser, + ...globals.node, + }, + parserOptions: { + tsconfigRootDir: import.meta.dirname, + project: "./tsconfig.eslint.json", + }, + }, + }, + { + files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"], + languageOptions: { + parserOptions: { + parser: tseslint.parser, + tsconfigRootDir: import.meta.dirname, + project: "./tsconfig.eslint.json", + extraFileExtensions: [".svelte"], + }, + }, + } +); diff --git a/packages/stacks-email/package.json b/packages/stacks-email/package.json new file mode 100644 index 0000000000..dad6091293 --- /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/api/index.ts", + "./sveltekit": "./src/lib/sveltekit/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", + "markdown-it": "^14.1.0", + "mjml": "^4.17.1", + "zod": "^4.1.12" + } +} 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/lib/api/catalog.ts b/packages/stacks-email/src/lib/api/catalog.ts new file mode 100644 index 0000000000..a08b6d9731 --- /dev/null +++ b/packages/stacks-email/src/lib/api/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/api/compile.ts b/packages/stacks-email/src/lib/api/compile.ts new file mode 100644 index 0000000000..5486ffa832 --- /dev/null +++ b/packages/stacks-email/src/lib/api/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 "./request-schemas"; + +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/api/components.ts b/packages/stacks-email/src/lib/api/components.ts new file mode 100644 index 0000000000..72c0f85efc --- /dev/null +++ b/packages/stacks-email/src/lib/api/components.ts @@ -0,0 +1,138 @@ +import { componentDefinitions } from "../registry"; +import type { CompileTarget } from "../tokens"; +import type { + EmailComponentMeta, + ComponentOptionReference, + MjmlNode, +} from "../types"; +import { compileMjml, type CompileMjmlOutput } from "../pipeline/compile"; +import { compileComponentInputSchema } from "./request-schemas"; +import { expandVariantRecords } from "./records"; + +type ExpandedComponentRecord = { + catalog: EmailComponentCatalogItem; + sourceNodes: MjmlNode[]; + htmlExtractionTag?: string; +}; + +export type EmailComponentCatalogItem = { + slug: string; + name: string; + description: string; + category: string; + tokens: NonNullable; + options: NonNullable; +}; + +export type CompileComponentInput = { + slug: string; + target: CompileTarget; +}; + +export type CompileComponentOutput = Omit< + CompileMjmlOutput, + "componentHtml" | "componentMjml" +> & { + kind: "component"; + slug: string; + componentHtml: string; + componentMjml: string; + meta: EmailComponentCatalogItem; +}; + +const DEFAULT_COMPONENT_CATEGORY = "Primitive"; + +const toNodeList = (value: T | T[]) => + Array.isArray(value) ? value : [value]; + +const expandedComponentRecords = expandVariantRecords({ + definitions: componentDefinitions, + defaultCategory: DEFAULT_COMPONENT_CATEGORY, + getMeta: (definition): EmailComponentMeta => definition.meta, + formatName: ({ baseName, variantName, hasMultipleVariants }) => + hasMultipleVariants ? `${baseName} — ${variantName}` : baseName, + createRecord: ({ + definition, + meta, + variant, + slug, + name, + description, + category, + tokens, + }): ExpandedComponentRecord => { + const rendered = definition.component( + variant.id as keyof typeof definition.variants & string + ); + + return { + catalog: { + slug, + name, + description, + category, + tokens, + options: meta.options ?? [], + }, + sourceNodes: toNodeList(rendered), + htmlExtractionTag: meta.htmlExtraction?.targetTag, + }; + }, +}); + +const componentBySlug = new Map( + expandedComponentRecords.map( + (record) => [record.catalog.slug, record] as const + ) +); + +export const listEmailComponents = () => + expandedComponentRecords.map((record) => record.catalog); + +export const getEmailComponentMeta = (slug: string) => { + const record = componentBySlug.get(slug); + if (!record) { + return null; + } + return record.catalog; +}; + +export const getEmailComponentOptions = ( + slug: string +): ComponentOptionReference[] | null => { + const record = componentBySlug.get(slug); + if (!record) { + return null; + } + + return record.catalog.options; +}; + +export const compileEmailComponent = ({ + slug, + target, +}: CompileComponentInput): CompileComponentOutput => { + const parsedInput = compileComponentInputSchema.parse({ slug, target }); + const record = componentBySlug.get(parsedInput.slug); + + if (!record) { + throw new Error(`Unknown email component slug: ${parsedInput.slug}`); + } + + const result = compileMjml({ + source: record.sourceNodes, + target: parsedInput.target, + props: {}, + extractComponentName: record.catalog.slug, + extractComponentTag: record.htmlExtractionTag, + }); + + return { + ...result, + kind: "component", + slug: parsedInput.slug, + componentHtml: result.componentHtml ?? result.html, + componentMjml: result.componentMjml ?? result.renderedMjml, + meta: record.catalog, + }; +}; diff --git a/packages/stacks-email/src/lib/api/index.ts b/packages/stacks-email/src/lib/api/index.ts new file mode 100644 index 0000000000..fd7f7cccf1 --- /dev/null +++ b/packages/stacks-email/src/lib/api/index.ts @@ -0,0 +1,16 @@ +export { + tokens, + targets, + targetNames, + isCompileTarget, + type Tokens, + type CompileTarget, +} from "../tokens"; + +export * from "../pipeline/compile"; +export * from "../pipeline/transform"; +export * from "./catalog"; +export * from "./compile"; +export * from "./components"; +export * from "./static-artifacts"; +export * from "./templates"; diff --git a/packages/stacks-email/src/lib/api/records.ts b/packages/stacks-email/src/lib/api/records.ts new file mode 100644 index 0000000000..150081c32d --- /dev/null +++ b/packages/stacks-email/src/lib/api/records.ts @@ -0,0 +1,100 @@ +import type { EmailTokenReference, EmailVariant } from "../types"; + +type VariantMeta = { + slug: string; + name?: string; + description?: string; + category?: string; + variants: readonly EmailVariant[]; + defaultVariant?: string; + tokens?: readonly EmailTokenReference[]; +}; + +type ExpandedVariantInput = { + definition: TDefinition; + meta: TMeta; + variant: TMeta["variants"][number]; + slug: string; + baseName: string; + variantName: string; + name: string; + description: string; + category: NonNullable; + tokens: EmailTokenReference[]; + hasMultipleVariants: boolean; +}; + +type ExpandVariantRecordsInput< + TDefinition, + TMeta extends VariantMeta, + TRecord, +> = { + definitions: readonly TDefinition[]; + defaultCategory: NonNullable; + getMeta: (definition: TDefinition) => TMeta; + formatName: (input: { + baseName: string; + variantName: string; + hasMultipleVariants: boolean; + }) => string; + createRecord: (input: ExpandedVariantInput) => TRecord; +}; + +export const toLabel = (value: string) => + value + .split(/[-_]/g) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); + +export const expandVariantRecords = < + TDefinition, + TMeta extends VariantMeta, + TRecord, +>({ + definitions, + defaultCategory, + getMeta, + formatName, + createRecord, +}: ExpandVariantRecordsInput) => + definitions + .flatMap((definition) => { + const meta = getMeta(definition); + const baseName = meta.name ?? toLabel(meta.slug); + const baseDescription = meta.description ?? ""; + const hasMultipleVariants = meta.variants.length > 1; + + return meta.variants.map((variant, index) => { + const isDefault = + variant.id === meta.defaultVariant || + (!meta.defaultVariant && index === 0); + const slug = isDefault + ? meta.slug + : `${meta.slug}-${variant.id}`; + const variantName = variant.name ?? toLabel(variant.id); + const description = hasMultipleVariants + ? (variant.description ?? baseDescription) + : baseDescription; + + return { + definition, + meta, + variant, + slug, + baseName, + variantName, + name: formatName({ + baseName, + variantName, + hasMultipleVariants, + }), + description, + category: meta.category ?? defaultCategory, + tokens: [...(meta.tokens ?? [])], + hasMultipleVariants, + }; + }); + }) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((input) => createRecord(input)); diff --git a/packages/stacks-email/src/lib/api/request-schemas.ts b/packages/stacks-email/src/lib/api/request-schemas.ts new file mode 100644 index 0000000000..4645acaa55 --- /dev/null +++ b/packages/stacks-email/src/lib/api/request-schemas.ts @@ -0,0 +1,39 @@ +import { z } from "zod/v4"; + +import { targetNames, type CompileTarget } from "../tokens"; + +const compileTargetValues = targetNames as [CompileTarget, ...CompileTarget[]]; +const compileTargetList = compileTargetValues.map((target) => `\`${target}\``); + +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 ${compileTargetList.join(", ")}.`, +}); + +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/lib/api/static-artifacts.ts b/packages/stacks-email/src/lib/api/static-artifacts.ts new file mode 100644 index 0000000000..be8804414f --- /dev/null +++ b/packages/stacks-email/src/lib/api/static-artifacts.ts @@ -0,0 +1,283 @@ +import { compileEmailRenderable, type EmailRenderableKind } from "./compile"; +import { getEmailCatalog, type EmailCatalog } from "./catalog"; +import { + isCompileTarget, + targetNames, + targets, + type CompileTarget, +} from "../tokens"; +import { transformTokens } from "../pipeline/transform"; + +export type StaticEmailManifestRecord = { + kind: EmailRenderableKind; + slug: string; + target: CompileTarget; + meta: EmailCatalog[`${EmailRenderableKind}s`][number]; + errors: ReturnType["errors"]; + files: { + documentHtml: string; + documentMjml: string; + displayHtml: string; + displayMjml: string; + }; +}; + +export type StaticEmailManifest = { + generatedAt: string; + basePath: string; + records: Record; + catalog: EmailCatalog; +}; + +type EmailArtifactKind = + | "documentHtml" + | "documentMjml" + | "displayHtml" + | "displayMjml"; + +type EmailArtifactFileExtension = "html" | "mjml"; +type EmailArtifactFileVariant = "fragment" | "full"; + +type ParsedEmailArtifactFile = { + target: CompileTarget; + variant?: EmailArtifactFileVariant; + extension: EmailArtifactFileExtension; +}; + +export type StaticEmailArtifactRouteEntry = { + kind: EmailRenderableKind; + slug: string; + file: string; +}; + +export type StaticEmailArtifactsOptions = { + basePath?: string; +}; + +export const defaultStaticEmailArtifactsBasePath = "/email/compiled"; + +const renderableKinds = ["component", "template"] as const; + +const manifestCacheByBasePath = new Map(); + +const normalizeBasePath = (basePath = defaultStaticEmailArtifactsBasePath) => + basePath.endsWith("/") ? basePath.slice(0, -1) : basePath; + +const toRecordKey = ( + kind: EmailRenderableKind, + slug: string, + target: CompileTarget +) => `${kind}:${slug}:${target}`; + +const toUrlPath = ( + basePath: string, + kind: EmailRenderableKind, + slug: string, + file: string +) => `${basePath}/${kind}/${slug}/${file}`; + +const getCatalogItems = (catalog: EmailCatalog, kind: EmailRenderableKind) => + kind === "component" ? catalog.components : catalog.templates; + +const isEmailArtifactFileExtension = ( + value: string +): value is EmailArtifactFileExtension => value === "html" || value === "mjml"; + +const isEmailArtifactFileVariant = ( + value: string +): value is EmailArtifactFileVariant => + value === "fragment" || value === "full"; + +const parseEmailArtifactFile = ( + file: string +): ParsedEmailArtifactFile | null => { + const parts = file.split("."); + if (parts.length !== 2 && parts.length !== 3) { + return null; + } + + const [target, maybeVariantOrExtension, maybeExtension] = parts; + if (!isCompileTarget(target)) { + return null; + } + + if (parts.length === 2) { + return isEmailArtifactFileExtension(maybeVariantOrExtension) + ? { target, extension: maybeVariantOrExtension } + : null; + } + + if ( + !maybeExtension || + !isEmailArtifactFileVariant(maybeVariantOrExtension) || + !isEmailArtifactFileExtension(maybeExtension) + ) { + return null; + } + + return { + target, + variant: maybeVariantOrExtension, + extension: maybeExtension, + }; +}; + +const getCompiledRecord = ( + kind: EmailRenderableKind, + slug: string, + target: CompileTarget +) => { + const compiled = compileEmailRenderable({ kind, slug, target }); + const documentMjml = transformTokens(compiled.mjml, targets[target].tokens); + + return { + compiled, + documentMjml, + displayHtml: + compiled.kind === "component" && compiled.componentHtml + ? compiled.componentHtml + : compiled.html, + displayMjml: + compiled.kind === "component" && compiled.componentMjml + ? compiled.componentMjml + : compiled.renderedMjml, + }; +}; + +const createManifestRecord = ( + basePath: string, + kind: EmailRenderableKind, + slug: string, + target: CompileTarget +): StaticEmailManifestRecord => { + const { compiled } = getCompiledRecord(kind, slug, target); + const documentHtmlFile = `${target}.html`; + const documentMjmlFile = `${target}.full.mjml`; + const displayHtmlFile = + kind === "component" ? `${target}.fragment.html` : documentHtmlFile; + const displayMjmlFile = `${target}.mjml`; + + return { + kind, + slug, + target, + meta: compiled.meta, + errors: compiled.errors, + files: { + documentHtml: toUrlPath(basePath, kind, slug, documentHtmlFile), + documentMjml: toUrlPath(basePath, kind, slug, documentMjmlFile), + displayHtml: toUrlPath(basePath, kind, slug, displayHtmlFile), + displayMjml: toUrlPath(basePath, kind, slug, displayMjmlFile), + }, + }; +}; + +export const getStaticEmailManifest = ( + options: StaticEmailArtifactsOptions = {} +): StaticEmailManifest => { + const basePath = normalizeBasePath(options.basePath); + const cachedManifest = manifestCacheByBasePath.get(basePath); + + if (cachedManifest) { + return cachedManifest; + } + + const catalog = getEmailCatalog(); + const records: StaticEmailManifest["records"] = {}; + + for (const kind of renderableKinds) { + for (const item of getCatalogItems(catalog, kind)) { + for (const target of targetNames) { + records[toRecordKey(kind, item.slug, target)] = + createManifestRecord(basePath, kind, item.slug, target); + } + } + } + + const manifest = { + generatedAt: new Date().toISOString(), + basePath, + records, + catalog, + }; + + manifestCacheByBasePath.set(basePath, manifest); + + return manifest; +}; + +export const getStaticEmailArtifactEntries = + (): StaticEmailArtifactRouteEntry[] => { + const catalog = getEmailCatalog(); + const entries: StaticEmailArtifactRouteEntry[] = []; + + for (const kind of renderableKinds) { + for (const item of getCatalogItems(catalog, kind)) { + for (const target of targetNames) { + entries.push( + { kind, slug: item.slug, file: `${target}.html` }, + { kind, slug: item.slug, file: `${target}.full.mjml` }, + { kind, slug: item.slug, file: `${target}.mjml` } + ); + + if (kind === "component") { + entries.push({ + kind, + slug: item.slug, + file: `${target}.fragment.html`, + }); + } + } + } + } + + return entries; + }; + +export const getStaticEmailArtifact = ( + kind: string, + slug: string, + file: string +) => { + if (kind !== "component" && kind !== "template") { + throw new Error(`Unknown email kind: ${kind}`); + } + + const parsedFile = parseEmailArtifactFile(file); + if (!parsedFile) { + throw new Error(`Unknown email artifact file: ${file}`); + } + + const { compiled, displayHtml, displayMjml, documentMjml } = + getCompiledRecord(kind, slug, parsedFile.target); + + let artifactKind: EmailArtifactKind; + if (parsedFile.extension === "html") { + artifactKind = + parsedFile.variant === "fragment" ? "displayHtml" : "documentHtml"; + } else { + artifactKind = + parsedFile.variant === "full" ? "documentMjml" : "displayMjml"; + } + + if (kind !== "component" && artifactKind === "displayHtml") { + throw new Error( + `Templates do not have fragment HTML artifacts: ${file}` + ); + } + + const contentsByKind: Record = { + documentHtml: compiled.html, + documentMjml, + displayHtml, + displayMjml, + }; + + return { + contents: contentsByKind[artifactKind], + contentType: + parsedFile.extension === "html" + ? "text/html; charset=utf-8" + : "text/plain; charset=utf-8", + }; +}; diff --git a/packages/stacks-email/src/lib/api/templates.ts b/packages/stacks-email/src/lib/api/templates.ts new file mode 100644 index 0000000000..d5432d86a8 --- /dev/null +++ b/packages/stacks-email/src/lib/api/templates.ts @@ -0,0 +1,176 @@ +import { templateDefinitions } from "../registry"; +import type { CompileTarget } from "../tokens"; +import type { EmailTemplateMeta, MjmlNode } from "../types"; +import { compileMjml, type CompileMjmlOutput } from "../pipeline/compile"; +import { compileTemplateInputSchema } from "./request-schemas"; +import { expandVariantRecords } from "./records"; +import { normalizeEmailOptions, type EmailTemplateDefinition } from "../schema"; + +type ExpandedTemplateRecord = { + catalog: EmailTemplateCatalogItem; + renderDocument: (props: Record) => MjmlNode; + resolvePreviewText: (props: Record) => string; +}; + +export type EmailTemplateCatalogItem = { + slug: string; + name: string; + description: string; + category: string; + tokens: NonNullable; + options: NonNullable; +}; + +export type CompileTemplateInput = { + slug: string; + target: CompileTarget; + props?: Record; +}; + +export type CompileTemplateOutput = CompileMjmlOutput & { + kind: "template"; + slug: string; + meta: EmailTemplateCatalogItem; +}; + +const DEFAULT_TEMPLATE_CATEGORY = "Transactional"; +const DEFAULT_TEMPLATE_PREVIEW_TEXT = "Stack Overflow update"; + +const TEMPLATE_PREVIEW_TOKEN: NonNullable[number] = + { + token: "PREVIEW_TEXT", + description: + "Inbox preheader text inserted into `` for all template compiles.", + }; + +const withSharedTemplateTokens = ( + tokens: NonNullable +) => { + const uniqueByToken = new Map(); + + for (const token of [TEMPLATE_PREVIEW_TOKEN, ...tokens]) { + if (!uniqueByToken.has(token.token)) { + uniqueByToken.set(token.token, token); + } + } + + return [...uniqueByToken.values()]; +}; + +const expandedTemplateRecords = expandVariantRecords({ + definitions: templateDefinitions, + defaultCategory: DEFAULT_TEMPLATE_CATEGORY, + getMeta: (definition): EmailTemplateMeta => definition.meta, + formatName: ({ baseName, variantName, hasMultipleVariants }) => + hasMultipleVariants ? variantName : baseName, + createRecord: ({ + definition, + meta, + variant, + slug, + name, + description, + baseName, + tokens, + }): ExpandedTemplateRecord => { + const catalog = { + slug, + name, + description, + // Group by the base template (e.g. "Transactional", "Newsletter") + // so each template's variants nest under it, rather than lumping + // every template under one default category. + category: baseName, + tokens: withSharedTemplateTokens(tokens), + options: meta.options ?? [], + }; + // Each template has its own props shape, so `definition` is a union + // across the registered templates. Treat it as the base definition + // (props = Record) for this generic machinery. + const def = definition as EmailTemplateDefinition; + const variantId = variant.id as keyof typeof def.variants & string; + type DefinitionProps = Parameters< + typeof def.renderDocument + >[0]["props"]; + const defaults = + def.variants[variantId] ?? def.variants[def.defaultVariant] ?? {}; + const resolveProps = (inputProps: Record) => + normalizeEmailOptions( + def.propsSchema, + defaults as Partial, + inputProps as Partial + ); + + return { + catalog, + renderDocument: (inputProps) => + def.renderDocument({ + variant: variantId, + props: resolveProps(inputProps), + }), + resolvePreviewText: (inputProps) => { + const props = resolveProps(inputProps); + const preview = def.preview?.({ + variant: variantId, + props, + }); + + return ( + preview?.previewText?.trim() || + catalog.description.trim() || + catalog.name.trim() || + DEFAULT_TEMPLATE_PREVIEW_TEXT + ); + }, + }; + }, +}); + +const templateBySlug = new Map( + expandedTemplateRecords.map( + (record) => [record.catalog.slug, record] as const + ) +); + +export const listEmailTemplates = () => + expandedTemplateRecords.map((record) => record.catalog); + +export const getEmailTemplateMeta = (slug: string) => { + const record = templateBySlug.get(slug); + if (!record) { + return null; + } + return record.catalog; +}; + +export const compileEmailTemplate = ({ + slug, + target, + props = {}, +}: CompileTemplateInput): CompileTemplateOutput => { + const parsedInput = compileTemplateInputSchema.parse({ + slug, + target, + props, + }); + const record = templateBySlug.get(parsedInput.slug); + + if (!record) { + throw new Error(`Unknown email template slug: ${parsedInput.slug}`); + } + + const inputProps = parsedInput.props ?? {}; + const result = compileMjml({ + source: record.renderDocument(inputProps), + target: parsedInput.target, + props: {}, + previewText: record.resolvePreviewText(inputProps), + }); + + return { + ...result, + kind: "template", + slug: parsedInput.slug, + meta: record.catalog, + }; +}; diff --git a/packages/stacks-email/src/lib/highlight.ts b/packages/stacks-email/src/lib/highlight.ts new file mode 100644 index 0000000000..90c9557efe --- /dev/null +++ b/packages/stacks-email/src/lib/highlight.ts @@ -0,0 +1,36 @@ +import type { LanguageFn } from "highlight.js"; +import hljs from "highlight.js/lib/core"; +import hljsXml from "highlight.js/lib/languages/xml"; + +type SupportedLanguage = "html" | "xml"; + +const registerLanguage = (language: SupportedLanguage, grammar: LanguageFn) => { + if (!hljs.getLanguage(language)) { + hljs.registerLanguage(language, grammar); + } +}; + +registerLanguage("html", hljsXml); +registerLanguage("xml", hljsXml); + +const escapeHtml = (input: string) => + input + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); + +export const highlightCode = async ( + code: string, + language: SupportedLanguage +) => { + try { + const highlighted = hljs.highlight(code, { + language, + }).value; + + return `
${highlighted}
`; + } catch { + const escaped = escapeHtml(code); + return `
${escaped}
`; + } +}; diff --git a/packages/stacks-email/src/lib/markdown.ts b/packages/stacks-email/src/lib/markdown.ts new file mode 100644 index 0000000000..63532e772e --- /dev/null +++ b/packages/stacks-email/src/lib/markdown.ts @@ -0,0 +1,78 @@ +import MarkdownIt from "markdown-it"; + +import { tokens } from "./tokens"; + +const markdown = new MarkdownIt({ + html: false, + breaks: true, + linkify: true, + typographer: true, +}); + +markdown.renderer.rules.link_open = (tokenList, index, options, env, self) => { + tokenList[index].attrJoin("class", "link"); + return self.renderToken(tokenList, index, options); +}; + +markdown.renderer.rules.paragraph_open = ( + tokenList, + index, + options, + env, + self +) => { + const hasAnotherParagraph = tokenList + .slice(index + 1) + .some((token) => token.type === "paragraph_open"); + + tokenList[index].attrSet( + "style", + `margin:${hasAnotherParagraph ? tokens.body.paragraphMargin : "0"};` + ); + + return self.renderToken(tokenList, index, options); +}; + +markdown.renderer.rules.bullet_list_open = ( + tokenList, + index, + options, + env, + self +) => { + tokenList[index].attrSet( + "style", + `margin:${tokens.body.listMargin};padding:${tokens.body.listPadding};` + ); + + return self.renderToken(tokenList, index, options); +}; + +markdown.renderer.rules.list_item_open = ( + tokenList, + index, + options, + env, + self +) => { + const hasAnotherListItem = tokenList + .slice(index + 1) + .some((token) => token.type === "list_item_open"); + + tokenList[index].attrSet( + "style", + `margin:${hasAnotherListItem ? tokens.body.listItemMargin : "0"};` + ); + + return self.renderToken(tokenList, index, options); +}; + +export const renderEmailBodyMarkdown = (value: string) => { + const content = value.trim(); + + if (!content) { + return ""; + } + + return markdown.render(content).trim(); +}; diff --git a/packages/stacks-email/src/lib/mjml/config.ts b/packages/stacks-email/src/lib/mjml/config.ts new file mode 100644 index 0000000000..88613de6e2 --- /dev/null +++ b/packages/stacks-email/src/lib/mjml/config.ts @@ -0,0 +1,260 @@ +import { mjmlJsonToString } from "./json"; +import { tokens } from "../tokens"; +import type { MjmlNode } from "../types"; + +const { color, font, layout, border } = tokens; + +const attributesChildren: MjmlNode[] = [ + { + tagName: "mj-all", + attributes: { + "font-family": font.family, + "color": color.text, + "font-size": "16px", + "line-height": "120%", + }, + }, + { + tagName: "mj-body", + attributes: { + "width": layout.maxWidth, + "background-color": color.background, + }, + }, + { + tagName: "mj-text", + attributes: { + padding: "0px", + }, + }, + { + tagName: "mj-image", + attributes: { + padding: "0px", + }, + }, + { + tagName: "mj-button", + attributes: { + padding: "0px", + }, + }, + { + tagName: "mj-table", + attributes: { + padding: "0px", + }, + }, + { + tagName: "mj-column", + attributes: { + padding: "0px", + }, + }, + { + tagName: "mj-section", + attributes: { + padding: "0px", + }, + }, + { + tagName: "mj-wrapper", + attributes: { + padding: "0px", + }, + }, + + // Utility classes generated from design-token arrays. + ...color.backgroundClasses.map(({ name, value }) => ({ + tagName: "mj-class", + attributes: { + "name": `bg-${name}`, + "background-color": value, + }, + })), + ...color.fontClasses.map(({ name, value }) => ({ + tagName: "mj-class", + attributes: { + name: `fc-${name}`, + color: value, + }, + })), + + { + tagName: "mj-class", + attributes: { + "name": "s-email-text-title", + "color": color.text, + "font-family": "Stack Sans Notch, Arial, Helvetica, sans-serif", + "font-size": "24px", + "font-weight": font.weightNormal, + "line-height": "100%", + "padding": "0", + }, + }, + { + tagName: "mj-class", + attributes: { + "name": "s-email-text-headline", + "color": color.text, + "font-family": "Stack Sans Notch, Arial, Helvetica, sans-serif", + "font-size": "36px", + "font-weight": font.weightNormal, + "line-height": "100%", + "padding": "0", + }, + }, + + { + tagName: "mj-class", + attributes: { + "name": "s-email-text-subtitle", + "color": color.textMuted, + "font-size": "14px", + "line-height": "120%", + "padding": "0", + }, + }, + { + tagName: "mj-class", + attributes: { + "name": "s-email-text-secondary-information", + "color": color.textMuted, + "font-size": "12px", + "font-weight": font.weightNormal, + "line-height": "120%", + "padding": "0", + }, + }, + { + tagName: "mj-class", + attributes: { + "name": "s-email-text-body", + "color": color.text, + "font-size": "16px", + "font-weight": font.weightNormal, + "line-height": "120%", + }, + }, + { + tagName: "mj-class", + attributes: { + "name": "s-email-text-caption", + "color": color.textMuted, + "font-size": "14px", + "font-weight": font.weightNormal, + "line-height": "120%", + }, + }, + { + tagName: "mj-class", + attributes: { + "name": "s-email-text-alert", + "color": color.text, + "font-size": "16px", + "font-weight": font.weightSemibold, + "line-height": "120%", + "padding": "0", + }, + }, + + { + tagName: "mj-class", + attributes: { + "name": "button", + "background-color": color.brandDark, + "color": "#ffffff", + "border-radius": border.radius, + "font-size": "14px", + "font-weight": font.weightBold, + "line-height": "120%", + "inner-padding": `12px 18px`, + }, + }, + { + tagName: "mj-class", + attributes: { + "name": "button__tonal", + "background-color": color.brandOffWhite, + "color": color.text, + }, + }, + { + tagName: "mj-class", + attributes: { + "name": "button__inverted", + "background-color": color.background, + "color": color.text, + }, + }, +]; + +const linkStyles = ` +a.link { + color: ${color.link}; + text-decoration: underline; + font-size: 16px; + line-height: normal; + font-weight: ${font.weightNormal}; +} +a.footer-link { + color: ${color.textFooter}; + text-decoration: underline; + font-size: 14px; + line-height: 120%; + margin-right: 10px; +} +`.trim(); + +// Head-only Progressive enhancement: hover states only apply in clients that support head CSS and :hover. +const hoverStyles = ` +a.link:hover { + color: ${color.linkHover} !important; +} +a.footer-link:hover, +a.footer-link-light:hover, +a.footer-link:hover { + opacity: 0.85 !important; +} +.button-hover a:hover { + background-color: #47484d !important; + color: #fff !important; +} +.button-hover-inverted a:hover { + background-color: ${color.brandOffWhite} !important; +} +`.trim(); + +export const mjmlConfigNodes: MjmlNode[] = [ + { + tagName: "mj-font", + attributes: { + name: "Stack Sans Headline", + href: "https://fonts.googleapis.com/css2?family=Stack+Sans+Headline:wght@400;600&display=swap", + }, + }, + { + tagName: "mj-font", + attributes: { + name: "Stack Sans Notch", + href: "https://fonts.googleapis.com/css2?family=Stack+Sans+Notch:wght@400&display=swap", + }, + }, + { + tagName: "mj-attributes", + children: attributesChildren, + }, + { + tagName: "mj-style", + attributes: { + inline: "inline", + }, + content: linkStyles, + }, + { + tagName: "mj-style", + content: hoverStyles, + }, +]; + +export const mjmlConfig = mjmlJsonToString(mjmlConfigNodes); diff --git a/packages/stacks-email/src/lib/mjml/index.ts b/packages/stacks-email/src/lib/mjml/index.ts new file mode 100644 index 0000000000..635e017a40 --- /dev/null +++ b/packages/stacks-email/src/lib/mjml/index.ts @@ -0,0 +1,97 @@ +import { z } from "zod/v4"; + +import type { MjmlNode } from "../types"; + +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, + }, + ], + }; +}; + +/** + * Two-column layout sugar. Like `Section`, but renders exactly two `mj-column`s + * side by side inside one `mj-section`. `columnClass`/`columnAttributes` apply to + * both columns. + */ +export const Columns = ( + columns: [MjmlNode[], MjmlNode[]], + options: SectionOptions = {} +): MjmlNode => { + const parsedOptions = sectionOptionsSchema.safeParse(options); + const normalizedOptions = parsedOptions.success + ? parsedOptions.data + : options; + + const sectionAttributes: NonNullable = { + ...(normalizedOptions.sectionClass + ? { "mj-class": normalizedOptions.sectionClass } + : {}), + ...(normalizedOptions.sectionAttributes ?? {}), + }; + + const columnAttributes: NonNullable = { + ...(normalizedOptions.columnClass + ? { "mj-class": normalizedOptions.columnClass } + : {}), + ...(normalizedOptions.columnAttributes ?? {}), + }; + + return { + tagName: "mj-section", + attributes: Object.keys(sectionAttributes).length + ? sectionAttributes + : undefined, + children: columns.map((children) => ({ + tagName: "mj-column", + attributes: Object.keys(columnAttributes).length + ? columnAttributes + : undefined, + children, + })), + }; +}; diff --git a/packages/stacks-email/src/lib/mjml/json.ts b/packages/stacks-email/src/lib/mjml/json.ts new file mode 100644 index 0000000000..af5e550c1d --- /dev/null +++ b/packages/stacks-email/src/lib/mjml/json.ts @@ -0,0 +1,45 @@ +import type { MjmlNode, MjmlAttributeValue } from "../types"; + +const serializeAttributes = ( + attributes: Record | undefined +) => { + if (!attributes) { + return ""; + } + return Object.entries(attributes) + .map( + ([key, value]) => + ` ${key}="${String(value).replaceAll('"', """)}"` + ) + .join(""); +}; + +const serializeNode = (node: MjmlNode, depth: number): string => { + const indent = " ".repeat(depth); + const open = `<${node.tagName}${serializeAttributes(node.attributes)}>`; + const close = ``; + const children = node.children ?? []; + const content = node.content ?? ""; + + // Leaf: keep content verbatim — mj-text holds raw HTML and mj-style holds CSS. + if (children.length === 0) { + return `${indent}${open}${content}${close}`; + } + + const inner = children + .map((child) => serializeNode(child, depth + 1)) + .join("\n"); + const body = content ? `${content}\n${inner}` : inner; + return `${indent}${open}\n${body}\n${indent}${close}`; +}; + +/** + * Serialize an MjmlNode tree (or list) to an MJML string. A small in-house + * serializer (replacing the CJS `json2mjml` dependency): node content is emitted + * verbatim so inline HTML inside `mj-text` and CSS inside `mj-style` are + * preserved, and the tree is indented for readability. + */ +export const mjmlJsonToString = (source: MjmlNode | MjmlNode[]): string => { + const nodes = Array.isArray(source) ? source : [source]; + return nodes.map((node) => serializeNode(node, 0)).join("\n"); +}; 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..c5913de8c6 --- /dev/null +++ b/packages/stacks-email/src/lib/pipeline/compile.ts @@ -0,0 +1,228 @@ +import mjml2html from "mjml"; + +import { + applyTemplateProps, + extractBetweenMarkers, + extractTagMarkup, + serializeMjml, + wrapComponentWithMarkers, + wrapTagWithMarkers, +} from "./template"; + +import { mjmlConfigNodes } from "../mjml/config"; +import { targets, tokens, type CompileTarget } from "../tokens"; +import { transformTokens } from "./transform"; +import type { MjmlNode } from "../types"; + +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 escapePreviewText = (value: string) => + value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); + +const previewNode = (previewText: string | undefined): MjmlNode | null => { + const normalizedPreviewText = previewText?.trim(); + if (!normalizedPreviewText) { + return null; + } + + return { + tagName: "mj-preview", + content: escapePreviewText(normalizedPreviewText), + }; +}; + +const buildHeadChildren = (previewText: string | undefined): MjmlNode[] => { + const preview = previewNode(previewText); + + return preview ? [preview, ...mjmlConfigNodes] : [...mjmlConfigNodes]; +}; + +const isDocumentRoot = (nodes: MjmlNode[]) => + nodes.length === 1 && nodes[0].tagName.toLowerCase() === "mjml"; + +const wrapInDocument = ( + fragment: MjmlNode[], + previewText: string | undefined +): MjmlNode => ({ + tagName: "mjml", + children: [ + { tagName: "mj-head", children: buildHeadChildren(previewText) }, + { + tagName: "mj-body", + attributes: { + "background-color": tokens.color.bodyBackground, + }, + children: [ + { + tagName: "mj-wrapper", + attributes: { + "padding-top": "20px", + "padding-bottom": "20px", + }, + children: fragment, + }, + ], + }, + ], +}); + +const injectHead = ( + documentNode: MjmlNode, + previewText: string | undefined +): MjmlNode => { + const headChildren = buildHeadChildren(previewText); + const children = documentNode.children ?? []; + const headIndex = children.findIndex((child) => isMjHead(child)); + + if (headIndex === -1) { + return { + ...documentNode, + children: [ + { tagName: "mj-head", children: headChildren }, + ...children, + ], + }; + } + + return { + ...documentNode, + children: children.map((child, index) => + index === headIndex + ? { + ...child, + children: [...(child.children ?? []), ...headChildren], + } + : child + ), + }; +}; + +const isMjHead = (node: MjmlNode) => node.tagName.toLowerCase() === "mj-head"; + +export type CompileMjmlInput = { + source: MjmlNode | MjmlNode[]; + 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 = ({ + source, + target, + props = {}, + previewText, + extractComponentName, + extractComponentTag, +}: CompileMjmlInput): CompileMjmlOutput => { + const sourceNodes = Array.isArray(source) ? source : [source]; + + // Component compiles insert extraction markers around the renderable so the + // compiled component HTML can be sliced back out (always a fragment, never a + // full `` document). + const renderableNodes = extractComponentName + ? extractComponentTag + ? wrapTagWithMarkers( + sourceNodes, + extractComponentName, + extractComponentTag + ) + : wrapComponentWithMarkers(sourceNodes, extractComponentName) + : sourceNodes; + + const documentNode = isDocumentRoot(renderableNodes) + ? injectHead(renderableNodes[0], previewText) + : wrapInDocument(renderableNodes, previewText); + + // Serialize exactly once. The source tree is never parsed back into nodes, + // so inline markup inside `mj-text` content (e.g. `` in a `
  • `) is + // preserved verbatim instead of being collapsed by mjml-parser-xml. + const fullMjml = applyTemplateProps(serializeMjml(documentNode), props); + + const compileResult = mjml2htmlSync(fullMjml, { + validationLevel: "soft", + keepComments: true, + minify: false, + }); + + const replacements = targets[target].tokens; + const renderedMjml = transformTokens( + applyTemplateProps(serializeMjml(sourceNodes), props), + replacements + ); + const html = transformTokens(compileResult.html, replacements); + + const componentMjml = extractComponentName + ? extractComponentTag + ? extractComponentTagMjml( + sourceNodes, + extractComponentTag, + props, + replacements + ) + : renderedMjml.trim() + : null; + const componentHtml = extractComponentName + ? extractBetweenMarkers(html, extractComponentName) + : null; + + return { + html, + componentMjml, + componentHtml, + mjml: fullMjml, + renderedMjml, + errors: compileResult.errors.map((issue) => ({ + line: issue.line, + message: issue.message, + tagName: issue.tagName, + })), + }; +}; + +const extractComponentTagMjml = ( + sourceNodes: MjmlNode[], + tagName: string, + props: Record, + replacements: Record +): string | null => { + const markup = extractTagMarkup(sourceNodes, tagName); + if (markup === null) { + return null; + } + + return transformTokens(applyTemplateProps(markup, props), replacements); +}; 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..9256ec4e71 --- /dev/null +++ b/packages/stacks-email/src/lib/pipeline/template.ts @@ -0,0 +1,140 @@ +import { mjmlJsonToString } from "../mjml/json"; +import type { MjmlNode } from "../types"; + +const markerStart = (name: string) => ``; +const markerEnd = (name: string) => ``; + +const isTag = (node: MjmlNode, tagName: string) => + node.tagName.toLowerCase() === tagName.toLowerCase(); + +const rawMarkerNode = (content: string): MjmlNode => ({ + tagName: "mj-raw", + content, +}); + +export const serializeMjml = (source: MjmlNode | MjmlNode[]) => + mjmlJsonToString(source).trim(); + +const findFirstTag = (node: MjmlNode, tagName: string): MjmlNode | null => { + if (isTag(node, tagName)) { + return node; + } + + for (const child of node.children ?? []) { + const match = findFirstTag(child, tagName); + if (match) { + return match; + } + } + + return null; +}; + +const wrapFirstTagNode = ( + node: MjmlNode, + name: string, + tagName: string +): { nodes: MjmlNode[]; found: boolean } => { + if (isTag(node, tagName)) { + return { + nodes: [ + rawMarkerNode(markerStart(name)), + node, + rawMarkerNode(markerEnd(name)), + ], + found: true, + }; + } + + let found = false; + const children = (node.children ?? []).flatMap((child) => { + if (found) { + return [child]; + } + + const wrapped = wrapFirstTagNode(child, name, tagName); + found = wrapped.found; + + return wrapped.nodes; + }); + + return { nodes: [{ ...node, children }], found }; +}; + +/** Wrap an entire fragment with extraction markers as sibling nodes. */ +export const wrapComponentWithMarkers = ( + nodes: MjmlNode[], + name: string +): MjmlNode[] => [ + rawMarkerNode(markerStart(name)), + ...nodes, + rawMarkerNode(markerEnd(name)), +]; + +/** + * Wrap the first node matching `tagName` with extraction markers, preserving + * the surrounding structure. Falls back to wrapping the whole fragment when no + * matching tag is found. + */ +export const wrapTagWithMarkers = ( + nodes: MjmlNode[], + name: string, + tagName: string +): MjmlNode[] => { + let found = false; + const wrappedNodes = nodes.flatMap((node) => { + if (found) { + return [node]; + } + + const wrapped = wrapFirstTagNode(node, name, tagName); + found = wrapped.found; + + return wrapped.nodes; + }); + + return found ? wrappedNodes : wrapComponentWithMarkers(nodes, name); +}; + +/** Serialize the first node matching `tagName` within a fragment. */ +export const extractTagMarkup = ( + nodes: MjmlNode[], + tagName: string +): string | null => { + for (const node of nodes) { + const match = findFirstTag(node, tagName); + if (match) { + return serializeMjml(match); + } + } + + return null; +}; + +/** Slice the compiled HTML between a component's start/end marker comments. */ +export const extractBetweenMarkers = (source: string, name: string) => { + const startMarker = markerStart(name); + const endMarker = markerEnd(name); + const start = source.indexOf(startMarker); + if (start === -1) { + return null; + } + + const contentStart = start + startMarker.length; + const end = source.indexOf(endMarker, contentStart); + if (end === -1) { + return null; + } + + return source.slice(contentStart, end).trim(); +}; + +export const applyTemplateProps = ( + source: string, + props: Record +) => + Object.entries(props).reduce( + (next, [key, value]) => + next.replaceAll(`{{${key}}}`, String(value ?? "")), + source + ); 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/registry.ts b/packages/stacks-email/src/lib/registry.ts new file mode 100644 index 0000000000..418328141f --- /dev/null +++ b/packages/stacks-email/src/lib/registry.ts @@ -0,0 +1,52 @@ +import button from "../../components/button"; +import callout from "../../components/callout"; +import card from "../../components/card"; +import footer from "../../components/footer"; +import graphic from "../../components/graphic"; +import headline from "../../components/headline"; +import header from "../../components/header"; +import spacer from "../../components/spacer"; +import subtitle from "../../components/subtitle"; +import text from "../../components/text"; +import title from "../../components/title"; + +import newsletter from "../../templates/newsletter"; +import promotional from "../../templates/promotional"; +import transactional from "../../templates/transactional"; + +export const componentDefinitions = [ + button, + callout, + card, + footer, + graphic, + headline, + header, + spacer, + subtitle, + text, + title, +] as const; + +export const templateDefinitions = [ + newsletter, + promotional, + transactional, +] as const; + +export { + button, + callout, + card, + footer, + graphic, + headline, + header, + spacer, + subtitle, + text, + title, + newsletter, + promotional, + transactional, +}; diff --git a/packages/stacks-email/src/lib/schema/component.ts b/packages/stacks-email/src/lib/schema/component.ts new file mode 100644 index 0000000000..7d1f5e8354 --- /dev/null +++ b/packages/stacks-email/src/lib/schema/component.ts @@ -0,0 +1,96 @@ +import { z } from "zod/v4"; + +import type { + EmailComponentMeta, + EmailTokenReference, + MjmlNode, +} from "../types"; +import { getSchemaOptionRows } from "./metadata"; +import { normalizeEmailOptions } from "./normalize"; +import { resolveVariantScaffold, type VariantMap } from "./variants"; + +export type EmailComponentDefinition< + TOptions extends Record = Record, + TReturn extends MjmlNode | MjmlNode[] = MjmlNode | MjmlNode[], +> = { + slug: string; + name?: string; + description?: string; + category?: string; + defaultVariant: string; + variants: VariantMap; + variantOption?: { description: string }; + tokens?: EmailTokenReference[]; + htmlExtraction?: EmailComponentMeta["htmlExtraction"]; + render: (input: { variant: string; options: TOptions }) => TReturn; + meta: EmailComponentMeta; + optionsSchema: z.ZodObject; + component: (variant: string, options?: Partial) => TReturn; +}; + +export const defineEmailComponent = < + TOptionsSchema extends z.ZodObject, + TVariants extends VariantMap< + z.output & Record + >, + TReturn extends MjmlNode | MjmlNode[], +>(definition: { + slug: string; + name?: string; + description?: string; + category?: string; + defaultVariant?: string; + variants?: TVariants; + optionsSchema: TOptionsSchema; + variantOption?: { description: string }; + tokens?: EmailTokenReference[]; + htmlExtraction?: EmailComponentMeta["htmlExtraction"]; + render: (input: { + variant: string; + options: z.output & Record; + }) => TReturn; +}): EmailComponentDefinition< + z.output & Record, + TReturn +> => { + type TOptions = z.output & Record; + const { optionsSchema } = definition; + const { defaultVariant, variants, getVariantDefaults, baseMeta } = + resolveVariantScaffold(optionsSchema, definition); + + const meta: EmailComponentMeta = { + ...baseMeta, + options: [ + { + argument: "variant", + type: Object.keys(variants) + .map((variant) => `"${variant}"`) + .join(" | "), + description: + definition.variantOption?.description ?? + "Selects the variant.", + }, + ...getSchemaOptionRows(optionsSchema), + ], + htmlExtraction: definition.htmlExtraction, + }; + + const component = (variant: string, options: Partial = {}) => { + const variantDefaults = + variants[variant] ?? variants[defaultVariant] ?? variants.default; + const normalizedOptions = normalizeEmailOptions( + optionsSchema, + getVariantDefaults(variantDefaults), + options + ); + return definition.render({ variant, options: normalizedOptions }); + }; + + return { + ...definition, + defaultVariant, + variants, + meta, + component, + }; +}; diff --git a/packages/stacks-email/src/lib/schema/index.ts b/packages/stacks-email/src/lib/schema/index.ts new file mode 100644 index 0000000000..18c6813ce7 --- /dev/null +++ b/packages/stacks-email/src/lib/schema/index.ts @@ -0,0 +1,8 @@ +export { emailOption, getSchemaOptionRows } from "./metadata"; +export { defineOption, defineOptions, mjmlAlignOptions } from "./options"; +export { normalizeEmailOptions } from "./normalize"; +export { + defineEmailComponent, + type EmailComponentDefinition, +} from "./component"; +export { defineEmailTemplate, type EmailTemplateDefinition } from "./template"; diff --git a/packages/stacks-email/src/lib/schema/metadata.ts b/packages/stacks-email/src/lib/schema/metadata.ts new file mode 100644 index 0000000000..fea27cefb0 --- /dev/null +++ b/packages/stacks-email/src/lib/schema/metadata.ts @@ -0,0 +1,41 @@ +import { z } from "zod/v4"; + +import type { ComponentOptionReference } from "../types"; + +export type EmailOptionMetadata = { + type: string; + description: string; + defaultValue?: string; + renderDefaultValueAsCode?: boolean; +}; + +export const emailOption = ( + schema: TSchema, + metadata: EmailOptionMetadata +): TSchema => schema.meta({ emailOption: metadata }) as TSchema; + +const getOptionMetadata = ( + schema: z.ZodType +): EmailOptionMetadata | undefined => + (schema.meta() as { emailOption?: EmailOptionMetadata } | undefined) + ?.emailOption; + +export const getSchemaOptionRows = ( + schema: z.ZodObject, + argumentPrefix = "options" +): ComponentOptionReference[] => + Object.entries(schema.shape as Record).flatMap( + ([key, fieldSchema]) => { + const metadata = getOptionMetadata(fieldSchema); + if (!metadata) return []; + return [ + { + argument: `${argumentPrefix}.${key}`, + type: metadata.type, + defaultValue: metadata.defaultValue, + renderDefaultValueAsCode: metadata.renderDefaultValueAsCode, + description: metadata.description, + }, + ]; + } + ); diff --git a/packages/stacks-email/src/lib/schema/normalize.ts b/packages/stacks-email/src/lib/schema/normalize.ts new file mode 100644 index 0000000000..065e25678c --- /dev/null +++ b/packages/stacks-email/src/lib/schema/normalize.ts @@ -0,0 +1,38 @@ +import { z } from "zod/v4"; + +import type { VariantProps } from "../types"; + +export const stringifyVariantProps = ( + defaults: Record +): VariantProps => + Object.fromEntries( + Object.entries(defaults) + .filter(([, value]) => value !== undefined && value !== null) + .map(([key, value]) => [key, String(value)]) + ); + +export const getDefinedEntries = >( + values: Partial +): Partial => + Object.fromEntries( + Object.entries(values).filter(([, value]) => value !== undefined) + ) as Partial; + +export const getSchemaDefaults = >( + schema: z.ZodObject +): Partial => { + const parsed = schema.safeParse({}); + return parsed.success + ? getDefinedEntries(parsed.data as Partial) + : ({} as Partial); +}; + +export const normalizeEmailOptions = >( + schema: z.ZodObject, + defaults: Partial, + options: Partial = {} +): TOptions => + schema.parse({ + ...getDefinedEntries(defaults), + ...getDefinedEntries(options), + }) as TOptions; diff --git a/packages/stacks-email/src/lib/schema/options.ts b/packages/stacks-email/src/lib/schema/options.ts new file mode 100644 index 0000000000..0357ff1b3b --- /dev/null +++ b/packages/stacks-email/src/lib/schema/options.ts @@ -0,0 +1,123 @@ +import { z } from "zod/v4"; + +import { emailOption } from "./metadata"; + +export const mjmlAlignOptions = ["left", "center", "right"] as const; + +type PrimitiveType = "string" | "boolean"; +type EnumValues = readonly [string, ...string[]]; +type PrimitiveValue = T extends "boolean" + ? boolean + : string; + +type OptionOutput = + TInitialValue extends PrimitiveValue + ? PrimitiveValue + : TOptional extends true + ? PrimitiveValue | undefined + : PrimitiveValue; + +type EnumOutput< + TValues extends EnumValues, + TOptional, + TInitialValue, +> = TInitialValue extends TValues[number] + ? TValues[number] + : TOptional extends true + ? TValues[number] | undefined + : TValues[number]; + +type OptionDefinition = { + name: TName; + schema: z.ZodType; +}; + +export function defineOption< + const TName extends string, + const TType extends PrimitiveType, + const TOptional extends boolean | undefined = undefined, + const TInitialValue extends PrimitiveValue | undefined = undefined, +>(input: { + name: TName; + type: TType; + initialValue?: TInitialValue; + optional?: TOptional; + description: string; +}): OptionDefinition>; +export function defineOption< + const TName extends string, + const TValues extends EnumValues, + const TOptional extends boolean | undefined = undefined, + const TInitialValue extends TValues[number] | undefined = undefined, +>(input: { + name: TName; + type: "enum"; + values: TValues; + initialValue?: TInitialValue; + optional?: TOptional; + description: string; +}): OptionDefinition>; + +export function defineOption(input: { + name: string; + type: PrimitiveType | "enum"; + values?: EnumValues; + initialValue?: string | boolean; + optional?: boolean; + description: string; +}): OptionDefinition { + const base: z.ZodType = + input.type === "boolean" + ? z.boolean() + : input.type === "enum" + ? z.enum(input.values as EnumValues) + : z.string(); + const metadataType = + input.type === "enum" + ? (input.values as EnumValues) + .map((value) => `"${value}"`) + .join(" | ") + : input.type; + + const schema = + input.initialValue !== undefined + ? (base as z.ZodAny).default(input.initialValue) + : input.optional + ? base.optional() + : base; + + return { + name: input.name, + schema: emailOption(schema, { + type: metadataType, + defaultValue: + input.initialValue === undefined + ? undefined + : String(input.initialValue), + renderDefaultValueAsCode: + input.initialValue === undefined ? undefined : true, + description: input.description, + }), + }; +} + +export const defineOptions = < + const TDefinitions extends readonly OptionDefinition[], +>( + definitions: TDefinitions +) => + z.object( + Object.fromEntries( + definitions.map((definition) => [ + definition.name, + definition.schema, + ]) + ) as { + [K in TDefinitions[number] as K["name"]]: K extends OptionDefinition< + K["name"], + infer TOutput + > + ? z.ZodType + : never; + } + ); diff --git a/packages/stacks-email/src/lib/schema/template.ts b/packages/stacks-email/src/lib/schema/template.ts new file mode 100644 index 0000000000..4d3ad55fcc --- /dev/null +++ b/packages/stacks-email/src/lib/schema/template.ts @@ -0,0 +1,78 @@ +import { z } from "zod/v4"; + +import type { + EmailTemplateMeta, + EmailTokenReference, + MjmlNode, +} from "../types"; +import { getSchemaOptionRows } from "./metadata"; +import { resolveVariantScaffold, type VariantMap } from "./variants"; + +export type EmailTemplateDefinition< + TProps extends Record = Record, +> = { + slug: string; + name?: string; + description?: string; + category?: string; + defaultVariant: string; + variants: VariantMap; + propsSchema: z.ZodObject; + tokens?: EmailTokenReference[]; + preview?: (input: { variant: string; props: TProps }) => { + name?: string; + description?: string; + previewText?: string; + }; + renderDocument: (input: { variant: string; props: TProps }) => MjmlNode; + meta: EmailTemplateMeta; +}; + +export const defineEmailTemplate = < + TPropsSchema extends z.ZodObject, + TVariants extends VariantMap< + z.output & Record + >, +>(definition: { + slug: string; + name?: string; + description?: string; + category?: string; + defaultVariant?: string; + variants?: TVariants; + propsSchema: TPropsSchema; + tokens?: EmailTokenReference[]; + preview?: (input: { + variant: string; + props: z.output & Record; + }) => { + name?: string; + description?: string; + previewText?: string; + }; + renderDocument: (input: { + variant: string; + props: z.output & Record; + }) => MjmlNode; +}): EmailTemplateDefinition< + z.output & Record +> => { + type TProps = z.output & Record; + const { propsSchema } = definition; + const { defaultVariant, variants, baseMeta } = + resolveVariantScaffold(propsSchema, definition); + + // Surface each template prop (the `props` passed to `compileEmailTemplate`) + // as an option row so docs can render an auto-generated options table. + const meta: EmailTemplateMeta = { + ...baseMeta, + options: getSchemaOptionRows(propsSchema, "props"), + }; + + return { + ...definition, + defaultVariant, + variants, + meta, + }; +}; diff --git a/packages/stacks-email/src/lib/schema/variants.ts b/packages/stacks-email/src/lib/schema/variants.ts new file mode 100644 index 0000000000..e575c7ca40 --- /dev/null +++ b/packages/stacks-email/src/lib/schema/variants.ts @@ -0,0 +1,85 @@ +import { z } from "zod/v4"; + +import type { EmailTokenReference, EmailVariant } from "../types"; +import { + getDefinedEntries, + getSchemaDefaults, + stringifyVariantProps, +} from "./normalize"; + +/** A variant is a partial set of option/prop overrides keyed by variant id. */ +export type VariantMap> = Record< + string, + Partial +>; + +type VariantScaffoldInput> = { + slug: string; + name?: string; + description?: string; + category?: string; + defaultVariant?: string; + variants?: VariantMap; + tokens?: EmailTokenReference[]; +}; + +export type VariantBaseMeta = { + slug: string; + name?: string; + description?: string; + category?: string; + defaultVariant: string; + variants: EmailVariant[]; + tokens?: EmailTokenReference[]; +}; + +export type VariantScaffold> = { + defaultVariant: string; + variants: VariantMap; + schemaDefaults: Partial; + getVariantDefaults: (variant: Partial) => T; + baseMeta: VariantBaseMeta; +}; + +/** + * Shared scaffolding for variant-driven schema definitions (components and + * templates). Resolves the implied `default` variant, merges schema defaults + * with each variant's overrides, and builds the common `meta` fields. Callers + * layer their own render contract and meta extras (e.g. a component's options + * table, a template's preview hook) on top. + */ +export const resolveVariantScaffold = >( + schema: z.ZodObject, + definition: VariantScaffoldInput +): VariantScaffold => { + const schemaDefaults = getSchemaDefaults(schema); + const defaultVariant = definition.defaultVariant ?? "default"; + const variants: VariantMap = { + [defaultVariant]: {}, + ...(definition.variants ?? {}), + }; + + const getVariantDefaults = (variant: Partial): T => + ({ ...schemaDefaults, ...getDefinedEntries(variant) }) as T; + + const baseMeta: VariantBaseMeta = { + slug: definition.slug, + name: definition.name, + description: definition.description, + category: definition.category, + defaultVariant, + variants: Object.entries(variants).map(([id, defaults]) => ({ + id, + props: stringifyVariantProps(getVariantDefaults(defaults)), + })), + tokens: definition.tokens, + }; + + return { + defaultVariant, + variants, + schemaDefaults, + getVariantDefaults, + baseMeta, + }; +}; diff --git a/packages/stacks-email/src/lib/sveltekit/index.ts b/packages/stacks-email/src/lib/sveltekit/index.ts new file mode 100644 index 0000000000..d2b225a78b --- /dev/null +++ b/packages/stacks-email/src/lib/sveltekit/index.ts @@ -0,0 +1,46 @@ +import { error, json } from "@sveltejs/kit"; + +import { + getStaticEmailArtifact, + getStaticEmailArtifactEntries, + getStaticEmailManifest, +} from "../api/static-artifacts"; + +type StaticEmailArtifactRouteEvent = { + params: { + kind: string; + slug: string; + file: string; + }; +}; + +export const staticEmailCompiledManifestGET = () => + json(getStaticEmailManifest()); + +export const staticEmailCompiledArtifactEntries = () => + getStaticEmailArtifactEntries(); + +export const staticEmailCompiledArtifactGET = ({ + params, +}: StaticEmailArtifactRouteEvent) => { + try { + const artifact = getStaticEmailArtifact( + params.kind, + params.slug, + params.file + ); + + return new Response(artifact.contents, { + headers: { + "content-type": artifact.contentType, + }, + }); + } catch (reason) { + throw error( + 404, + reason instanceof Error + ? reason.message + : "Unknown static email artifact." + ); + } +}; diff --git a/packages/stacks-email/src/lib/tokens.ts b/packages/stacks-email/src/lib/tokens.ts new file mode 100644 index 0000000000..8dc1b3c204 --- /dev/null +++ b/packages/stacks-email/src/lib/tokens.ts @@ -0,0 +1,193 @@ +/** + * Color tokens used for all email component palettes, state surfaces, and links. + */ +const color = { + bodyBackground: "#f7f6f5", + background: "#fff", + blockBackground: "#fff", + accent: "#ffcc01", + border: "#d6d9dc", + brand: "#FF5E00", + brandDark: "#201C1D", + brandOffWhite: "#eeeeee", + brandYellow: "#ffcc01", + text: "#211d1e", + textMuted: "#636261", + textInvert: "#ffffff", + textFooter: "#cdc8c2", + link: "#2445b4", + linkHover: "#5074ef", + lightBlue: "#d8e1ed", + surface: "#eee", + invertSurface: "#4a4a4a", +} 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 }, + { name: "card", value: color.surface }, + { name: "light-blue", value: color.lightBlue }, +] 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; + +/** + * Shared typography tokens used by the MJML config and generated classes. + */ +const font = { + family: "Arial, Helvetica, sans-serif", + weightNormal: "400", + weightSemibold: "600", + weightBold: "700", + eyebrowSize: "14px", +} as const; + +/** + * Body content rhythm tokens for generated HTML copy. + */ +const body = { + paragraphMargin: "0 0 16px", + listMargin: "0 0 16px 24px", + listPadding: "0", + listItemMargin: "0 0 8px", +} 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; + +/** + * Canonical Stack Overflow destinations used by shared email components. + */ +const links = { + emailSettings: "https://stackoverflow.com/users/email/settings/current", + contact: "https://stackoverflow.com/company/contact", + privacy: "https://stackoverflow.com/legal/privacy-policy", + social: { + linkedin: "https://linkedin.com/company/stack-overflow/", + x: "https://x.com/stackoverflow/", + threads: "https://www.threads.net/@thestackoverflow", + instagram: "https://www.instagram.com/thestackoverflow/", + youtube: "https://www.youtube.com/c/StackOverflowOfficial", + }, +} as const; + +export const tokens = { + color: { + ...color, + backgroundClasses, + fontClasses, + }, + font, + body, + spacing, + layout, + border, + links, +} as const; + +export type Tokens = typeof tokens; + +/** + * 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", + CARD_URL: "https://example.com/card", + 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", + CARD_URL: "@Model.CardUrl", + 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}}}", + CARD_URL: "{{custom_attribute.${card_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/src/lib/types.ts b/packages/stacks-email/src/lib/types.ts new file mode 100644 index 0000000000..4725d4e243 --- /dev/null +++ b/packages/stacks-email/src/lib/types.ts @@ -0,0 +1,55 @@ +export type MjmlAttributeValue = string | number | boolean; + +export type MjmlNode = { + tagName: string; + attributes?: Record; + children?: MjmlNode[]; + content?: string; +}; + +export type VariantProps = Record; + +export type EmailVariant = { + id: string; + name?: string; + description?: string; + props: VariantProps; +}; + +export type EmailTokenReference = { + token: string; + description: string; +}; + +export type ComponentOptionReference = { + argument: string; + type: string; + defaultValue?: string; + renderDefaultValueAsCode?: boolean; + description: string; +}; + +export type EmailComponentMeta = { + slug: string; + name?: string; + description?: string; + category?: string; + variants: EmailVariant[]; + defaultVariant?: string; + tokens?: EmailTokenReference[]; + options?: ComponentOptionReference[]; + htmlExtraction?: { + targetTag: string; + }; +}; + +export type EmailTemplateMeta = { + slug: string; + name?: string; + description?: string; + category?: string; + variants: EmailVariant[]; + defaultVariant?: string; + tokens?: EmailTokenReference[]; + options?: ComponentOptionReference[]; +}; 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..973d98b540 --- /dev/null +++ b/packages/stacks-email/src/routes/+page.server.ts @@ -0,0 +1,7 @@ +import type { PageServerLoad } from "./$types"; + +import { listEmailTemplates } from "$lib/api/templates"; + +export const load: PageServerLoad = async () => ({ + templates: listEmailTemplates(), +}); diff --git a/packages/stacks-email/src/routes/+page.svelte b/packages/stacks-email/src/routes/+page.svelte new file mode 100644 index 0000000000..3b14b0cf94 --- /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..ab1317212e --- /dev/null +++ b/packages/stacks-email/src/routes/api/compile/+server.ts @@ -0,0 +1,266 @@ +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/api/request-schemas"; +import button from "../../../../components/button"; +import footer from "../../../../components/footer"; +import headline from "../../../../components/headline"; +import header from "../../../../components/header"; +import spacer from "../../../../components/spacer"; +import text from "../../../../components/text"; +import title from "../../../../components/title"; +import { Section } from "$lib/mjml"; +import { tokens } from "$lib/tokens"; +import type { EmailComponentMeta, MjmlNode } from "$lib/types"; + +const getVariantIds = (meta: EmailComponentMeta) => + meta.variants.map((variant) => variant.id) as [string, ...string[]]; + +const componentVariantSchema = (meta: EmailComponentMeta) => + z + .enum(getVariantIds(meta)) + .optional() + .default(meta.defaultVariant ?? meta.variants[0].id); + +type RenderableBlock = { + variant?: string; + size?: string; + props?: unknown; +}; + +type BlockDefinition = { + type: string; + schema: z.ZodObject; + render: (block: RenderableBlock) => MjmlNode; +}; + +const blockDefinitions = { + headline: { + type: "headline", + schema: z.object({ + type: z.literal("headline"), + variant: componentVariantSchema(headline.meta), + props: headline.optionsSchema.optional(), + }), + render: (block) => + headline.component( + block.variant as keyof typeof headline.variants & string, + (block.props ?? {}) as z.output + ) as MjmlNode, + }, + text: { + type: "text", + schema: z.object({ + type: z.literal("text"), + variant: componentVariantSchema(text.meta), + props: text.optionsSchema.optional(), + }), + render: (block) => + text.component( + block.variant as keyof typeof text.variants & string, + (block.props ?? {}) as z.output + ) as MjmlNode, + }, + button: { + type: "button", + schema: z.object({ + type: z.literal("button"), + variant: componentVariantSchema(button.meta), + props: button.optionsSchema + .extend({ + sectionClass: z.string().optional(), + }) + .optional(), + }), + render: (block) => { + const props = (block.props ?? {}) as z.output< + typeof button.optionsSchema + > & { + sectionClass?: string; + }; + const sectionClass = props.sectionClass ?? "bg-block"; + const buttonProps: z.output = { + align: props.align, + className: props.className, + cssClass: props.cssClass, + href: props.href, + text: props.text, + }; + return Section( + [ + button.component( + block.variant as keyof typeof button.variants & string, + buttonProps + ) as MjmlNode, + ], + { + sectionClass, + } + ); + }, + }, + title: { + type: "title", + schema: z.object({ + type: z.literal("title"), + variant: componentVariantSchema(title.meta), + props: title.optionsSchema.optional(), + }), + render: (block) => + title.component( + block.variant as keyof typeof title.variants & string, + (block.props ?? {}) as z.output + ) as MjmlNode, + }, + spacer: { + type: "spacer", + schema: z.object({ + type: z.literal("spacer"), + size: z.enum(["medium", "large"]).optional().default("medium"), + props: spacer.optionsSchema.optional(), + }), + render: (block) => + spacer.component( + block.size as keyof typeof spacer.variants & string, + (block.props ?? {}) as z.output + ) as MjmlNode, + }, +} satisfies Record; + +const blockDefinitionList = Object.values(blockDefinitions); + +const composeBlockSchema = z.discriminatedUnion( + "type", + blockDefinitionList.map((definition) => definition.schema) as [ + (typeof blockDefinitionList)[number]["schema"], + (typeof blockDefinitionList)[number]["schema"], + ...(typeof blockDefinitionList)[number]["schema"][], + ] +); + +const composeRequestSchema = z.object({ + template: z.literal("transactional"), + target: compileTargetSchema, + previewText: z.string().optional(), + blocks: z + .array(composeBlockSchema) + .min(1, { error: "`blocks` must contain at least one block." }), +}); + +type ComposeBlock = z.infer; +type ComposeBlockType = ComposeBlock["type"]; + +const blockRenderers = Object.fromEntries( + blockDefinitionList.map((definition) => [ + definition.type as ComposeBlockType, + definition.render, + ]) +) as { + [Type in ComposeBlockType]: (block: RenderableBlock) => MjmlNode; +}; + +const hasValidBearerToken = (request: Request): boolean => { + const expectedToken = env.STACKS_EMAIL_AUTH_TOKEN?.trim(); + 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 => { + return blockRenderers[block.type](block); +}; + +const buildTransactionalDocument = (blocks: ComposeBlock[]): MjmlNode => ({ + tagName: "mjml", + children: [ + { + tagName: "mj-body", + attributes: { + "background-color": tokens.color.bodyBackground, + }, + children: [ + spacer.component("large", { + sectionClass: "bg-page", + }) as MjmlNode, + header.component("transactional") as MjmlNode, + ...blocks.map((block) => renderTransactionalBlock(block)), + spacer.component("medium", { + sectionClass: "bg-block", + }) as MjmlNode, + footer.component("default", { + unsubscribeUrl: "[[UNSUBSCRIBE_URL]]", + }) as MjmlNode, + spacer.component("large", { + sectionClass: "bg-page", + }) as MjmlNode, + ], + }, + ], +}); + +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 compiled = compileMjml({ + source: document, + 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..69cb7c2e5f --- /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"; +import { targetNames, type CompileTarget } from "$lib/tokens"; +import { + compileEmailTemplate, + getEmailTemplateMeta, + listEmailTemplates, +} from "$lib/api/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..c24da51814 --- /dev/null +++ b/packages/stacks-email/src/routes/emails/[slug]/+page.svelte @@ -0,0 +1,175 @@ + + + + +
    + + +
    +
    + + {#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/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/src/ui/TemplateSidebar.svelte b/packages/stacks-email/src/ui/TemplateSidebar.svelte new file mode 100644 index 0000000000..a04104e3a7 --- /dev/null +++ b/packages/stacks-email/src/ui/TemplateSidebar.svelte @@ -0,0 +1,78 @@ + + + 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/hero/200x200.png b/packages/stacks-email/static/email/hero/200x200.png new file mode 100644 index 0000000000..20bd4c4941 Binary files /dev/null and b/packages/stacks-email/static/email/hero/200x200.png differ diff --git a/packages/stacks-email/static/email/icons/help.png b/packages/stacks-email/static/email/icons/help.png new file mode 100644 index 0000000000..3756e7a84c Binary files /dev/null and b/packages/stacks-email/static/email/icons/help.png differ diff --git a/packages/stacks-email/static/email/promos/helix.png b/packages/stacks-email/static/email/promos/helix.png new file mode 100644 index 0000000000..443f59055e Binary files /dev/null and b/packages/stacks-email/static/email/promos/helix.png differ diff --git a/packages/stacks-email/static/email/promos/rotor-1.png b/packages/stacks-email/static/email/promos/rotor-1.png new file mode 100644 index 0000000000..697a2a1823 Binary files /dev/null and b/packages/stacks-email/static/email/promos/rotor-1.png differ diff --git a/packages/stacks-email/static/email/promos/rotor.png b/packages/stacks-email/static/email/promos/rotor.png new file mode 100644 index 0000000000..b4c182e8a7 Binary files /dev/null and b/packages/stacks-email/static/email/promos/rotor.png differ diff --git a/packages/stacks-email/static/email/promos/tumble.png b/packages/stacks-email/static/email/promos/tumble.png new file mode 100644 index 0000000000..d2a513c7d9 Binary files /dev/null and b/packages/stacks-email/static/email/promos/tumble.png differ diff --git a/packages/stacks-email/static/email/promos/ziggurat.png b/packages/stacks-email/static/email/promos/ziggurat.png new file mode 100644 index 0000000000..aec047b704 Binary files /dev/null and b/packages/stacks-email/static/email/promos/ziggurat.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/static/email/strip/600x140.png b/packages/stacks-email/static/email/strip/600x140.png new file mode 100644 index 0000000000..4ffc01db4b Binary files /dev/null and b/packages/stacks-email/static/email/strip/600x140.png differ diff --git a/packages/stacks-email/static/email/strip/strip-business.png b/packages/stacks-email/static/email/strip/strip-business.png new file mode 100644 index 0000000000..d5aa9f13e6 Binary files /dev/null and b/packages/stacks-email/static/email/strip/strip-business.png differ diff --git a/packages/stacks-email/static/email/strip/strip-divider.png b/packages/stacks-email/static/email/strip/strip-divider.png new file mode 100644 index 0000000000..30bfc26f9d Binary files /dev/null and b/packages/stacks-email/static/email/strip/strip-divider.png differ diff --git a/packages/stacks-email/static/email/strip/strip-overflow.png b/packages/stacks-email/static/email/strip/strip-overflow.png new file mode 100644 index 0000000000..512d022f12 Binary files /dev/null and b/packages/stacks-email/static/email/strip/strip-overflow.png differ diff --git a/packages/stacks-email/static/email/strip/strip-webinar.png b/packages/stacks-email/static/email/strip/strip-webinar.png new file mode 100644 index 0000000000..db59a144de Binary files /dev/null and b/packages/stacks-email/static/email/strip/strip-webinar.png differ diff --git a/packages/stacks-email/svelte.config.js b/packages/stacks-email/svelte.config.js new file mode 100644 index 0000000000..14aea5f429 --- /dev/null +++ b/packages/stacks-email/svelte.config.js @@ -0,0 +1,55 @@ +import { mdsvex } from "mdsvex"; +import adapter from "@sveltejs/adapter-auto"; +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; +import hljs from "highlight.js/lib/core"; +import hljsBash from "highlight.js/lib/languages/bash"; +import hljsTypeScript from "highlight.js/lib/languages/typescript"; +import hljsXml from "highlight.js/lib/languages/xml"; + +hljs.registerLanguage("bash", hljsBash); +hljs.registerLanguage("html", hljsXml); +hljs.registerLanguage("typescript", hljsTypeScript); +hljs.registerLanguage("xml", hljsXml); + +const languageAliases = { + sh: "bash", + ts: "typescript", +}; + +const resolveLanguage = (lang) => { + const language = languageAliases[lang] ?? lang; + return hljs.getLanguage(language) ? language : undefined; +}; + +const escapeHtml = (code) => + code.replace(/&/g, "&").replace(//g, ">"); + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: [ + vitePreprocess(), + mdsvex({ + extension: ".md", + highlight: { + highlighter: (code, lang) => { + const language = resolveLanguage(lang); + const highlighted = language + ? hljs.highlight(code, { + language, + }).value + : escapeHtml(code); + 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/newsletter.ts b/packages/stacks-email/templates/newsletter.ts new file mode 100644 index 0000000000..48278ade17 --- /dev/null +++ b/packages/stacks-email/templates/newsletter.ts @@ -0,0 +1,128 @@ +import { Card } from "../components/card"; +import { Footer } from "../components/footer"; +import { Graphic } from "../components/graphic"; +import { Header } from "../components/header"; +import { Title } from "../components/title"; +import { Headline } from "../components/headline"; +import { Text } from "../components/text"; +import { Spacer } from "../components/spacer"; + +import { + defineEmailTemplate, + defineOption, + defineOptions, +} from "../src/lib/schema"; +import { tokens } from "../src/lib/tokens"; +import type { MjmlNode } from "../src/lib/types"; + +const twoColumnCards = (left: MjmlNode, right: MjmlNode): MjmlNode => { + const column = (card: MjmlNode): MjmlNode => { + const col = (card.children ?? [])[0] ?? { + tagName: "mj-column", + children: [], + }; + return { + ...col, + attributes: { + ...col.attributes, + "width": "50%", + "padding-left": tokens.spacing.sm, + "padding-right": tokens.spacing.sm, + }, + }; + }; + + return { + tagName: "mj-section", + attributes: { + "mj-class": "bg-block", + "padding": `0px ${tokens.spacing.md} ${tokens.layout.containerYPadding}`, + }, + children: [column(left), column(right)], + }; +}; + +const headlinePlaceholder = + "Lorem ipsum dolor sit amet consectetur. Dolor vitae dapibus a netus mattis est"; + +const bodyPlaceholder = `The entire software development lifecycle has been dramatically changed by AI, introducing a new model for team organization and leadership. + +AI has accelerated coding, allowing developers to dedicate more time to complex and creative tasks. Simultaneously, it enables teams to clear bottlenecks of repetitive tasks through automation, allowing leaders to create more agile teams and focus on higher-level strategic problems. + +Ultimately, it is really AI’s ability to automate the “work around the work” that is proving to be transformative for organizations.`; + +const newsletter = defineEmailTemplate({ + slug: "newsletter", + propsSchema: defineOptions([ + defineOption({ + name: "previewText", + type: "string", + initialValue: "The Overflow", + description: "Inbox preheader text inserted into ``.", + }), + ]), + tokens: [], + preview: ({ props }) => ({ previewText: props.previewText }), + renderDocument: (): MjmlNode => ({ + tagName: "mjml", + children: [ + { + tagName: "mj-body", + attributes: { + "background-color": tokens.color.bodyBackground, + }, + children: [ + Spacer("large", { sectionClass: "bg-page" }), + Header("brand"), + Headline("default", { + sectionClass: "bg-card", + eyebrow: "The Overflow", + textContent: headlinePlaceholder, + }), + Text("body", { + textClass: "s-email-text-subtitle", + columnClass: "bg-card", + textContent: "01 January 1970", + }), + Graphic("strip", { + imageSrc: "/email/strip/strip-overflow.png" + }), + Text("body", { textContent: bodyPlaceholder }), + Title("default", { textContent: "Featured" }), + twoColumnCards( + Card("vertical", { + ctaStyle: "title", + titleContent: "Vibe coding without code knowledge", + textContent: "With AI, being able to code has never been easier. But is it any good? Here is what vibe coding is like for someone without technical skills." + }), + Card("vertical", { + ctaStyle: "title", + titleContent: "What is cloud computing and why use it?", + textContent: "In this No Dumb Questions, we’re joined by tech lead for the infrastructure team, to learn about the cloud, compute, and data centers." + }) + ), + Title("default", { + textContent: "Interesting Questions", + }), + Card("vertical", { imageSrc: "", ctaStyle: "title" }), + Card("vertical", { imageSrc: "", ctaStyle: "title" }), + Graphic("strip", { + imageSrc: "/email/strip/strip-divider.png" + }), + Title("default", { + textContent: "Links from around the web", + }), + Card("vertical", { imageSrc: "", ctaStyle: "title" }), + Card("vertical", { imageSrc: "", ctaStyle: "title" }), + Card("horizontal-left", { + ctaStyle: "button", + }), + Footer("social"), + Spacer("large", { sectionClass: "bg-page" }), + ], + }, + ], + }), +}); + +export default newsletter; diff --git a/packages/stacks-email/templates/promotional.ts b/packages/stacks-email/templates/promotional.ts new file mode 100644 index 0000000000..3697fcade4 --- /dev/null +++ b/packages/stacks-email/templates/promotional.ts @@ -0,0 +1,85 @@ +import { Footer } from "../components/footer"; +import { Graphic } from "../components/graphic"; +import { Header } from "../components/header"; +import { Button } from "../components/button"; +import { Headline } from "../components/headline"; +import { Text } from "../components/text"; +import { Spacer } from "../components/spacer"; + +import { Section } from "../src/lib/mjml"; +import { + defineEmailTemplate, + defineOption, + defineOptions, +} from "../src/lib/schema"; +import { tokens } from "../src/lib/tokens"; +import type { MjmlNode } from "../src/lib/types"; + +const promotional = defineEmailTemplate({ + slug: "promotional", + propsSchema: defineOptions([ + defineOption({ + name: "previewText", + type: "string", + initialValue: "The Overflow", + description: "Inbox preheader text inserted into ``.", + }), + defineOption({ + name: "sectionClass", + type: "string", + initialValue: "bg-light-blue", + description: "The class applied to all sections - promo is color drenched by default.", + }), + ]), + tokens: [], + preview: ({ props }) => ({ previewText: props.previewText }), + renderDocument: ({ props }): MjmlNode => ({ + tagName: "mjml", + children: [ + { + tagName: "mj-body", + attributes: { + "background-color": tokens.color.bodyBackground, + }, + children: [ + Spacer("large", { sectionClass: "bg-page" }), + Header("brand-center", { + sectionClass: props.sectionClass, + }), + Headline("default", { + sectionClass: props.sectionClass, + textContent: "Lorem ipsum dolor sit amet consectetur!", + textAlign: "center", + }), + Text("body", { + columnClass: props.sectionClass, + textContent: + "A starting point for more simple transactional emails with a single message. It can [contain links](#) or **rich text**. Read this if text is written in markdown.", + textAlign: "center", + }), + Section([ + Button("primary", { + align: "center", + }) + ], { + sectionClass: props.sectionClass, + }), + Spacer("large", { + sectionClass: props.sectionClass, + }), + Graphic("strip", { + imageSrc: "/email/promos/tumble.png", + sectionClass: "bg-light-blue", + }), + Spacer("medium", { + sectionClass: props.sectionClass, + }), + Footer("social"), + Spacer("large", { sectionClass: "bg-page" }), + ], + }, + ], + }), +}); + +export default promotional; diff --git a/packages/stacks-email/templates/transactional.ts b/packages/stacks-email/templates/transactional.ts new file mode 100644 index 0000000000..0685ecec25 --- /dev/null +++ b/packages/stacks-email/templates/transactional.ts @@ -0,0 +1,153 @@ +import { Button } from "../components/button"; +import { Footer } from "../components/footer"; +import { Graphic } from "../components/graphic"; +import { Headline } from "../components/headline"; +import { Header } from "../components/header"; +import { Spacer } from "../components/spacer"; +import { Text } from "../components/text"; + +import { Section } from "../src/lib/mjml"; +import { renderEmailBodyMarkdown } from "../src/lib/markdown"; +import { + defineEmailTemplate, + defineOption, + defineOptions, +} from "../src/lib/schema"; +import { tokens } from "../src/lib/tokens"; + +const shortBodyMarkdown = ` +**Hi [[FIRST_NAME]]**. We received a request to reset your password. Use the button below to choose a new password. + `; + +const longBodyMarkdown = ` +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: + `; + +const transactional = defineEmailTemplate({ + slug: "transactional", + defaultVariant: "short", + variants: { + long: { + headlineText: "Privacy Policy Update", + bodyMarkdown: longBodyMarkdown, + ctaText: "View privacy policy", + graphicPath: "/email/spots/SpotLock.png", + previewText: "Privacy Policy Update", + }, + }, + propsSchema: defineOptions([ + defineOption({ + name: "headlineText", + type: "string", + initialValue: "Reset your password", + description: "Headline copy rendered near the top of the email.", + }), + defineOption({ + name: "bodyMarkdown", + type: "string", + initialValue: shortBodyMarkdown, + description: + "Markdown body copy rendered into the main text block.", + }), + defineOption({ + name: "bodyContent", + type: "string", + optional: true, + description: + "Pre-rendered body HTML. When omitted, bodyMarkdown is rendered.", + }), + defineOption({ + name: "ctaText", + type: "string", + initialValue: "Reset password", + description: "Primary call-to-action button label.", + }), + defineOption({ + name: "graphicPath", + type: "string", + initialValue: "", + description: + "Optional spot graphic path. Leave empty to hide the graphic.", + }), + defineOption({ + name: "previewText", + type: "string", + initialValue: "Reset your password", + description: "Inbox preheader text inserted into ``.", + }), + ]), + 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.", + }, + ], + preview: ({ props }) => ({ + previewText: props.previewText || props.headlineText, + }), + renderDocument: ({ variant, props }) => { + const graphicPath = props.graphicPath.trim(); + const bodyContent = + props.bodyContent ?? renderEmailBodyMarkdown(props.bodyMarkdown); + + return { + tagName: "mjml", + children: [ + { + tagName: "mj-body", + attributes: { + "background-color": tokens.color.bodyBackground, + }, + children: [ + Spacer("large", { sectionClass: "bg-page" }), + Header("transactional"), + Headline(variant === "long" ? "default" : "highlight", { + textContent: props.headlineText, + }), + ...(graphicPath.length > 0 + ? [Graphic("spot", { imageSrc: graphicPath })] + : []), + Text("body", { textContent: bodyContent }), + Section( + [ + Button("primary", { + href: "[[CTA_URL]]", + align: "left", + text: props.ctaText, + }), + ], + { sectionClass: "bg-block" } + ), + Spacer("large"), + Footer("default", { + unsubscribeUrl: "[[UNSUBSCRIBE_URL]]", + }), + Spacer("large", { sectionClass: "bg-page" }), + ], + }, + ], + }; + }, +}); + +export default transactional; diff --git a/packages/stacks-email/tsconfig.eslint.json b/packages/stacks-email/tsconfig.eslint.json new file mode 100644 index 0000000000..4ab6036224 --- /dev/null +++ b/packages/stacks-email/tsconfig.eslint.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./.svelte-kit/ambient.d.ts", + "./.svelte-kit/non-ambient.d.ts", + "./.svelte-kit/types/**/$types.d.ts", + "./components/**/*.ts", + "./templates/**/*.ts", + "./src/**/*.js", + "./src/**/*.ts", + "./src/**/*.svelte", + "./eslint.config.js", + "./svelte.config.js", + "./vite.config.ts" + ], + "exclude": ["./.svelte-kit/output/**", "./node_modules/**"] +} diff --git a/packages/stacks-email/tsconfig.json b/packages/stacks-email/tsconfig.json new file mode 100644 index 0000000000..8ae706bf0a --- /dev/null +++ b/packages/stacks-email/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "allowSyntheticDefaultImports": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} 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()], +});