diff --git a/package-lock.json b/package-lock.json index f6c2856c10..fe9f38c149 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,11 +14,14 @@ "@codemirror/lang-java": "^6.0.2", "@codemirror/lang-javascript": "^6.2.5", "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-less": "^6.0.2", "@codemirror/lang-markdown": "^6.5.2", "@codemirror/lang-php": "^6.0.2", "@codemirror/lang-python": "^6.2.1", "@codemirror/lang-rust": "^6.0.2", + "@codemirror/lang-sass": "^6.0.2", "@codemirror/lang-sql": "^6.10.0", + "@codemirror/lang-vue": "^0.1.3", "@codemirror/lang-xml": "^6.1.0", "@codemirror/lang-yaml": "^6.1.3", "@codemirror/legacy-modes": "^6.5.4", @@ -28,6 +31,7 @@ "@hugeicons/core-free-icons": "^4.3.0", "@hugeicons/react": "^1.1.10", "@lezer/highlight": "^1.2.3", + "@replit/codemirror-lang-svelte": "^6.0.0", "@streamdown/code": "^1.1.1", "@streamdown/mermaid": "^1.0.2", "@tauri-apps/api": "^2.7.0", @@ -470,6 +474,19 @@ "@lezer/json": "^1.0.0" } }, + "node_modules/@codemirror/lang-less": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-less/-/lang-less-6.0.2.tgz", + "integrity": "sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-css": "^6.2.0", + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, "node_modules/@codemirror/lang-markdown": { "version": "6.5.2", "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.2.tgz", @@ -521,6 +538,19 @@ "@lezer/rust": "^1.0.0" } }, + "node_modules/@codemirror/lang-sass": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-sass/-/lang-sass-6.0.2.tgz", + "integrity": "sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-css": "^6.2.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/sass": "^1.0.0" + } + }, "node_modules/@codemirror/lang-sql": { "version": "6.10.0", "resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz", @@ -535,6 +565,20 @@ "@lezer/lr": "^1.0.0" } }, + "node_modules/@codemirror/lang-vue": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-vue/-/lang-vue-0.1.3.tgz", + "integrity": "sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-html": "^6.0.0", + "@codemirror/lang-javascript": "^6.1.2", + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.1" + } + }, "node_modules/@codemirror/lang-xml": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz", @@ -1300,6 +1344,17 @@ "@lezer/lr": "^1.0.0" } }, + "node_modules/@lezer/sass": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lezer/sass/-/sass-1.1.0.tgz", + "integrity": "sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, "node_modules/@lezer/xml": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz", @@ -1370,6 +1425,25 @@ "node": ">=20" } }, + "node_modules/@replit/codemirror-lang-svelte": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@replit/codemirror-lang-svelte/-/codemirror-lang-svelte-6.0.0.tgz", + "integrity": "sha512-U2OqqgMM6jKelL0GNWbAmqlu1S078zZNoBqlJBW+retTc5M4Mha6/Y2cf4SVg6ddgloJvmcSpt4hHrVoM4ePRA==", + "license": "MIT", + "peerDependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.1", + "@codemirror/lang-html": "^6.2.0", + "@codemirror/lang-javascript": "^6.1.1", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/highlight": "^1.0.0", + "@lezer/javascript": "^1.2.0", + "@lezer/lr": "^1.0.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", diff --git a/package.json b/package.json index 4fc8ebc8ff..5565839695 100644 --- a/package.json +++ b/package.json @@ -35,11 +35,14 @@ "@codemirror/lang-java": "^6.0.2", "@codemirror/lang-javascript": "^6.2.5", "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-less": "^6.0.2", "@codemirror/lang-markdown": "^6.5.2", "@codemirror/lang-php": "^6.0.2", "@codemirror/lang-python": "^6.2.1", "@codemirror/lang-rust": "^6.0.2", + "@codemirror/lang-sass": "^6.0.2", "@codemirror/lang-sql": "^6.10.0", + "@codemirror/lang-vue": "^0.1.3", "@codemirror/lang-xml": "^6.1.0", "@codemirror/lang-yaml": "^6.1.3", "@codemirror/legacy-modes": "^6.5.4", @@ -49,6 +52,7 @@ "@hugeicons/core-free-icons": "^4.3.0", "@hugeicons/react": "^1.1.10", "@lezer/highlight": "^1.2.3", + "@replit/codemirror-lang-svelte": "^6.0.0", "@streamdown/code": "^1.1.1", "@streamdown/mermaid": "^1.0.2", "@tauri-apps/api": "^2.7.0", diff --git a/src/features/files/editor/editorLanguage.test.ts b/src/features/files/editor/editorLanguage.test.ts index 289e7c64c3..68c56dda79 100644 --- a/src/features/files/editor/editorLanguage.test.ts +++ b/src/features/files/editor/editorLanguage.test.ts @@ -11,6 +11,18 @@ function languageName(extension: Extension): string | null { } describe("languageForPath", () => { + it.each([ + ["App.vue", "vue"], + ["theme.scss", "sass"], + ["theme.sass", "sass"], + ["theme.less", "less"], + ])("maps component and stylesheet file %s to %s", async (path, name) => { + const extension = await languageForPath(path); + + expect(extension).not.toBeNull(); + expect(languageName(extension!)).toBe(name); + }); + it.each([ "index.php", "Program.cs", @@ -51,6 +63,7 @@ describe("languageForPath", () => { "messages.proto", "Dockerfile", "settings.jsonc", + "Counter.svelte", ])("loads highlighting for additional mainstream file %s", async (path) => { await expect(languageForPath(path)).resolves.not.toBeNull(); }); diff --git a/src/features/files/editor/editorLanguage.ts b/src/features/files/editor/editorLanguage.ts index f5c1dd718e..e777097018 100644 --- a/src/features/files/editor/editorLanguage.ts +++ b/src/features/files/editor/editorLanguage.ts @@ -167,10 +167,26 @@ export async function languageForPath(path: string): Promise { const { css } = await import("@codemirror/lang-css"); return css(); } + if ([".scss", ".sass"].includes(extension)) { + const { sass } = await import("@codemirror/lang-sass"); + return sass({ indented: extension === ".sass" }); + } + if (extension === ".less") { + const { less } = await import("@codemirror/lang-less"); + return less(); + } if ([".html", ".htm"].includes(extension)) { const { html } = await import("@codemirror/lang-html"); return html(); } + if (extension === ".vue") { + const { vue } = await import("@codemirror/lang-vue"); + return vue(); + } + if (extension === ".svelte") { + const { svelte } = await import("@replit/codemirror-lang-svelte"); + return svelte(); + } if ([".md", ".mdx", ".markdown"].includes(extension)) { const { markdown } = await import("@codemirror/lang-markdown"); return markdown(); diff --git a/src/features/files/editor/syntaxTokens.test.ts b/src/features/files/editor/syntaxTokens.test.ts index 0de01b5df0..b1dabdb8e7 100644 --- a/src/features/files/editor/syntaxTokens.test.ts +++ b/src/features/files/editor/syntaxTokens.test.ts @@ -10,8 +10,37 @@ import { const KEYWORD_DARK = "#ff8ffd"; const STRING_DARK = "#b4fa72"; const COMMENT_DARK = "#fefdc2"; +const PROPERTY_DARK = "#d0d1fe"; describe("highlightSource", () => { + it("colors script blocks in Vue and Svelte components", async () => { + for (const path of ["App.vue", "Counter.svelte"]) { + const language = await languageForPath(path); + const lines = highlightSource( + '', + language, + "dark", + ); + expect(token(lines[1], "const")?.color).toBe(KEYWORD_DARK); + expect(token(lines[1], '"agent"')?.color).toBe(STRING_DARK); + } + }); + + it.each(["theme.scss", "theme.less"])( + "colors comments in %s stylesheets", + async (path) => { + const language = await languageForPath(path); + const lines = highlightSource("// note\na { color: red; }", language, "dark"); + expect(token(lines[0], "// note")?.color).toBe(COMMENT_DARK); + }, + ); + + it("colors declarations in indented Sass stylesheets", async () => { + const language = await languageForPath("theme.sass"); + const lines = highlightSource(".card\n color: red", language, "dark"); + expect(token(lines[1], "color")?.color).toBe(PROPERTY_DARK); + }); + it("colors TypeScript keywords, strings, and comments", () => { const lines = highlightSource( 'const name = "agent";\n// note',