feat: expose all theme.json properties in theme editor

Add ThemePropertyEditor component that surfaces all customizable
properties from theme.json — typography, headings, lists, code blocks,
blockquote, table, and horizontal rule — organized into collapsible
sections with appropriate input types (number, color, select, text).

- themeSchema.ts: derives flat property list from theme.json with
  auto-detected input types, units, and select options
- ThemePropertyEditor.tsx: sectioned editor with collapsible sections,
  keyboard-accessible toggles, debounced live updates
- ThemeManager: add updateThemeProperty() and activeThemeContent
- SettingsPanel: show property editor below theme list when active

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Test
2026-03-05 14:39:19 +01:00
parent 25260c7d58
commit 32b4a90ae5
7 changed files with 856 additions and 2 deletions

View File

@@ -103,7 +103,7 @@ function App() {
const resolvedPath = onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath
const vault = useVaultLoader(resolvedPath)
const { settings, saveSettings } = useSettings()
const themeManager = useThemeManager(resolvedPath, vault.entries, vault.allContent)
const themeManager = useThemeManager(resolvedPath, vault.entries, vault.allContent, vault.updateContent)
const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage)

View File

@@ -1,6 +1,7 @@
import { useState, useRef, useCallback, useEffect } from 'react'
import { X, Eye, EyeSlash, GithubLogo, SignOut, Check, Plus } from '@phosphor-icons/react'
import { GitHubDeviceFlow } from './GitHubDeviceFlow'
import { ThemePropertyEditor } from './ThemePropertyEditor'
import type { Settings, ThemeFile } from '../types'
import type { ThemeManager } from '../hooks/useThemeManager'
@@ -356,6 +357,13 @@ function AppearanceSection({ themeManager }: { themeManager: ThemeManager }) {
<Plus size={14} />
New Theme
</button>
{activeThemeId && (
<>
<div style={{ height: 1, background: 'var(--border)', margin: '4px 0' }} />
<ThemePropertyEditor themeManager={themeManager} />
</>
)}
</>
)
}

View File

