@maizzle/framework 6.0.0-rc.21 → 6.0.0-rc.22

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.
Files changed (69) hide show
  1. package/dist/components/Body.vue +1 -1
  2. package/dist/components/CodeBlock.vue +1 -1
  3. package/dist/components/CodeInline.vue +72 -2
  4. package/dist/components/Column.vue +2 -1
  5. package/dist/components/Container.vue +1 -11
  6. package/dist/components/Img.vue +199 -4
  7. package/dist/components/Preheader.vue +33 -5
  8. package/dist/components/Section.vue +9 -14
  9. package/dist/components/Text.vue +1 -1
  10. package/dist/composables/defineConfig.d.ts +3 -4
  11. package/dist/composables/defineConfig.d.ts.map +1 -1
  12. package/dist/composables/defineConfig.js +3 -4
  13. package/dist/composables/defineConfig.js.map +1 -1
  14. package/dist/composables/renderContext.d.ts +0 -1
  15. package/dist/composables/renderContext.d.ts.map +1 -1
  16. package/dist/composables/renderContext.js.map +1 -1
  17. package/dist/composables/usePreheader.d.ts +6 -5
  18. package/dist/composables/usePreheader.d.ts.map +1 -1
  19. package/dist/composables/usePreheader.js +3 -3
  20. package/dist/composables/usePreheader.js.map +1 -1
  21. package/dist/composables/useTransformers.d.ts +1 -1
  22. package/dist/composables/useTransformers.js +1 -1
  23. package/dist/composables/useTransformers.js.map +1 -1
  24. package/dist/index.d.ts +2 -2
  25. package/dist/index.js +2 -2
  26. package/dist/render/createRenderer.js +2 -2
  27. package/dist/render/createRenderer.js.map +1 -1
  28. package/dist/transformers/addAttributes.d.ts +18 -8
  29. package/dist/transformers/addAttributes.d.ts.map +1 -1
  30. package/dist/transformers/addAttributes.js +22 -8
  31. package/dist/transformers/addAttributes.js.map +1 -1
  32. package/dist/transformers/columnWidth.d.ts.map +1 -1
  33. package/dist/transformers/columnWidth.js +136 -150
  34. package/dist/transformers/columnWidth.js.map +1 -1
  35. package/dist/transformers/entities.d.ts.map +1 -1
  36. package/dist/transformers/entities.js +1 -0
  37. package/dist/transformers/entities.js.map +1 -1
  38. package/dist/transformers/index.d.ts.map +1 -1
  39. package/dist/transformers/index.js +7 -5
  40. package/dist/transformers/index.js.map +1 -1
  41. package/dist/transformers/inlineCss.js +2 -7
  42. package/dist/transformers/inlineCss.js.map +1 -1
  43. package/dist/transformers/minifyCodeInline.d.ts +29 -0
  44. package/dist/transformers/minifyCodeInline.d.ts.map +1 -0
  45. package/dist/transformers/minifyCodeInline.js +36 -0
  46. package/dist/transformers/minifyCodeInline.js.map +1 -0
  47. package/dist/transformers/msoPlaceholders.d.ts +10 -5
  48. package/dist/transformers/msoPlaceholders.d.ts.map +1 -1
  49. package/dist/transformers/msoPlaceholders.js +38 -7
  50. package/dist/transformers/msoPlaceholders.js.map +1 -1
  51. package/dist/transformers/safeSelectors.d.ts +37 -0
  52. package/dist/transformers/safeSelectors.d.ts.map +1 -0
  53. package/dist/transformers/{safeClassNames.js → safeSelectors.js} +24 -5
  54. package/dist/transformers/safeSelectors.js.map +1 -0
  55. package/dist/transformers/shorthandCss.js +38 -7
  56. package/dist/transformers/shorthandCss.js.map +1 -1
  57. package/dist/types/config.d.ts +2 -2
  58. package/dist/types/config.d.ts.map +1 -1
  59. package/dist/utils/ast/serializer.d.ts.map +1 -1
  60. package/dist/utils/ast/serializer.js +27 -17
  61. package/dist/utils/ast/serializer.js.map +1 -1
  62. package/dist/utils/cssBox.d.ts +42 -0
  63. package/dist/utils/cssBox.d.ts.map +1 -0
  64. package/dist/utils/cssBox.js +156 -0
  65. package/dist/utils/cssBox.js.map +1 -0
  66. package/package.json +1 -1
  67. package/dist/transformers/safeClassNames.d.ts +0 -22
  68. package/dist/transformers/safeClassNames.d.ts.map +0 -1
  69. package/dist/transformers/safeClassNames.js.map +0 -1
