Use configured Shiki colors in both editors #105
No files matched your search
@@ -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.
|
||||
@@ -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'
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,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', () => {
|
||||
|
||||
Generated
+3
@@ -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:'
|
||||
|
||||
Reference in new issue
Block a user