@@ -0,0 +1,133 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent, act } from '@testing-library/react'
import { ThemePropertyEditor } from './ThemePropertyEditor'
import type { ThemeManager } from '../hooks/useThemeManager'
function makeThemeManager(overrides: Partial<ThemeManager> = {}): ThemeManager {
return {
themes: [],
activeThemeId: '/vault/_themes/My Theme.md',
activeTheme: { id: '/vault/_themes/My Theme.md', name: 'My Theme', description: '', colors: {}, typography: {}, spacing: {} },
activeThemeContent: '---\nIs A: Theme\nName: My Theme\neditor-font-size: 18px\nlists-bullet-color: "#ff0000"\n---\n',
isDark: false,
switchTheme: vi.fn(),
createTheme: vi.fn().mockResolvedValue(''),
reloadThemes: vi.fn(),
updateThemeProperty: vi.fn(),
...overrides,
}
}
describe('ThemePropertyEditor', () => {
it('shows message when no theme is active', () => {
const tm = makeThemeManager({ activeThemeId: null })
render(<ThemePropertyEditor themeManager={tm} />)
expect(screen.getByText(/Select a theme/)).toBeInTheDocument()
})
it('renders the editor when a theme is active', () => {
const tm = makeThemeManager()
render(<ThemePropertyEditor themeManager={tm} />)
expect(screen.getByTestId('theme-property-editor')).toBeInTheDocument()
})
it('shows section headers for all theme.json sections', () => {
const tm = makeThemeManager()
render(<ThemePropertyEditor themeManager={tm} />)
expect(screen.getByText('Typography')).toBeInTheDocument()
expect(screen.getByText('Headings')).toBeInTheDocument()
expect(screen.getByText('Lists')).toBeInTheDocument()
expect(screen.getByText('Code Blocks')).toBeInTheDocument()
expect(screen.getByText('Blockquote')).toBeInTheDocument()
expect(screen.getByText('Table')).toBeInTheDocument()
expect(screen.getByText('Horizontal Rule')).toBeInTheDocument()
expect(screen.getByText('Colors')).toBeInTheDocument()
})
it('shows active theme name', () => {
const tm = makeThemeManager()
render(<ThemePropertyEditor themeManager={tm} />)
expect(screen.getByText('My Theme')).toBeInTheDocument()
})
it('expands Typography section by default', () => {
const tm = makeThemeManager()
render(<ThemePropertyEditor themeManager={tm} />)
// Typography section should be expanded, showing its properties
expect(screen.getByTestId('theme-input-editor-font-size')).toBeInTheDocument()
})
it('shows current theme value for overridden properties', () => {
const tm = makeThemeManager()
render(<ThemePropertyEditor themeManager={tm} />)
const fontSizeInput = screen.getByTestId('theme-input-editor-font-size') as HTMLInputElement
expect(fontSizeInput.value).toBe('18')
})
it('shows default value for non-overridden properties', () => {
const tm = makeThemeManager()
render(<ThemePropertyEditor themeManager={tm} />)
// editor-max-width is not in the theme content, so it should show the default (720)
const maxWidthInput = screen.getByTestId('theme-input-editor-max-width') as HTMLInputElement
expect(maxWidthInput.value).toBe('720')
})
it('calls updateThemeProperty on number input change', async () => {
vi.useFakeTimers()
const updateFn = vi.fn()
const tm = makeThemeManager({ updateThemeProperty: updateFn })
render(<ThemePropertyEditor themeManager={tm} />)
const fontSizeInput = screen.getByTestId('theme-input-editor-font-size') as HTMLInputElement
fireEvent.change(fontSizeInput, { target: { value: '16' } })
// Debounce fires after 300ms
act(() => { vi.advanceTimersByTime(300) })
expect(updateFn).toHaveBeenCalledWith('editor-font-size', '16px')
vi.useRealTimers()
})
it('expands collapsed sections on click', () => {
const tm = makeThemeManager()
render(<ThemePropertyEditor themeManager={tm} />)
// Lists section should be collapsed by default
expect(screen.queryByTestId('theme-input-lists-bullet-size')).not.toBeInTheDocument()
// Click to expand
fireEvent.click(screen.getByTestId('theme-section-lists-toggle'))
expect(screen.getByTestId('theme-input-lists-bullet-size')).toBeInTheDocument()
})
it('toggles section via keyboard Enter', () => {
const tm = makeThemeManager()
render(<ThemePropertyEditor themeManager={tm} />)
const toggle = screen.getByTestId('theme-section-lists-toggle')
fireEvent.keyDown(toggle, { key: 'Enter' })
expect(screen.getByTestId('theme-input-lists-bullet-size')).toBeInTheDocument()
})
it('toggles section via keyboard Space', () => {
const tm = makeThemeManager()
render(<ThemePropertyEditor themeManager={tm} />)
const toggle = screen.getByTestId('theme-section-lists-toggle')
fireEvent.keyDown(toggle, { key: ' ' })
expect(screen.getByTestId('theme-input-lists-bullet-size')).toBeInTheDocument()
})
it('shows heading subsections after expanding Headings', () => {
const tm = makeThemeManager()
render(<ThemePropertyEditor themeManager={tm} />)
fireEvent.click(screen.getByTestId('theme-section-headings-toggle'))
expect(screen.getByText('Heading 1')).toBeInTheDocument()
expect(screen.getByText('Heading 2')).toBeInTheDocument()
expect(screen.getByText('Heading 3')).toBeInTheDocument()
expect(screen.getByText('Heading 4')).toBeInTheDocument()
})
it('shows unit label for numeric properties', () => {
const tm = makeThemeManager()
render(<ThemePropertyEditor themeManager={tm} />)
// "px" should appear near Font Size input
const container = screen.getByTestId('theme-input-editor-font-size').parentElement!
expect(container.textContent).toContain('px')
})
})

View File

