also looking at this
fix: browser-safe config loading and correct dual-theme token colors
#477
6 files
+237
-21
| @@ -9,33 +9,36 @@ export interface DualThemeOptions extends RenderOptions { | ||
| 9 | 9 | |
| 10 | 10 | /** |
| 11 | 11 | * Render code with dual theme support (light and dark) |
| 12 | * | |
| 13 | * The two themes render the same markup - same tokens, same scopes, same | |
| 14 | * classes - so the light pass's `html` describes both. What differs is which | |
| 15 | * stylesheet is active: both renders run in `colorViaClass` mode so neither | |
| 16 | * bakes a color into `style="..."` (which no later selector could override), | |
| 17 | * then the dark stylesheet is rescoped under `selector` and left to override | |
| 18 | * the light one, which stays the page's unscoped default. | |
| 12 | 19 | */ |
| 13 | 20 | export function renderDualTheme( |
| 14 | 21 | tokens: TokenLine[], |
| 15 | 22 | options: DualThemeOptions, |
| 16 | 23 | ): RenderedCode { |
| 17 | 24 | const { lightTheme, darkTheme, selector = 'html', ...renderOptions } = options |
| 25 | const colorViaClassOptions = { ...renderOptions, colorViaClass: true } | |
| 18 | 26 | |
| 19 | // Render with both themes | |
| 20 | 27 | const lightRenderer = new Renderer(lightTheme) |
| 21 | 28 | const darkRenderer = new Renderer(darkTheme) |
| 22 | 29 | |
| 23 | const lightResult = lightRenderer.render(tokens, renderOptions) | |
| 24 | const darkResult = darkRenderer.render(tokens, renderOptions) | |
| 30 | const lightResult = lightRenderer.render(tokens, colorViaClassOptions) | |
| 31 | const darkResult = darkRenderer.render(tokens, colorViaClassOptions) | |
| 32 | ||
| 33 | const darkPrefixes = [`${selector}.dark`, `${selector}[data-theme="dark"]`] | |
| 34 | const scopedDarkRules = darkResult.css ? scopeCssRules(darkResult.css, darkPrefixes) : '' | |
| 25 | 35 | |
| 26 | // Combine CSS with media query | |
| 27 | 36 | const combinedCSS = ` |
| 28 | 37 | /* Light theme (default) */ |
| 29 | 38 | ${lightResult.css} |
| 30 | 39 | |
| 31 | 40 | /* Dark theme */ |
| 32 | ${selector}.dark .syntax, | |
| 33 | ${selector}[data-theme="dark"] .syntax, | |
| 34 | @media (prefers-color-scheme: dark) { | |
| 35 | ${selector}:not(.light) .syntax { | |
| 36 | ${darkResult.css?.split('\n').filter(line => line.includes(':')).join('\n ')} | |
| 37 | } | |
| 38 | } | |
| 41 | ${scopedDarkRules} | |
| 39 | 42 | `.trim() |
| 40 | 43 | |
| 41 | 44 | return { |
| @@ -46,6 +49,32 @@ ${selector}[data-theme="dark"] .syntax, | ||
| 46 | 49 | } |
| 47 | 50 | } |
| 48 | 51 | |
| 52 | /** | |
| 53 | * Prepend each of `prefixes` to every selector in a flat stylesheet (no | |
| 54 | * nested at-rules - `generateCSS`'s output never has any, so a rule is just | |
| 55 | * whatever sits between one `{...}` pair, comma-separated selectors and all). | |
| 56 | */ | |
| 57 | function scopeCssRules(css: string, prefixes: string[]): string { | |
| 58 | return css | |
| 59 | .split('}') | |
| 60 | .map(chunk => chunk.trim()) | |
| 61 | .filter(chunk => chunk.length > 0) | |
| 62 | .map((chunk) => { | |
| 63 | const braceIndex = chunk.indexOf('{') | |
| 64 | if (braceIndex === -1) { | |
| 65 | return null | |
| 66 | } | |
| 67 | const selectors = chunk.slice(0, braceIndex).trim().split(',').map(s => s.trim()) | |
| 68 | const body = chunk.slice(braceIndex) | |
| 69 | const scopedSelector = prefixes | |
| 70 | .flatMap(prefix => selectors.map(sel => `${prefix} ${sel}`)) | |
| 71 | .join(',\n') | |
| 72 | return `${scopedSelector} ${body}\n}` | |
| 73 | }) | |
| 74 | .filter((rule): rule is string => rule !== null) | |
| 75 | .join('\n\n') | |
| 76 | } | |
| 77 | ||
| 49 | 78 | /** |
| 50 | 79 | * Create a dual theme renderer |
| 51 | 80 | */ |