@@ -94,7 +94,7 @@ const render = () => {
94
94
 
95
95
  const parts = [
96
96
  `dir="${props.dir}"`,
97
- 'style="margin: 0; padding: 0; width: 100%; word-break: break-word;"',
97
+ 'style="margin: 0; padding: 0; width: 100%; height: 100%; word-break: break-word;"',
98
98
  ]
99
99
  if (outlookFallback) {
100
100
  parts.unshift(`xml:lang="${lang}"`)
@@ -61,7 +61,7 @@ export default {
61
61
  const baseStyles = `background-color:${bg};padding:16px;overflow:auto;white-space:pre;word-wrap:normal;word-break:normal;word-spacing:normal`
62
62
  const styles = [baseStyles, attrs.style].filter(Boolean).join(';')
63
63
 
64
- const html = `<table class="w-full"><tr><td class="${props.tdClass}"><pre class="${classes}" style="${styles}"><code>${codeContent}</code></pre></td></tr></table>`
64
+ const html = `<table class="w-full"><tr><td class="${props.tdClass}" style="background-color:${bg}"><pre class="${classes}" style="${styles}"><code>${codeContent}</code></pre></td></tr></table>`
65
65
 
66
66
  return () => createStaticVNode(html, 1)
67
67
  }
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
- import { createStaticVNode } from 'vue'
2
+ import { createStaticVNode, type PropType } from 'vue'
3
+ import { codeToHtml, getSingletonHighlighter, type BundledLanguage, type BundledTheme } from 'shiki'
3
4
 
4
5
  export default {
5
6
  inheritAttrs: false,
@@ -13,9 +14,27 @@ export default {
13
14
  code: {
14
15
  type: String,
15
16
  default: ''
17
+ },
18
+ /**
19
+ * Language for syntax highlighting. Only consulted when `theme` is set.
20
+ * @default 'html'
21
+ */
22
+ language: {
23
+ type: String as PropType<BundledLanguage>,
24
+ default: 'html'
25
+ },
26
+ /**
27
+ * Shiki theme to apply. When set, the inline code is syntax-highlighted
28
+ * with this theme and the cell uses the theme's background color.
29
+ * When unset, falls back to the plain gray-styled `<code>` (no Shiki
30
+ * pass, faster, and visually quieter in body copy).
31
+ */
32
+ theme: {
33
+ type: String as PropType<BundledTheme | undefined>,
34
+ default: undefined
16
35
  }
17
36
  },
18
- setup(props, { slots, attrs }) {
37
+ async setup(props, { slots, attrs }) {
19
38
  let source = props.code
20
39
 
21
40
  if (!source) {
@@ -32,6 +51,57 @@ export default {
32
51
  }
33
52
 
34
53
  const classes = attrs.class ? ` class="${attrs.class}"` : ''
54
+
55
+ if (props.theme) {
56
+ const highlighted = await codeToHtml(source, {
57
+ lang: props.language,
58
+ theme: props.theme,
59
+ })
60
+
61
+ const hl = await getSingletonHighlighter({ themes: [props.theme], langs: [] })
62
+ const bg = hl.getTheme(props.theme).bg
63
+
64
+ const codeContent = highlighted
65
+ .replace(/^<pre[^>]*><code>/, '')
66
+ .replace(/<\/code><\/pre>$/, '')
67
+
68
+ /**
69
+ * Replace shiki's structural `<`/`>` (the `<span>` tag delimiters)
70
+ * with private string markers `§MZLT§`/`§MZGT§`. Source-level
71
+ * entities like `&lt;` (representing a literal `<` in the user's
72
+ * code) are made of `&`, `l`, `t`, `;` — no real `<` character —
73
+ * so they pass through untouched.
74
+ *
75
+ * Why markers and not HTML entities? Both levels of escaping would
76
+ * end up as `&lt;` after a round-trip, and the decoder couldn't
77
+ * tell which to decode back to a real `<` (structural) vs leave
78
+ * as `&lt;` (content). Using non-entity markers makes the two
79
+ * levels distinguishable: only `§MZ*§` gets decoded.
80
+ *
81
+ * Two pipeline passes that would otherwise mangle the shiki HTML
82
+ * are defused by the markers:
83
+ * - `format` (oxfmt with `htmlWhitespaceSensitivity: 'ignore'`)
84
+ * sees the `<code>` body as plain text and won't reflow the
85
+ * chain of `<span>` tokens onto separate lines.
86
+ * - The HTML5 self-close strip (`( \/>)` regex at the end of
87
+ * the pipeline) won't match anything inside a shiki cell,
88
+ * so a highlighted Vue self-closing tag like `<MyTag />`
89
+ * keeps its ` />` instead of being silently shortened to `>`.
90
+ *
91
+ * `minifyCodeInline` swaps the markers back to real angle brackets
92
+ * at the very end of the pipeline, after both passes have run.
93
+ */
94
+ const escaped = codeContent
95
+ .replace(/</g, '§MZLT§')
96
+ .replace(/>/g, '§MZGT§')
97
+
98
+ const baseStyles = `background-color:${bg};border-radius:6px;padding:2px 6px;font-size:11px`
99
+ const styles = [baseStyles, attrs.style].filter(Boolean).join(';')
100
+
101
+ const html = `<code${classes} style="${styles}" data-minify-inline>${escaped}</code>`
102
+ return () => createStaticVNode(html, 1)
103
+ }
104
+
35
105
  const baseStyles = 'white-space:normal;border-radius:6px;border:1px solid #d1d5db;background-color:#f3f4f6;padding:2px 6px;font-size:11px;color:inherit'
36
106
  const styles = [baseStyles, attrs.style].filter(Boolean).join(';')
37
107
 
@@ -71,13 +71,14 @@ const msoWidth = computed(() => {
71
71
  * `inline-table` during CSS inlining; routing both through twMerge lets
72
72
  * the user's utility cleanly replace ours instead of being dropped.
73
73
  */
74
- const baseClass = 'inline-block align-top text-base'
74
+ const baseClass = 'inline-block align-top text-[medium]'
75
75
  const mergedClass = computed(() => twMerge(baseClass, (attrs.class as string) ?? ''))
76
76
 
77
77
  const styles = computed(() => `min-width: ${minWidth.value};`)
78
78
 
79
79
  const tdStyle = computed(() => {
80
80
  const parts = [`width: ${msoWidth.value}`, 'vertical-align: top']
81
+ if (useMarker) parts.push(`__MAIZZLE_COLTDX_${colId}__`)
81
82
  if (props.msoStyle) parts.push(props.msoStyle)
82
83
  return parts.join('; ')
83
84
  })
@@ -26,15 +26,6 @@ const props = defineProps({
26
26
  type: [String, Number],
27
27
  default: null
28
28
  },
29
- /**
30
- * Override the Outlook (MSO) table width independently of the
31
- * div's width. Highest priority — wins over `width` and any
32
- * class-derived value.
33
- */
34
- msoWidth: {
35
- type: [String, Number],
36
- default: null
37
- },
38
29
  /**
39
30
  * Inline CSS applied only to the MSO `<td>` element.
40
31
  *
@@ -65,7 +56,7 @@ const outlookFallback = useOutlookFallback(props.outlookFallback)
65
56
 
66
57
  provide('containerWidth', computed(() => props.width))
67
58
 
68
- const useMarker = outlookFallback && props.width == null && props.msoWidth == null
59
+ const useMarker = outlookFallback && props.width == null
69
60
  const msoId = useMarker ? nextId('c') : null
70
61
  const tdId = outlookFallback ? nextId('ct') : null
71
62
 
@@ -84,7 +75,6 @@ const mergedClass = computed(() => {
84
75
  })
85
76
 
86
77
  const msoWidth = computed(() => {
87
- if (props.msoWidth != null) return normalizeToPixels(props.msoWidth)
88
78
  if (props.width != null) return normalizeToPixels(props.width)
89
79
  return `__MAIZZLE_MSOW_${msoId}__`
90
80
  })
@@ -1,5 +1,16 @@
1
1
  <script setup lang="ts">
2
- import { computed, useAttrs } from 'vue'
2
+ import { computed, createStaticVNode, useAttrs, type PropType } from 'vue'
3
+ import { outlookFallbackProp } from './utils.ts'
4
+ import { useOutlookFallback } from '../composables/useOutlookFallback'
5
+
6
+ type AspectRatio = '1:1' | '4:3' | '3:2' | '16:9' | '21:9' | '2:1' | '3:4' | '9:16' | (string & {})
7
+ type BackgroundPosition =
8
+ | 'top' | 'right' | 'bottom' | 'left' | 'center'
9
+ | 'top left' | 'top right' | 'top center'
10
+ | 'bottom left' | 'bottom right' | 'bottom center'
11
+ | 'center left' | 'center right' | 'center center'
12
+ | (string & {})
13
+ type BackgroundSize = 'cover' | 'contain' | 'auto' | (string & {})
3
14
 
4
15
  defineOptions({ inheritAttrs: false })
5
16
 
@@ -30,9 +41,61 @@ const props = defineProps({
30
41
  motionSrc: {
31
42
  type: String,
32
43
  default: null
33
- }
44
+ },
45
+ /**
46
+ * Aspect ratio for cropped images.
47
+ *
48
+ * Accepts colon or slash form: `'16:9'`, `'16/9'`, `'4:3'`, `'1:1'`, etc.
49
+ *
50
+ * Alternatively, set a Tailwind aspect class on the component:
51
+ * `aspect-square`, `aspect-video`, `aspect-[16/9]`, `aspect-3/2`. The
52
+ * prop wins when both are provided.
53
+ *
54
+ * @example '16:9'
55
+ * @example '4:3'
56
+ * @example '1:1'
57
+ */
58
+ aspect: {
59
+ type: String as PropType<AspectRatio>,
60
+ default: ''
61
+ },
62
+ /**
63
+ * CSS `background-position` for the cropped image fill.
64
+ *
65
+ * @default 'center'
66
+ * @example 'top'
67
+ * @example 'top left'
68
+ * @example '20% 30%'
69
+ */
70
+ position: {
71
+ type: String as PropType<BackgroundPosition>,
72
+ default: 'center'
73
+ },
74
+ /**
75
+ * CSS `background-size` for the cropped image fill.
76
+ *
77
+ * @default 'cover'
78
+ * @example 'contain'
79
+ * @example 'auto'
80
+ */
81
+ size: {
82
+ type: String as PropType<BackgroundSize>,
83
+ default: 'cover'
84
+ },
85
+ /**
86
+ * Toggle Outlook (MSO) and VML fallback markup for this image.
87
+ *
88
+ * Only relevant in cropped mode (`aspect`). When `false`, the VML
89
+ * `<v:rect>` shape is skipped and the modern padding-hack div renders
90
+ * to all clients including Outlook (which will show an empty area).
91
+ *
92
+ * @default true
93
+ */
94
+ outlookFallback: outlookFallbackProp,
34
95
  })
35
96
 
97
+ const outlookFallback = useOutlookFallback(props.outlookFallback)
98
+
36
99
  function mimeFromExtension(src: string): string {
37
100
  const ext = src.split('.').pop()?.toLowerCase() ?? ''
38
101
 
@@ -51,17 +114,149 @@ function mimeFromExtension(src: string): string {
51
114
  return types[ext] ?? ''
52
115
  }
53
116
 
117
+ const ASPECT_KEYWORDS: Record<string, string> = {
118
+ 'aspect-square': '1/1',
119
+ 'aspect-video': '16/9',
120
+ }
121
+
122
+ function normalizeClass(value: unknown): string {
123
+ if (!value) return ''
124
+ if (typeof value === 'string') return value
125
+ if (Array.isArray(value)) return value.map(normalizeClass).filter(Boolean).join(' ')
126
+ if (typeof value === 'object') {
127
+ return Object.entries(value as Record<string, unknown>)
128
+ .filter(([, v]) => v)
129
+ .map(([k]) => k)
130
+ .join(' ')
131
+ }
132
+ return ''
133
+ }
134
+
135
+ /**
136
+ * Pull Tailwind `aspect-*` tokens out of the inherited class list. Returns
137
+ * both the derived ratio (first match wins) and the cleaned class string
138
+ * so the aspect token isn't duplicated on the wrapper.
139
+ */
140
+ const parsedClass = computed(() => {
141
+ const tokens = normalizeClass(attrs.class).split(/\s+/).filter(Boolean)
142
+ let ratio: string | null = null
143
+ const rest: string[] = []
144
+ for (const t of tokens) {
145
+ if (ASPECT_KEYWORDS[t]) {
146
+ if (!ratio) ratio = ASPECT_KEYWORDS[t]
147
+ continue
148
+ }
149
+ const m = t.match(/^aspect-(?:\[(\d+(?:\.\d+)?)[/:](\d+(?:\.\d+)?)\]|(\d+(?:\.\d+)?)\/(\d+(?:\.\d+)?))$/)
150
+ if (m) {
151
+ if (!ratio) ratio = `${m[1] ?? m[3]}/${m[2] ?? m[4]}`
152
+ continue
153
+ }
154
+ rest.push(t)
155
+ }
156
+ return { ratio, className: rest.join(' ') }
157
+ })
158
+
159
+ const resolvedAspect = computed(() => props.aspect || parsedClass.value.ratio || '')
160
+
161
+ const ratio = computed(() => {
162
+ if (!resolvedAspect.value) return null
163
+ const [w, h] = resolvedAspect.value.split(/[:/]/).map(Number)
164
+ if (!w || !h || !Number.isFinite(w) || !Number.isFinite(h)) return null
165
+ const pct = ((h / w) * 100).toFixed(4).replace(/\.?0+$/, '')
166
+ return { w, h, paddingBottom: `${pct}%` }
167
+ })
168
+
169
+ const isCropped = computed(() => ratio.value !== null)
170
+
54
171
  const motionType = computed(() => mimeFromExtension(props.motionSrc ?? ''))
55
172
 
56
173
  const imgWidth = computed(() => Number.parseInt(String(props.width), 10))
57
174
 
58
- const usePicture = computed(() => props.darkSrc || props.motionSrc)
175
+ const heightPx = computed(() =>
176
+ ratio.value && Number.isFinite(imgWidth.value)
177
+ ? Math.round((imgWidth.value * ratio.value.h) / ratio.value.w)
178
+ : null
179
+ )
180
+
181
+ const usePicture = computed(() => !isCropped.value && (props.darkSrc || props.motionSrc))
182
+
183
+ /**
184
+ * Escape characters that break Tailwind's `bg-[url('...')]` arbitrary value
185
+ * (the closing `']`, braces, spaces) and the `url()` wrapper itself (quotes,
186
+ * parens). Targeted replace so already-encoded URLs aren't double-encoded.
187
+ *
188
+ * Only used for the dark/motion variant classes — those have to be Tailwind
189
+ * arbitrary classes so they compile to `@media` rules. The base background
190
+ * image is set inline via `:style` to avoid the CSS pipeline rewriting it.
191
+ */
192
+ const escapeForClass = (url: string) => url
193
+ .replace(/'/g, '%27')
194
+ .replace(/\(/g, '%28')
195
+ .replace(/\)/g, '%29')
196
+ .replace(/ /g, '%20')
197
+ .replace(/\]/g, '%5D')
198
+ .replace(/\}/g, '%7D')
199
+
200
+ /** Escape a URL for safe use inside `url('...')` in an inline style. */
201
+ const escapeForCssUrl = (s: string) => s
202
+ .replace(/\\/g, '\\\\')
203
+ .replace(/'/g, "\\'")
204
+
205
+ const escapeAttr = (s: string) => s
206
+ .replace(/&/g, '&amp;')
207
+ .replace(/"/g, '&quot;')
208
+ .replace(/</g, '&lt;')
209
+ .replace(/>/g, '&gt;')
210
+
211
+ const vmlAspect = computed(() => {
212
+ if (props.size === 'cover') return 'atleast'
213
+ if (props.size === 'contain') return 'atmost'
214
+ return ''
215
+ })
216
+
217
+ const VmlRect = () => {
218
+ if (!isCropped.value || !heightPx.value || !Number.isFinite(imgWidth.value)) return null
219
+ const aspectAttr = vmlAspect.value ? ` aspect="${vmlAspect.value}"` : ''
220
+ return createStaticVNode(
221
+ `<!--[if mso]><v:rect xmlns:v="urn:schemas-microsoft-com:vml" fill="true" stroke="false" style="width:${imgWidth.value}px;height:${heightPx.value}px;"><v:fill type="frame" src="${escapeAttr(props.src)}"${aspectAttr} /></v:rect><![endif]-->`,
222
+ 1
223
+ )
224
+ }
225
+
226
+ const NotMsoBefore = () => createStaticVNode('<!--[if !mso]><!-->', 1)
227
+ const NotMsoAfter = () => createStaticVNode('<!--<![endif]-->', 1)
59
228
 
60
229
  const imgStyle = 'max-width: 100%; vertical-align: middle;'
61
230
  </script>
62
231
 
63
232
  <template>
64
- <picture v-if="usePicture">
233
+ <template v-if="isCropped">
234
+ <VmlRect v-if="outlookFallback" />
235
+ <NotMsoBefore v-if="outlookFallback" />
236
+ <div
237
+ v-bind="{ ...attrs, class: undefined }"
238
+ role="img"
239
+ :aria-label="alt || undefined"
240
+ :class="['overflow-hidden table', parsedClass.className]"
241
+ :style="`width: ${imgWidth}px; max-width: 100%;`"
242
+ >
243
+ <div
244
+ :class="[
245
+ 'table-cell w-full h-0 bg-no-repeat',
246
+ darkSrc ? `dark:bg-[url('${escapeForClass(darkSrc)}')]!` : '',
247
+ motionSrc ? `motion-safe:bg-[url('${escapeForClass(motionSrc)}')]!` : '',
248
+ ]"
249
+ :style="{
250
+ paddingBottom: ratio!.paddingBottom,
251
+ backgroundImage: `url('${escapeForCssUrl(src)}')`,
252
+ backgroundSize: size,
253
+ backgroundPosition: position,
254
+ }"
255
+ />
256
+ </div>
257
+ <NotMsoAfter v-if="outlookFallback" />
258
+ </template>
259
+ <picture v-else-if="usePicture">
65
260
  <source v-if="darkSrc" :srcset="darkSrc" media="(prefers-color-scheme: dark)">
66
261
  <source v-if="motionSrc" :srcset="motionSrc" :type="motionType || undefined" media="(prefers-reduced-motion: no-preference)">
67
262
  <img v-bind="attrs" :src="src" :alt="alt" :width="imgWidth" :style="imgStyle">
@@ -1,15 +1,43 @@
1
1
  <script setup lang="ts">
2
- defineProps({
3
- /** Number of `&#8199;&#65279;&#847;` filler sequences to render after the preview text. */
2
+ import { useSlots, computed } from 'vue'
3
+
4
+ const props = defineProps({
5
+ /**
6
+ * Explicit number of filler sequences to render. When omitted, the count
7
+ * is auto-derived to fill our default 200-char inbox preview budget.
8
+ */
4
9
  spaces: {
5
10
  type: Number,
6
- default: 150
7
- }
11
+ default: undefined,
12
+ },
8
13
  })
14
+
15
+ const slots = useSlots()
16
+
17
+ function vnodesToText(nodes: unknown): string {
18
+ if (nodes == null || nodes === false || nodes === true) return ''
19
+ if (typeof nodes === 'string' || typeof nodes === 'number') return String(nodes)
20
+ if (Array.isArray(nodes)) return nodes.map(vnodesToText).join('')
21
+ if (typeof nodes === 'object' && 'children' in (nodes as Record<string, unknown>)) {
22
+ return vnodesToText((nodes as { children: unknown }).children)
23
+ }
24
+ return ''
25
+ }
26
+
27
+ // Inbox preview budget. Pad with invisible fillers so the
28
+ // client doesn't pull body content into the snippet.
29
+ const PREVIEW_LENGTH = 200
30
+
31
+ const text = computed(() => vnodesToText(slots.default?.()))
32
+ const fillerCount = computed(() =>
33
+ props.spaces !== undefined
34
+ ? Math.max(0, props.spaces)
35
+ : Math.max(0, PREVIEW_LENGTH - text.value.length),
36
+ )
9
37
  </script>
10
38
 
11
39
  <template>
12
40
  <Teleport to="body:start">
13
- <div style="display: none"><slot /><template v-for="i in spaces" :key="i">&#8199;&#65279;&#847; </template>&nbsp;</div>
41
+ <div style="display: none">{{ text }}<template v-for="i in fillerCount" :key="i">&#8199;&#65279;&#847; </template>&nbsp;</div>
14
42
  </Teleport>
15
43
  </template>
@@ -63,6 +63,7 @@ const userHasWidth = computed(() => {
63
63
 
64
64
  const useMarker = outlookFallback && props.width == null && userHasWidth.value
65
65
  const msoId = useMarker ? nextId('s') : null
66
+ const tdId = outlookFallback ? nextId('st') : null
66
67
 
67
68
  const divStyle = computed(() => {
68
69
  const parts: string[] = []
@@ -76,13 +77,6 @@ const restAttrs = computed(() => {
76
77
  return rest
77
78
  })
78
79
 
79
- const tdStyles = computed(() => {
80
- const parts: string[] = []
81
- if (userStyle.value) parts.push(userStyle.value)
82
- if (props.msoStyle) parts.push(props.msoStyle)
83
- return parts.length ? parts.join('; ') : ''
84
- })
85
-
86
80
  const msoWidth = computed(() => {
87
81
  if (props.width != null) return normalizeToPixels(props.width)
88
82
  if (useMarker) return `__MAIZZLE_MSOW_${msoId}__`
@@ -95,13 +89,12 @@ const colWidthSource = computed(() => {
95
89
  return null
96
90
  })
97
91
 
98
- const MsoBefore = () => {
99
- const tdStyle = tdStyles.value ? ` style="${tdStyles.value}"` : ''
100
- return createStaticVNode(
101
- `<!--[if mso]><table role="none" cellpadding="0" cellspacing="0" style="width: ${msoWidth.value}"><tr><td${tdStyle}><![endif]-->`,
102
- 1
103
- )
104
- }
92
+ const tdMarker = tdId ? `__MAIZZLE_MSOTDSTYLE_${tdId}__` : ''
93
+
94
+ const MsoBefore = () => createStaticVNode(
95
+ `<!--[if mso]><table role="none" cellpadding="0" cellspacing="0" style="width: ${msoWidth.value}"><tr><td${tdMarker}><![endif]-->`,
96
+ 1
97
+ )
105
98
 
106
99
  const MsoAfter = () => createStaticVNode(
107
100
  '<!--[if mso]></td></tr></table><![endif]-->',
@@ -117,6 +110,8 @@ const MsoAfter = () => createStaticVNode(
117
110
  :data-maizzle-msow-id="msoId"
118
111
  :data-maizzle-msow-fallback="useMarker ? '100%' : null"
119
112
  :data-maizzle-cw="colWidthSource"
113
+ :data-maizzle-mso-td-id="tdId"
114
+ :data-maizzle-mso-style="tdId && props.msoStyle ? props.msoStyle : null"
120
115
  >
121
116
  <slot />
122
117
  </div>
@@ -18,7 +18,7 @@ const props = defineProps({
18
18
 
19
19
  const attrs = useAttrs()
20
20
 
21
- const defaultClass = computed(() => props.as === 'span' ? 'text-base' : 'm-0 my-4 text-base')
21
+ const defaultClass = computed(() => props.as === 'span' ? 'text-base' : 'mt-4 text-base')
22
22
  const mergedClass = computed(() => twMerge(defaultClass.value, attrs.class as string))
23
23
  </script>
24
24
 
@@ -3,10 +3,9 @@ import { MaizzleConfig } from "../types/config.js";
3
3
  /**
4
4
  * Define Maizzle config.
5
5
  *
6
- * Works in both contexts:
7
- * - In maizzle.config.ts: typed identity function, returns the config as-is
8
- * - In Vue SFC <script setup>: merges with the global config and provides
9
- * the result to child components via useConfig()
6
+ * In maizzle.config.ts: typed identity function, returns the config as-is
7
+ * In Vue SFC `<script setup>`: merges with the global config and provides
8
+ * the result to child components via `useConfig()`
10
9
  */
11
10
  declare function defineConfig(data?: Partial<MaizzleConfig>): MaizzleConfig;
12
11
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"defineConfig.d.ts","names":[],"sources":["../../src/composables/defineConfig.ts"],"mappings":";;;;;AAqBA;;;;;iBAAgB,YAAA,CAAa,IAAA,GAAM,OAAA,CAAQ,aAAA,IAAsB,aAAA"}
1
+ {"version":3,"file":"defineConfig.d.ts","names":[],"sources":["../../src/composables/defineConfig.ts"],"mappings":";;;;;AAoBA;;;;iBAAgB,YAAA,CAAa,IAAA,GAAM,OAAA,CAAQ,aAAA,IAAsB,aAAA"}
@@ -12,10 +12,9 @@ const merge = createDefu((obj, key, value) => {
12
12
  /**
13
13
  * Define Maizzle config.
14
14
  *
15
- * Works in both contexts:
16
- * - In maizzle.config.ts: typed identity function, returns the config as-is
17
- * - In Vue SFC <script setup>: merges with the global config and provides
18
- * the result to child components via useConfig()
15
+ * In maizzle.config.ts: typed identity function, returns the config as-is
16
+ * In Vue SFC `<script setup>`: merges with the global config and provides
17
+ * the result to child components via `useConfig()`
19
18
  */
20
19
  function defineConfig(data = {}) {
21
20
  if (getCurrentInstance()) {
@@ -1 +1 @@
1
- {"version":3,"file":"defineConfig.js","names":[],"sources":["../../src/composables/defineConfig.ts"],"sourcesContent":["import { getCurrentInstance, inject, provide } from 'vue'\nimport { createDefu } from 'defu'\nimport { MaizzleConfigKey } from './useConfig.ts'\nimport { RenderContextKey } from './renderContext.ts'\nimport type { MaizzleConfig } from '../types/index.ts'\n\nconst merge = createDefu((obj, key, value) => {\n if (Array.isArray(obj[key])) {\n obj[key] = value\n return true\n }\n})\n\n/**\n * Define Maizzle config.\n *\n * Works in both contexts:\n * - In maizzle.config.ts: typed identity function, returns the config as-is\n * - In Vue SFC <script setup>: merges with the global config and provides\n * the result to child components via useConfig()\n */\nexport function defineConfig(data: Partial<MaizzleConfig> = {}): MaizzleConfig {\n // Inside a Vue SFC — merge with global config and provide to children\n if (getCurrentInstance()) {\n const globalConfig = inject(MaizzleConfigKey, {} as MaizzleConfig)\n const merged = merge(data, globalConfig) as MaizzleConfig\n\n const ctx = inject(RenderContextKey)\n if (ctx) ctx.sfcConfig = merged\n\n provide(MaizzleConfigKey, merged)\n\n return merged\n }\n\n // Outside Vue (maizzle.config.ts) — just return the config\n return data as MaizzleConfig\n}\n"],"mappings":";;;;;AAMA,MAAM,QAAQ,YAAY,KAAK,KAAK,UAAU;CAC5C,IAAI,MAAM,QAAQ,IAAI,KAAK,EAAE;EAC3B,IAAI,OAAO;EACX,OAAO;;EAET;;;;;;;;;AAUF,SAAgB,aAAa,OAA+B,EAAE,EAAiB;CAE7E,IAAI,oBAAoB,EAAE;EAExB,MAAM,SAAS,MAAM,MADA,OAAO,kBAAkB,EAAE,CACT,CAAC;EAExC,MAAM,MAAM,OAAO,iBAAiB;EACpC,IAAI,KAAK,IAAI,YAAY;EAEzB,QAAQ,kBAAkB,OAAO;EAEjC,OAAO;;CAIT,OAAO"}
1
+ {"version":3,"file":"defineConfig.js","names":[],"sources":["../../src/composables/defineConfig.ts"],"sourcesContent":["import { getCurrentInstance, inject, provide } from 'vue'\nimport { createDefu } from 'defu'\nimport { MaizzleConfigKey } from './useConfig.ts'\nimport { RenderContextKey } from './renderContext.ts'\nimport type { MaizzleConfig } from '../types/index.ts'\n\nconst merge = createDefu((obj, key, value) => {\n if (Array.isArray(obj[key])) {\n obj[key] = value\n return true\n }\n})\n\n/**\n * Define Maizzle config.\n *\n * In maizzle.config.ts: typed identity function, returns the config as-is\n * In Vue SFC `<script setup>`: merges with the global config and provides\n * the result to child components via `useConfig()`\n */\nexport function defineConfig(data: Partial<MaizzleConfig> = {}): MaizzleConfig {\n // Inside a Vue SFC — merge with global config and provide to children\n if (getCurrentInstance()) {\n const globalConfig = inject(MaizzleConfigKey, {} as MaizzleConfig)\n const merged = merge(data, globalConfig) as MaizzleConfig\n\n const ctx = inject(RenderContextKey)\n if (ctx) ctx.sfcConfig = merged\n\n provide(MaizzleConfigKey, merged)\n\n return merged\n }\n\n // Outside Vue (maizzle.config.ts) — just return the config\n return data as MaizzleConfig\n}\n"],"mappings":";;;;;AAMA,MAAM,QAAQ,YAAY,KAAK,KAAK,UAAU;CAC5C,IAAI,MAAM,QAAQ,IAAI,KAAK,EAAE;EAC3B,IAAI,OAAO;EACX,OAAO;;EAET;;;;;;;;AASF,SAAgB,aAAa,OAA+B,EAAE,EAAiB;CAE7E,IAAI,oBAAoB,EAAE;EAExB,MAAM,SAAS,MAAM,MADA,OAAO,kBAAkB,EAAE,CACT,CAAC;EAExC,MAAM,MAAM,OAAO,iBAAiB;EACpC,IAAI,KAAK,IAAI,YAAY;EAEzB,QAAQ,kBAAkB,OAAO;EAEjC,OAAO;;CAIT,OAAO"}
@@ -20,7 +20,6 @@ interface RenderContext {
20
20
  preheader?: {
21
21
  text: string;
22
22
  fillerCount: number;
23
- shyCount: number;
24
23
  };
25
24
  sfcConfig?: MaizzleConfig;
26
25
  sfcEventHandlers: Array<{
@@ -1 +1 @@
1
- {"version":3,"file":"renderContext.d.ts","names":[],"sources":["../../src/composables/renderContext.ts"],"mappings":";;;;;;UAKiB,gBAAA;EACf,MAAA;EACA,IAAA;EACA,WAAA;EACA,GAAA;AAAA;AAAA,UAGe,aAAA;EACf,EAAA;EANA;EAQA,GAAA;AAAA;AAAA,UAGe,aAAA;EACf,OAAA;EACA,SAAA;IAAc,IAAA;IAAc,WAAA;IAAqB,QAAA;EAAA;EACjD,SAAA,GAAY,aAAA;EACZ,gBAAA,EAAkB,KAAA;IAAQ,IAAA,EAAM,SAAA;IAAW,OAAA,EAAS,QAAA,CAAS,SAAA;EAAA;EAC7D,SAAA,GAAY,mBAAA;EACZ,KAAA,GAAQ,gBAAA;EACR,cAAA,GAAiB,aAAA;AAAA;AAAA,cAGN,gBAAA,EAAkB,YAAA,CAAa,aAAA"}
1
+ {"version":3,"file":"renderContext.d.ts","names":[],"sources":["../../src/composables/renderContext.ts"],"mappings":";;;;;;UAKiB,gBAAA;EACf,MAAA;EACA,IAAA;EACA,WAAA;EACA,GAAA;AAAA;AAAA,UAGe,aAAA;EACf,EAAA;EANA;EAQA,GAAA;AAAA;AAAA,UAGe,aAAA;EACf,OAAA;EACA,SAAA;IAAc,IAAA;IAAc,WAAA;EAAA;EAC5B,SAAA,GAAY,aAAA;EACZ,gBAAA,EAAkB,KAAA;IAAQ,IAAA,EAAM,SAAA;IAAW,OAAA,EAAS,QAAA,CAAS,SAAA;EAAA;EAC7D,SAAA,GAAY,mBAAA;EACZ,KAAA,GAAQ,gBAAA;EACR,cAAA,GAAiB,aAAA;AAAA;AAAA,cAGN,gBAAA,EAAkB,YAAA,CAAa,aAAA"}
@@ -1 +1 @@
1
- {"version":3,"file":"renderContext.js","names":[],"sources":["../../src/composables/renderContext.ts"],"sourcesContent":["import type { InjectionKey } from 'vue'\nimport type { MaizzleConfig } from '../types/index.ts'\nimport type { EventName, EventMap } from '../events/index.ts'\nimport type { UsePlaintextOptions } from './usePlaintext.ts'\n\nexport interface FontRegistration {\n family: string\n slug: string\n declaration: string\n url: string\n}\n\nexport interface TailwindBlock {\n id: string\n /** Optional raw CSS from the component's `#config` slot. */\n css?: string\n}\n\nexport interface RenderContext {\n doctype?: string\n preheader?: { text: string; fillerCount: number; shyCount: number }\n sfcConfig?: MaizzleConfig\n sfcEventHandlers: Array<{ name: EventName; handler: EventMap[EventName] }>\n plaintext?: UsePlaintextOptions\n fonts?: FontRegistration[]\n tailwindBlocks?: TailwindBlock[]\n}\n\nexport const RenderContextKey: InjectionKey<RenderContext> = Symbol('RenderContext')\n"],"mappings":";AA4BA,MAAa,mBAAgD,OAAO,gBAAgB"}
1
+ {"version":3,"file":"renderContext.js","names":[],"sources":["../../src/composables/renderContext.ts"],"sourcesContent":["import type { InjectionKey } from 'vue'\nimport type { MaizzleConfig } from '../types/index.ts'\nimport type { EventName, EventMap } from '../events/index.ts'\nimport type { UsePlaintextOptions } from './usePlaintext.ts'\n\nexport interface FontRegistration {\n family: string\n slug: string\n declaration: string\n url: string\n}\n\nexport interface TailwindBlock {\n id: string\n /** Optional raw CSS from the component's `#config` slot. */\n css?: string\n}\n\nexport interface RenderContext {\n doctype?: string\n preheader?: { text: string; fillerCount: number }\n sfcConfig?: MaizzleConfig\n sfcEventHandlers: Array<{ name: EventName; handler: EventMap[EventName] }>\n plaintext?: UsePlaintextOptions\n fonts?: FontRegistration[]\n tailwindBlocks?: TailwindBlock[]\n}\n\nexport const RenderContextKey: InjectionKey<RenderContext> = Symbol('RenderContext')\n"],"mappings":";AA4BA,MAAa,mBAAgD,OAAO,gBAAgB"}
@@ -1,9 +1,10 @@
1
1
  //#region src/composables/usePreheader.d.ts
2
2
  interface UsePreheaderOptions {
3
- /** Number of &#8199;&#847; filler pairs to render. @default 150 */
4
- fillerCount?: number;
5
- /** Number of &shy; entities to render. @default 150 */
6
- shyCount?: number;
3
+ /**
4
+ * Explicit number of filler sequences to render. When omitted, the count
5
+ * is auto-derived to fill the 200-char inbox preview budget.
6
+ */
7
+ spaces?: number;
7
8
  }
8
9
  /**
9
10
  * Set the preheader text for the current email template.
@@ -15,7 +16,7 @@ interface UsePreheaderOptions {
15
16
  * Usage in SFC <script setup>:
16
17
  * ```ts
17
18
  * usePreheader('Thanks for signing up!')
18
- * usePreheader('Welcome!', { fillerCount: 200, shyCount: 200 })
19
+ * usePreheader('Welcome!', { spaces: 50 })
19
20
  * ```
20
21
  */
21
22
  declare function usePreheader(text: string, options?: UsePreheaderOptions): void;
@@ -1 +1 @@
1
- {"version":3,"file":"usePreheader.d.ts","names":[],"sources":["../../src/composables/usePreheader.ts"],"mappings":";UAGiB,mBAAA;EAAA;EAEf,WAAA;;EAEA,QAAA;AAAA;AAgBF;;;;;;;;;;;;;AAAA,iBAAgB,YAAA,CAAa,IAAA,UAAc,OAAA,GAAU,mBAAA"}
1
+ {"version":3,"file":"usePreheader.d.ts","names":[],"sources":["../../src/composables/usePreheader.ts"],"mappings":";UAKiB,mBAAA;EAAA;;;;EAKf,MAAA;AAAA;;;;;;;;;;;;;;iBAgBc,YAAA,CAAa,IAAA,UAAc,OAAA,GAAU,mBAAA"}
@@ -1,6 +1,7 @@
1
1
  import { RenderContextKey } from "./renderContext.js";
2
2
  import { inject } from "vue";
3
3
  //#region src/composables/usePreheader.ts
4
+ const PREVIEW_LENGTH = 200;
4
5
  /**
5
6
  * Set the preheader text for the current email template.
6
7
  *
@@ -11,15 +12,14 @@ import { inject } from "vue";
11
12
  * Usage in SFC <script setup>:
12
13
  * ```ts
13
14
  * usePreheader('Thanks for signing up!')
14
- * usePreheader('Welcome!', { fillerCount: 200, shyCount: 200 })
15
+ * usePreheader('Welcome!', { spaces: 50 })
15
16
  * ```
16
17
  */
17
18
  function usePreheader(text, options) {
18
19
  const ctx = inject(RenderContextKey);
19
20
  if (ctx) ctx.preheader = {
20
21
  text,
21
- fillerCount: options?.fillerCount ?? 150,
22
- shyCount: options?.shyCount ?? 150
22
+ fillerCount: options?.spaces !== void 0 ? Math.max(0, options.spaces) : Math.max(0, PREVIEW_LENGTH - text.length)
23
23
  };
24
24
  }
25
25
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"usePreheader.js","names":[],"sources":["../../src/composables/usePreheader.ts"],"sourcesContent":["import { inject } from 'vue'\nimport { RenderContextKey } from './renderContext.ts'\n\nexport interface UsePreheaderOptions {\n /** Number of &#8199;&#847; filler pairs to render. @default 150 */\n fillerCount?: number\n /** Number of &shy; entities to render. @default 150 */\n shyCount?: number\n}\n\n/**\n * Set the preheader text for the current email template.\n *\n * Injects a hidden `<div>` at the start of `<body>` with the preheader text\n * followed by filler characters that prevent email clients from pulling\n * in body content after the preheader.\n *\n * Usage in SFC <script setup>:\n * ```ts\n * usePreheader('Thanks for signing up!')\n * usePreheader('Welcome!', { fillerCount: 200, shyCount: 200 })\n * ```\n */\nexport function usePreheader(text: string, options?: UsePreheaderOptions): void {\n const ctx = inject(RenderContextKey)\n if (ctx) {\n ctx.preheader = {\n text,\n fillerCount: options?.fillerCount ?? 150,\n shyCount: options?.shyCount ?? 150,\n }\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAuBA,SAAgB,aAAa,MAAc,SAAqC;CAC9E,MAAM,MAAM,OAAO,iBAAiB;CACpC,IAAI,KACF,IAAI,YAAY;EACd;EACA,aAAa,SAAS,eAAe;EACrC,UAAU,SAAS,YAAY;EAChC"}
1
+ {"version":3,"file":"usePreheader.js","names":[],"sources":["../../src/composables/usePreheader.ts"],"sourcesContent":["import { inject } from 'vue'\nimport { RenderContextKey } from './renderContext.ts'\n\nconst PREVIEW_LENGTH = 200\n\nexport interface UsePreheaderOptions {\n /**\n * Explicit number of filler sequences to render. When omitted, the count\n * is auto-derived to fill the 200-char inbox preview budget.\n */\n spaces?: number\n}\n\n/**\n * Set the preheader text for the current email template.\n *\n * Injects a hidden `<div>` at the start of `<body>` with the preheader text\n * followed by filler characters that prevent email clients from pulling\n * in body content after the preheader.\n *\n * Usage in SFC <script setup>:\n * ```ts\n * usePreheader('Thanks for signing up!')\n * usePreheader('Welcome!', { spaces: 50 })\n * ```\n */\nexport function usePreheader(text: string, options?: UsePreheaderOptions): void {\n const ctx = inject(RenderContextKey)\n if (ctx) {\n const fillerCount = options?.spaces !== undefined\n ? Math.max(0, options.spaces)\n : Math.max(0, PREVIEW_LENGTH - text.length)\n ctx.preheader = { text, fillerCount }\n }\n}\n"],"mappings":";;;AAGA,MAAM,iBAAiB;;;;;;;;;;;;;;AAuBvB,SAAgB,aAAa,MAAc,SAAqC;CAC9E,MAAM,MAAM,OAAO,iBAAiB;CACpC,IAAI,KAIF,IAAI,YAAY;EAAE;EAAM,aAHJ,SAAS,WAAW,KAAA,IACpC,KAAK,IAAI,GAAG,QAAQ,OAAO,GAC3B,KAAK,IAAI,GAAG,iBAAiB,KAAK,OAAO;EACR"}
@@ -11,7 +11,7 @@ import { TransformerToggles } from "../types/config.js";
11
11
  * - `useTransformers({ prettify: true, minify: true })` *enables*
12
12
  * transformers that would otherwise no-op (boolean-driven ones:
13
13
  * inlineCss, purgeCss, prettify, minify, shorthandCss, sixHex,
14
- * safeClassNames, entities). Same effect as setting their config
14
+ * safeSelectors, entities). Same effect as setting their config
15
15
  * slice directly, scoped to one template.
16
16
  *
17
17
  * Data-driven transformers (filters, baseURL, urlQuery, addAttributes,