@@ -0,0 +1,321 @@
import { useState, useCallback, useMemo, useRef } from 'react'
import { CaretRight } from '@phosphor-icons/react'
import { ColorSwatch } from './ColorInput'
import { getThemeSchema, formatValueForFrontmatter, parseValueFromFrontmatter } from '../utils/themeSchema'
import type { ThemeProperty, ThemeSection, ThemeSubsection } from '../utils/themeSchema'
import type { ThemeManager } from '../hooks/useThemeManager'
import { parseFrontmatter } from '../utils/frontmatter'
import { isValidCssColor } from '../utils/colorUtils'
/** Extract current theme property values from frontmatter content. */
function useThemeValues(content: string | undefined): Record<string, string> {
return useMemo(() => {
if (!content) return {}
const fm = parseFrontmatter(content)
const result: Record<string, string> = {}
for (const [key, value] of Object.entries(fm)) {
if (typeof value === 'string') result[key] = value
else if (typeof value === 'number') result[key] = String(value)
else if (typeof value === 'boolean') result[key] = String(value)
}
return result
}, [content])
}
// --- Individual input components ---
function NumberInput({ property, value, onChange }: {
property: ThemeProperty
value: string | number
onChange: (val: string) => void
}) {
const numericValue = typeof value === 'number' ? value : parseFloat(String(value)) || 0
const debounceRef = useRef<ReturnType<typeof setTimeout>>()
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value
if (raw === '' || raw === '-') return
const num = parseFloat(raw)
if (isNaN(num)) return
if (property.min !== undefined && num < property.min) return
clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => {
onChange(formatValueForFrontmatter(num, property))
}, 300)
}, [onChange, property])
return (
<div className="flex items-center gap-1">
<input
type="number"
defaultValue={numericValue}
onChange={handleChange}
min={property.min}
step={property.unit ? 1 : 0.1}
className="w-20 rounded border border-border bg-transparent px-2 py-1 text-xs text-foreground outline-none focus:border-primary"
data-testid={`theme-input-${property.cssVar}`}
/>
{property.unit && (
<span className="text-[11px] text-muted-foreground">{property.unit}</span>
)}
</div>
)
}
function ColorInput({ property, value, onChange }: {
property: ThemeProperty
value: string
onChange: (val: string) => void
}) {
const [localValue, setLocalValue] = useState(value)
const debounceRef = useRef<ReturnType<typeof setTimeout>>()
const showSwatch = isValidCssColor(localValue)
const handleTextChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const newVal = e.target.value
setLocalValue(newVal)
clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => onChange(newVal), 300)
}, [onChange])
const handlePickerChange = useCallback((hex: string) => {
setLocalValue(hex)
onChange(hex)
}, [onChange])
return (
<div className="flex items-center gap-1.5">
{showSwatch && <ColorSwatch color={localValue} onChange={handlePickerChange} />}
<input
type="text"
value={localValue}
onChange={handleTextChange}
className="w-28 rounded border border-border bg-transparent px-2 py-1 text-xs text-foreground outline-none focus:border-primary"
data-testid={`theme-input-${property.cssVar}`}
/>
</div>
)
}
function SelectInput({ property, value, onChange }: {
property: ThemeProperty
value: string
onChange: (val: string) => void
}) {
return (
<select
value={value}
onChange={e => onChange(e.target.value)}
className="w-28 rounded border border-border bg-transparent px-2 py-1 text-xs text-foreground outline-none focus:border-primary"
data-testid={`theme-input-${property.cssVar}`}
>
{property.options?.map(opt => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
)
}
function TextInput({ property, value, onChange }: {
property: ThemeProperty
value: string
onChange: (val: string) => void
}) {
const debounceRef = useRef<ReturnType<typeof setTimeout>>()
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const newVal = e.target.value
clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => onChange(newVal), 500)
}, [onChange])
return (
<input
type="text"
defaultValue={value}
onChange={handleChange}
className="w-40 rounded border border-border bg-transparent px-2 py-1 text-xs text-foreground outline-none focus:border-primary"
data-testid={`theme-input-${property.cssVar}`}
/>
)
}
// --- Property row ---
function PropertyRow({ property, currentValue, onUpdate }: {
property: ThemeProperty
currentValue: string | undefined
onUpdate: (cssVar: string, value: string) => void
}) {
const displayValue = currentValue !== undefined
? parseValueFromFrontmatter(currentValue, property)
: property.defaultValue
const isPlaceholder = currentValue === undefined
const handleChange = useCallback((val: string) => {
onUpdate(property.cssVar, val)
}, [property.cssVar, onUpdate])
return (
<div
className="flex items-center justify-between gap-2 py-1"
style={{ minHeight: 28 }}
>
<label
className="text-xs shrink-0"
style={{ color: isPlaceholder ? 'var(--muted-foreground)' : 'var(--foreground)', minWidth: 100 }}
>
{property.label}
</label>
<div className="flex-shrink-0">
{property.inputType === 'number' && (
<NumberInput property={property} value={displayValue} onChange={handleChange} />
)}
{property.inputType === 'color' && (
<ColorInput property={property} value={String(displayValue)} onChange={handleChange} />
)}
{property.inputType === 'select' && (
<SelectInput property={property} value={String(displayValue)} onChange={handleChange} />
)}
{property.inputType === 'text' && (
<TextInput property={property} value={String(displayValue)} onChange={handleChange} />
)}
</div>
</div>
)
}
// --- Collapsible section ---
function CollapsibleSection({ label, defaultOpen, children, testId }: {
label: string
defaultOpen?: boolean
children: React.ReactNode
testId?: string
}) {
const [open, setOpen] = useState(defaultOpen ?? false)
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setOpen(prev => !prev)
}
}, [])
return (
<div data-testid={testId}>
<button
type="button"
className="flex w-full items-center gap-1 border-none bg-transparent p-0 cursor-pointer"
style={{ fontSize: 12, fontWeight: 600, color: 'var(--foreground)', padding: '4px 0' }}
onClick={() => setOpen(prev => !prev)}
onKeyDown={handleKeyDown}
aria-expanded={open}
data-testid={testId ? `${testId}-toggle` : undefined}
>
<CaretRight
size={12}
weight="bold"
style={{ transform: open ? 'rotate(90deg)' : 'none', transition: 'transform 0.15s' }}
/>
{label}
</button>
{open && (
<div style={{ paddingLeft: 16 }}>
{children}
</div>
)}
</div>
)
}
// --- Section renderers ---
function SubsectionBlock({ subsection, currentValues, onUpdate }: {
subsection: ThemeSubsection
currentValues: Record<string, string>
onUpdate: (cssVar: string, value: string) => void
}) {
return (
<CollapsibleSection label={subsection.label} testId={`theme-sub-${subsection.id}`}>
{subsection.properties.map(prop => (
<PropertyRow
key={prop.cssVar}
property={prop}
currentValue={currentValues[prop.cssVar]}
onUpdate={onUpdate}
/>
))}
</CollapsibleSection>
)
}
function SectionBlock({ section, currentValues, onUpdate }: {
section: ThemeSection
currentValues: Record<string, string>
onUpdate: (cssVar: string, value: string) => void
}) {
return (
<CollapsibleSection
label={section.label}
defaultOpen={section.id === 'editor'}
testId={`theme-section-${section.id}`}
>
{section.properties.map(prop => (
<PropertyRow
key={prop.cssVar}
property={prop}
currentValue={currentValues[prop.cssVar]}
onUpdate={onUpdate}
/>
))}
{section.subsections.map(sub => (
<SubsectionBlock
key={sub.id}
subsection={sub}
currentValues={currentValues}
onUpdate={onUpdate}
/>
))}
</CollapsibleSection>
)
}
// --- Main component ---
export function ThemePropertyEditor({ themeManager }: { themeManager: ThemeManager }) {
const schema = useMemo(() => getThemeSchema(), [])
const currentValues = useThemeValues(themeManager.activeThemeContent)
const handleUpdate = useCallback((cssVar: string, value: string) => {
themeManager.updateThemeProperty(cssVar, value)
}, [themeManager])
if (!themeManager.activeThemeId) {
return (
<div className="text-xs text-muted-foreground" style={{ padding: '8px 0' }}>
Select a theme to customize its properties.
</div>
)
}
return (
<div
className="flex flex-col gap-1"
data-testid="theme-property-editor"
style={{ fontSize: 12 }}
>
<div style={{ fontSize: 11, color: 'var(--muted-foreground)', marginBottom: 4 }}>
Editing: <strong>{themeManager.activeTheme?.name ?? 'Theme'}</strong>
</div>
{schema.map(section => (
<SectionBlock
key={section.id}
section={section}
currentValues={currentValues}
onUpdate={handleUpdate}
/>
))}
</div>
)
}

