@maizzle/framework 6.0.0-0 → 6.0.0-10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,153 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [6.0.0-10] - 2025-07-16
8
+
9
+ ### Fixed
10
+
11
+ - fix: resolve props default options typo 8673a1e
12
+
13
+ ## [6.0.0-9] - 2025-07-16
14
+
15
+ ### Changed
16
+
17
+ - refactor: resolving css props acacc14
18
+
19
+ ## [6.0.0-8] - 2025-07-16
20
+
21
+ ### Changed
22
+
23
+ - refactor: css inlining d443c31
24
+
25
+ ## [6.0.0-7] - 2025-07-15
26
+
27
+ This release integrates the `v5.2.2` patch release, which fixes an issue where both local and production `build.content` paths were used when building for production. When specified in a `config.{env}.js` file, build.content must not be merged with the one in the base `config.js`.
28
+
29
+ ### Fixed
30
+
31
+ - fix: ensure content paths are unique to each build environment 46c292b
32
+
33
+ ## [6.0.0-6] - 2025-07-14
34
+
35
+ ### Fixed
36
+
37
+ - use extension when importing file 99835d3
38
+
39
+ ## [6.0.0-5] - 2025-07-14
40
+
41
+ ### Changed
42
+
43
+ - run css compilation before components too 272bbdb
44
+
45
+ ## [6.0.0-4] - 2025-07-14
46
+
47
+ ### Added
48
+
49
+ - added support for skipping CSS compilation on individual `<style>` tags by adding any of the following attributes: `raw`, `plain`, `as-is`, `uncompiled`, `unprocessed`
50
+
51
+ ### Changed
52
+
53
+ - refactored CSS compilation into a custom PostHTML plugin
54
+
55
+ ### Removed
56
+
57
+ - removed `posthtml-postcss` dependency
58
+
59
+ ## [6.0.0-3] - 2025-07-14
60
+
61
+ ### Added
62
+
63
+ - safelist spark targeting selectors f29efb8
64
+ - safelist targeting for superhuman beb6b41
65
+ - safelist notion mail targeting 5cfd68f
66
+
67
+ ### Changed
68
+
69
+ - safelisting outlook targeting b8b21f3
70
+ - safelisting selectors acb7b80
71
+
72
+ ### Fixed
73
+
74
+ - safelist class names for container queries b828c44
75
+ - purge safelisting patterns d6b9c48
76
+ - safelisting comcast targeting selector 9677421
77
+ - preserve yahoo mail targeting selectors 2f3429f
78
+
79
+ ## [6.0.0-2] - 2025-07-11
80
+
81
+ ### Fixed
82
+
83
+ - fixed an issue with duplicate CSS selectors for utilities that cannot be disabled in Tailwind CSS v4, like `text-decoration`
84
+ - fixed an issue where some Tailwind directives like `@layer` or `@property` were still present in the final build, even though they were not used
85
+
86
+ ## [6.0.0-1] - 2025-07-11
87
+
88
+ ### Added
89
+
90
+ - types for `css.combineMediaQueries` and `css.lightningcss` options in the config
91
+
92
+ ### Changed
93
+
94
+ - made `combineMediaQueries` configurable through the `css.combineMediaQueries` option, which can be set to an object with options for `postcss-sort-media-queries` or to `false` to disable it
95
+ - made `lightningcss` configurable through the `css.lightningcss` option
96
+
97
+ ### Fixed
98
+
99
+ - fixed an issue with CSS syntax lowering, we now use `lightningcss` directly instead of `postcss-lightningcss` which was it to trip on CSS inside `style=""` attributes
100
+
101
+ ## [6.0.0-0] - 2025-07-10
102
+
103
+ This release adds initial support for Tailwind CSS v4 in Maizzle.
104
+
105
+ To jump right in, simply use the `next` branch of the Starter:
106
+
107
+ ```sh
108
+ git clone -b next https://github.com/maizzle/maizzle.git
109
+ ```
110
+
111
+ Or, if you have a project:
112
+
113
+ 1. Update `package.json`:
114
+
115
+ ```json
116
+ {
117
+ "private": true,
118
+ "type": "module",
119
+ "scripts": {
120
+ "dev": "maizzle serve",
121
+ "build": "maizzle build"
122
+ },
123
+ "dependencies": {
124
+ "@maizzle/framework": "next",
125
+ "@maizzle/tailwindcss": "latest"
126
+ }
127
+ }
128
+ ```
129
+
130
+ 2. Update the `style` tag in `layouts/main.html`:
131
+
132
+ ```html
133
+ <style>
134
+ @import "@maizzle/tailwindcss";
135
+
136
+ img {
137
+ @apply max-w-full align-middle;
138
+ }
139
+ </style>
140
+ ```
141
+
142
+ There are still things missign and/or broken:
143
+
144
+ - inline CSS like the `line-height` on spacers is broken/missing
145
+ - `tailwindcss-email-variants` and `tailwindcss-mso` are not ported yet
146
+ - some CSS duplication and artifacts still present in the final build
147
+
148
+ ---
149
+
150
+ - feat: add tailwindcss v4 support 8b5596e
151
+
152
+ https://github.com/maizzle/framework/compare/v5.2.1...v6.0.0-0
153
+
7
154
  ## [5.2.1] - 2025-06-25
