mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
feat(themes): free-form community themes with a security floor (#1015)
* feat(themes): free-form community themes with a security floor Community themes are no longer token-only. The in-app guard (validateThemeCss) now enforces only a security floor — no network (@import / non-data url()), no scripts (<style>/<script>/expression()/ javascript:/-moz-binding), no @property, @keyframes namespaced as <id>-, and a 256 KB cap — and otherwise allows any selectors, structure and animations. validateThemePackage checks the manifest plus that floor. Themes can react to app state via same-element attributes set on the theme root: data-playing, data-fullscreen, data-sidebar-collapsed, data-lyrics-open. The local-import confirm dialog now notes that imported themes aren't reviewed and are installed at the user's own risk. Removes the now-unused bundled token whitelist. * docs(themes): note free-form themes in the Theme Store entry Add PR #1015 and a free-form bullet to the still-unreleased Theme Store changelog and credits entry.
This commit is contained in:
+3
-2
@@ -56,11 +56,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Themes — community Theme Store
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1009](https://github.com/Psychotoxical/psysonic/pull/1009), [#1011](https://github.com/Psychotoxical/psysonic/pull/1011), [#1012](https://github.com/Psychotoxical/psysonic/pull/1012), [#1013](https://github.com/Psychotoxical/psysonic/pull/1013), [#1014](https://github.com/Psychotoxical/psysonic/pull/1014)**
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1009](https://github.com/Psychotoxical/psysonic/pull/1009), [#1011](https://github.com/Psychotoxical/psysonic/pull/1011), [#1012](https://github.com/Psychotoxical/psysonic/pull/1012), [#1013](https://github.com/Psychotoxical/psysonic/pull/1013), [#1014](https://github.com/Psychotoxical/psysonic/pull/1014), [#1015](https://github.com/Psychotoxical/psysonic/pull/1015)**
|
||||
|
||||
* New **Settings → Themes** tab: pick a theme, set the day/night scheduler, and browse a built-in **Theme Store** to install, update and uninstall community themes — with search, a dark/light filter, and full-size thumbnail previews.
|
||||
* The app now bundles six core themes (Catppuccin Mocha & Latte, Kanagawa Wave, Stark HUD, and the colour-blind-safe Vision Dark / Vision Navy); every other palette installs on demand from the [psysonic-themes](https://github.com/Psysonic/psysonic-themes) repo. Installed themes are saved locally and apply instantly at startup, even offline.
|
||||
* **Import a theme from a local `.zip`** (manifest.json + theme.css): the package is fully validated against the theme contract, you confirm its name and author, then it installs like any other community theme.
|
||||
* **Import a theme from a local `.zip`** (manifest.json + theme.css): the package is validated, you confirm its name and author, then it installs like any other community theme.
|
||||
* Themes are **free-form** — beyond recolouring, they can add their own styling and animations and react to playback / fullscreen / sidebar / lyrics state. A safety floor (no network, no scripts) is always enforced; store themes are reviewed, and imported themes install at your own risk.
|
||||
* The store paginates large catalogues, and refreshing the list no longer jumps back to the top.
|
||||
* Upgrading from an older build: an active or scheduled theme that has moved to the store and isn't installed falls back to Mocha (dark) / Latte (light).
|
||||
|
||||
|
||||
+21
@@ -1,5 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
import { usePlayerStore } from './store/playerStore';
|
||||
import { useLyricsStore } from './store/lyricsStore';
|
||||
import { useThemeStore } from './store/themeStore';
|
||||
import { useInstalledThemesStore } from './store/installedThemesStore';
|
||||
import { syncInjectedThemes } from './utils/themes/themeInjection';
|
||||
@@ -34,6 +36,25 @@ export default function App() {
|
||||
document.documentElement.setAttribute('data-theme', effectiveTheme);
|
||||
}, [effectiveTheme]);
|
||||
|
||||
// Expose app state on the theme root so themes can react to it with a
|
||||
// same-element compound selector, e.g. `[data-theme='x'][data-playing='true']`.
|
||||
// The set of allowed state attributes is the contract's `stateSelectors`.
|
||||
// (Sidebar-collapsed is set in AppShell, where that state lives.)
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-playing', isPlaying ? 'true' : 'false');
|
||||
}, [isPlaying]);
|
||||
|
||||
const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen);
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-fullscreen', isFullscreenOpen ? 'true' : 'false');
|
||||
}, [isFullscreenOpen]);
|
||||
|
||||
const lyricsOpen = useLyricsStore(s => s.activeTab === 'lyrics');
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-lyrics-open', lyricsOpen ? 'true' : 'false');
|
||||
}, [lyricsOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-font', font);
|
||||
}, [font]);
|
||||
|
||||
@@ -189,6 +189,13 @@ export function AppShell() {
|
||||
return () => window.removeEventListener('psy:toggle-sidebar', onToggleSidebar);
|
||||
}, [isSidebarCollapsed, setSidebarCollapsed]);
|
||||
|
||||
// Expose sidebar state on the theme root so themes can react with a
|
||||
// `[data-theme='x'][data-sidebar-collapsed='true']` compound (contract
|
||||
// `stateSelectors`). Other state attributes are set in App.tsx.
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-sidebar-collapsed', isSidebarCollapsed ? 'true' : 'false');
|
||||
}, [isSidebarCollapsed]);
|
||||
|
||||
// Workaround for WebKitGTK 2.50.x text-input repaint hang on
|
||||
// Linux Mint / Ubuntu 24.04 (issues #342, #782). When opted in,
|
||||
// nudge WebKit awake on every input/textarea focus via a sync
|
||||
|
||||
@@ -132,7 +132,7 @@ export function ThemeImportSection() {
|
||||
<ConfirmModal
|
||||
open={pending !== null}
|
||||
title={t('settings.themeImportConfirmTitle')}
|
||||
message={pending ? t('settings.themeImportConfirmBody', { name: pending.name, author: pending.author }) : ''}
|
||||
message={pending ? `${t('settings.themeImportConfirmBody', { name: pending.name, author: pending.author })} ${t('settings.themeImportConfirmRisk')}` : ''}
|
||||
confirmLabel={t('settings.themeStoreInstall')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
onConfirm={confirmInstall}
|
||||
|
||||
@@ -356,7 +356,7 @@ const CONTRIBUTOR_ENTRIES = [
|
||||
'Performance Probe: Monitor/Toggles redesign, live CPU/RSS/thread metrics, overlay pins and sparklines, macOS snapshots (PR #890)',
|
||||
'Performance Probe: opt-in thread-group CPU poll toggle and includeThreadGroups IPC fix (PR #891)',
|
||||
'Queue: switchable display mode — Queue (upcoming only) vs Playlist (full list), with header toggle and settings entry (PR #922)',
|
||||
'Community Theme Store: semantic-token refactor, dedicated Themes tab with day/night scheduler, install/update/uninstall, local .zip import with full contract validation, and 80+ palettes moved to an on-demand CDN repo (PR #1009, #1011, #1012, #1013, #1014)',
|
||||
'Community Theme Store: semantic-token refactor, dedicated Themes tab with day/night scheduler, install/update/uninstall, local .zip import, and free-form community themes (safety floor + state-reactive styling); 80+ palettes moved to an on-demand CDN repo (PR #1009, #1011, #1012, #1013, #1014, #1015)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -365,6 +365,7 @@ export const settings = {
|
||||
themeImportErrorDetails: 'Technische Details',
|
||||
themeImportConfirmTitle: 'Theme installieren?',
|
||||
themeImportConfirmBody: '„{{name}}“ von {{author}} installieren?',
|
||||
themeImportConfirmRisk: 'Importierte Themes werden nicht geprüft — installiere nur Themes, denen du vertraust.',
|
||||
tabLibrary: 'Bibliothek',
|
||||
tabServers: 'Server',
|
||||
tabLyrics: 'Songtexte',
|
||||
|
||||
@@ -432,6 +432,7 @@ export const settings = {
|
||||
themeImportErrorDetails: 'Technical details',
|
||||
themeImportConfirmTitle: 'Install theme?',
|
||||
themeImportConfirmBody: 'Install "{{name}}" by {{author}}?',
|
||||
themeImportConfirmRisk: "Imported themes aren't reviewed — only install themes you trust.",
|
||||
tabLibrary: 'Library',
|
||||
tabServers: 'Servers',
|
||||
tabLyrics: 'Lyrics',
|
||||
|
||||
@@ -363,6 +363,7 @@ export const settings = {
|
||||
themeImportErrorDetails: 'Detalles técnicos',
|
||||
themeImportConfirmTitle: '¿Instalar tema?',
|
||||
themeImportConfirmBody: '¿Instalar «{{name}}» de {{author}}?',
|
||||
themeImportConfirmRisk: 'Los temas importados no se revisan: instala solo temas en los que confíes.',
|
||||
tabLibrary: 'Biblioteca',
|
||||
tabServers: 'Servidores',
|
||||
tabLyrics: 'Letras',
|
||||
|
||||
@@ -361,6 +361,7 @@ export const settings = {
|
||||
themeImportErrorDetails: 'Détails techniques',
|
||||
themeImportConfirmTitle: 'Installer le thème ?',
|
||||
themeImportConfirmBody: 'Installer « {{name}} » de {{author}} ?',
|
||||
themeImportConfirmRisk: "Les thèmes importés ne sont pas vérifiés — n'installez que des thèmes de confiance.",
|
||||
tabLibrary: 'Bibliothèque',
|
||||
tabServers: 'Serveurs',
|
||||
tabLyrics: 'Paroles',
|
||||
|
||||
@@ -364,6 +364,7 @@ export const settings = {
|
||||
themeImportErrorDetails: 'Tekniske detaljer',
|
||||
themeImportConfirmTitle: 'Installere tema?',
|
||||
themeImportConfirmBody: 'Installere «{{name}}» av {{author}}?',
|
||||
themeImportConfirmRisk: 'Importerte temaer blir ikke gjennomgått — installer bare temaer du stoler på.',
|
||||
tabStorage: 'Frakoblet & Cache',
|
||||
inputKeybindingsTitle: 'Tastatursnarveier',
|
||||
aboutContributorsCount_one: '{{count}} bidrag',
|
||||
|
||||
@@ -361,6 +361,7 @@ export const settings = {
|
||||
themeImportErrorDetails: 'Technische details',
|
||||
themeImportConfirmTitle: 'Thema installeren?',
|
||||
themeImportConfirmBody: '"{{name}}" van {{author}} installeren?',
|
||||
themeImportConfirmRisk: 'Geïmporteerde thema’s worden niet gecontroleerd — installeer alleen thema’s die je vertrouwt.',
|
||||
tabLibrary: 'Bibliotheek',
|
||||
tabServers: 'Servers',
|
||||
tabLyrics: 'Songteksten',
|
||||
|
||||
@@ -367,6 +367,7 @@ export const settings = {
|
||||
themeImportErrorDetails: 'Detalii tehnice',
|
||||
themeImportConfirmTitle: 'Instalezi tema?',
|
||||
themeImportConfirmBody: 'Instalezi „{{name}}” de {{author}}?',
|
||||
themeImportConfirmRisk: 'Temele importate nu sunt verificate — instalează doar teme în care ai încredere.',
|
||||
tabLibrary: 'Librărie',
|
||||
tabServers: 'Servere',
|
||||
tabLyrics: 'Versuri',
|
||||
|
||||
@@ -443,6 +443,7 @@ export const settings = {
|
||||
themeImportErrorDetails: 'Технические подробности',
|
||||
themeImportConfirmTitle: 'Установить тему?',
|
||||
themeImportConfirmBody: 'Установить «{{name}}» от {{author}}?',
|
||||
themeImportConfirmRisk: 'Импортированные темы не проверяются — устанавливайте только темы, которым доверяете.',
|
||||
tabLibrary: 'Библиотека',
|
||||
tabServers: 'Серверы',
|
||||
tabLyrics: 'Тексты песен',
|
||||
|
||||
@@ -360,6 +360,7 @@ export const settings = {
|
||||
themeImportErrorDetails: '技术细节',
|
||||
themeImportConfirmTitle: '安装主题?',
|
||||
themeImportConfirmBody: '安装来自 {{author}} 的“{{name}}”?',
|
||||
themeImportConfirmRisk: '导入的主题未经审核——请仅安装你信任的主题。',
|
||||
tabLibrary: '媒体库',
|
||||
tabServers: '服务器',
|
||||
tabLyrics: '歌词',
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"frozenAt": "2026-06-05",
|
||||
"description": "The community Theme Store contract surface. A community theme.css may set ONLY these custom properties (plus color-scheme) on its single [data-theme='<id>'] selector. Frozen from the real token surface of the built-in themes (B0 semantic-token refactor). --ctp-* (Catppuccin palette) and the global design-system tokens (spacing/radii/elevation-shadows/transitions/fonts) are intentionally NOT part of the contract.",
|
||||
"colorScheme": {
|
||||
"required": true,
|
||||
"values": ["dark", "light"],
|
||||
"note": "Declared on the same selector (not a custom property). Must match manifest.mode. Drives OS-level form/scrollbar theming and the store dark/light filter."
|
||||
},
|
||||
"dataUriTokens": ["--select-arrow"],
|
||||
"core": {
|
||||
"--accent": "Primary accent / brand colour.",
|
||||
"--accent-dim": "Low-alpha accent for fills/backgrounds (e.g. selected row).",
|
||||
"--accent-glow": "Accent glow / focus halo colour.",
|
||||
"--accent-2": "Secondary accent — second stop of two-colour gradient flourishes.",
|
||||
"--bg-app": "Main app background.",
|
||||
"--bg-sidebar": "Sidebar / navigation background.",
|
||||
"--bg-card": "Card / panel surface.",
|
||||
"--bg-hover": "Hover / elevated surface.",
|
||||
"--bg-elevated": "Highest elevated surface.",
|
||||
"--bg-player": "Player bar background.",
|
||||
"--bg-deep": "Deepest background / gradient stop.",
|
||||
"--bg-glass": "Translucent glass background (use rgba — sits over blurred surfaces).",
|
||||
"--border": "Default border colour.",
|
||||
"--border-subtle": "Subtle / hairline border.",
|
||||
"--border-dropdown": "Border for dropdowns / popovers.",
|
||||
"--text-primary": "Primary text.",
|
||||
"--text-secondary": "Secondary text.",
|
||||
"--text-muted": "Muted / tertiary text.",
|
||||
"--text-on-accent": "Text/icon colour drawn on top of --accent.",
|
||||
"--danger": "Error / destructive.",
|
||||
"--positive": "Success / positive.",
|
||||
"--warning": "Warning.",
|
||||
"--highlight": "Highlight / rating colour.",
|
||||
"--select-arrow": "Dropdown chevron, an inline SVG data-URI. The one token whose value is url(data:...). Recolour by changing the stroke=%23RRGGBB hex inside the URI.",
|
||||
"--shadow-dropdown": "Drop-shadow colour for dropdowns/popovers (e.g. rgba(0,0,0,0.6))."
|
||||
},
|
||||
"optional": {
|
||||
"--volume-accent": "Volume slider fill (falls back to --accent).",
|
||||
"--waveform-played": "Waveform — played portion (falls back to --accent).",
|
||||
"--waveform-unplayed": "Waveform — unplayed portion.",
|
||||
"--waveform-buffered": "Waveform — buffered portion.",
|
||||
"--player-title": "Player-bar track title colour (falls back to --text-primary).",
|
||||
"--player-artist": "Player-bar artist colour (falls back to --text-secondary)."
|
||||
},
|
||||
"granular": {
|
||||
"$comment": "Optional per-region override tokens (added 2026-06-06). Each defaults to a base token in the app, so omitting them all is fine — a theme that sets only the core tokens still looks complete. Set one to recolour that region independently, e.g. give the sidebar its own hover without touching every other hover. Colour values only, same as everything else.",
|
||||
"--sidebar-item-hover": "Sidebar item hover background.",
|
||||
"--sidebar-item-active-bg": "Sidebar active item background.",
|
||||
"--sidebar-item-active-text": "Sidebar active item text/icon.",
|
||||
"--sidebar-text": "Sidebar item text (resting).",
|
||||
"--sidebar-trigger-bg": "Sidebar library-scope trigger background.",
|
||||
"--sidebar-border": "Sidebar borders/dividers.",
|
||||
"--player-control": "Player-bar transport icon (resting).",
|
||||
"--player-control-hover": "Player-bar transport icon (hover).",
|
||||
"--player-control-hover-bg": "Player-bar transport button hover background.",
|
||||
"--player-time-toggle-hover": "Player-bar time-toggle hover background.",
|
||||
"--player-time-toggle-active": "Player-bar time-toggle active background.",
|
||||
"--player-border": "Player-bar top border.",
|
||||
"--row-hover": "List/track row hover background.",
|
||||
"--row-playing-bg": "Now-playing row background.",
|
||||
"--row-playing-text": "Now-playing row title + indicator colour.",
|
||||
"--row-divider": "Row divider line.",
|
||||
"--row-text": "Row primary text (track title).",
|
||||
"--row-text-secondary": "Row secondary text (artist).",
|
||||
"--row-text-muted": "Row muted text (number, duration).",
|
||||
"--col-header-text": "List column-header text.",
|
||||
"--col-resize-active": "Column resize handle when active.",
|
||||
"--card-hover-border": "Album/artist card hover border.",
|
||||
"--card-title": "Card title text.",
|
||||
"--card-subtitle": "Card subtitle/meta text.",
|
||||
"--card-placeholder-bg": "Card cover placeholder background.",
|
||||
"--menu-bg": "Dropdown / context-menu / modal background.",
|
||||
"--menu-item-hover": "Menu item hover background.",
|
||||
"--menu-item-active-text": "Menu item active/selected text.",
|
||||
"--menu-divider": "Menu divider line.",
|
||||
"--modal-bg": "Modal panel background.",
|
||||
"--modal-scrim": "Modal backdrop scrim.",
|
||||
"--input-bg": "Input / select background.",
|
||||
"--input-border": "Input / select border.",
|
||||
"--input-focus-border": "Input border on focus.",
|
||||
"--button-hover": "Secondary / ghost button hover background.",
|
||||
"--slider-track": "Range slider track.",
|
||||
"--slider-thumb": "Range slider thumb.",
|
||||
"--slider-thumb-hover": "Range slider thumb on hover.",
|
||||
"--scrollbar-thumb": "Scrollbar thumb.",
|
||||
"--scrollbar-thumb-hover": "Scrollbar thumb on hover.",
|
||||
"--scrollbar-track": "Scrollbar track."
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* Tests for the runtime theme-CSS trust boundary and <head> injection sync.
|
||||
* Tests for the runtime theme-CSS security floor and <head> injection sync.
|
||||
* Community themes are free-form; the floor only blocks the hard safety
|
||||
* invariants (network, scripts, breakout, unscoped @keyframes, size).
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
@@ -21,56 +23,67 @@ afterEach(() => {
|
||||
document.head.querySelectorAll(`style[${ATTR}]`).forEach((el) => el.remove());
|
||||
});
|
||||
|
||||
describe('validateThemeCss', () => {
|
||||
it('accepts a valid single scoped rule', () => {
|
||||
describe('validateThemeCss (security floor)', () => {
|
||||
it('accepts a simple scoped rule', () => {
|
||||
expect(validateThemeCss(block('dracula'), 'dracula')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('accepts a data: url on the contract (e.g. --select-arrow)', () => {
|
||||
it('accepts free-form selectors and structure', () => {
|
||||
const css = `html { color: red; } .sidebar { background: #000; } [data-theme='x'] .player-bar { opacity: 0.9; }`;
|
||||
expect(validateThemeCss(css, 'x')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('accepts @media and multiple rules', () => {
|
||||
const css = `${block('x')} @media (min-width: 600px) { .sidebar { width: 200px; } }`;
|
||||
expect(validateThemeCss(css, 'x')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('accepts @keyframes namespaced with the theme id, and its animation use', () => {
|
||||
const css = `@keyframes x-pulse { from { opacity: 1 } to { opacity: 0.5 } } .sidebar { animation: x-pulse 2s infinite; }`;
|
||||
expect(validateThemeCss(css, 'x')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('accepts a data: url()', () => {
|
||||
const css = block('x', `--select-arrow: url("data:image/svg+xml,%3Csvg%3E%3C/svg%3E");`);
|
||||
expect(validateThemeCss(css, 'x')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('rejects @keyframes not namespaced with the theme id', () => {
|
||||
expect(validateThemeCss(`@keyframes pulse { from {} to {} } ${block('x')}`, 'x')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects @import', () => {
|
||||
expect(validateThemeCss(`@import 'evil.css'; ${block('x')}`, 'x')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects @property (global custom-prop registration)', () => {
|
||||
const css = `@property --x { syntax: '<color>'; inherits: false; initial-value: red; } ${block('x')}`;
|
||||
expect(validateThemeCss(css, 'x')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a non-data url()', () => {
|
||||
expect(validateThemeCss(block('x', `--accent: url(https://evil.test/x.png);`), 'x')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects </style> breakout', () => {
|
||||
it('rejects </style> / <script> breakout', () => {
|
||||
expect(validateThemeCss(`${block('x')}</style><script>`, 'x')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an unscoped/global selector', () => {
|
||||
expect(validateThemeCss(':root{ --accent:red; }', 'x')).toBeNull();
|
||||
expect(validateThemeCss('*{ color:red; }', 'x')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a foreign theme id selector', () => {
|
||||
expect(validateThemeCss(block('other'), 'dracula')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects more than one rule', () => {
|
||||
expect(validateThemeCss(`${block('x')} ${block('x', '--bg-app:#000;')}`, 'x')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects expression() / javascript:', () => {
|
||||
expect(validateThemeCss(block('x', '--accent: expression(alert(1));'), 'x')).toBeNull();
|
||||
expect(validateThemeCss(block('x', '--accent: javascript:alert(1);'), 'x')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an oversized css blob', () => {
|
||||
const huge = `[data-theme='x']{ ${'--accent:#ffffff;'.repeat(6000)} }`;
|
||||
expect(huge.length).toBeGreaterThan(64 * 1024);
|
||||
const huge = `[data-theme='x']{ ${'--accent:#ffffff;'.repeat(20000)} }`;
|
||||
expect(huge.length).toBeGreaterThan(256 * 1024);
|
||||
expect(validateThemeCss(huge, 'x')).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores comments when validating', () => {
|
||||
expect(validateThemeCss(`/* hi */ ${block('x')}`, 'x')).not.toBeNull();
|
||||
// comment cannot smuggle a second rule past the single-rule shape
|
||||
expect(validateThemeCss(`${block('x')} /* */ ${block('x')}`, 'x')).toBeNull();
|
||||
// A comment cannot smuggle an @import past the floor.
|
||||
expect(validateThemeCss(`${block('x')} /* */ @import 'x';`, 'x')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,8 +115,8 @@ describe('syncInjectedThemes', () => {
|
||||
expect(el?.textContent).toContain('#222');
|
||||
});
|
||||
|
||||
it('does not inject invalid css', () => {
|
||||
syncInjectedThemes([mk('a', ':root{ --accent:red; }')]);
|
||||
it('does not inject css that fails the floor', () => {
|
||||
syncInjectedThemes([mk('a', `@import 'evil.css'; ${block('a')}`)]);
|
||||
expect(injected()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,58 +3,65 @@ import type { InstalledTheme } from '../../store/installedThemesStore';
|
||||
/**
|
||||
* Runtime CSS injection for installed community themes. Built-in themes are
|
||||
* bundled at build time (`src/styles/themes/index.css`); installed ones have no
|
||||
* build-time presence, so their `[data-theme='<id>']` block must be injected
|
||||
* into <head> at runtime. Each installed theme gets one
|
||||
* `<style data-installed-theme="<id>">` element, kept in sync with the store.
|
||||
* build-time presence, so their CSS is injected into <head> at runtime. Each
|
||||
* installed theme gets one `<style data-installed-theme="<id>">` element, kept
|
||||
* in sync with the store.
|
||||
*/
|
||||
|
||||
const ATTR = 'data-installed-theme';
|
||||
|
||||
function escapeRegExp(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
// Generous but bounded — a rich free-form theme (animations, an embedded
|
||||
// data: font/icon) is still small; this matches the import command's CSS cap
|
||||
// and keeps one theme from eating the install store's localStorage quota.
|
||||
const MAX_CSS_BYTES = 256 * 1024;
|
||||
|
||||
/**
|
||||
* Validate one installed theme's CSS against the runtime trust boundary. The
|
||||
* repo CI is the authoritative contract check (token whitelist, thumbnail, …);
|
||||
* this is the in-app guard for the structural invariants the app *relies on* to
|
||||
* keep a theme contained — important because every installed theme is injected
|
||||
* into <head> at all times and scoping depends entirely on the selector.
|
||||
* The in-app **security floor** for an installed theme's CSS. Community themes
|
||||
* are free-form (any selectors, structure, `@keyframes`, animations — quality
|
||||
* is handled by store moderation, and sideloaded themes are installed at the
|
||||
* user's own risk). This guard enforces only the hard safety invariants the app
|
||||
* relies on, because every installed theme is injected into <head> at all times:
|
||||
*
|
||||
* A valid theme CSS is **exactly one rule**, scoped **exactly** to this theme's
|
||||
* `[data-theme='<id>']` selector, with:
|
||||
* - no `<style>`/`</style>` (can't break out of the element),
|
||||
* - no `@import` / at-rules (the single-rule shape rejects these),
|
||||
* - no unscoped/global (`:root`, `html`, `*`) or *foreign* (another id's)
|
||||
* selector — so it can never style anything but its own theme,
|
||||
* - `url()` only as `data:` (the inline `--select-arrow` SVG), and
|
||||
* - no `expression()` / `javascript:`.
|
||||
* - can't break out of its `<style>` element (`</style>` / `<script>`),
|
||||
* - can't pull anything off the network — no `@import`, and `url()` may only be
|
||||
* an inline `data:` URI (prevents tracking/exfiltration on every app start),
|
||||
* - no `@property` (would register global custom properties that could clash
|
||||
* with the app or other themes),
|
||||
* - no legacy script-in-CSS vectors (`expression()`, `javascript:`,
|
||||
* `-moz-binding`),
|
||||
* - a size cap, and
|
||||
* - `@keyframes` must be namespaced with the theme id (`<id>-…`) so animations
|
||||
* from different installed themes can't collide.
|
||||
*
|
||||
* Returns the original CSS if valid, or `null` if it must not be injected.
|
||||
* Returns the original CSS if it passes, or `null` if it must not be injected.
|
||||
*/
|
||||
export function validateThemeCss(css: string, id: string): string | null {
|
||||
if (typeof css !== 'string' || !css) return null;
|
||||
// Bound the per-theme localStorage footprint. A real token-only theme is a
|
||||
// few KB; this generous cap rejects a pathological/huge file before it can
|
||||
// eat the install store's quota.
|
||||
if (css.length > 64 * 1024) return null;
|
||||
if (css.length > MAX_CSS_BYTES) return null;
|
||||
// Strip comments first so they can't smuggle content past the checks.
|
||||
const stripped = css.replace(/\/\*[\s\S]*?\*\//g, '').trim();
|
||||
if (/<\/?\s*style/i.test(stripped)) return null;
|
||||
if (/@import/i.test(stripped)) return null;
|
||||
// Exactly one rule, scoped to exactly this theme's selector. The single
|
||||
// braces-pair shape also rejects at-rules (`@media{…}`), extra rules, and
|
||||
// unscoped/foreign selectors.
|
||||
const selector = `\\[data-theme=(['"])${escapeRegExp(id)}\\1\\]`;
|
||||
const match = stripped.match(new RegExp(`^${selector}\\s*\\{([^{}]*)\\}$`));
|
||||
if (!match) return null;
|
||||
const body = match[2]; // group 1 is the selector quote; group 2 is the declarations
|
||||
if (/expression\s*\(/i.test(body) || /javascript:/i.test(body)) return null;
|
||||
const urls = body.match(/url\(\s*['"]?[^'")]*/gi) || [];
|
||||
const s = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
|
||||
// No element breakout, no remote stylesheet, no global custom-prop registration.
|
||||
if (/<\/?\s*(?:style|script)\b/i.test(s)) return null;
|
||||
if (/@import\b/i.test(s)) return null;
|
||||
if (/@(?:-[a-z]+-)?property\b/i.test(s)) return null;
|
||||
// No legacy script-in-CSS vectors.
|
||||
if (/expression\s*\(/i.test(s) || /javascript:/i.test(s) || /-moz-binding/i.test(s)) return null;
|
||||
|
||||
// url() may only be an inline data: URI. Match each `url(` and inspect the
|
||||
// start of its content — a non-data target never starts with `data:`.
|
||||
const urls = s.match(/url\(\s*['"]?\s*[^'")]*/gi) || [];
|
||||
for (const u of urls) {
|
||||
const inner = u.replace(/^url\(\s*['"]?/i, '');
|
||||
const inner = u.replace(/^url\(\s*['"]?\s*/i, '');
|
||||
if (!/^data:/i.test(inner)) return null;
|
||||
}
|
||||
|
||||
// @keyframes (and vendor-prefixed) must start with `<id>-`.
|
||||
const prefix = `${id}-`;
|
||||
const kf = s.matchAll(/@(?:-[a-z]+-)?keyframes\s+([A-Za-z0-9_-]+)/gi);
|
||||
for (const m of kf) {
|
||||
if (!m[1].startsWith(prefix)) return null;
|
||||
}
|
||||
|
||||
return css;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,32 +1,9 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { validateThemePackage } from './validateThemePackage';
|
||||
import tokens from './contract/allowed-tokens.json';
|
||||
|
||||
const CORE = Object.keys(tokens.core).filter((k) => !k.startsWith('$'));
|
||||
|
||||
interface CssOpts {
|
||||
id?: string;
|
||||
mode?: 'dark' | 'light';
|
||||
omit?: string;
|
||||
overrides?: Record<string, string>;
|
||||
extraDecl?: string;
|
||||
extraRule?: string;
|
||||
}
|
||||
|
||||
/** Build a complete, contract-valid theme.css (all core tokens), with knobs to
|
||||
* break exactly one thing per test. */
|
||||
function buildCss(o: CssOpts = {}): string {
|
||||
const { id = 'my-theme', mode = 'dark', omit, overrides = {}, extraDecl, extraRule } = o;
|
||||
const decls = CORE.filter((tok) => tok !== omit).map((tok) => {
|
||||
if (overrides[tok] !== undefined) return `${tok}: ${overrides[tok]}`;
|
||||
if (tok === '--select-arrow') {
|
||||
return `${tok}: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg'/>")`;
|
||||
}
|
||||
return `${tok}: #abcdef`;
|
||||
});
|
||||
if (extraDecl) decls.push(extraDecl);
|
||||
const rule = `[data-theme='${id}'] {\n color-scheme: ${mode};\n ${decls.join(';\n ')};\n}`;
|
||||
return extraRule ? `${rule}\n${extraRule}` : rule;
|
||||
/** A minimal floor-passing theme.css for `id`. */
|
||||
function css(id = 'my-theme'): string {
|
||||
return `[data-theme='${id}'] { color-scheme: dark; --accent: #abcdef; }`;
|
||||
}
|
||||
|
||||
function manifest(over: Record<string, unknown> = {}): string {
|
||||
@@ -45,8 +22,8 @@ const hasError = (r: ReturnType<typeof validateThemePackage>, re: RegExp): boole
|
||||
!r.ok && r.errors.some((e) => re.test(e));
|
||||
|
||||
describe('validateThemePackage', () => {
|
||||
it('accepts a fully contract-valid package', () => {
|
||||
const r = validateThemePackage(manifest(), buildCss());
|
||||
it('accepts a valid package', () => {
|
||||
const r = validateThemePackage(manifest(), css());
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.theme.id).toBe('my-theme');
|
||||
@@ -55,76 +32,60 @@ describe('validateThemePackage', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts free-form CSS — foreign selectors and namespaced animations', () => {
|
||||
const freeform = `
|
||||
[data-theme='my-theme'] { color-scheme: dark; --accent: #abcdef; }
|
||||
@keyframes my-theme-pulse { from { opacity: 1 } to { opacity: .5 } }
|
||||
.sidebar { animation: my-theme-pulse 2s infinite; }
|
||||
[data-theme='my-theme'][data-playing='true'] .player-bar { filter: brightness(1.1); }
|
||||
`;
|
||||
expect(validateThemePackage(manifest(), freeform).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves valid tags', () => {
|
||||
const r = validateThemePackage(manifest({ tags: ['dark', 'neon'] }), buildCss());
|
||||
const r = validateThemePackage(manifest({ tags: ['dark', 'neon'] }), css());
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.theme.tags).toEqual(['dark', 'neon']);
|
||||
});
|
||||
|
||||
it('rejects invalid JSON', () => {
|
||||
const r = validateThemePackage('{ not json', buildCss());
|
||||
expect(hasError(r, /not valid JSON/)).toBe(true);
|
||||
expect(hasError(validateThemePackage('{ not json', css()), /not valid JSON/)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a non-object manifest', () => {
|
||||
const r = validateThemePackage('"a string"', buildCss());
|
||||
expect(hasError(r, /must be a JSON object/)).toBe(true);
|
||||
expect(hasError(validateThemePackage('"a string"', css()), /must be a JSON object/)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects unknown manifest properties', () => {
|
||||
const r = validateThemePackage(manifest({ evil: true }), buildCss());
|
||||
expect(hasError(r, /unknown property "evil"/)).toBe(true);
|
||||
expect(hasError(validateThemePackage(manifest({ evil: true }), css()), /unknown property "evil"/)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a missing required field', () => {
|
||||
const r = validateThemePackage(manifest({ name: undefined }), buildCss());
|
||||
expect(hasError(r, /manifest\.name is required/)).toBe(true);
|
||||
expect(hasError(validateThemePackage(manifest({ name: undefined }), css()), /manifest\.name is required/)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an id that is not lowercase kebab-case', () => {
|
||||
const r = validateThemePackage(manifest({ id: 'My_Theme' }), buildCss({ id: 'My_Theme' }));
|
||||
const r = validateThemePackage(manifest({ id: 'My_Theme' }), css('My_Theme'));
|
||||
expect(hasError(r, /kebab-case/)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an id that collides with a built-in theme', () => {
|
||||
const r = validateThemePackage(manifest({ id: 'mocha' }), buildCss({ id: 'mocha' }));
|
||||
const r = validateThemePackage(manifest({ id: 'mocha' }), css('mocha'));
|
||||
expect(hasError(r, /collides with a built-in/)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a missing required core token', () => {
|
||||
const r = validateThemePackage(manifest(), buildCss({ omit: '--accent' }));
|
||||
expect(hasError(r, /missing required core token --accent/)).toBe(true);
|
||||
it('rejects CSS that reaches the network via @import', () => {
|
||||
const r = validateThemePackage(manifest(), `@import 'https://evil/x.css'; ${css()}`);
|
||||
expect(hasError(r, /failed the safety check/)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a token that is not in the whitelist', () => {
|
||||
const r = validateThemePackage(manifest(), buildCss({ extraDecl: '--not-a-real-token: #fff' }));
|
||||
expect(hasError(r, /--not-a-real-token is not in the contract whitelist/)).toBe(true);
|
||||
it('rejects CSS with a non-data url() (containment)', () => {
|
||||
const r = validateThemePackage(manifest(), `[data-theme='my-theme'] { background: url(https://evil/x.png); }`);
|
||||
expect(hasError(r, /failed the safety check/)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects color-scheme not matching manifest.mode', () => {
|
||||
const r = validateThemePackage(manifest({ mode: 'light' }), buildCss({ mode: 'dark' }));
|
||||
expect(hasError(r, /must match/)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a data-URI on a token other than --select-arrow', () => {
|
||||
const r = validateThemePackage(
|
||||
manifest(),
|
||||
buildCss({ overrides: { '--accent': 'url("data:image/png;base64,AAAA")' } }),
|
||||
);
|
||||
expect(hasError(r, /data-URI values are only allowed on --select-arrow/)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an external url() value (containment)', () => {
|
||||
const r = validateThemePackage(
|
||||
manifest(),
|
||||
buildCss({ overrides: { '--bg-app': 'url(https://evil.example/x.png)' } }),
|
||||
);
|
||||
// validateThemeCss flags this structurally (only data: URIs are allowed).
|
||||
expect(hasError(r, /one safe \[data-theme/)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects more than one rule (structural)', () => {
|
||||
const r = validateThemePackage(manifest(), buildCss({ extraRule: "[data-theme='other'] { --accent: #000 }" }));
|
||||
expect(hasError(r, /one safe \[data-theme/)).toBe(true);
|
||||
it('rejects @keyframes not namespaced with the theme id', () => {
|
||||
const r = validateThemePackage(manifest(), `@keyframes pulse { from {} to {} } ${css()}`);
|
||||
expect(hasError(r, /failed the safety check/)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,37 +1,21 @@
|
||||
import allowedTokens from './contract/allowed-tokens.json';
|
||||
import { validateThemeCss } from './themeInjection';
|
||||
import { FIXED_THEMES } from '../../components/settings/fixedThemes';
|
||||
|
||||
/**
|
||||
* Full theme-package validation for locally imported themes (a .zip holding
|
||||
* manifest.json + theme.css). This is the in-app mirror of the repo CI
|
||||
* contract check (`scripts/validate-theme.mjs`): it enforces the exact same
|
||||
* manifest schema and CSS token whitelist, using a byte-identical copy of
|
||||
* `schema/allowed-tokens.json` and the schema's own field patterns.
|
||||
* Validation for a locally imported theme package (a .zip holding manifest.json
|
||||
* + theme.css). Community themes are free-form, so this enforces two things:
|
||||
*
|
||||
* Layering: `validateThemeCss` (themeInjection) is the security/containment
|
||||
* guard — it guarantees the CSS is a single, scoped, at-rule-free rule with
|
||||
* data-URI-only `url()`. Once that holds, the declaration list is a flat
|
||||
* `prop: value;` sequence we can extract safely (no nesting) and check against
|
||||
* the contract here. The same `validateThemeCss` runs again at injection time,
|
||||
* so a theme that slips past this is still contained at the boundary.
|
||||
* 1. the **manifest** is well-formed (the same field rules as the repo schema),
|
||||
* and its id doesn't collide with a built-in theme, and
|
||||
* 2. the CSS passes the in-app **security floor** (`validateThemeCss`) — no
|
||||
* network/scripts/breakout, data:-only `url()`, id-namespaced `@keyframes`.
|
||||
*
|
||||
* Quality, structure, animations and taste are deliberately NOT checked here:
|
||||
* store themes are vetted by maintainers, and sideloaded themes are installed
|
||||
* at the user's own risk (the import UI says so). The floor is the safety line.
|
||||
*/
|
||||
|
||||
const noMeta = (o: Record<string, unknown>): string[] =>
|
||||
Object.keys(o || {}).filter((k) => !k.startsWith('$'));
|
||||
|
||||
const CORE = noMeta(allowedTokens.core as Record<string, unknown>);
|
||||
const ALLOWED = new Set([
|
||||
...CORE,
|
||||
...noMeta(allowedTokens.optional as Record<string, unknown>),
|
||||
...noMeta(allowedTokens.granular as Record<string, unknown>),
|
||||
]);
|
||||
const DATA_URI_TOKENS = new Set(allowedTokens.dataUriTokens as string[]);
|
||||
const SCHEME_VALUES = new Set(allowedTokens.colorScheme.values as string[]);
|
||||
const BUILTIN_IDS = new Set(FIXED_THEMES.map((f) => f.id));
|
||||
|
||||
// Field patterns copied verbatim from schema/manifest.schema.json so the
|
||||
// in-app check stays identical to CI without bundling a JSON-schema engine.
|
||||
// Field patterns copied verbatim from the repo's schema/manifest.schema.json.
|
||||
const ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
||||
const AUTHOR_RE = /^[A-Za-z0-9](-?[A-Za-z0-9]){0,38}$/;
|
||||
const SEMVER_RE =
|
||||
@@ -41,6 +25,7 @@ const TAG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
||||
const MANIFEST_KEYS = new Set([
|
||||
'id', 'name', 'author', 'version', 'description', 'mode', 'tags', 'minAppVersion',
|
||||
]);
|
||||
const BUILTIN_IDS = new Set(FIXED_THEMES.map((f) => f.id));
|
||||
|
||||
export interface ValidatedTheme {
|
||||
id: string;
|
||||
@@ -57,44 +42,6 @@ export type ValidateResult =
|
||||
| { ok: true; theme: ValidatedTheme }
|
||||
| { ok: false; errors: string[] };
|
||||
|
||||
function escapeRegExp(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/** The declarations inside the single `[data-theme='<id>']` rule, or null. */
|
||||
function ruleBody(css: string, id: string): string | null {
|
||||
const stripped = css.replace(/\/\*[\s\S]*?\*\//g, '').trim();
|
||||
const sel = `\\[data-theme=(['"])${escapeRegExp(id)}\\1\\]`;
|
||||
const m = stripped.match(new RegExp(`^${sel}\\s*\\{([^{}]*)\\}$`));
|
||||
return m ? m[2] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a flat declaration body on top-level `;` only — never inside `()` or a
|
||||
* quoted string, so a `url("data:image/svg+xml;...")` value (the `;` in the
|
||||
* MIME type / SVG) stays intact.
|
||||
*/
|
||||
function splitDeclarations(body: string): string[] {
|
||||
const out: string[] = [];
|
||||
let cur = '';
|
||||
let depth = 0;
|
||||
let quote: string | null = null;
|
||||
for (const ch of body) {
|
||||
if (quote) {
|
||||
cur += ch;
|
||||
if (ch === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") { quote = ch; cur += ch; continue; }
|
||||
if (ch === '(') { depth++; cur += ch; continue; }
|
||||
if (ch === ')') { if (depth > 0) depth--; cur += ch; continue; }
|
||||
if (ch === ';' && depth === 0) { out.push(cur); cur = ''; continue; }
|
||||
cur += ch;
|
||||
}
|
||||
if (cur.trim()) out.push(cur);
|
||||
return out.map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export function validateThemePackage(manifestText: string, css: string): ValidateResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
@@ -165,61 +112,20 @@ export function validateThemePackage(manifestText: string, css: string): Validat
|
||||
}
|
||||
}
|
||||
|
||||
// ---- css ----
|
||||
// The selector check needs a valid id; if the id is bad, skip the CSS rule
|
||||
// checks (they would all fail for the wrong reason) but keep the manifest
|
||||
// errors above.
|
||||
// ---- css security floor ----
|
||||
// Needs a valid id (the @keyframes namespace check is keyed on it).
|
||||
const idForCss = id && ID_RE.test(id) ? id : null;
|
||||
if (idForCss === null) {
|
||||
errors.push('theme.css cannot be validated until manifest.id is valid');
|
||||
return { ok: false, errors };
|
||||
}
|
||||
|
||||
if (validateThemeCss(css, idForCss) == null) {
|
||||
errors.push(
|
||||
`theme.css must be exactly one safe [data-theme='${idForCss}'] rule (no @-rules, no foreign/global selectors, url() may only be a data: URI)`,
|
||||
"theme.css failed the safety check — it may exceed the size limit, reach the network (only data: url() is allowed), use @import / @property / scripts, break out of its <style>, or define @keyframes not namespaced as \"" + idForCss + "-…\"",
|
||||
);
|
||||
return { ok: false, errors };
|
||||
}
|
||||
|
||||
const body = ruleBody(css, idForCss);
|
||||
if (body === null) {
|
||||
// Should not happen once validateThemeCss passed, but stay defensive.
|
||||
errors.push('theme.css declarations could not be read');
|
||||
return { ok: false, errors };
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
let scheme: string | null = null;
|
||||
for (const decl of splitDeclarations(body)) {
|
||||
const idx = decl.indexOf(':');
|
||||
if (idx < 0) { errors.push(`malformed declaration: "${decl}"`); continue; }
|
||||
const prop = decl.slice(0, idx).trim();
|
||||
const value = decl.slice(idx + 1).trim();
|
||||
|
||||
if (prop === 'color-scheme') {
|
||||
scheme = value;
|
||||
if (!SCHEME_VALUES.has(value)) errors.push(`color-scheme must be one of ${[...SCHEME_VALUES].join(' | ')} (got: ${value})`);
|
||||
continue;
|
||||
}
|
||||
if (!prop.startsWith('--')) { errors.push(`only custom properties and color-scheme are allowed (found: ${prop})`); continue; }
|
||||
if (!ALLOWED.has(prop)) { errors.push(`token ${prop} is not in the contract whitelist`); continue; }
|
||||
if (seen.has(prop)) errors.push(`token ${prop} is declared more than once`);
|
||||
seen.add(prop);
|
||||
|
||||
const urls = value.toLowerCase().match(/url\(([^)]*)\)/g) || [];
|
||||
for (const u of urls) {
|
||||
if (!/url\(\s*["']?\s*data:/.test(u)) errors.push(`${prop}: only url(data:...) is allowed`);
|
||||
else if (!DATA_URI_TOKENS.has(prop)) errors.push(`${prop}: data-URI values are only allowed on ${[...DATA_URI_TOKENS].join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (scheme === null) errors.push('color-scheme is required in theme.css');
|
||||
if (mode && scheme && mode !== scheme) errors.push(`manifest.mode "${mode}" must match theme.css color-scheme "${scheme}"`);
|
||||
for (const t of CORE) {
|
||||
if (!seen.has(t)) errors.push(`missing required core token ${t}`);
|
||||
}
|
||||
|
||||
if (errors.length) return { ok: false, errors };
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user