diff --git a/lib/__tests__/plugin-validator.test.ts b/lib/__tests__/plugin-validator.test.ts index bd432829..83a070c7 100644 --- a/lib/__tests__/plugin-validator.test.ts +++ b/lib/__tests__/plugin-validator.test.ts @@ -28,23 +28,11 @@ describe('extractTheme', () => { }); it('rejects oversized theme', async () => { - const zip = new JSZip(); - zip.file('manifest.json', JSON.stringify({ - id: 'big-theme', - name: 'Big', - version: '1.0.0', - author: 'Test', - type: 'theme', - variants: ['light'], - })); - // Make a large file > 1MB - zip.file('theme.css', 'x'.repeat(1024 * 1024 + 1)); - - // Manually create oversized File - const oversizedFile = new File([new ArrayBuffer(1024 * 1024 + 1)], 'big.zip'); + // Theme size limit is 2 MB; create a file just past it. + const oversizedFile = new File([new ArrayBuffer(2 * 1024 * 1024 + 1)], 'big.zip'); const result = await extractTheme(oversizedFile); expect(result.valid).toBe(false); - expect(result.errors).toContain('Theme ZIP exceeds 1 MB size limit'); + expect(result.errors).toContain('Theme ZIP exceeds 2 MB size limit'); }); it('rejects non-ZIP file', async () => { @@ -139,6 +127,129 @@ describe('extractTheme', () => { expect(result.valid).toBe(true); expect(result.manifest!.id).toBe('nested-theme'); }); + + // ── Theme API v2 (advanced manifest) ────────────────────────────── + + it('compiles a v2 manifest with tokens and no theme.css', async () => { + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify({ + id: 'tokens-only', + name: 'Tokens Only', + version: '1.0.0', + author: 'Test', + type: 'theme', + variants: ['light', 'dark'], + apiVersion: 2, + tokens: { + light: { primary: '#1373d9', background: '#ffffff' }, + dark: { primary: '#58c9ff', background: '#1a202c' }, + }, + })); + const file = await createZipFile(zip); + const result = await extractTheme(file); + expect(result.valid).toBe(true); + expect(result.css).toContain('--color-primary: #1373d9'); + expect(result.css).toContain('--color-primary: #58c9ff'); + }); + + it('concatenates compiled tokens with author-supplied theme.css', async () => { + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify({ + id: 'tokens-plus-css', + name: 'Tokens + CSS', + version: '1.0.0', + author: 'Test', + type: 'theme', + variants: ['light'], + apiVersion: 2, + tokens: { light: { primary: '#000' } }, + })); + zip.file('theme.css', '@font-face { font-family: "X"; src: local("X"); }'); + const file = await createZipFile(zip); + const result = await extractTheme(file); + expect(result.valid).toBe(true); + expect(result.css).toContain('--color-primary: #000'); + expect(result.css).toContain('@font-face'); + }); + + it('extracts a skin.css when shipped with a v2 manifest', async () => { + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify({ + id: 'with-skin', + name: 'With Skin', + version: '1.0.0', + author: 'Test', + type: 'theme', + variants: ['light'], + apiVersion: 2, + tokens: { light: { primary: '#000' } }, + })); + zip.file('skin.css', '[data-tour="email-list"] { font-size: 13px; }'); + const file = await createZipFile(zip); + const result = await extractTheme(file); + expect(result.valid).toBe(true); + expect(result.skin).not.toBeNull(); + expect(result.skin!).toContain('[data-tour="email-list"]'); + }); + + it('strips dangerous patterns from skin.css', async () => { + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify({ + id: 'evil-skin', + name: 'Evil', + version: '1.0.0', + author: 'Test', + type: 'theme', + variants: ['light'], + apiVersion: 2, + tokens: { light: { primary: '#000' } }, + })); + zip.file('skin.css', '@import url("https://x.com/p.css"); button { background: javascript:alert(1); }'); + const file = await createZipFile(zip); + const result = await extractTheme(file); + expect(result.valid).toBe(true); + expect(result.skin).not.toBeNull(); + expect(result.skin!).not.toContain('javascript:'); + expect(result.skin!).not.toContain('@import'); + expect(result.warnings.some((w) => w.toLowerCase().includes('skin'))).toBe(true); + }); + + it('ignores skin.css when manifest is not v2', async () => { + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify({ + id: 'v1-with-skin', + name: 'V1', + version: '1.0.0', + author: 'Test', + type: 'theme', + variants: ['light'], + })); + zip.file('theme.css', ':root { --color-primary: #000; }'); + zip.file('skin.css', 'body { display: none; }'); + const file = await createZipFile(zip); + const result = await extractTheme(file); + expect(result.valid).toBe(true); + expect(result.skin).toBeNull(); + expect(result.warnings.some((w) => w.includes('skin.css ignored'))).toBe(true); + }); + + it('rejects a v2 manifest with invalid density', async () => { + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify({ + id: 'bad-density', + name: 'Bad', + version: '1.0.0', + author: 'Test', + type: 'theme', + variants: ['light'], + density: 'gigantic', + tokens: { light: { primary: '#000' } }, + })); + const file = await createZipFile(zip); + const result = await extractTheme(file); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes('density'))).toBe(true); + }); }); describe('extractPlugin', () => { diff --git a/lib/__tests__/theme-compiler.test.ts b/lib/__tests__/theme-compiler.test.ts new file mode 100644 index 00000000..db2cc0c6 --- /dev/null +++ b/lib/__tests__/theme-compiler.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect } from 'vitest'; +import { compileAdvancedTheme, isAdvancedManifest } from '../theme-compiler'; +import type { ThemeManifest } from '../plugin-types'; + +const baseManifest = (overrides: Partial = {}): ThemeManifest => ({ + id: 't', + name: 'T', + version: '1.0.0', + author: 'tester', + description: '', + type: 'theme', + variants: ['light', 'dark'], + ...overrides, +}); + +describe('isAdvancedManifest', () => { + it('returns false for plain v1 manifests', () => { + expect(isAdvancedManifest(baseManifest())).toBe(false); + }); + + it.each([ + { apiVersion: 2 as const }, + { tokens: { light: { primary: '#000' } } }, + { extends: 'builtin-nord' }, + { derive: true }, + { density: 'compact' as const }, + { radii: { md: '6px' } }, + { typography: { fontSans: 'Inter' } }, + ])('returns true when manifest has %p', (extra) => { + expect(isAdvancedManifest(baseManifest(extra))).toBe(true); + }); +}); + +describe('compileAdvancedTheme', () => { + it('emits :root and .dark blocks from token sets', () => { + const { css, errors } = compileAdvancedTheme( + baseManifest({ + tokens: { + light: { primary: '#1373d9', background: '#ffffff' }, + dark: { primary: '#58c9ff', background: '#1a202c' }, + }, + }), + ); + expect(errors).toHaveLength(0); + expect(css).toMatch(/:root\s*\{[\s\S]*--color-primary:\s*#1373d9/); + expect(css).toMatch(/\.dark\s*\{[\s\S]*--color-primary:\s*#58c9ff/); + }); + + it('omits .dark block for light-only themes', () => { + const { css } = compileAdvancedTheme( + baseManifest({ + variants: ['light'], + tokens: { light: { primary: '#000' }, dark: { primary: '#fff' } }, + }), + ); + expect(css).toContain(':root'); + expect(css).not.toContain('.dark'); + }); + + it('emits common tokens into both :root and .dark', () => { + const { css } = compileAdvancedTheme( + baseManifest({ + tokens: { + common: { ring: '#abc' }, + light: { background: '#fff' }, + dark: { background: '#000' }, + }, + }), + ); + const rootMatch = css.match(/:root\s*\{([\s\S]*?)\}/)?.[1] ?? ''; + const darkMatch = css.match(/\.dark\s*\{([\s\S]*?)\}/)?.[1] ?? ''; + expect(rootMatch).toContain('--color-ring: #abc'); + expect(darkMatch).toContain('--color-ring: #abc'); + }); + + it('derives a contrasting *-foreground when derive: true', () => { + const { css } = compileAdvancedTheme( + baseManifest({ + derive: true, + tokens: { light: { primary: '#000000' }, dark: { primary: '#ffffff' } }, + }), + ); + expect(css).toMatch(/:root\s*\{[\s\S]*--color-primary-foreground:\s*#ffffff/); + expect(css).toMatch(/\.dark\s*\{[\s\S]*--color-primary-foreground:\s*#0f172a/); + }); + + it('respects an author-provided *-foreground over derive', () => { + const { css } = compileAdvancedTheme( + baseManifest({ + derive: true, + tokens: { + light: { primary: '#000000', 'primary-foreground': '#ff00ff' }, + }, + }), + ); + expect(css).toContain('--color-primary-foreground: #ff00ff'); + }); + + it('emits radii, typography, and density vars', () => { + const { css } = compileAdvancedTheme( + baseManifest({ + tokens: { light: { primary: '#000' } }, + radii: { sm: '2px', md: '6px', full: '9999px' }, + typography: { fontSans: 'Inter, sans-serif', baseFontSize: '15px' }, + density: 'compact', + }), + ); + expect(css).toContain('--radius-sm: 2px'); + expect(css).toContain('--radius-full: 9999px'); + expect(css).toContain('--font-sans: Inter, sans-serif'); + expect(css).toContain('--font-size-base: 15px'); + expect(css).toContain('--density-row-height: 28px'); + }); + + it('drops tokens with unsafe values and warns', () => { + const { css, warnings } = compileAdvancedTheme( + baseManifest({ + tokens: { + light: { + primary: '#000', + evil: 'red; background: url("https://x.com/track.png")', + }, + }, + }), + ); + expect(css).toContain('--color-primary: #000'); + expect(css).not.toContain('https://x.com'); + expect(warnings.some((w) => w.includes('evil'))).toBe(true); + }); + + it('drops tokens with unsafe keys and warns', () => { + const { css, warnings } = compileAdvancedTheme( + baseManifest({ + tokens: { light: { 'primary }; body { background: red': '#fff', primary: '#000' } }, + }), + ); + expect(css).toContain('--color-primary: #000'); + expect(css).not.toContain('body { background'); + expect(warnings.some((w) => w.includes('invalid key'))).toBe(true); + }); + + it('errors when no structured fields are present', () => { + const { errors } = compileAdvancedTheme(baseManifest()); + expect(errors.length).toBeGreaterThan(0); + }); + + it('inlines parent CSS when extends + resolver supplied', () => { + const { css, warnings } = compileAdvancedTheme( + baseManifest({ + extends: 'parent-theme', + tokens: { light: { primary: '#fff' } }, + }), + { resolveExtends: (id) => (id === 'parent-theme' ? ':root { --x: 1; }' : null) }, + ); + expect(css).toContain('--x: 1'); + expect(css).toContain('--color-primary: #fff'); + expect(warnings).toHaveLength(0); + }); + + it('warns when extends parent cannot be resolved', () => { + const { warnings } = compileAdvancedTheme( + baseManifest({ + extends: 'missing', + tokens: { light: { primary: '#fff' } }, + }), + { resolveExtends: () => null }, + ); + expect(warnings.some((w) => w.includes('missing'))).toBe(true); + }); + + it('appends user-supplied CSS after compiled output', () => { + const { css } = compileAdvancedTheme( + baseManifest({ tokens: { light: { primary: '#fff' } } }), + { userCSS: '@font-face { font-family: "X"; src: local("X"); }' }, + ); + const compiledIdx = css.indexOf('--color-primary'); + const userIdx = css.indexOf('@font-face'); + expect(compiledIdx).toBeGreaterThanOrEqual(0); + expect(userIdx).toBeGreaterThan(compiledIdx); + }); +}); diff --git a/lib/__tests__/theme-loader.test.ts b/lib/__tests__/theme-loader.test.ts index bd3aa4a2..1dd29a42 100644 --- a/lib/__tests__/theme-loader.test.ts +++ b/lib/__tests__/theme-loader.test.ts @@ -1,9 +1,12 @@ import { describe, it, expect, afterEach } from 'vitest'; import { sanitizeThemeCSS, + sanitizeSkinCSS, validateThemeSelectors, injectThemeCSS, removeThemeCSS, + injectThemeSkinCSS, + removeThemeSkinCSS, validateThemeCSSSafety, } from '../theme-loader'; @@ -144,6 +147,64 @@ describe('theme-loader', () => { }); }); + describe('injectThemeSkinCSS / removeThemeSkinCSS', () => { + afterEach(() => { + removeThemeSkinCSS(); + }); + + it('injects a separate