Use configured Shiki colors in both editors #105

Merged
eleith merged 2 commits from feat/configured-editor-theme into main 2026-10-05 13:27:48 +00:00
18 changed files with 598 additions and 253 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"@luzzle/web": patch
---
Use the configured Shiki light/dark themes for source and asset editor palettes instead of fixed Gruvbox colors. Keep native CodeMirror highlighting and editing behavior, with a bounded Markdown/YAML color mapping and no Shiki tokenizer in the browser.
+2 -2
View File
@@ -213,5 +213,5 @@ theme:
color-outline-variant: '#d6d3d1'
markdown:
code:
light: 'github-light-default'
dark: 'github-dark-default'
light: 'min-light'
dark: 'min-dark'
+4 -1
View File
@@ -1,4 +1,5 @@
import { generateThemeCss } from '@luzzle/web.theme'
import { generateEditorThemeCss } from '@luzzle/web.theme/editor'
import { loadConfig } from '@luzzle/web.config'
import path from 'path'
import { existsSync } from 'fs'
@@ -23,7 +24,9 @@ if (isEntrypoint) {
try {
const configPath = resolveConfigPath(process.argv[2])
const config = loadConfig(configPath)
process.stdout.write(generateThemeCss(config))
const themeCss = generateThemeCss(config)
const editorCss = await generateEditorThemeCss(config.theme.markdown.code)
process.stdout.write(themeCss + editorCss)
} catch (err) {
console.error(err instanceof Error ? err.message : err)
process.exit(1)
@@ -2,8 +2,7 @@
import { onMount, onDestroy } from 'svelte'
import { EditorView } from 'codemirror'
import { EditorState, Compartment } from '@codemirror/state'
import { gruvboxDark, gruvboxDarkBg } from './themes/gruvbox-dark'
import { gruvboxLight, gruvboxLightBg } from './themes/gruvbox-light'
import { configuredTheme } from './themes/configured'
import { createEditorExtensions } from './config'
import { createLSPExtension, destroyLSPClient } from './lsp'
import { luzzleFieldEditor } from './extensions/luzzleFieldEditor'
@@ -41,11 +40,7 @@
function updateTheme() {
const { isDark } = readTheme()
const themeExtension = isDark ? gruvboxDark : gruvboxLight
if (editorContainer) {
editorContainer.style.backgroundColor = isDark ? gruvboxDarkBg : gruvboxLightBg
}
const themeExtension = configuredTheme(isDark ? 'dark' : 'light')
view.dispatch({
effects: themeConfig.reconfigure(themeExtension)
@@ -54,7 +49,7 @@
onMount(() => {
const { isDark } = readTheme()
const initialTheme = isDark ? gruvboxDark : gruvboxLight
const initialTheme = configuredTheme(isDark ? 'dark' : 'light')
const extensions = createEditorExtensions({
themeConfig,
@@ -174,6 +169,7 @@
<style>
.editor-wrapper {
background-color: var(--editor-background, var(--color-surface));
width: 100%;
border-radius: var(--radius-medium);
overflow: hidden;
@@ -186,7 +182,7 @@
content: '';
position: absolute;
inset: 0;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.5);
box-shadow: inset 0 2px 4px var(--color-shadow);
pointer-events: none;
z-index: 12;
border-radius: inherit;
@@ -194,7 +190,6 @@
:global(.cm-editor) {
height: 100%;
background-color: transparent !important;
}
:global(.cm-scroller) {
@@ -1,71 +0,0 @@
import { type Extension } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { HighlightStyle, type TagStyle, syntaxHighlighting } from '@codemirror/language'
export interface ThemeSettings {
background: string
foreground: string
caret: string
selection: string
selectionMatch?: string
lineHighlight: string
gutterBackground: string
gutterForeground: string
gutterActiveForeground?: string
/** CSS var --cm-attribute, used by yamlAssetWidget / linkDetector */
attribute?: string
/** CSS var --cm-variable, used by yamlKeyWidget */
variable?: string
/** CSS var --cm-link */
link?: string
}
export function createTheme(
theme: 'light' | 'dark',
settings: ThemeSettings,
styles: TagStyle[]
): Extension {
const baseStyles: Record<string, string> = {
color: settings.foreground,
backgroundColor: settings.background
}
if (settings.attribute) baseStyles['--cm-attribute'] = settings.attribute
if (settings.variable) baseStyles['--cm-variable'] = settings.variable
if (settings.link) baseStyles['--cm-link'] = settings.link
const s: Record<string, Record<string, string>> = {
'&': baseStyles,
'.cm-content': {
caretColor: settings.caret
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: settings.caret
},
'.cm-gutters': {
backgroundColor: settings.gutterBackground,
color: settings.gutterForeground,
borderRightColor: 'transparent'
},
'.cm-activeLine': {
backgroundColor: settings.lineHighlight
},
'.cm-activeLineGutter': {
backgroundColor: settings.lineHighlight,
color: settings.gutterActiveForeground ?? settings.foreground
},
['&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground,' +
' .cm-selectionBackground,' +
' .cm-content ::selection']: {
backgroundColor: settings.selection
}
}
if (settings.selectionMatch) {
s['.cm-selectionMatch'] = { backgroundColor: settings.selectionMatch }
}
return [
EditorView.theme(s, { dark: theme === 'dark' }),
syntaxHighlighting(HighlightStyle.define(styles))
]
}
@@ -0,0 +1,110 @@
.cm-editor {
color: var(--editor-foreground, var(--color-on-surface));
background-color: var(--editor-background, var(--color-surface));
--cm-attribute: var(--editor-string, var(--color-on-surface));
--cm-variable: var(--editor-key, var(--color-on-surface));
--cm-link: var(--editor-link, var(--color-on-surface));
}
.cm-editor .cm-content {
caret-color: var(--editor-caret, var(--color-on-surface));
}
.cm-editor :is(.cm-cursor, .cm-dropCursor) {
border-left-color: var(--editor-caret, var(--color-on-surface));
}
.cm-editor .cm-gutters {
background-color: var(--editor-gutterBackground, var(--color-surface));
color: var(--editor-gutterForeground, var(--color-on-surface-variant));
border-right-color: transparent;
}
.cm-editor :is(.cm-activeLine, .cm-activeLineGutter) {
background-color: var(--editor-lineHighlight, var(--color-surface-container-low));
}
.cm-editor .cm-activeLineGutter {
color: var(--editor-gutterActiveForeground, var(--color-on-surface));
}
.cm-editor.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground,
.cm-editor .cm-selectionBackground,
.cm-editor .cm-content ::selection,
.cm-editor .cm-content::selection {
background-color: var(--editor-selection, var(--color-surface-container-highest));
}
.cm-editor .cm-selectionMatch {
background-color: var(--editor-selectionMatch, var(--color-surface-container-high));
}
.cm-editor :is(.cm-tooltip, .cm-panels) {
background-color: var(--editor-panelBackground, var(--color-surface-container));
color: var(--editor-panelForeground, var(--color-on-surface));
border-color: var(--editor-border, var(--color-outline));
}
.cm-editor .cm-tooltip-autocomplete > ul > li[aria-selected] {
background-color: var(--editor-selection, var(--color-surface-container-highest));
color: var(--editor-foreground, var(--color-on-surface));
}
/* Keep the spike's role colors and Markdown formatting, now on stable classes. */
.cm-editor .tok-heading {
color: var(--editor-heading, var(--color-on-surface));
font-weight: bold;
}
.cm-editor .tok-strong {
color: var(--editor-strong, var(--color-on-surface));
font-weight: bold;
}
.cm-editor .tok-emphasis {
color: var(--editor-emphasis, var(--color-on-surface));
font-style: italic;
}
.cm-editor .tok-strikethrough {
text-decoration: line-through;
}
.cm-editor .tok-quote {
color: var(--editor-quote, var(--color-on-surface-variant));
}
.cm-editor .tok-monospace {
color: var(--editor-code, var(--color-on-surface));
}
.cm-editor .tok-link {
color: var(--editor-link, var(--color-on-surface));
text-decoration: underline;
}
.cm-editor .tok-url {
color: var(--editor-url, var(--color-on-surface));
text-decoration: underline;
}
.cm-editor .tok-name {
color: var(--editor-key, var(--color-on-surface));
}
.cm-editor :is(.tok-string, .tok-string2) {
color: var(--editor-string, var(--color-on-surface));
}
.cm-editor :is(.tok-number, .tok-bool, .tok-atom, .tok-keyword) {
color: var(--editor-literal, var(--color-on-surface));
}
.cm-editor .tok-comment {
color: var(--editor-comment, var(--color-on-surface-variant));
font-style: italic;
}
.cm-editor :is(.tok-punctuation, .tok-meta, .tok-contentSeparator, .tok-operator) {
color: var(--editor-punctuation, var(--color-on-surface-variant));
}
@@ -0,0 +1,152 @@
import { expect, test } from 'vitest'
import { readFileSync } from 'node:fs'
import { Compartment, EditorState, type Transaction } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { history, undo } from '@codemirror/commands'
import { ensureSyntaxTree, highlightingFor, syntaxTree } from '@codemirror/language'
import { markdown } from '@codemirror/lang-markdown'
import { yamlFrontmatter } from '@codemirror/lang-yaml'
import { highlightTree, tags as t, type Tag } from '@lezer/highlight'
import { configuredTheme } from './configured'
test.each(['light', 'dark'] as const)('highlights Markdown/YAML in %s mode', (mode) => {
const doc = `---
title: "A title"
count: 42
published: true
# a comment
---
# Heading
**strong** and *emphasis* with \`code\` and [label](https://example.test).
> quote
`
const state = EditorState.create({
doc,
extensions: [yamlFrontmatter({ content: markdown() }), configuredTheme(mode)]
})
expect(state.facet(EditorView.darkTheme)).toBe(mode === 'dark')
const tree = ensureSyntaxTree(state, doc.length, 1000)
expect(tree).not.toBeNull()
const ranges: { text: string; classes: string }[] = []
highlightTree(tree!, { style: (tags) => highlightingFor(state, tags) }, (from, to, classes) =>
ranges.push({ text: doc.slice(from, to), classes })
)
const examples: [string, Tag][] = [
['title', t.propertyName],
['A title', t.string],
['a comment', t.comment],
['---', t.meta],
['Heading', t.heading],
['strong', t.strong],
['emphasis', t.emphasis],
['code', t.monospace],
['https://example.test', t.url],
['quote', t.quote]
]
for (const [text, tag] of examples) {
const style = highlightingFor(state, [tag])
expect(style, text).toBeTruthy()
expect(
ranges.some(
(range) =>
range.text.includes(text) &&
style!.split(' ').every((name) => range.classes.split(' ').includes(name))
),
text
).toBe(true)
}
// YAML's parser leaves unquoted scalars as content, rather than number/bool tags.
for (const tag of [t.link, t.strikethrough, t.punctuation, t.number, t.bool, t.atom]) {
expect(highlightingFor(state, [tag])).toBeTruthy()
}
})
test('emits stable classes for other asset languages without another palette', () => {
const state = EditorState.create({ extensions: configuredTheme('light') })
const examples: [Tag, string][] = [
[t.variableName, 'tok-name'],
[t.function(t.variableName), 'tok-name'],
[t.function(t.propertyName), 'tok-name'],
[t.typeName, 'tok-name'],
[t.tagName, 'tok-name'],
[t.attributeName, 'tok-name'],
[t.attributeValue, 'tok-string'],
[t.keyword, 'tok-keyword'],
[t.controlKeyword, 'tok-keyword'],
[t.operator, 'tok-operator'],
[t.processingInstruction, 'tok-meta'],
[t.contentSeparator, 'tok-contentSeparator']
]
for (const [tag, name] of examples) {
expect(highlightingFor(state, [tag])?.split(' ')).toContain(name)
}
})
test('CSS binds the token classes to the accepted palette instead of fixed colors', () => {
const css = readFileSync(new URL('./configured.css', import.meta.url), 'utf8')
const roles = {
heading: 'heading',
strong: 'strong',
emphasis: 'emphasis',
quote: 'quote',
monospace: 'code',
link: 'link',
url: 'url',
name: 'key',
string: 'string',
string2: 'string',
number: 'literal',
bool: 'literal',
atom: 'literal',
keyword: 'literal',
comment: 'comment',
punctuation: 'punctuation',
meta: 'punctuation',
contentSeparator: 'punctuation',
operator: 'punctuation'
}
for (const [token, role] of Object.entries(roles)) {
expect(css).toMatch(
new RegExp(`\\.tok-${token}\\b[^{]*\\{[^}]*color:\\s*var\\(--editor-${role},`)
)
}
expect(css).toMatch(/^\.cm-editor\s*\{/)
expect(css).not.toContain('luzzle-editor')
expect(css).toContain('--cm-attribute: var(--editor-string,')
expect(css).toContain('--cm-variable: var(--editor-key,')
expect(css).toContain('--cm-link: var(--editor-link,')
expect(css).not.toMatch(/#[\da-f]{3,8}\b/i)
})
test('theme reconfiguration preserves the document, selection, parser and undo history', () => {
const theme = new Compartment()
let state = EditorState.create({
doc: '# Heading',
extensions: [markdown(), history(), theme.of(configuredTheme('light'))]
})
state = state.update({ changes: { from: 9, insert: ' edited' }, selection: { anchor: 4 } }).state
const tree = syntaxTree(state)
for (const mode of ['dark', 'light'] as const) {
state = state.update({ effects: theme.reconfigure(configuredTheme(mode)) }).state
expect(state.facet(EditorView.darkTheme)).toBe(mode === 'dark')
expect(state.doc.toString()).toBe('# Heading edited')
expect(state.selection.main.anchor).toBe(4)
expect(syntaxTree(state)).toBe(tree)
expect(highlightingFor(state, [t.heading])).toBeTruthy()
}
expect(
undo({
state,
dispatch: (transaction: Transaction) => {
state = transaction.state
}
})
).toBe(true)
expect(state.doc.toString()).toBe('# Heading')
})
@@ -0,0 +1,21 @@
import { classHighlighter, tagHighlighter, tags as t } from '@lezer/highlight'
import { syntaxHighlighting } from '@codemirror/language'
import { EditorView } from '@codemirror/view'
import './configured.css'
// Supplement the standard token classes with the Markdown roles it omits.
const extraClasses = tagHighlighter([
{ tag: t.quote, class: 'tok-quote' },
{ tag: t.monospace, class: 'tok-monospace' },
{ tag: t.strikethrough, class: 'tok-strikethrough' },
{ tag: t.contentSeparator, class: 'tok-contentSeparator' },
{ tag: t.name, class: 'tok-name' }
])
export function configuredTheme(mode: 'light' | 'dark') {
return [
EditorView.darkTheme.of(mode === 'dark'),
syntaxHighlighting(classHighlighter),
syntaxHighlighting(extraClasses)
]
}
@@ -1,73 +0,0 @@
import { tags as t } from '@lezer/highlight'
import { createTheme, type ThemeSettings } from '../create-theme.js'
const settings: ThemeSettings = {
background: '#282828',
foreground: '#ebdbb2',
caret: '#ebdbb2',
selection: '#b99d555c',
selectionMatch: '#b99d555c',
lineHighlight: '#baa1602b',
gutterBackground: '#282828',
gutterForeground: '#7c6f64',
gutterActiveForeground: '#ebdbb2',
attribute: '#fabd2f',
variable: '#83a598',
link: '#7c6f64'
}
const red = '#fb4934'
const green = '#b8bb26'
const yellow = '#fabd2f'
const blue = '#83a598'
const purple = '#d3869b'
const orange = '#fe8019'
const gray = '#928374'
export const gruvboxDark = createTheme('dark', settings, [
{ tag: t.keyword, color: red },
{ tag: [t.controlKeyword, t.moduleKeyword, t.modifier, t.operatorKeyword], color: red },
{ tag: [t.variableName, t.definition(t.variableName)], color: blue },
{ tag: [t.propertyName, t.standard(t.variableName)], color: green },
{
tag: [t.function(t.variableName), t.function(t.propertyName)],
color: green,
fontStyle: 'bold'
},
{ tag: [t.labelName, t.macroName], color: '#ebdbb2' },
{ tag: [t.typeName, t.className, t.definition(t.typeName)], color: yellow },
{ tag: [t.namespace], color: blue, fontStyle: 'italic' },
{ tag: [t.number, t.changed, t.self, t.constant(t.name), t.bool, t.atom], color: purple },
{ tag: [t.string, t.processingInstruction, t.inserted], color: green },
{ tag: [t.special(t.string), t.regexp, t.escape], color: purple },
{ tag: t.tagName, color: green },
{ tag: [t.attributeName, t.attributeValue], color: yellow },
{ tag: [t.operator, t.bracket, t.brace, t.separator, t.punctuation], color: '#ebdbb2' },
{ tag: t.squareBracket, color: orange },
{ tag: t.angleBracket, color: blue },
{ tag: [t.comment, t.lineComment, t.blockComment], color: gray, fontStyle: 'italic' },
{ tag: [t.docComment, t.meta], color: yellow, fontStyle: 'italic' },
{ tag: [t.heading, t.heading1, t.heading2], fontWeight: 'bold', color: green },
{ tag: [t.heading3, t.heading4], fontWeight: 'bold', color: yellow },
{ tag: [t.heading5, t.heading6], color: yellow },
{ tag: t.strong, fontWeight: 'bold', color: orange },
{ tag: t.emphasis, fontStyle: 'italic', color: green },
{ tag: t.strikethrough, textDecoration: 'line-through' },
{ tag: t.quote, color: gray },
{ tag: t.link, color: '#7c6f64', textDecoration: 'underline' },
{ tag: t.url, color: purple, textDecoration: 'underline' },
{ tag: t.contentSeparator, color: red },
{ tag: t.invalid, color: orange }
])
export const gruvboxDarkBg = settings.background
@@ -1,73 +0,0 @@
import { tags as t } from '@lezer/highlight'
import { createTheme, type ThemeSettings } from '../create-theme.js'
const settings: ThemeSettings = {
background: '#fbf1c7',
foreground: '#3c3836',
caret: '#af3a03',
selection: '#bdae9391',
selectionMatch: '#bdae9391',
lineHighlight: '#a37f2238',
gutterBackground: '#fbf1c7',
gutterForeground: '#665c54',
gutterActiveForeground: '#3c3836',
attribute: '#b57614',
variable: '#076678',
link: '#7c6f64'
}
const red = '#9d0006'
const green = '#79740e'
const yellow = '#b57614'
const blue = '#076678'
const purple = '#8f3f71'
const orange = '#af3a03'
const gray = '#928374'
export const gruvboxLight = createTheme('light', settings, [
{ tag: t.keyword, color: red },
{ tag: [t.controlKeyword, t.moduleKeyword, t.modifier, t.operatorKeyword], color: red },
{ tag: [t.variableName, t.definition(t.variableName)], color: blue },
{ tag: [t.propertyName, t.standard(t.variableName)], color: green },
{
tag: [t.function(t.variableName), t.function(t.propertyName)],
color: green,
fontStyle: 'bold'
},
{ tag: [t.labelName, t.macroName], color: '#3c3836' },
{ tag: [t.typeName, t.className, t.definition(t.typeName)], color: yellow },
{ tag: [t.namespace], color: blue, fontStyle: 'italic' },
{ tag: [t.number, t.changed, t.self, t.constant(t.name), t.bool, t.atom], color: purple },
{ tag: [t.string, t.processingInstruction, t.inserted], color: green },
{ tag: [t.special(t.string), t.regexp, t.escape], color: purple },
{ tag: t.tagName, color: green },
{ tag: [t.attributeName, t.attributeValue], color: yellow },
{ tag: [t.operator, t.bracket, t.brace, t.separator, t.punctuation], color: '#3c3836' },
{ tag: t.squareBracket, color: orange },
{ tag: t.angleBracket, color: blue },
{ tag: [t.comment, t.lineComment, t.blockComment], color: gray, fontStyle: 'italic' },
{ tag: [t.docComment, t.meta], color: yellow, fontStyle: 'italic' },
{ tag: [t.heading, t.heading1, t.heading2], fontWeight: 'bold', color: green },
{ tag: [t.heading3, t.heading4], fontWeight: 'bold', color: yellow },
{ tag: [t.heading5, t.heading6], color: yellow },
{ tag: t.strong, fontWeight: 'bold', color: orange },
{ tag: t.emphasis, fontStyle: 'italic', color: green },
{ tag: t.strikethrough, textDecoration: 'line-through' },
{ tag: t.quote, color: gray },
{ tag: t.link, color: '#7c6f64', textDecoration: 'underline' },
{ tag: t.url, color: purple, textDecoration: 'underline' },
{ tag: t.contentSeparator, color: red },
{ tag: t.invalid, color: orange }
])
export const gruvboxLightBg = settings.background
@@ -8,14 +8,7 @@
import type { PageProps } from './$types'
import { EditorView } from 'codemirror'
import { EditorState, Compartment } from '@codemirror/state'
import {
gruvboxDark,
gruvboxDarkBg
} from '../../../../../lib/components/editor/themes/gruvbox-dark'
import {
gruvboxLight,
gruvboxLightBg
} from '../../../../../lib/components/editor/themes/gruvbox-light'
import { configuredTheme } from '$lib/components/editor/themes/configured'
import {
highlightSpecialChars,
drawSelection,
@@ -102,11 +95,7 @@
function updateTheme() {
if (!view) return
const { isDark } = readTheme()
const themeExtension = isDark ? gruvboxDark : gruvboxLight
if (editorContainer) {
editorContainer.style.backgroundColor = isDark ? gruvboxDarkBg : gruvboxLightBg
}
const themeExtension = configuredTheme(isDark ? 'dark' : 'light')
view.dispatch({
effects: themeConfig.reconfigure(themeExtension)
@@ -144,11 +133,7 @@
if (data.isBinary || !editorContainer) return
const { isDark } = readTheme()
const initialTheme = isDark ? gruvboxDark : gruvboxLight
if (editorContainer) {
editorContainer.style.backgroundColor = isDark ? gruvboxDarkBg : gruvboxLightBg
}
const initialTheme = configuredTheme(isDark ? 'dark' : 'light')
const extensions = [
highlightSpecialChars(),
@@ -611,6 +596,7 @@
}
.editor-element {
background-color: var(--editor-background, var(--color-surface));
flex: 1;
position: relative;
overflow: hidden;
@@ -622,7 +608,7 @@
content: '';
position: absolute;
inset: 0;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.5);
box-shadow: inset 0 2px 4px var(--color-shadow);
pointer-events: none;
z-index: 12;
border-radius: inherit;
@@ -630,7 +616,6 @@
:global(.cm-editor) {
height: 100%;
background-color: transparent !important;
}
:global(.cm-scroller) {
+1 -1
View File
@@ -97,7 +97,7 @@ The builder settings define how the Explorer triggers background build processes
| `theme.globals` | No | (Material) | Global design tokens (fonts, spacing, radii, breakpoints). |
| `theme.light` | No | (Material) | Custom colors for light mode. |
| `theme.dark` | No | (Material) | Custom colors for dark mode. |
| `theme.markdown.code` | Yes | - | Shiki themes for code blocks. |
| `theme.markdown.code` | Yes | - | Shiki themes for code blocks and editor palettes. |
<!-- markdownlint-enable MD013 -->
+21
View File
@@ -10,9 +10,30 @@ This package exposes:
- Reset styles (`styles/reset.css`)
- Base theme layout CSS (`styles/base.css`)
- Markdown display styling (`styles/markdown.css`)
- Editor color variables derived from the configured Shiki light/dark themes
- TypeScript models for theme customization
Web pages must leverage variables from this package rather than hardcoding style
properties to preserve dark mode and custom configuration flexibility.
Editor palettes are prepared through the build-side `@luzzle/web.theme/editor`
entry point. They share `theme.markdown.code.light` and `.dark` with rendered
code blocks; the browser keeps native CodeMirror highlighting and does not load
Shiki's tokenizer. In the web app, CodeMirror emits stable token classes and
`configured.css` applies the generated colors, including existing field/link
widget colors. No palette values live in browser JavaScript:
```text
Configured Shiki names → build-side projection → theme.build.css variables
CodeMirror parser → stable token classes → configured.css rules
```
Markdown/YAML colors use a bounded scope mapping, so contextual rules such as
distinct heading-level colors may differ from rendered Shiki output. Other asset
languages reuse these basic color roles.
The ordinary page-theme generator remains synchronous and independent of editor
palette loading. Application theme CSS must be regenerated through the normal
startup/build workflow after changing the configured themes.
It is not published to npm.
+7 -1
View File
@@ -28,6 +28,11 @@
"types": "./dist/src/index.d.ts",
"import": "./dist/src/index.js"
},
"./editor": {
"development": "./src/editor.ts",
"types": "./dist/src/editor.d.ts",
"import": "./dist/src/editor.js"
},
"./styles/base.css": "./assets/styles/base.css",
"./styles/reset.css": "./assets/styles/reset.css",
"./styles/markdown.css": "./assets/styles/markdown.css"
@@ -47,7 +52,8 @@
"vitest": "catalog:"
},
"dependencies": {
"@luzzle/web.config": "workspace:*"
"@luzzle/web.config": "workspace:*",
"@shikijs/themes": "3.22.0"
},
"type": "module"
}
+144
View File
@@ -0,0 +1,144 @@
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { generateEditorThemeCss } from './editor.js'
const fixtures = vi.hoisted(() => ({
light: {} as Record<string, unknown>,
dark: {} as Record<string, unknown>,
}))
vi.mock('@shikijs/themes', () => ({ themeNames: ['github-light-default', 'github-dark-default'] }))
vi.mock('@shikijs/themes/github-light-default', () => ({ get default() { return fixtures.light } }))
vi.mock('@shikijs/themes/github-dark-default', () => ({ get default() { return fixtures.dark } }))
const names = { light: 'github-light-default', dark: 'github-dark-default' }
beforeEach(() => {
fixtures.light = { name: names.light, bg: '#fff', fg: '#222' }
fixtures.dark = { name: names.dark, bg: '#111', fg: '#eee' }
})
async function lightVariables() {
const css = await generateEditorThemeCss(names)
const firstBlock = css.slice(0, css.indexOf('}'))
return Object.fromEntries(
[...firstBlock.matchAll(/--editor-([\w-]+): ([^;]+);/g)].map((match) => [match[1], match[2]])
)
}
describe('generateEditorThemeCss', () => {
test('emits default, explicit light/dark and system preference selectors', async () => {
const css = await generateEditorThemeCss(names)
expect(css).toContain(":root, :root[data-theme='light'], section[data-theme='light']")
expect(css).toContain(":root[data-theme='dark'], section[data-theme='dark']")
expect(css).toContain('@media (prefers-color-scheme: dark)')
expect(css).toContain(":root:not([data-theme='light'])")
expect(css.match(/--editor-background: #fff;/g)).toHaveLength(1)
expect(css.match(/--editor-background: #111;/g)).toHaveLength(2)
})
test('uses supplied editor chrome instead of inventing another palette', async () => {
fixtures.light.colors = {
'editor.background': '#fafafa', 'editor.foreground': '#333',
'editorCursor.foreground': '#123', 'editor.selectionBackground': '#abc',
'editor.selectionHighlightBackground': '#bcd', 'editor.lineHighlightBackground': '#def',
'editorGutter.background': '#eee', 'editorLineNumber.foreground': '#777',
'editorLineNumber.activeForeground': '#555', 'editorWidget.background': '#ddd',
'editorWidget.foreground': '#444', 'editorWidget.border': '#ccc',
}
expect(await lightVariables()).toMatchObject({
background: '#fafafa', foreground: '#333', caret: '#123', selection: '#abc',
selectionMatch: '#bcd', lineHighlight: '#def', gutterBackground: '#eee',
gutterForeground: '#777', gutterActiveForeground: '#555', panelBackground: '#ddd',
panelForeground: '#444', border: '#ccc',
})
})
test('uses shared selection and group border when editor-specific colors are missing', async () => {
fixtures.light.colors = { 'selection.background': '#abc', 'editorGroup.border': '#777' }
expect(await lightVariables()).toMatchObject({ selection: '#abc', border: '#777' })
})
test('uses the theme-wide foreground when no editor or token foreground is provided', async () => {
fixtures.light = { name: 'theme-wide', colors: { 'editor.background': '#fff', foreground: '#444' } }
expect(await lightVariables()).toMatchObject({ foreground: '#444', string: '#444' })
})
test('derives missing chrome and syntax values from the theme foreground/background', async () => {
const variables = await lightVariables()
expect(variables).toMatchObject({
background: '#fff', foreground: '#222', caret: '#222',
selection: 'color-mix(in srgb, #222 20%, #fff)',
lineHighlight: 'color-mix(in srgb, #222 6%, #fff)',
gutterBackground: '#fff', gutterForeground: '#222', gutterActiveForeground: '#222',
panelBackground: '#fff', panelForeground: '#222',
border: 'color-mix(in srgb, #222 20%, #fff)',
})
for (const role of ['heading', 'strong', 'emphasis', 'quote', 'code', 'link', 'url', 'key', 'string', 'literal', 'comment', 'punctuation']) {
expect(variables[role]).toBe('#222')
}
})
test('accepts arrays, comma-separated scopes, and scope-prefix matching', async () => {
fixtures.light.tokenColors = [
{ scope: [' comment, string ', 'markup.heading'], settings: { foreground: '#123' } },
]
expect(await lightVariables()).toMatchObject({ comment: '#123', string: '#123', heading: '#123' })
})
test('prefers specificity over order, using later rules to break ties', async () => {
fixtures.light.tokenColors = [
{ scope: 'string.quoted', settings: { foreground: '#111' } },
{ scope: 'string.quoted.double.yaml', settings: { foreground: '#333' } },
{ scope: 'string', settings: { foreground: '#222' } },
{ scope: 'string.quoted.double.yaml', settings: { foreground: '#444' } },
]
expect((await lightVariables()).string).toBe('#444')
})
test('uses alternate canonical scopes when the primary scope has no match', async () => {
fixtures.light.tokenColors = [
{ scope: 'markup.raw', settings: { foreground: '#123' } },
{ scope: 'string.other.link', settings: { foreground: '#456' } },
]
expect(await lightVariables()).toMatchObject({ code: '#123', url: '#456' })
})
test('ignores partial prefixes, contextual selectors, wildcards and colorless rules', async () => {
fixtures.light.tokenColors = [
{ scope: 'str', settings: { foreground: '#bad' } },
{ scope: 'heading.1.markdown entity.name.section.markdown', settings: { foreground: '#bad' } },
{ scope: 'markup.*', settings: { foreground: '#bad' } },
{ scope: 'markup.italic', settings: { fontStyle: 'italic' } },
{ scope: 'string' },
{},
]
expect(await lightVariables()).toMatchObject({ string: '#222', heading: '#222', emphasis: '#222' })
})
test('accepts legacy settings and the last unscoped foreground', async () => {
fixtures.light = { name: 'legacy', bg: '#fff', settings: [
{ settings: { foreground: '#111' } },
{ settings: { foreground: '#444' } },
{ scope: 'comment', settings: { foreground: '#777' } },
] }
expect(await lightVariables()).toMatchObject({ foreground: '#444', comment: '#777' })
})
test.each(['light', 'dark'] as const)('rejects an unknown %s theme before importing it', async (mode) => {
await expect(generateEditorThemeCss({ ...names, [mode]: '../not-a-theme' }))
.rejects.toThrow('Unknown editor theme: ../not-a-theme')
})
test.each([{ fg: '#222' }, { bg: '#fff' }, { tokenColors: [{ settings: {} }] }])(
'rejects missing base colors: %j', async (properties) => {
fixtures.light = { name: 'incomplete', ...properties }
await expect(generateEditorThemeCss(names)).rejects.toThrow('needs a background and foreground')
}
)
test('does not mutate loaded theme definitions', async () => {
fixtures.light.tokenColors = [{ scope: 'comment', settings: { foreground: '#777' } }]
const original = structuredClone(fixtures.light)
await generateEditorThemeCss(names)
expect(fixtures.light).toEqual(original)
})
})
+117
View File
@@ -0,0 +1,117 @@
import type { Config } from '@luzzle/web.config'
import { themeNames } from '@shikijs/themes'
type Rule = { scope?: string | string[]; settings?: { foreground?: string } }
type Theme = {
name: string
fg?: string
bg?: string
colors?: Record<string, string>
tokenColors?: Rule[]
settings?: Rule[]
}
/** Build-side only: project configured Shiki colors without shipping a tokenizer. */
export async function generateEditorThemeCss(themes: Config['theme']['markdown']['code']): Promise<string> {
const light = await loadTheme(themes.light)
const dark = await loadTheme(themes.dark)
const lightCss = themeVariables(light)
const darkCss = themeVariables(dark)
return `
:root, :root[data-theme='light'], section[data-theme='light'] {
${lightCss}
}
:root[data-theme='dark'], section[data-theme='dark'] {
${darkCss}
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
${darkCss}
}
}
`
}
async function loadTheme(name: string): Promise<Theme> {
if (!themeNames.includes(name)) throw new Error(`Unknown editor theme: ${name}`)
const theme = await import(`@shikijs/themes/${name}`)
return theme.default
}
// Canonical Markdown/YAML scopes, in fallback order. Not a TextMate selector engine.
const roles = {
heading: ['markup.heading.markdown', 'entity.name.section.markdown'],
strong: ['markup.bold.markdown', 'strong'],
emphasis: ['markup.italic.markdown', 'emphasis'],
quote: ['markup.quote.markdown', 'markup.punctuation.quote.beginning.markdown'],
code: ['markup.inline.raw.markdown', 'markup.raw.inline.markdown', 'markup.fenced_code.block.markdown'],
link: ['string.other.link.title.markdown', 'markup.underline.link.markdown'],
url: ['markup.underline.link.markdown', 'string.other.link.markdown'],
key: ['entity.name.tag.yaml', 'support.type.property-name.yaml'],
string: ['string.quoted.double.yaml', 'string.unquoted.plain.out.yaml', 'string'],
literal: ['constant.numeric.yaml', 'constant.language.yaml'],
comment: ['comment.line.number-sign.yaml', 'comment.block.html'],
punctuation: ['punctuation'],
}
function findColor(rules: Rule[], candidates: string[]): string | undefined {
for (const candidate of candidates) {
let color: string | undefined
let specificity = 0
for (const rule of rules) {
if (!rule.settings?.foreground) continue
const entries = Array.isArray(rule.scope) ? rule.scope : [rule.scope ?? '']
const scopes = entries.flatMap((entry) => entry.split(',')).map((entry) => entry.trim())
for (const scope of scopes) {
if (!/^[\w-]+(?:\.[\w-]+)*$/.test(scope)) continue
if (candidate !== scope && !candidate.startsWith(`${scope}.`)) continue
// More-specific scopes win; later rules break ties.
if (scope.length >= specificity) {
color = rule.settings.foreground
specificity = scope.length
}
}
}
if (color) return color
}
return undefined
}
function themeVariables(theme: Theme): string {
const colors = theme.colors ?? {}
const rules = theme.tokenColors ?? theme.settings ?? []
let defaultForeground = theme.fg
for (const rule of rules) {
if (!rule.scope && rule.settings?.foreground) defaultForeground = rule.settings.foreground
}
const background = colors['editor.background'] ?? theme.bg
const foreground = colors['editor.foreground'] ?? defaultForeground ?? colors.foreground
if (!background || !foreground) throw new Error(`Theme ${theme.name} needs a background and foreground.`)
const syntax: Record<string, string> = {}
for (const [role, candidates] of Object.entries(roles)) {
syntax[role] = findColor(rules, candidates) ?? foreground
}
const caret = colors['editorCursor.foreground'] ?? foreground
const selection = colors['editor.selectionBackground'] ?? colors['selection.background']
?? `color-mix(in srgb, ${caret} 20%, ${background})`
const chrome = {
background,
foreground,
caret,
selection,
selectionMatch: colors['editor.selectionHighlightBackground'] ?? selection,
lineHighlight: colors['editor.lineHighlightBackground']
?? `color-mix(in srgb, ${foreground} 6%, ${background})`,
gutterBackground: colors['editorGutter.background'] ?? background,
gutterForeground: colors['editorLineNumber.foreground'] ?? syntax.comment,
gutterActiveForeground: colors['editorLineNumber.activeForeground'] ?? foreground,
panelBackground: colors['editorWidget.background'] ?? background,
panelForeground: colors['editorWidget.foreground'] ?? foreground,
border: colors['editorWidget.border'] ?? colors['editorGroup.border']
?? `color-mix(in srgb, ${foreground} 20%, ${background})`,
}
return Object.entries({ ...chrome, ...syntax })
.map(([name, value]) => `\t--editor-${name}: ${value};`)
.join('\n')
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, test, expect } from 'vitest'
import type { Config } from '@luzzle/web.config'
import { generateThemeCss } from './theme.js'
import { generateThemeCss } from './index.js'
describe('generateThemeCss', () => {
test('generates theme CSS with custom font and colors', () => {
+3
View File
@@ -679,6 +679,9 @@ importers:
'@luzzle/web.config':
specifier: workspace:*
version: link:../config
'@shikijs/themes':
specifier: 3.22.0
version: 3.22.0
devDependencies:
'@eslint/js':
specifier: 'catalog:'