feat: add Theme API v2 with token compiler, skin slot
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
@@ -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 <style> tag from the colour block', () => {
|
||||
injectThemeCSS(':root { --color-primary: red; }');
|
||||
injectThemeSkinCSS('button { padding: 4px; }', 'thunderbird');
|
||||
expect(document.getElementById('active-theme')).not.toBeNull();
|
||||
expect(document.getElementById('active-theme-skin')).not.toBeNull();
|
||||
expect(document.getElementById('active-theme-skin')?.textContent).toContain('button');
|
||||
});
|
||||
|
||||
it('sets data-theme-skin on body to the active theme id', () => {
|
||||
injectThemeSkinCSS('button { padding: 4px; }', 'my-theme');
|
||||
expect(document.body.getAttribute('data-theme-skin')).toBe('my-theme');
|
||||
});
|
||||
|
||||
it('removes the skin tag and body attribute on remove', () => {
|
||||
injectThemeSkinCSS('button { padding: 4px; }', 'my-theme');
|
||||
removeThemeSkinCSS();
|
||||
expect(document.getElementById('active-theme-skin')).toBeNull();
|
||||
expect(document.body.getAttribute('data-theme-skin')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not throw when removing without a prior inject', () => {
|
||||
expect(() => removeThemeSkinCSS()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeSkinCSS', () => {
|
||||
it('preserves component-level selectors', () => {
|
||||
const css = '[data-tour="email-list"] { font-size: 13px; } button { padding: 4px; }';
|
||||
const { css: cleaned, warnings } = sanitizeSkinCSS(css);
|
||||
expect(cleaned).toBe(css);
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('strips dangerous patterns', () => {
|
||||
const { css: cleaned, warnings } = sanitizeSkinCSS(
|
||||
'@import url("https://x.com/p.css"); button { background: javascript:alert(1); }',
|
||||
);
|
||||
expect(cleaned).not.toContain('@import');
|
||||
expect(cleaned).not.toContain('javascript:');
|
||||
expect(warnings.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('strips @charset and @namespace', () => {
|
||||
const { css: cleaned, warnings } = sanitizeSkinCSS(
|
||||
'@charset "utf-8"; @namespace url(http://www.w3.org/1999/xhtml); button { padding: 4px; }',
|
||||
);
|
||||
expect(cleaned).not.toContain('@charset');
|
||||
expect(cleaned).not.toContain('@namespace');
|
||||
expect(cleaned).toContain('button');
|
||||
expect(warnings.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateThemeCSSSafety', () => {
|
||||
it('accepts valid theme CSS', () => {
|
||||
const css = ':root { --color-primary: #3b82f6; --color-background: #fff; }';
|
||||
|
||||
@@ -365,6 +365,18 @@ export const themeHooks = {
|
||||
onThemeChange: new HookBus(),
|
||||
onCustomThemeChange: new HookBus(),
|
||||
onLocaleChange: new HookBus(),
|
||||
/**
|
||||
* Transform hook fired immediately before a theme's compiled CSS is
|
||||
* injected into the document.
|
||||
*
|
||||
* handler(css: string, ctx: { themeId: string | null; variant: 'light' | 'dark' }): string | undefined
|
||||
*
|
||||
* Return a new CSS string to override what gets injected, or `undefined`
|
||||
* to pass through unchanged. Use this to inject extra `@font-face` rules,
|
||||
* patch a third-party theme's variables for accessibility, or implement
|
||||
* site-wide design-token overrides.
|
||||
*/
|
||||
onThemeBeforeApply: new HookBus(),
|
||||
};
|
||||
|
||||
// §7.15 Toast Hooks
|
||||
|
||||
+18
-1
@@ -1,9 +1,11 @@
|
||||
// IndexedDB storage for plugin/theme binary blobs (JS bundles, CSS, previews)
|
||||
|
||||
const DB_NAME = 'bulwark-plugins';
|
||||
const DB_VERSION = 1;
|
||||
// Bumped to 2 to add the theme-skin store; existing stores are preserved.
|
||||
const DB_VERSION = 2;
|
||||
const STORE_PLUGINS = 'plugin-code';
|
||||
const STORE_THEMES = 'theme-css';
|
||||
const STORE_THEME_SKINS = 'theme-skin';
|
||||
const STORE_PREVIEWS = 'previews';
|
||||
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
@@ -18,6 +20,9 @@ function openDB(): Promise<IDBDatabase> {
|
||||
if (!db.objectStoreNames.contains(STORE_THEMES)) {
|
||||
db.createObjectStore(STORE_THEMES);
|
||||
}
|
||||
if (!db.objectStoreNames.contains(STORE_THEME_SKINS)) {
|
||||
db.createObjectStore(STORE_THEME_SKINS);
|
||||
}
|
||||
if (!db.objectStoreNames.contains(STORE_PREVIEWS)) {
|
||||
db.createObjectStore(STORE_PREVIEWS);
|
||||
}
|
||||
@@ -83,6 +88,18 @@ export const pluginStorage = {
|
||||
await deleteItem(STORE_THEMES, themeId);
|
||||
},
|
||||
|
||||
// Theme skin CSS — separate store so it can be present/absent independently
|
||||
// of the colour-token CSS (e.g. some v2 themes ship colours only).
|
||||
async saveThemeSkin(themeId: string, skin: string): Promise<void> {
|
||||
await putItem(STORE_THEME_SKINS, themeId, skin);
|
||||
},
|
||||
async getThemeSkin(themeId: string): Promise<string | null> {
|
||||
return getItem<string>(STORE_THEME_SKINS, themeId);
|
||||
},
|
||||
async deleteThemeSkin(themeId: string): Promise<void> {
|
||||
await deleteItem(STORE_THEME_SKINS, themeId);
|
||||
},
|
||||
|
||||
// Preview images (stored as data URIs)
|
||||
async savePreview(id: string, dataUri: string): Promise<void> {
|
||||
await putItem(STORE_PREVIEWS, id, dataUri);
|
||||
|
||||
+76
-2
@@ -11,6 +11,41 @@ export type ThemeVariant = 'light' | 'dark';
|
||||
|
||||
// ─── Manifests ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Advanced theme fields ("Theme API v2"). All optional and additive — a
|
||||
* legacy theme that ships only `:root`/`.dark` CSS continues to work.
|
||||
*
|
||||
* When `apiVersion >= 2` (or any of `tokens`/`extends`/`derive`/`density`/
|
||||
* `radii`/`typography` is present), the theme compiler runs at install time
|
||||
* and produces a single CSS string from the structured fields, optionally
|
||||
* concatenated with a hand-written `theme.css` for fine-grained overrides.
|
||||
*/
|
||||
export interface ThemeTokenSet {
|
||||
/** Tokens applied regardless of variant (emitted into `:root`). */
|
||||
common?: Record<string, string>;
|
||||
/** Tokens applied in light mode (emitted into `:root`). */
|
||||
light?: Record<string, string>;
|
||||
/** Tokens applied in dark mode (emitted into `.dark`). */
|
||||
dark?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type ThemeDensity = 'compact' | 'normal' | 'touch';
|
||||
|
||||
export interface ThemeRadii {
|
||||
sm?: string;
|
||||
md?: string;
|
||||
lg?: string;
|
||||
xl?: string;
|
||||
full?: string;
|
||||
}
|
||||
|
||||
export interface ThemeTypography {
|
||||
fontSans?: string;
|
||||
fontMono?: string;
|
||||
fontDisplay?: string;
|
||||
baseFontSize?: string;
|
||||
}
|
||||
|
||||
export interface ThemeManifest {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -21,6 +56,22 @@ export interface ThemeManifest {
|
||||
preview?: string;
|
||||
variants: ThemeVariant[];
|
||||
minAppVersion?: string;
|
||||
|
||||
// ─── Advanced (Theme API v2) ─────────────────────────────────
|
||||
/** Theme API version. Defaults to 1 (raw-CSS only). */
|
||||
apiVersion?: 1 | 2;
|
||||
/** Inherit tokens/CSS from another installed (or built-in) theme by id. */
|
||||
extends?: string;
|
||||
/** Structured colour tokens — compiled into CSS at install time. */
|
||||
tokens?: ThemeTokenSet;
|
||||
/** When true, missing standard tokens are derived (e.g. *-foreground from contrast). */
|
||||
derive?: boolean;
|
||||
/** Default UI density preset (compact / normal / touch). */
|
||||
density?: ThemeDensity;
|
||||
/** Border-radius scale, emitted as `--radius-*` vars. */
|
||||
radii?: ThemeRadii;
|
||||
/** Font stacks + base size, emitted as `--font-*` vars. */
|
||||
typography?: ThemeTypography;
|
||||
}
|
||||
|
||||
export interface PluginManifest {
|
||||
@@ -70,12 +121,29 @@ export interface InstalledTheme {
|
||||
author: string;
|
||||
description: string;
|
||||
preview?: string; // data: URI or blob URL
|
||||
css: string; // raw CSS text
|
||||
css: string; // compiled CSS text — what gets injected
|
||||
/**
|
||||
* Optional "skin" CSS shipped by Theme API v2 themes that need to restyle
|
||||
* actual UI components (toolbars, lists, buttons, etc.) — not just colour
|
||||
* tokens. Injected into a separate `<style>` tag so it can be stripped
|
||||
* cleanly when the theme is deactivated. Stored in IndexedDB with the same
|
||||
* lifecycle as `css` to keep localStorage small.
|
||||
*/
|
||||
skin?: string;
|
||||
variants: ThemeVariant[];
|
||||
enabled: boolean;
|
||||
builtIn: boolean;
|
||||
managed?: boolean;
|
||||
forceEnabled?: boolean;
|
||||
|
||||
// ─── Advanced (Theme API v2) ─ carried over from the manifest ─
|
||||
apiVersion?: 1 | 2;
|
||||
extends?: string;
|
||||
tokens?: ThemeTokenSet;
|
||||
derive?: boolean;
|
||||
density?: ThemeDensity;
|
||||
radii?: ThemeRadii;
|
||||
typography?: ThemeTypography;
|
||||
}
|
||||
|
||||
export interface InstalledPlugin {
|
||||
@@ -527,7 +595,13 @@ export const IMPLICIT_PERMISSIONS: Permission[] = ['ui:observe', 'app:lifecycle'
|
||||
// ─── Validation ──────────────────────────────────────────────
|
||||
|
||||
export const MAX_PLUGIN_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||
export const MAX_THEME_SIZE = 1 * 1024 * 1024; // 1 MB
|
||||
export const MAX_THEME_SIZE = 2 * 1024 * 1024; // 2 MB (was 1 MB; v2 themes may ship a skin.css)
|
||||
/**
|
||||
* Maximum size of an individual `skin.css` payload after extraction.
|
||||
* Skins are component-level CSS, not images — anything bigger than this is
|
||||
* almost certainly bundling assets the validator will refuse anyway.
|
||||
*/
|
||||
export const MAX_THEME_SKIN_BYTES = 256 * 1024; // 256 KB
|
||||
|
||||
export const ALLOWED_PLUGIN_FILES = new Set([
|
||||
'.js', '.mjs', '.css', '.json', '.png', '.svg', '.woff2', '.jpg', '.jpeg', '.webp',
|
||||
|
||||
+91
-14
@@ -8,9 +8,11 @@ import {
|
||||
ALL_PERMISSIONS,
|
||||
MAX_PLUGIN_SIZE,
|
||||
MAX_THEME_SIZE,
|
||||
MAX_THEME_SKIN_BYTES,
|
||||
ALLOWED_PLUGIN_FILES,
|
||||
} from './plugin-types';
|
||||
import { sanitizeThemeCSS, validateThemeCSSSafety } from './theme-loader';
|
||||
import { sanitizeThemeCSS, sanitizeSkinCSS, validateThemeCSSSafety } from './theme-loader';
|
||||
import { compileAdvancedTheme, isAdvancedManifest } from './theme-compiler';
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
@@ -21,6 +23,11 @@ export interface ValidationResult {
|
||||
export interface ThemeExtractionResult extends ValidationResult {
|
||||
manifest: ThemeManifest | null;
|
||||
css: string;
|
||||
/**
|
||||
* Optional skin CSS — component-level overrides extracted from `skin.css`.
|
||||
* Only populated for Theme API v2 manifests; v1 themes ignore the file.
|
||||
*/
|
||||
skin: string | null;
|
||||
preview: string | null; // data URI
|
||||
}
|
||||
|
||||
@@ -63,6 +70,29 @@ function validateThemeManifest(manifest: Record<string, unknown>): { result: The
|
||||
if (!valid) errors.push('Variants must be "light" or "dark"');
|
||||
}
|
||||
|
||||
// ── Theme API v2 fields (all optional) ──
|
||||
if (manifest.apiVersion !== undefined && manifest.apiVersion !== 1 && manifest.apiVersion !== 2) {
|
||||
errors.push('"apiVersion" must be 1 or 2 if present');
|
||||
}
|
||||
if (manifest.extends !== undefined && typeof manifest.extends !== 'string') {
|
||||
errors.push('"extends" must be a string (the parent theme id)');
|
||||
}
|
||||
if (manifest.tokens !== undefined && (typeof manifest.tokens !== 'object' || manifest.tokens === null)) {
|
||||
errors.push('"tokens" must be an object with optional "common"/"light"/"dark" maps');
|
||||
}
|
||||
if (manifest.density !== undefined && !['compact', 'normal', 'touch'].includes(manifest.density as string)) {
|
||||
errors.push('"density" must be "compact", "normal", or "touch"');
|
||||
}
|
||||
if (manifest.derive !== undefined && typeof manifest.derive !== 'boolean') {
|
||||
errors.push('"derive" must be a boolean');
|
||||
}
|
||||
if (manifest.radii !== undefined && (typeof manifest.radii !== 'object' || manifest.radii === null)) {
|
||||
errors.push('"radii" must be an object');
|
||||
}
|
||||
if (manifest.typography !== undefined && (typeof manifest.typography !== 'object' || manifest.typography === null)) {
|
||||
errors.push('"typography" must be an object');
|
||||
}
|
||||
|
||||
if (errors.length > 0) return { result: null, errors };
|
||||
|
||||
return {
|
||||
@@ -157,7 +187,12 @@ export async function extractTheme(file: File): Promise<ThemeExtractionResult> {
|
||||
|
||||
// Size check
|
||||
if (file.size > MAX_THEME_SIZE) {
|
||||
return { valid: false, errors: ['Theme ZIP exceeds 1 MB size limit'], warnings: [], manifest: null, css: '', preview: null };
|
||||
return {
|
||||
valid: false,
|
||||
errors: [`Theme ZIP exceeds ${Math.round(MAX_THEME_SIZE / (1024 * 1024))} MB size limit`],
|
||||
warnings: [],
|
||||
manifest: null, css: '', skin: null, preview: null,
|
||||
};
|
||||
}
|
||||
|
||||
let zip: JSZip;
|
||||
@@ -165,7 +200,7 @@ export async function extractTheme(file: File): Promise<ThemeExtractionResult> {
|
||||
const buffer = await file.arrayBuffer();
|
||||
zip = await JSZip.loadAsync(buffer);
|
||||
} catch {
|
||||
return { valid: false, errors: ['Invalid ZIP file'], warnings: [], manifest: null, css: '', preview: null };
|
||||
return { valid: false, errors: ['Invalid ZIP file'], warnings: [], manifest: null, css: '', skin: null, preview: null };
|
||||
}
|
||||
|
||||
const root = findZipRoot(zip);
|
||||
@@ -173,7 +208,7 @@ export async function extractTheme(file: File): Promise<ThemeExtractionResult> {
|
||||
// Read manifest
|
||||
const manifestFile = zip.file(root + 'manifest.json');
|
||||
if (!manifestFile) {
|
||||
return { valid: false, errors: ['Missing manifest.json'], warnings: [], manifest: null, css: '', preview: null };
|
||||
return { valid: false, errors: ['Missing manifest.json'], warnings: [], manifest: null, css: '', skin: null, preview: null };
|
||||
}
|
||||
|
||||
let manifestData: Record<string, unknown>;
|
||||
@@ -181,28 +216,49 @@ export async function extractTheme(file: File): Promise<ThemeExtractionResult> {
|
||||
const raw = await manifestFile.async('string');
|
||||
manifestData = JSON.parse(raw);
|
||||
} catch {
|
||||
return { valid: false, errors: ['Invalid manifest.json (not valid JSON)'], warnings: [], manifest: null, css: '', preview: null };
|
||||
return { valid: false, errors: ['Invalid manifest.json (not valid JSON)'], warnings: [], manifest: null, css: '', skin: null, preview: null };
|
||||
}
|
||||
|
||||
const { result: manifest, errors: manifestErrors } = validateThemeManifest(manifestData);
|
||||
errors.push(...manifestErrors);
|
||||
if (!manifest) {
|
||||
return { valid: false, errors, warnings, manifest: null, css: '', preview: null };
|
||||
return { valid: false, errors, warnings, manifest: null, css: '', skin: null, preview: null };
|
||||
}
|
||||
|
||||
// Read theme.css
|
||||
// Read theme.css — required for v1 themes, optional when the manifest
|
||||
// declares Theme API v2 fields (tokens/extends/derive/density/radii/typography),
|
||||
// since the compiler can produce CSS purely from the manifest.
|
||||
const cssFile = zip.file(root + 'theme.css');
|
||||
if (!cssFile) {
|
||||
const isAdvanced = isAdvancedManifest(manifest);
|
||||
|
||||
let userCSS = '';
|
||||
if (cssFile) {
|
||||
userCSS = await cssFile.async('string');
|
||||
const safety = validateThemeCSSSafety(userCSS);
|
||||
if (!safety.valid) {
|
||||
// Sanitize instead of rejecting
|
||||
const sanitized = sanitizeThemeCSS(userCSS);
|
||||
userCSS = sanitized.css;
|
||||
warnings.push(...sanitized.warnings);
|
||||
}
|
||||
} else if (!isAdvanced) {
|
||||
errors.push('Missing theme.css');
|
||||
return { valid: false, errors, warnings, manifest, css: '', preview: null };
|
||||
return { valid: false, errors, warnings, manifest, css: '', skin: null, preview: null };
|
||||
}
|
||||
|
||||
let rawCSS = await cssFile.async('string');
|
||||
// Compile advanced tokens into CSS (for v2 manifests). The compiled output
|
||||
// is concatenated with any user-supplied theme.css for fine-grained overrides.
|
||||
let rawCSS = userCSS;
|
||||
if (isAdvanced) {
|
||||
const compiled = compileAdvancedTheme(manifest, { userCSS });
|
||||
if (compiled.errors.length > 0) {
|
||||
errors.push(...compiled.errors);
|
||||
return { valid: false, errors, warnings, manifest, css: '', skin: null, preview: null };
|
||||
}
|
||||
warnings.push(...compiled.warnings);
|
||||
rawCSS = compiled.css;
|
||||
|
||||
// Validate CSS safety
|
||||
const safety = validateThemeCSSSafety(rawCSS);
|
||||
if (!safety.valid) {
|
||||
// Sanitize instead of rejecting
|
||||
// Run sanitizer over the final compiled output as a defence-in-depth check.
|
||||
const sanitized = sanitizeThemeCSS(rawCSS);
|
||||
rawCSS = sanitized.css;
|
||||
warnings.push(...sanitized.warnings);
|
||||
@@ -222,12 +278,33 @@ export async function extractTheme(file: File): Promise<ThemeExtractionResult> {
|
||||
}
|
||||
}
|
||||
|
||||
// Read skin.css if present (Theme API v2 only). Skins target real
|
||||
// component selectors and bypass the strict :root/.dark selector check —
|
||||
// they still go through the dangerous-pattern sanitizer.
|
||||
let skin: string | null = null;
|
||||
const skinFile = zip.file(root + 'skin.css');
|
||||
if (skinFile) {
|
||||
if (!isAdvanced) {
|
||||
warnings.push('skin.css ignored — only Theme API v2 manifests can ship a skin');
|
||||
} else {
|
||||
const rawSkin = await skinFile.async('string');
|
||||
if (rawSkin.length > MAX_THEME_SKIN_BYTES) {
|
||||
warnings.push(`skin.css exceeds ${Math.round(MAX_THEME_SKIN_BYTES / 1024)} KB and was dropped`);
|
||||
} else {
|
||||
const sanitized = sanitizeSkinCSS(rawSkin);
|
||||
skin = sanitized.css;
|
||||
warnings.push(...sanitized.warnings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
manifest,
|
||||
css: rawCSS,
|
||||
skin,
|
||||
preview,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
// Advanced Theme API v2 — compiles structured manifest fields (tokens,
|
||||
// radii, typography, density, extends) into a single CSS string that the
|
||||
// existing `injectThemeCSS` pipeline can apply unchanged.
|
||||
|
||||
import type {
|
||||
ThemeDensity,
|
||||
ThemeManifest,
|
||||
ThemeRadii,
|
||||
ThemeTokenSet,
|
||||
ThemeTypography,
|
||||
} from './plugin-types';
|
||||
import { getLuminance, parseColor } from './color-transform';
|
||||
|
||||
export interface CompiledTheme {
|
||||
css: string;
|
||||
warnings: string[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard tokens whose `*-foreground` counterpart can be auto-derived from
|
||||
* contrast when `derive: true` and only the base colour is supplied.
|
||||
*/
|
||||
const DERIVE_PAIRS: Array<[base: string, fg: string]> = [
|
||||
['primary', 'primary-foreground'],
|
||||
['secondary', 'secondary-foreground'],
|
||||
['muted', 'muted-foreground'],
|
||||
['accent', 'accent-foreground'],
|
||||
['destructive', 'destructive-foreground'],
|
||||
['popover', 'popover-foreground'],
|
||||
['card', 'card-foreground'],
|
||||
['sidebar', 'sidebar-foreground'],
|
||||
['success', 'success-foreground'],
|
||||
['warning', 'warning-foreground'],
|
||||
['info', 'info-foreground'],
|
||||
];
|
||||
|
||||
/** Pick a foreground colour (white or near-black) by background luminance. */
|
||||
function pickForeground(bg: string): string {
|
||||
const rgb = parseColor(bg);
|
||||
if (!rgb) return '#ffffff';
|
||||
return getLuminance(rgb.r, rgb.g, rgb.b) >= 0.55 ? '#0f172a' : '#ffffff';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a manifest token key to a fully-qualified CSS custom property:
|
||||
* "primary" → "--color-primary"
|
||||
* "color-primary" → "--color-primary"
|
||||
* "--color-primary" → "--color-primary"
|
||||
* "font-sans" → "--font-sans"
|
||||
*/
|
||||
const PREFIXED_NAMESPACES = ['color-', 'font-', 'radius-', 'density-'];
|
||||
function tokenName(key: string): string {
|
||||
if (key.startsWith('--')) return key;
|
||||
if (PREFIXED_NAMESPACES.some((ns) => key.startsWith(ns))) return `--${key}`;
|
||||
return `--color-${key}`;
|
||||
}
|
||||
|
||||
function emitTokens(
|
||||
tokens: Record<string, string>,
|
||||
derive: boolean,
|
||||
): { lines: string[]; warnings: string[] } {
|
||||
const warnings: string[] = [];
|
||||
const expanded: Record<string, string> = { ...tokens };
|
||||
|
||||
if (derive) {
|
||||
for (const [base, fg] of DERIVE_PAIRS) {
|
||||
if (expanded[base] && !expanded[fg]) {
|
||||
expanded[fg] = pickForeground(expanded[base]);
|
||||
}
|
||||
}
|
||||
// Common alias: --color-foreground used as page text colour.
|
||||
if (expanded.background && !expanded.foreground) {
|
||||
expanded.foreground = pickForeground(expanded.background);
|
||||
}
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const [rawKey, value] of Object.entries(expanded)) {
|
||||
if (typeof value !== 'string' || !value.trim()) continue;
|
||||
if (!isSafeTokenKey(rawKey)) {
|
||||
warnings.push(`Token "${rawKey}" dropped — invalid key (only [a-z0-9-] allowed)`);
|
||||
continue;
|
||||
}
|
||||
if (!isSafeTokenValue(value)) {
|
||||
warnings.push(`Token "${rawKey}" dropped — value contains unsafe characters`);
|
||||
continue;
|
||||
}
|
||||
lines.push(` ${tokenName(rawKey)}: ${value.trim()};`);
|
||||
}
|
||||
return { lines, warnings };
|
||||
}
|
||||
|
||||
const SAFE_KEY_PATTERN = /^(--)?[a-z][a-z0-9-]*$/;
|
||||
function isSafeTokenKey(key: string): boolean {
|
||||
return SAFE_KEY_PATTERN.test(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Token values are emitted verbatim into CSS, so they must not contain
|
||||
* anything that could break out of the declaration (`{`, `}`, `;`,
|
||||
* `<`/`>`) or pull in remote/scripted content.
|
||||
*/
|
||||
function isSafeTokenValue(value: string): boolean {
|
||||
if (/[{}<>]/.test(value)) return false;
|
||||
if (value.includes(';')) return false;
|
||||
if (/url\s*\(\s*['"]?(https?|data|javascript):/i.test(value)) return false;
|
||||
if (/expression\s*\(/i.test(value)) return false;
|
||||
if (/-moz-binding/i.test(value)) return false;
|
||||
if (/javascript\s*:/i.test(value)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function emitRadii(radii: ThemeRadii): string[] {
|
||||
const out: string[] = [];
|
||||
for (const [k, v] of Object.entries(radii)) {
|
||||
if (typeof v === 'string' && isSafeTokenValue(v)) {
|
||||
out.push(` --radius-${k}: ${v.trim()};`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function emitTypography(typography: ThemeTypography): string[] {
|
||||
const out: string[] = [];
|
||||
if (typography.fontSans && isSafeTokenValue(typography.fontSans)) {
|
||||
out.push(` --font-sans: ${typography.fontSans.trim()};`);
|
||||
}
|
||||
if (typography.fontMono && isSafeTokenValue(typography.fontMono)) {
|
||||
out.push(` --font-mono: ${typography.fontMono.trim()};`);
|
||||
}
|
||||
if (typography.fontDisplay && isSafeTokenValue(typography.fontDisplay)) {
|
||||
out.push(` --font-display: ${typography.fontDisplay.trim()};`);
|
||||
}
|
||||
if (typography.baseFontSize && isSafeTokenValue(typography.baseFontSize)) {
|
||||
out.push(` --font-size-base: ${typography.baseFontSize.trim()};`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const DENSITY_VARS: Record<ThemeDensity, Record<string, string>> = {
|
||||
compact: {
|
||||
'--density-row-height': '28px',
|
||||
'--density-control-height': '28px',
|
||||
'--density-spacing-1': '2px',
|
||||
'--density-spacing-2': '4px',
|
||||
'--density-spacing-3': '6px',
|
||||
},
|
||||
normal: {
|
||||
'--density-row-height': '36px',
|
||||
'--density-control-height': '32px',
|
||||
'--density-spacing-1': '4px',
|
||||
'--density-spacing-2': '8px',
|
||||
'--density-spacing-3': '12px',
|
||||
},
|
||||
touch: {
|
||||
'--density-row-height': '44px',
|
||||
'--density-control-height': '40px',
|
||||
'--density-spacing-1': '6px',
|
||||
'--density-spacing-2': '12px',
|
||||
'--density-spacing-3': '18px',
|
||||
},
|
||||
};
|
||||
|
||||
function emitDensity(density: ThemeDensity): string[] {
|
||||
return Object.entries(DENSITY_VARS[density]).map(([k, v]) => ` ${k}: ${v};`);
|
||||
}
|
||||
|
||||
export interface CompileOptions {
|
||||
/**
|
||||
* Resolves a `extends: <id>` chain to that base theme's compiled CSS.
|
||||
* Implementations should return null for unknown ids; circular refs are
|
||||
* the caller's problem (we don't recurse — just one level of inheritance).
|
||||
*/
|
||||
resolveExtends?: (id: string) => string | null;
|
||||
/**
|
||||
* Optional hand-written CSS appended after compiled tokens. Use this for
|
||||
* the rare overrides the structured API can't express (extra `@font-face`,
|
||||
* `@keyframes`, `@media (prefers-contrast)` blocks, etc.).
|
||||
*/
|
||||
userCSS?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile an advanced theme manifest into a single safe CSS string.
|
||||
*
|
||||
* Output layout:
|
||||
* 1. parent (extends) CSS, if any
|
||||
* 2. `:root { common + light + radii + typography + density }`
|
||||
* 3. `.dark { common + dark }` (only when the theme declares a dark variant)
|
||||
* 4. user-supplied `theme.css` content (sanitized upstream)
|
||||
*
|
||||
* The compiler never emits selectors other than `:root` and `.dark`, so the
|
||||
* existing CSS sanitizer/selector validator continues to apply.
|
||||
*/
|
||||
export function compileAdvancedTheme(
|
||||
manifest: ThemeManifest,
|
||||
opts: CompileOptions = {},
|
||||
): CompiledTheme {
|
||||
const warnings: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!isAdvancedManifest(manifest)) {
|
||||
return { css: '', warnings, errors: ['Manifest does not declare any advanced theme fields'] };
|
||||
}
|
||||
|
||||
const tokens: ThemeTokenSet = manifest.tokens ?? {};
|
||||
const derive = manifest.derive === true;
|
||||
const wantsDark = manifest.variants.includes('dark');
|
||||
const wantsLight = manifest.variants.includes('light');
|
||||
|
||||
const sections: string[] = [];
|
||||
|
||||
// 1. extends — prepend parent CSS verbatim
|
||||
if (manifest.extends && opts.resolveExtends) {
|
||||
const parentCSS = opts.resolveExtends(manifest.extends);
|
||||
if (parentCSS == null) {
|
||||
warnings.push(`extends: parent theme "${manifest.extends}" not found — skipping`);
|
||||
} else {
|
||||
sections.push(`/* inherited from ${manifest.extends} */\n${parentCSS}`);
|
||||
}
|
||||
} else if (manifest.extends) {
|
||||
warnings.push(`extends: no resolver provided — "${manifest.extends}" ignored`);
|
||||
}
|
||||
|
||||
// 2. :root block (light + common + structural)
|
||||
const rootLines: string[] = [];
|
||||
|
||||
if (tokens.common) {
|
||||
const { lines, warnings: w } = emitTokens(tokens.common, derive);
|
||||
rootLines.push(...lines);
|
||||
warnings.push(...w);
|
||||
}
|
||||
if (wantsLight && tokens.light) {
|
||||
const { lines, warnings: w } = emitTokens(tokens.light, derive);
|
||||
rootLines.push(...lines);
|
||||
warnings.push(...w);
|
||||
}
|
||||
if (manifest.radii) rootLines.push(...emitRadii(manifest.radii));
|
||||
if (manifest.typography) rootLines.push(...emitTypography(manifest.typography));
|
||||
if (manifest.density) rootLines.push(...emitDensity(manifest.density));
|
||||
|
||||
if (rootLines.length > 0) {
|
||||
sections.push(`:root {\n${rootLines.join('\n')}\n}`);
|
||||
}
|
||||
|
||||
// 3. .dark block
|
||||
if (wantsDark) {
|
||||
const darkLines: string[] = [];
|
||||
if (tokens.common) {
|
||||
const { lines, warnings: w } = emitTokens(tokens.common, derive);
|
||||
darkLines.push(...lines);
|
||||
warnings.push(...w);
|
||||
}
|
||||
if (tokens.dark) {
|
||||
const { lines, warnings: w } = emitTokens(tokens.dark, derive);
|
||||
darkLines.push(...lines);
|
||||
warnings.push(...w);
|
||||
}
|
||||
if (darkLines.length > 0) {
|
||||
sections.push(`.dark {\n${darkLines.join('\n')}\n}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. hand-written overrides
|
||||
if (opts.userCSS && opts.userCSS.trim()) {
|
||||
sections.push(`/* user overrides */\n${opts.userCSS.trim()}`);
|
||||
}
|
||||
|
||||
if (sections.length === 0) {
|
||||
errors.push('Compiled theme is empty — no tokens, radii, typography, or density supplied');
|
||||
}
|
||||
|
||||
return {
|
||||
css: sections.join('\n\n'),
|
||||
warnings,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* True if a manifest opts into Theme API v2 by setting `apiVersion: 2` or by
|
||||
* declaring any of the structured fields.
|
||||
*/
|
||||
export function isAdvancedManifest(manifest: ThemeManifest): boolean {
|
||||
return (
|
||||
manifest.apiVersion === 2 ||
|
||||
!!manifest.tokens ||
|
||||
!!manifest.extends ||
|
||||
!!manifest.derive ||
|
||||
!!manifest.density ||
|
||||
!!manifest.radii ||
|
||||
!!manifest.typography
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
import { DISALLOWED_CSS_PATTERNS } from './plugin-types';
|
||||
|
||||
const THEME_STYLE_ID = 'active-theme';
|
||||
const THEME_SKIN_STYLE_ID = 'active-theme-skin';
|
||||
const THEME_SKIN_BODY_ATTR = 'data-theme-skin';
|
||||
|
||||
/**
|
||||
* Sanitize theme CSS: strip dangerous patterns like @import, external url(),
|
||||
@@ -88,6 +90,67 @@ export function removeThemeCSS(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject a theme's *skin* CSS — component-level overrides shipped by Theme
|
||||
* API v2 themes via `skin.css`. Lives in a separate `<style>` tag so it can
|
||||
* be removed cleanly without touching the colour-token block, and is placed
|
||||
* AFTER the colour block so component rules win specificity.
|
||||
*
|
||||
* Also sets `body[data-theme-skin="<themeId>"]` so authors can scope their
|
||||
* own `:not(...)` overrides if they want belt-and-braces specificity.
|
||||
*/
|
||||
export function injectThemeSkinCSS(css: string, themeId: string): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
let styleEl = document.getElementById(THEME_SKIN_STYLE_ID) as HTMLStyleElement | null;
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement('style');
|
||||
styleEl.id = THEME_SKIN_STYLE_ID;
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
styleEl.textContent = css;
|
||||
|
||||
if (document.body) {
|
||||
document.body.setAttribute(THEME_SKIN_BODY_ATTR, themeId);
|
||||
}
|
||||
}
|
||||
|
||||
export function removeThemeSkinCSS(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
const styleEl = document.getElementById(THEME_SKIN_STYLE_ID);
|
||||
if (styleEl) styleEl.remove();
|
||||
if (document.body) document.body.removeAttribute(THEME_SKIN_BODY_ATTR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a theme *skin* — looser than `sanitizeThemeCSS` because skins
|
||||
* intentionally target real component selectors (toolbars, lists, buttons),
|
||||
* not just `:root`/`.dark`. The same script-injection / external-resource
|
||||
* prohibitions still apply.
|
||||
*/
|
||||
export function sanitizeSkinCSS(css: string): { css: string; warnings: string[] } {
|
||||
const warnings: string[] = [];
|
||||
let cleaned = css;
|
||||
|
||||
for (const pattern of DISALLOWED_CSS_PATTERNS) {
|
||||
if (pattern.test(cleaned)) {
|
||||
warnings.push(`Skin: removed disallowed pattern: ${pattern.source}`);
|
||||
cleaned = cleaned.replace(new RegExp(pattern.source, 'gi'), '/* [removed] */');
|
||||
}
|
||||
}
|
||||
|
||||
// `@import` is already covered by DISALLOWED_CSS_PATTERNS, but skins also
|
||||
// get an explicit no-`@charset`/`@namespace` policy so they can't change
|
||||
// how the host stylesheet parses subsequent rules.
|
||||
cleaned = cleaned.replace(/@(charset|namespace)\b[^;]*;?/gi, () => {
|
||||
warnings.push('Skin: removed @charset/@namespace directive');
|
||||
return '/* [removed] */';
|
||||
});
|
||||
|
||||
return { css: cleaned, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a theme CSS string is valid and safe.
|
||||
*/
|
||||
|
||||
+92
-4
@@ -2,11 +2,18 @@ import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { InstalledTheme, ThemeVariant } from '@/lib/plugin-types';
|
||||
import { pluginStorage } from '@/lib/plugin-storage';
|
||||
import { injectThemeCSS, removeThemeCSS, sanitizeThemeCSS } from '@/lib/theme-loader';
|
||||
import {
|
||||
injectThemeCSS,
|
||||
removeThemeCSS,
|
||||
sanitizeThemeCSS,
|
||||
injectThemeSkinCSS,
|
||||
removeThemeSkinCSS,
|
||||
} from '@/lib/theme-loader';
|
||||
import { extractTheme } from '@/lib/plugin-validator';
|
||||
import { BUILTIN_THEMES } from '@/lib/builtin-themes';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { themeHooks } from '@/lib/plugin-hooks';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
@@ -173,9 +180,22 @@ export const useThemeStore = create<ThemeState>()(
|
||||
return { success: false, error: result.errors.join('; '), warnings: result.warnings };
|
||||
}
|
||||
|
||||
const { manifest, css, preview } = result;
|
||||
const { manifest, css, skin, preview } = result;
|
||||
const { installedThemes } = get();
|
||||
|
||||
// Carry advanced (Theme API v2) fields from the manifest through
|
||||
// to the InstalledTheme so the activate/sync paths can re-compile
|
||||
// or re-apply tokens later if needed.
|
||||
const advancedFields = {
|
||||
apiVersion: manifest.apiVersion,
|
||||
extends: manifest.extends,
|
||||
tokens: manifest.tokens,
|
||||
derive: manifest.derive,
|
||||
density: manifest.density,
|
||||
radii: manifest.radii,
|
||||
typography: manifest.typography,
|
||||
};
|
||||
|
||||
// Check for duplicate
|
||||
if (installedThemes.some(t => t.id === manifest.id)) {
|
||||
// Update existing
|
||||
@@ -188,12 +208,19 @@ export const useThemeStore = create<ThemeState>()(
|
||||
description: manifest.description || '',
|
||||
preview: preview || undefined,
|
||||
css: sanitized.css,
|
||||
skin: skin ?? undefined,
|
||||
variants: manifest.variants,
|
||||
enabled: true,
|
||||
builtIn: false,
|
||||
...advancedFields,
|
||||
};
|
||||
|
||||
await pluginStorage.saveThemeCSS(manifest.id, sanitized.css);
|
||||
if (skin) {
|
||||
await pluginStorage.saveThemeSkin(manifest.id, skin);
|
||||
} else {
|
||||
await pluginStorage.deleteThemeSkin(manifest.id);
|
||||
}
|
||||
if (preview) await pluginStorage.savePreview(manifest.id, preview);
|
||||
|
||||
set({
|
||||
@@ -215,12 +242,15 @@ export const useThemeStore = create<ThemeState>()(
|
||||
description: manifest.description || '',
|
||||
preview: preview || undefined,
|
||||
css: sanitized.css,
|
||||
skin: skin ?? undefined,
|
||||
variants: manifest.variants,
|
||||
enabled: true,
|
||||
builtIn: false,
|
||||
...advancedFields,
|
||||
};
|
||||
|
||||
await pluginStorage.saveThemeCSS(manifest.id, sanitized.css);
|
||||
if (skin) await pluginStorage.saveThemeSkin(manifest.id, skin);
|
||||
if (preview) await pluginStorage.savePreview(manifest.id, preview);
|
||||
|
||||
set({ installedThemes: [...installedThemes, theme] });
|
||||
@@ -237,11 +267,13 @@ export const useThemeStore = create<ThemeState>()(
|
||||
// Deactivate if active
|
||||
if (activeThemeId === id) {
|
||||
removeThemeCSS();
|
||||
removeThemeSkinCSS();
|
||||
set({ activeThemeId: null });
|
||||
}
|
||||
|
||||
// Clean up storage
|
||||
pluginStorage.deleteThemeCSS(id);
|
||||
pluginStorage.deleteThemeSkin(id);
|
||||
pluginStorage.deletePreview(id);
|
||||
|
||||
set({
|
||||
@@ -264,6 +296,7 @@ export const useThemeStore = create<ThemeState>()(
|
||||
|
||||
if (id === null) {
|
||||
removeThemeCSS();
|
||||
removeThemeSkinCSS();
|
||||
set({ activeThemeId: null });
|
||||
return;
|
||||
}
|
||||
@@ -407,10 +440,11 @@ export const useThemeStore = create<ThemeState>()(
|
||||
partialize: (state) => ({
|
||||
theme: state.theme,
|
||||
activeThemeId: state.activeThemeId,
|
||||
// Store theme metadata but NOT full CSS (that goes in IndexedDB)
|
||||
// Store theme metadata but NOT full CSS / skin (those go in IndexedDB)
|
||||
installedThemes: state.installedThemes.map(t => ({
|
||||
...t,
|
||||
css: t.builtIn ? t.css : '', // only keep CSS for built-in themes
|
||||
skin: undefined, // skins also in IndexedDB
|
||||
preview: undefined, // previews also in IndexedDB
|
||||
})),
|
||||
}),
|
||||
@@ -434,14 +468,68 @@ export const useThemeStore = create<ThemeState>()(
|
||||
)
|
||||
);
|
||||
|
||||
/** Apply a custom theme's CSS, filtering to the appropriate variant */
|
||||
/**
|
||||
* Apply a custom theme's CSS, filtering to the appropriate variant.
|
||||
*
|
||||
* Fires the `themeHooks.onThemeBeforeApply` transform hook so plugins can
|
||||
* post-process the CSS (e.g. inject extra `@font-face` rules or override
|
||||
* specific tokens). The hook is fire-and-forget — we inject the original
|
||||
* CSS synchronously first to avoid a flash, then re-inject the transformed
|
||||
* version once handlers settle.
|
||||
*
|
||||
* If the theme also has a `skin` (Theme API v2 component-level overrides),
|
||||
* it's injected into a separate `<style>` tag after the colour block so
|
||||
* skin rules win specificity. The skin is hydrated lazily from IndexedDB
|
||||
* on first activation.
|
||||
*/
|
||||
function applyCustomThemeCSS(theme: InstalledTheme, resolvedTheme: 'light' | 'dark'): void {
|
||||
// If theme only supports one variant and current mode doesn't match, skip
|
||||
if (!theme.variants.includes(resolvedTheme as ThemeVariant)) {
|
||||
removeThemeCSS();
|
||||
removeThemeSkinCSS();
|
||||
return;
|
||||
}
|
||||
injectThemeCSS(theme.css);
|
||||
|
||||
// Apply skin if the theme ships one. Hydrate from IndexedDB if the cached
|
||||
// copy was stripped from localStorage on persist.
|
||||
if (theme.skin) {
|
||||
injectThemeSkinCSS(theme.skin, theme.id);
|
||||
} else if (theme.apiVersion === 2) {
|
||||
pluginStorage.getThemeSkin(theme.id).then((skin) => {
|
||||
// Bail out if the user switched themes mid-flight.
|
||||
if (useThemeStore.getState().activeThemeId !== theme.id) return;
|
||||
if (skin) {
|
||||
injectThemeSkinCSS(skin, theme.id);
|
||||
useThemeStore.setState((state) => ({
|
||||
installedThemes: state.installedThemes.map((it) =>
|
||||
it.id === theme.id ? { ...it, skin } : it,
|
||||
),
|
||||
}));
|
||||
} else {
|
||||
removeThemeSkinCSS();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
removeThemeSkinCSS();
|
||||
}
|
||||
|
||||
// Run plugin transforms asynchronously and re-inject if any handler
|
||||
// modified the CSS. No handlers → no extra work.
|
||||
if (themeHooks.onThemeBeforeApply.size === 0) return;
|
||||
const themeId = theme.id;
|
||||
themeHooks.onThemeBeforeApply
|
||||
.transform(theme.css, { themeId, variant: resolvedTheme })
|
||||
.then((transformed) => {
|
||||
// Bail out if the user switched themes while we were awaiting handlers.
|
||||
if (useThemeStore.getState().activeThemeId !== themeId) return;
|
||||
if (transformed && transformed !== theme.css) {
|
||||
injectThemeCSS(transformed);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Hook failures are tracked by the hook bus; nothing to do here.
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Server Theme Sync Helpers ───────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user