diff --git a/apps/web/src/components/settings/ThemeImportDialog.browser.test.tsx b/apps/web/src/components/settings/ThemeImportDialog.browser.test.tsx index 2ee0085bbb1c6..81f3e499d7a30 100644 --- a/apps/web/src/components/settings/ThemeImportDialog.browser.test.tsx +++ b/apps/web/src/components/settings/ThemeImportDialog.browser.test.tsx @@ -9,6 +9,7 @@ import { installCustomTheme, invalidateCustomThemes, parseThemeFile, + themeColorToHex, THEME_FILE_VERSION, } from "../../themePalette"; import type { OpenVsxThemeExtension } from "../../openVsxThemes"; @@ -112,3 +113,82 @@ it.each(["Update existing", "Keep both"])( expect(onOpenChange).not.toHaveBeenCalledWith(false); }, ); + +function isolateThemes() { + const previous = localStorage.getItem(CUSTOM_THEMES_STORAGE_KEY); + localStorage.removeItem(CUSTOM_THEMES_STORAGE_KEY); + invalidateCustomThemes(); + return { + [Symbol.dispose]() { + if (previous === null) localStorage.removeItem(CUSTOM_THEMES_STORAGE_KEY); + else localStorage.setItem(CUSTOM_THEMES_STORAGE_KEY, previous); + invalidateCustomThemes(); + }, + }; +} + +function themeJsonc(name: string, background: string) { + return `{ + // VS Code themes use JSONC even with a .json extension. + "name": "${name}", + "colors": { + "editor.background": "${background}", + }, + "tokenColors": [], + }`; +} + +describe("ThemeImportDialog JSONC imports", () => { + it.each(["paste", "file"])("imports a VS Code JSONC theme from %s", async (source) => { + using _themes = isolateThemes(); + await using _screen = await render( + {}} + onImported={() => true} + onImportedMany={() => {}} + />, + ); + const json = themeJsonc("JSONC Night", "#002b36"); + if (source === "paste") { + await page.getByRole("textbox", { name: "Theme JSON" }).fill(json); + } else { + const input = document.querySelector('input[type="file"]')!; + await userEvent.upload(input, new File([json], "night.json", { type: "application/json" })); + await expect.element(page.getByRole("textbox", { name: "Theme JSON" })).toHaveValue(json); + } + await page.getByRole("button", { name: "Add theme", exact: true }).click(); + + expect(getCustomThemes().map((theme) => theme.label)).toEqual(["JSONC Night"]); + expect(themeColorToHex(getCustomThemes()[0]!.colors.canvas)).toBe("#002b36"); + }); + + it("imports each VS Code JSONC theme in a file batch", async () => { + using _themes = isolateThemes(); + await using _screen = await render( + {}} + onImported={() => true} + onImportedMany={() => {}} + />, + ); + const input = document.querySelector('input[type="file"]')!; + await userEvent.upload(input, [ + new File([themeJsonc("JSONC Night", "#002b36")], "night.json", { + type: "application/json", + }), + new File([themeJsonc("JSONC Day", "#fdf6e3")], "day.json", { + type: "application/json", + }), + ]); + + await vi.waitFor(() => { + expect(getCustomThemes().map((theme) => theme.label)).toEqual(["JSONC Night", "JSONC Day"]); + }); + expect(getCustomThemes().map((theme) => themeColorToHex(theme.colors.canvas))).toEqual([ + "#002b36", + "#fdf6e3", + ]); + }); +}); diff --git a/apps/web/src/components/settings/ThemeImportDialog.tsx b/apps/web/src/components/settings/ThemeImportDialog.tsx index e9d37f45322d1..5c8911623dfed 100644 --- a/apps/web/src/components/settings/ThemeImportDialog.tsx +++ b/apps/web/src/components/settings/ThemeImportDialog.tsx @@ -14,9 +14,8 @@ import { } from "../../themePalette"; import { humanizeThemeName, - isVsCodeThemeFile, pairVsCodeThemes, - parseVsCodeThemeFile, + parseThemeImport, resolveThemeLabelCollisions, } from "../../vscodeThemeImport"; import { Alert } from "../ui/alert"; @@ -256,10 +255,9 @@ function ThemeImportBody({ continue; } try { - const value: unknown = JSON.parse(await file.text()); parsed.push({ sourceName: file.name, - theme: isVsCodeThemeFile(value) ? parseVsCodeThemeFile(value) : parseThemeFile(value), + theme: parseThemeImport(await file.text()), }); } catch (cause) { failures.push( @@ -397,10 +395,7 @@ function ThemeImportBody({ return; } try { - const parsed: unknown = JSON.parse(json); - const theme = isVsCodeThemeFile(parsed) - ? parseVsCodeThemeFile(parsed) - : parseThemeFile(parsed); + const theme = parseThemeImport(json); if (getCustomThemes().some((existing) => existing.id === theme.id)) { setError(null); setConflicts([theme]); diff --git a/apps/web/src/vscodeThemeImport.test.ts b/apps/web/src/vscodeThemeImport.test.ts index 93792b815d852..f0172c2354d03 100644 --- a/apps/web/src/vscodeThemeImport.test.ts +++ b/apps/web/src/vscodeThemeImport.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { getThemeColorsForMode, + parseThemeFile, themeColorToHex, THEME_FILE_VERSION, toCanonicalThemeColor, @@ -9,6 +10,7 @@ import { import { isVsCodeThemeFile, pairVsCodeThemes, + parseThemeImport, parseVsCodeThemeFile, resolveThemeLabelCollisions, } from "./vscodeThemeImport"; @@ -63,6 +65,59 @@ const VSCODE_DARK = { tokenColors: [], }; +describe("theme import text", () => { + it("preserves conversion of ordinary VS Code JSON", () => { + expect(parseThemeImport(JSON.stringify(VSCODE_DARK))).toEqual( + parseVsCodeThemeFile(VSCODE_DARK), + ); + }); + + it.each([ + '{ // line comment\n "name": "Test", "colors": { "editor.background": "#002b36" } }', + '{ /* block comment */ "name": "Test", "colors": { "editor.background": "#002b36" } }', + '{ "name": "Test", "colors": { "editor.background": "#002b36", }, "tokenColors": [ { "scope": ["comment",], "settings": {}, }, ], }', + ])("imports VS Code JSONC without changing conversion: %s", (source) => { + const theme = parseThemeImport(source); + expect(theme.label).toBe("Test"); + expect(theme.appearance).toBe("dark"); + expect(asHex(theme.colors.canvas)).toBe("#002b36"); + }); + + it.each([ + "", + '{ "name": "Test", "colors": { "editor.background": "#002b36" }', + '{ "name": "Test" "colors": { "editor.background": "#002b36" } }', + '{ "name": "Test", "colors": { "editor.background": "#002b36" } } /* unterminated', + '{ "name": "Test", "colors": { "editor.background": "#002b36" } } extra', + ])("rejects malformed input rather than using parser recovery: %s", (source) => { + expect(() => parseThemeImport(source)).toThrow("That theme file is not valid JSON."); + }); + + it("preserves native T3 theme parsing and strict JSON syntax", () => { + const nativeTheme = { + version: THEME_FILE_VERSION, + name: "Native theme", + appearance: "dark", + colors: { canvas: "#002b36" }, + }; + const source = JSON.stringify(nativeTheme); + expect(parseThemeImport(source)).toEqual(parseThemeFile(nativeTheme)); + expect(() => parseThemeImport(source.replace("{", "{ /* native theme */"))).toThrow(); + expect(() => parseThemeImport(source.slice(0, -1) + ",}")).toThrow(); + }); + + it("still validates theme structure after parsing JSONC", () => { + expect(() => + parseThemeImport('{ /* not a theme */ "colors": { "editor.foreground": "#fff" } }'), + ).toThrow('no "editor.background"'); + expect(() => + parseThemeImport( + '{ "version": 1, "name": "Bad native", "appearance": "dark", "colors": { "unknown": "#fff" } }', + ), + ).toThrow('"unknown" is not a supported theme color role.'); + }); +}); + describe("VS Code theme import", () => { it("recognises workbench themes and rejects our own files", () => { expect(isVsCodeThemeFile(VSCODE_DARK)).toBe(true); diff --git a/apps/web/src/vscodeThemeImport.ts b/apps/web/src/vscodeThemeImport.ts index 57e3d245b212b..416351b16edfb 100644 --- a/apps/web/src/vscodeThemeImport.ts +++ b/apps/web/src/vscodeThemeImport.ts @@ -1,3 +1,5 @@ +import { parse, type ParseError } from "jsonc-parser"; + import { createVividThemeColors, getStandardThemeColors, @@ -133,6 +135,15 @@ export function isVsCodeThemeFile(value: unknown): boolean { return hasWorkbenchColors || Array.isArray(value.tokenColors); } +export function parseThemeImport(source: string): ThemeDefinition { + const errors: ParseError[] = []; + const value: unknown = parse(source, errors, { allowTrailingComma: true }); + if (errors.length > 0) throw new Error("That theme file is not valid JSON."); + return isVsCodeThemeFile(value) + ? parseVsCodeThemeFile(value) + : parseThemeFile(JSON.parse(source)); +} + function resolveAppearance(value: Record, canvas: VsCodeRgb): ThemeAppearance { const type = typeof value.type === "string" ? value.type.toLowerCase() : null; if (type === "light" || type === "hc-light") return "light";