Skip to content

Commit 0cb1d65

Browse files
authored
Merge pull request #1778 from maizzle/fix/entities-comment-nodes
fix(transformers): encode entities in comment nodes before purge
2 parents 4c0571c + ee1a742 commit 0cb1d65

4 files changed

Lines changed: 68 additions & 7 deletions

File tree

src/tests/transformers/entities.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,30 @@ describe('entities', () => {
118118
})
119119
})
120120

121+
describe('comment nodes', () => {
122+
it('encodes entities inside comments by default', () => {
123+
const result = run('<!--[if mso]>\u00A0\u00A0<![endif]-->', true)
124+
expect(result).toBe('<!--[if mso]>&nbsp;&nbsp;<![endif]-->')
125+
})
126+
127+
it('encodes comments regardless of conditional syntax', () => {
128+
const result = run('<!-- \u00A0 -->', true)
129+
expect(result).toBe('<!-- &nbsp; -->')
130+
})
131+
132+
it('scope.comments false leaves comments untouched', () => {
133+
const html = '<!--[if mso]>\u00A0<![endif]--><p>\u00A0</p>'
134+
const result = entities(html, true, { comments: false })
135+
expect(result).toBe('<!--[if mso]>\u00A0<![endif]--><p>&nbsp;</p>')
136+
})
137+
138+
it('scope.text false leaves text nodes untouched', () => {
139+
const html = '<!--[if mso]>\u00A0<![endif]--><p>\u00A0</p>'
140+
const result = entities(html, true, { text: false })
141+
expect(result).toBe('<!--[if mso]>&nbsp;<![endif]--><p>\u00A0</p>')
142+
})
143+
})
144+
121145
describe('edge cases', () => {
122146
it('handles empty HTML', () => {
123147
const result = run('', true)

src/tests/transformers/runTransformers.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,11 @@ describe('runTransformers config branches', () => {
7777
expect(result).not.toContain('data-x')
7878
expect(result).toContain('data-keep')
7979
})
80+
81+
it('preserves whitespace-only MSO conditionals through purge and minify', async () => {
82+
const spacer = '<html><head></head><body><!--[if mso]>\u00A0\u00A0<![endif]--><p>x</p></body></html>'
83+
const result = await runTransformers(spacer, { css: { purge: true }, html: { minify: true } })
84+
expect(result).toContain('<!--[if mso]>&nbsp;&nbsp;')
85+
expect(result).toContain('<![endif]-->')
86+
})
8087
})

src/transformers/entities.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,25 @@ const DEFAULT_ENTITIES: Record<string, string> = {
3030
}
3131

3232
/**
33-
* Replace literal Unicode characters in text nodes with their HTML entity
34-
* equivalents (zero-width joiners, non-breaking spaces, smart quotes,
35-
* dashes, etc.) for better email-client rendering.
33+
* Node types {@link entitiesDom} encodes. Both default to `true`.
34+
*/
35+
export interface EntitiesScope {
36+
/** Encode entities in text nodes */
37+
text?: boolean
38+
/**
39+
* Encode entities in comment nodes. MSO conditional comment content is
40+
* rendered by Outlook, and comment data survives parse round-trips
41+
* un-decoded — so encoding here protects whitespace-only conditionals
42+
* (e.g. `<Outlook>&nbsp;</Outlook>` spacers) from being collapsed
43+
* or removed by email-comb and html-crush downstream.
44+
*/
45+
comments?: boolean
46+
}
47+
48+
/**
49+
* Replace literal Unicode characters in text and comment nodes with their
50+
* HTML entity equivalents (zero-width joiners, non-breaking spaces, smart
51+
* quotes, dashes, etc.) for better email-client rendering.
3652
*
3753
* @param html HTML string to transform.
3854
* @param custom Extra entries merged on top of the built-in entity map, or
@@ -52,24 +68,27 @@ const DEFAULT_ENTITIES: Record<string, string> = {
5268
* // Disable the transform
5369
* entities('hello world', false)
5470
*/
55-
export function entities(html: string, custom: EntitiesConfig = true): string {
56-
return serialize(entitiesDom(parse(html), custom))
71+
export function entities(html: string, custom: EntitiesConfig = true, scope: EntitiesScope = {}): string {
72+
return serialize(entitiesDom(parse(html), custom, scope))
5773
}
5874

5975
/**
6076
* DOM-form of {@link entities} used by the internal transformer pipeline.
6177
* Takes a parsed DOM, returns a parsed DOM — avoids redundant
6278
* serialize/parse round-trips when chained with other transformers.
6379
*/
64-
export function entitiesDom(dom: ChildNode[], custom: EntitiesConfig = true): ChildNode[] {
80+
export function entitiesDom(dom: ChildNode[], custom: EntitiesConfig = true, scope: EntitiesScope = {}): ChildNode[] {
6581
if (!custom) return dom
6682

6783
const map = typeof custom === 'object'
6884
? merge(custom as Record<string, string>, DEFAULT_ENTITIES)
6985
: DEFAULT_ENTITIES
7086

7187
walk(dom, (node) => {
72-
if (node.type === 'text') {
88+
if (
89+
(node.type === 'text' && scope.text !== false)
90+
|| (node.type === 'comment' && scope.comments !== false)
91+
) {
7392
for (const [char, entity] of Object.entries(map)) {
7493
node.data = node.data.split(char).join(entity)
7594
}

src/transformers/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import type { TailwindBlock } from '../composables/renderContext.ts'
4747
* 9. Filters
4848
* 10. Base URL
4949
* 11. URL query
50+
* 11.5 Entities in comment nodes (before purge — protects MSO conditionals)
5051
* 12. Purge CSS (serializes/parses internally around email-comb)
5152
* 13. Entities
5253
* + Vue-generated comments stripped here (on serialized string)
@@ -165,6 +166,16 @@ export async function runTransformers(
165166
dom = urlQueryDom(dom, queryParams, (queryOptions ?? {}) as import('../types/config.ts').UrlQueryOptions)
166167
}
167168

169+
/**
170+
* 11.5. Encode entities in comment nodes before purge/minify. Raw
171+
* invisible chars (e.g. U+00A0 from Vue decoding &nbsp; at compile
172+
* time) inside MSO conditionals make email-comb remove the whole
173+
* "whitespace-only" conditional and html-crush collapse the chars.
174+
* Text nodes are skipped here — purge's internal parse round-trip
175+
* would decode them again — comment data survives un-decoded.
176+
*/
177+
if (enabled('entities')) dom = entitiesDom(dom, effective.html?.decodeEntities, { text: false })
178+
168179
// 12. Remove unused CSS (serializes/parses internally around email-comb)
169180
if (enabled('purgeCss') && effective.css?.purge) {
170181
const purgeOptions = typeof effective.css.purge === 'object' ? effective.css.purge : {}

0 commit comments

Comments
 (0)