')
+ })
+ })
+
+ describe('class attribute merging', () => {
+ it('merges new classes with existing classes', () => {
+ const result = run('
')
+ })
+
+ it('does not duplicate existing classes', () => {
+ const result = run('
')
+ })
+
+ it('adds class when element has no class attribute', () => {
+ const result = run('
', {
+ div: { class: 'new-class' },
+ })
+ expect(result).toBe('
')
+ })
+
+ it('handles multiple space-separated classes in config', () => {
+ const result = run('
')
+ })
+
+ it('leaves the class untouched when it already has the added class', () => {
+ const result = run('
')
+ })
+ })
+
+ describe('attribute value types', () => {
+ it('handles boolean true values', () => {
+ const result = run('
')
+ })
+
+ it('handles number values', () => {
+ const result = run('
')
+ })
+
+ it('handles string values', () => {
+ const result = run('
')
+ })
+ })
+
+ describe('nested elements', () => {
+ it('processes nested matching elements', () => {
+ const result = run('
')
+ // Both tables should get default attributes
+ expect(result.match(/cellpadding="0"/g)).toHaveLength(2)
+ })
+
+ it('handles deeply nested targets', () => {
+ const result = run('
')
+ expect(result).toContain(' alt>')
+ })
+ })
+
+ describe('short-circuit', () => {
+ it('returns original string when no elements match', () => {
+ const html = '
'
+ const result = run(html)
+ expect(result).toBe(html)
+ })
+
+ it('returns original string when all attributes already exist', () => {
+ const html = '
'
+ const result = run(html)
+ expect(result).toBe(html)
+ })
+
+ it('returns original string when empty config and no defaults match', () => {
+ const html = '
'
+ const result = addAttributes(html, {})
+ expect(result).toBe(html)
+ })
+ })
+
+ describe('edge cases', () => {
+ it('handles empty HTML', () => {
+ const result = run('')
+ expect(result).toBe('')
+ })
+
+ it('handles HTML with no matching elements', () => {
+ const html = '
'
+ const result = run(html)
+ expect(result).toBe(html)
+ })
+
+ it('handles self-closing elements', () => {
+ const result = run('
', {
+ br: { class: 'break' },
+ })
+ expect(result).toContain('
')
+ })
+
+ it('preserves existing attributes when adding new ones', () => {
+ const result = run('
', {
+ div: { class: 'added' },
+ })
+ expect(result).toContain('id="main"')
+ expect(result).toContain('data-existing="yes"')
+ expect(result).toContain('class="added"')
+ })
+
+ it('adds attributes to elements with no attribs object', () => {
+ const el = new Element('div', {})
+ // @ts-expect-error — simulating a node with undefined attribs
+ delete el.attribs
+
+ const dom = addAttributesDom([el], { add: { div: { role: 'article' } } })
+ expect((dom[0] as Element).attribs.role).toBe('article')
+ })
+ })
+})
diff --git a/src/tests/transformers/attributeToStyle.test.ts b/src/tests/transformers/attributeToStyle.test.ts
new file mode 100644
index 00000000..0a300db0
--- /dev/null
+++ b/src/tests/transformers/attributeToStyle.test.ts
@@ -0,0 +1,175 @@
+import { describe, it, expect } from 'vitest'
+import { attributeToStyle } from '../../transformers/attributeToStyle.ts'
+
+function run(html: string, attributes?: boolean | string[]): string {
+ return attributeToStyle(html, attributes)
+}
+
+describe('attributeToStyle', () => {
+ describe('argument', () => {
+ it('returns unchanged when attributes is false', () => {
+ const html = '
'
+ expect(run(html, false)).toBe(html)
+ })
+
+ it('returns unchanged when attributes is an empty array', () => {
+ const html = '
'
+ expect(run(html, [])).toBe(html)
+ })
+
+ it('processes all defaults when attributes is true', () => {
+ const html = '
'
+ const result = run(html, true)
+ expect(result).toContain('style="width: 100%; height: 200px; background-color: #fff; background-image: url(\'image.jpg\'); margin-left: auto; margin-right: auto; vertical-align: top"')
+ })
+
+ it('processes all defaults when attributes is omitted', () => {
+ const html = '
'
+ expect(attributeToStyle(html)).toContain('style="background-color: #fff"')
+ })
+
+ it('ignores a requested attribute with no style mapping', () => {
+ const html = '
'
+ expect(run(html, ['id'])).toBe(html)
+ })
+ })
+
+ describe('width attribute', () => {
+ it('converts width attribute to style with percentage value', () => {
+ const html = '
')
+ })
+
+ it('converts width attribute to style with px value', () => {
+ const html = '
')
+ })
+ })
+
+ describe('height attribute', () => {
+ it('converts height attribute to style with percentage value', () => {
+ const html = '
')
+ })
+
+ it('converts height attribute to style with px value', () => {
+ const html = '
')
+ })
+ })
+
+ describe('bgcolor attribute', () => {
+ it('converts bgcolor to background-color', () => {
+ const html = '
')
+ })
+
+ it('converts bgcolor with named color', () => {
+ const html = '
')
+ })
+ })
+
+ describe('background attribute', () => {
+ it('converts background to background-image', () => {
+ const html = '
`)
+ })
+ })
+
+ describe('align attribute', () => {
+ it('converts align to text-align on non-table elements', () => {
+ const html = '
')
+ })
+
+ it('converts align="left" to float on table elements', () => {
+ const html = '
')
+ })
+
+ it('converts align="right" to float on table elements', () => {
+ const html = '
')
+ })
+
+ it('converts align="center" to margin auto on table elements', () => {
+ const html = '
')
+ })
+ })
+
+ describe('valign attribute', () => {
+ it('converts valign to vertical-align', () => {
+ const html = '
')
+ })
+ })
+
+ describe('multiple attributes', () => {
+ it('handles multiple attributes on the same element', () => {
+ const html = '
'
+ const result = run(html, ['width', 'height', 'bgcolor'])
+ expect(result).toBe('
')
+ })
+
+ it('appends to existing style attribute', () => {
+ const html = '
')
+ })
+
+ it('handles existing style without trailing semicolon', () => {
+ const html = '
')
+ })
+ })
+
+ describe('nested elements', () => {
+ it('processes attributes on nested elements', () => {
+ const html = '
'
+ const result = run(html, ['width', 'height'])
+ expect(result).toContain('
')
+ expect(result).toContain('')
+ })
+ })
+
+ describe('short-circuit behavior', () => {
+ it('returns original string when no matching attributes found', () => {
+ const html = ''
+ const result = run(html, ['width'])
+ expect(result).toBe(html)
+ })
+
+ it('returns original string when configured attributes are not present', () => {
+ const html = ''
+ const result = run(html, ['width'])
+ expect(result).toBe(html)
+ })
+ })
+})
diff --git a/src/tests/transformers/base.test.ts b/src/tests/transformers/base.test.ts
new file mode 100644
index 00000000..d8131a02
--- /dev/null
+++ b/src/tests/transformers/base.test.ts
@@ -0,0 +1,574 @@
+import { describe, it, expect } from 'vitest'
+import { base, type BaseUrlOptions } from '../../transformers/base.ts'
+import type { UrlConfig } from '../../types/config.ts'
+
+function run(html: string, config: { url?: UrlConfig } = {}): string {
+ const baseOption = config.url?.base
+ if (!baseOption) return html
+ return base(html, baseOption as string | BaseUrlOptions)
+}
+
+describe('base URL', () => {
+ describe('default behavior', () => {
+ it('does nothing when url.base is not set', () => {
+ const html = ' '
+ expect(run(html)).toBe(html)
+ })
+
+ it('does nothing when url.base is false', () => {
+ const html = ' '
+ expect(run(html, { url: { base: false as unknown as undefined } })).toBe(html)
+ })
+
+ it('does nothing when url.base is empty string', () => {
+ const html = ' '
+ expect(run(html, { url: { base: '' } })).toBe(html)
+ })
+ })
+
+ describe('string url.base', () => {
+ it('prepends base URL to img src', () => {
+ const result = run(' ', { url: { base: 'https://cdn.example.com/' } })
+ expect(result).toBe(' ')
+ })
+
+ it('prepends base URL to anchor href', () => {
+ const result = run('Link', { url: { base: 'https://example.com/' } })
+ expect(result).toBe('Link')
+ })
+
+ it('prepends base URL to multiple img src', () => {
+ const html = ' '
+ const result = run(html, { url: { base: 'https://cdn.example.com/' } })
+ expect(result).toContain('src="https://cdn.example.com/a.jpg"')
+ expect(result).toContain('src="https://cdn.example.com/b.jpg"')
+ })
+
+ it('does not modify absolute URLs', () => {
+ const html = ' '
+ const result = run(html, { url: { base: 'https://cdn.example.com/' } })
+ expect(result).toBe(html)
+ })
+
+ it('does not modify data URIs', () => {
+ const html = ' '
+ const result = run(html, { url: { base: 'https://cdn.example.com/' } })
+ expect(result).toBe(html)
+ })
+
+ it('handles mailto: URLs', () => {
+ const html = 'Email'
+ const result = run(html, { url: { base: 'https://example.com/' } })
+ expect(result).toBe(html)
+ })
+
+ it('handles tel: URLs', () => {
+ const html = 'Call'
+ const result = run(html, { url: { base: 'https://example.com/' } })
+ expect(result).toBe(html)
+ })
+
+ it('does not modify protocol-relative URLs', () => {
+ const html = ' '
+ const result = run(html, { url: { base: 'https://example.com/' } })
+ expect(result).toBe(html)
+ })
+
+ it('does not modify fragment-only URLs', () => {
+ const html = 'Jump'
+ const result = run(html, { url: { base: 'https://example.com/' } })
+ expect(result).toBe(html)
+ })
+
+ it('does not modify query-only URLs', () => {
+ const html = 'Link'
+ const result = run(html, { url: { base: 'https://example.com/' } })
+ expect(result).toBe(html)
+ })
+ })
+
+ describe('srcset attribute', () => {
+ it('prepends base URL to srcset URLs', () => {
+ const html = ' '
+ const result = run(html, { url: { base: 'https://cdn.example.com/' } })
+ expect(result).toBe(' ')
+ })
+
+ it('preserves width descriptors in srcset', () => {
+ const html = ' '
+ const result = run(html, { url: { base: 'https://cdn.example.com/' } })
+ expect(result).toBe(' ')
+ })
+
+ it('does not modify absolute URLs in srcset', () => {
+ const html = ' '
+ const result = run(html, { url: { base: 'https://cdn.example.com/' } })
+ expect(result).toBe(html)
+ })
+ })
+
+ describe('video elements', () => {
+ it('prepends base URL to video src', () => {
+ const html = ''
+ const result = run(html, { url: { base: 'https://cdn.example.com/' } })
+ expect(result).toBe('')
+ })
+
+ it('prepends base URL to video poster', () => {
+ const html = ''
+ const result = run(html, { url: { base: 'https://cdn.example.com/' } })
+ expect(result).toBe('')
+ })
+ })
+
+ describe('source elements', () => {
+ it('prepends base URL to source src', () => {
+ const html = ''
+ const result = run(html, { url: { base: 'https://cdn.example.com/' } })
+ expect(result).toBe('')
+ })
+
+ it('prepends base URL to source srcset', () => {
+ const html = ''
+ const result = run(html, { url: { base: 'https://cdn.example.com/' } })
+ expect(result).toBe('')
+ })
+ })
+
+ describe('link elements', () => {
+ it('prepends base URL to link href', () => {
+ const html = ''
+ const result = run(html, { url: { base: 'https://cdn.example.com/' } })
+ expect(result).toBe('')
+ })
+ })
+
+ describe('script elements', () => {
+ it('prepends base URL to script src', () => {
+ const html = ''
+ const result = run(html, { url: { base: 'https://cdn.example.com/' } })
+ expect(result).toBe('')
+ })
+ })
+
+ describe('object and embed elements', () => {
+ it('prepends base URL to object data', () => {
+ const html = ''
+ const result = run(html, { url: { base: 'https://cdn.example.com/' } })
+ expect(result).toBe('')
+ })
+
+ it('prepends base URL to embed src', () => {
+ const html = ' |