@maizzle/framework 6.0.0-1 → 6.0.0-11

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,91 @@ 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-11] - 2025-07-16
8
+
9
+ ### Fixed
10
+
11
+ - fix: return posthtml tree from inliner 5d78370
12
+
13
+ ## [6.0.0-10] - 2025-07-16
14
+
15
+ ### Fixed
16
+
17
+ - fix: resolve props default options typo 8673a1e
18
+
19
+ ## [6.0.0-9] - 2025-07-16
20
+
21
+ ### Changed
22
+
23
+ - refactor: resolving css props acacc14
24
+
25
+ ## [6.0.0-8] - 2025-07-16
26
+
27
+ ### Changed
28
+
29
+ - refactor: css inlining d443c31
30
+
31
+ ## [6.0.0-7] - 2025-07-15
32
+
33
+ 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`.
34
+
35
+ ### Fixed
36
+
37
+ - fix: ensure content paths are unique to each build environment 46c292b
38
+
39
+ ## [6.0.0-6] - 2025-07-14
40
+
41
+ ### Fixed
42
+
43
+ - use extension when importing file 99835d3
44
+
45
+ ## [6.0.0-5] - 2025-07-14
46
+
47
+ ### Changed
48
+
49
+ - run css compilation before components too 272bbdb
50
+
51
+ ## [6.0.0-4] - 2025-07-14
52
+
53
+ ### Added
54
+
55
+ - added support for skipping CSS compilation on individual `<style>` tags by adding any of the following attributes: `raw`, `plain`, `as-is`, `uncompiled`, `unprocessed`
56
+
57
+ ### Changed
58
+
59
+ - refactored CSS compilation into a custom PostHTML plugin
60
+
61
+ ### Removed
62
+
63
+ - removed `posthtml-postcss` dependency
64
+
65
+ ## [6.0.0-3] - 2025-07-14
66
+
67
+ ### Added
68
+
69
+ - safelist spark targeting selectors f29efb8
70
+ - safelist targeting for superhuman beb6b41
71
+ - safelist notion mail targeting 5cfd68f
72
+
73
+ ### Changed
74
+
75
+ - safelisting outlook targeting b8b21f3
76
+ - safelisting selectors acb7b80
77
+
78
+ ### Fixed
79
+
80
+ - safelist class names for container queries b828c44
81
+ - purge safelisting patterns d6b9c48
82
+ - safelisting comcast targeting selector 9677421
83
+ - preserve yahoo mail targeting selectors 2f3429f
84
+
85
+ ## [6.0.0-2] - 2025-07-11
86
+
87
+ ### Fixed
88
+
89
+ - fixed an issue with duplicate CSS selectors for utilities that cannot be disabled in Tailwind CSS v4, like `text-decoration`
90
+ - fixed an issue where some Tailwind directives like `@layer` or `@property` were still present in the final build, even though they were not used
91
+
7
92
  ## [6.0.0-1] - 2025-07-11
8
93
 
9
94
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maizzle/framework",
3
- "version": "6.0.0-1",
3
+ "version": "6.0.0-11",
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",
@@ -71,8 +71,7 @@
71
71
  "ora": "^8.1.0",
72
72
  "pathe": "^2.0.0",
73
73
  "postcss": "^8.4.49",
74
- "postcss-calc": "^10.0.2",
75
- "postcss-css-variables": "^0.19.0",
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,47 +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
- import lowerCssSyntax from './plugins/lowerCssSyntax.js'
14
12
  import combineMediaQueries from './plugins/combineMediaQueries.js'
13
+ import defaultComponentsConfig from './defaultComponentsConfig.js'
14
+ import removeRawStyleAttributes from './plugins/removeRawStyleAttributes.js'
15
15
 
16
16
  // PostCSS
17
- import tailwindcss from '@tailwindcss/postcss'
18
- import postcssCalc from 'postcss-calc'
19
- import cssVariables from 'postcss-css-variables'
20
- import postcssSafeParser from 'postcss-safe-parser'
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
- ...get(config, 'postcss.plugins', []),
40
- ],
41
- merge(
42
- get(config, 'postcss.options', {}),
43
- {
44
- from: config.cwd || './',
45
- parser: postcssSafeParser
46
- }
47
- )
48
- )
49
-
50
20
  /**
51
21
  * Define PostHTML options by merging user-provided ones
52
22
  * on top of a default configuration.
@@ -102,18 +72,17 @@ export async function process(html = '', config = {}) {
102
72
 
103
73
  return posthtml([
104
74
  ...beforePlugins,
105
- envTags(config.env),
106
- envAttributes(config.env),
107
- expandLinkTag(),
108
- postcssPlugin,
75
+ compileCss(config),
109
76
  fetchPlugin,
110
77
  components(componentsConfig),
78
+ fetchPlugin,
111
79
  expandLinkTag(),
112
- postcssPlugin,
113
80
  envTags(config.env),
114
81
  envAttributes(config.env),
115
- lowerCssSyntax(get(config, 'css.lightningcss', {})),
116
- get(config, 'css.combineMediaQueries') !== false && 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(),
117
86
  ...get(
118
87
  config,
119
88
  'posthtml.plugins.after',
@@ -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
+ /**
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 => {
16
22
  return inline(render(tree), options).then(html => parse(html, getPosthtmlOptions()))
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,90 +195,112 @@ 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
- */
177
-
178
- // Loop over selectors that we found in the <style> tags
179
- selectors.forEach(({ name, prop }) => {
180
- try {
181
- const elements = $(name).get()
182
-
183
- // If the property is excluded from inlining, skip
184
- if (!juice.excludedProperties.includes(prop)) {
185
- // Find the selector in the HTML
186
- elements.forEach((el) => {
187
- // Get a `property|value` list from the inline style attribute
188
- const styleAttr = $(el).attr('style')
189
- const inlineStyles = {}
190
-
191
- // 1. `preferUnitlessValues`
192
- if (styleAttr) {
193
- try {
194
- const root = postcss.parse(`* { ${styleAttr} }`)
195
-
196
- root.first.each((decl) => {
197
- const property = decl.prop
198
- let value = decl.value
199
-
200
- if (value && options.preferUnitlessValues) {
201
- value = value.replace(
202
- /\b0(px|rem|em|%|vh|vw|vmin|vmax|in|cm|mm|pt|pc|ex|ch)\b/g,
203
- '0'
204
- )
205
- }
206
-
207
- if (property) {
208
- inlineStyles[property] = value
209
- }
210
- })
211
-
212
- // Update the element's style attribute with the new value
213
- $(el).attr(
214
- 'style',
215
- Object.entries(inlineStyles).map(([property, value]) => `${property}: ${value}`).join('; ')
216
- )
217
- } catch {}
218
- }
202
+ // This is inlined_tree
203
+ return node
204
+ })
219
205
 
220
- // Get the classes from the element's class attribute
221
- const classes = $(el).attr('class')
206
+ /**
207
+ * CSS optimizations
208
+ *
209
+ * `preferUnitlessValues` - Replace unit values with `0` where possible
210
+ * `removeInlinedSelectors` - Remove inlined selectors from the HTML
211
+ */
222
212
 
223
- // 2. `removeInlinedSelectors`
224
- if (options.removeInlinedSelectors && classes) {
225
- const classList = classes.split(' ')
213
+ const $ = cheerio.load(render(inlined_tree), {
214
+ xml: {
215
+ decodeEntities: false,
216
+ xmlMode: false,
217
+ }
218
+ })
226
219
 
227
- // If the class has been inlined in the style attribute...
228
- if (has(inlineStyles, prop)) {
229
- // Try to remove the classes that have been inlined
230
- if (![...options.safelist].some(item => item.includes(name))) {
231
- remove(classList, classToRemove => name.includes(classToRemove))
220
+ // Loop over selectors that we found in the <style> tags
221
+ selectors.forEach(({ name, prop }) => {
222
+ try {
223
+ const elements = $(name).get()
224
+ // If the property is excluded from inlining, skip
225
+ if (!juice.excludedProperties.includes(prop)) {
226
+ // Find the selector in the HTML
227
+ elements.forEach((el) => {
228
+ // Get a `property|value` list from the inline style attribute
229
+ const styleAttr = $(el).attr('style')
230
+ // Store the element's inline styles
231
+ const inlineStyles = {}
232
+
233
+ // `preferUnitlessValues`
234
+ if (styleAttr) {
235
+ try {
236
+ const root = postcss.parse(`* { ${styleAttr} }`)
237
+
238
+ root.first.each((decl) => {
239
+ const property = decl.prop
240
+ let value = decl.value
241
+
242
+ if (value && options.preferUnitlessValues) {
243
+ value = value.replace(
244
+ /\b0(px|rem|em|%|vh|vw|vmin|vmax|in|cm|mm|pt|pc|ex|ch)\b/g,
245
+ '0'
246
+ )
232
247
  }
233
248
 
234
- // Update the class list on the element with the new classes
235
- if (classList.length > 0) {
236
- $(el).attr('class', classList.join(' '))
237
- } else {
238
- $(el).removeAttr('class')
249
+ if (property) {
250
+ inlineStyles[property] = value
239
251
  }
252
+ })
253
+
254
+ // Update the element's style attribute with the new value
255
+ $(el).attr(
256
+ 'style',
257
+ Object.entries(inlineStyles).map(([property, value]) => `${property}: ${value}`).join('; ')
258
+ )
259
+ } catch { }
260
+ }
261
+
262
+ // Get the classes from the element's class attribute
263
+ const classes = $(el).attr('class')
264
+
265
+ // `removeInlinedSelectors`
266
+ if (options.removeInlinedSelectors && classes) {
267
+ const classList = classes.split(' ')
268
+
269
+ // If the class has been inlined in the style attribute...
270
+ if (has(inlineStyles, prop)) {
271
+ // Try to remove the classes that have been inlined
272
+ if (![...options.safelist].some(item => item.includes(name))) {
273
+ remove(classList, classToRemove => name.includes(classToRemove))
274
+ }
275
+
276
+ // Update the class list on the element with the new classes
277
+ if (classList.length > 0) {
278
+ $(el).attr('class', classList.join(' '))
279
+ } else {
280
+ $(el).removeAttr('class')
240
281
  }
241
282
  }
242
- })
243
- }
244
- } catch {}
245
- })
283
+ }
284
+ })
285
+ }
286
+ } catch { }
246
287
  })
247
288
 
248
- $('style[embed]').each((_i, el) => {
249
- $(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
250
302
  })
251
303
 
252
- return $.html()
304
+ // Finally, return the inlined html
305
+ return render(optimized_tree)
253
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
  }
@@ -1,43 +0,0 @@
1
- import { defu as merge } from 'defu'
2
- import { transform } from 'lightningcss'
3
-
4
- const plugin = (options = {}) => tree => {
5
- options = merge(options, {
6
- targets: options.targets ? {} : {
7
- ie: 1,
8
- },
9
- })
10
-
11
- const process = node => {
12
- // Check if this is a style tag with content
13
- if (node.tag === 'style' && node.content && Array.isArray(node.content)) {
14
- // Get the CSS content from the style tag
15
- const cssContent = node.content.join('')
16
-
17
- if (cssContent.trim()) {
18
- try {
19
- const { code } = transform(
20
- merge(
21
- options,
22
- {
23
- code: Buffer.from(cssContent)
24
- }
25
- )
26
- )
27
-
28
- // Replace the content with processed CSS
29
- node.content = [code.toString()]
30
- } catch (error) {
31
- // If processing fails, leave the content unchanged
32
- console.warn('Failed to process media queries:', error.message)
33
- }
34
- }
35
- }
36
-
37
- return node
38
- }
39
-
40
- return tree.walk(process)
41
- }
42
-
43
- export default plugin