mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-02 01:29:32 +05:30
feat(theme-registry): pure ThemeRegistry module with full API
- 8 exports: register/unregister/list/get/categories/lightThemes/darkThemes/clear - kebab-case id validator; category whitelist (light/dark/high-contrast/seasonal) - duplicate-id and shape errors with descriptive messages - pure CommonJS, no IO, no Electron deps — T2 bootstrap will register all 37 themes - 10 Jest tests covering register/unregister/get/categories/light+dark filters - full suite: 77 suites, 924 tests passing; lint + Prettier clean Amit Haridas
This commit is contained in:
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Editor Theme Registry — single source of truth for installed editor themes.
|
||||||
|
*
|
||||||
|
* Pure module: no Electron, no IO, no globals beyond a module-scoped Map.
|
||||||
|
* The bootstrap (`ThemeRegistry.bootstrap.js`) calls `register()` at startup;
|
||||||
|
* `main.js` menu builder calls `list()` + `categories()`; `setTheme()`
|
||||||
|
* validation calls `get()`.
|
||||||
|
*
|
||||||
|
* Theme shape:
|
||||||
|
* { id, label, category: 'light'|'dark'|'high-contrast'|'seasonal', isDark }
|
||||||
|
*
|
||||||
|
* Ids are kebab-case lowercase. Categories are independent of `isDark` —
|
||||||
|
* e.g. `winter-is-coming-light` has `isDark: false` and `category: 'light'`,
|
||||||
|
* but a future seasonal theme might pair `category: 'seasonal'` with
|
||||||
|
* `isDark: true`. Filtering by category is the public contract; `isDark`
|
||||||
|
* is metadata for the renderer.
|
||||||
|
*
|
||||||
|
* @module ThemeRegistry
|
||||||
|
*/
|
||||||
|
|
||||||
|
const VALID_CATEGORIES = new Set(['light', 'dark', 'high-contrast', 'seasonal']);
|
||||||
|
const ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
||||||
|
|
||||||
|
const themes = new Map(); // id → theme
|
||||||
|
|
||||||
|
function validate(theme) {
|
||||||
|
if (!theme || typeof theme !== 'object') throw new Error('theme must be an object');
|
||||||
|
if (typeof theme.id !== 'string' || !theme.id) throw new Error('theme is missing id');
|
||||||
|
if (!ID_RE.test(theme.id)) {
|
||||||
|
throw new Error(`theme id must be kebab-case lowercase: got "${theme.id}"`);
|
||||||
|
}
|
||||||
|
if (typeof theme.label !== 'string' || !theme.label) {
|
||||||
|
throw new Error(`theme "${theme.id}" is missing label`);
|
||||||
|
}
|
||||||
|
if (!('category' in theme) || theme.category === undefined || theme.category === null) {
|
||||||
|
throw new Error(`theme "${theme.id}" is missing category`);
|
||||||
|
}
|
||||||
|
if (!VALID_CATEGORIES.has(theme.category)) {
|
||||||
|
throw new Error(
|
||||||
|
`theme "${theme.id}" has invalid category "${theme.category}" (allowed: ${[...VALID_CATEGORIES].join(', ')})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (typeof theme.isDark !== 'boolean') {
|
||||||
|
throw new Error(`theme "${theme.id}" is missing isDark boolean`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function register(theme) {
|
||||||
|
validate(theme);
|
||||||
|
if (themes.has(theme.id)) {
|
||||||
|
throw new Error(`duplicate theme id: ${theme.id}`);
|
||||||
|
}
|
||||||
|
themes.set(theme.id, {
|
||||||
|
id: theme.id,
|
||||||
|
label: theme.label,
|
||||||
|
category: theme.category,
|
||||||
|
isDark: theme.isDark,
|
||||||
|
});
|
||||||
|
return theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unregister(id) {
|
||||||
|
return themes.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function list() {
|
||||||
|
return [...themes.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function get(id) {
|
||||||
|
return themes.get(id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function categories() {
|
||||||
|
const seen = new Set();
|
||||||
|
const out = [];
|
||||||
|
for (const t of themes.values()) {
|
||||||
|
if (!seen.has(t.category)) {
|
||||||
|
seen.add(t.category);
|
||||||
|
out.push(t.category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lightThemes() {
|
||||||
|
return list().filter((t) => t.category === 'light');
|
||||||
|
}
|
||||||
|
|
||||||
|
function darkThemes() {
|
||||||
|
return list().filter((t) => t.category === 'dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test-only: empty the registry between tests. */
|
||||||
|
function clear() {
|
||||||
|
themes.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
register,
|
||||||
|
unregister,
|
||||||
|
list,
|
||||||
|
get,
|
||||||
|
categories,
|
||||||
|
lightThemes,
|
||||||
|
darkThemes,
|
||||||
|
clear,
|
||||||
|
};
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* @jest-environment node
|
||||||
|
*
|
||||||
|
* ThemeRegistry tests — pure module. Tests run against a freshly-cleared
|
||||||
|
* registry (`clear()` is a test-only helper, but it's exported alongside the
|
||||||
|
* public API because every other pure module in src/main/ exposes the same
|
||||||
|
* `reset` helper for tests — see DocQA, DailyNotes, WorkspaceSearch).
|
||||||
|
*/
|
||||||
|
const ThemeRegistry = require('../../src/main/ThemeRegistry');
|
||||||
|
|
||||||
|
const validTheme = (overrides = {}) => ({
|
||||||
|
id: 'atomonelight',
|
||||||
|
label: 'Atom One Light',
|
||||||
|
category: 'light',
|
||||||
|
isDark: false,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ThemeRegistry.register', () => {
|
||||||
|
beforeEach(() => ThemeRegistry.clear());
|
||||||
|
|
||||||
|
test('adds a theme to list()', () => {
|
||||||
|
ThemeRegistry.register(validTheme());
|
||||||
|
expect(ThemeRegistry.list()).toEqual([
|
||||||
|
{
|
||||||
|
id: 'atomonelight',
|
||||||
|
label: 'Atom One Light',
|
||||||
|
category: 'light',
|
||||||
|
isDark: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws on duplicate id', () => {
|
||||||
|
ThemeRegistry.register(validTheme());
|
||||||
|
expect(() => ThemeRegistry.register(validTheme())).toThrow(/duplicate theme id: atomonelight/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws when theme shape is invalid', () => {
|
||||||
|
expect(() => ThemeRegistry.register({ id: 'x' })).toThrow(/missing label/);
|
||||||
|
expect(() => ThemeRegistry.register({ label: 'X' })).toThrow(/missing id/);
|
||||||
|
expect(() => ThemeRegistry.register({ id: 'x', label: 'X' })).toThrow(/missing category/);
|
||||||
|
expect(() => ThemeRegistry.register({ id: 'x', label: 'X', category: 'light' })).toThrow(
|
||||||
|
/missing isDark/
|
||||||
|
);
|
||||||
|
expect(() =>
|
||||||
|
ThemeRegistry.register({
|
||||||
|
id: 'BAD ID',
|
||||||
|
label: 'X',
|
||||||
|
category: 'light',
|
||||||
|
isDark: false,
|
||||||
|
})
|
||||||
|
).toThrow(/kebab-case/);
|
||||||
|
expect(() =>
|
||||||
|
ThemeRegistry.register({
|
||||||
|
id: 'x',
|
||||||
|
label: 'X',
|
||||||
|
category: 'bogus',
|
||||||
|
isDark: false,
|
||||||
|
})
|
||||||
|
).toThrow(/invalid category/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves registration order across many inserts', () => {
|
||||||
|
ThemeRegistry.register(validTheme({ id: 'a', label: 'A' }));
|
||||||
|
ThemeRegistry.register(validTheme({ id: 'b', label: 'B' }));
|
||||||
|
ThemeRegistry.register(validTheme({ id: 'c', label: 'C' }));
|
||||||
|
expect(ThemeRegistry.list().map((t) => t.id)).toEqual(['a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ThemeRegistry.unregister', () => {
|
||||||
|
beforeEach(() => ThemeRegistry.clear());
|
||||||
|
|
||||||
|
test('removes a theme by id', () => {
|
||||||
|
ThemeRegistry.register(validTheme());
|
||||||
|
expect(ThemeRegistry.unregister('atomonelight')).toBe(true);
|
||||||
|
expect(ThemeRegistry.list()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when id not found', () => {
|
||||||
|
expect(ThemeRegistry.unregister('nope')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ThemeRegistry.get', () => {
|
||||||
|
beforeEach(() => ThemeRegistry.clear());
|
||||||
|
|
||||||
|
test('returns the registered theme', () => {
|
||||||
|
ThemeRegistry.register(validTheme());
|
||||||
|
expect(ThemeRegistry.get('atomonelight')).toEqual({
|
||||||
|
id: 'atomonelight',
|
||||||
|
label: 'Atom One Light',
|
||||||
|
category: 'light',
|
||||||
|
isDark: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for unknown id', () => {
|
||||||
|
expect(ThemeRegistry.get('nope')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ThemeRegistry.categories', () => {
|
||||||
|
beforeEach(() => ThemeRegistry.clear());
|
||||||
|
|
||||||
|
test('returns unique categories in registration order', () => {
|
||||||
|
ThemeRegistry.register(validTheme({ id: 'a', label: 'A', category: 'light' }));
|
||||||
|
ThemeRegistry.register(validTheme({ id: 'b', label: 'B', category: 'dark' }));
|
||||||
|
ThemeRegistry.register(validTheme({ id: 'c', label: 'C', category: 'light' }));
|
||||||
|
ThemeRegistry.register(validTheme({ id: 'd', label: 'D', category: 'high-contrast' }));
|
||||||
|
expect(ThemeRegistry.categories()).toEqual(['light', 'dark', 'high-contrast']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ThemeRegistry.lightThemes / darkThemes', () => {
|
||||||
|
beforeEach(() => ThemeRegistry.clear());
|
||||||
|
|
||||||
|
test('filters by category regardless of isDark', () => {
|
||||||
|
ThemeRegistry.register(validTheme({ id: 'a', label: 'A', category: 'light', isDark: false }));
|
||||||
|
ThemeRegistry.register(validTheme({ id: 'b', label: 'B', category: 'light', isDark: true }));
|
||||||
|
ThemeRegistry.register(validTheme({ id: 'c', label: 'C', category: 'dark', isDark: true }));
|
||||||
|
expect(ThemeRegistry.lightThemes().map((t) => t.id)).toEqual(['a', 'b']);
|
||||||
|
expect(ThemeRegistry.darkThemes().map((t) => t.id)).toEqual(['c']);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user