| 1 | import { |
| 2 | createVividThemeColors, |
| 3 | getStandardThemeColors, |
| 4 | getThemeModes, |
| 5 | isReservedThemeId, |
| 6 | parseThemeFile, |
| 7 | themeColorToHex, |
| 8 | themeIdFromName, |
| 9 | THEME_FILE_VERSION, |
| 10 | type ThemeAppearance, |
| 11 | type ThemeColorRole, |
| 12 | type ThemeDefinition, |
| 13 | } from "./themePalette"; |
| 14 | |
| 15 | /** |
| 16 | * Best-effort import of a VS Code color theme (`*-color-theme.json`). |
| 17 | * |
| 18 | * VS Code themes describe editor chrome, not an app palette: they carry a few |
| 19 | * hundred workbench keys, leave most of them unset, and freely use 8-digit |
| 20 | * hex with alpha for overlays. The conversion derives a complete palette from |
| 21 | * a visible accent and the editor background, then layers usable workbench |
| 22 | * colors on top. Foregrounds must remain readable, while authored focus and |
| 23 | * control candidates must remain distinct from adjacent surfaces and states. |
| 24 | */ |
| 25 | |
| 26 | type VsCodeRgba = { r: number; g: number; b: number; a: number }; |
| 27 | type VsCodeRgb = { r: number; g: number; b: number }; |
| 28 | |
| 29 | function isRecord(value: unknown): value is Record<string, unknown> { |
| 30 | return typeof value === "object" && value !== null && !Array.isArray(value); |
| 31 | } |
| 32 | |
| 33 | /** sRGB transfer function, and its inverse, shared by the wide-gamut path. */ |
| 34 | function decodeGamma(value: number): number { |
| 35 | return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; |
| 36 | } |
| 37 | |
| 38 | function encodeGamma(value: number): number { |
| 39 | const clamped = Math.max(0, Math.min(1, value)); |
| 40 | return clamped <= 0.0031308 ? clamped * 12.92 : 1.055 * clamped ** (1 / 2.4) - 0.055; |
| 41 | } |
| 42 | |
| 43 | /** |
| 44 | * `color(display-p3 r g b / a)` shows up in themes authored for wide-gamut |
| 45 | * displays. Out-of-gamut colors clip to the sRGB edge, which is what a browser |
| 46 | * on an sRGB screen shows anyway. |
| 47 | */ |
| 48 | function parseColorFunction(value: string): VsCodeRgba | null { |
| 49 | const match = /^color\(\s*(display-p3|srgb)\s+([^)]+)\)$/i.exec(value); |
| 50 | if (!match) return null; |
| 51 | const [space, body] = [match[1]!.toLowerCase(), match[2]!]; |
| 52 | const [channelPart, alphaPart] = body.split("/"); |
| 53 | const channels = channelPart! |
| 54 | .trim() |
| 55 | .split(/\s+/) |
| 56 | .map((part) => (part.endsWith("%") ? Number.parseFloat(part) / 100 : Number.parseFloat(part))); |
| 57 | if (channels.length !== 3 || channels.some((channel) => !Number.isFinite(channel))) return null; |
| 58 | const alphaRaw = alphaPart?.trim(); |
| 59 | const alpha = |
| 60 | alphaRaw === undefined |
| 61 | ? 1 |
| 62 | : alphaRaw.endsWith("%") |
| 63 | ? Number.parseFloat(alphaRaw) / 100 |
| 64 | : Number.parseFloat(alphaRaw); |
| 65 | if (!Number.isFinite(alpha)) return null; |
| 66 | |
| 67 | const [red, green, blue] = channels as [number, number, number]; |
| 68 | if (space === "srgb") { |
| 69 | return { r: red * 255, g: green * 255, b: blue * 255, a: Math.max(0, Math.min(1, alpha)) }; |
| 70 | } |
| 71 | const [linearRed, linearGreen, linearBlue] = [red, green, blue].map(decodeGamma) as [ |
| 72 | number, |
| 73 | number, |
| 74 | number, |
| 75 | ]; |
| 76 | // Display P3 linear -> sRGB linear. |
| 77 | const srgb = [ |
| 78 | 1.2249401762805 * linearRed - 0.2249401762805 * linearGreen, |
| 79 | -0.042056961239 * linearRed + 1.042056961239 * linearGreen, |
| 80 | -0.0196375547643 * linearRed - 0.0786360655012 * linearGreen + 1.0982736202656 * linearBlue, |
| 81 | ].map((channel) => encodeGamma(channel) * 255) as [number, number, number]; |
| 82 | return { r: srgb[0], g: srgb[1], b: srgb[2], a: Math.max(0, Math.min(1, alpha)) }; |
| 83 | } |
| 84 | |
| 85 | /** VS Code accepts #RGB, #RGBA, #RRGGBB, and #RRGGBBAA; some themes also use |
| 86 | * CSS color() notation for wide-gamut palettes. */ |
| 87 | function parseVsCodeColor(value: unknown): VsCodeRgba | null { |
| 88 | if (typeof value !== "string") return null; |
| 89 | const trimmed = value.trim(); |
| 90 | if (trimmed.startsWith("color(")) return parseColorFunction(trimmed); |
| 91 | const hex = trimmed.replace(/^#/, ""); |
| 92 | if (!/^(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(hex)) return null; |
| 93 | const expand = (part: string) => |
| 94 | part.length === 1 ? Number.parseInt(part + part, 16) : Number.parseInt(part, 16); |
| 95 | if (hex.length <= 4) { |
| 96 | return { |
| 97 | r: expand(hex[0]!), |
| 98 | g: expand(hex[1]!), |
| 99 | b: expand(hex[2]!), |
| 100 | a: hex.length === 4 ? expand(hex[3]!) / 255 : 1, |
| 101 | }; |
| 102 | } |
| 103 | return { |
| 104 | r: Number.parseInt(hex.slice(0, 2), 16), |
| 105 | g: Number.parseInt(hex.slice(2, 4), 16), |
| 106 | b: Number.parseInt(hex.slice(4, 6), 16), |
| 107 | a: hex.length === 8 ? Number.parseInt(hex.slice(6, 8), 16) / 255 : 1, |
| 108 | }; |
| 109 | } |
| 110 | |
| 111 | function toHex(color: VsCodeRgb): string { |
| 112 | const channel = (value: number) => |
| 113 | Math.max(0, Math.min(255, Math.round(value))) |
| 114 | .toString(16) |
| 115 | .padStart(2, "0"); |
| 116 | return `#${channel(color.r)}${channel(color.g)}${channel(color.b)}`; |
| 117 | } |
| 118 | |
| 119 | /** Overlays are semi-transparent in VS Code; our roles are opaque, so they are |
| 120 | * composited onto whatever surface they sit on. */ |
| 121 | function flattenOver(color: VsCodeRgba, base: VsCodeRgb): string { |
| 122 | if (color.a >= 1) return toHex(color); |
| 123 | return toHex({ |
| 124 | r: color.r * color.a + base.r * (1 - color.a), |
| 125 | g: color.g * color.a + base.g * (1 - color.a), |
| 126 | b: color.b * color.a + base.b * (1 - color.a), |
| 127 | }); |
| 128 | } |
| 129 | |
| 130 | function relativeLuminance(color: VsCodeRgb): number { |
| 131 | const channel = (value: number) => { |
| 132 | const ratio = value / 255; |
| 133 | return ratio <= 0.03928 ? ratio / 12.92 : ((ratio + 0.055) / 1.055) ** 2.4; |
| 134 | }; |
| 135 | return 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b); |
| 136 | } |
| 137 | |
| 138 | function contrastRatio(first: VsCodeRgb, second: VsCodeRgb): number { |
| 139 | const a = relativeLuminance(first); |
| 140 | const b = relativeLuminance(second); |
| 141 | return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); |
| 142 | } |
| 143 | |
| 144 | function hexToRgb(value: string): VsCodeRgb { |
| 145 | return parseVsCodeColor(themeColorToHex(value) ?? value) ?? { r: 0, g: 0, b: 0, a: 1 }; |
| 146 | } |
| 147 | |
| 148 | /** |
| 149 | * A VS Code theme is recognised by its workbench colors: the keys are dotted |
| 150 | * paths (`editor.background`), which our own files never use. |
| 151 | */ |
| 152 | export function isVsCodeThemeFile(value: unknown): boolean { |
| 153 | if (!isRecord(value)) return false; |
| 154 | if (value.version === THEME_FILE_VERSION) return false; |
| 155 | const hasWorkbenchColors = |
| 156 | isRecord(value.colors) && Object.keys(value.colors).some((key) => key.includes(".")); |
| 157 | return hasWorkbenchColors || Array.isArray(value.tokenColors); |
| 158 | } |
| 159 | |
| 160 | function resolveAppearance(value: Record<string, unknown>, canvas: VsCodeRgb): ThemeAppearance { |
| 161 | const type = typeof value.type === "string" ? value.type.toLowerCase() : null; |
| 162 | if (type === "light" || type === "hc-light") return "light"; |
| 163 | if (type === "dark" || type === "hc-black") return "dark"; |
| 164 | // Unlabelled themes (and the odd custom `type`) follow the editor surface. |
| 165 | return relativeLuminance(canvas) < 0.179 ? "dark" : "light"; |
| 166 | } |
| 167 | |
| 168 | /** Extension `name` fields are often package slugs; read them as words. */ |
| 169 | export function humanizeThemeName(raw: string): string { |
| 170 | const trimmed = raw.trim(); |
| 171 | if (/\s/.test(trimmed) || !/[-_.]/.test(trimmed)) return trimmed; |
| 172 | return trimmed |
| 173 | .split(/[-_.]+/) |
| 174 | .filter(Boolean) |
| 175 | .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) |
| 176 | .join(" "); |
| 177 | } |
| 178 | |
| 179 | function resolveName(value: Record<string, unknown>): string { |
| 180 | // Judge candidates by their humanized form: a displayName of "---" |
| 181 | // humanizes to nothing and must fall through to the name. |
| 182 | for (const candidate of [value.displayName, value.name]) { |
| 183 | if (typeof candidate !== "string") continue; |
| 184 | const humanized = humanizeThemeName(candidate); |
| 185 | if (humanized.length > 0) return humanized.slice(0, 48); |
| 186 | } |
| 187 | return "VS Code theme"; |
| 188 | } |
| 189 | |
| 190 | export function parseVsCodeThemeFile(value: unknown): ThemeDefinition { |
| 191 | if (!isRecord(value)) throw new Error("Theme files must contain a JSON object."); |
| 192 | const colors = isRecord(value.colors) ? value.colors : {}; |
| 193 | |
| 194 | /** First key that carries a usable color, in priority order. */ |
| 195 | const pick = (...keys: ReadonlyArray<string>): VsCodeRgba | null => { |
| 196 | for (const key of keys) { |
| 197 | const parsed = parseVsCodeColor(colors[key]); |
| 198 | if (parsed) return parsed; |
| 199 | } |
| 200 | return null; |
| 201 | }; |
| 202 | const solidOver = (base: VsCodeRgb, ...keys: ReadonlyArray<string>): string | null => { |
| 203 | const parsed = pick(...keys); |
| 204 | return parsed ? flattenOver(parsed, base) : null; |
| 205 | }; |
| 206 | |
| 207 | const canvasColor = pick("editor.background", "editorPane.background"); |
| 208 | if (!canvasColor) { |
| 209 | throw new Error( |
| 210 | 'That VS Code theme has no "editor.background" color, so there is nothing to build a palette from.', |
| 211 | ); |
| 212 | } |
| 213 | const canvas = { r: canvasColor.r, g: canvasColor.g, b: canvasColor.b }; |
| 214 | const appearance = resolveAppearance(value, canvas); |
| 215 | |
| 216 | const canvasHex = toHex(canvas); |
| 217 | const raisedSurfaceCandidateHex = solidOver( |
| 218 | canvas, |
| 219 | "editorWidget.background", |
| 220 | "dropdown.background", |
| 221 | ); |
| 222 | |
| 223 | /** Accent and control candidates must clear this small separation floor |
| 224 | * from every adjacent surface or state checked by the importer. */ |
| 225 | const standsApart = (first: string, second: string) => |
| 226 | contrastRatio(hexToRgb(first), hexToRgb(second)) >= 1.1; |
| 227 | |
| 228 | let accentColor: VsCodeRgba | null = null; |
| 229 | let accentHex: string | null = null; |
| 230 | for (const key of [ |
| 231 | "focusBorder", |
| 232 | "button.background", |
| 233 | "textLink.foreground", |
| 234 | "activityBarBadge.background", |
| 235 | "progressBar.background", |
| 236 | "badge.background", |
| 237 | ]) { |
| 238 | const candidate = parseVsCodeColor(colors[key]); |
| 239 | if (!candidate) continue; |
| 240 | const candidateHex = flattenOver(candidate, canvas); |
| 241 | if ( |
| 242 | !standsApart(candidateHex, canvasHex) || |
| 243 | (raisedSurfaceCandidateHex !== null && !standsApart(candidateHex, raisedSurfaceCandidateHex)) |
| 244 | ) |
| 245 | continue; |
| 246 | accentColor = candidate; |
| 247 | accentHex = candidateHex; |
| 248 | break; |
| 249 | } |
| 250 | if (!accentColor || !accentHex) { |
| 251 | const standardAccentHex = themeColorToHex(getStandardThemeColors(appearance).accent)!; |
| 252 | accentHex = |
| 253 | [standardAccentHex, "#ffffff", "#000000"].find( |
| 254 | (candidate) => |
| 255 | standsApart(candidate, canvasHex) && |
| 256 | (raisedSurfaceCandidateHex === null || standsApart(candidate, raisedSurfaceCandidateHex)), |
| 257 | ) ?? standardAccentHex; |
| 258 | accentColor = parseVsCodeColor(accentHex)!; |
| 259 | } |
| 260 | |
| 261 | // The derived palette is the floor: every role starts contrast-solved, then |
| 262 | // the theme's own workbench colors replace what it actually specified. The |
| 263 | // floor derives from a muted accent -- the vivid engine carries the accent |
| 264 | // hue into every surface, which washes an imported neutral palette (a gray |
| 265 | // theme with a blue focusBorder would get blue code and text surfaces). |
| 266 | const mutedAccentHex = flattenOver( |
| 267 | { r: accentColor.r, g: accentColor.g, b: accentColor.b, a: 0.2 }, |
| 268 | canvas, |
| 269 | ); |
| 270 | const derived = createVividThemeColors(appearance, canvasHex, mutedAccentHex); |
| 271 | const sidebarHex = |
| 272 | solidOver(canvas, "sideBar.background", "activityBar.background") ?? derived.sidebar; |
| 273 | const sidebar = hexToRgb(sidebarHex); |
| 274 | const terminalHex = |
| 275 | solidOver(canvas, "terminal.background", "panel.background") ?? derived.terminalBackground; |
| 276 | const terminal = hexToRgb(terminalHex); |
| 277 | |
| 278 | /** Foregrounds only win when they stay readable on the surface they land on; |
| 279 | * a theme tuned for its own chrome can be unreadable on ours. */ |
| 280 | const readableOn = ( |
| 281 | surface: string, |
| 282 | fallback: string, |
| 283 | ...keys: ReadonlyArray<string> |
| 284 | ): string => { |
| 285 | const surfaceRgb = hexToRgb(surface); |
| 286 | const isReadable = (candidate: string) => contrastRatio(hexToRgb(candidate), surfaceRgb) >= 4.5; |
| 287 | const specified = solidOver(surfaceRgb, ...keys); |
| 288 | if (specified && isReadable(specified)) return specified; |
| 289 | // The derived fallback was solved against the derived surface. When the |
| 290 | // file replaced that surface (a light sideBar in a dark theme, say), the |
| 291 | // fallback has to clear the bar there too, or the text falls back to the |
| 292 | // readable end of the greyscale for the surface actually in play. |
| 293 | if (isReadable(fallback)) return fallback; |
| 294 | return relativeLuminance(surfaceRgb) < 0.179 ? "#ffffff" : "#000000"; |
| 295 | }; |
| 296 | |
| 297 | const surfaceRaisedHex = raisedSurfaceCandidateHex ?? derived.surfaceRaised; |
| 298 | // The checked switch track maps to messageAction, so resolve it before |
| 299 | // choosing the input role used by the unchecked track. |
| 300 | const buttonHex = solidOver(canvas, "button.background"); |
| 301 | const actionHex = |
| 302 | buttonHex && standsApart(buttonHex, canvasHex) && standsApart(buttonHex, surfaceRaisedHex) |
| 303 | ? buttonHex |
| 304 | : accentHex; |
| 305 | const inputCandidates = [ |
| 306 | derived.input, |
| 307 | derived.surfaceRaised, |
| 308 | getStandardThemeColors(appearance).input, |
| 309 | "#000000", |
| 310 | "#ffffff", |
| 311 | "#808080", |
| 312 | ]; |
| 313 | let inputHex = |
| 314 | inputCandidates.find( |
| 315 | (candidate) => standsApart(candidate, canvasHex) && standsApart(candidate, actionHex), |
| 316 | ) ?? "#808080"; |
| 317 | for (const key of ["input.background", "input.border"]) { |
| 318 | const candidate = solidOver(canvas, key); |
| 319 | if (candidate && standsApart(candidate, canvasHex) && standsApart(candidate, actionHex)) { |
| 320 | inputHex = candidate; |
| 321 | break; |
| 322 | } |
| 323 | } |
| 324 | // Find overlays can cover prose, code, or message bubbles. Flatten them onto |
| 325 | // the editor canvas so each imported foreground has one predictable surface. |
| 326 | const searchMatchBackground = |
| 327 | solidOver(canvas, "editor.findMatchHighlightBackground") ?? derived.searchMatchBackground; |
| 328 | const searchMatchActiveBackground = |
| 329 | solidOver(canvas, "editor.findMatchBackground") ?? derived.searchMatchActiveBackground; |
| 330 | |
| 331 | const overrides: Partial<Record<ThemeColorRole, string>> = { |
| 332 | canvas: canvasHex, |
| 333 | text: readableOn(canvasHex, derived.text, "editor.foreground", "foreground"), |
| 334 | textMuted: readableOn( |
| 335 | canvasHex, |
| 336 | derived.textMuted, |
| 337 | "descriptionForeground", |
| 338 | "disabledForeground", |
| 339 | ), |
| 340 | surface: solidOver(canvas, "editorWidget.background") ?? derived.surface, |
| 341 | surfaceRaised: surfaceRaisedHex, |
| 342 | surfaceOverlay: |
| 343 | solidOver(canvas, "menu.background", "quickInput.background", "dropdown.background") ?? |
| 344 | derived.surfaceOverlay, |
| 345 | border: |
| 346 | solidOver(canvas, "panel.border", "editorGroup.border", "contrastBorder") ?? derived.border, |
| 347 | input: inputHex, |
| 348 | placeholder: readableOn(surfaceRaisedHex, derived.placeholder, "input.placeholderForeground"), |
| 349 | error: readableOn(canvasHex, derived.error, "editorError.foreground", "errorForeground"), |
| 350 | warning: readableOn(canvasHex, derived.warning, "editorWarning.foreground"), |
| 351 | accentSurface: |
| 352 | solidOver(canvas, "list.activeSelectionBackground", "list.hoverBackground") ?? |
| 353 | derived.accentSurface, |
| 354 | codeBackground: solidOver(canvas, "textCodeBlock.background") ?? derived.codeBackground, |
| 355 | searchMatchBackground, |
| 356 | searchMatchForeground: readableOn( |
| 357 | searchMatchBackground, |
| 358 | derived.searchMatchForeground, |
| 359 | "editor.findMatchHighlightForeground", |
| 360 | "editor.foreground", |
| 361 | "foreground", |
| 362 | ), |
| 363 | searchMatchActiveBackground, |
| 364 | searchMatchActiveForeground: readableOn( |
| 365 | searchMatchActiveBackground, |
| 366 | derived.searchMatchActiveForeground, |
| 367 | "editor.findMatchForeground", |
| 368 | "editor.foreground", |
| 369 | "foreground", |
| 370 | ), |
| 371 | sidebar: sidebarHex, |
| 372 | sidebarForeground: readableOn(sidebarHex, derived.sidebarForeground, "sideBar.foreground"), |
| 373 | sidebarBorder: solidOver(sidebar, "sideBar.border") ?? derived.sidebarBorder, |
| 374 | sidebarRowHover: solidOver(sidebar, "list.hoverBackground") ?? derived.sidebarRowHover, |
| 375 | sidebarRowActive: |
| 376 | solidOver(sidebar, "list.inactiveSelectionBackground", "list.hoverBackground") ?? |
| 377 | derived.sidebarRowActive, |
| 378 | sidebarRowSelected: |
| 379 | solidOver(sidebar, "list.activeSelectionBackground") ?? derived.sidebarRowSelected, |
| 380 | terminalBackground: terminalHex, |
| 381 | terminalForeground: readableOn(terminalHex, derived.terminalForeground, "terminal.foreground"), |
| 382 | terminalCursor: |
| 383 | solidOver(terminal, "terminalCursor.foreground", "editorCursor.foreground") ?? |
| 384 | derived.terminalCursor, |
| 385 | terminalSelection: |
| 386 | solidOver(terminal, "terminal.selectionBackground", "editor.selectionBackground") ?? |
| 387 | derived.terminalSelection, |
| 388 | terminalScrollbar: |
| 389 | solidOver(terminal, "scrollbarSlider.background") ?? derived.terminalScrollbar, |
| 390 | }; |
| 391 | overrides.accent = accentHex; |
| 392 | overrides.focus = accentHex; |
| 393 | overrides.messageAction = actionHex; |
| 394 | overrides.messageActionForeground = readableOn( |
| 395 | actionHex, |
| 396 | derived.messageActionForeground, |
| 397 | "button.foreground", |
| 398 | ); |
| 399 | overrides.accentForeground = readableOn(accentHex, derived.accentForeground, "button.foreground"); |
| 400 | |
| 401 | // Reuse the theme-file parser so ids, names, and color values go through the |
| 402 | // same validation as a hand-written file. |
| 403 | const name = resolveName(value); |
| 404 | const generatedId = themeIdFromName(name); |
| 405 | return parseThemeFile({ |
| 406 | version: THEME_FILE_VERSION, |
| 407 | ...(isReservedThemeId(generatedId) ? { id: `${generatedId}-vscode` } : {}), |
| 408 | name, |
| 409 | appearance, |
| 410 | colors: { ...derived, ...overrides }, |
| 411 | }); |
| 412 | } |
| 413 | |
| 414 | /** |
| 415 | * Extensions ship a family of themes (GitHub: dark, light, dark-colorblind, |
| 416 | * light-colorblind, dark-dimmed, ...). When several are imported together, |
| 417 | * a light and a dark file whose names differ only by the appearance word |
| 418 | * become one dual-mode theme; everything else stays its own theme. |
| 419 | */ |
| 420 | export function pairVsCodeThemes( |
| 421 | themes: ReadonlyArray<ThemeDefinition>, |
| 422 | options?: { |
| 423 | pairedId?: (light: ThemeDefinition, dark: ThemeDefinition) => string; |
| 424 | }, |
| 425 | ): ReadonlyArray<ThemeDefinition> { |
| 426 | const stripAppearance = (label: string) => |
| 427 | label |
| 428 | .replace(/\b(?:light|dark)\b/gi, " ") |
| 429 | .replace(/\s+/g, " ") |
| 430 | .trim(); |
| 431 | |
| 432 | type Group = { light: ThemeDefinition[]; dark: ThemeDefinition[]; order: number }; |
| 433 | const groups = new Map<string, Group>(); |
| 434 | const passthrough: Array<{ theme: ThemeDefinition; order: number }> = []; |
| 435 | themes.forEach((theme, order) => { |
| 436 | // Only single-appearance themes with an appearance word in the name can |
| 437 | // pair, and only when the rest of the name can still identify the pair |
| 438 | // ("Dark+" and "Light+" would pair as "+"); anything else is already |
| 439 | // what the user asked for. |
| 440 | const key = stripAppearance(theme.label); |
| 441 | if (getThemeModes(theme).length !== 1 || key === theme.label || !/[a-z0-9]/i.test(key)) { |
| 442 | passthrough.push({ theme, order }); |
| 443 | return; |
| 444 | } |
| 445 | const group = groups.get(key) ?? { light: [], dark: [], order }; |
| 446 | group[theme.appearance].push(theme); |
| 447 | groups.set(key, group); |
| 448 | }); |
| 449 | |
| 450 | const paired: Array<{ theme: ThemeDefinition; order: number }> = []; |
| 451 | for (const [key, group] of groups) { |
| 452 | // Ambiguity (two darks for one light) is not guessed at, and a pair |
| 453 | // whose stripped name collides with a built-in id ("Grove Light" + |
| 454 | // "Grove Dark" -> the reserved "grove") stays two single themes rather |
| 455 | // than failing the whole batch. |
| 456 | if (group.light.length === 1 && group.dark.length === 1) { |
| 457 | try { |
| 458 | paired.push({ |
| 459 | order: group.order, |
| 460 | theme: parseThemeFile({ |
| 461 | version: THEME_FILE_VERSION, |
| 462 | ...(options?.pairedId ? { id: options.pairedId(group.light[0]!, group.dark[0]!) } : {}), |
| 463 | name: key, |
| 464 | appearance: "light", |
| 465 | colors: group.light[0]!.colors, |
| 466 | variants: { dark: group.dark[0]!.colors }, |
| 467 | }), |
| 468 | }); |
| 469 | continue; |
| 470 | } catch { |
| 471 | // Fall through to the individual themes below. |
| 472 | } |
| 473 | } |
| 474 | for (const theme of [...group.light, ...group.dark]) { |
| 475 | paired.push({ theme, order: group.order }); |
| 476 | } |
| 477 | } |
| 478 | |
| 479 | return [...passthrough, ...paired].sort((a, b) => a.order - b.order).map((entry) => entry.theme); |
| 480 | } |
| 481 | |
| 482 | /** |
| 483 | * Some extensions reuse one display name across every file: Dracula ships |
| 484 | * dracula.json and dracula-soft.json that both say "Dracula". When a batch |
| 485 | * carries the same id twice, the file names are the only thing that tells |
| 486 | * the variants apart, so colliding entries are relabelled from their file |
| 487 | * name (and numbered only when even that collides). |
| 488 | */ |
| 489 | export function resolveThemeLabelCollisions( |
| 490 | entries: ReadonlyArray<{ theme: ThemeDefinition; sourceName?: string | undefined }>, |
| 491 | ): ReadonlyArray<ThemeDefinition> { |
| 492 | // Null when the new name is unusable (reserved id, invalid characters). |
| 493 | const rename = (theme: ThemeDefinition, name: string): ThemeDefinition | null => { |
| 494 | try { |
| 495 | return parseThemeFile({ |
| 496 | version: THEME_FILE_VERSION, |
| 497 | name: name.slice(0, 48), |
| 498 | appearance: theme.appearance, |
| 499 | colors: theme.colors, |
| 500 | ...(theme.variants ? { variants: theme.variants } : {}), |
| 501 | ...(theme.managed ? { managed: true } : {}), |
| 502 | }); |
| 503 | } catch { |
| 504 | return null; |
| 505 | } |
| 506 | }; |
| 507 | |
| 508 | const counts = new Map<string, number>(); |
| 509 | for (const entry of entries) { |
| 510 | counts.set(entry.theme.id, (counts.get(entry.theme.id) ?? 0) + 1); |
| 511 | } |
| 512 | const relabelled = entries.map(({ theme, sourceName }) => { |
| 513 | if ((counts.get(theme.id) ?? 0) < 2) return theme; |
| 514 | const stem = sourceName?.replace(/\.[^.]+$/, ""); |
| 515 | const fromFile = stem ? humanizeThemeName(stem) : null; |
| 516 | const renamed = |
| 517 | fromFile && fromFile.toLowerCase() !== theme.label.toLowerCase() |
| 518 | ? rename(theme, fromFile) |
| 519 | : null; |
| 520 | return renamed ?? theme; |
| 521 | }); |
| 522 | |
| 523 | const seen = new Set<string>(); |
| 524 | return relabelled.map((theme) => { |
| 525 | if (!seen.has(theme.id)) { |
| 526 | seen.add(theme.id); |
| 527 | return theme; |
| 528 | } |
| 529 | for (let suffix = 2; suffix < 100; suffix += 1) { |
| 530 | const candidate = rename(theme, `${theme.label} ${suffix}`); |
| 531 | if (candidate && !seen.has(candidate.id)) { |
| 532 | seen.add(candidate.id); |
| 533 | return candidate; |
| 534 | } |
| 535 | } |
| 536 | return theme; |
| 537 | }); |
| 538 | } |