Skip to content

[lexical-code-shiki] Feature: Multi-theme / CSS-variable color mode#8454

Draft
mayrang wants to merge 1 commit into
facebook:mainfrom
mayrang:feat/shiki-multi-theme-color-mode-v2
Draft

[lexical-code-shiki] Feature: Multi-theme / CSS-variable color mode#8454
mayrang wants to merge 1 commit into
facebook:mainfrom
mayrang:feat/shiki-multi-theme-color-mode-v2

Conversation

@mayrang

@mayrang mayrang commented May 2, 2026

Copy link
Copy Markdown
Contributor

Description

Issue #8108 has two complaints:

  1. Shiki output puts theme colors in each token's inline style as rgb(...), so a runtime dark/light toggle requires re-tokenizing.
  2. HTML exported from Lexical carries the original scheme's colors and can't be reskinned at the consuming page.

The issue suggests "use vars instead of rgb values," which is exactly what Shiki's multi-theme mode emits when given a themes map. This PR exposes that mode through CodeShikiExtension config.

Config

configExtension(CodeShikiExtension, {
  themes: {light: 'one-light', dark: 'one-dark-pro'},
  defaultColor: 'light',
});

CodeShikiConfig gains:

  • themes: Record<string, string> | null — multi-theme map. null keeps the existing single-theme behavior.
  • defaultColor: string | false | undefined — forwarded to Shiki's codeToTokens({themes, defaultColor}):
    • a key from themes: that theme renders inline as the fallback; the others become --shiki-{name} / --shiki-{name}-bg variables.
    • 'light-dark()': CSS-native light-dark(...) fallback (Baseline 2024). Requires light + dark keys.
    • false: emit only CSS variables, no inline color. The page's CSS owns the active scheme.
    • omitted: defers to Shiki's own default of 'light' (Shiki throws if themes lacks a light key).

Per-node CodeNode.__theme, Tokenizer.defaultTheme, and a custom tokenizer.$tokenize are skipped in multi-theme mode. Themes are a Shiki-specific feature with no equivalent in the generic Tokenizer abstraction, so the multi-theme path goes straight to Shiki's codeToTokens.

Backwards compatibility

  • registerCodeHighlighting signature unchanged.
  • The Extension's register effect calls the existing internal registerHighlightingOnly directly with themes / defaultColor. The legacy export doesn't gain a themes parameter.
  • Tokenizer interface unchanged.

Playground