8
155
 
9
156
  This is just a maintenance release to update dependencies.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maizzle/framework",
3
- "version": "6.0.0-0",
3
+ "version": "6.0.0-10",
4
4
  "description": "Maizzle is a framework that helps you quickly build HTML emails with Tailwind CSS.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -65,14 +65,13 @@
65
65
  "is-url-superb": "^6.1.0",
66
66
  "istextorbinary": "^9.5.0",
67
67
  "juice": "^11.0.0",
68
+ "lightningcss": "^1.30.1",
68
69
  "lodash-es": "^4.17.21",
69
70
  "morphdom": "^2.7.4",
70
71
  "ora": "^8.1.0",
71
72
  "pathe": "^2.0.0",
72
73
  "postcss": "^8.4.49",
73
- "postcss-calc": "^10.0.2",
74
- "postcss-css-variables": "^0.19.0",
75
- "postcss-lightningcss": "^1.0.1",
74
+ "postcss-custom-properties": "^14.0.6",
76
75
  "postcss-safe-parser": "^7.0.0",
77
76
  "postcss-sort-media-queries": "^5.2.0",
78
77
  "posthtml": "^0.16.6",
@@ -86,7 +85,6 @@
86
85
  "posthtml-markdownit": "^3.1.0",
87
86
  "posthtml-mso": "^3.1.0",
88
87
  "posthtml-parser": "^0.12.1",
89
- "posthtml-postcss": "^1.0.2",
90
88
  "posthtml-postcss-merge-longhand": "^3.1.2",
91
89
  "posthtml-render": "^3.0.0",
92
90
  "posthtml-safe-class-names": "^4.1.0",
@@ -6,56 +6,17 @@ import posthtml from 'posthtml'
6
6
  import posthtmlFetch from 'posthtml-fetch'
7
7
  import envTags from './plugins/envTags.js'
8
8
  import components from 'posthtml-component'
9
- import posthtmlPostcss from 'posthtml-postcss'
10
9
  import expandLinkTag from './plugins/expandLinkTag.js'
11
10
  import envAttributes from './plugins/envAttributes.js'
12
11
  import { getPosthtmlOptions } from './defaultConfig.js'
13
12
  import combineMediaQueries from './plugins/combineMediaQueries.js'
13
+ import defaultComponentsConfig from './defaultComponentsConfig.js'
14
+ import removeRawStyleAttributes from './plugins/removeRawStyleAttributes.js'
14
15
 
15
16
  // PostCSS
16
- import tailwindcss from '@tailwindcss/postcss'
17
- import postcssCalc from 'postcss-calc'
18
- import cssVariables from 'postcss-css-variables'
19
- import postcssSafeParser from 'postcss-safe-parser'
20
- import postcssLightningCss from 'postcss-lightningcss'
21
-
22
- import defaultComponentsConfig from './defaultComponentsConfig.js'
17
+ import { compileCss } from './plugins/postcss/compileCss.js'
23
18
 
