дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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
Цей коміт міститься в:
1 джерело
b2fec4ae30
коміт
30310810e6
22 files changed
+1885
-34
No files matched your search
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Regenerates the token reference in docs/plugins/themes.md from
|
||||
* src/styles/tokens.css (`pnpm gen:theme-docs`). scripts/theme/theme.test.ts
|
||||
* fails when the checked-in reference is stale.
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { referenceMarkdown, themeTokenGroups, withReference } from "./tokens.ts";
|
||||
|
||||
const DOC = "docs/plugins/themes.md";
|
||||
const groups = themeTokenGroups(readFileSync("src/styles/tokens.css", "utf8"));
|
||||
const doc = readFileSync(DOC, "utf8");
|
||||
const next = withReference(doc, referenceMarkdown(groups));
|
||||
if (next !== doc) writeFileSync(DOC, next);
|
||||
console.log(next === doc ? `${DOC} is up to date` : `updated ${DOC}`);
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Converts an IntelliJ theme into an OpenIDEA theme plugin package.
|
||||
*
|
||||
* pnpm theme:import-intellij <file.theme.json> [file.icls] --out <dir> [--id <id>] [--author <name>]
|
||||
*
|
||||
* Writes <dir>/plugin.toml and <dir>/themes/<id>.theme.json (+ .scheme.json),
|
||||
* ready for Settings › Plugins › Install Plugin from Disk…. Only port themes
|
||||
* whose licence allows it, and credit the original author.
|
||||
*/
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
import { parseArgs } from "node:util";
|
||||
import { convertIntellijTheme, pluginManifest } from "./intellij.ts";
|
||||
|
||||
const { values, positionals } = parseArgs({
|
||||
allowPositionals: true,
|
||||
options: { out: { type: "string" }, id: { type: "string" }, author: { type: "string" } },
|
||||
});
|
||||
const [themePath, iclsPath] = positionals;
|
||||
if (!themePath || !values.out) {
|
||||
console.error(
|
||||
"usage: pnpm theme:import-intellij <file.theme.json> [file.icls] --out <dir> [--id <id>] [--author <name>]",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const converted = convertIntellijTheme(
|
||||
readFileSync(themePath, "utf8"),
|
||||
iclsPath ? readFileSync(iclsPath, "utf8") : undefined,
|
||||
values.id,
|
||||
);
|
||||
const { theme, scheme } = converted;
|
||||
const source = [basename(themePath), iclsPath && basename(iclsPath)].filter(Boolean).join(" + ");
|
||||
const header = (what: string) => `// ${theme.name}: ${what}, converted from ${source}.\n`;
|
||||
|
||||
mkdirSync(join(values.out, "themes"), { recursive: true });
|
||||
writeFileSync(join(values.out, "plugin.toml"), pluginManifest(converted, values.author ?? "Unknown", source));
|
||||
writeFileSync(
|
||||
join(values.out, "themes", `${theme.id}.theme.json`),
|
||||
header("UI theme") + JSON.stringify(theme, null, 2) + "\n",
|
||||
);
|
||||
if (scheme) {
|
||||
writeFileSync(
|
||||
join(values.out, "themes", `${theme.id}.scheme.json`),
|
||||
header("editor colour scheme") + JSON.stringify(scheme, null, 2) + "\n",
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`${theme.name} (${theme.kind}): ${Object.keys(theme.tokens).length} UI tokens` +
|
||||
(scheme ? `, ${Object.keys(scheme.tokens).length} editor tokens` : "") +
|
||||
` → ${values.out}\nUnmapped tokens keep the built-in ${theme.kind} values; see docs/plugins/themes.md to fill them in.`,
|
||||
);
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Converts an IntelliJ theme (`.theme.json`, optionally with its editor scheme
|
||||
* `.icls`) into an OpenIDEA theme + editor scheme. The mapping covers the
|
||||
* main keys only; anything unmapped keeps the built-in value of the base
|
||||
* (`kind`). Pure functions, no dependencies: see import-intellij-theme.ts
|
||||
* for the command line.
|
||||
*/
|
||||
import { stripJsonComments } from "./tokens.ts";
|
||||
|
||||
/** OpenIDEA token ← IntelliJ `ui` keys, first present wins. `*.prop` wildcards apply too. */
|
||||
export const UI_KEYS: Record<string, string[]> = {
|
||||
"--bg-app": ["Panel.background"],
|
||||
"--bg-frame": ["MainWindow.background", "MainToolbar.background", "Panel.background"],
|
||||
"--bg-island": ["ToolWindow.background", "Panel.background"],
|
||||
"--bg-elev": ["ToolWindow.background", "SidePanel.background"],
|
||||
"--bg-popup": ["Popup.background", "PopupMenu.background"],
|
||||
"--bg-backdrop": ["Popup.background"],
|
||||
"--bg-hover": ["List.hoverBackground", "Tree.hoverBackground"],
|
||||
"--bg-selected": ["List.selectionBackground", "Tree.selectionBackground"],
|
||||
"--bg-selected-strong": ["List.selectionBackground"],
|
||||
"--bg-tree-selected": ["Tree.selectionBackground", "List.selectionBackground"],
|
||||
"--bg-tree-selected-inactive": ["Tree.selectionInactiveBackground", "List.selectionInactiveBackground"],
|
||||
"--bg-input": ["TextField.background"],
|
||||
"--bg-field": ["TextField.background"],
|
||||
"--bg-dialog": ["Dialog.background", "Panel.background"],
|
||||
"--border": ["Borders.color"],
|
||||
"--border-popup": ["Popup.borderColor", "Popup.Border.color"],
|
||||
"--border-strong": ["Component.borderColor", "Borders.ContrastBorderColor"],
|
||||
"--border-window": ["Borders.ContrastBorderColor", "Component.borderColor"],
|
||||
"--fg": ["Label.foreground"],
|
||||
"--fg-emphasis": ["Label.foreground"],
|
||||
"--fg-strong": ["Label.selectedForeground", "Label.foreground"],
|
||||
"--fg-secondary": ["Label.infoForeground", "Component.infoForeground"],
|
||||
"--fg-muted": ["Label.infoForeground", "Component.infoForeground"],
|
||||
"--fg-subtle": ["Label.disabledForeground"],
|
||||
"--fg-faint": ["Label.disabledForeground"],
|
||||
"--accent": ["Button.default.startBackground", "Button.default.background", "Component.focusColor"],
|
||||
"--fg-on-accent": ["Button.default.foreground"],
|
||||
"--fg-button-primary": ["Button.default.foreground"],
|
||||
"--focus-ring-soft": ["Component.focusColor"],
|
||||
"--link": ["Link.activeForeground", "Hyperlink.linkColor"],
|
||||
"--link-hover": ["Link.hoverForeground", "Link.activeForeground"],
|
||||
"--danger": ["Component.errorFocusColor"],
|
||||
"--warning": ["Component.warningFocusColor"],
|
||||
"--bg-tooltip": ["ToolTip.background"],
|
||||
"--fg-tooltip": ["ToolTip.foreground"],
|
||||
"--border-tooltip": ["ToolTip.borderColor"],
|
||||
"--bg-button": ["Button.startBackground", "Button.background"],
|
||||
"--border-button": ["Button.startBorderColor", "Button.borderColor"],
|
||||
"--bg-checkbox": ["Checkbox.Background.Default"],
|
||||
"--border-checkbox": ["Checkbox.Border.Default"],
|
||||
"--bg-tab-selected": ["EditorTabs.underlinedTabBackground", "EditorTabs.selectedBackground"],
|
||||
"--border-tab-selected": ["EditorTabs.underlineColor"],
|
||||
"--bg-tab-hover": ["EditorTabs.hoverBackground"],
|
||||
"--border-editor-tabs": ["EditorTabs.borderColor"],
|
||||
"--bg-toolbar-hover": ["ActionButton.hoverBackground"],
|
||||
"--bg-toolbar-pressed": ["ActionButton.pressedBackground"],
|
||||
"--bg-stripe-hover": ["ToolWindow.Button.hoverBackground"],
|
||||
"--bg-stripe-selected": ["ToolWindow.Button.selectedBackground"],
|
||||
"--scrollbar-thumb": ["ScrollBar.Mac.thumbColor", "ScrollBar.thumbColor"],
|
||||
"--scrollbar-thumb-hover": ["ScrollBar.Mac.hoverThumbColor", "ScrollBar.hoverThumbColor"],
|
||||
"--bg-progress-track": ["ProgressBar.trackColor"],
|
||||
"--bg-counter": ["Counter.background"],
|
||||
"--fg-counter": ["Counter.foreground"],
|
||||
"--bg-search-match": ["SearchMatch.startBackground", "SearchMatch.endBackground"],
|
||||
"--bg-toggle-on": ["ToggleButton.onBackground"],
|
||||
"--fg-toggle-on": ["ToggleButton.onForeground"],
|
||||
"--bg-toggle-off": ["ToggleButton.offBackground"],
|
||||
"--fg-toggle-off": ["ToggleButton.offForeground"],
|
||||
"--border-toggle": ["ToggleButton.borderColor"],
|
||||
"--bg-toggle-knob": ["ToggleButton.buttonColor"],
|
||||
"--bg-feedback-info": ["Banner.infoBackground"],
|
||||
"--border-feedback-info": ["Banner.infoBorderColor"],
|
||||
"--bg-feedback-error": ["Banner.errorBackground"],
|
||||
"--border-feedback-error": ["Banner.errorBorderColor"],
|
||||
"--bg-feedback-warning": ["Banner.warningBackground"],
|
||||
"--border-feedback-warning": ["Banner.warningBorderColor"],
|
||||
"--bg-feedback-success": ["Banner.successBackground"],
|
||||
"--border-feedback-success": ["Banner.successBorderColor"],
|
||||
};
|
||||
|
||||
/** `.icls` `<colors>` options → tokens. */
|
||||
export const ICLS_COLORS: Record<string, string[]> = {
|
||||
CARET_COLOR: ["--caret"],
|
||||
CARET_ROW_COLOR: ["--bg-current-line"],
|
||||
SELECTION_BACKGROUND: ["--bg-editor-selection"],
|
||||
LINE_NUMBERS_COLOR: ["--fg-line-number"],
|
||||
LINE_NUMBER_ON_CARET_ROW_COLOR: ["--fg-line-number-current"],
|
||||
GUTTER_BACKGROUND: ["--bg-editor-gutter"],
|
||||
INDENT_GUIDE: ["--border-indent-guide"],
|
||||
SELECTED_INDENT_GUIDE: ["--border-indent-guide-active"],
|
||||
RIGHT_MARGIN_COLOR: ["--border-right-margin"],
|
||||
WHITESPACES: ["--fg-whitespace"],
|
||||
METHOD_SEPARATORS_COLOR: ["--border-method-separator"],
|
||||
FILESTATUS_MODIFIED: ["--vcs-modified"],
|
||||
FILESTATUS_ADDED: ["--vcs-added"],
|
||||
FILESTATUS_DELETED: ["--vcs-deleted"],
|
||||
FILESTATUS_UNKNOWN: ["--vcs-untracked"],
|
||||
FILESTATUS_IDEA_FILESTATUS_IGNORED: ["--vcs-ignored"],
|
||||
FILESTATUS_IDEA_FILESTATUS_MERGED_WITH_CONFLICTS: ["--vcs-conflict"],
|
||||
MODIFIED_LINES_COLOR: ["--gutter-modified"],
|
||||
ADDED_LINES_COLOR: ["--gutter-added"],
|
||||
DELETED_LINES_COLOR: ["--gutter-deleted"],
|
||||
};
|
||||
|
||||
type Part = "FOREGROUND" | "BACKGROUND" | "EFFECT_COLOR";
|
||||
|
||||
/** `.icls` `<attributes>` options → [part, token] pairs. */
|
||||
export const ICLS_ATTRIBUTES: Record<string, Array<[Part, string]>> = {
|
||||
TEXT: [
|
||||
["FOREGROUND", "--fg-editor"],
|
||||
["BACKGROUND", "--bg-editor"],
|
||||
],
|
||||
DEFAULT_KEYWORD: [["FOREGROUND", "--syn-keyword"]],
|
||||
DEFAULT_STRING: [["FOREGROUND", "--syn-string"]],
|
||||
DEFAULT_NUMBER: [["FOREGROUND", "--syn-number"]],
|
||||
DEFAULT_LINE_COMMENT: [["FOREGROUND", "--syn-comment"]],
|
||||
DEFAULT_DOC_COMMENT: [["FOREGROUND", "--syn-doc-comment"]],
|
||||
DEFAULT_FUNCTION_DECLARATION: [["FOREGROUND", "--syn-function"]],
|
||||
DEFAULT_FUNCTION_CALL: [["FOREGROUND", "--syn-function-call"]],
|
||||
DEFAULT_CLASS_NAME: [["FOREGROUND", "--syn-type"]],
|
||||
DEFAULT_CONSTANT: [["FOREGROUND", "--syn-constant"]],
|
||||
DEFAULT_INSTANCE_FIELD: [["FOREGROUND", "--syn-property"]],
|
||||
DEFAULT_IDENTIFIER: [["FOREGROUND", "--syn-variable"]],
|
||||
DEFAULT_LOCAL_VARIABLE: [["FOREGROUND", "--syn-variable"]],
|
||||
DEFAULT_PARAMETER: [["FOREGROUND", "--syn-param"]],
|
||||
DEFAULT_PREDEFINED_SYMBOL: [["FOREGROUND", "--syn-builtin"]],
|
||||
DEFAULT_METADATA: [
|
||||
["FOREGROUND", "--syn-annotation"],
|
||||
["FOREGROUND", "--syn-decorator"],
|
||||
],
|
||||
DEFAULT_OPERATION_SIGN: [["FOREGROUND", "--syn-punct"]],
|
||||
DEFAULT_VALID_STRING_ESCAPE: [["FOREGROUND", "--syn-escape"]],
|
||||
DEFAULT_TAG: [["FOREGROUND", "--syn-tag"]],
|
||||
DEFAULT_ATTRIBUTE: [["FOREGROUND", "--syn-attribute"]],
|
||||
TODO_DEFAULT_ATTRIBUTES: [["FOREGROUND", "--syn-todo"]],
|
||||
BAD_CHARACTER: [["FOREGROUND", "--syn-invalid"]],
|
||||
ERRORS_ATTRIBUTES: [["EFFECT_COLOR", "--wave-error"]],
|
||||
WARNING_ATTRIBUTES: [["EFFECT_COLOR", "--wave-warning"]],
|
||||
NOT_USED_ELEMENT_ATTRIBUTES: [["FOREGROUND", "--fg-unused"]],
|
||||
MATCHED_BRACE_ATTRIBUTES: [["BACKGROUND", "--bg-matched-brace"]],
|
||||
SEARCH_RESULT_ATTRIBUTES: [["BACKGROUND", "--bg-search-match"]],
|
||||
TEXT_SEARCH_RESULT_ATTRIBUTES: [["BACKGROUND", "--bg-text-search-result"]],
|
||||
IDENTIFIER_UNDER_CARET_ATTRIBUTES: [["BACKGROUND", "--bg-identifier-under-caret"]],
|
||||
WRITE_IDENTIFIER_UNDER_CARET_ATTRIBUTES: [["BACKGROUND", "--bg-identifier-under-caret-write"]],
|
||||
FOLDED_TEXT_ATTRIBUTES: [
|
||||
["FOREGROUND", "--fg-fold-placeholder"],
|
||||
["BACKGROUND", "--bg-fold-placeholder"],
|
||||
],
|
||||
INLINE_PARAMETER_HINT: [
|
||||
["FOREGROUND", "--fg-inlay"],
|
||||
["BACKGROUND", "--bg-inlay"],
|
||||
],
|
||||
BREAKPOINT_ATTRIBUTES: [["BACKGROUND", "--bg-breakpoint-line"]],
|
||||
DEBUGGER_INLINED_VALUES: [["FOREGROUND", "--syn-inline-value"]],
|
||||
"PY.SELF_PARAMETER": [["FOREGROUND", "--syn-self"]],
|
||||
"PY.KEYWORD_ARGUMENT": [["FOREGROUND", "--syn-keyword-arg"]],
|
||||
"PY.DOC_COMMENT": [["FOREGROUND", "--syn-docstring"]],
|
||||
"PY.BUILTIN_NAME": [["FOREGROUND", "--syn-predefined"]],
|
||||
MARKDOWN_HEADER_LEVEL_1: [["FOREGROUND", "--syn-markup-heading"]],
|
||||
MARKDOWN_CODE_SPAN: [["FOREGROUND", "--syn-markup-code"]],
|
||||
MARKDOWN_LINK_TEXT: [["FOREGROUND", "--syn-link"]],
|
||||
CONSOLE_BLACK_OUTPUT: [["FOREGROUND", "--ansi-black"]],
|
||||
CONSOLE_RED_OUTPUT: [["FOREGROUND", "--ansi-red"]],
|
||||
CONSOLE_GREEN_OUTPUT: [["FOREGROUND", "--ansi-green"]],
|
||||
CONSOLE_YELLOW_OUTPUT: [["FOREGROUND", "--ansi-yellow"]],
|
||||
CONSOLE_BLUE_OUTPUT: [["FOREGROUND", "--ansi-blue"]],
|
||||
CONSOLE_MAGENTA_OUTPUT: [["FOREGROUND", "--ansi-magenta"]],
|
||||
CONSOLE_CYAN_OUTPUT: [["FOREGROUND", "--ansi-cyan"]],
|
||||
CONSOLE_GRAY_OUTPUT: [["FOREGROUND", "--ansi-white"]],
|
||||
CONSOLE_DARKGRAY_OUTPUT: [["FOREGROUND", "--ansi-bright-black"]],
|
||||
CONSOLE_RED_BRIGHT_OUTPUT: [["FOREGROUND", "--ansi-bright-red"]],
|
||||
CONSOLE_GREEN_BRIGHT_OUTPUT: [["FOREGROUND", "--ansi-bright-green"]],
|
||||
CONSOLE_YELLOW_BRIGHT_OUTPUT: [["FOREGROUND", "--ansi-bright-yellow"]],
|
||||
CONSOLE_BLUE_BRIGHT_OUTPUT: [["FOREGROUND", "--ansi-bright-blue"]],
|
||||
CONSOLE_MAGENTA_BRIGHT_OUTPUT: [["FOREGROUND", "--ansi-bright-magenta"]],
|
||||
CONSOLE_CYAN_BRIGHT_OUTPUT: [["FOREGROUND", "--ansi-bright-cyan"]],
|
||||
CONSOLE_WHITE_OUTPUT: [["FOREGROUND", "--ansi-bright-white"]],
|
||||
};
|
||||
|
||||
export interface ConvertedTheme {
|
||||
theme: { id: string; name: string; kind: "dark" | "light"; tokens: Record<string, string> };
|
||||
scheme: { name: string; tokens: Record<string, string> } | null;
|
||||
}
|
||||
|
||||
type Json = string | number | boolean | null | Json[] | { [k: string]: Json };
|
||||
|
||||
/** `"Button": {"default": {"startBackground": x}}` → `Button.default.startBackground`; `"*": {...}` → `*.prop`. */
|
||||
function flatten(obj: { [k: string]: Json }, prefix = "", out = new Map<string, string>()): Map<string, string> {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const key = prefix ? `${prefix}.${k}` : k;
|
||||
if (typeof v === "string") out.set(key, v);
|
||||
else if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const HEX = /^#?([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
||||
|
||||
/** IntelliJ colour (`#rrggbb`, `#rrggbbaa`, or a named colour from `colors`) → CSS hex. */
|
||||
function resolveColor(value: string, named: Map<string, string>, depth = 0): string | null {
|
||||
const m = HEX.exec(value.trim());
|
||||
if (m) return `#${m[1]!.toLowerCase()}`;
|
||||
const ref = named.get(value);
|
||||
return ref !== undefined && depth < 8 ? resolveColor(ref, named, depth + 1) : null;
|
||||
}
|
||||
|
||||
/** `.icls` hex: RGB without `#`, leading zeros dropped (`"0"` is black, `"ff"` is #0000ff). */
|
||||
function iclsColor(value: string): string | null {
|
||||
const v = value.trim().toLowerCase();
|
||||
if (!/^[0-9a-f]{1,8}$/.test(v)) return null;
|
||||
return `#${v.length <= 6 ? v.padStart(6, "0") : v.padStart(8, "0")}`;
|
||||
}
|
||||
|
||||
const attr = (tag: string, name: string) => new RegExp(`${name}="([^"]*)"`).exec(tag)?.[1];
|
||||
|
||||
/** Parses the colours and attributes of an `.icls` file into tokens. */
|
||||
export function convertIcls(xml: string): { name: string; dark: boolean | null; tokens: Record<string, string> } {
|
||||
const tokens: Record<string, string> = {};
|
||||
const set = (names: string[], color: string | null) => {
|
||||
if (color) for (const n of names) tokens[n] ??= color;
|
||||
};
|
||||
const schemeTag = /<scheme\b[^>]*>/.exec(xml)?.[0] ?? "";
|
||||
const name = attr(schemeTag, "name") ?? "Imported";
|
||||
const parent = attr(schemeTag, "parent_scheme") ?? "";
|
||||
const dark = parent ? /darcula|dark/i.test(parent) : null;
|
||||
|
||||
const colors = /<colors>([\s\S]*?)<\/colors>/.exec(xml)?.[1] ?? "";
|
||||
for (const m of colors.matchAll(/<option\b[^>]*\/>/g)) {
|
||||
const key = attr(m[0], "name");
|
||||
const value = attr(m[0], "value");
|
||||
if (key && value !== undefined && ICLS_COLORS[key]) set(ICLS_COLORS[key], iclsColor(value));
|
||||
}
|
||||
|
||||
const attributes = /<attributes>([\s\S]*?)<\/attributes>/.exec(xml)?.[1] ?? "";
|
||||
for (const m of attributes.matchAll(/<option name="([^"]+)"[^>]*>\s*<value>([\s\S]*?)<\/value>\s*<\/option>/g)) {
|
||||
const map = ICLS_ATTRIBUTES[m[1]!];
|
||||
if (!map) continue;
|
||||
const parts = new Map<string, string>();
|
||||
for (const o of m[2]!.matchAll(/<option\b[^>]*\/>/g)) {
|
||||
const k = attr(o[0], "name");
|
||||
const v = attr(o[0], "value");
|
||||
if (k && v !== undefined) parts.set(k, v);
|
||||
}
|
||||
for (const [part, token] of map) set([token], iclsColor(parts.get(part) ?? ""));
|
||||
// FONT_TYPE: 1 bold, 2 italic, 3 bold italic.
|
||||
if (m[1] === "DEFAULT_LINE_COMMENT" && parts.has("FONT_TYPE")) {
|
||||
tokens["--syn-comment-style"] = Number(parts.get("FONT_TYPE")) & 2 ? "italic" : "normal";
|
||||
}
|
||||
}
|
||||
if (tokens["--wave-error"]) tokens["--editor-error-decoration"] = `underline wavy ${tokens["--wave-error"]}`;
|
||||
if (tokens["--bg-editor"]) tokens["--bg-editor-gutter"] ??= tokens["--bg-editor"];
|
||||
return { name, dark, tokens };
|
||||
}
|
||||
|
||||
export function slug(name: string): string {
|
||||
return (
|
||||
name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 48) || "imported"
|
||||
);
|
||||
}
|
||||
|
||||
/** Converts an IntelliJ `.theme.json` (and optionally its `.icls`) into OpenIDEA theme files. */
|
||||
export function convertIntellijTheme(themeJson: string, icls?: string, id?: string): ConvertedTheme {
|
||||
const src = JSON.parse(stripJsonComments(themeJson)) as { [k: string]: Json };
|
||||
const name = typeof src.name === "string" ? src.name : "Imported Theme";
|
||||
const named = new Map<string, string>();
|
||||
if (src.colors && typeof src.colors === "object" && !Array.isArray(src.colors)) {
|
||||
for (const [k, v] of Object.entries(src.colors)) if (typeof v === "string") named.set(k, v);
|
||||
}
|
||||
const ui =
|
||||
src.ui && typeof src.ui === "object" && !Array.isArray(src.ui) ? flatten(src.ui) : new Map<string, string>();
|
||||
const lookup = (key: string): string | null => {
|
||||
const v = ui.get(key) ?? ui.get(`*.${key.slice(key.lastIndexOf(".") + 1)}`);
|
||||
return v === undefined ? null : resolveColor(v, named);
|
||||
};
|
||||
|
||||
const tokens: Record<string, string> = {};
|
||||
for (const [token, keys] of Object.entries(UI_KEYS)) {
|
||||
for (const key of keys) {
|
||||
const color = lookup(key);
|
||||
if (color) {
|
||||
tokens[token] = color;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const scheme = icls ? convertIcls(icls) : null;
|
||||
const dark = typeof src.dark === "boolean" ? src.dark : (scheme?.dark ?? true);
|
||||
// The editor surface sits on an island in OpenIDEA: keep it on the editor background.
|
||||
if (scheme?.tokens["--bg-editor"] && !tokens["--bg-island"]) tokens["--bg-island"] = scheme.tokens["--bg-editor"];
|
||||
return {
|
||||
theme: { id: id ?? slug(name), name, kind: dark ? "dark" : "light", tokens },
|
||||
scheme: scheme ? { name: scheme.name, tokens: scheme.tokens } : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** `plugin.toml` for a converted theme. */
|
||||
export function pluginManifest(c: ConvertedTheme, author: string, sourceName: string): string {
|
||||
const q = (s: string) => JSON.stringify(s);
|
||||
return [
|
||||
`# Converted from ${sourceName} by scripts/theme/import-intellij-theme.ts.`,
|
||||
`id = ${q(`theme-${c.theme.id}`)}`,
|
||||
`name = ${q(`${c.theme.name} Theme`)}`,
|
||||
`version = "1.0.0"`,
|
||||
`description = ${q(`${c.theme.name}, converted from an IntelliJ theme.`)}`,
|
||||
`authors = [${q(author)}]`,
|
||||
`openidea-api = "0.1"`,
|
||||
"",
|
||||
"[[contributes.themes]]",
|
||||
`path = ${q(`themes/${c.theme.id}.theme.json`)}`,
|
||||
...(c.scheme ? [`editor-scheme = ${q(`themes/${c.theme.id}.scheme.json`)}`] : []),
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
convertIcls,
|
||||
convertIntellijTheme,
|
||||
ICLS_ATTRIBUTES,
|
||||
ICLS_COLORS,
|
||||
pluginManifest,
|
||||
UI_KEYS,
|
||||
} from "./intellij.ts";
|
||||
import { referenceMarkdown, stripJsonComments, themeTokenGroups, tokenSets, withReference } from "./tokens.ts";
|
||||
|
||||
const read = (p: string) => readFileSync(p, "utf8");
|
||||
const css = read("src/styles/tokens.css");
|
||||
const groups = themeTokenGroups(css);
|
||||
const sets = tokenSets(groups);
|
||||
const TEMPLATE = "templates/theme-plugin";
|
||||
const themeSrc = read(`${TEMPLATE}/themes/harbor-night.theme.json`);
|
||||
const schemeSrc = read(`${TEMPLATE}/themes/harbor-night.scheme.json`);
|
||||
const theme = JSON.parse(stripJsonComments(themeSrc)) as { id: string; kind: string; tokens: Record<string, string> };
|
||||
const scheme = JSON.parse(stripJsonComments(schemeSrc)) as { tokens: Record<string, string> };
|
||||
|
||||
const sorted = (xs: Iterable<string>) => [...xs].sort();
|
||||
|
||||
describe("theme token reference", () => {
|
||||
it("reads every variable of the Islands Light block, grouped", () => {
|
||||
const lightBlock = /:root\[data-theme="light"\] \{([\s\S]*?)\n\}/.exec(css)![1]!;
|
||||
const declared = new Set([...lightBlock.matchAll(/(--[a-z0-9-]+)\s*:/g)].map((m) => m[1]!));
|
||||
expect(sorted([...sets.ui, ...sets.scheme])).toEqual(sorted(declared));
|
||||
expect(groups.map((g) => g.title)).toEqual(
|
||||
expect.arrayContaining(["surfaces", "syntax", "editor", "ANSI palette"]),
|
||||
);
|
||||
expect(sets.scheme.every((t) => !sets.ui.includes(t))).toBe(true);
|
||||
});
|
||||
|
||||
it("requires a section comment for every chunk of the light block", () => {
|
||||
const bad = ':root { --a: #000; }\n:root[data-theme="light"] {\n /* ok */\n --a: #fff;\n\n --b: #fff;\n}';
|
||||
expect(() => themeTokenGroups(bad)).toThrow(/--b .*section comment/);
|
||||
});
|
||||
|
||||
it("is current in docs/plugins/themes.md (run pnpm gen:theme-docs)", () => {
|
||||
const doc = read("docs/plugins/themes.md");
|
||||
expect(withReference(doc, referenceMarkdown(groups))).toBe(doc);
|
||||
});
|
||||
});
|
||||
|
||||
describe("theme plugin template", () => {
|
||||
it("sets every UI token in the theme file and every editor token in the scheme file", () => {
|
||||
// A failure here means tokens.css gained (or lost) a themeable token:
|
||||
// add it to templates/theme-plugin/themes/*.json, then run pnpm gen:theme-docs.
|
||||
expect(sorted(Object.keys(theme.tokens))).toEqual(sorted(sets.ui));
|
||||
expect(sorted(Object.keys(scheme.tokens))).toEqual(sorted(sets.scheme));
|
||||
});
|
||||
|
||||
it("groups tokens under the section comments of tokens.css", () => {
|
||||
for (const g of groups) {
|
||||
const src = g.scheme ? schemeSrc : themeSrc;
|
||||
const heading = `// ${g.title.charAt(0).toUpperCase()}${g.title.slice(1)}`;
|
||||
expect(src, heading).toContain(heading);
|
||||
const at = src.indexOf(heading);
|
||||
for (const t of g.tokens) expect(src.indexOf(`"${t.name}"`), t.name).toBeGreaterThan(at);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses only values the plugin host accepts", () => {
|
||||
for (const [k, v] of Object.entries({ ...theme.tokens, ...scheme.tokens })) {
|
||||
expect(/[;{}<>\\\n]|url\(|image\(/i.test(v), `${k}: ${v}`).toBe(false);
|
||||
}
|
||||
expect(theme.kind).toBe("dark");
|
||||
});
|
||||
|
||||
it("is referenced by its manifest", () => {
|
||||
const manifest = read(`${TEMPLATE}/plugin.toml`);
|
||||
expect(manifest).toContain('path = "themes/harbor-night.theme.json"');
|
||||
expect(manifest).toContain('editor-scheme = "themes/harbor-night.scheme.json"');
|
||||
expect(manifest).not.toMatch(/^wasm\s*=/m);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IntelliJ theme import", () => {
|
||||
const themeJson = `{
|
||||
// comment
|
||||
"name": "Deep Sea",
|
||||
"dark": true,
|
||||
"author": "Someone",
|
||||
"colors": { "base": "#101820", "accent": "#33AAFF" },
|
||||
"ui": {
|
||||
"*": { "background": "base", "foreground": "#c0c8d0", "selectionBackground": "#203040" },
|
||||
"Button": { "default": { "startBackground": "accent" } },
|
||||
"Popup.background": "#181f28",
|
||||
"Label.infoForeground": "#80888F80",
|
||||
"Tree": { "rowHeight": 24 }
|
||||
}
|
||||
}`;
|
||||
const icls = `<scheme name="Deep Sea" version="142" parent_scheme="Darcula">
|
||||
<colors>
|
||||
<option name="CARET_COLOR" value="ffcc00" />
|
||||
<option name="LINE_NUMBERS_COLOR" value="0" />
|
||||
<option name="UNKNOWN_KEY" value="123456" />
|
||||
</colors>
|
||||
<attributes>
|
||||
<option name="DEFAULT_KEYWORD">
|
||||
<value>
|
||||
<option name="FOREGROUND" value="cc7832" />
|
||||
<option name="FONT_TYPE" value="1" />
|
||||
</value>
|
||||
</option>
|
||||
<option name="DEFAULT_LINE_COMMENT">
|
||||
<value>
|
||||
<option name="FOREGROUND" value="808080" />
|
||||
<option name="FONT_TYPE" value="2" />
|
||||
</value>
|
||||
</option>
|
||||
<option name="TEXT">
|
||||
<value>
|
||||
<option name="FOREGROUND" value="a9b7c6" />
|
||||
<option name="BACKGROUND" value="2b2b2b" />
|
||||
</value>
|
||||
</option>
|
||||
<option name="ERRORS_ATTRIBUTES">
|
||||
<value>
|
||||
<option name="EFFECT_COLOR" value="ff0000" />
|
||||
<option name="EFFECT_TYPE" value="2" />
|
||||
</value>
|
||||
</option>
|
||||
<option name="DEFAULT_CONSTANT" baseAttributes="DEFAULT_IDENTIFIER" />
|
||||
</attributes>
|
||||
</scheme>`;
|
||||
|
||||
it("maps ui keys, named colours and wildcards", () => {
|
||||
const { theme: t, scheme: s } = convertIntellijTheme(themeJson);
|
||||
expect(t).toMatchObject({ id: "deep-sea", name: "Deep Sea", kind: "dark" });
|
||||
expect(t.tokens["--bg-app"]).toBe("#101820"); // *.background → named colour
|
||||
expect(t.tokens["--accent"]).toBe("#33aaff");
|
||||
expect(t.tokens["--bg-popup"]).toBe("#181f28");
|
||||
expect(t.tokens["--bg-selected"]).toBe("#203040");
|
||||
expect(t.tokens["--fg-muted"]).toBe("#80888f80");
|
||||
expect(t.tokens["--fg"]).toBe("#c0c8d0");
|
||||
expect(s).toBeNull();
|
||||
});
|
||||
|
||||
it("only targets tokens that exist in tokens.css", () => {
|
||||
const declared = new Set([...css.matchAll(/(--[a-z0-9-]+)\s*:/g)].map((m) => m[1]!));
|
||||
// Read with a fallback by the editor (no tokens.css default), still themeable.
|
||||
declared.add("--border-method-separator");
|
||||
const targets = [
|
||||
...Object.keys(UI_KEYS),
|
||||
...Object.values(ICLS_COLORS).flat(),
|
||||
...Object.values(ICLS_ATTRIBUTES).flatMap((parts) => parts.map(([, token]) => token)),
|
||||
];
|
||||
expect(targets.filter((t) => !declared.has(t))).toEqual([]);
|
||||
});
|
||||
|
||||
it("maps .icls colours and attributes", () => {
|
||||
const s = convertIcls(icls);
|
||||
expect(s.name).toBe("Deep Sea");
|
||||
expect(s.dark).toBe(true);
|
||||
expect(s.tokens).toMatchObject({
|
||||
"--caret": "#ffcc00",
|
||||
"--fg-line-number": "#000000",
|
||||
"--syn-keyword": "#cc7832",
|
||||
"--syn-comment": "#808080",
|
||||
"--syn-comment-style": "italic",
|
||||
"--fg-editor": "#a9b7c6",
|
||||
"--bg-editor": "#2b2b2b",
|
||||
"--bg-editor-gutter": "#2b2b2b",
|
||||
"--wave-error": "#ff0000",
|
||||
"--editor-error-decoration": "underline wavy #ff0000",
|
||||
});
|
||||
expect(s.tokens["--syn-constant"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("produces an installable package", () => {
|
||||
const c = convertIntellijTheme(themeJson.replace('"dark": true,', ""), icls.replace("Darcula", "Default"), "sea");
|
||||
expect(c.theme.kind).toBe("light");
|
||||
expect(c.theme.id).toBe("sea");
|
||||
expect(c.theme.tokens["--bg-island"]).toBe("#101820"); // ToolWindow ← *.background wins over the editor
|
||||
const toml = pluginManifest(c, "Someone", "DeepSea.theme.json");
|
||||
expect(toml).toContain('id = "theme-sea"');
|
||||
expect(toml).toContain('editor-scheme = "themes/sea.scheme.json"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* The themeable design tokens, read from `src/styles/tokens.css`: every
|
||||
* variable of the `:root[data-theme="light"]` block (it redefines each colour
|
||||
* token of the dark `:root`), grouped by the block's section comments.
|
||||
* Sections named in `SCHEME_GROUPS` belong in a theme's editor colour scheme
|
||||
* file, the rest in its UI theme file. Pure functions: no Node APIs.
|
||||
*/
|
||||
|
||||
export interface ThemeToken {
|
||||
name: string;
|
||||
/** Islands Dark value (`:root`). */
|
||||
dark: string;
|
||||
/** Islands Light value. */
|
||||
light: string;
|
||||
}
|
||||
|
||||
export interface TokenGroup {
|
||||
/** Section comment of tokens.css, without its parenthetical. */
|
||||
title: string;
|
||||
/** True when the group belongs in the editor colour scheme file. */
|
||||
scheme: boolean;
|
||||
tokens: ThemeToken[];
|
||||
}
|
||||
|
||||
/** tokens.css sections that IntelliJ keeps in an editor scheme (`.icls`). */
|
||||
export const SCHEME_GROUPS = ["syntax", "editor", "ANSI palette"];
|
||||
|
||||
const LIGHT_SELECTOR = ':root[data-theme="light"]';
|
||||
|
||||
/** Raw bodies (comments kept) of top-level rules whose selector list is exactly `selector`. */
|
||||
function ruleBodies(css: string, selector: string): string[] {
|
||||
const out: string[] = [];
|
||||
let depth = 0;
|
||||
let start = 0;
|
||||
let head = "";
|
||||
for (let i = 0; i < css.length; i++) {
|
||||
if (css.startsWith("/*", i)) {
|
||||
const end = css.indexOf("*/", i + 2);
|
||||
i = end < 0 ? css.length : end + 1;
|
||||
continue;
|
||||
}
|
||||
const ch = css[i];
|
||||
if (ch === "{") {
|
||||
if (depth === 0) {
|
||||
head = css
|
||||
.slice(start, i)
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.trim();
|
||||
start = i + 1;
|
||||
}
|
||||
depth++;
|
||||
} else if (ch === "}") {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
if (head === selector) out.push(css.slice(start, i));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const DECL = /^\s*(--[a-z0-9-]+)\s*:\s*(.+?);\s*$/;
|
||||
|
||||
function declarations(body: string): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
for (const line of body.replace(/\/\*[\s\S]*?\*\//g, "").split("\n")) {
|
||||
const m = DECL.exec(line);
|
||||
if (m) out.set(m[1]!, m[2]!);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Themeable tokens in tokens.css order. Throws when a blank-line separated
|
||||
* chunk of the light block has no section comment, so new tokens always land
|
||||
* in a named group.
|
||||
*/
|
||||
export function themeTokenGroups(css: string): TokenGroup[] {
|
||||
const [lightBody] = ruleBodies(css, LIGHT_SELECTOR);
|
||||
if (lightBody === undefined) throw new Error(`tokens.css has no ${LIGHT_SELECTOR} block`);
|
||||
const dark = new Map<string, string>();
|
||||
for (const body of ruleBodies(css, ":root")) for (const [k, v] of declarations(body)) dark.set(k, v);
|
||||
|
||||
const groups: TokenGroup[] = [];
|
||||
const placed = new Map<string, TokenGroup>();
|
||||
let current: TokenGroup | null = null;
|
||||
let chunkHasComment = false;
|
||||
const lines = lightBody.replace(/\/\*\s*\n[\s\S]*?\*\//g, "").split("\n");
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
chunkHasComment = false;
|
||||
current = null;
|
||||
continue;
|
||||
}
|
||||
const comment = /^\/\*\s*(.*?)\s*\*\/$/.exec(trimmed);
|
||||
if (comment) {
|
||||
const title = comment[1]!
|
||||
.replace(/\s*\(.*$/, "")
|
||||
.replace(/\s+as\s.*$/, "")
|
||||
.trim();
|
||||
current = { title, scheme: SCHEME_GROUPS.some((g) => title.startsWith(g)), tokens: [] };
|
||||
groups.push(current);
|
||||
chunkHasComment = true;
|
||||
continue;
|
||||
}
|
||||
const m = DECL.exec(line);
|
||||
if (!m) continue;
|
||||
if (!current || !chunkHasComment) {
|
||||
throw new Error(`tokens.css: ${m[1]} in ${LIGHT_SELECTOR} needs a section comment (/* group */) above its chunk`);
|
||||
}
|
||||
const name = m[1]!;
|
||||
// A token listed twice stays in the later group (the redefinition wins in CSS too).
|
||||
const prev = placed.get(name);
|
||||
if (prev) prev.tokens = prev.tokens.filter((t) => t.name !== name);
|
||||
current.tokens.push({ name, light: m[2]!, dark: dark.get(name) ?? m[2]! });
|
||||
placed.set(name, current);
|
||||
}
|
||||
return groups.filter((g) => g.tokens.length > 0);
|
||||
}
|
||||
|
||||
/** Every themeable token name, split into UI theme and editor scheme sets. */
|
||||
export function tokenSets(groups: readonly TokenGroup[]): { ui: string[]; scheme: string[] } {
|
||||
const ui: string[] = [];
|
||||
const scheme: string[] = [];
|
||||
for (const g of groups) (g.scheme ? scheme : ui).push(...g.tokens.map((t) => t.name));
|
||||
return { ui, scheme };
|
||||
}
|
||||
|
||||
const cell = (v: string) => "`" + v.replace(/\|/g, "\\|") + "`";
|
||||
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
||||
|
||||
/** Markdown token reference (one table per group) for docs/plugins/themes.md. */
|
||||
export function referenceMarkdown(groups: readonly TokenGroup[]): string {
|
||||
const { ui, scheme } = tokenSets(groups);
|
||||
const parts = [
|
||||
`${ui.length + scheme.length} themeable tokens: ${ui.length} in the UI theme file, ${scheme.length} in the editor colour scheme file.`,
|
||||
];
|
||||
for (const g of groups) {
|
||||
parts.push(
|
||||
`### ${capitalize(g.title)} (${g.scheme ? "editor scheme" : "UI theme"})`,
|
||||
[
|
||||
"| Token | Islands Dark | Islands Light |",
|
||||
"|---|---|---|",
|
||||
...g.tokens.map((t) => `| ${cell(t.name)} | ${cell(t.dark)} | ${cell(t.light)} |`),
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
export const REFERENCE_BEGIN = "<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->";
|
||||
export const REFERENCE_END = "<!-- END GENERATED TOKEN REFERENCE -->";
|
||||
|
||||
/** `doc` with the generated reference section replaced. */
|
||||
export function withReference(doc: string, reference: string): string {
|
||||
const begin = doc.indexOf(REFERENCE_BEGIN);
|
||||
const end = doc.indexOf(REFERENCE_END);
|
||||
if (begin < 0 || end < begin) throw new Error("token reference markers not found");
|
||||
return `${doc.slice(0, begin + REFERENCE_BEGIN.length)}\n\n${reference}\n\n${doc.slice(end)}`;
|
||||
}
|
||||
|
||||
/** JSON with `//` and `/* *\/` comments removed outside strings (as the plugin host accepts). */
|
||||
export function stripJsonComments(src: string): string {
|
||||
let out = "";
|
||||
let inStr = false;
|
||||
let escaped = false;
|
||||
for (let i = 0; i < src.length; i++) {
|
||||
const c = src[i]!;
|
||||
if (inStr) {
|
||||
out += c;
|
||||
if (escaped) escaped = false;
|
||||
else if (c === "\\") escaped = true;
|
||||
else if (c === '"') inStr = false;
|
||||
continue;
|
||||
}
|
||||
if (c === "/" && src[i + 1] === "/") {
|
||||
const nl = src.indexOf("\n", i);
|
||||
if (nl < 0) break;
|
||||
i = nl - 1;
|
||||
} else if (c === "/" && src[i + 1] === "*") {
|
||||
const end = src.indexOf("*/", i + 2);
|
||||
out += src.slice(i, end < 0 ? src.length : end).replace(/[^\n]/g, "") + " ";
|
||||
i = end < 0 ? src.length : end + 1;
|
||||
} else {
|
||||
inStr = c === '"';
|
||||
out += c;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача