diff --git a/apps/desktop/src/renderer/components/MarkdownEditor.tsx b/apps/desktop/src/renderer/components/MarkdownEditor.tsx index bf0b4ffe..6a05239a 100644 --- a/apps/desktop/src/renderer/components/MarkdownEditor.tsx +++ b/apps/desktop/src/renderer/components/MarkdownEditor.tsx @@ -53,6 +53,7 @@ import { wrapSelectionWithUrl, continueMarkupKeymap, editorLineKeymap, + listIndentKeymap, } from '@dripnex/commands'; import { createWikilinkHighlighter, @@ -78,6 +79,7 @@ import { } from '../utils/isMissingWikilink'; import { notebookStyleProps } from '../utils/notebookStyle'; import { createEditorTheme, markdownHighlighting, SCROLL_PAST_END_PADDING } from './editorTheme.js'; +import { listMarkHighlighter } from './editor/listMarkDecorations'; import { fenceLanguageCompletions, slashCompletions } from './editor/slashCompletions'; import { UrlPastePicker } from './editor/UrlPastePicker'; import styles from './MarkdownEditor.module.css'; @@ -412,6 +414,7 @@ export const MarkdownEditor = forwardRef { + it('cycles every three nesting levels', () => { + expect(listMarkLevelClass(1)).toBe('md-list-mark'); + expect(listMarkLevelClass(2)).toBe('md-list-mark md-list-mark-2'); + expect(listMarkLevelClass(3)).toBe('md-list-mark md-list-mark-3'); + expect(listMarkLevelClass(4)).toBe('md-list-mark'); + expect(listMarkLevelClass(5)).toBe('md-list-mark md-list-mark-2'); + }); +}); + +describe('lezer markdown list marks', () => { + it('exposes ListMark nodes at each nesting level', () => { + const state = EditorState.create({ + doc: '- a\n - b\n - c', + extensions: [markdown()], + }); + const marks: { from: number; to: number; depth: number }[] = []; + syntaxTree(state).iterate({ + enter(node) { + if (node.name !== 'ListMark') return; + let depth = 0; + let parent = node.node.parent; + while (parent) { + if (parent.name === 'ListItem') depth += 1; + parent = parent.parent; + } + marks.push({ from: node.from, to: node.to, depth }); + }, + }); + expect(marks.map(m => m.depth)).toEqual([1, 2, 3]); + expect(state.doc.sliceString(marks[0]!.from, marks[0]!.to)).toMatch(/-/); + }); +}); diff --git a/apps/desktop/src/renderer/components/editor/listMarkDecorations.ts b/apps/desktop/src/renderer/components/editor/listMarkDecorations.ts new file mode 100644 index 00000000..7898ed78 --- /dev/null +++ b/apps/desktop/src/renderer/components/editor/listMarkDecorations.ts @@ -0,0 +1,55 @@ +/** + * Color Markdown list markers by nesting depth. + * + * Level 1 uses `--md-list-mark-color`; levels 2 and 3 use the matching + * tokens; deeper levels cycle. Only the marker is colored. + */ + +import { syntaxTree } from '@codemirror/language'; +import { RangeSetBuilder } from '@codemirror/state'; +import { Decoration, ViewPlugin, type EditorView, type ViewUpdate } from '@codemirror/view'; + +export function listMarkLevelClass(depth: number): string { + const cycle = ((Math.max(1, depth) - 1) % 3) + 1; + if (cycle === 1) return 'md-list-mark'; + return `md-list-mark md-list-mark-${cycle}`; +} + +function listMarkDecorations(view: EditorView) { + const builder = new RangeSetBuilder(); + for (const { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter(node) { + if (node.name !== 'ListMark') return; + let depth = 0; + let parent = node.node.parent; + while (parent) { + if (parent.name === 'ListItem') depth += 1; + parent = parent.parent; + } + if (depth < 1) depth = 1; + builder.add(node.from, node.to, Decoration.mark({ class: listMarkLevelClass(depth) })); + }, + }); + } + return builder.finish(); +} + +export const listMarkHighlighter = ViewPlugin.fromClass( + class { + decorations = Decoration.none; + + constructor(view: EditorView) { + this.decorations = listMarkDecorations(view); + } + + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged) { + this.decorations = listMarkDecorations(update.view); + } + } + }, + { decorations: value => value.decorations } +); diff --git a/apps/desktop/src/renderer/components/editorTheme.ts b/apps/desktop/src/renderer/components/editorTheme.ts index a6148dec..2c569791 100644 --- a/apps/desktop/src/renderer/components/editorTheme.ts +++ b/apps/desktop/src/renderer/components/editorTheme.ts @@ -70,6 +70,15 @@ export function createEditorTheme(fontSize: number, fontFamily: string, lineHeig '.cm-line': { padding: '0 4px', }, + '.md-list-mark': { + color: 'var(--md-list-mark-color, var(--cm-list))', + }, + '.md-list-mark-2': { + color: 'var(--md-list-mark-2-color)', + }, + '.md-list-mark-3': { + color: 'var(--md-list-mark-3-color)', + }, '.cm-nes-ghost': { opacity: '0.45', pointerEvents: 'none', @@ -181,8 +190,7 @@ export const markdownHighlighting = HighlightStyle.define([ { tag: tags.link, color: 'var(--cm-link)', textDecoration: 'underline' }, { tag: tags.url, color: 'var(--cm-link)' }, - // Lists - { tag: tags.list, color: 'var(--cm-list)' }, + // Lists — marker color comes from listMarkDecorations (.md-list-mark*) // Quotes { diff --git a/apps/desktop/src/renderer/ui/tokens/tokens.css b/apps/desktop/src/renderer/ui/tokens/tokens.css index 3e054eaa..ac6cdcf7 100644 --- a/apps/desktop/src/renderer/ui/tokens/tokens.css +++ b/apps/desktop/src/renderer/ui/tokens/tokens.css @@ -121,6 +121,15 @@ --chrome-header-padding: 8px 10px; --chrome-traffic-inset: 72px; + /* ── Editor list marks (Inkdrop --md-list-mark-*) ─────────────────────────── */ + --cm-list: var(--accent); + --md-list-mark-color: var(--accent); + --md-list-mark-2-color: #60a5fa; + --md-list-mark-3-color: #c4b5fd; + --mde-preview-list-marker-color: var(--text-primary); + --mde-preview-list-marker-2-color: var(--text-primary); + --mde-preview-list-marker-3-color: var(--text-primary); + /* ── Radii ────────────────────────────────────────────────────────────────── */ --radius-sm: 4px; --radius-md: 6px; @@ -209,6 +218,14 @@ --status-completed: #16a34a; --status-dropped: #e11d48; + --cm-list: var(--accent); + --md-list-mark-color: var(--accent); + --md-list-mark-2-color: #2563eb; + --md-list-mark-3-color: #7c3aed; + --mde-preview-list-marker-color: var(--text-primary); + --mde-preview-list-marker-2-color: var(--text-primary); + --mde-preview-list-marker-3-color: var(--text-primary); + --glass-bg: rgba(247, 246, 243, 0.88); --glass-bg-fallback: rgba(247, 246, 243, 0.96); --glass-bg-menu: rgba(255, 255, 255, 0.94); diff --git a/packages/commands/src/index.ts b/packages/commands/src/index.ts index 27055ad0..7c2db99b 100644 --- a/packages/commands/src/index.ts +++ b/packages/commands/src/index.ts @@ -45,6 +45,16 @@ export { editorLineKeymap, } from './markdown/lineCommands.js'; +export { + parseListLine, + formatListLine, + indentList, + indentListItem, + dedentListItem, + listIndentKeymap, +} from './markdown/listIndent.js'; +export type { ParsedListLine, ListKind } from './markdown/listIndent.js'; + export { SLASH_ITEMS, FENCE_LANGUAGES, diff --git a/packages/commands/src/markdown/listIndent.test.ts b/packages/commands/src/markdown/listIndent.test.ts new file mode 100644 index 00000000..9f1f9f7a --- /dev/null +++ b/packages/commands/src/markdown/listIndent.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, vi } from 'vitest'; +import { EditorState, EditorSelection } from '@codemirror/state'; +import type { EditorView } from '@codemirror/view'; +import { + parseListLine, + formatListLine, + indentList, + indentListItem, + dedentListItem, + fencesByLine, +} from './listIndent.js'; + +function fakeView(initialDoc: string, selection: { from: number; to: number }) { + let state = EditorState.create({ + doc: initialDoc, + selection: EditorSelection.range(selection.from, selection.to), + }); + const dispatch = vi.fn((spec: Parameters[0]) => { + state = state.update(spec).state; + }); + const view = { + get state() { + return state; + }, + dispatch, + }; + return { + view: view as unknown as EditorView, + doc: () => state.doc.toString(), + selectionMain: () => state.selection.main, + }; +} + +function indent(doc: string, line: number, direction: 1 | -1 = 1): string | null { + const next = indentList(doc, [line], direction); + return next ? next.join('\n') : null; +} + +describe('parseListLine', () => { + it('parses bullets with marker width 2', () => { + const p = parseListLine('- item'); + expect(p).toMatchObject({ + kind: 'bullet', + bullet: '-', + indent: 0, + markerWidth: 2, + rest: 'item', + }); + }); + + it('parses 1. with marker width 3', () => { + const p = parseListLine('1. item'); + expect(p).toMatchObject({ kind: 'ordered', number: 1, delimiter: '.', markerWidth: 3 }); + }); + + it('parses 10. with marker width 4', () => { + const p = parseListLine('10. item'); + expect(p).toMatchObject({ kind: 'ordered', number: 10, markerWidth: 4 }); + }); + + it('parses 1) delimiters', () => { + const p = parseListLine('1) item'); + expect(p).toMatchObject({ delimiter: ')', markerWidth: 3 }); + }); + + it('parses task items without counting the checkbox in marker width', () => { + const p = parseListLine('- [ ] task'); + expect(p).toMatchObject({ kind: 'bullet', markerWidth: 2, task: ' [ ]', rest: 'task' }); + expect(formatListLine(p!, 0)).toBe('- [ ] task'); + }); + + it('counts leading spaces as indent', () => { + expect(parseListLine(' - nested')?.indent).toBe(3); + }); + + it('rejects a marker without a following space', () => { + expect(parseListLine('-item')).toBeNull(); + expect(parseListLine('1.item')).toBeNull(); + }); +}); + +describe('fencesByLine', () => { + it('marks fenced contents, not the opening fence', () => { + const inside = fencesByLine(['para', '```js', '- not a list', '```', '- list']); + expect(inside).toEqual([false, false, true, true, false]); + }); +}); + +describe('indentList', () => { + it('nests a bullet under a numbered item by 3 spaces', () => { + expect(indent('1. This is a line of text.\n- This is a bullet.', 2)).toBe( + '1. This is a line of text.\n - This is a bullet.' + ); + }); + + it('nests under 10. by 4 spaces', () => { + expect(indent('10. parent\n- child', 2)).toBe('10. parent\n - child'); + }); + + it('nests a numbered item under a bullet by 2 spaces', () => { + expect(indent('- parent\n1. child', 2)).toBe('- parent\n 1. child'); + }); + + it('Shift-Tab reverses a nest under a numbered item', () => { + expect(indent('1. parent\n - child', 2, -1)).toBe('1. parent\n- child'); + }); + + it('renumbers when a numbered item nests under the previous', () => { + expect(indent('1. first\n2. second\n3. third', 2)).toBe('1. first\n 1. second\n2. third'); + }); + + it('renumbers when a nested numbered item is lifted', () => { + expect(indent('1. first\n 1. second\n2. third', 2, -1)).toBe('1. first\n2. second\n3. third'); + }); + + it('starts a nested numbered list at 1', () => { + expect(indent('1. parent\n2. child', 2)).toBe('1. parent\n 1. child'); + }); + + it('indents a first list item by its own marker width', () => { + expect(indent('- only', 1)).toBe(' - only'); + expect(indent('1. only', 1)).toBe(' 1. only'); + }); + + it('does nothing when Shift-Tab is on a top-level item', () => { + expect(indent('- only', 1, -1)).toBeNull(); + }); + + it('skips list-looking lines inside a fence', () => { + expect(indent('```\n- code\n```', 2)).toBeNull(); + }); + + it('moves descendants with the parent', () => { + expect(indent('- a\n - b\n - c\n- d', 1)).toBe(' - a\n - b\n - c\n- d'); + }); + + it('does not double-indent a selected child of a selected parent', () => { + const next = indentList('- a\n - b', [1, 2], 1); + expect(next?.join('\n')).toBe(' - a\n - b'); + }); + + it('does not double-indent a selected grandchild of a selected ancestor', () => { + const next = indentList('- a\n - b\n - c', [1, 3], 1); + expect(next?.join('\n')).toBe(' - a\n - b\n - c'); + }); + + it('nests several selected siblings under the item above', () => { + const next = indentList('1. a\n2. b\n3. c', [2, 3], 1); + expect(next?.join('\n')).toBe('1. a\n 1. b\n 2. c'); + }); + + it('preserves task checkboxes', () => { + expect(indent('- [ ] parent\n- [x] child', 2)).toBe('- [ ] parent\n - [x] child'); + }); + + it('indents continuation lines with the item', () => { + expect(indent('- parent\n- child\n continued', 2)).toBe('- parent\n - child\n continued'); + }); +}); + +describe('indentListItem command', () => { + it('keeps the cursor on the same text after indenting', () => { + const doc = '1. parent\n- child'; + // cursor on 'c' of child + const t = fakeView(doc, { from: 12, to: 12 }); + expect(indentListItem(t.view)).toBe(true); + expect(t.doc()).toBe('1. parent\n - child'); + expect(t.selectionMain().from).toBe(15); + }); + + it('returns false outside a list so Tab can fall through', () => { + const t = fakeView('plain text', { from: 0, to: 0 }); + expect(indentListItem(t.view)).toBe(false); + expect(dedentListItem(t.view)).toBe(false); + }); +}); diff --git a/packages/commands/src/markdown/listIndent.ts b/packages/commands/src/markdown/listIndent.ts new file mode 100644 index 00000000..88c0f2e7 --- /dev/null +++ b/packages/commands/src/markdown/listIndent.ts @@ -0,0 +1,320 @@ +/** + * Smart Markdown list indent — Inkdrop v6.1 behavior. + * + * Tab indents a list item by the marker width of the item above it + * (`-` → 2, `1.` → 3, `10.` → 4). Shift-Tab reverses. Numbered lists + * in the same region are renumbered in the same change. + */ + +import { EditorView, type KeyBinding } from '@codemirror/view'; + +export type ListKind = 'bullet' | 'ordered'; + +export interface ParsedListLine { + indent: number; + bullet: string; + delimiter: '.' | ')' | ''; + number: number | null; + task: string; + rest: string; + markerWidth: number; + kind: ListKind; +} + +const LIST_RE = /^([ \t]*)([-*+]|\d+[.)])( \[[ xX]\])? (.*)$/; + +export function visualCols(ws: string): number { + let cols = 0; + for (const ch of ws) { + if (ch === '\t') cols += 4 - (cols % 4); + else cols += 1; + } + return cols; +} + +export function leadingWs(line: string): string { + const m = line.match(/^[ \t]*/); + return m?.[0] ?? ''; +} + +export function parseListLine(line: string): ParsedListLine | null { + const m = line.match(LIST_RE); + if (!m) return null; + const rawMarker = m[2] ?? ''; + const ordered = /^\d+[.)]$/.test(rawMarker); + return { + indent: visualCols(m[1] ?? ''), + bullet: ordered ? '' : rawMarker, + delimiter: ordered ? (rawMarker.slice(-1) as '.' | ')') : '', + number: ordered ? parseInt(rawMarker, 10) : null, + task: m[3] ?? '', + rest: m[4] ?? '', + markerWidth: rawMarker.length + 1, + kind: ordered ? 'ordered' : 'bullet', + }; +} + +export function formatListLine( + parsed: ParsedListLine, + indent: number, + number: number | null = parsed.number +): string { + const mark = parsed.kind === 'ordered' ? `${number ?? 1}${parsed.delimiter}` : parsed.bullet; + return `${' '.repeat(Math.max(0, indent))}${mark}${parsed.task} ${parsed.rest}`; +} + +interface Fence { + ch: '`' | '~'; + len: number; +} + +function fenceAfterLine(line: string, fence: Fence | null): Fence | null { + const m = line.match(/^( {0,3})(`{3,}|~{3,})(.*)$/); + if (!m) return fence; + const marker = m[2] ?? ''; + const ch = marker[0] as '`' | '~'; + const len = marker.length; + const info = m[3] ?? ''; + if (!fence) { + if (ch === '`' && info.includes('`')) return fence; + return { ch, len }; + } + if (fence.ch === ch && len >= fence.len && info.trim() === '') return null; + return fence; +} + +/** True when the line is inside a fenced code block (not the opening fence). */ +export function fencesByLine(lines: readonly string[]): boolean[] { + const inside: boolean[] = []; + let fence: Fence | null = null; + for (const line of lines) { + inside.push(fence !== null); + fence = fenceAfterLine(line, fence); + } + return inside; +} + +function findPrevList( + parsed: readonly (ParsedListLine | null)[], + index: number, + maxIndent: number +): ParsedListLine | null { + for (let i = index - 1; i >= 0; i--) { + const prev = parsed[i]; + if (!prev) continue; + if (prev.indent <= maxIndent) return prev; + } + return null; +} + +function isSelectedAncestor( + parsed: readonly (ParsedListLine | null)[], + index: number, + selected: ReadonlySet +): boolean { + const current = parsed[index]; + if (!current) return false; + let indent = current.indent; + for (let j = index - 1; j >= 0; j--) { + const prev = parsed[j]; + if (!prev) continue; + if (prev.indent < indent) { + if (selected.has(j)) return true; + indent = prev.indent; + } + } + return false; +} + +function renumberLists(lines: string[], moved: ReadonlySet): string[] { + const inside = fencesByLine(lines); + const out = lines.slice(); + const stack: { indent: number; next: number }[] = []; + + for (let i = 0; i < lines.length; i++) { + if (inside[i]) { + stack.length = 0; + continue; + } + const parsed = parseListLine(lines[i] ?? ''); + if (!parsed) { + if ((lines[i] ?? '').trim() === '') continue; + if (visualCols(leadingWs(lines[i] ?? '')) > (stack.at(-1)?.indent ?? -1)) continue; + stack.length = 0; + continue; + } + + while (stack.length > 0 && stack[stack.length - 1]!.indent > parsed.indent) { + stack.pop(); + } + + if (parsed.kind === 'ordered') { + const top = stack.at(-1); + let n: number; + if (top && top.indent === parsed.indent) { + n = top.next; + top.next = n + 1; + } else { + // A newly nested list starts at 1; an unmoved run keeps its start number. + n = moved.has(i) ? 1 : (parsed.number ?? 1); + stack.push({ indent: parsed.indent, next: n + 1 }); + } + if (n !== parsed.number) { + out[i] = formatListLine(parsed, parsed.indent, n); + } + } else { + const top = stack.at(-1); + if (top && top.indent === parsed.indent) stack.pop(); + stack.push({ indent: parsed.indent, next: 1 }); + } + } + + return out; +} + +/** Smallest from/to/insert so the cursor stays on the same characters. */ +export function minimalLineChange( + lineFrom: number, + oldText: string, + newText: string +): { from: number; to: number; insert: string } { + let suffix = 0; + const maxSuffix = Math.min(oldText.length, newText.length); + while ( + suffix < maxSuffix && + oldText[oldText.length - 1 - suffix] === newText[newText.length - 1 - suffix] + ) { + suffix += 1; + } + const oldPrefixLen = oldText.length - suffix; + const newPrefixLen = newText.length - suffix; + let prefix = 0; + while (prefix < oldPrefixLen && prefix < newPrefixLen && oldText[prefix] === newText[prefix]) { + prefix += 1; + } + return { + from: lineFrom + prefix, + to: lineFrom + oldPrefixLen, + insert: newText.slice(prefix, newPrefixLen), + }; +} + +/** + * Indent (`direction = 1`) or dedent (`direction = -1`) list items on the + * given 1-based line numbers. Returns new lines, or null when Tab/Shift-Tab + * should fall through to ordinary indent. + */ +export function indentList( + doc: string, + lineNumbers: readonly number[], + direction: 1 | -1 +): string[] | null { + const lines = doc.split('\n'); + const inside = fencesByLine(lines); + const parsed = lines.map((line, i) => (inside[i] ? null : parseListLine(line))); + + const selected = new Set(lineNumbers.filter(n => n >= 1 && n <= lines.length).map(n => n - 1)); + const selectedItems = [...selected].filter(i => parsed[i] != null).sort((a, b) => a - b); + if (selectedItems.length === 0) return null; + + const roots = selectedItems.filter(i => !isSelectedAncestor(parsed, i, selected)); + + const nextIndent = new Map(); + const nextCols = new Map(); + + for (const i of roots) { + const item = parsed[i]; + if (!item) continue; + + let next: number; + if (direction === 1) { + const above = findPrevList(parsed, i, item.indent); + next = item.indent + (above?.markerWidth ?? item.markerWidth); + } else { + const parent = findPrevList(parsed, i, item.indent - 1); + if (parent) next = parent.indent; + else if (item.indent > 0) next = 0; + else continue; + } + const delta = next - item.indent; + nextIndent.set(i, next); + + for (let j = i + 1; j < lines.length; j++) { + const child = parsed[j]; + if (child) { + if (child.indent <= item.indent) break; + nextIndent.set(j, Math.max(0, child.indent + delta)); + continue; + } + const line = lines[j] ?? ''; + if (line.trim() === '') continue; + const cols = visualCols(leadingWs(line)); + if (cols > item.indent) { + nextCols.set(j, Math.max(0, cols + delta)); + } else { + break; + } + } + } + + if (nextIndent.size === 0 && nextCols.size === 0) return null; + + const out = lines.slice(); + for (const [i, indent] of nextIndent) { + const item = parsed[i]; + if (item) out[i] = formatListLine(item, indent, item.number ?? 1); + } + for (const [i, cols] of nextCols) { + const line = lines[i] ?? ''; + const ws = leadingWs(line); + out[i] = `${' '.repeat(cols)}${line.slice(ws.length)}`; + } + + return renumberLists(out, new Set(nextIndent.keys())); +} + +function applyListIndent(view: EditorView, direction: 1 | -1): boolean { + const { state } = view; + const lineNumbers: number[] = []; + for (const range of state.selection.ranges) { + const fromLine = state.doc.lineAt(range.from).number; + const toLine = state.doc.lineAt(range.to).number; + for (let n = fromLine; n <= toLine; n++) lineNumbers.push(n); + } + + const next = indentList(state.doc.toString(), lineNumbers, direction); + if (!next) return false; + + const changes: { from: number; to: number; insert: string }[] = []; + for (let n = 1; n <= state.doc.lines; n++) { + const line = state.doc.line(n); + const text = next[n - 1] ?? ''; + if (text !== line.text) { + changes.push(minimalLineChange(line.from, line.text, text)); + } + } + if (changes.length === 0) return false; + + view.dispatch({ + changes, + userEvent: direction === 1 ? 'indent' : 'dedent', + }); + return true; +} + +/** Tab: nest the list item under the one above. */ +export function indentListItem(view: EditorView): boolean { + return applyListIndent(view, 1); +} + +/** Shift-Tab: lift the list item one level. */ +export function dedentListItem(view: EditorView): boolean { + return applyListIndent(view, -1); +} + +/** Keymap binding. Put before `indentWithTab` so lists win, then fall through. */ +export const listIndentKeymap: KeyBinding = { + key: 'Tab', + run: indentListItem, + shift: dedentListItem, +}; diff --git a/packages/plugin-api/src/theme/themeTypes.ts b/packages/plugin-api/src/theme/themeTypes.ts index d31beab0..faa5e019 100644 --- a/packages/plugin-api/src/theme/themeTypes.ts +++ b/packages/plugin-api/src/theme/themeTypes.ts @@ -40,7 +40,14 @@ export const CORE_THEME_TOKENS = [ ] as const; /** Valid extension scope prefixes for non-core tokens */ -export const THEME_EXTENSION_SCOPES = ['--syntax-', '--preview-', '--ui-', '--cm-'] as const; +export const THEME_EXTENSION_SCOPES = [ + '--syntax-', + '--preview-', + '--ui-', + '--cm-', + '--md-', + '--mde-', +] as const; /** A complete theme definition */ export interface ThemeDefinition { diff --git a/packages/plugin-api/tests/themeTypes.test.ts b/packages/plugin-api/tests/themeTypes.test.ts index f1a72e93..beb456a5 100644 --- a/packages/plugin-api/tests/themeTypes.test.ts +++ b/packages/plugin-api/tests/themeTypes.test.ts @@ -14,6 +14,8 @@ describe('isValidThemeToken', () => { expect(isValidThemeToken('--preview-heading-color')).toBe(true); expect(isValidThemeToken('--ui-sidebar-bg')).toBe(true); expect(isValidThemeToken('--cm-heading')).toBe(true); + expect(isValidThemeToken('--md-list-mark-2-color')).toBe(true); + expect(isValidThemeToken('--mde-preview-list-marker-3-color')).toBe(true); }); it('rejects unknown tokens', () => {