24
19
  export async function process(html = '', config = {}) {
25
- /**
26
- * Configure PostCSS pipeline. Plugins defined and added here
27
- * will apply to all `<style>` tags in the HTML.
28
- */
29
- const resolveCSSProps = get(config, 'css.resolveProps')
30
- const resolveCalc = get(config, 'css.resolveCalc') !== false
31
- ? get(config, 'css.resolveCalc', { precision: 2 }) // it's true by default, use default precision 2
32
- : false
33
-
34
- const postcssPlugin = posthtmlPostcss(
35
- [
36
- tailwindcss(get(config, 'css.tailwind', {})),
37
- resolveCSSProps !== false && cssVariables(resolveCSSProps),
38
- resolveCalc !== false && postcssCalc(resolveCalc),
39
- postcssLightningCss({
40
- lightningcssOptions: {
41
- errorRecovery: true,
42
- minify: false,
43
- targets: {
44
- ie: 1,
45
- }
46
- }
47
- }),
48
- ...get(config, 'postcss.plugins', []),
49
- ],
50
- merge(
51
- get(config, 'postcss.options', {}),
52
- {
53
- from: config.cwd || './',
54
- parser: postcssSafeParser
55
- }
56
- )
57
- )
58
-
59
20
  /**
60
21
  * Define PostHTML options by merging user-provided ones
61
22
  * on top of a default configuration.
@@ -111,17 +72,17 @@ export async function process(html = '', config = {}) {
111
72
 
112
73
  return posthtml([
113
74
  ...beforePlugins,
114
- envTags(config.env),
115
- envAttributes(config.env),
116
- expandLinkTag(),
117
- postcssPlugin,
75
+ compileCss(config),
118
76
  fetchPlugin,
119
77
  components(componentsConfig),
78
+ fetchPlugin,
120
79
  expandLinkTag(),
121
- postcssPlugin,
122
80
  envTags(config.env),
123
81
  envAttributes(config.env),
124
- combineMediaQueries(get(config, 'css.combineMediaQueries', { sort: 'mobile-first' })),
82
+ compileCss(config),
83
+ get(config, 'css.combineMediaQueries') !== false
84
+ && combineMediaQueries(get(config, 'css.combineMediaQueries', { sort: 'mobile-first' })),
85
+ removeRawStyleAttributes(),
125
86
  ...get(
126
87
  config,
127
88
  'posthtml.plugins.after',
@@ -129,7 +90,7 @@ export async function process(html = '', config = {}) {
129
90
  ? []
130
91
  : get(config, 'posthtml.plugins', [])
131
92
  ),
132
- ])
93
+ ].filter(Boolean))
133
94
  .process(html, posthtmlOptions)
134
95
  .then(result => ({
135
96
  config: merge(config, { page: config }),
@@ -4,7 +4,7 @@ import sortMediaQueries from 'postcss-sort-media-queries'
4
4
  const plugin = (options = {}) => tree => {
5
5
  const process = node => {
6
6
  // Check if this is a style tag with content
7
- if (node.tag === 'style' && node.content && Array.isArray(node.content)) {
7
+ if (node && node.tag === 'style' && node.content && Array.isArray(node.content)) {
8
8
  // Get the CSS content from the style tag
9
9
  const cssContent = node.content.join('')
10
10
 
@@ -0,0 +1,128 @@
1
+ import postcss from 'postcss'
2
+ import get from 'lodash-es/get.js'
3
+ import { defu as merge } from 'defu'
4
+ import { transform } from 'lightningcss'
5
+ import tailwindcss from '@tailwindcss/postcss'
6
+ import postcssSafeParser from 'postcss-safe-parser'
7
+ import customProperties from 'postcss-custom-properties'
8
+
9
+ const attributes = new Set([
10
+ 'raw',
11
+ 'plain',
12
+ 'as-is',
13
+ 'uncompiled',
14
+ 'unprocessed',
15
+ ])
16
+
17
+ // export attributes
18
+ export const validAttributeNames = attributes
19
+
20
+ /**
21
+ * PostHTML plugin to process Tailwind CSS within style tags.
22
+ *
23
+ * This plugin processes CSS content in `<style>` tags and
24
+ * compiles it with PostCSS. Tags marked with attribute
25
+ * names found in `attributes` will be skipped.
26
+ */
27
+ export function compileCss(config = {}) {
28
+ return tree => {
29
+ return new Promise((resolve, reject) => {
30
+ const stylePromises = []
31
+
32
+ tree.walk(node => {
33
+ if (node && node.tag === 'style' && node.content) {
34
+ if (node.attrs && Object.keys(node.attrs).some(attr => attributes.has(attr))) {
35
+ return node
36
+ }
37
+
38
+ const css = Array.isArray(node.content)
39
+ ? node.content.join('')
40
+ : node.content
41
+
42
+ const promise = processCss(css, config)
43
+ .then(processedCss => {
44
+ node.content = [processedCss]
45
+ })
46
+ .catch(error => {
47
+ console.warn('Error processing CSS in style tag:', error.message)
48
+ })
49
+
50
+ stylePromises.push(promise)
51
+ }
52
+
53
+ return node
54
+ })
55
+
56
+ Promise.all(stylePromises)
57
+ .then(() => resolve(tree))
58
+ .catch(reject)
59
+ })
60
+ }
61
+ }
62
+
63
+ async function processCss(css, config) {
64
+ /**
65
+ * PostCSS pipeline. Plugins defined and added here
66
+ * will apply to all `<style>` tags in the HTML,
67
+ * unless marked to be excluded.
68
+ */
69
+ let resolveCSSProps = get(config, 'css.resolveProps')
70
+ if (resolveCSSProps !== false) {
71
+ resolveCSSProps = merge(resolveCSSProps, { preserve: false })
72
+ }
73
+
74
+ let lightningCssOptions = get(config, 'css.lightning')
75
+ if (lightningCssOptions !== false) {
76
+ lightningCssOptions = merge(
77
+ lightningCssOptions,
78
+ {
79
+ targets: {
80
+ ie: 1,
81
+ },
82
+ }
83
+ )
84
+ }
85
+
86
+ try {
87
+ const result = await postcss([
88
+ tailwindcss(get(config, 'css.tailwind', {})),
89
+ resolveCSSProps && customProperties(resolveCSSProps),
90
+ ...get(config, 'postcss.plugins', []),
91
+ ].filter(Boolean)).process(css, merge(
92
+ get(config, 'postcss.options', {}),
93
+ {
94
+ from: config.cwd || './',
95
+ parser: postcssSafeParser
96
+ }
97
+ ))
98
+
99
+ /**
100
+ * Lightning CSS processing
101
+ *
102
+ * We use this to lower the modern Tailwind CSS 4 syntax
103
+ * to be more email-friendly.
104
+ */
105
+
106
+ if (result.css?.trim() && lightningCssOptions !== false) {
107
+ try {
108
+ const { code } = transform(
109
+ merge(
110
+ lightningCssOptions,
111
+ {
112
+ code: Buffer.from(result.css)
113
+ }
114
+ )
115
+ )
116
+
117
+ return code.toString()
118
+ } catch (error) {
119
+ console.warn('Failed to lower syntax with Lightning CSS:', error.message)
120
+ }
121
+ }
122
+
123
+ return result.css
124
+ } catch (error) {
125
+ console.warn('Error compiling CSS:', error.message)
126
+ return css
127
+ }
128
+ }
@@ -0,0 +1,30 @@
1
+ import { validAttributeNames } from './postcss/compileCss.js'
2
+
3
+ /**
4
+ * Remove specific attributes from `<style>` tags in PostHTML.
5
+ *
6
+ * Use to clean up <style> tag attributes after proccessing
7
+ * has taken place. A "raw" attribute is used to indicate
8
+ * that the content should not be compiled.
9
+ * @returns {Function} PostHTML plugin
10
+ */
11
+ const plugin = () => tree => {
12
+ const process = node => {
13
+ if (node && node.tag === 'style') {
14
+ if (node.attrs && Object.keys(node.attrs).some(attr => validAttributeNames.has(attr))) {
15
+ // Remove the attribute
16
+ for (const attr of Object.keys(node.attrs)) {
17
+ if (validAttributeNames.has(attr)) {
18
+ delete node.attrs[attr]
19
+ }
20
+ }
21
+ }
22
+ }
23
+
24
+ return node
25
+ }
26
+
27
+ return tree.walk(process)
28
+ }
29
+
30
+ export default plugin
@@ -6,18 +6,29 @@ import * as cheerio from 'cheerio/slim'
6
6
  import remove from 'lodash-es/remove.js'
7
7
  import { render } from 'posthtml-render'
8
8
  import isEmpty from 'lodash-es/isEmpty.js'
9
+ import { match } from 'posthtml/lib/api.js'
9
10
  import safeParser from 'postcss-safe-parser'
10
11
  import isObject from 'lodash-es/isObject.js'
11
12
  import { parser as parse } from 'posthtml-parser'
12
13
  import { useAttributeSizes } from './useAttributeSizes.js'
13
14
  import { getPosthtmlOptions } from '../posthtml/defaultConfig.js'
14
15
 
15
- const posthtmlPlugin = (options = {}) => tree => {
16
- return inline(render(tree), options).then(html => parse(html, getPosthtmlOptions()))
16
+ /**
17
+ * PostHTML plugin to inline CSS
18
+ * @param {*} options `css.inline` object from config
19
+ * @returns {Function} PostHTML tree
20
+ */
21
+ export default (options = {}) => tree => {
22
+ return inline(render(tree), options)
17
23
  }
18
24
 
19
- export default posthtmlPlugin
20
-
25
+ /**
26
+ * Function to inline CSS styles with Posthtml and Juice.
27
+ *
28
+ * @param {*} html HTML string to process
29
+ * @param {*} options Options for Juice
30
+ * @returns {string} HTML with inlined styles
31
+ */
21
32
  export async function inline(html = '', options = {}) {
22
33
  // Exit early if no HTML is passed
23
34
  if (typeof html !== 'string' || html === '') {
@@ -32,21 +43,26 @@ export async function inline(html = '', options = {}) {
32
43
  options.safelist = new Set([
33
44
  ...get(options, 'safelist', []),
34
45
  ...[
35
- '.body', // Gmail
36
- '.gmail', // Gmail
37
- '.apple', // Apple Mail
38
- '.ios', // Mail on iOS
39
- '.ox-', // Open-Xchange
40
- '.outlook', // Outlook.com
46
+ 'body', // Gmail
47
+ 'gmail', // Gmail
48
+ 'apple', // Apple Mail
49
+ 'ios', // Mail on iOS
50
+ 'ox-', // Open-Xchange
51
+ 'yahoo', // Yahoo! Mail
52
+ 'outlook', // Outlook Mac and Android
41
53
  '[data-ogs', // Outlook.com
42
- '.bloop_container', // Airmail
43
- '.Singleton', // Apple Mail 10
44
- '.unused', // Notes 8
45
- '.moz-text-html', // Thunderbird
46
- '.mail-detail-content', // Comcast, Libero webmail
54
+ 'bloop_container', // Airmail
55
+ 'Singleton', // Apple Mail 10
56
+ 'unused', // Notes 8
57
+ 'moz-text-html', // Thunderbird
58
+ 'mail-detail-content', // Comcast, Libero webmail
59
+ 'mail-content', // Notion
47
60
  'edo', // Edison (all)
48
61
  '#msgBody', // Freenet uses #msgBody
49
- '.lang' // Fenced code blocks
62
+ 'lang', // Fenced code blocks
63
+ 'ShadowHTML', // Superhuman
64
+ 'spark', // Spark
65
+ 'at-', // Safe class names for container queries
50
66
  ],
51
67
  ])
52
68
 
@@ -63,32 +79,34 @@ export async function inline(html = '', options = {}) {
63
79
  })
64
80
  }
65
81
 
66
- const $ = cheerio.load(html, {
67
- xml: {
68
- decodeEntities: false,
69
- xmlMode: false,
70
- }
71
- })
72
-
73
- // Add a `data-embed` attribute to style tags that have the embed attribute
74
- $('style[embed]:not([data-embed])').each((_i, el) => {
75
- $(el).attr('data-embed', '')
76
- })
77
- $('style[data-embed]:not([embed])').each((_i, el) => {
78
- $(el).attr('embed', '')
79
- })
80
-
81
82
  /**
82
83
  * Inline the CSS
83
84
  *
84
85
  * If customCSS is passed, inline that CSS specifically
85
86
  * Otherwise, use Juice's default inlining
86
87
  */
87
- $.root().html(
88
- css
89
- ? juice.inlineContent($.html(), css, { removeStyleTags, ...options })
90
- : juice($.html(), { removeStyleTags, ...options })
91
- )
88
+ const tree = parse(html, getPosthtmlOptions())
89
+ tree.match = match
90
+
91
+ /**
92
+ * Add a `data-embed` attribute to style tags that have the `embed`
93
+ * attribute, so that Juice can skip them.
94
+ */
95
+ tree.match({ tag: 'style' }, (node) => {
96
+ if (node.attrs && node.attrs.embed !== undefined) {
97
+ node.attrs['data-embed'] = true
98
+ }
99
+
100
+ if (node.attrs && node.attrs['data-embed'] !== undefined) {
101
+ node.attrs.embed = true
102
+ }
103
+
104
+ return node
105
+ })
106
+
107
+ let inlined_html = css
108
+ ? juice.inlineContent(render(tree), css, { removeStyleTags, ...options })
109
+ : juice(render(tree), { removeStyleTags, ...options })
92
110
 
93
111
  /**
94
112
  * Prefer attribute sizes
@@ -99,23 +117,34 @@ export async function inline(html = '', options = {}) {
99
117
  * natural size.
100
118
  */
101
119
  if (options.useAttributeSizes) {
102
- $.root().html(
103
- await useAttributeSizes(html, {
104
- width: juice.widthElements,
105
- height: juice.heightElements,
106
- })
107
- )
120
+ inlined_html = await useAttributeSizes(inlined_html, {
121
+ width: juice.widthElements,
122
+ height: juice.heightElements,
123
+ }, getPosthtmlOptions())
108
124
  }
109
125
 
110
126
  /**
111
- * Remove inlined selectors from the HTML
112
- */
113
- // For each style tag
114
- $('style:not([embed])').each((_i, el) => {
127
+ * Remove inlined selectors from the HTML
128
+ *
129
+ */
130
+ const inlined_tree = parse(inlined_html, getPosthtmlOptions())
131
+ inlined_tree.match = match
132
+
133
+ const preservedAtRules = get(options, 'preservedAtRules', ['media'])
134
+ const selectors = new Set()
135
+
136
+ inlined_tree.match({ tag: 'style' }, node => {
137
+ // If this is an embedded style tag, exit early
138
+ if (node.attrs && ('data-embed' in node.attrs || 'embed' in node.attrs)) {
139
+ return node
140
+ }
141
+
115
142
  // Parse the CSS
116
143
  const { root } = postcss()
117
144
  .process(
118
- $(el).html(),
145
+ Array.isArray(node.content)
146
+ ? node.content.join('')
147
+ : node.content,
119
148
  {
120
149
  from: undefined,
121
150
  parser: safeParser
@@ -129,14 +158,15 @@ export async function inline(html = '', options = {}) {
129
158
 
130
159
  const combinedRegex = new RegExp(combinedPattern)
131
160
 
132
- const selectors = new Set()
133
-
134
- // Preserve selectors in at rules
161
+ // Preserve selectors in predefined at-rules
135
162
  root.walkAtRules(rule => {
136
- if (['media', 'supports'].includes(rule.name)) {
163
+ if (preservedAtRules.includes(rule.name)) {
137
164
  rule.walkRules(rule => {
138
165
  options.safelist.add(rule.selector)
139
166
  })
167
+ } else {
168
+ // Remove the at rule if it's not predefined
169
+ rule.remove()
140
170
  }
141
171
  })
142
172
 
@@ -152,11 +182,12 @@ export async function inline(html = '', options = {}) {
152
182
  prop: get(rule.nodes[0], 'prop')
153
183
  })
154
184
  }
155
- // Preserve pseudo selectors
156
185
  else {
186
+ // Preserve pseudo selectors
157
187
  options.safelist.add(selector)
158
188
  }
159
189
 
190
+
160
191
  if (options.removeInlinedSelectors) {
161
192
  // Remove the rule in the <style> tag as long as it's not a preserved class
162
193
  if (!options.safelist.has(selector) && !combinedRegex.test(selector)) {
@@ -164,30 +195,42 @@ export async function inline(html = '', options = {}) {
164
195
  }
165
196
 
166
197
  // Update the <style> tag contents
167
- $(el).html(root.toString())
198
+ node.content = root.toString()
168
199
  }
169
200
  })
170
201
 
171
- /**
172
- * CSS optimizations
173
- *
174
- * 1. `preferUnitlessValues` - Replace unit values with `0` where possible
175
- * 2. `removeInlinedSelectors` - Remove inlined selectors from the HTML
176
- */
202
+ // This is inlined_tree
203
+ return node
204
+ })
177
205
 
178
- // Loop over selectors that we found in the <style> tags
179
- selectors.forEach(({ name, prop }) => {
180
- const elements = $(name).get()
206
+ /**
207
+ * CSS optimizations
208
+ *
209
+ * `preferUnitlessValues` - Replace unit values with `0` where possible
210
+ * `removeInlinedSelectors` - Remove inlined selectors from the HTML
211
+ */
181
212
 
213
+ const $ = cheerio.load(render(inlined_tree), {
214
+ xml: {
215
+ decodeEntities: false,
216
+ xmlMode: false,
217
+ }
218
+ })
219
+
220
+ // Loop over selectors that we found in the <style> tags
221
+ selectors.forEach(({ name, prop }) => {
222
+ try {
223
+ const elements = $(name).get()
182
224
  // If the property is excluded from inlining, skip
183
225
  if (!juice.excludedProperties.includes(prop)) {
184
226
  // Find the selector in the HTML
185
227
  elements.forEach((el) => {
186
228
  // Get a `property|value` list from the inline style attribute
187
229
  const styleAttr = $(el).attr('style')
230
+ // Store the element's inline styles
188
231
  const inlineStyles = {}
189
232
 
190
- // 1. `preferUnitlessValues`
233
+ // `preferUnitlessValues`
191
234
  if (styleAttr) {
192
235
  try {
193
236
  const root = postcss.parse(`* { ${styleAttr} }`)
@@ -213,13 +256,13 @@ export async function inline(html = '', options = {}) {
213
256
  'style',
214
257
  Object.entries(inlineStyles).map(([property, value]) => `${property}: ${value}`).join('; ')
215
258
  )
216
- } catch {}
259
+ } catch { }
217
260
  }
218
261
 
219
262
  // Get the classes from the element's class attribute
220
263
  const classes = $(el).attr('class')
221
264
 
222
- // 2. `removeInlinedSelectors`
265
+ // `removeInlinedSelectors`
223
266
  if (options.removeInlinedSelectors && classes) {
224
267
  const classList = classes.split(' ')
225
268
 
@@ -240,12 +283,24 @@ export async function inline(html = '', options = {}) {
240
283
  }
241
284
  })
242
285
  }
243
- })
286
+ } catch { }
244
287
  })
245
288
 
246
- $('style[embed]').each((_i, el) => {
247
- $(el).removeAttr('embed')
289
+ const optimized_tree = parse($.html(), getPosthtmlOptions())
290
+ optimized_tree.match = match
291
+
292
+ /**
293
+ * Remove the `embed` attribute from style tags
294
+ */
295
+ optimized_tree.match({ tag: 'style' }, (node) => {
296
+ // If this is an embedded style tag, exit early
297
+ if (node.attrs && node.attrs.embed !== undefined) {
298
+ delete node.attrs.embed
299
+ }
300
+
301
+ return node
248
302
  })
249
303
 
250
- return $.html()
304
+ // Finally, return the inlined html
305
+ return render(optimized_tree)
251
306
  }
@@ -9,20 +9,24 @@ import { getPosthtmlOptions } from '../posthtml/defaultConfig.js'
9
9
  const posthtmlPlugin = options => tree => {
10
10
  const defaultSafelist = [
11
11
  '*body*', // Gmail
12
- '.gmail*', // Gmail
13
- '.apple*', // Apple Mail
14
- '.ios*', // Mail on iOS
15
- '.ox-*', // Open-Xchange
16
- '.outlook*', // Outlook.com
12
+ '*gmail*', // Gmail
13
+ '*apple*', // Apple Mail
14
+ '*ios*', // Mail on iOS
15
+ '*ox-*', // Open-Xchange
16
+ '*outlook*', // Outlook.com
17
17
  '[data-ogs*', // Outlook.com
18
- '.bloop_container', // Airmail
19
- '.Singleton', // Apple Mail 10
20
- '.unused', // Notes 8
21
- '.moz-text-html', // Thunderbird
22
- '.mail-detail-content', // Comcast, Libero webmail
18
+ '*bloop_container*', // Airmail
19
+ '*Singleton*', // Apple Mail 10
20
+ '*unused', // Notes 8
21
+ '*moz-text-html*', // Thunderbird
22
+ '*mail-detail-content*', // Comcast, Libero webmail
23
+ '*mail-content-*', // Notion
23
24
  '*edo*', // Edison (all)
24
25
  '#*', // Freenet uses #msgBody
25
- '.lang*' // Fenced code blocks
26
+ '*lang*', // Fenced code blocks
27
+ '*ShadowHTML*', // Superhuman
28
+ '*spark*', // Spark
29
+ '*at-*', // Safe class names for container queries
26
30
  ]
27
31
 
28
32
  const defaultOptions = {
@@ -12,7 +12,8 @@ export default function posthtmlPlugin(options = {}) {
12
12
  options = merge({
13
13
  replacements: {
14
14
  '{': '{',
15
- '}': '}'
15
+ '}': '}',
16
+ '&': '&',
16
17
  }
17
18
  }, options)
18
19
 
@@ -118,16 +118,25 @@ export async function readFileConfig(config) {
118
118
  }
119
119
 
120
120
  /**
121
- * Override the `content` key in `baseConfig` with the one in `envConfig`.
122
- *
123
- * This is done so that each build uses its own `content` paths, in
124
- * order to avoid compiling unnecessary files.
121
+ * Override the `build.content` key in `baseConfig` with the one in `envConfig`
122
+ * if present. We do this so that each build uses its own `content` paths,
123
+ * in order to avoid compiling unnecessary files.
125
124
  */
126
- if (Array.isArray(envConfig.content)) {
127
- baseConfig.content = []
125
+ if (envConfig.build && Array.isArray(envConfig.build.content)) {
126
+ baseConfig.build = baseConfig.build || {}
127
+ baseConfig.build.content = envConfig.build.content
128
+ // Remove build.content from envConfig to prevent merging duplicates
129
+ envConfig = { ...envConfig, build: { ...envConfig.build, content: undefined } }
130
+ }
131
+
132
+ // Merge envConfig and baseConfig, but ensure build.content is not duplicated
133
+ const merged = merge(envConfig, baseConfig)
134
+
135
+ if (baseConfig.build && Array.isArray(baseConfig.build.content)) {
136
+ merged.build.content = baseConfig.build.content
128
137
  }
129
138
 
130
- return merge(envConfig, baseConfig)
139
+ return merged
131
140
  } catch (_error) {
132
141
  throw new Error('Could not compute config')
133
142
  }
package/types/config.d.ts CHANGED
@@ -14,9 +14,10 @@ import type { BaseURLConfig } from 'posthtml-base-url';
14
14
  import type URLParametersConfig from './urlParameters';
15
15
  import type { PostCssCalcOptions } from 'postcss-calc';
16
16
  import type { PostHTMLFetchConfig } from 'posthtml-fetch';
17
- import type { Config as TailwindConfig } from 'tailwindcss';
18
17
  import type { PostHTMLComponents } from 'posthtml-component';
19
18
  import type { PostHTMLExpressions } from 'posthtml-expressions';
19
+ import type { TransformOptions, CustomAtRules } from 'lightningcss';
20
+ import type { PostCSSSortMediaQueriesOptions } from './css/combineMediaQueries';
20
21
 
21
22
  export default interface Config {
22
23
  /**
@@ -217,9 +218,30 @@ export default interface Config {
217
218
  sixHex?: boolean;
218
219
 
219
220
  /**
220
- * Use a custom Tailwind CSS configuration object.
221
+ * Configure Lightning CSS.
222
+ *
223
+ * This is used mainly for lowering CSS syntax to be more compatible with email clients.
224
+ */
225
+ lightningcss?: TransformOptions<CustomAtRules>;
226
+
227
+ /**
228
+ * Combine media queries in your CSS.
229
+ *
230
+ * @default { sort: 'mobile-first' }
231
+ *
232
+ * @example
233
+ * ```
234
+ * export default {
235
+ * css: {
236
+ * combineMediaQueries: {
237
+ * sort: 'desktop-first',
238
+ * onlyTopLevel: true,
239
+ * }
240
+ * }
241
+ * }
242
+ * ```
221
243
  */
222
- tailwind?: TailwindConfig;
244
+ combineMediaQueries?: PostCSSSortMediaQueriesOptions;
223
245
  }
224
246
 
225
247
  /**
@@ -0,0 +1,90 @@
1
+ import type { Plugin } from 'postcss';
2
+
3
+ /**
4
+ * Sort function for media queries.
5
+ * Takes two media query strings and returns a number indicating their relative order.
6
+ */
7
+ export type SortFunction = (a: string, b: string) => number;
8
+
9
+ /**
10
+ * Configuration object for sort-css-media-queries library.
11
+ */
12
+ export interface SortConfiguration {
13
+ [key: string]: any;
14
+ }
15
+
16
+ /**
17
+ * Options for the postcss-sort-media-queries plugin.
18
+ */
19
+ export interface PostCSSSortMediaQueriesOptions {
20
+ /**
21
+ * Sorting method for media queries.
22
+ *
23
+ * @default 'mobile-first'
24
+ *
25
+ * @example
26
+ * ```
27
+ * // Use built-in mobile-first sorting
28
+ * sort: 'mobile-first'
29
+ *
30
+ * // Use built-in desktop-first sorting
31
+ * sort: 'desktop-first'
32
+ *
33
+ * // Use custom sorting function
34
+ * sort: (a, b) => a.localeCompare(b)
35
+ * ```
36
+ */
37
+ sort?: 'mobile-first' | 'desktop-first' | SortFunction;
38
+
39
+ /**
40
+ * Custom configuration object for the sort-css-media-queries library.
41
+ * When provided, it will be used to create a custom sort function.
42
+ *
43
+ * @default false
44
+ *
45
+ * @example
46
+ * ```
47
+ * configuration: {
48
+ * unitlessMqAlwaysFirst: true,
49
+ * sort: 'mobile-first'
50
+ * }
51
+ * ```
52
+ */
53
+ configuration?: false | SortConfiguration;
54
+
55
+ /**
56
+ * Whether to only process media queries at the top level (direct children of root).
57
+ * When true, nested media queries will be ignored.
58
+ *
59
+ * @default false
60
+ *
61
+ * @example
62
+ * ```
63
+ * onlyTopLevel: true
64
+ * ```
65
+ */
66
+ onlyTopLevel?: boolean;
67
+ }
68
+
69
+ /**
70
+ * PostCSS plugin that sorts CSS media queries.
71
+ *
72
+ * @param options - Plugin configuration options
73
+ * @returns PostCSS plugin instance
74
+ *
75
+ * @example
76
+ * ```
77
+ * import postcss from 'postcss';
78
+ * import sortMediaQueries from 'postcss-sort-media-queries';
79
+ *
80
+ * postcss([
81
+ * sortMediaQueries({
82
+ * sort: 'mobile-first',
83
+ * onlyTopLevel: false
84
+ * })
85
+ * ])
86
+ * ```
87
+ */
88
+ declare function postcssSortMediaQueries(options?: PostCSSSortMediaQueriesOptions): Plugin;
89
+
90
+ export default postcssSortMediaQueries;