.*]*>.*]*>.*.*<\/code>.*<\/pre>.*<\/td>.*<\/tr>.*<\/table>/s)
+ })
+
+ it('adds w-full class to the wrapping table', async () => {
+ const html = await render({ code: 'test ' })
+
+ expect(html).toContain('')
+ })
+
+ it('adds font-mono class to the pre element', async () => {
+ const html = await render({ code: 'test ' })
+
+ expect(html).toMatch(/ {
+ const html = await render({ code: 'test ' })
+
+ expect(html).toContain('background-color:#fff')
+ expect(html).toContain('padding:24px')
+ expect(html).toContain('overflow:auto')
+ expect(html).toContain('white-space:pre')
+ expect(html).toContain('word-wrap:normal')
+ expect(html).toContain('word-break:normal')
+ expect(html).toContain('word-spacing:normal')
+ })
+
+ it('uses default td-class', async () => {
+ const html = await render({ code: 'test ' })
+
+ expect(html).toContain('')
+ })
+
+ it('accepts custom td-class', async () => {
+ const html = await render({ code: ' test ', 'td-class': 'custom-class' })
+
+ expect(html).toContain(' | ')
+ })
+ })
+
+ describe('attrs forwarding', () => {
+ it('merges class onto the pre element', async () => {
+ const html = await render({ code: ' test ', class: 'p-6 rounded-lg' })
+
+ expect(html).toMatch(/ {
+ const html = await render({ code: 'test ', style: 'border:1px solid red' })
+
+ expect(html).toContain('border:1px solid red')
+ })
+ })
+
+ describe('encodedCode prop', () => {
+ it('decodes base64-encoded code', async () => {
+ const code = 'hello '
+ const encoded = Buffer.from(code).toString('base64')
+ const html = await render({ 'encoded-code': encoded })
+
+ expect(html).toContain('')
+ expect(html).toContain('style="color:')
+ })
+
+ it('prefers encodedCode over code prop', async () => {
+ const encoded = Buffer.from('.foo { color: red; }').toString('base64')
+ const html = await render({ 'encoded-code': encoded, code: 'ignored', lang: 'css' })
+
+ // Should highlight the CSS from encodedCode, not the 'ignored' string
+ expect(html).toContain('style="color:')
+ })
+ })
+
+ describe('themes', () => {
+ it('uses github-light theme by default', async () => {
+ const html = await render({ code: '.foo { color: red; }', lang: 'css' })
+
+ expect(html).toContain('background-color:#fff')
+ })
+
+ it('supports dark themes', async () => {
+ const html = await render({ code: '.foo { color: red; }', lang: 'css', theme: 'github-dark' })
+
+ expect(html).toContain('background-color:#24292e')
+ })
+ })
+
+ describe('empty content', () => {
+ it('renders nothing when code is empty', async () => {
+ const html = await render({ code: '' })
+
+ expect(html).not.toContain('')
+ })
+
+ it('renders nothing when code is only whitespace', async () => {
+ const html = await render({ code: ' \n ' })
+
+ expect(html).not.toContain('')
+ })
+ })
+})
diff --git a/src/tests/components/CodeInline.test.ts b/src/tests/components/CodeInline.test.ts
new file mode 100644
index 00000000..cf25f6e2
--- /dev/null
+++ b/src/tests/components/CodeInline.test.ts
@@ -0,0 +1,91 @@
+import { describe, it, expect } from 'vitest'
+import { createSSRApp, h, Suspense } from 'vue'
+import { renderToString } from '@vue/server-renderer'
+import CodeInline from '../../components/CodeInline.vue'
+
+function render(props: Record = {}, slotContent?: string) {
+ const app = createSSRApp({
+ render: () => h(Suspense, null, {
+ default: () => h(CodeInline, props, slotContent
+ ? { default: () => slotContent }
+ : undefined
+ ),
+ }),
+ })
+
+ return renderToString(app)
+}
+
+describe('CodeInline', () => {
+ describe('rendering', () => {
+ it('renders code from prop', async () => {
+ const html = await render({ code: 'npm install' })
+
+ expect(html).toContain(' {
+ const html = await render({}, 'npm install')
+
+ expect(html).toContain('npm install')
+ })
+
+ it('prefers code prop over slot', async () => {
+ const html = await render({ code: 'from prop' }, 'from slot')
+
+ expect(html).toContain('from prop')
+ expect(html).not.toContain('from slot')
+ })
+
+ it('renders nothing when empty', async () => {
+ const html = await render({ code: '' })
+
+ expect(html).not.toContain(' {
+ it('escapes HTML entities', async () => {
+ const html = await render({ code: '' })
+
+ expect(html).toContain('<div class="foo">')
+ expect(html).not.toContain(' ')
+ })
+
+ it('escapes ampersands', async () => {
+ const html = await render({ code: 'a && b' })
+
+ expect(html).toContain('a && b')
+ })
+ })
+
+ describe('styles', () => {
+ it('applies default inline styles', async () => {
+ const html = await render({ code: 'test' })
+
+ expect(html).toContain('white-space:normal')
+ expect(html).toContain('border-radius:6px')
+ expect(html).toContain('border:1px solid #d1d5db')
+ expect(html).toContain('background-color:#f3f4f6')
+ expect(html).toContain('padding:2px 6px')
+ expect(html).toContain('font-size:11px')
+ expect(html).toContain('color:inherit')
+ })
+
+ it('merges custom style', async () => {
+ const html = await render({ code: 'test', style: 'font-weight:bold' })
+
+ expect(html).toContain('font-weight:bold')
+ expect(html).toContain('white-space:normal')
+ })
+ })
+
+ describe('attrs forwarding', () => {
+ it('merges class onto the code element', async () => {
+ const html = await render({ code: 'test', class: 'font-mono' })
+
+ expect(html).toContain('class="font-mono"')
+ })
+ })
+})
diff --git a/src/tests/components/Divider.test.ts b/src/tests/components/Divider.test.ts
new file mode 100644
index 00000000..865ea94d
--- /dev/null
+++ b/src/tests/components/Divider.test.ts
@@ -0,0 +1,132 @@
+import { describe, it, expect } from 'vitest'
+import { mount } from '@vue/test-utils'
+import Divider from '../../components/Divider.vue'
+
+describe('Divider', () => {
+ describe('defaults', () => {
+ it('renders a div with role="separator"', () => {
+ const wrapper = mount(Divider)
+ expect(wrapper.html()).toContain('role="separator"')
+ })
+
+ it('uses 1px height by default', () => {
+ const wrapper = mount(Divider)
+ expect(wrapper.html()).toContain('height: 1px')
+ expect(wrapper.html()).toContain('line-height: 1px')
+ })
+
+ it('uses default background color', () => {
+ const wrapper = mount(Divider)
+ expect(wrapper.html()).toContain('background-color: #cbd5e1')
+ })
+
+ it('applies default spaceY margins', () => {
+ const wrapper = mount(Divider)
+ // happy-dom collapses margin-top/bottom into shorthand
+ expect(wrapper.html()).toContain('margin: 24px 0px')
+ })
+
+ it('contains zero-width joiner', () => {
+ const wrapper = mount(Divider)
+ expect(wrapper.text()).toContain('\u200D')
+ })
+ })
+
+ describe('height prop', () => {
+ it('accepts a string value', () => {
+ const wrapper = mount(Divider, { props: { height: '2px' } })
+ expect(wrapper.html()).toContain('height: 2px')
+ expect(wrapper.html()).toContain('line-height: 2px')
+ })
+
+ it('accepts a number and adds px suffix', () => {
+ const wrapper = mount(Divider, { props: { height: 3 } })
+ expect(wrapper.html()).toContain('height: 3px')
+ expect(wrapper.html()).toContain('line-height: 3px')
+ })
+ })
+
+ describe('color prop', () => {
+ it('overrides the default background color', () => {
+ const wrapper = mount(Divider, { props: { color: '#ff0000' } })
+ expect(wrapper.html()).toContain('background-color: #ff0000')
+ expect(wrapper.html()).not.toContain('background-color: #cbd5e1')
+ })
+ })
+
+ describe('spacing props', () => {
+ it('spaceY sets top and bottom margins', () => {
+ const wrapper = mount(Divider, { props: { spaceY: '16px' } })
+ expect(wrapper.html()).toContain('margin: 16px 0px')
+ })
+
+ it('spaceY accepts a number', () => {
+ const wrapper = mount(Divider, { props: { spaceY: 10 } })
+ expect(wrapper.html()).toContain('margin: 10px 0px')
+ })
+
+ it('spaceY of 0 outputs 0px', () => {
+ const wrapper = mount(Divider, { props: { spaceY: 0 } })
+ expect(wrapper.html()).toContain('margin: 0px')
+ })
+
+ it('spaceX sets left and right margins', () => {
+ const wrapper = mount(Divider, { props: { spaceX: '32px' } })
+ // spaceY default (24px) still applies alongside spaceX
+ expect(wrapper.html()).toContain('margin: 24px 32px')
+ })
+
+ it('spaceX of 0 outputs 0px for horizontal margins', () => {
+ const wrapper = mount(Divider, { props: { spaceX: 0 } })
+ // spaceY default (24px) still applies
+ expect(wrapper.html()).toContain('margin: 24px 0px')
+ })
+ })
+
+ describe('individual margin props', () => {
+ it('top sets margin-top', () => {
+ const wrapper = mount(Divider, { props: { top: '8px' } })
+ expect(wrapper.html()).toContain('8px')
+ })
+
+ it('bottom sets margin-bottom', () => {
+ const wrapper = mount(Divider, { props: { bottom: '12px' } })
+ expect(wrapper.html()).toContain('12px')
+ })
+
+ it('left sets margin-left', () => {
+ const wrapper = mount(Divider, { props: { left: '4px' } })
+ expect(wrapper.html()).toContain('4px')
+ })
+
+ it('right sets margin-right', () => {
+ const wrapper = mount(Divider, { props: { right: '4px' } })
+ expect(wrapper.html()).toContain('4px')
+ })
+
+ it('individual margins accept numbers', () => {
+ const wrapper = mount(Divider, { props: { top: 5, bottom: 10 } })
+ const html = wrapper.html()
+ expect(html).toContain('5px')
+ expect(html).toContain('10px')
+ })
+
+ it('individual margin overrides spaceY', () => {
+ const wrapper = mount(Divider, { props: { spaceY: '24px', top: '8px' } })
+ // margin shorthand: top=8px right=0 bottom=24px
+ expect(wrapper.html()).toContain('margin: 8px 0px 24px')
+ })
+ })
+
+ describe('bg class detection', () => {
+ it('omits default background-color when a bg- class is present', () => {
+ const wrapper = mount(Divider, { attrs: { class: 'bg-red-500' } })
+ expect(wrapper.html()).not.toContain('background-color: #cbd5e1')
+ })
+
+ it('applies default background-color when no bg- class is present', () => {
+ const wrapper = mount(Divider, { attrs: { class: 'text-red-500' } })
+ expect(wrapper.html()).toContain('background-color: #cbd5e1')
+ })
+ })
+})
diff --git a/src/tests/components/NoWidows.test.ts b/src/tests/components/NoWidows.test.ts
new file mode 100644
index 00000000..d9254909
--- /dev/null
+++ b/src/tests/components/NoWidows.test.ts
@@ -0,0 +1,172 @@
+import { describe, it, expect } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { h } from 'vue'
+import NoWidows from '../../components/NoWidows.vue'
+
+describe('NoWidows', () => {
+ describe('basic widow prevention', () => {
+ it('replaces space before last word with non-breaking space for text with >= 4 words', () => {
+ const wrapper = mount(NoWidows, {
+ slots: {
+ default: 'This is a test sentence'
+ }
+ })
+ expect(wrapper.html()).toContain('This is a test sentence')
+ })
+
+ it('does not modify text with fewer than minWords words', () => {
+ const wrapper = mount(NoWidows, {
+ slots: {
+ default: 'Hello world'
+ }
+ })
+ expect(wrapper.html()).toContain('Hello world')
+ })
+
+ it('handles text with exactly minWords words', () => {
+ const wrapper = mount(NoWidows, {
+ slots: {
+ default: 'One two three four'
+ }
+ })
+ expect(wrapper.html()).toContain('One two three four')
+ })
+ })
+
+ describe('minWords prop', () => {
+ it('respects custom minWords value (string)', () => {
+ const wrapper = mount(NoWidows, {
+ props: {
+ minWords: '3'
+ },
+ slots: {
+ default: 'Hello world there'
+ }
+ })
+ expect(wrapper.html()).toContain('Hello world there')
+ })
+
+ it('respects custom minWords value (number)', () => {
+ const wrapper = mount(NoWidows, {
+ props: {
+ minWords: 3
+ },
+ slots: {
+ default: 'Hello world there'
+ }
+ })
+ expect(wrapper.html()).toContain('Hello world there')
+ })
+
+ it('uses default minWords of 4 when not specified', () => {
+ const wrapper = mount(NoWidows, {
+ slots: {
+ default: 'One two three'
+ }
+ })
+ expect(wrapper.html()).toContain('One two three')
+ })
+ })
+
+ describe('template expression handling', () => {
+ it('skips known ignored templating delimiters', () => {
+ const wrapper = mount(NoWidows, {
+ slots: {
+ default: '{% Hello world there test %}'
+ }
+ })
+ expect(wrapper.html()).toContain('{% Hello world there test %}')
+ })
+
+ it('processes text inside HTML elements', () => {
+ const wrapper = mount(NoWidows, {
+ slots: {
+ default: ' This is a test paragraph '
+ }
+ })
+ expect(wrapper.html()).toContain(' This is a test paragraph ')
+ })
+ })
+
+ describe('nested elements', () => {
+ it('processes text in nested elements', () => {
+ const wrapper = mount(NoWidows, {
+ slots: {
+ default: ' This is a nested sentence '
+ }
+ })
+ expect(wrapper.html()).toContain(' This is a nested sentence ')
+ })
+
+ it('handles multiple elements', () => {
+ const wrapper = mount(NoWidows, {
+ slots: {
+ default: ' First paragraph text here Second paragraph text here '
+ }
+ })
+ const html = wrapper.html()
+ expect(html).toContain(' First paragraph text here ')
+ expect(html).toContain(' Second paragraph text here ')
+ })
+ })
+
+ describe('edge cases', () => {
+ it('handles empty slots', () => {
+ const wrapper = mount(NoWidows)
+ expect(wrapper.html()).toBe('')
+ })
+
+ it('handles text with trailing whitespace', () => {
+ const wrapper = mount(NoWidows, {
+ slots: {
+ default: 'This is a test sentence '
+ }
+ })
+ expect(wrapper.html()).toContain('This is a test sentence')
+ })
+
+ it('handles multiple spaces between words', () => {
+ const wrapper = mount(NoWidows, {
+ slots: {
+ default: 'This is a test sentence'
+ }
+ })
+ expect(wrapper.html()).toContain('This is a test sentence')
+ })
+
+ it('handles single word', () => {
+ const wrapper = mount(NoWidows, {
+ slots: {
+ default: 'Hello'
+ }
+ })
+ expect(wrapper.html()).toContain('Hello')
+ })
+
+ it('handles text with newlines', () => {
+ const wrapper = mount(NoWidows, {
+ slots: {
+ default: `This is a test\nsentence`
+ }
+ })
+ expect(wrapper.html()).toContain('This is a test sentence')
+ })
+ })
+
+ describe('component preservation', () => {
+ it('does not modify component vnodes', () => {
+ const TestComponent = {
+ name: 'TestComponent',
+ template: ' Component text here'
+ }
+
+ const wrapper = mount(NoWidows, {
+ slots: {
+ default: () => [h(TestComponent)]
+ }
+ })
+
+ expect(wrapper.findComponent(TestComponent).exists()).toBe(true)
+ })
+ })
+})
diff --git a/src/tests/components/NotOutlook.test.ts b/src/tests/components/NotOutlook.test.ts
new file mode 100644
index 00000000..238ca0c5
--- /dev/null
+++ b/src/tests/components/NotOutlook.test.ts
@@ -0,0 +1,59 @@
+import { describe, it, expect } from 'vitest'
+import { createSSRApp, h } from 'vue'
+import { renderToString } from '@vue/server-renderer'
+import NotOutlook from '../../components/NotOutlook.vue'
+
+function render(slotFn?: () => any) {
+ const app = createSSRApp({
+ render: () => h(NotOutlook, null, {
+ default: slotFn ?? (() => h('p', 'Test')),
+ }),
+ })
+ return renderToString(app)
+}
+
+describe('NotOutlook', () => {
+ describe('conditional comments', () => {
+ it('renders the opening non-Outlook conditional comment', async () => {
+ const html = await render()
+ expect(html).toContain('')
+ })
+
+ it('renders the closing comment', async () => {
+ const html = await render()
+ expect(html).toContain('')
+ })
+
+ it('renders slot content between the comments', async () => {
+ const html = await render()
+ expect(html).toContain(' Test ')
+ })
+
+ it('outputs comments and content in the correct order', async () => {
+ const html = await render()
+
+ const startIdx = html.indexOf('')
+ const contentIdx = html.indexOf(' Test ')
+ const endIdx = html.indexOf('')
+
+ expect(startIdx).toBeGreaterThanOrEqual(0)
+ expect(startIdx).toBeLessThan(contentIdx)
+ expect(contentIdx).toBeLessThan(endIdx)
+ })
+ })
+
+ describe('slot content', () => {
+ it('renders nested HTML in the slot', async () => {
+ const html = await render(() => h('table', [h('tr', [h('td', 'Hello')])]))
+ expect(html).toContain(' ')
+ expect(html).toContain('| Hello | ')
+ })
+
+ it('renders with an empty slot', async () => {
+ const app = createSSRApp({ render: () => h(NotOutlook) })
+ const html = await renderToString(app)
+ expect(html).toContain('')
+ expect(html).toContain('')
+ })
+ })
+})
diff --git a/src/tests/components/Outlook.test.ts b/src/tests/components/Outlook.test.ts
new file mode 100644
index 00000000..a636aab0
--- /dev/null
+++ b/src/tests/components/Outlook.test.ts
@@ -0,0 +1,135 @@
+import { describe, it, expect } from 'vitest'
+import { createSSRApp, h } from 'vue'
+import { renderToString } from '@vue/server-renderer'
+import Outlook from '../../components/Outlook.vue'
+
+function renderRaw(props: Record = {}, slotFn?: () => any) {
+ const app = createSSRApp({
+ render: () => h(Outlook, props, {
+ default: slotFn ?? (() => h('p', 'Test')),
+ }),
+ })
+
+ return renderToString(app)
+}
+
+describe('Outlook', () => {
+ describe('default (all Outlook versions)', () => {
+ it('wraps slot content in mso conditional comments', async () => {
+ const html = await renderRaw()
+
+ expect(html).toContain('')
+ })
+
+ it('outputs comments in the correct order', async () => {
+ const html = await renderRaw()
+
+ const startIdx = html.indexOf('')
+
+ expect(startIdx).toBeGreaterThanOrEqual(0)
+ expect(startIdx).toBeLessThan(contentIdx)
+ expect(contentIdx).toBeLessThan(endIdx)
+ })
+ })
+
+ describe('only prop', () => {
+ it('targets a single Outlook version', async () => {
+ const html = await renderRaw({ only: '2007' })
+ expect(html).toContain('')
+ })
+ })
+
+ describe('empty slot', () => {
+ it('renders conditional comments with no content between them', async () => {
+ const app = createSSRApp({
+ render: () => h(Outlook),
+ })
+ const html = await renderToString(app)
+
+ expect(html).toContain('')
+ })
+ })
+})
diff --git a/src/tests/components/Preview.test.ts b/src/tests/components/Preview.test.ts
new file mode 100644
index 00000000..0228bd87
--- /dev/null
+++ b/src/tests/components/Preview.test.ts
@@ -0,0 +1,84 @@
+import { describe, it, expect } from 'vitest'
+import { createSSRApp, h } from 'vue'
+import { renderToString } from '@vue/server-renderer'
+import Preview from '../../components/Preview.vue'
+
+function render(props: Record = {}, slotContent?: string) {
+ const app = createSSRApp({
+ render: () => h(Preview, props, slotContent
+ ? { default: () => slotContent }
+ : undefined
+ ),
+ })
+
+ const ctx: Record = {}
+ return renderToString(app, ctx).then(() => {
+ // Teleported content is in ctx.teleports keyed by target selector
+ const teleported = Object.values(ctx.teleports ?? {}).join('')
+ return teleported
+ })
+}
+
+describe('Preview', () => {
+ describe('structure', () => {
+ it('renders a hidden div', async () => {
+ const html = await render()
+
+ expect(html).toContain('display:none')
+ })
+ })
+
+ describe('slot content', () => {
+ it('renders slot content as preview text', async () => {
+ const html = await render({}, 'Hello preview!')
+
+ expect(html).toContain('Hello preview!')
+ })
+ })
+
+ describe('filler entities', () => {
+ it('renders 150 filler pairs by default', async () => {
+ const html = await render()
+
+ const fillerCount = (html.match(/\u2007\u034F/g) || []).length
+ expect(fillerCount).toBe(150)
+ })
+
+ it('accepts custom filler count', async () => {
+ const html = await render({ fillerCount: 5 })
+
+ const fillerCount = (html.match(/\u2007\u034F/g) || []).length
+ expect(fillerCount).toBe(5)
+ })
+
+ it('renders zero fillers when set to 0', async () => {
+ const html = await render({ fillerCount: 0 })
+
+ expect(html).not.toContain('\u2007\u034F')
+ })
+ })
+
+ describe('shy entities', () => {
+ it('renders 150 shy entities by default', async () => {
+ const html = await render()
+
+ const shyCount = (html.match(/\u00AD/g) || []).length
+ expect(shyCount).toBe(150)
+ })
+
+ it('accepts custom shy count', async () => {
+ const html = await render({ shyCount: 3 })
+
+ const shyCount = (html.match(/\u00AD/g) || []).length
+ expect(shyCount).toBe(3)
+ })
+ })
+
+ describe('nbsp', () => {
+ it('ends with a non-breaking space before closing div', async () => {
+ const html = await render()
+
+ expect(html).toContain('\u00A0')
+ })
+ })
+})
diff --git a/src/tests/components/Spacer.test.ts b/src/tests/components/Spacer.test.ts
new file mode 100644
index 00000000..1da6feb3
--- /dev/null
+++ b/src/tests/components/Spacer.test.ts
@@ -0,0 +1,63 @@
+import { describe, it, expect } from 'vitest'
+import { mount } from '@vue/test-utils'
+import Spacer from '../../components/Spacer.vue'
+
+describe('Spacer', () => {
+ describe('defaults', () => {
+ it('renders a div with role="separator"', () => {
+ const wrapper = mount(Spacer)
+ expect(wrapper.html()).toContain('role="separator"')
+ })
+
+ it('renders without style attribute when no height is set', () => {
+ const wrapper = mount(Spacer)
+ expect(wrapper.html()).not.toContain('line-height:')
+ })
+
+ it('contains zero-width joiner', () => {
+ const wrapper = mount(Spacer)
+ expect(wrapper.text()).toContain('\u200D')
+ })
+ })
+
+ describe('size prop', () => {
+ it('sets line-height when provided as string', () => {
+ const wrapper = mount(Spacer, { props: { size: '32px' } })
+ expect(wrapper.html()).toContain('line-height: 32px')
+ })
+
+ it('accepts a number and adds px suffix', () => {
+ const wrapper = mount(Spacer, { props: { size: 24 } })
+ expect(wrapper.html()).toContain('line-height: 24px')
+ })
+
+ it('preserves non-numeric string values', () => {
+ const wrapper = mount(Spacer, { props: { size: '2rem' } })
+ expect(wrapper.html()).toContain('line-height: 2rem')
+ })
+ })
+
+ describe('msoHeight prop', () => {
+ it('sets mso-line-height-alt', () => {
+ const wrapper = mount(Spacer, { props: { size: '32px', msoHeight: '40px' } })
+ expect(wrapper.html()).toContain('mso-line-height-alt: 40px')
+ })
+
+ it('accepts a number and adds px suffix', () => {
+ const wrapper = mount(Spacer, { props: { size: '32px', msoHeight: 48 } })
+ expect(wrapper.html()).toContain('mso-line-height-alt: 48px')
+ })
+ })
+
+ describe('conditional rendering', () => {
+ it('renders with style when size is provided', () => {
+ const wrapper = mount(Spacer, { props: { size: '16px' } })
+ expect(wrapper.html()).toContain('style=')
+ })
+
+ it('renders without style when no size is provided', () => {
+ const wrapper = mount(Spacer)
+ expect(wrapper.html()).not.toContain('style=')
+ })
+ })
+})
diff --git a/src/tests/components/Vml.test.ts b/src/tests/components/Vml.test.ts
new file mode 100644
index 00000000..3e96806d
--- /dev/null
+++ b/src/tests/components/Vml.test.ts
@@ -0,0 +1,226 @@
+import { describe, it, expect } from 'vitest'
+import { createSSRApp, h } from 'vue'
+import { renderToString } from '@vue/server-renderer'
+import Vml from '../../components/Vml.vue'
+
+function render(props: Record = {}, slotFn?: () => any) {
+ const app = createSSRApp({
+ render: () => h(Vml, props, {
+ default: slotFn ?? (() => h('p', 'Test')),
+ }),
+ })
+
+ return renderToString(app)
+}
+
+describe('Vml', () => {
+ describe('defaults', () => {
+ it('wraps slot content in VML conditional comments', async () => {
+ const html = await render()
+
+ expect(html).toContain('')
+ })
+
+ it('renders v:rect with default width of 600px', async () => {
+ const html = await render()
+
+ expect(html).toContain(' {
+ const html = await render()
+
+ expect(html).toContain('fill="t"')
+ expect(html).toContain('stroke="f"')
+ })
+
+ it('uses default fillcolor of none', async () => {
+ const html = await render()
+
+ expect(html).toContain('fillcolor="none"')
+ })
+
+ it('uses default src placeholder', async () => {
+ const html = await render()
+
+ expect(html).toContain('src="https://via.placeholder.com/600x400"')
+ })
+
+ it('uses default type of frame', async () => {
+ const html = await render()
+
+ expect(html).toContain('type="frame"')
+ })
+
+ it('uses default inset of 0,0,0,0', async () => {
+ const html = await render()
+
+ expect(html).toContain('inset="0,0,0,0"')
+ })
+
+ it('renders slot content between VML wrappers', async () => {
+ const html = await render()
+
+ expect(html).toContain('Test ')
+ })
+
+ it('includes v:fill and v:textbox elements', async () => {
+ const html = await render()
+
+ expect(html).toContain(' {
+ const html = await render()
+
+ expect(html).toContain('mso-fit-shape-to-text: true')
+ })
+
+ it('includes vml namespace on v:rect', async () => {
+ const html = await render()
+
+ expect(html).toContain('xmlns:v="urn:schemas-microsoft-com:vml"')
+ })
+ })
+
+ describe('width prop', () => {
+ it('accepts a string value', async () => {
+ const html = await render({ width: '400px' })
+
+ expect(html).toContain('style="width: 400px;"')
+ })
+
+ it('accepts a number and adds px suffix', async () => {
+ const html = await render({ width: 500 })
+
+ expect(html).toContain('style="width: 500px;"')
+ })
+ })
+
+ describe('height prop', () => {
+ it('does not include height by default', async () => {
+ const html = await render()
+
+ expect(html).not.toContain('height:')
+ })
+
+ it('includes height when provided', async () => {
+ const html = await render({ height: '300px' })
+
+ expect(html).toContain('height: 300px;')
+ })
+
+ it('accepts a number and adds px suffix', async () => {
+ const html = await render({ height: 250 })
+
+ expect(html).toContain('height: 250px;')
+ })
+ })
+
+ describe('fill props', () => {
+ it('type sets v:fill type', async () => {
+ const html = await render({ type: 'tile' })
+
+ expect(html).toContain('type="tile"')
+ })
+
+ it('src sets v:fill src', async () => {
+ const html = await render({ src: 'https://example.com/bg.jpg' })
+
+ expect(html).toContain('src="https://example.com/bg.jpg"')
+ })
+
+ it('fillcolor sets fillcolor on v:rect', async () => {
+ const html = await render({ fillcolor: '#ff0000' })
+
+ expect(html).toContain('fillcolor="#ff0000"')
+ })
+
+ it('color sets color on v:fill', async () => {
+ const html = await render({ color: '#0000ff' })
+
+ expect(html).toContain('color="#0000ff"')
+ })
+ })
+
+ describe('stroke props', () => {
+ it('strokecolor enables stroke and sets color', async () => {
+ const html = await render({ strokecolor: '#333333' })
+
+ expect(html).toContain('stroke="t"')
+ expect(html).toContain('strokecolor="#333333"')
+ })
+ })
+
+ describe('optional v:fill attributes', () => {
+ it('sets sizes when provided', async () => {
+ const html = await render({ sizes: '100%' })
+
+ expect(html).toContain('sizes="100%"')
+ })
+
+ it('sets aspect when provided', async () => {
+ const html = await render({ aspect: 'atleast' })
+
+ expect(html).toContain('aspect="atleast"')
+ })
+
+ it('sets origin when provided', async () => {
+ const html = await render({ origin: '0,0' })
+
+ expect(html).toContain('origin="0,0"')
+ })
+
+ it('sets position when provided', async () => {
+ const html = await render({ position: '0.5,0.5' })
+
+ expect(html).toContain('position="0.5,0.5"')
+ })
+
+ it('omits optional attributes when not provided', async () => {
+ const html = await render()
+
+ expect(html).not.toContain('sizes=')
+ expect(html).not.toContain('aspect=')
+ expect(html).not.toContain('origin=')
+ expect(html).not.toContain('position=')
+ expect(html).not.toMatch(/\scolor="/)
+ })
+ })
+
+ describe('inset prop', () => {
+ it('sets custom inset', async () => {
+ const html = await render({ inset: '10,20,10,20' })
+
+ expect(html).toContain('inset="10,20,10,20"')
+ })
+ })
+
+ describe('closing comment', () => {
+ it('includes closing VML tags in correct order', async () => {
+ const html = await render()
+
+ expect(html).toContain('')
+ })
+ })
+
+ describe('structure', () => {
+ it('outputs VML elements in correct order', async () => {
+ const html = await render()
+
+ const rectIdx = html.indexOf('Test')
+ const closingIdx = html.indexOf('')
+
+ expect(rectIdx).toBeLessThan(fillIdx)
+ expect(fillIdx).toBeLessThan(textboxIdx)
+ expect(textboxIdx).toBeLessThan(contentIdx)
+ expect(contentIdx).toBeLessThan(closingIdx)
+ })
+ })
+})
diff --git a/src/tests/components/WithUrl.test.ts b/src/tests/components/WithUrl.test.ts
new file mode 100644
index 00000000..7e350499
--- /dev/null
+++ b/src/tests/components/WithUrl.test.ts
@@ -0,0 +1,500 @@
+import { describe, it, expect } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { defineComponent, h } from 'vue'
+import WithUrl from '../../components/WithUrl.vue'
+
+describe('WithUrl', () => {
+ // ─── base prop (base URL) ──────────────────────────────────────────────────
+
+ describe('base — img src', () => {
+ it('prepends base URL to img src', () => {
+ const wrapper = mount(WithUrl, {
+ props: { base: 'https://cdn.example.com/' },
+ slots: {
+ default: () => h('img', { src: 'image.jpg' }),
+ },
+ })
+
+ expect(wrapper.find('img').attributes('src')).toBe('https://cdn.example.com/image.jpg')
+ })
+
+ it('does not modify absolute URLs', () => {
+ const wrapper = mount(WithUrl, {
+ props: { base: 'https://cdn.example.com/' },
+ slots: {
+ default: () => h('img', { src: 'https://other.com/image.jpg' }),
+ },
+ })
+
+ expect(wrapper.find('img').attributes('src')).toBe('https://other.com/image.jpg')
+ })
+
+ it('does not modify data URIs', () => {
+ const wrapper = mount(WithUrl, {
+ props: { base: 'https://cdn.example.com/' },
+ slots: {
+ default: () => h('img', { src: 'data:image/png;base64,abc123' }),
+ },
+ })
+
+ expect(wrapper.find('img').attributes('src')).toBe('data:image/png;base64,abc123')
+ })
+
+ it('does not modify protocol-relative URLs', () => {
+ const wrapper = mount(WithUrl, {
+ props: { base: 'https://cdn.example.com/' },
+ slots: {
+ default: () => h('img', { src: '//other.com/image.jpg' }),
+ },
+ })
+
+ expect(wrapper.find('img').attributes('src')).toBe('//other.com/image.jpg')
+ })
+ })
+
+ describe('base — anchor href', () => {
+ it('prepends base URL to anchor href', () => {
+ const wrapper = mount(WithUrl, {
+ props: { base: 'https://example.com/' },
+ slots: {
+ default: () => h('a', { href: 'page.html' }, 'Link'),
+ },
+ })
+
+ expect(wrapper.find('a').attributes('href')).toBe('https://example.com/page.html')
+ })
+
+ it('does not modify mailto: URLs', () => {
+ const wrapper = mount(WithUrl, {
+ props: { base: 'https://example.com/' },
+ slots: {
+ default: () => h('a', { href: 'mailto:test@example.com' }, 'Email'),
+ },
+ })
+
+ expect(wrapper.find('a').attributes('href')).toBe('mailto:test@example.com')
+ })
+
+ it('does not modify fragment URLs', () => {
+ const wrapper = mount(WithUrl, {
+ props: { base: 'https://example.com/' },
+ slots: {
+ default: () => h('a', { href: '#section' }, 'Jump'),
+ },
+ })
+
+ expect(wrapper.find('a').attributes('href')).toBe('#section')
+ })
+ })
+
+ describe('base — srcset', () => {
+ it('prepends base URL to srcset values', () => {
+ const wrapper = mount(WithUrl, {
+ props: { base: 'https://cdn.example.com/' },
+ slots: {
+ default: () => h('img', { srcset: 'small.jpg 320w, large.jpg 1024w' }),
+ },
+ })
+
+ expect(wrapper.find('img').attributes('srcset')).toBe(
+ 'https://cdn.example.com/small.jpg 320w, https://cdn.example.com/large.jpg 1024w'
+ )
+ })
+ })
+
+ describe('base — other elements', () => {
+ it('prepends base URL to video src and poster', () => {
+ const wrapper = mount(WithUrl, {
+ props: { base: 'https://cdn.example.com/' },
+ slots: {
+ default: () => h('video', { src: 'video.mp4', poster: 'poster.jpg' }),
+ },
+ })
+
+ expect(wrapper.find('video').attributes('src')).toBe('https://cdn.example.com/video.mp4')
+ expect(wrapper.find('video').attributes('poster')).toBe('https://cdn.example.com/poster.jpg')
+ })
+
+ it('prepends base URL to link href', () => {
+ const wrapper = mount(WithUrl, {
+ props: { base: 'https://cdn.example.com/' },
+ slots: {
+ default: () => h('link', { href: 'styles.css' }),
+ },
+ })
+
+ expect(wrapper.find('link').attributes('href')).toBe('https://cdn.example.com/styles.css')
+ })
+ })
+
+ describe('base — nested elements', () => {
+ it('processes deeply nested elements', () => {
+ const wrapper = mount(WithUrl, {
+ props: { base: 'https://cdn.example.com/' },
+ slots: {
+ default: () => h('div', [
+ h('table', [
+ h('tr', [
+ h('td', [
+ h('img', { src: 'deep.jpg' }),
+ ]),
+ ]),
+ ]),
+ ]),
+ },
+ })
+
+ expect(wrapper.find('img').attributes('src')).toBe('https://cdn.example.com/deep.jpg')
+ })
+
+ it('processes multiple elements at different depths', () => {
+ const wrapper = mount(WithUrl, {
+ props: { base: 'https://cdn.example.com/' },
+ slots: {
+ default: () => h('div', [
+ h('img', { src: 'top.jpg' }),
+ h('div', [
+ h('a', { href: 'page.html' }, 'Link'),
+ ]),
+ ]),
+ },
+ })
+
+ expect(wrapper.find('img').attributes('src')).toBe('https://cdn.example.com/top.jpg')
+ expect(wrapper.find('a').attributes('href')).toBe('https://cdn.example.com/page.html')
+ })
+ })
+
+ describe('base — scoping', () => {
+ it('does not affect elements outside the component', () => {
+ const wrapper = mount({
+ template: `
+
+ 
+
+
+
+
+ `,
+ components: { WithUrl },
+ })
+
+ const images = wrapper.findAll('img')
+ expect(images[0].attributes('src')).toBe('outside.jpg')
+ expect(images[1].attributes('src')).toBe('https://cdn.example.com/inside.jpg')
+ })
+ })
+
+ describe('base — elements not in tag map', () => {
+ it('does not modify attributes on unknown elements', () => {
+ const wrapper = mount(WithUrl, {
+ props: { base: 'https://cdn.example.com/' },
+ slots: {
+ default: () => h('div', { 'data-src': 'file.txt' }),
+ },
+ })
+
+ expect(wrapper.find('div').attributes('data-src')).toBe('file.txt')
+ })
+ })
+
+ describe('base — child components', () => {
+ it('rewrites URL props on child components', () => {
+ const Button = defineComponent({
+ props: { href: String },
+ setup(props, { slots }) {
+ return () => h('a', { href: props.href }, slots.default?.())
+ },
+ })
+
+ const wrapper = mount(WithUrl, {
+ props: { base: 'https://example.com/' },
+ slots: {
+ default: () => h(Button, { href: 'test' }, () => 'click me'),
+ },
+ })
+
+ expect(wrapper.find('a').attributes('href')).toBe('https://example.com/test')
+ })
+
+ it('rewrites src prop on child components', () => {
+ const Image = defineComponent({
+ props: { src: String, alt: String },
+ setup(props) {
+ return () => h('img', { src: props.src, alt: props.alt })
+ },
+ })
+
+ const wrapper = mount(WithUrl, {
+ props: { base: 'https://cdn.example.com/' },
+ slots: {
+ default: () => h(Image, { src: 'photo.jpg', alt: 'A photo' }),
+ },
+ })
+
+ expect(wrapper.find('img').attributes('src')).toBe('https://cdn.example.com/photo.jpg')
+ expect(wrapper.find('img').attributes('alt')).toBe('A photo')
+ })
+
+ it('does not rewrite absolute URLs on child components', () => {
+ const Button = defineComponent({
+ props: { href: String },
+ setup(props, { slots }) {
+ return () => h('a', { href: props.href }, slots.default?.())
+ },
+ })
+
+ const wrapper = mount(WithUrl, {
+ props: { base: 'https://example.com/' },
+ slots: {
+ default: () => h(Button, { href: 'https://other.com/page' }, () => 'click'),
+ },
+ })
+
+ expect(wrapper.find('a').attributes('href')).toBe('https://other.com/page')
+ })
+ })
+
+ // ─── base — slash normalisation ──────────────────────────────────────────
+
+ describe('base — slash normalisation', () => {
+ it('works when base has no trailing slash and path has no leading slash', () => {
+ const wrapper = mount(WithUrl, {
+ props: { base: 'https://cdn.example.com' },
+ slots: { default: () => h('img', { src: 'image.jpg' }) },
+ })
+ expect(wrapper.find('img').attributes('src')).toBe('https://cdn.example.com/image.jpg')
+ })
+
+ it('works when base has trailing slash and path has no leading slash', () => {
+ const wrapper = mount(WithUrl, {
+ props: { base: 'https://cdn.example.com/' },
+ slots: { default: () => h('img', { src: 'image.jpg' }) },
+ })
+ expect(wrapper.find('img').attributes('src')).toBe('https://cdn.example.com/image.jpg')
+ })
+
+ it('works when base has no trailing slash and path has leading slash', () => {
+ const wrapper = mount(WithUrl, {
+ props: { base: 'https://cdn.example.com' },
+ slots: { default: () => h('a', { href: '/about' }, 'About') },
+ })
+ expect(wrapper.find('a').attributes('href')).toBe('https://cdn.example.com/about')
+ })
+
+ it('works when base has trailing slash and path has leading slash', () => {
+ const wrapper = mount(WithUrl, {
+ props: { base: 'https://cdn.example.com/' },
+ slots: { default: () => h('a', { href: '/about' }, 'About') },
+ })
+ expect(wrapper.find('a').attributes('href')).toBe('https://cdn.example.com/about')
+ })
+
+ it('works with a base that has a path prefix', () => {
+ const wrapper = mount(WithUrl, {
+ props: { base: 'https://cdn.example.com/assets' },
+ slots: { default: () => h('img', { src: 'image.jpg' }) },
+ })
+ expect(wrapper.find('img').attributes('src')).toBe('https://cdn.example.com/assets/image.jpg')
+ })
+
+ it('works with a base that has a path prefix and trailing slash', () => {
+ const wrapper = mount(WithUrl, {
+ props: { base: 'https://cdn.example.com/assets/' },
+ slots: { default: () => h('img', { src: 'image.jpg' }) },
+ })
+ expect(wrapper.find('img').attributes('src')).toBe('https://cdn.example.com/assets/image.jpg')
+ })
+
+ it('normalises slashes in srcset entries', () => {
+ const wrapper = mount(WithUrl, {
+ props: { base: 'https://cdn.example.com' },
+ slots: {
+ default: () => h('img', { srcset: 'small.jpg 320w, large.jpg 1024w' }),
+ },
+ })
+ expect(wrapper.find('img').attributes('srcset')).toBe(
+ 'https://cdn.example.com/small.jpg 320w, https://cdn.example.com/large.jpg 1024w'
+ )
+ })
+ })
+
+ // ─── parameters prop (query params) ──────────────────────────────────────
+
+ describe('parameters — anchor href', () => {
+ it('appends query params to anchor href', () => {
+ const wrapper = mount(WithUrl, {
+ props: { parameters: 'utm_source=newsletter&utm_medium=email' },
+ slots: {
+ default: () => h('a', { href: 'https://example.com/page' }, 'Link'),
+ },
+ })
+
+ const href = wrapper.find('a').attributes('href')!
+ expect(href).toContain('https://example.com/page?')
+ expect(href).toContain('utm_source=newsletter')
+ expect(href).toContain('utm_medium=email')
+ })
+
+ it('appends query params to relative href', () => {
+ const wrapper = mount(WithUrl, {
+ props: { parameters: 'foo=bar' },
+ slots: {
+ default: () => h('a', { href: '/about' }, 'About'),
+ },
+ })
+
+ expect(wrapper.find('a').attributes('href')).toBe('/about?foo=bar')
+ })
+
+ it('merges query params when href already has params', () => {
+ const wrapper = mount(WithUrl, {
+ props: { parameters: 'utm_source=newsletter' },
+ slots: {
+ default: () => h('a', { href: 'https://example.com/page?existing=1' }, 'Link'),
+ },
+ })
+
+ expect(wrapper.find('a').attributes('href')).toBe(
+ 'https://example.com/page?existing=1&utm_source=newsletter'
+ )
+ })
+
+ it('does not modify fragment-only hrefs', () => {
+ const wrapper = mount(WithUrl, {
+ props: { parameters: 'utm_source=newsletter' },
+ slots: {
+ default: () => h('a', { href: '#section' }, 'Jump'),
+ },
+ })
+
+ // Fragment URLs are treated as absolute (isAbsoluteUrl returns true for #)
+ // so no base rewriting; but parameters can still be appended by query-string
+ const href = wrapper.find('a').attributes('href')
+ // query-string will produce '#section?utm_source=newsletter' or '#section' depending on behaviour
+ // The important thing is it doesn't crash; we just assert it contains #section
+ expect(href).toContain('#section')
+ })
+ })
+
+ describe('parameters — img src', () => {
+ it('appends query params to img src', () => {
+ const wrapper = mount(WithUrl, {
+ props: { parameters: 'v=2' },
+ slots: {
+ default: () => h('img', { src: 'https://cdn.example.com/image.jpg' }),
+ },
+ })
+
+ expect(wrapper.find('img').attributes('src')).toBe('https://cdn.example.com/image.jpg?v=2')
+ })
+ })
+
+ describe('parameters — child components', () => {
+ it('appends query params to URL props on child components', () => {
+ const Button = defineComponent({
+ props: { href: String },
+ setup(props, { slots }) {
+ return () => h('a', { href: props.href }, slots.default?.())
+ },
+ })
+
+ const wrapper = mount(WithUrl, {
+ props: { parameters: 'utm_source=foo' },
+ slots: {
+ default: () => h(Button, { href: 'https://example.com/page' }, () => 'click me'),
+ },
+ })
+
+ expect(wrapper.find('a').attributes('href')).toBe('https://example.com/page?utm_source=foo')
+ })
+ })
+
+ // ─── both props together ──────────────────────────────────────────────────
+
+ describe('base + parameters combined', () => {
+ it('prepends base URL then appends query params', () => {
+ const wrapper = mount(WithUrl, {
+ props: {
+ base: 'https://example.com/',
+ parameters: 'utm_source=newsletter',
+ },
+ slots: {
+ default: () => h('a', { href: 'about' }, 'About'),
+ },
+ })
+
+ expect(wrapper.find('a').attributes('href')).toBe(
+ 'https://example.com/about?utm_source=newsletter'
+ )
+ })
+
+ it('applies base then params to img src', () => {
+ const wrapper = mount(WithUrl, {
+ props: {
+ base: 'https://cdn.example.com/',
+ parameters: 'v=2',
+ },
+ slots: {
+ default: () => h('img', { src: 'photo.jpg' }),
+ },
+ })
+
+ expect(wrapper.find('img').attributes('src')).toBe(
+ 'https://cdn.example.com/photo.jpg?v=2'
+ )
+ })
+
+ it('applies base then params to child component URL props', () => {
+ const Button = defineComponent({
+ props: { href: String },
+ setup(props, { slots }) {
+ return () => h('a', { href: props.href }, slots.default?.())
+ },
+ })
+
+ const wrapper = mount(WithUrl, {
+ props: {
+ base: 'https://example.com/',
+ parameters: 'utm_campaign=spring',
+ },
+ slots: {
+ default: () => h(Button, { href: 'shop' }, () => 'Buy now'),
+ },
+ })
+
+ expect(wrapper.find('a').attributes('href')).toBe(
+ 'https://example.com/shop?utm_campaign=spring'
+ )
+ })
+
+ it('does not double-prepend base on absolute URLs but still appends params', () => {
+ const wrapper = mount(WithUrl, {
+ props: {
+ base: 'https://example.com/',
+ parameters: 'ref=email',
+ },
+ slots: {
+ default: () => h('a', { href: 'https://other.com/page' }, 'Link'),
+ },
+ })
+
+ expect(wrapper.find('a').attributes('href')).toBe('https://other.com/page?ref=email')
+ })
+ })
+
+ // ─── no props ─────────────────────────────────────────────────────────────
+
+ describe('no props', () => {
+ it('renders children unchanged when no props are provided', () => {
+ const wrapper = mount(WithUrl, {
+ props: {},
+ slots: {
+ default: () => h('a', { href: 'https://example.com/' }, 'Link'),
+ },
+ })
+
+ expect(wrapper.find('a').attributes('href')).toBe('https://example.com/')
+ })
+ })
+})
diff --git a/src/tests/composables/defineConfig.test.ts b/src/tests/composables/defineConfig.test.ts
new file mode 100644
index 00000000..4e20f2c7
--- /dev/null
+++ b/src/tests/composables/defineConfig.test.ts
@@ -0,0 +1,295 @@
+import { describe, it, expect } from 'vitest'
+import { defineComponent, h, inject } from 'vue'
+import { mount } from '@vue/test-utils'
+import { defineConfig } from '../../composables/defineConfig.ts'
+import { MaizzleConfigKey } from '../../composables/useConfig.ts'
+import { RenderContextKey, type RenderContext } from '../../composables/renderContext.ts'
+import type { MaizzleConfig } from '../../types/config.ts'
+
+function createRenderContext(): RenderContext {
+ return { doctype: undefined, sfcConfig: undefined, sfcEventHandlers: [] }
+}
+
+describe('defineConfig', () => {
+ describe('outside Vue (config file usage)', () => {
+ it('returns the config as-is (same reference)', () => {
+ const input = { content: ['emails/**/*.vue'], output: { path: 'dist' } }
+ const result = defineConfig(input)
+
+ expect(result).toBe(input)
+ })
+
+ it('returns empty object when called with no args', () => {
+ const result = defineConfig()
+
+ expect(result).toEqual({})
+ })
+
+ it('preserves all config properties', () => {
+ const input = {
+ content: ['src/**/*.vue'],
+ output: { path: 'build', extension: 'html' },
+ css: { safe: true, shorthand: true },
+ server: { port: 4000 },
+ }
+
+ const result = defineConfig(input)
+
+ expect(result).toBe(input)
+ expect(result.content).toEqual(['src/**/*.vue'])
+ expect(result.output?.path).toBe('build')
+ expect(result.css?.safe).toBe(true)
+ })
+
+ it('preserves arbitrary user data', () => {
+ const input = { company: 'Acme', theme: { primary: '#ff0000' } }
+ const result = defineConfig(input)
+
+ expect(result.company).toBe('Acme')
+ expect((result as any).theme).toEqual({ primary: '#ff0000' })
+ })
+ })
+
+ describe('inside Vue SFC', () => {
+ it('merges SFC config with injected global config', () => {
+ let merged: MaizzleConfig | undefined
+
+ const Comp = defineComponent({
+ setup() {
+ merged = defineConfig({ css: { sixHex: true } })
+ return () => h('div')
+ },
+ })
+
+ mount(Comp, {
+ global: {
+ provide: {
+ [MaizzleConfigKey as symbol]: {
+ content: ['emails/**/*.vue'],
+ css: { safe: true },
+ } as MaizzleConfig,
+ [RenderContextKey as symbol]: createRenderContext(),
+ },
+ },
+ })
+
+ expect(merged).toBeDefined()
+ expect(merged!.content).toEqual(['emails/**/*.vue'])
+ expect(merged!.css?.sixHex).toBe(true)
+ expect(merged!.css?.safe).toBe(true)
+ })
+
+ it('SFC values take priority over global config', () => {
+ let merged: MaizzleConfig | undefined
+
+ const Comp = defineComponent({
+ setup() {
+ merged = defineConfig({
+ output: { path: 'sfc-output' },
+ })
+ return () => h('div')
+ },
+ })
+
+ mount(Comp, {
+ global: {
+ provide: {
+ [MaizzleConfigKey as symbol]: {
+ output: { path: 'global-output', extension: 'html' },
+ } as MaizzleConfig,
+ [RenderContextKey as symbol]: createRenderContext(),
+ },
+ },
+ })
+
+ expect(merged!.output?.path).toBe('sfc-output')
+ // Global values preserved for keys not overridden
+ expect(merged!.output?.extension).toBe('html')
+ })
+
+ it('replaces arrays instead of merging them', () => {
+ let merged: MaizzleConfig | undefined
+
+ const Comp = defineComponent({
+ setup() {
+ merged = defineConfig({
+ content: ['sfc/**/*.vue'],
+ })
+ return () => h('div')
+ },
+ })
+
+ mount(Comp, {
+ global: {
+ provide: {
+ [MaizzleConfigKey as symbol]: {
+ content: ['global/**/*.vue', 'shared/**/*.vue'],
+ } as MaizzleConfig,
+ [RenderContextKey as symbol]: createRenderContext(),
+ },
+ },
+ })
+
+ // Should replace, not concatenate
+ expect(merged!.content).toEqual(['sfc/**/*.vue'])
+ })
+
+ it('deep merges nested objects', () => {
+ let merged: MaizzleConfig | undefined
+
+ const Comp = defineComponent({
+ setup() {
+ merged = defineConfig({
+ css: { shorthand: true },
+ })
+ return () => h('div')
+ },
+ })
+
+ mount(Comp, {
+ global: {
+ provide: {
+ [MaizzleConfigKey as symbol]: {
+ css: {
+ safe: true,
+ preferUnitless: true,
+ },
+ } as MaizzleConfig,
+ [RenderContextKey as symbol]: createRenderContext(),
+ },
+ },
+ })
+
+ expect(merged!.css?.shorthand).toBe(true)
+ expect(merged!.css?.safe).toBe(true)
+ expect(merged!.css?.preferUnitless).toBe(true)
+ })
+
+ it('stores merged config in render context', () => {
+ const ctx = createRenderContext()
+
+ expect(ctx.sfcConfig).toBeUndefined()
+
+ const Comp = defineComponent({
+ setup() {
+ defineConfig({ output: { path: 'stored' } })
+ return () => h('div')
+ },
+ })
+
+ mount(Comp, {
+ global: {
+ provide: {
+ [MaizzleConfigKey as symbol]: {} as MaizzleConfig,
+ [RenderContextKey as symbol]: ctx,
+ },
+ },
+ })
+
+ expect(ctx.sfcConfig).toBeDefined()
+ expect(ctx.sfcConfig!.output?.path).toBe('stored')
+ })
+
+ it('provides merged config to child components', () => {
+ let childConfig: MaizzleConfig | undefined
+
+ const Child = defineComponent({
+ setup() {
+ childConfig = inject(MaizzleConfigKey)
+ return () => h('span')
+ },
+ })
+
+ const Parent = defineComponent({
+ setup() {
+ defineConfig({
+ css: { sixHex: true },
+ })
+ return () => h(Child)
+ },
+ })
+
+ mount(Parent, {
+ global: {
+ provide: {
+ [MaizzleConfigKey as symbol]: {
+ content: ['emails/**/*.vue'],
+ } as MaizzleConfig,
+ [RenderContextKey as symbol]: createRenderContext(),
+ },
+ },
+ })
+
+ expect(childConfig).toBeDefined()
+ expect(childConfig!.css?.sixHex).toBe(true)
+ expect(childConfig!.content).toEqual(['emails/**/*.vue'])
+ })
+
+ it('works when no global config is injected', () => {
+ let merged: MaizzleConfig | undefined
+
+ const Comp = defineComponent({
+ setup() {
+ merged = defineConfig({ output: { path: 'no-global' } })
+ return () => h('div')
+ },
+ })
+
+ mount(Comp, {
+ global: {
+ provide: {
+ [RenderContextKey as symbol]: createRenderContext(),
+ },
+ },
+ })
+
+ expect(merged).toBeDefined()
+ expect(merged!.output?.path).toBe('no-global')
+ })
+ })
+
+ describe('render context interaction', () => {
+ it('each defineConfig call in SFC context updates render context', () => {
+ const ctx1 = createRenderContext()
+ const ctx2 = createRenderContext()
+
+ const First = defineComponent({
+ setup() {
+ defineConfig({ output: { path: 'first' } })
+ return () => h('div')
+ },
+ })
+
+ const Second = defineComponent({
+ setup() {
+ defineConfig({ output: { path: 'second' } })
+ return () => h('div')
+ },
+ })
+
+ mount(First, {
+ global: {
+ provide: {
+ [MaizzleConfigKey as symbol]: {} as MaizzleConfig,
+ [RenderContextKey as symbol]: ctx1,
+ },
+ },
+ })
+
+ expect(ctx1.sfcConfig!.output?.path).toBe('first')
+
+ mount(Second, {
+ global: {
+ provide: {
+ [MaizzleConfigKey as symbol]: {} as MaizzleConfig,
+ [RenderContextKey as symbol]: ctx2,
+ },
+ },
+ })
+
+ expect(ctx2.sfcConfig!.output?.path).toBe('second')
+ // First context is unaffected
+ expect(ctx1.sfcConfig!.output?.path).toBe('first')
+ })
+ })
+})
diff --git a/src/tests/composables/useConfig.test.ts b/src/tests/composables/useConfig.test.ts
new file mode 100644
index 00000000..46e32df7
--- /dev/null
+++ b/src/tests/composables/useConfig.test.ts
@@ -0,0 +1,130 @@
+import { describe, it, expect } from 'vitest'
+import { defineComponent, h } from 'vue'
+import { mount } from '@vue/test-utils'
+import { useConfig, MaizzleConfigKey } from '../../composables/useConfig.ts'
+import type { MaizzleConfig } from '../../types/config.ts'
+
+describe('useConfig', () => {
+ it('returns the provided config', () => {
+ let result: MaizzleConfig | undefined
+
+ const provided: MaizzleConfig = {
+ content: ['emails/**/*.vue'],
+ output: { path: 'dist' },
+ css: { safe: true },
+ }
+
+ const Comp = defineComponent({
+ setup() {
+ result = useConfig()
+ return () => h('div')
+ },
+ })
+
+ mount(Comp, {
+ global: {
+ provide: {
+ [MaizzleConfigKey as symbol]: provided,
+ },
+ },
+ })
+
+ expect(result).toBe(provided)
+ })
+
+ it('returns the same reference as provided', () => {
+ let result: MaizzleConfig | undefined
+
+ const provided = { content: ['src/**/*.vue'] } as MaizzleConfig
+
+ const Comp = defineComponent({
+ setup() {
+ result = useConfig()
+ return () => h('div')
+ },
+ })
+
+ mount(Comp, {
+ global: {
+ provide: {
+ [MaizzleConfigKey as symbol]: provided,
+ },
+ },
+ })
+
+ expect(result).toBe(provided)
+ expect(result!.content).toEqual(['src/**/*.vue'])
+ })
+
+ it('throws when no config is provided', () => {
+ const Comp = defineComponent({
+ setup() {
+ useConfig()
+ return () => h('div')
+ },
+ })
+
+ expect(() => mount(Comp)).toThrow(
+ 'useConfig() requires the Maizzle plugin to provide config'
+ )
+ })
+
+ it('receives config from a parent component', () => {
+ let childConfig: MaizzleConfig | undefined
+
+ const provided: MaizzleConfig = {
+ output: { path: 'build', extension: 'html' },
+ css: { shorthand: true },
+ }
+
+ const Child = defineComponent({
+ setup() {
+ childConfig = useConfig()
+ return () => h('span')
+ },
+ })
+
+ const Parent = defineComponent({
+ setup() {
+ return () => h(Child)
+ },
+ })
+
+ mount(Parent, {
+ global: {
+ provide: {
+ [MaizzleConfigKey as symbol]: provided,
+ },
+ },
+ })
+
+ expect(childConfig).toBe(provided)
+ })
+
+ it('preserves arbitrary user data on the config', () => {
+ let result: MaizzleConfig | undefined
+
+ const provided = {
+ company: 'Acme',
+ theme: { primary: '#ff0000' },
+ } as MaizzleConfig
+
+ const Comp = defineComponent({
+ setup() {
+ result = useConfig()
+ return () => h('div')
+ },
+ })
+
+ mount(Comp, {
+ global: {
+ provide: {
+ [MaizzleConfigKey as symbol]: provided,
+ },
+ },
+ })
+
+ expect(result!.company).toBe('Acme')
+ expect((result as any).theme).toEqual({ primary: '#ff0000' })
+ })
+})
diff --git a/src/tests/config.test.ts b/src/tests/config.test.ts
new file mode 100644
index 00000000..05c6c580
--- /dev/null
+++ b/src/tests/config.test.ts
@@ -0,0 +1,139 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest'
+import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
+import { join, resolve } from 'node:path'
+import { tmpdir } from 'node:os'
+import { resolveConfig } from '../config/index.ts'
+import { defaults } from '../config/defaults.ts'
+
+describe('resolveConfig', () => {
+ let tempDir: string
+
+ beforeEach(() => {
+ tempDir = mkdtempSync(join(tmpdir(), 'maizzle-test-'))
+ })
+
+ afterEach(() => {
+ rmSync(tempDir, { recursive: true, force: true })
+ })
+
+ it('returns defaults when no config file exists', async () => {
+ const config = await resolveConfig(undefined, tempDir)
+
+ expect(config.content).toEqual(defaults.content!.map(p => resolve(tempDir, p).replace(/\\/g, '/')))
+ expect(config.output?.path).toBe('dist')
+ expect(config.output?.extension).toBe('html')
+ expect(config.server?.port).toBe(3000)
+ expect(config.useTransformers).toBe(true)
+ expect(config.css?.preferUnitless).toBe(true)
+ expect(config.css?.resolveCalc).toBe(true)
+ expect(config.css?.resolveProps).toBe(true)
+ })
+
+ it('loads maizzle.config.js from cwd', async () => {
+ writeFileSync(
+ join(tempDir, 'maizzle.config.js'),
+ 'export default { content: ["src/**/*.vue"] }'
+ )
+
+ const config = await resolveConfig(undefined, tempDir)
+
+ expect(config.content).toEqual([resolve(tempDir, 'src/**/*.vue').replace(/\\/g, '/')])
+ // defaults are still applied for missing keys
+ expect(config.output?.path).toBe('dist')
+ })
+
+ it('loads maizzle.config.ts from cwd', async () => {
+ writeFileSync(
+ join(tempDir, 'maizzle.config.ts'),
+ 'export default { output: { path: "dist", extension: "htm" } }'
+ )
+
+ const config = await resolveConfig(undefined, tempDir)
+
+ expect(config.output?.path).toBe('dist')
+ expect(config.output?.extension).toBe('htm')
+ // defaults for non-overridden keys
+ expect(config.content).toEqual(defaults.content!.map(p => resolve(tempDir, p).replace(/\\/g, '/')))
+ })
+
+ it('prefers maizzle.config.ts over maizzle.config.js', async () => {
+ writeFileSync(
+ join(tempDir, 'maizzle.config.ts'),
+ 'export default { content: ["from-ts"] }'
+ )
+ writeFileSync(
+ join(tempDir, 'maizzle.config.js'),
+ 'export default { content: ["from-js"] }'
+ )
+
+ const config = await resolveConfig(undefined, tempDir)
+
+ expect(config.content).toEqual([resolve(tempDir, 'from-ts').replace(/\\/g, '/')])
+ })
+
+ it('loads from explicit config path', async () => {
+ writeFileSync(
+ join(tempDir, 'custom.config.js'),
+ 'export default { content: ["custom/**/*.vue"] }'
+ )
+
+ const config = await resolveConfig('custom.config.js', tempDir)
+
+ expect(config.content).toEqual([resolve(tempDir, 'custom/**/*.vue').replace(/\\/g, '/')])
+ })
+
+ it('throws when explicit config path does not exist', async () => {
+ await expect(
+ resolveConfig('nonexistent.config.js', tempDir)
+ ).rejects.toThrow('Config file not found')
+ })
+
+ it('merges user config with defaults using defu', async () => {
+ writeFileSync(
+ join(tempDir, 'maizzle.config.js'),
+ 'export default { css: { sixHex: true }, server: { port: 4000 } }'
+ )
+
+ const config = await resolveConfig(undefined, tempDir)
+
+ // User values
+ expect(config.css?.sixHex).toBe(true)
+ expect(config.server?.port).toBe(4000)
+ // Defaults preserved for unset nested keys
+ expect(config.css?.preferUnitless).toBe(true)
+ expect(config.server?.watch).toEqual([])
+ })
+
+ it('resolves components.source string relative to cwd', async () => {
+ const config = await resolveConfig({
+ root: 'project',
+ components: { source: 'src/shared' },
+ }, tempDir)
+
+ expect(config.components?.source).toEqual([resolve(tempDir, 'src/shared')])
+ })
+
+ it('resolves components.source array relative to cwd', async () => {
+ const config = await resolveConfig({
+ root: 'project',
+ components: { source: ['layouts', 'partials'] },
+ }, tempDir)
+
+ expect(config.components?.source).toEqual([
+ resolve(tempDir, 'layouts'),
+ resolve(tempDir, 'partials'),
+ ])
+ })
+
+ it('passes through arbitrary user data', async () => {
+ writeFileSync(
+ join(tempDir, 'maizzle.config.js'),
+ 'export default { foo: "bar", myData: { nested: true } }'
+ )
+
+ const config = await resolveConfig(undefined, tempDir)
+
+ expect(config.foo).toBe('bar')
+ expect((config as any).myData).toEqual({ nested: true })
+ })
+})
diff --git a/src/tests/plaintext.test.ts b/src/tests/plaintext.test.ts
new file mode 100644
index 00000000..9c9804c3
--- /dev/null
+++ b/src/tests/plaintext.test.ts
@@ -0,0 +1,53 @@
+import { describe, it, expect } from 'vitest'
+import { createPlaintext } from '../plaintext.ts'
+
+describe('createPlaintext', () => {
+ it('strips HTML tags from simple HTML', () => {
+ const result = createPlaintext('Hello World ')
+ expect(result).toBe('Hello World')
+ })
+
+ it('preserves text content', () => {
+ const result = createPlaintext('TitleSome paragraph text here. ')
+ expect(result).toContain('Title')
+ expect(result).toContain('Some paragraph text here.')
+ })
+
+ it('handles empty input', () => {
+ const result = createPlaintext('')
+ expect(result).toBe('')
+ })
+
+ it('handles input with no HTML tags', () => {
+ const result = createPlaintext('Just plain text')
+ expect(result).toBe('Just plain text')
+ })
+
+ it('passes options through to string-strip-html', () => {
+ const result = createPlaintext(
+ 'Hello World',
+ { ignoreTags: ['br'] },
+ )
+ expect(result).toContain(' ')
+ })
+
+ it('strips a full email template', () => {
+ const html = `
+
+Email
+
+
+ Welcome
+ Thank you for signing up.
+ |
+
+`
+
+ const result = createPlaintext(html)
+ expect(result).toContain('Welcome')
+ expect(result).toContain('Thank you for signing up.')
+ expect(result).not.toContain('')
+ expect(result).not.toContain('')
+ expect(result).not.toContain('color: red')
+ })
+})
diff --git a/src/tests/render.test.ts b/src/tests/render.test.ts
new file mode 100644
index 00000000..6b75154c
--- /dev/null
+++ b/src/tests/render.test.ts
@@ -0,0 +1,813 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest'
+import { mkdtempSync, writeFileSync, mkdirSync, rmSync, symlinkSync } from 'node:fs'
+import { join, resolve } from 'node:path'
+import { tmpdir } from 'node:os'
+import { defineComponent, h } from 'vue'
+import { render } from '../render/index.ts'
+
+function createTempProject() {
+ const dir = mkdtempSync(join(tmpdir(), 'maizzle-render-'))
+ return dir
+}
+
+function writeSfc(dir: string, path: string, content: string) {
+ const full = join(dir, path)
+ mkdirSync(join(dir, ...path.split('/').slice(0, -1)), { recursive: true })
+ writeFileSync(full, content)
+}
+
+describe('render', () => {
+ let tempDir: string
+ const originalCwd = process.cwd()
+
+ beforeEach(() => {
+ tempDir = createTempProject()
+ process.chdir(tempDir)
+ })
+
+ afterEach(() => {
+ process.chdir(originalCwd)
+ rmSync(tempDir, { recursive: true, force: true })
+ })
+
+ describe('raw SFC source', () => {
+ it('renders a simple template', async () => {
+ const result = await render(`
+
+ Hello World
+
+ `)
+
+ expect(result.html).toContain('Hello World')
+ expect(result.html).toContain('')
+ expect(result.html).not.toContain(' ')
+ })
+
+ it('renders expressions', async () => {
+ const result = await render(`
+
+ {{ 1 + 1 }}
+
+ `)
+
+ expect(result.html).toContain('2')
+ })
+
+ it('renders script setup variables', async () => {
+ const result = await render(`
+
+
+ {{ name }}
+
+ `)
+
+ expect(result.html).toContain('Maizzle ')
+ })
+ })
+
+ describe('file path', () => {
+ it('renders a .vue file from disk', async () => {
+ writeSfc(tempDir, 'emails/test.vue', `
+
+ From File
+
+ `)
+
+ const result = await render(join(tempDir, 'emails/test.vue'))
+
+ expect(result.html).toContain('From File')
+ })
+ })
+
+ describe('return value', () => {
+ it('returns html and config', async () => {
+ const result = await render(`
+
+ Test
+
+ `)
+
+ expect(result).toHaveProperty('html')
+ expect(result).toHaveProperty('config')
+ expect(typeof result.html).toBe('string')
+ expect(typeof result.config).toBe('object')
+ })
+
+ it('config contains defaults', async () => {
+ const result = await render(`
+
+ Test
+
+ `)
+
+ expect(result.config.content).toEqual([resolve(tempDir, 'emails/**/*.{vue,md}').replace(/\\/g, '/')])
+ expect(result.config.css?.inline).toBe(undefined)
+ })
+ })
+
+ describe('doctype', () => {
+ it('prepends default doctype', async () => {
+ const result = await render(`
+
+ Test
+
+ `)
+
+ expect(result.html).toMatch(/^\n/)
+ })
+
+ it('uses custom doctype from config', async () => {
+ const result = await render(`
+
+ Test
+
+ `, {
+ config: {
+ doctype: '',
+ },
+ })
+
+ expect(result.html).toMatch(/^\n/)
+ })
+
+ it('uses useDoctype() from SFC', async () => {
+ const result = await render(`
+
+
+ Test
+
+ `)
+
+ expect(result.html).toMatch(/^/)
+ })
+
+ it('useDoctype() takes priority over config doctype', async () => {
+ const result = await render(`
+
+
+ Test
+
+ `, {
+ config: {
+ doctype: '',
+ },
+ })
+
+ expect(result.html).toMatch(/^/)
+ })
+ })
+
+ describe('config', () => {
+ it('merges programmatic config with defaults', async () => {
+ const result = await render(`
+
+ Test
+
+ `, {
+ config: {
+ css: { resolveCalc: false },
+ },
+ })
+
+ expect(result.config.css?.resolveCalc).toBe(false)
+ // Defaults still present
+ expect(result.config.css?.preferUnitless).toBe(true)
+ })
+
+ it('uses template-level defineConfig()', async () => {
+ const result = await render(`
+
+
+ Test
+
+ `)
+
+ expect(result.config.css?.inline).toBe(true)
+ })
+
+ it('template defineConfig() merges on top of global config', async () => {
+ const result = await render(`
+
+
+ Test
+
+ `, {
+ config: {
+ css: { inline: true },
+ },
+ })
+
+ expect(result.config.css?.shorthand).toBe(true)
+ expect(result.config.css?.inline).toBe(true)
+ })
+ })
+
+ describe('transformers', () => {
+ it('compiles Tailwind CSS utilities', async () => {
+ symlinkSync(join(originalCwd, 'node_modules'), join(tempDir, 'node_modules'))
+
+ writeSfc(tempDir, 'emails/test.vue', `
+
+
+
+
+
+
+ Test
+
+
+
+ `)
+
+ const result = await render(join(tempDir, 'emails/test.vue'))
+
+ expect(result.html).toContain('border: 1px solid red')
+ })
+
+ it('skips transformers when useTransformers is false', async () => {
+ const result = await render(`
+
+
+
+
+
+
+
+ Test
+
+
+
+ `)
+
+ // CSS should remain in
+
+
+ Hello
+
+ | |