Files
openidea/crates/ide-plugin/src/theme.rs
T
l0ng-ai 30310810e6 feat(plugins): Theme plugin template, theme docs and IntelliJ theme import (#54)
Per the maintainer's decision for #24, no third-party themes are bundled; instead a theme plugin template and docs let the community port themes (IntelliJ also ships few themes, the rest are plugins).
- templates/theme-plugin: manifest-only plugin with an original example (Harbor Night) covering every UI token and an editor scheme (.icls equivalent).
- docs/plugins/themes.md: format, IntelliJ / VS Code porting tables, generated token reference (pnpm gen:theme-docs); drift test fails when tokens.css and the template/reference disagree.
- pnpm theme:import-intellij: dependency-free converter for IntelliJ .theme.json (+ .icls).
- Plugin host: themes may name an editor-scheme merged over the theme; comments allowed in theme files.
- Fixes: a plugin theme's kind now switches the light/dark base; plugin themes go in their own stylesheet so the Appearance accent and editor scheme still layer on top.
Verified: ide-plugin tests, vitest (44), in-app on macOS: installed the template from disk, applied Harbor Night (dark base + teal accent over Islands Light), back to default restored the light theme.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 10:39:08 +08:00

192 lines
6.7 KiB
Rust

//! Theme contributions: design-token overrides applied as CSS variables on `:root`.
//! Theme and editor-scheme files are JSON that may contain `//` and `/* */` comments.
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::BTreeMap;
const MAX_TOKENS: usize = 512;
const MAX_VALUE_LEN: usize = 256;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum ThemeKind {
Dark,
Light,
}
/// `{"id", "name", "kind", "tokens": {"--bg-app": "#002b36", ...}}`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ThemeDef {
pub id: String,
pub name: String,
pub kind: ThemeKind,
pub tokens: BTreeMap<String, String>,
}
/// `{"name"?, "tokens": {...}}`: an editor colour scheme merged over a theme.
#[derive(Deserialize)]
struct EditorScheme {
tokens: BTreeMap<String, String>,
}
impl ThemeDef {
pub fn parse(src: &str) -> Result<Self, String> {
let theme: Self =
serde_json::from_str(&strip_json_comments(src)).map_err(|e| format!("invalid theme JSON: {e}"))?;
theme.validate()?;
Ok(theme)
}
/// Merges an editor colour scheme file over the theme's tokens (the scheme wins).
pub fn merge_editor_scheme(&mut self, src: &str) -> Result<(), String> {
let scheme: EditorScheme =
serde_json::from_str(&strip_json_comments(src)).map_err(|e| format!("invalid editor scheme JSON: {e}"))?;
self.tokens.extend(scheme.tokens);
self.validate()
}
pub fn validate(&self) -> Result<(), String> {
crate::manifest::validate_command_id(&self.id).map_err(|e| e.replace("command id", "theme id"))?;
if self.name.trim().is_empty() {
return Err("theme name must not be empty".into());
}
if self.tokens.len() > MAX_TOKENS {
return Err(format!("too many tokens (max {MAX_TOKENS})"));
}
for (name, value) in &self.tokens {
let valid_name = name.len() > 2
&& name.len() <= 64
&& name.starts_with("--")
&& name[2..].bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
if !valid_name {
return Err(format!("invalid token name {name:?}: expected a CSS variable like --bg-app"));
}
if !is_safe_value(value) {
return Err(format!("token {name}: unsupported value {value:?}"));
}
}
Ok(())
}
}
/// Blanks out `//` and `/* */` comments outside strings, keeping newlines so
/// parse errors still point at the right line.
fn strip_json_comments(src: &str) -> String {
let mut out = String::with_capacity(src.len());
let mut chars = src.chars().peekable();
let (mut in_str, mut escaped) = (false, false);
while let Some(c) = chars.next() {
if in_str {
out.push(c);
if escaped {
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == '"' {
in_str = false;
}
continue;
}
match (c, chars.peek()) {
('/', Some('/')) => {
if chars.by_ref().any(|c| c == '\n') {
out.push('\n');
}
}
('/', Some('*')) => {
chars.next();
let mut prev = ' ';
for c in chars.by_ref() {
if c == '\n' {
out.push('\n');
}
if prev == '*' && c == '/' {
break;
}
prev = c;
}
out.push(' ');
}
_ => {
in_str = c == '"';
out.push(c);
}
}
}
out
}
/// Values are plain CSS values (colours, lengths, font stacks). Anything that
/// could break out of the declaration or fetch resources is rejected.
fn is_safe_value(v: &str) -> bool {
let lower = v.to_ascii_lowercase();
!v.trim().is_empty()
&& v.len() <= MAX_VALUE_LEN
&& !v.chars().any(|c| matches!(c, ';' | '{' | '}' | '<' | '>' | '\\' | '\n' | '\r'))
&& !lower.contains("url(")
&& !lower.contains("image(")
&& !lower.contains("@import")
&& !lower.contains("expression(")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_and_validates() {
let t = ThemeDef::parse(r##"{"id":"sol","name":"Sol","kind":"dark","tokens":{"--bg-app":"#002b36","--font-ui":"'Inter', sans-serif"}}"##)
.unwrap();
assert_eq!(t.kind, ThemeKind::Dark);
assert_eq!(t.tokens["--bg-app"], "#002b36");
}
#[test]
fn rejects_unsafe_tokens() {
let mk = |k: &str, v: &str| {
ThemeDef { id: "t".into(), name: "T".into(), kind: ThemeKind::Light, tokens: [(k.into(), v.into())].into() }
.validate()
};
assert!(mk("--bg", "red").is_ok());
assert!(mk("bg", "red").is_err());
assert!(mk("--Bg", "red").is_err());
assert!(mk("--bg", "red; color: blue").is_err());
assert!(mk("--bg", "url(https://evil)").is_err());
assert!(mk("--bg", "}body{").is_err());
assert!(mk("--bg", " ").is_err());
assert!(ThemeDef::parse("{\"id\":\"x\"}").is_err());
}
#[test]
fn allows_comments_outside_strings() {
let src = r##"{
// line comment
"id": "c", "name": "C // not a comment", "kind": "dark",
/* block
comment */
"tokens": { "--bg-app": "#000" /* inline */, "--font-ui": "\"a/*b\", sans-serif" }
}"##;
let t = ThemeDef::parse(src).unwrap();
assert_eq!(t.name, "C // not a comment");
assert_eq!(t.tokens["--font-ui"], "\"a/*b\", sans-serif");
assert!(ThemeDef::parse("{\n // x\n \"id\": 1\n}").unwrap_err().contains("line 3"));
}
#[test]
fn merges_editor_scheme_over_theme() {
let mut t = ThemeDef::parse(
r##"{"id":"m","name":"M","kind":"dark","tokens":{"--bg-app":"#111","--syn-keyword":"#222"}}"##,
)
.unwrap();
t.merge_editor_scheme(r##"{"name":"M", /* editor */ "tokens":{"--syn-keyword":"#333","--caret":"#fff"}}"##)
.unwrap();
assert_eq!(t.tokens["--bg-app"], "#111");
assert_eq!(t.tokens["--syn-keyword"], "#333");
assert_eq!(t.tokens["--caret"], "#fff");
assert!(t.clone().merge_editor_scheme(r#"{"tokens":{"--caret":"url(x)"}}"#).is_err());
assert!(t.merge_editor_scheme(r#"{"name":"no tokens"}"#).is_err());
}
}