also looking at this
fix: browser-safe config loading and correct dual-theme token colors
#477
6 files
+237
-21
| @@ -1,5 +1,4 @@ | ||
| 1 | 1 | import type { SyntaxHighlighterConfig } from './types' |
| 2 | import { loadConfig } from 'bunfig' | |
| 3 | 2 | |
| 4 | 3 | export const defaultConfig: SyntaxHighlighterConfig = { |
| 5 | 4 | verbose: false, |
| @@ -14,6 +13,13 @@ let _config: SyntaxHighlighterConfig | null = null | ||
| 14 | 13 | |
| 15 | 14 | export async function loadSyntaxHighlighterConfig(): Promise<SyntaxHighlighterConfig> { |
| 16 | 15 | if (!_config) { |
| 16 | // `bunfig` reads config files from disk and is Node/Bun-only, so it is | |
| 17 | // imported dynamically here rather than at module scope. A static | |
| 18 | // top-level import would drag its own eager, unguarded `process.env` | |
| 19 | // access into every consumer's bundle purely by importing this | |
| 20 | // package's index, crashing immediately in a browser even for callers | |
| 21 | // who never touch config loading. | |
| 22 | const { loadConfig } = await import('bunfig') | |
| 17 | 23 | _config = await loadConfig({ |
| 18 | 24 | name: 'syntax', |
| 19 | 25 | defaultConfig, |
| @@ -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 | */ |
| @@ -4,6 +4,7 @@ export class Renderer { | ||
| 4 | 4 | private theme: Theme |
| 5 | 5 | private colorCache: Map<string, TokenSettings> = new Map() |
| 6 | 6 | private cssCache: string | null = null |
| 7 | private scopeColorRules: Map<string, TokenSettings> = new Map() | |
| 7 | 8 | |
| 8 | 9 | constructor(theme: Theme) { |
| 9 | 10 | this.theme = theme |
| @@ -13,7 +14,10 @@ export class Renderer { | ||
| 13 | 14 | * Render tokens to HTML |
| 14 | 15 | */ |
| 15 | 16 | render(tokens: TokenLine[], options: RenderOptions = {}): RenderedCode { |
| 16 | const { inline = false, showCopyButton = false } = options | |
| 17 | const { inline = false, showCopyButton = false, colorViaClass = false } = options | |
| 18 | if (colorViaClass) { | |
| 19 | this.scopeColorRules.clear() | |
| 20 | } | |
| 17 | 21 | const lines = this.renderLineSpans(tokens, options) |
| 18 | 22 | |
| 19 | 23 | const copyButton = showCopyButton |
| @@ -24,7 +28,10 @@ export class Renderer { | ||
| 24 | 28 | ? `<code class="syntax-inline">${lines.join('')}</code>` |
| 25 | 29 | : `<div class="syntax-wrapper">${copyButton}<pre class="syntax"><code>${lines.join('\n')}</code></pre></div>` |
| 26 | 30 | |
| 27 | const css = this.generateCSS(options) | |
| 31 | let css = this.generateCSS(options) | |
| 32 | if (colorViaClass) { | |
| 33 | css = `${css}\n\n${this.generateTokenColorCSS()}`.trim() | |
| 34 | } | |
| 28 | 35 | const ansi = this.renderAnsi(tokens) |
| 29 | 36 | |
| 30 | 37 | return { |
| @@ -65,6 +72,7 @@ export class Renderer { | ||
| 65 | 72 | addedLines = [], |
| 66 | 73 | removedLines = [], |
| 67 | 74 | annotations = [], |
| 75 | colorViaClass = false, | |
| 68 | 76 | } = options |
| 69 | 77 | |
| 70 | 78 | return tokens.map((tokenLine, index) => { |
| @@ -79,7 +87,7 @@ export class Renderer { | ||
| 79 | 87 | removedLines, |
| 80 | 88 | }) |
| 81 | 89 | |
| 82 | const lineContent = this.renderLine(tokenLine, inline) | |
| 90 | const lineContent = this.renderLine(tokenLine, inline, colorViaClass) | |
| 83 | 91 | |
| 84 | 92 | if (inline) { |
| 85 | 93 | return lineContent |
| @@ -154,23 +162,35 @@ export class Renderer { | ||
| 154 | 162 | /** |
| 155 | 163 | * Render a single line of tokens |
| 156 | 164 | */ |
| 157 | private renderLine(tokenLine: TokenLine, _inline: boolean): string { | |
| 165 | private renderLine(tokenLine: TokenLine, _inline: boolean, colorViaClass = false): string { | |
| 158 | 166 | return tokenLine.tokens |
| 159 | .map(token => this.renderToken(token)) | |
| 167 | .map(token => this.renderToken(token, colorViaClass)) | |
| 160 | 168 | .join('') |
| 161 | 169 | } |
| 162 | 170 | |
| 163 | 171 | /** |
| 164 | 172 | * Render a single token |
| 165 | 173 | */ |
| 166 | private renderToken(token: Token): string { | |
| 174 | private renderToken(token: Token, colorViaClass = false): string { | |
| 167 | 175 | const color = this.getColorForScopes(token.scopes) |
| 168 | 176 | const escapedContent = this.escapeHtml(token.content) |
| 169 | 177 | const classes = [this.getScopeClass(token.scopes)] |
| 170 | 178 | const styles: string[] = [] |
| 171 | 179 | |
| 172 | // Apply base color | |
| 173 | if (color.foreground || color.fontStyle) { | |
| 180 | // Apply base color. In `colorViaClass` mode the color is recorded as a | |
| 181 | // CSS rule instead of inlined, so a caller rendering the same tokens | |
| 182 | // under two themes (see `renderDualTheme`) can select between the two | |
| 183 | // resulting stylesheets - an inline `style` attribute would always win | |
| 184 | // over either one. | |
| 185 | if (colorViaClass) { | |
| 186 | if (color.foreground || color.background) { | |
| 187 | this.scopeColorRules.set(classes[0]!, color) | |
| 188 | } | |
| 189 | if (color.fontStyle) { | |
| 190 | styles.push(this.buildFontStyleOnly(color)) | |
| 191 | } | |
| 192 | } | |
| 193 | else if (color.foreground || color.fontStyle) { | |
| 174 | 194 | styles.push(this.buildStyle(color)) |
| 175 | 195 | } |
| 176 | 196 | |
| @@ -283,6 +303,24 @@ export class Renderer { | ||
| 283 | 303 | styles.push(`background-color: ${settings.background}`) |
| 284 | 304 | } |
| 285 | 305 | |
| 306 | styles.push(...this.fontStyleDeclarations(settings)) | |
| 307 | ||
| 308 | return styles.join('; ') | |
| 309 | } | |
| 310 | ||
| 311 | /** | |
| 312 | * Inline style string for the typographic part of a token's settings only | |
| 313 | * (italic/bold/underline) - no color. Used in `colorViaClass` mode, where | |
| 314 | * color moves to a CSS rule but weight/slant/decoration are the same in | |
| 315 | * every theme and can stay inline. | |
| 316 | */ | |
| 317 | private buildFontStyleOnly(settings: TokenSettings): string { | |
| 318 | return this.fontStyleDeclarations(settings).join('; ') | |
| 319 | } | |
| 320 | ||
| 321 | private fontStyleDeclarations(settings: TokenSettings): string[] { | |
| 322 | const styles: string[] = [] | |
| 323 | ||
| 286 | 324 | if (settings.fontStyle) { |
| 287 | 325 | if (settings.fontStyle.includes('italic')) { |
| 288 | 326 | styles.push('font-style: italic') |
| @@ -295,7 +333,7 @@ export class Renderer { | ||
| 295 | 333 | } |
| 296 | 334 | } |
| 297 | 335 | |
| 298 | return styles.join('; ') | |
| 336 | return styles | |
| 299 | 337 | } |
| 300 | 338 | |
| 301 | 339 | /** |
| @@ -510,6 +548,33 @@ export class Renderer { | ||
| 510 | 548 | return this.cssCache |
| 511 | 549 | } |
| 512 | 550 | |
| 551 | /** | |
| 552 | * CSS rules for every scope class seen since the last `colorViaClass` | |
| 553 | * render, one per distinct scope actually present in that render rather | |
| 554 | * than the theme's full scope list - a file only ever touches a slice of | |
| 555 | * a grammar's scopes. | |
| 556 | */ | |
| 557 | private generateTokenColorCSS(): string { | |
| 558 | const rules: string[] = [] | |
| 559 | ||
| 560 | for (const [className, settings] of this.scopeColorRules) { | |
| 561 | const declarations: string[] = [] | |
| 562 | if (settings.foreground) { | |
| 563 | declarations.push(`color: ${settings.foreground}`) | |
| 564 | } | |
| 565 | if (settings.background) { | |
| 566 | declarations.push(`background-color: ${settings.background}`) | |
| 567 | } | |
| 568 | if (declarations.length === 0) { | |
| 569 | continue | |
| 570 | } | |
| 571 | const selector = `.${className.split(' ').join('.')}` | |
| 572 | rules.push(`${selector} { ${declarations.join('; ')}; }`) | |
| 573 | } | |
| 574 | ||
| 575 | return rules.join('\n') | |
| 576 | } | |
| 577 | ||
| 513 | 578 | /** |
| 514 | 579 | * Render tokens to ANSI terminal output |
| 515 | 580 | */ |
| @@ -544,5 +609,6 @@ export class Renderer { | ||
| 544 | 609 | // Clear caches when theme changes |
| 545 | 610 | this.colorCache.clear() |
| 546 | 611 | this.cssCache = null |
| 612 | this.scopeColorRules.clear() | |
| 547 | 613 | } |
| 548 | 614 | } |
| @@ -271,6 +271,13 @@ export interface RenderOptions { | ||
| 271 | 271 | showCopyButton?: boolean |
| 272 | 272 | lineTransformers?: LineTransformer[] |
| 273 | 273 | tokensTransformers?: TokenTransformer[] |
| 274 | /** | |
| 275 | * Emit token colors as `.token.<scope-class>` CSS rules instead of an | |
| 276 | * inline `style="color: ..."` per span. Used by `renderDualTheme`, which | |
| 277 | * needs a light and a dark stylesheet to select between at the same | |
| 278 | * markup - not possible if the color is baked into the HTML itself. | |
| 279 | */ | |
| 280 | colorViaClass?: boolean | |
| 274 | 281 | } |
| 275 | 282 | |
| 276 | 283 | export interface RenderedCode { |
| @@ -0,0 +1,32 @@ | ||
| 1 | import { readFileSync } from 'node:fs' | |
| 2 | import { describe, expect, it } from 'bun:test' | |
| 3 | import { config, defaultConfig, getConfig, isVerbose, loadSyntaxHighlighterConfig } from '../src/config' | |
| 4 | ||
| 5 | describe('config', () => { | |
| 6 | it('exposes a usable default config without requiring loadSyntaxHighlighterConfig to run', () => { | |
| 7 | expect(config).toEqual(defaultConfig) | |
| 8 | expect(getConfig('theme')).toBe(defaultConfig.theme) | |
| 9 | expect(isVerbose()).toBe(false) | |
| 10 | }) | |
| 11 | ||
| 12 | it('still loads config from disk when a caller opts in', async () => { | |
| 13 | const loaded = await loadSyntaxHighlighterConfig() | |
| 14 | expect(loaded).toBeTruthy() | |
| 15 | expect(loaded.theme).toBeTruthy() | |
| 16 | }) | |
| 17 | ||
| 18 | it('does not statically import the Node/Bun-only config loader at module scope', () => { | |
| 19 | // `bunfig` reads config files from disk and, as of the version this | |
| 20 | // package depends on, runs its own eager, unguarded `process.env` | |
| 21 | // access the moment it is imported - safe under Bun/Node, but a hard | |
| 22 | // crash for any browser consumer that pulls it in just by importing | |
| 23 | // this package's index, whether or not they ever call | |
| 24 | // `loadSyntaxHighlighterConfig()`. A static top-level import here | |
| 25 | // regresses that; it must stay dynamic, inside the function that | |
| 26 | // actually needs it. | |
| 27 | const source = readFileSync(new URL('../src/config.ts', import.meta.url), 'utf-8') | |
| 28 | ||
| 29 | expect(source).not.toMatch(/^import\s+.*from\s+['"]bunfig['"]/m) | |
| 30 | expect(source).toContain('await import(\'bunfig\')') | |
| 31 | }) | |
| 32 | }) | |
| @@ -0,0 +1,76 @@ | ||
| 1 | import type { TokenLine } from '../src/types' | |
| 2 | import { describe, expect, it } from 'bun:test' | |
| 3 | import { renderDualTheme } from '../src/dual-theme' | |
| 4 | import { githubDark } from '../src/themes/github-dark' | |
| 5 | import { githubLight } from '../src/themes/github-light' | |
| 6 | ||
| 7 | describe('renderDualTheme', () => { | |
| 8 | const tokens: TokenLine[] = [ | |
| 9 | { | |
| 10 | line: 1, | |
| 11 | tokens: [ | |
| 12 | { type: 'keyword', content: 'const', scopes: ['source.js', 'storage.type.js'], line: 1, offset: 0 }, | |
| 13 | { type: 'text', content: ' x = ', scopes: ['source.js'], line: 1, offset: 5 }, | |
| 14 | { type: 'number', content: '42', scopes: ['source.js', 'constant.numeric.js'], line: 1, offset: 10 }, | |
| 15 | ], | |
| 16 | }, | |
| 17 | ] | |
| 18 | ||
| 19 | it('does not bake either theme color into the HTML as an inline style', () => { | |
| 20 | const result = renderDualTheme(tokens, { | |
| 21 | lightTheme: githubLight, | |
| 22 | darkTheme: githubDark, | |
| 23 | selector: '.dark-root', | |
| 24 | }) | |
| 25 | ||
| 26 | // A theme color inlined here could never be overridden by any | |
| 27 | // stylesheet, dark or light - that was the bug. | |
| 28 | expect(result.html).not.toContain('style="color:') | |
| 29 | }) | |
| 30 | ||
| 31 | it('emits a light-mode color rule for a scope with no selector prefix', () => { | |
| 32 | const result = renderDualTheme(tokens, { | |
| 33 | lightTheme: githubLight, | |
| 34 | darkTheme: githubDark, | |
| 35 | selector: '.dark-root', | |
| 36 | }) | |
| 37 | ||
| 38 | const lightKeywordColor = githubLight.tokenColors.find( | |
| 39 | t => (Array.isArray(t.scope) ? t.scope : [t.scope]).some(s => s.includes('storage.type')), | |
| 40 | )?.settings.foreground | |
| 41 | ||
| 42 | expect(lightKeywordColor).toBeTruthy() | |
| 43 | expect(result.css).toContain(`color: ${lightKeywordColor}`) | |
| 44 | }) | |
| 45 | ||
| 46 | it('emits the dark-mode color rule scoped under the given selector, not applied by default', () => { | |
| 47 | const result = renderDualTheme(tokens, { | |
| 48 | lightTheme: githubLight, | |
| 49 | darkTheme: githubDark, | |
| 50 | selector: '.dark-root', | |
| 51 | }) | |
| 52 | ||
| 53 | const darkKeywordColor = githubDark.tokenColors.find( | |
| 54 | t => (Array.isArray(t.scope) ? t.scope : [t.scope]).some(s => s.includes('storage.type')), | |
| 55 | )?.settings.foreground | |
| 56 | ||
| 57 | expect(darkKeywordColor).toBeTruthy() | |
| 58 | expect(result.css).toContain('.dark-root.dark') | |
| 59 | expect(result.css).toContain(`color: ${darkKeywordColor}`) | |
| 60 | ||
| 61 | // Every occurrence of the dark color must sit after some occurrence of | |
| 62 | // the dark-mode selector prefix, i.e. inside a scoped rule - not as a | |
| 63 | // bare, always-applied rule sitting ahead of any `.dark-root.dark ...`. | |
| 64 | const firstPrefixIndex = result.css!.indexOf('.dark-root.dark') | |
| 65 | const colorIndex = result.css!.indexOf(`color: ${darkKeywordColor}`) | |
| 66 | expect(firstPrefixIndex).toBeGreaterThanOrEqual(0) | |
| 67 | expect(colorIndex).toBeGreaterThan(firstPrefixIndex) | |
| 68 | }) | |
| 69 | ||
| 70 | it('renders identical markup regardless of which theme colors were requested', () => { | |
| 71 | const first = renderDualTheme(tokens, { lightTheme: githubLight, darkTheme: githubDark }) | |
| 72 | const second = renderDualTheme(tokens, { lightTheme: githubDark, darkTheme: githubLight }) | |
| 73 | ||
| 74 | expect(first.html).toBe(second.html) | |
| 75 | }) | |
| 76 | }) | |