[lexical-code-shiki] Feature: Multi-theme / CSS-variable color mode#8454
[lexical-code-shiki] Feature: Multi-theme / CSS-variable color mode#8454mayrang wants to merge 1 commit into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
It probably makes sense for the default configuration of CodeShikiExtension to continue working without extra CSS, but it's completely fine for the playground configuration to require custom CSS. The playground is more of a demonstration of complex configuration, and more importantly a host for the e2e test suite (which would be better served by variables rather than rgb values, like the classes in the prism highlighter). That said the playground doesn't currently have any light/dark toggle so having CodeNodes not match the rest of the editor might be a bit awkward looking. |
etrepum
left a comment
There was a problem hiding this comment.
Looks like a great direction, tests could use some cleanup, and I think we can use a better naming convention than defaultColor - not very clear what that means from the name.
| expect($isCodeNode(node)).toBe(true); | ||
| const codeStyle = (node as CodeNode).getStyle(); |
There was a problem hiding this comment.
use assert for checking guards instead of expect+cast (only marking the first instance of this issue)
There was a problem hiding this comment.
Switched to assert, casts dropped.
| function $createCodeBlock(text: string): CodeNode { | ||
| const codeNode = $createCodeNode(); | ||
| codeNode.append($createTextNode(text)); | ||
| $getRoot().clear().append(codeNode); | ||
| return codeNode; | ||
| } |
There was a problem hiding this comment.
This looks like the same setup in every editor, and the text is always the same so that doesn't need to be an argument. Could just be used as an $initialEditorState
There was a problem hiding this comment.
Setup is in $initialEditorState now (factored as $populateCodeBlock); test bodies are just editor.read(...).
| const firstChild = (node as CodeNode).getFirstChild(); | ||
| if (!$isTextNode(firstChild)) { | ||
| throw new Error('expected first child to be a TextNode'); | ||
| } |
| * | ||
| * Ignored when {@link themes} is `null`. | ||
| */ | ||
| defaultColor: string | false | undefined; |
There was a problem hiding this comment.
something like defaultThemeName or defaultThemeKey makes more sense. I don't know if we need a false inhabitant here either, null probably makes more sense
There was a problem hiding this comment.
Renamed to defaultThemeName. Took "I don't know if we need a false inhabitant" as "consolidate false → null" so vars-only stays — that matches the playground/e2e direction in your other comment. Let me know if you meant drop the inhabitant entirely.
| @@ -435,7 +515,11 @@ export interface CodeShikiConfig { | |||
| export const CodeShikiExtension = defineExtension({ | |||
| build: (editor, config) => namedSignals(config), | |||
| config: safeCast<CodeShikiConfig>({ | |||
There was a problem hiding this comment.
we should also have a mergeConfig for this data structure now
There was a problem hiding this comment.
Added mergeShikiConfig modeled on mergeRichTextConfig — deep-merges themes, shallow for the rest.
| // Listed explicitly so namedSignals creates a `defaultColor` signal; | ||
| // dropping the key would leave the signal absent at runtime. |
There was a problem hiding this comment.
extension config should generally never have optional properties, the default config should always provide every key. overrides are always a partial
There was a problem hiding this comment.
Dropped | undefined from the type and defaultThemeName: undefined from the safeCast; default is 'light'.
| ? shiki.codeToTokens(code, {defaultColor, lang, themes}) | ||
| : shiki.codeToTokens(code, { | ||
| lang, | ||
| theme: codeNode.getTheme() || 'poimandres', |
There was a problem hiding this comment.
maybe lift this default to the configuration
There was a problem hiding this comment.
$getHighlightNodes takes defaultTheme now; ShikiTokenizer.$tokenize passes this.defaultTheme. 'poimandres' is gone.
7fe5a64 to
32ce150
Compare
|
Playground is now |
|
Looking at the playground we still have the theme pulldown for code blocks when shiki is enabled, would be nice to make that do something? |
|
Confirmed — Playground runs |
|
I think we should probably take a closer look at the Shiki interface to see if it needs to change more substantially than this. Tokenizer isn't really a generic abstraction, it already has a Shiki-only shape, the Prism one is different. |
|
Agreed — Direction we're leaning, unless you have a different read: deprecate Two open calls if that direction lands:
Per-block override (the dropdown wiring) falls out naturally on either path. Open to a different shape entirely if you had something else in mind. |
|
I think we can add extra arguments to $tokenize without breaking backwards compatibility so long as the first arguments match, and maybe just deprecate the defaults in that data structure (or at least have them as lower priority than the values in top-level config). Almost no use cases will override tokenizer once the themes are taken out of it.
additionally we should think about making some or most of this be reconfigurable or extensible at runtime via output signals and/or functions. |
|
BC route works — On the string-or-object dispatch — reading it as: collapse |
|
I think the per-node __theme being an object is where I don't like that particular design and it might need more thought. Except in the specific case of something like {light: string, dark: string} there's no reason to have __theme be a non-string, and as far as usage goes it would be better keep it as a string and have a configuration table in the extension for the names to light/dark mappings. I don't have a concrete proposal for exactly how it should work, requires a bit more focus than I have right now. Only feedback I can really give is that I don't quite think we've uncovered the ideal structure yet. |
|
Took your registry sketch and worked through it. Two designs I'd consider: Common base: type ShikiThemeDef = string | {light: string; dark: string};
type ShikiThemeSpec = ShikiThemeDef | ((codeNode: CodeNode) => ShikiThemeDef);
interface CodeShikiConfig {
defaultLanguage: string;
$tokenize: (codeNode, lang, themeContext) => LexicalNode[];
themes: Record<string, ShikiThemeSpec>;
defaultTheme: string;
}Default stays single-theme inline ( The split is where inline policy lives (inline policy = which of light/dark renders inline; null = vars-only): A) Root field inlineDefault: 'light' | 'dark' | null;Single field at config root, applies to every multi-theme entry. Closest to your "names to light/dark mappings" sketch literally. Cost: when all entries are single-theme strings, B) Per-entry type ShikiThemeDef = string | {light: string; dark: string; inline: 'light' | 'dark' | null};Inline policy inside each multi entry. Type self-contained — entry says how it renders. Cost: site with uniform policy across N multi entries repeats Both keep That's what I came up with. Moving the PR to draft for now. |
|
What does the inlineDefault / inline policy actually do? |
|
Both options control the same thing — which theme's colors get baked into the style attribute (the rest get exposed as
It's the |
|
What would be the use case for setting |
|
Fair point. Honestly we got a bit tunnel-visioned on the existing design through these review rounds — tried to find a real use case for the inline mode and couldn't. Progressive enhancement (inline fallback + vars for capable browsers) was the theoretical motivation, but if you want inline-only you'd just use a single-theme string, and if you want vars you'd own the CSS anyway. Going to simplify — type ShikiThemeDef = string | {light: string; dark: string};
type ShikiThemeSpec = ShikiThemeDef | ((codeNode: CodeNode) => ShikiThemeDef);
interface CodeShikiConfig {
defaultLanguage: string;
$tokenize: (codeNode, lang, themeContext) => LexicalNode[];
themes: Record<string, ShikiThemeSpec>;
defaultTheme: string;
}Resolves the naming question too — no field, no name. |
32ce150 to
5a2d747
Compare
Closes facebook#8108. Currently Shiki tokenization writes the active theme's color into each token's inline `style` as an rgb() value, so the page can't toggle a dark/light scheme at runtime and exported HTML stays in its original scheme. Add Shiki's multi-theme output as an opt-in on `CodeShikiExtension` via a theme registry. `CodeShikiConfig.themes` is now a `Record<string, ShikiThemeSpec>` — a registry of named theme entries. Each entry is either a single Shiki theme id (rendered inline as `color` / `background-color`) or a `{light, dark}` pair (rendered as `--shiki-light(-bg)` / `--shiki-dark(-bg)` CSS variables, no inline color, so the consuming page's CSS owns the active scheme). Function entries `(codeNode) => ShikiThemeDef` resolve at tokenize time for runtime per-node decisions. `CodeShikiConfig.defaultTheme` is the registry key used when a code node's per-node `__theme` is unset. Per-node `__theme` is now honored as a registry key — if it matches an entry, that entry is used; otherwise it falls back to a raw Shiki theme id with a dev warning so legacy `setTheme('one-light')` flows keep working. The default config is `themes: {default: 'one-light'}, defaultTheme: 'default'`, so a bare `configExtension(CodeShikiExtension)` keeps the existing single-theme inline-rgb behavior with no CSS work required. `Tokenizer` is a deprecated type alias retained for backwards compatibility; `defaultLanguage` and `$tokenize` are flattened onto `CodeShikiConfig` directly. The new `$tokenize` signature is `(codeNode, lang, themeContext) => LexicalNode[]`, backwards compatible because additional arguments don't affect callers that ignore them. The default `$tokenize` reads the per-node theme via `themeContext.resolveTheme(codeNode)` and dispatches: string spec → inline path, `{light, dark}` spec → vars-only path. The multi-theme bypass branch in the transform dissolves into the default `$tokenize`. Backwards compatibility: - `registerCodeHighlighting` (legacy export) signature unchanged. - `CodeHighlighterShikiExtension` continues to work — its `init` forwards the legacy Tokenizer to `CodeShikiConfig.tokenizer`, and `register` routes back to `tokenizer.$tokenize` / `tokenizer.defaultLanguage` when top-level config is still at framework defaults. - `Tokenizer` interface unchanged. - `mergeShikiConfig` deep-merges the `themes` registry so multiple `configExtension(...)` calls compose cleanly. Tests cover the default single-theme inline path, `{light, dark}` vars-only output, per-node registry-key resolution, function-form registry entries receiving the code node, and unknown-key fallback to raw Shiki ids.
5a2d747 to
0be8555
Compare
potatowagon
left a comment
There was a problem hiding this comment.
Review — Multi-theme / CSS-Variable Color Mode for Code Shiki
Assessment: Looks good to land ✅
What I verified:
-
Feature scope: Adds CSS-variable-based theming support to lexical-code-shiki, enabling multiple themes (light/dark) without re-highlighting. Uses Shiki's built-in multi-theme support with CSS custom properties.
-
CI status: Full CI suite green (38 checks pass).
-
Author context: mayrang has multiple PRs in the queue (also #8603 Named Slots) — active contributor with good familiarity with the codebase.
-
Risk: Additive feature, extends existing code-shiki package without modifying core highlighting logic.
— via Navi on behalf of potatowagon
…wser tests to buildEditorFromExtensions
Switch the four `createEditor` + `editor.update($prepopulate, {discrete: true})` setups in ShadowRootSelection.test.ts to `buildEditorFromExtensions(defineExtension({$initialEditorState}))`, the recurring etrepum convention flagged across PRs facebook#8453/facebook#8454. Manual shadow attach + setRootElement + onTestFinished cleanup are preserved — the tests still target the same scenarios (web-component-style shadow root, iframe document, light-DOM fallback). All 17 tests pass before and after.
…wser tests to buildEditorFromExtensions
Switch the four `createEditor` + `editor.update($prepopulate, {discrete: true})` setups in ShadowRootSelection.test.ts to `buildEditorFromExtensions(defineExtension({$initialEditorState}))`, the recurring etrepum convention flagged across PRs facebook#8453/facebook#8454. Manual shadow attach + setRootElement + onTestFinished cleanup are preserved — the tests still target the same scenarios (web-component-style shadow root, iframe document, light-DOM fallback). All 17 tests pass before and after.
Description
Issue #8108 has two complaints:
styleasrgb(...), so a runtime dark/light toggle requires re-tokenizing.The issue suggests "use vars instead of rgb values," which is exactly what Shiki's multi-theme mode emits when given a
themesmap. This PR exposes that mode throughCodeShikiExtensionconfig.Config
CodeShikiConfiggains:themes: Record<string, string> | null— multi-theme map.nullkeeps the existing single-theme behavior.defaultColor: string | false | undefined— forwarded to Shiki'scodeToTokens({themes, defaultColor}):themes: that theme renders inline as the fallback; the others become--shiki-{name}/--shiki-{name}-bgvariables.'light-dark()': CSS-nativelight-dark(...)fallback (Baseline 2024). Requireslight+darkkeys.false: emit only CSS variables, no inline color. The page's CSS owns the active scheme.'light'(Shiki throws ifthemeslacks alightkey).Per-node
CodeNode.__theme,Tokenizer.defaultTheme, and a customtokenizer.$tokenizeare skipped in multi-theme mode. Themes are a Shiki-specific feature with no equivalent in the genericTokenizerabstraction, so the multi-theme path goes straight to Shiki'scodeToTokens.Backwards compatibility
registerCodeHighlightingsignature unchanged.registereffect calls the existing internalregisterHighlightingOnlydirectly withthemes/defaultColor. The legacy export doesn't gain athemesparameter.Tokenizerinterface unchanged.Playground
CodeHighlightExtension(the playground aggregator) configuresCodeShikiExtensionwiththemes: {light: 'one-light', dark: 'one-dark-pro'}, defaultColor: 'light', so QA in Shiki mode shows the feature directly.Design notes
A few alternatives I considered, easy to swap if you'd prefer a different shape:
themes/defaultColoronCodeShikiExtension.config(this PR) vs. extendingTokenizer. Putting them on the highlighter-level config seemed cleaner since themes are Shiki-specific, but aTokenizer.themesfield is also workable.Tokenizer.$tokenizeentirely; a custom Tokenizer is consulted only in single-theme mode. The alternative is extendingTokenizer.$tokenizeto take theme args. The bypass keeps the generic interface unchanged at the cost of leaving custom tokenizers single-theme-only.defaultColor: 'light'so the feature is visible without any extra CSS.false(vars-only) would force every consumer to add CSS rules — verified to work but felt rough as a default.Closes #8108
Test plan
pnpm vitest run --project unit -t "Shiki multi-theme"— 4 cases pass:defaultColor: 'dark'selects the inline theme; the other becomes a CSS variabledefaultColor: falseemits only CSS variables, no inline colorsetThemeCodeShikiExtensionconfig (extension scenario), not the internal helper.defaultColor: 'light'):<code>has inlinebackground-color: rgb(...)(light) +--shiki-dark-bg: #.... Each token has inlinecolor: rgb(...)(light) +--shiki-dark: #....document.querySelectorAll('code, code span').forEach(el => el.style.color = 'var(--shiki-dark)')flips colors to the dark scheme without re-tokenizing.styleincluding CSS variables.defaultColor: false(temporary swap):<code>and each token carry only--shiki-light(-bg)and--shiki-dark(-bg)variables. No inlinecolor:/background-color:declarations.Example CSS for runtime toggle