CodeHighlightExtension (the playground aggregator) configures CodeShikiExtension with themes: {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 / defaultColor on CodeShikiExtension.config (this PR) vs. extending Tokenizer. Putting them on the highlighter-level config seemed cleaner since themes are Shiki-specific, but a Tokenizer.themes field is also workable.
  • Multi-theme bypasses Tokenizer.$tokenize entirely; a custom Tokenizer is consulted only in single-theme mode. The alternative is extending Tokenizer.$tokenize to take theme args. The bypass keeps the generic interface unchanged at the cost of leaving custom tokenizers single-theme-only.
  • The playground demo defaults to 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:
    • Single-theme mode keeps inline rgb color (BC)
    • defaultColor: 'dark' selects the inline theme; the other becomes a CSS variable
    • defaultColor: false emits only CSS variables, no inline color
    • Multi-theme mode ignores per-node setTheme
  • Tests exercise behavior through CodeShikiExtension config (extension scenario), not the internal helper.
  • Full unit suite (2450 tests) passes.
  • tsc / flow / prettier / eslint clean.
  • Manual playground (Shiki mode, defaultColor: 'light'):
    • <code> has inline background-color: rgb(...) (light) + --shiki-dark-bg: #.... Each token has inline color: rgb(...) (light) + --shiki-dark: #....
    • In DevTools: document.querySelectorAll('code, code span').forEach(el => el.style.color = 'var(--shiki-dark)') flips colors to the dark scheme without re-tokenizing.
    • "Convert to HTML" preserves the inline style including CSS variables.
  • Manual playground with defaultColor: false (temporary swap):
    • <code> and each token carry only --shiki-light(-bg) and --shiki-dark(-bg) variables. No inline color: / background-color: declarations.

Example CSS for runtime toggle

/* defaultColor: 'light' — light is inline, dark is exposed as variable */
[data-theme="dark"] code {
  background-color: var(--shiki-dark-bg);
  color: var(--shiki-dark);
}
[data-theme="dark"] code span {
  color: var(--shiki-dark);
}
@vercel

vercel Bot commented May 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
lexical Ready Ready Preview, Comment May 5, 2026 1:53am
lexical-playground Ready Ready Preview, Comment May 5, 2026 1:53am

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label May 2, 2026
@etrepum etrepum added the extended-tests Run extended e2e tests on a PR label May 2, 2026
@etrepum

etrepum commented May 2, 2026

Copy link
Copy Markdown
Collaborator

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 etrepum left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +62 to +63
expect($isCodeNode(node)).toBe(true);
const codeStyle = (node as CodeNode).getStyle();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use assert for checking guards instead of expect+cast (only marking the first instance of this issue)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switched to assert, casts dropped.

Comment on lines +29 to +34
function $createCodeBlock(text: string): CodeNode {
const codeNode = $createCodeNode();
codeNode.append($createTextNode(text));
$getRoot().clear().append(codeNode);
return codeNode;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Setup is in $initialEditorState now (factored as $populateCodeBlock); test bodies are just editor.read(...).

Comment on lines +66 to +69
const firstChild = (node as CodeNode).getFirstChild();
if (!$isTextNode(firstChild)) {
throw new Error('expected first child to be a TextNode');
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assert

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

*
* Ignored when {@link themes} is `null`.
*/
defaultColor: string | false | undefined;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to defaultThemeName. Took "I don't know if we need a false inhabitant" as "consolidate falsenull" 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>({

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should also have a mergeConfig for this data structure now

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added mergeShikiConfig modeled on mergeRichTextConfig — deep-merges themes, shallow for the rest.

Comment on lines +518 to +519
// Listed explicitly so namedSignals creates a `defaultColor` signal;
// dropping the key would leave the signal absent at runtime.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extension config should generally never have optional properties, the default config should always provide every key. overrides are always a partial

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe lift this default to the configuration

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$getHighlightNodes takes defaultTheme now; ShikiTokenizer.$tokenize passes this.defaultTheme. 'poimandres' is gone.

@mayrang

mayrang commented May 2, 2026

Copy link
Copy Markdown
Contributor Author

Playground is now defaultThemeName: null (vars-only); PlaygroundEditorTheme.css reads --shiki-light(-bg) with rgb(240, 242, 245) as the off/Prism-mode fallback. Default config still has themes: null, so single-theme users get inline rgb with no CSS work.

@etrepum

etrepum commented May 2, 2026

Copy link
Copy Markdown
Collaborator

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?

@mayrang

mayrang commented May 2, 2026

Copy link
Copy Markdown
Contributor Author

Confirmed — data-theme gets written but $getHighlightNodes doesn't read it, so the dropdown is dead in shiki mode.

Playground runs defaultThemeName: null (vars-only, system light/dark via light-dark()). If someone picks dark-plus from the dropdown, should that one block break out of vars-only and use inline colors, or should it switch the editor-wide theme?

@etrepum

etrepum commented May 3, 2026

Copy link
Copy Markdown
Collaborator

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.

@mayrang

mayrang commented May 3, 2026

Copy link
Copy Markdown
Contributor Author

Agreed — Tokenizer is leaky. defaultTheme and $tokenize are both bypassed in multi-theme mode (the JSDoc admits as much), and Prism's Tokenizer is a different shape.

Direction we're leaning, unless you have a different read: deprecate Tokenizer as a type alias (keeping it exported for BC), flatten defaultLanguage / $tokenize onto CodeShikiConfig, and change $tokenize to (codeNode, lang, themeContext) => LexicalNode[] so the transform's multi-theme branch dissolves into the default $tokenize. registerCodeHighlighting and CodeHighlighterShikiExtension keep their signatures and forward Tokenizer fields into the new flat config.

Two open calls if that direction lands:

  1. Deprecated alias vs clean break on Tokenizer? Leaning deprecated to honor BC on legacy exports.

  2. How to model single-theme in the new config — (a) as a 1-key multi case (themes: {only: '...'}, defaultThemeName: 'only'), or (b) a flat defaultTheme field alongside themes / defaultThemeName?

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.

@etrepum

etrepum commented May 3, 2026

Copy link
Copy Markdown
Collaborator

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.

  1. Generally we lean towards backwards compatibility whenever feasible

  2. Will have to think about this more, especially in the context of code nodes being able to individually choose a theme. Other options in the solution space allow for the data type to be string or some kind of object and dispatch accordingly, which solves some precedence confusion.

additionally we should think about making some or most of this be reconfigurable or extensible at runtime via output signals and/or functions.

@mayrang

mayrang commented May 3, 2026

Copy link
Copy Markdown
Contributor Author

BC route works — Tokenizer deprecated alias, $tokenize third arg, Tokenizer defaults below top-level. Starting on those.

On the string-or-object dispatch — reading it as: collapse themes + defaultThemeName into one field that's either a string (single theme name) or an object with the themes map + inline default. Type encodes the mode, precedence question dissolves. Extending the same shape to per-node __theme. Let me know if that's off; otherwise going with it.

@etrepum

etrepum commented May 3, 2026

Copy link
Copy Markdown
Collaborator

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.

@mayrang mayrang marked this pull request as draft May 3, 2026 19:38
@mayrang

mayrang commented May 3, 2026

Copy link
Copy Markdown
Contributor Author

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 ({default: 'one-light'}) so a bare configExtension(CodeShikiExtension) works without CSS. setTheme(key) looks up registry first, falls back to single-theme with the raw id if the key is a known Shiki theme (BC for setTheme('one-light') legacy use), with a dev warn nudging toward registry. Function form on the registry value covers the runtime extensibility you flagged.

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, inlineDefault is dead config — JSDoc'd as only meaningful for {light, dark} entries.

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 inline: null N times (DRY-able with a helper).

Both keep __theme as string per your earlier point — the split is just where inline lives.

That's what I came up with. Moving the PR to draft for now.

@etrepum

etrepum commented May 3, 2026

Copy link
Copy Markdown
Collaborator

What does the inlineDefault / inline policy actually do?

@mayrang

mayrang commented May 3, 2026

Copy link
Copy Markdown
Contributor Author

Both options control the same thing — which theme's colors get baked into the style attribute (the rest get exposed as --shiki-{name} CSS variables). Values:

  • 'light' → light inline, dark as var(--shiki-dark)
  • 'dark' → dark inline, light as var
  • null → both as vars only, no inline color. Page CSS picks the active scheme via light-dark(), class toggle, etc.

It's the defaultColor field renamed (since you flagged that name in round 1).

@etrepum

etrepum commented May 3, 2026

Copy link
Copy Markdown
Collaborator

What would be the use case for setting {light: 'lightTheme', dark: 'darkTheme', inline: 'light' | 'dark'} instead of using 'lightTheme' or 'darkTheme' directly as a string? I'm having a hard time coming up with a reason you wouldn't always want the behavior to be equivalent to {light: 'lightTheme', dark: 'darkTheme', inline: null}

@mayrang

mayrang commented May 3, 2026

Copy link
Copy Markdown
Contributor Author

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 — {light, dark} always means vars-only, no inline field.

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.

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.

@potatowagon potatowagon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — Multi-theme / CSS-Variable Color Mode for Code Shiki

Assessment: Looks good to land

What I verified:

  1. 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.

  2. CI status: Full CI suite green (38 checks pass).

  3. Author context: mayrang has multiple PRs in the queue (also #8603 Named Slots) — active contributor with good familiarity with the codebase.

  4. Risk: Additive feature, extends existing code-shiki package without modifying core highlighting logic.

— via Navi on behalf of potatowagon

mayrang added a commit to mayrang/lexical that referenced this pull request Jun 14, 2026
…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.
mayrang added a commit to mayrang/lexical that referenced this pull request Jun 17, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. extended-tests Run extended e2e tests on a PR

3 participants