From 287e74ce42a334aada927168d2b2627e553390c1 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:16:24 +0530 Subject: [PATCH 1/2] feat(plugins): expose static CodeMirror highlighter --- .babelrc | 2 +- src/lib/acode.js | 22 ++++ src/lib/editorFile.js | 6 ++ src/test/editor.tests.js | 66 ++++++++++++ src/utils/codeHighlight.js | 180 ++++++++++++++++++++++++++++--- tests/unit/codeHighlight.test.js | 154 ++++++++++++++++++++++++++ 6 files changed, 413 insertions(+), 17 deletions(-) create mode 100644 tests/unit/codeHighlight.test.js diff --git a/.babelrc b/.babelrc index d50502316..e5efe292f 100644 --- a/.babelrc +++ b/.babelrc @@ -15,6 +15,6 @@ "@babel/plugin-transform-runtime", "@babel/plugin-transform-block-scoping" ], - "compact": true, + "compact": false, "sourceMaps": "inline" } diff --git a/src/lib/acode.js b/src/lib/acode.js index 15cc4b6f9..6d9310d24 100644 --- a/src/lib/acode.js +++ b/src/lib/acode.js @@ -69,6 +69,15 @@ import appSettings from "lib/settings"; import FileBrowser from "pages/fileBrowser"; import ThemeBuilder from "theme/builder"; import themes from "theme/list"; +import { + applyHighlightStyles, + clearHighlightCache, + getHighlightStyleSheet, + getHighlightStyles, + HIGHLIGHT_CLASS, + highlightCodeBlock, + highlightLine, +} from "utils/codeHighlight"; import Color from "utils/color"; import encodings, { decode, encode } from "utils/encodings"; import helpers from "utils/helpers"; @@ -312,6 +321,17 @@ class Acode { }, }; + const codeHighlightModule = Object.freeze({ + highlightLine, + highlightCodeBlock, + highlight: highlightCodeBlock, + clearCache: clearHighlightCache, + applyStyles: applyHighlightStyles, + getStyles: getHighlightStyles, + getStyleSheet: getHighlightStyleSheet, + HIGHLIGHT_CLASS, + }); + const codemirrorModule = Object.freeze({ autocomplete: cmAutocomplete, commands: cmCommands, @@ -326,6 +346,7 @@ class Acode { search: cmSearch, state: cmState, view: cmView, + highlight: codeHighlightModule, }); const configProxy = new Proxy(config, { @@ -414,6 +435,7 @@ class Acode { this.define("terminal", terminalModule); this.define("webview", webview); this.define("codemirror", codemirrorModule); + this.define("codeHighlight", codeHighlightModule); this.define("@codemirror/autocomplete", cmAutocomplete); this.define("@codemirror/commands", cmCommands); this.define("@codemirror/language", cmLanguage); diff --git a/src/lib/editorFile.js b/src/lib/editorFile.js index 449d8ea4b..2aee9b500 100644 --- a/src/lib/editorFile.js +++ b/src/lib/editorFile.js @@ -23,6 +23,7 @@ import startDrag from "handlers/editorFileTab"; import actions from "handlers/quickTools"; import tag from "html-tag-js"; import mimeTypes from "mime-types"; +import { applyHighlightStyles } from "utils/codeHighlight"; import helpers from "utils/helpers"; import Path from "utils/Path"; import { readRemoteFilePreview } from "utils/remoteFilePreview"; @@ -329,6 +330,7 @@ function maybeRecommendLanguageModeExtension(file, modeInfo) { * @property {string} [paneId] target editor pane id * @property {object} [pane] target editor pane * @property {boolean} [isPanePlaceholder] temporary empty tab for an empty pane + * @property {boolean} [highlightStyles] adopt static CodeMirror highlight CSS into the custom tab shadow root */ export default class EditorFile { @@ -590,6 +592,10 @@ export default class EditorFile { this.#addCustomStyles(options.stylesheets, shadow); } + if (options.highlightStyles) { + applyHighlightStyles(shadow); + } + const content =
; if (typeof options.content === "string") { diff --git a/src/test/editor.tests.js b/src/test/editor.tests.js index 4003dfc8c..4af037e88 100644 --- a/src/test/editor.tests.js +++ b/src/test/editor.tests.js @@ -213,6 +213,72 @@ export async function runCodeMirrorTests(writeOutput) { ); }); + runner.test( + "Acode exposes the static CodeMirror highlighter", + async (test) => { + const codeHighlight = acode.require("codeHighlight"); + const codemirror = acode.require("codemirror"); + + test.assert(codeHighlight != null, "codeHighlight module should exist"); + test.assertEqual( + typeof codeHighlight.highlightCodeBlock, + "function", + "highlightCodeBlock should be a function", + ); + test.assertEqual( + typeof codeHighlight.highlightLine, + "function", + "highlightLine should be a function", + ); + test.assertEqual( + typeof codeHighlight.applyStyles, + "function", + "applyStyles should be a function", + ); + test.assertEqual( + typeof codeHighlight.getStyles, + "function", + "getStyles should be a function", + ); + test.assertEqual( + codeHighlight.HIGHLIGHT_CLASS, + "cm-highlighted", + "HIGHLIGHT_CLASS should match the internal token wrapper", + ); + test.assertEqual( + codemirror.highlight, + codeHighlight, + "codemirror.highlight should be the same highlighter module", + ); + + const css = codeHighlight.getStyles(); + test.assert( + typeof css === "string" && css.includes(".tok-keyword"), + "getStyles should return token CSS", + ); + + const highlighted = await codeHighlight.highlightCodeBlock( + 'const value = "acode";', + "javascript", + ); + test.assert( + highlighted.includes("value") && !highlighted.includes(" { const { view, container } = createEditor(); test.assert(view != null, "EditorView instance should be created"); diff --git a/src/utils/codeHighlight.js b/src/utils/codeHighlight.js index 1b587dfc3..7df866b70 100644 --- a/src/utils/codeHighlight.js +++ b/src/utils/codeHighlight.js @@ -6,9 +6,16 @@ import settings from "lib/settings"; const highlightCache = new Map(); const MAX_CACHE_SIZE = 500; +const STYLE_ID = "cm-static-highlight-styles"; + +export const HIGHLIGHT_CLASS = "cm-highlighted"; +export const REF_PREVIEW_CLASS = "ref-preview"; let styleElement = null; +let constructedSheet = null; let currentThemeId = null; +let initialized = false; +const fallbackStyleElements = new Set(); export function sanitize(text) { if (!text) return ""; @@ -42,6 +49,17 @@ function setCache(key, value) { highlightCache.set(key, value); } +function canUseConstructedStyleSheets() { + return ( + typeof CSSStyleSheet !== "undefined" && + typeof CSSStyleSheet.prototype.replaceSync === "function" + ); +} + +function currentEditorThemeId() { + return settings?.value?.editorTheme || "one_dark"; +} + /** * Generates CSS styles for syntax highlighting tokens * @param {Object} config - Theme config with color values @@ -106,25 +124,147 @@ ${selector} .tok-changed { color: ${number}; } } /** - * Injects dynamic CSS for syntax highlighting based on current editor theme + * CSS for the current editor theme. Token classes come from Lezer's + * `classHighlighter` (e.g. `.tok-keyword`). + * @returns {string} */ -function injectStyles() { - const themeId = settings?.value?.editorTheme || "one_dark"; - const config = getThemeConfig(themeId); +export function getHighlightStyles() { + const config = getThemeConfig(currentEditorThemeId()); + const codeBlockStyles = generateStyles(config, `.${HIGHLIGHT_CLASS}`, true); + const refPreviewStyles = generateStyles( + config, + `.${REF_PREVIEW_CLASS}`, + false, + ); + return `${codeBlockStyles}\n${refPreviewStyles}`; +} + +function ensureConstructedSheet(css) { + if (!canUseConstructedStyleSheets()) return null; + if (!constructedSheet) { + constructedSheet = new CSSStyleSheet(); + } + constructedSheet.replaceSync(css); + return constructedSheet; +} - // Code blocks need background, references panel uses parent's background - const codeBlockStyles = generateStyles(config, ".cm-highlighted", true); - const refPreviewStyles = generateStyles(config, ".ref-preview", false); - const allStyles = `${codeBlockStyles}\n${refPreviewStyles}`; +function syncFallbackStyleElements(css) { + for (const style of fallbackStyleElements) { + if (!style.isConnected) { + fallbackStyleElements.delete(style); + continue; + } + style.textContent = css; + } +} - if (!styleElement) { +function injectDocumentStyleElement(css) { + if (typeof document === "undefined") return null; + if (!styleElement || !styleElement.isConnected) { styleElement = document.createElement("style"); - styleElement.id = "cm-static-highlight-styles"; - document.head.appendChild(styleElement); + styleElement.id = STYLE_ID; + (document.head || document.documentElement).appendChild(styleElement); + } + styleElement.textContent = css; + return styleElement; +} + +/** + * Rebuilds the shared highlight stylesheet from the current editor theme. + * Constructed-sheet adopters update automatically via `replaceSync`. + */ +function syncHighlightStyles() { + const css = getHighlightStyles(); + currentThemeId = currentEditorThemeId(); + ensureConstructedSheet(css); + injectDocumentStyleElement(css); + syncFallbackStyleElements(css); + return css; +} + +function resolveStyleRoot(root) { + if (!root || root === document) return document; + if (typeof ShadowRoot !== "undefined" && root instanceof ShadowRoot) { + return root; + } + if (root.shadowRoot) return root.shadowRoot; + return root; +} + +function adoptSheet(root, sheet) { + if (!sheet || !root || !("adoptedStyleSheets" in root)) return false; + const sheets = Array.from(root.adoptedStyleSheets || []); + if (sheets.includes(sheet)) return true; + try { + root.adoptedStyleSheets = [...sheets, sheet]; + return true; + } catch (e) { + console.warn("Failed to adopt highlight stylesheet", e); + return false; + } +} + +function injectFallbackStyle(root, css) { + const owner = + root === document ? document.head || document.documentElement : root; + if (!owner || typeof owner.appendChild !== "function") return null; + + let style = null; + if (typeof owner.querySelector === "function") { + style = owner.querySelector(`#${STYLE_ID}`); + } + + if (!style) { + style = document.createElement("style"); + style.id = STYLE_ID; + owner.appendChild(style); + } + + style.textContent = css; + fallbackStyleElements.add(style); + return style; +} + +/** + * Shared constructed stylesheet used for document + shadow roots. + * @returns {CSSStyleSheet|null} + */ +export function getHighlightStyleSheet() { + syncHighlightStyles(); + return constructedSheet; +} + +/** + * Applies current-theme highlight CSS to a document or shadow root. + * Custom editor tabs opt in with `highlightStyles: true`. Call this + * for other shadow roots (dialogs, custom elements) that insert + * highlighted HTML. + * + * Prefers `adoptedStyleSheets` so theme changes update in place. + * + * @param {Document|ShadowRoot|ParentNode|null} [root=document] + * @returns {CSSStyleSheet|HTMLStyleElement|null} + */ +export function applyHighlightStyles(root = document) { + const css = syncHighlightStyles(); + const target = resolveStyleRoot(root); + + if (constructedSheet && adoptSheet(target, constructedSheet)) { + return constructedSheet; } - styleElement.textContent = allStyles; - currentThemeId = themeId; + if (target === document) { + return styleElement; + } + + return injectFallbackStyle(target, css); +} + +/** + * Injects dynamic CSS for syntax highlighting based on current editor theme + */ +function injectStyles() { + applyHighlightStyles(document); } /** @@ -193,7 +333,7 @@ async function getParserForLanguage(langName) { export async function highlightLine(text, uri, symbolName = null) { if (!text || !text.trim()) return ""; - const themeId = settings?.value?.editorTheme || "one_dark"; + const themeId = currentEditorThemeId(); const cacheKey = `line:${themeId}:${uri}:${text}:${symbolName || ""}`; if (highlightCache.has(cacheKey)) { @@ -250,7 +390,7 @@ export async function highlightLine(text, uri, symbolName = null) { export async function highlightCodeBlock(code, language) { if (!code) return ""; - const themeId = settings?.value?.editorTheme || "one_dark"; + const themeId = currentEditorThemeId(); const langKey = (language || "text").toLowerCase(); const cacheKey = `block:${themeId}:${langKey}:${code}`; @@ -305,8 +445,11 @@ export function clearHighlightCache() { export function initHighlighting() { injectStyles(); + if (initialized) return; + initialized = true; + settings.on("update:editorTheme:after", () => { - const newThemeId = settings?.value?.editorTheme || "one_dark"; + const newThemeId = currentEditorThemeId(); if (newThemeId !== currentThemeId) { injectStyles(); highlightCache.clear(); @@ -320,4 +463,9 @@ export default { highlightCodeBlock, clearHighlightCache, initHighlighting, + applyHighlightStyles, + getHighlightStyles, + getHighlightStyleSheet, + HIGHLIGHT_CLASS, + REF_PREVIEW_CLASS, }; diff --git a/tests/unit/codeHighlight.test.js b/tests/unit/codeHighlight.test.js new file mode 100644 index 000000000..4afdd6ccd --- /dev/null +++ b/tests/unit/codeHighlight.test.js @@ -0,0 +1,154 @@ +// @vitest-environment happy-dom + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const themeListeners = []; + +vi.mock("lib/settings", () => ({ + default: { + value: { editorTheme: "one_dark" }, + on(event, callback) { + if (event === "update:editorTheme:after") { + themeListeners.push(callback); + } + }, + }, +})); + +import "cm/supportedModes"; +import settings from "lib/settings"; +import { + HIGHLIGHT_CLASS, + applyHighlightStyles, + clearHighlightCache, + getHighlightStyleSheet, + getHighlightStyles, + highlightCodeBlock, + highlightLine, + initHighlighting, +} from "utils/codeHighlight"; + +function adoptCount(root, sheet) { + return Array.from(root.adoptedStyleSheets || []).filter( + (entry) => entry === sheet, + ).length; +} + +describe("codeHighlight", () => { + beforeEach(() => { + settings.value.editorTheme = "one_dark"; + clearHighlightCache(); + document + .querySelectorAll("#cm-static-highlight-styles") + .forEach((node) => node.remove()); + }); + + it("emits token CSS for the current editor theme", () => { + const css = getHighlightStyles(); + expect(css).toContain(`.${HIGHLIGHT_CLASS}`); + expect(css).toContain(".tok-keyword"); + expect(css).toContain(".tok-string"); + expect(css).toContain("#c678dd"); + }); + + it("highlights a JavaScript code block with token spans", async () => { + const html = await highlightCodeBlock( + 'const answer = "forty-two";', + "javascript", + ); + expect(html).toContain("tok-"); + expect(html).toContain("answer"); + expect(html).toContain("forty-two"); + expect(html).not.toContain(" { + const html = await highlightCodeBlock( + '', + "not-a-real-language", + ); + expect(html).toContain("<img"); + expect(html).not.toContain(" { + const html = await highlightLine( + "export function greet() {}", + "file:///tmp/hello.js", + "greet", + ); + expect(html).toContain("greet"); + expect(html).toContain("symbol-match"); + }); + + it("returns empty string for blank input", async () => { + expect(await highlightCodeBlock("")).toBe(""); + expect(await highlightLine(" ", "file.js")).toBe(""); + }); + + it("adopts the highlight stylesheet into a shadow root", () => { + initHighlighting(); + const host = document.createElement("div"); + const shadow = host.attachShadow({ mode: "open" }); + + const applied = applyHighlightStyles(shadow); + const sheet = getHighlightStyleSheet(); + + expect(sheet).toBeTruthy(); + expect(applied).toBe(sheet); + expect(adoptCount(shadow, sheet)).toBe(1); + }); + + it("does not duplicate the adopted sheet on repeated apply", () => { + const host = document.createElement("div"); + const shadow = host.attachShadow({ mode: "open" }); + applyHighlightStyles(shadow); + applyHighlightStyles(shadow); + applyHighlightStyles(host); + + const sheet = getHighlightStyleSheet(); + expect(adoptCount(shadow, sheet)).toBe(1); + }); + + it("resolves a host element to its shadow root", () => { + const host = document.createElement("div"); + const shadow = host.attachShadow({ mode: "open" }); + applyHighlightStyles(host); + + const sheet = getHighlightStyleSheet(); + expect(adoptCount(shadow, sheet)).toBe(1); + }); + + it("keeps highlight colors after the host replaces adoptedStyleSheets", () => { + const host = document.createElement("div"); + const shadow = host.attachShadow({ mode: "open" }); + const other = new CSSStyleSheet(); + other.replaceSync(":host { display: block; }"); + shadow.adoptedStyleSheets = [other]; + + applyHighlightStyles(shadow); + + const sheet = getHighlightStyleSheet(); + expect(shadow.adoptedStyleSheets).toContain(other); + expect(shadow.adoptedStyleSheets).toContain(sheet); + }); + + it("updates adopted shadow styles when the editor theme changes", () => { + initHighlighting(); + const host = document.createElement("div"); + const shadow = host.attachShadow({ mode: "open" }); + applyHighlightStyles(shadow); + + const sheet = getHighlightStyleSheet(); + const before = getHighlightStyles(); + expect(before).toContain("#c678dd"); + + settings.value.editorTheme = "githubLight"; + for (const listener of themeListeners) listener(); + + const after = getHighlightStyles(); + expect(after).toContain("#cf222e"); + expect(after).not.toBe(before); + expect(shadow.adoptedStyleSheets).toContain(sheet); + }); +}); From e01c1b7fc4351430e8effce3dc69c46f8b461492 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:25:24 +0530 Subject: [PATCH 2/2] fix --- src/utils/codeHighlight.js | 8 +++++--- tests/unit/codeHighlight.test.js | 27 +++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/utils/codeHighlight.js b/src/utils/codeHighlight.js index 7df866b70..3734157b3 100644 --- a/src/utils/codeHighlight.js +++ b/src/utils/codeHighlight.js @@ -150,7 +150,9 @@ function ensureConstructedSheet(css) { function syncFallbackStyleElements(css) { for (const style of fallbackStyleElements) { - if (!style.isConnected) { + // `isConnected` is false while a custom-tab host is still detached. + // Keep updating those nodes; only drop styles that have been removed. + if (!style.parentNode) { fallbackStyleElements.delete(style); continue; } @@ -193,9 +195,9 @@ function resolveStyleRoot(root) { function adoptSheet(root, sheet) { if (!sheet || !root || !("adoptedStyleSheets" in root)) return false; - const sheets = Array.from(root.adoptedStyleSheets || []); - if (sheets.includes(sheet)) return true; try { + const sheets = Array.from(root.adoptedStyleSheets || []); + if (sheets.includes(sheet)) return true; root.adoptedStyleSheets = [...sheets, sheet]; return true; } catch (e) { diff --git a/tests/unit/codeHighlight.test.js b/tests/unit/codeHighlight.test.js index 4afdd6ccd..b434faa8c 100644 --- a/tests/unit/codeHighlight.test.js +++ b/tests/unit/codeHighlight.test.js @@ -133,6 +133,33 @@ describe("codeHighlight", () => { expect(shadow.adoptedStyleSheets).toContain(sheet); }); + it("updates fallback styles while the shadow host is still detached", () => { + initHighlighting(); + const host = document.createElement("div"); + const shadow = host.attachShadow({ mode: "open" }); + Object.defineProperty(shadow, "adoptedStyleSheets", { + configurable: true, + get() { + throw new Error("adoptedStyleSheets unavailable"); + }, + set() { + throw new Error("adoptedStyleSheets unavailable"); + }, + }); + + applyHighlightStyles(shadow); + const style = shadow.querySelector("#cm-static-highlight-styles"); + expect(style).toBeTruthy(); + expect(style.isConnected).toBe(false); + expect(style.textContent).toContain("#c678dd"); + + settings.value.editorTheme = "githubLight"; + for (const listener of themeListeners) listener(); + + expect(style.parentNode).toBe(shadow); + expect(style.textContent).toContain("#cf222e"); + }); + it("updates adopted shadow styles when the editor theme changes", () => { initHighlighting(); const host = document.createElement("div");