|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #25189 — the mail HTML hole placement judge, one placement class at a time. |
| 5 | + * |
| 6 | + * Each case names the class it asserts; the bodies carry holes and markup |
| 7 | + * only, never a value. The judge's grammar parity with the renderer is pinned |
| 8 | + * in `plugin-email` (`text-field-html-outlets.pin.test.ts`), where the |
| 9 | + * renderer is. |
| 10 | + */ |
| 11 | +import { describe, expect, it } from 'vitest'; |
| 12 | + |
| 13 | +import { |
| 14 | + judgeMailHtmlHoles, |
| 15 | + MAIL_HTML_PLACEMENT_LABELS, |
| 16 | + MAIL_HTML_SUPPORTED_PLACEMENTS, |
| 17 | + type MailHtmlHolePlacement, |
| 18 | +} from './mail-html-hole-placement'; |
| 19 | + |
| 20 | +const one = (html: string, urlVariables: string[] = []) => { |
| 21 | + const holes = judgeMailHtmlHoles(html, { urlVariables }); |
| 22 | + expect(holes, html).toHaveLength(1); |
| 23 | + return holes[0]; |
| 24 | +}; |
| 25 | + |
| 26 | +describe('the supported placements', () => { |
| 27 | + it.each([ |
| 28 | + ['<p>{{ who }}</p>', 'element-text'], |
| 29 | + ['{{who}}', 'element-text'], |
| 30 | + ['<table><tr><td>{{ who | upper }}</td></tr></table>', 'element-text'], |
| 31 | + ['<title>{{who}}</title>', 'element-text'], |
| 32 | + ['<textarea>{{who}}</textarea>', 'element-text'], |
| 33 | + ['<p title="{{who}}">x</p>', 'quoted-attribute'], |
| 34 | + ["<p title='{{who}}'>x</p>", 'quoted-attribute'], |
| 35 | + ['<td width="{{w}}" class="a {{c}} b">x</td>', 'quoted-attribute'], |
| 36 | + ] as const)('%s → %s, supported', (html, placement) => { |
| 37 | + for (const hole of judgeMailHtmlHoles(html)) { |
| 38 | + expect(hole.placement).toBe(placement); |
| 39 | + expect(hole.supported).toBe(true); |
| 40 | + expect(hole.raw).toBe(false); |
| 41 | + } |
| 42 | + }); |
| 43 | + |
| 44 | + it('a triple-braced hole is supported as a declared url variable forming the whole double-quoted href', () => { |
| 45 | + for (const html of ['<a href="{{{url}}}">go</a>', '<a HREF="{{{ url }}}">go</a>', '<a class="b" href = "{{{url}}}">go</a>']) { |
| 46 | + const hole = one(html, ['url']); |
| 47 | + expect(hole).toMatchObject({ raw: true, placement: 'url-attribute', supported: true, path: 'url' }); |
| 48 | + } |
| 49 | + }); |
| 50 | + |
| 51 | + it('the supported list is the two encoded placements', () => { |
| 52 | + expect([...MAIL_HTML_SUPPORTED_PLACEMENTS]).toEqual(['element-text', 'quoted-attribute']); |
| 53 | + }); |
| 54 | +}); |
| 55 | + |
| 56 | +describe('the refused placements, by class', () => { |
| 57 | + it.each([ |
| 58 | + ['<script>{{x}}</script>', 'raw-text-element'], |
| 59 | + ['<style>p { color: {{x}} }</style>', 'raw-text-element'], |
| 60 | + ['<noscript>{{x}}</noscript>', 'raw-text-element'], |
| 61 | + ['<plaintext>{{x}}', 'raw-text-element'], |
| 62 | + ['<!-- {{x}} -->', 'comment'], |
| 63 | + ['<!--[if mso]><p>{{x}}</p><![endif]-->', 'comment'], |
| 64 | + ['<!DOCTYPE {{x}}>', 'comment'], |
| 65 | + ['<? {{x}} ?>', 'comment'], |
| 66 | + ['<td width={{x}}>a</td>', 'unquoted-attribute'], |
| 67 | + ['<td width=1{{x}}>a</td>', 'unquoted-attribute'], |
| 68 | + ['<p {{x}}>a</p>', 'tag'], |
| 69 | + ['<{{x}}>', 'tag'], |
| 70 | + ['</{{x}}>', 'tag'], |
| 71 | + ['<p title="a"{{x}}>a</p>', 'tag'], |
| 72 | + ['<p data-{{x}}="1">a</p>', 'tag'], |
| 73 | + ['<a href="{{x}}">a</a>', 'url-attribute'], |
| 74 | + ['<a href="https://example.test/{{x}}">a</a>', 'url-attribute'], |
| 75 | + ['<img src="{{x}}">', 'url-attribute'], |
| 76 | + ['<form action="{{x}}"></form>', 'url-attribute'], |
| 77 | + ['<td background="{{x}}">a</td>', 'url-attribute'], |
| 78 | + ['<a xlink:href="{{x}}">a</a>', 'url-attribute'], |
| 79 | + ['<meta http-equiv="refresh" content="{{x}}">', 'url-attribute'], |
| 80 | + ['<p onclick="{{x}}">a</p>', 'event-handler-attribute'], |
| 81 | + ['<p style="color: {{x}}">a</p>', 'style-attribute'], |
| 82 | + ['<iframe srcdoc="{{x}}"></iframe>', 'markup-attribute'], |
| 83 | + ['<svg><text>{{x}}</text></svg>', 'foreign-content'], |
| 84 | + ['<svg width="{{x}}"></svg>', 'foreign-content'], |
| 85 | + ['<math><mi>{{x}}</mi></math>', 'foreign-content'], |
| 86 | + ] as const)('%s → %s, refused', (html, placement) => { |
| 87 | + const hole = one(html); |
| 88 | + expect(hole.placement).toBe(placement); |
| 89 | + expect(hole.supported).toBe(false); |
| 90 | + }); |
| 91 | + |
| 92 | + it('a triple-braced hole anywhere but the href shape is refused', () => { |
| 93 | + const refused: Array<[string, string[]]> = [ |
| 94 | + ['<p>{{{url}}}</p>', ['url']], |
| 95 | + ['<p title="{{{url}}}">a</p>', ['url']], |
| 96 | + ['<a href="{{{url}}}">a</a>', []], |
| 97 | + ['<a href="{{{other}}}">a</a>', ['url']], |
| 98 | + ['<a href="{{{ url | upper }}}">a</a>', ['url']], |
| 99 | + ["<a href='{{{url}}}'>a</a>", ['url']], |
| 100 | + ['<a href="{{{url}}}/next">a</a>', ['url']], |
| 101 | + ['<a href="https://example.test/{{{url}}}">a</a>', ['url']], |
| 102 | + ['<a href={{{url}}}>a</a>', ['url']], |
| 103 | + ['<img src="{{{url}}}">', ['url']], |
| 104 | + ['<a data-href="{{{url}}}">a</a>', ['url']], |
| 105 | + ['<svg><a href="{{{url}}}">a</a></svg>', ['url']], |
| 106 | + ]; |
| 107 | + for (const [html, urlVariables] of refused) { |
| 108 | + const hole = one(html, urlVariables); |
| 109 | + expect(hole.raw, html).toBe(true); |
| 110 | + expect(hole.supported, html).toBe(false); |
| 111 | + } |
| 112 | + }); |
| 113 | + |
| 114 | + it('a hole after a closed raw-text element, comment or foreign root is judged by the markup that follows', () => { |
| 115 | + const holes = judgeMailHtmlHoles( |
| 116 | + '<style>p{}</style><p>{{a}}</p><!-- c --><b>{{b}}</b><svg></svg><i>{{c}}</i><script></SCRIPT ><u>{{d}}</u>', |
| 117 | + ); |
| 118 | + expect(holes.map((h) => [h.path, h.placement, h.supported])).toEqual([ |
| 119 | + ['a', 'element-text', true], |
| 120 | + ['b', 'element-text', true], |
| 121 | + ['c', 'element-text', true], |
| 122 | + ['d', 'element-text', true], |
| 123 | + ]); |
| 124 | + }); |
| 125 | + |
| 126 | + it('text inside a raw-text element that only looks like a closing tag does not close it', () => { |
| 127 | + expect(one('<script></scriptx>{{x}}</script>').placement).toBe('raw-text-element'); |
| 128 | + expect(one('<style></style-x>{{x}}</style>').placement).toBe('raw-text-element'); |
| 129 | + }); |
| 130 | + |
| 131 | + it('an abruptly closed comment ends where the parser ends it', () => { |
| 132 | + expect(one('<!--><p>{{x}}</p>').placement).toBe('element-text'); |
| 133 | + expect(one('<!---><p>{{x}}</p>').placement).toBe('element-text'); |
| 134 | + expect(one('<!-- a --!><p>{{x}}</p>').placement).toBe('element-text'); |
| 135 | + }); |
| 136 | + |
| 137 | + it('every refused class has an author-facing label', () => { |
| 138 | + const classes: MailHtmlHolePlacement[] = [ |
| 139 | + 'element-text', 'quoted-attribute', 'url-attribute', 'event-handler-attribute', 'style-attribute', |
| 140 | + 'markup-attribute', 'unquoted-attribute', 'tag', 'raw-text-element', 'comment', 'foreign-content', |
| 141 | + ]; |
| 142 | + expect(Object.keys(MAIL_HTML_PLACEMENT_LABELS).sort()).toEqual([...classes].sort()); |
| 143 | + }); |
| 144 | +}); |
| 145 | + |
| 146 | +describe('what is and is not a hole — the renderer\'s grammar', () => { |
| 147 | + it('text the renderer leaves verbatim is markup, not a hole', () => { |
| 148 | + expect(judgeMailHtmlHoles('<p>{{ not a path }}</p>')).toEqual([]); |
| 149 | + expect(judgeMailHtmlHoles('<p>{x}</p>')).toEqual([]); |
| 150 | + expect(judgeMailHtmlHoles('')).toEqual([]); |
| 151 | + }); |
| 152 | + |
| 153 | + it('a verbatim brace pair still moves the markup around it', () => { |
| 154 | + // The pair is not a hole, so the renderer leaves it in the output — and the |
| 155 | + // tag it opens there is where the hole after it sits. |
| 156 | + const holes = judgeMailHtmlHoles('<p>{{ <b }}{{x}}</p>'); |
| 157 | + expect(holes).toHaveLength(1); |
| 158 | + expect(holes[0]).toMatchObject({ path: 'x', placement: 'tag', supported: false }); |
| 159 | + }); |
| 160 | + |
| 161 | + it('a hole\'s span, braces included, and its form', () => { |
| 162 | + expect(judgeMailHtmlHoles('a {{ x.y | currency:EUR }} b {{{z}}} c {{{w}}')).toEqual([ |
| 163 | + expect.objectContaining({ start: 2, text: '{{ x.y | currency:EUR }}', path: 'x.y', raw: false }), |
| 164 | + expect.objectContaining({ text: '{{{z}}}', path: 'z', raw: true }), |
| 165 | + expect.objectContaining({ text: '{{{w}}', path: 'w', raw: false }), |
| 166 | + ]); |
| 167 | + }); |
| 168 | +}); |
0 commit comments