View File

@@ -117,10 +117,13 @@ export interface ThemeManager {
themes: ThemeFile[]
activeThemeId: string | null
activeTheme: ThemeFile | null
activeThemeContent: string | undefined
isDark: boolean
switchTheme: (themeId: string) => Promise<void>
createTheme: (name?: string) => Promise<string>
reloadThemes: () => Promise<void>
/** Update a single frontmatter property on the active theme note. */
updateThemeProperty: (key: string, value: string) => Promise<void>
}
/** Manages loading and persisting the active theme path from vault settings. */
@@ -195,6 +198,7 @@ export function useThemeManager(
vaultPath: string | null,
entries: VaultEntry[],
allContent: Record<string, string>,
updateContent?: (path: string, content: string) => void,
): ThemeManager {
// Ensure default theme files exist on vault open (creates theme/ dir + defaults if missing)
useEffect(() => {
@@ -268,5 +272,21 @@ export function useThemeManager(
const reloadThemes = useCallback(async () => { await reload() }, [reload])
return { themes, activeThemeId, activeTheme, isDark, switchTheme, createTheme, reloadThemes }
const updateThemeProperty = useCallback(async (key: string, value: string) => {
if (!activeThemeId) return
try {
const newContent = await tauriCall<string>('update_frontmatter', {
path: activeThemeId,
key,
value,
})
updateContent?.(activeThemeId, newContent)
} catch (err) { console.error('Failed to update theme property:', err) }
}, [activeThemeId, updateContent])
return {
themes, activeThemeId, activeTheme,
activeThemeContent: cachedThemeContent,
isDark, switchTheme, createTheme, reloadThemes, updateThemeProperty,
}
}

View File

@@ -0,0 +1,179 @@
import { describe, it, expect } from 'vitest'
import { buildThemeSchema, formatValueForFrontmatter, parseValueFromFrontmatter } from './themeSchema'
import type { ThemeProperty } from './themeSchema'
describe('buildThemeSchema', () => {
const schema = buildThemeSchema()
it('returns all top-level sections from theme.json', () => {
const ids = schema.map(s => s.id)
expect(ids).toContain('editor')
expect(ids).toContain('headings')
expect(ids).toContain('lists')
expect(ids).toContain('checkboxes')
expect(ids).toContain('inlineStyles')
expect(ids).toContain('codeBlocks')
expect(ids).toContain('blockquote')
expect(ids).toContain('table')
expect(ids).toContain('horizontalRule')
expect(ids).toContain('colors')
})
it('assigns human-readable labels to sections', () => {
const editor = schema.find(s => s.id === 'editor')!
expect(editor.label).toBe('Typography')
const headings = schema.find(s => s.id === 'headings')!
expect(headings.label).toBe('Headings')
})
it('produces flat CSS variable names from editor section', () => {
const editor = schema.find(s => s.id === 'editor')!
const vars = editor.properties.map(p => p.cssVar)
expect(vars).toContain('editor-font-family')
expect(vars).toContain('editor-font-size')
expect(vars).toContain('editor-line-height')
expect(vars).toContain('editor-max-width')
expect(vars).toContain('editor-padding-horizontal')
expect(vars).toContain('editor-paragraph-spacing')
})
it('detects numeric input type for number values', () => {
const editor = schema.find(s => s.id === 'editor')!
const fontSize = editor.properties.find(p => p.cssVar === 'editor-font-size')!
expect(fontSize.inputType).toBe('number')
expect(fontSize.unit).toBe('px')
expect(fontSize.defaultValue).toBe(15)
})
it('detects unitless numbers for lineHeight and fontWeight', () => {
const editor = schema.find(s => s.id === 'editor')!
const lineHeight = editor.properties.find(p => p.cssVar === 'editor-line-height')!
expect(lineHeight.inputType).toBe('number')
expect(lineHeight.unit).toBeUndefined()
})
it('detects text input type for font family', () => {
const editor = schema.find(s => s.id === 'editor')!
const fontFamily = editor.properties.find(p => p.cssVar === 'editor-font-family')!
expect(fontFamily.inputType).toBe('text')
})
it('creates subsections for headings h1-h4', () => {
const headings = schema.find(s => s.id === 'headings')!
const subIds = headings.subsections.map(s => s.id)
expect(subIds).toContain('h1')
expect(subIds).toContain('h2')
expect(subIds).toContain('h3')
expect(subIds).toContain('h4')
})
it('produces correct CSS var names for heading subsections', () => {
const headings = schema.find(s => s.id === 'headings')!
const h1 = headings.subsections.find(s => s.id === 'h1')!
const vars = h1.properties.map(p => p.cssVar)
expect(vars).toContain('headings-h1-font-size')
expect(vars).toContain('headings-h1-font-weight')
expect(vars).toContain('headings-h1-line-height')
expect(vars).toContain('headings-h1-margin-top')
expect(vars).toContain('headings-h1-color')
expect(vars).toContain('headings-h1-letter-spacing')
})
it('detects color values from var(--) references', () => {
const headings = schema.find(s => s.id === 'headings')!
const h1 = headings.subsections.find(s => s.id === 'h1')!
const color = h1.properties.find(p => p.cssVar === 'headings-h1-color')!
expect(color.inputType).toBe('color')
})
it('detects hex color values', () => {
const lists = schema.find(s => s.id === 'lists')!
const bulletColor = lists.properties.find(p => p.cssVar === 'lists-bullet-color')!
expect(bulletColor.inputType).toBe('color')
})
it('creates subsections for inline styles', () => {
const inline = schema.find(s => s.id === 'inlineStyles')!
const subIds = inline.subsections.map(s => s.id)
expect(subIds).toContain('bold')
expect(subIds).toContain('italic')
expect(subIds).toContain('code')
expect(subIds).toContain('link')
expect(subIds).toContain('wikilink')
})
it('produces correct CSS var names for code blocks section', () => {
const codeBlocks = schema.find(s => s.id === 'codeBlocks')!
const vars = codeBlocks.properties.map(p => p.cssVar)
expect(vars).toContain('code-blocks-font-family')
expect(vars).toContain('code-blocks-font-size')
expect(vars).toContain('code-blocks-background-color')
expect(vars).toContain('code-blocks-border-radius')
})
it('skips array values like nestedBulletSymbols', () => {
const lists = schema.find(s => s.id === 'lists')!
const vars = lists.properties.map(p => p.cssVar)
expect(vars).not.toContain('lists-nested-bullet-symbols')
})
it('assigns select input type for fontStyle', () => {
const blockquote = schema.find(s => s.id === 'blockquote')!
const fontStyle = blockquote.properties.find(p => p.cssVar === 'blockquote-font-style')!
expect(fontStyle.inputType).toBe('select')
expect(fontStyle.options).toContain('normal')
expect(fontStyle.options).toContain('italic')
})
})
describe('formatValueForFrontmatter', () => {
it('appends unit to numeric values', () => {
const prop: ThemeProperty = {
cssVar: 'editor-font-size', label: 'Font Size', defaultValue: 15,
inputType: 'number', unit: 'px', min: 0,
}
expect(formatValueForFrontmatter(15, prop)).toBe('15px')
})
it('does not append unit for unitless values', () => {
const prop: ThemeProperty = {
cssVar: 'editor-line-height', label: 'Line Height', defaultValue: 1.5,
inputType: 'number',
}
expect(formatValueForFrontmatter(1.5, prop)).toBe('1.5')
})
it('returns string values as-is', () => {
const prop: ThemeProperty = {
cssVar: 'editor-font-family', label: 'Font Family', defaultValue: 'Inter',
inputType: 'text',
}
expect(formatValueForFrontmatter('Helvetica', prop)).toBe('Helvetica')
})
})
describe('parseValueFromFrontmatter', () => {
it('extracts numeric value from string with unit', () => {
const prop: ThemeProperty = {
cssVar: 'editor-font-size', label: 'Font Size', defaultValue: 15,
inputType: 'number', unit: 'px',
}
expect(parseValueFromFrontmatter('15px', prop)).toBe(15)
})
it('returns string for non-numeric values', () => {
const prop: ThemeProperty = {
cssVar: 'editor-font-family', label: 'Font Family', defaultValue: 'Inter',
inputType: 'text',
}
expect(parseValueFromFrontmatter('Helvetica', prop)).toBe('Helvetica')
})
it('parses bare numbers', () => {
const prop: ThemeProperty = {
cssVar: 'editor-line-height', label: 'Line Height', defaultValue: 1.5,
inputType: 'number',
}
expect(parseValueFromFrontmatter('1.6', prop)).toBe(1.6)
})
})

193
src/utils/themeSchema.ts Normal file
View File

@@ -0,0 +1,193 @@
import themeConfig from '../theme.json'
import { isValidCssColor } from './colorUtils'
export type InputType = 'number' | 'color' | 'text' | 'select'
export interface ThemeProperty {
/** Flat kebab-case key used in frontmatter, e.g. "editor-font-size" */
cssVar: string
/** Human-readable label, e.g. "Font Size" */
label: string
/** Default value from theme.json */
defaultValue: string | number
inputType: InputType
/** Unit label shown next to numeric inputs (e.g. "px"). Absent for unitless. */
unit?: string
/** Options for select inputs (e.g. font weights). */
options?: string[]
/** Minimum allowed value for numeric inputs. */
min?: number
}
export interface ThemeSubsection {
id: string
label: string
properties: ThemeProperty[]
}
export interface ThemeSection {
id: string
label: string
properties: ThemeProperty[]
subsections: ThemeSubsection[]
}
const SECTION_LABELS: Record<string, string> = {
editor: 'Typography',
headings: 'Headings',
lists: 'Lists',
checkboxes: 'Checkboxes',
inlineStyles: 'Inline Styles',
codeBlocks: 'Code Blocks',
blockquote: 'Blockquote',
table: 'Table',
horizontalRule: 'Horizontal Rule',
colors: 'Colors',
}
const SUBSECTION_LABELS: Record<string, string> = {
h1: 'Heading 1',
h2: 'Heading 2',
h3: 'Heading 3',
h4: 'Heading 4',
bold: 'Bold',
italic: 'Italic',
strikethrough: 'Strikethrough',
code: 'Inline Code',
link: 'Link',
wikilink: 'Wiki Link',
}
/** Keys where the numeric value is unitless (ratios, weights). */
const UNITLESS_KEYS = /weight|lineHeight|opacity/i
/** Keys that should use a select input with predefined options. */
const SELECT_OPTIONS: Record<string, string[]> = {
fontWeight: ['400', '500', '600', '700'],
fontStyle: ['normal', 'italic'],
textDecoration: ['none', 'underline', 'line-through'],
cursor: ['default', 'pointer', 'text'],
}
function camelToKebab(str: string): string {
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
}
function camelToTitle(str: string): string {
return str
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/^./, c => c.toUpperCase())
}
function isColorValue(value: unknown, key: string): boolean {
if (typeof value !== 'string') return false
if (value.startsWith('#') || value.startsWith('var(--')) return isColorKeyHint(key) || isValidCssColor(value)
return false
}
function isColorKeyHint(key: string): boolean {
const lower = key.toLowerCase()
return lower === 'color' || lower.endsWith('color') || lower === 'background'
|| lower.endsWith('background') || lower === 'fill' || lower === 'tint'
}
function deriveInputType(key: string, value: unknown): { inputType: InputType; unit?: string; options?: string[]; min?: number } {
// Select options take priority
for (const [pattern, opts] of Object.entries(SELECT_OPTIONS)) {
if (key === pattern || key.endsWith(pattern.charAt(0).toUpperCase() + pattern.slice(1))) {
// Check if current key ends with the select key (e.g. "fontWeight" matches "boldFontWeight")
if (key === pattern || key.toLowerCase().endsWith(pattern.toLowerCase())) {
return { inputType: 'select', options: opts }
}
}
}
if (typeof value === 'number') {
const isUnitless = UNITLESS_KEYS.test(key)
return { inputType: 'number', unit: isUnitless ? undefined : 'px', min: 0 }
}
if (isColorValue(value, key)) {
return { inputType: 'color' }
}
return { inputType: 'text' }
}
function buildProperty(parentPrefix: string, key: string, value: string | number): ThemeProperty {
const cssVar = `${parentPrefix}${camelToKebab(key)}`
const { inputType, unit, options, min } = deriveInputType(key, value)
return { cssVar, label: camelToTitle(key), defaultValue: value, inputType, unit, options, min }
}
/** Build the full theme schema from theme.json, grouped by section. */
export function buildThemeSchema(): ThemeSection[] {
const sections: ThemeSection[] = []
for (const [sectionKey, sectionValue] of Object.entries(themeConfig)) {
if (typeof sectionValue !== 'object' || sectionValue === null || Array.isArray(sectionValue)) continue
const sectionObj = sectionValue as Record<string, unknown>
const sectionPrefix = `${camelToKebab(sectionKey)}-`
const section: ThemeSection = {
id: sectionKey,
label: SECTION_LABELS[sectionKey] ?? camelToTitle(sectionKey),
properties: [],
subsections: [],
}
for (const [key, value] of Object.entries(sectionObj)) {
if (Array.isArray(value)) continue // skip arrays like nestedBulletSymbols
if (typeof value === 'object' && value !== null) {
// Subsection (e.g. headings.h1, inlineStyles.bold)
const subPrefix = `${sectionPrefix}${camelToKebab(key)}-`
const subProperties: ThemeProperty[] = []
for (const [subKey, subValue] of Object.entries(value as Record<string, unknown>)) {
if (typeof subValue === 'string' || typeof subValue === 'number') {
subProperties.push(buildProperty(subPrefix, subKey, subValue))
}
}
if (subProperties.length > 0) {
section.subsections.push({
id: key,
label: SUBSECTION_LABELS[key] ?? camelToTitle(key),
properties: subProperties,
})
}
} else if (typeof value === 'string' || typeof value === 'number') {
section.properties.push(buildProperty(sectionPrefix, key, value))
}
}
if (section.properties.length > 0 || section.subsections.length > 0) {
sections.push(section)
}
}
return sections
}
/** Format a value for storage in theme note frontmatter. */
export function formatValueForFrontmatter(value: string | number, property: ThemeProperty): string {
if (property.inputType === 'number' && property.unit && typeof value === 'number') {
return `${value}${property.unit}`
}
return String(value)
}
/** Parse a frontmatter value back to its editable form (strip unit suffix). */
export function parseValueFromFrontmatter(raw: string, property: ThemeProperty): string | number {
if (property.inputType === 'number') {
const numeric = parseFloat(raw)
if (!isNaN(numeric)) return numeric
}
return raw
}
/** Cached schema — built once from theme.json. */
let cachedSchema: ThemeSection[] | null = null
export function getThemeSchema(): ThemeSection[] {
if (!cachedSchema) cachedSchema = buildThemeSchema()
return cachedSchema
}