release: v0.1.5 — theme system usability, extensibility, external theme following

feat(themes): comprehensive theme system enhancement
- Fix resolveTheme('auto') always resolves to system theme (no stale override)
- Add per-instance theme isolation via createInstanceTheme()
- Add MarkdownEditor.setTheme()/getTheme()/getThemeContext() instance methods
- Add 'themeChange' instance event with { theme, resolved, config } payload
- Multi-instance independent themes (no cross-contamination)

feat(themes): external theme following (v0.1.5)
- followExternalTheme() — follow theme via data attr, CSS class, or callback
- adoptFromParent() — inherit theme from parent container
- watch() — watch external theme source (function or selector)

feat(themes): extensibility (v0.1.5)
- registerTheme() supports 'extends' option for theme inheritance
- exportCSSVars() — export all CSS variable values from element
- getCSSVariable() — query single CSS variable
- applyThemeToElement() — apply theme to any DOM element

chore: bump version 0.1.4 → 0.1.5 across all files
test(themes): 30 new tests covering all v0.1.5 theme APIs (499 total)
This commit is contained in:
2026-07-24 09:53:20 +08:00
parent f8b9f4a761
commit 517dc34435
16 changed files with 1087 additions and 277 deletions
+389
View File
@@ -34,8 +34,16 @@ import {
setThemeVariables,
presetThemes,
createThemeManager,
exportCSSVars,
getCSSVariable,
applyThemeToElement,
followExternalTheme,
adoptFromParent,
watch,
createInstanceTheme,
} from '../src/themes.js';
import { THEMES } from '../src/constants.js';
import { MarkdownEditor } from '../src/core.js';
// 确保 localStorage 存在(jsdom 兼容)
if (typeof global.localStorage === 'undefined') {
@@ -431,3 +439,384 @@ describe('createThemeManager', () => {
expect(m.getCurrentTheme()).toBe('dark');
});
});
// ============ v0.1.5 新增测试 ============
describe('v0.1.5 resolveTheme auto 修复', () => {
beforeEach(() => {
clearThemeListeners();
resetToAuto();
localStorage.clear();
});
test('auto 始终解析为系统主题,不受上次手动设置污染', () => {
switchTheme('dark');
expect(getCurrentTheme()).toBe('dark');
// 即使手动设为 darkresolveTheme('auto') 仍应返回系统主题
const sys = getSystemTheme();
expect(resolveTheme('auto')).toBe(sys);
});
test('resolveTheme 传入具体主题名原样返回', () => {
expect(resolveTheme('light')).toBe('light');
expect(resolveTheme('dark')).toBe('dark');
expect(resolveTheme('warm')).toBe('warm');
});
test('resolveTheme 无参/空值返回系统主题', () => {
const sys = getSystemTheme();
expect(resolveTheme()).toBe(sys);
expect(resolveTheme(null)).toBe(sys);
expect(resolveTheme('')).toBe(sys);
});
});
describe('v0.1.5 exportCSSVars / getCSSVariable', () => {
test('exportCSSVars 返回 CSS 变量对象', () => {
applyTheme('light');
const vars = exportCSSVars();
expect(vars).toHaveProperty('--md-bg');
expect(vars).toHaveProperty('--md-accent');
});
test('exportCSSVars 从指定元素读取', () => {
const div = document.createElement('div');
div.style.setProperty('--md-bg', '#fff');
document.body.appendChild(div);
const vars = exportCSSVars(div);
expect(vars['--md-bg']).toBe('#fff');
document.body.removeChild(div);
});
test('getCSSVariable 获取单个变量', () => {
applyTheme('light');
const bg = getCSSVariable('--md-bg');
expect(typeof bg).toBe('string');
expect(bg.length).toBeGreaterThan(0);
});
test('getCSSVariable 自动补全 --md- 前缀', () => {
applyTheme('light');
const accent = getCSSVariable('accent');
expect(typeof accent).toBe('string');
expect(accent.length).toBeGreaterThan(0);
});
});
describe('v0.1.5 applyThemeToElement', () => {
test('应用主题到指定元素不改变全局状态', () => {
switchTheme('light');
const div = document.createElement('div');
document.body.appendChild(div);
applyThemeToElement('dark', div);
expect(div.getAttribute('data-md-theme')).toBe('dark');
// 全局主题不变
expect(getCurrentTheme()).toBe('light');
document.body.removeChild(div);
});
test('应用 auto 主题正确解析', () => {
const div = document.createElement('div');
document.body.appendChild(div);
applyThemeToElement('auto', div);
const resolved = resolveTheme('auto');
expect(div.getAttribute('data-md-theme')).toBe(resolved);
document.body.removeChild(div);
});
});
describe('v0.1.5 主题继承 registerTheme extends', () => {
test('extends 继承基础主题', () => {
registerTheme('ocean', {
extends: 'dark',
accent: '#00ddff',
});
expect(hasTheme('ocean')).toBe(true);
const cfg = getThemeConfig('ocean');
expect(cfg.accent).toBe('#00ddff');
// 从 dark 继承
expect(cfg.bg).toBe(THEMES.dark.bg);
expect(cfg.text).toBe(THEMES.dark.text);
unregisterTheme('ocean');
});
test('默认 extends light', () => {
registerTheme('minimal', { accent: '#ff0000' });
const cfg = getThemeConfig('minimal');
expect(cfg.bg).toBe(THEMES.light.bg);
unregisterTheme('minimal');
});
});
describe('v0.1.5 createInstanceTheme', () => {
test('创建实例主题上下文', () => {
const div = document.createElement('div');
document.body.appendChild(div);
const mockEditor = {
el: div,
config: { theme: 'dark' },
_emit: jest.fn(),
refresh: jest.fn(),
};
const ctx = createInstanceTheme(mockEditor);
expect(ctx.get()).toBe('dark');
expect(ctx.getResolved()).toBe('dark');
expect(typeof ctx.set).toBe('function');
expect(typeof ctx.toggle).toBe('function');
expect(typeof ctx.getConfig).toBe('function');
expect(typeof ctx.getVars).toBe('function');
expect(typeof ctx.syncWithElement).toBe('function');
expect(typeof ctx.adopt).toBe('function');
expect(typeof ctx.watch).toBe('function');
document.body.removeChild(div);
});
test('实例主题 set 触发 _emit themeChange', () => {
const div = document.createElement('div');
document.body.appendChild(div);
const mockEditor = {
el: div,
config: { theme: 'light' },
_emit: jest.fn(),
refresh: jest.fn(),
};
const ctx = createInstanceTheme(mockEditor);
ctx.set('dark');
expect(mockEditor._emit).toHaveBeenCalledWith('themeChange', expect.objectContaining({
theme: 'dark',
resolved: 'dark',
}));
expect(mockEditor.refresh).toHaveBeenCalled();
document.body.removeChild(div);
});
test('实例主题 toggle 切换 light/dark', () => {
const div = document.createElement('div');
document.body.appendChild(div);
const mockEditor = {
el: div,
config: { theme: 'light' },
_emit: jest.fn(),
refresh: jest.fn(),
};
const ctx = createInstanceTheme(mockEditor);
ctx.toggle();
expect(ctx.get()).toBe('dark');
ctx.toggle();
expect(ctx.get()).toBe('light');
document.body.removeChild(div);
});
test('getConfig 返回主题配置对象', () => {
const div = document.createElement('div');
document.body.appendChild(div);
const mockEditor = {
el: div,
config: { theme: 'light' },
_emit: jest.fn(),
refresh: jest.fn(),
};
const ctx = createInstanceTheme(mockEditor);
const cfg = ctx.getConfig();
expect(cfg).toHaveProperty('bg');
expect(cfg).toHaveProperty('accent');
document.body.removeChild(div);
});
test('getVars 返回实例 CSS 变量', () => {
const div = document.createElement('div');
document.body.appendChild(div);
const mockEditor = {
el: div,
config: { theme: 'light' },
_emit: jest.fn(),
refresh: jest.fn(),
};
const ctx = createInstanceTheme(mockEditor);
applyThemeToElement('light', div);
const vars = ctx.getVars();
expect(vars).toHaveProperty('--md-bg');
document.body.removeChild(div);
});
});
describe('v0.1.5 followExternalTheme', () => {
test('通过 data-theme 属性跟随', () => {
const div = document.createElement('div');
div.setAttribute('data-theme', 'dark');
document.body.appendChild(div);
let detected = null;
const unsub = followExternalTheme({ element: div }, (theme) => { detected = theme; });
expect(detected).toBe('dark');
div.setAttribute('data-theme', 'light');
// 手动触发 MutationObserverjsdom 中需手动 dispatch
const observer = new MutationObserver(() => {});
// 注:jsdom 环境 MutationObserver 行为受限,此处只验证初始化回调
unsub();
document.body.removeChild(div);
});
test('通过 classMap 跟随', () => {
const div = document.createElement('div');
div.className = 'theme-dark';
document.body.appendChild(div);
let detected = null;
followExternalTheme(
{ element: div, classMap: { 'theme-dark': 'dark', 'theme-light': 'light' } },
(theme) => { detected = theme; },
);
expect(detected).toBe('dark');
document.body.removeChild(div);
});
test('通过 callback 跟随', () => {
const div = document.createElement('div');
document.body.appendChild(div);
let detected = null;
followExternalTheme(
{ element: div, callback: () => 'warm' },
(theme) => { detected = theme; },
);
expect(detected).toBe('warm');
document.body.removeChild(div);
});
test('返回取消跟随函数', () => {
const div = document.createElement('div');
document.body.appendChild(div);
const unsub = followExternalTheme({ element: div }, () => {});
expect(typeof unsub).toBe('function');
unsub();
document.body.removeChild(div);
});
});
describe('v0.1.5 adoptFromParent', () => {
test('从父容器 data-md-theme 继承', () => {
const parent = document.createElement('div');
parent.setAttribute('data-md-theme', 'dark');
const child = document.createElement('div');
parent.appendChild(child);
document.body.appendChild(parent);
let detected = null;
const unsub = adoptFromParent(child, (theme) => { detected = theme; });
expect(detected).toBe('dark');
unsub();
document.body.removeChild(parent);
});
test('从父容器 class 继承', () => {
const parent = document.createElement('div');
parent.className = 'dark';
const child = document.createElement('div');
parent.appendChild(child);
document.body.appendChild(parent);
let detected = null;
adoptFromParent(child, (theme) => { detected = theme; });
expect(detected).toBe('dark');
document.body.removeChild(parent);
});
test('容器无父元素不抛错', () => {
const orphan = document.createElement('div');
let detected = null;
expect(() => adoptFromParent(orphan, (t) => { detected = t; })).not.toThrow();
});
test('返回取消函数', () => {
const parent = document.createElement('div');
const child = document.createElement('div');
parent.appendChild(child);
document.body.appendChild(parent);
const unsub = adoptFromParent(child, () => {});
expect(typeof unsub).toBe('function');
unsub();
document.body.removeChild(parent);
});
});
describe('v0.1.5 watch 外部主题源', () => {
test('函数源:回调被调用', () => {
let detected = null;
const unsub = watch(() => 'dark', (theme) => { detected = theme; });
expect(detected).toBe('dark');
unsub();
});
test('返回取消函数', () => {
const unsub = watch(() => 'light', () => {});
expect(typeof unsub).toBe('function');
unsub();
});
});
describe('v0.1.5 MarkdownEditor 实例主题 API', () => {
test('setTheme / getTheme 实例方法', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, { theme: 'light' });
expect(ed.getTheme()).toBe('light');
ed.setTheme('dark');
expect(ed.getTheme()).toBe('dark');
// el 类名应更新
expect(ed.el.className).toContain('me-theme-dark');
ed.destroy();
});
test('getThemeContext 返回实例主题上下文', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
const ctx = ed.getThemeContext();
expect(ctx).not.toBeNull();
expect(typeof ctx.set).toBe('function');
expect(typeof ctx.get).toBe('function');
ed.destroy();
});
test('多实例独立主题互不干扰', () => {
document.body.innerHTML = '';
const c1 = document.createElement('div');
const c2 = document.createElement('div');
document.body.appendChild(c1);
document.body.appendChild(c2);
const ed1 = new MarkdownEditor(c1, { theme: 'light' });
const ed2 = new MarkdownEditor(c2, { theme: 'dark' });
expect(ed1.getTheme()).toBe('light');
expect(ed2.getTheme()).toBe('dark');
ed1.setTheme('warm');
expect(ed1.getTheme()).toBe('warm');
expect(ed2.getTheme()).toBe('dark'); // ed2 不受影响
ed1.destroy();
ed2.destroy();
});
test('setTheme 触发 themeChange 事件', () => {
document.body.innerHTML = '';
const c = document.createElement('div');
document.body.appendChild(c);
const ed = new MarkdownEditor(c, {});
const handler = jest.fn();
ed.on('themeChange', handler);
ed.setTheme('dark');
expect(handler).toHaveBeenCalled();
ed.destroy();
});
});