ReviewOS

also looking at this

stacks/ts-syntax-highlighter

fix: browser-safe config loading and correct dual-theme token colors

#477
Open jmadore-payfacto wants to merge fix/browser-safe-config-and-dual-theme-colors into main
6 files +237 -21
packages/ts-syntax-highlighter/src/config.tsmodified+7-1
Changes to packages/ts-syntax-highlighter/src/config.ts
@@ -1,5 +1,4 @@
11import type { SyntaxHighlighterConfig } from './types'
2import { loadConfig } from 'bunfig'
32
43export const defaultConfig: SyntaxHighlighterConfig = {
54 verbose: false,
@@ -14,6 +13,13 @@ let _config: SyntaxHighlighterConfig | null = null
1413
1514export async function loadSyntaxHighlighterConfig(): Promise<SyntaxHighlighterConfig> {
1615 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')
1723 _config = await loadConfig({
1824 name: 'syntax',
1925 defaultConfig,
packages/ts-syntax-highlighter/src/dual-theme.tsmodified+40-11
Changes to packages/ts-syntax-highlighter/src/dual-theme.ts
@@ -9,33 +9,36 @@ export interface DualThemeOptions extends RenderOptions {
99
1010/**
1111 * 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.
1219 */
1320export function renderDualTheme(
1421 tokens: TokenLine[],
1522 options: DualThemeOptions,
1623): RenderedCode {
1724 const { lightTheme, darkTheme, selector = 'html', ...renderOptions } = options
25 const colorViaClassOptions = { ...renderOptions, colorViaClass: true }
1826
19 // Render with both themes
2027 const lightRenderer = new Renderer(lightTheme)
2128 const darkRenderer = new Renderer(darkTheme)
2229
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) : ''
2535
26 // Combine CSS with media query
2736 const combinedCSS = `
2837/* Light theme (default) */
2938${lightResult.css}
3039
3140/* 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}
3942`.trim()
4043
4144 return {
@@ -46,6 +49,32 @@ ${selector}[data-theme="dark"] .syntax,
4649 }
4750}
4851
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 */
57function 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
4978/**
5079 * Create a dual theme renderer
5180 */
packages/ts-syntax-highlighter/src/renderer.tsmodified+75-9
Changes to packages/ts-syntax-highlighter/src/renderer.ts
@@ -4,6 +4,7 @@ export class Renderer {
44 private theme: Theme
55 private colorCache: Map<string, TokenSettings> = new Map()
66 private cssCache: string | null = null
7 private scopeColorRules: Map<string, TokenSettings> = new Map()
78
89 constructor(theme: Theme) {
910 this.theme = theme
@@ -13,7 +14,10 @@ export class Renderer {
1314 * Render tokens to HTML
1415 */
1516 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 }
1721 const lines = this.renderLineSpans(tokens, options)
1822
1923 const copyButton = showCopyButton
@@ -24,7 +28,10 @@ export class Renderer {
2428 ? `<code class="syntax-inline">${lines.join('')}</code>`
2529 : `<div class="syntax-wrapper">${copyButton}<pre class="syntax"><code>${lines.join('\n')}</code></pre></div>`
2630
27 const css = this.generateCSS(options)
31 let css = this.generateCSS(options)
32 if (colorViaClass) {
33 css = `${css}\n\n${this.generateTokenColorCSS()}`.trim()
34 }
2835 const ansi = this.renderAnsi(tokens)
2936
3037 return {
@@ -65,6 +72,7 @@ export class Renderer {
6572 addedLines = [],
6673 removedLines = [],
6774 annotations = [],
75 colorViaClass = false,
6876 } = options
6977
7078 return tokens.map((tokenLine, index) => {
@@ -79,7 +87,7 @@ export class Renderer {
7987 removedLines,
8088 })
8189
82 const lineContent = this.renderLine(tokenLine, inline)
90 const lineContent = this.renderLine(tokenLine, inline, colorViaClass)
8391
8492 if (inline) {
8593 return lineContent
@@ -154,23 +162,35 @@ export class Renderer {
154162 /**
155163 * Render a single line of tokens
156164 */
157 private renderLine(tokenLine: TokenLine, _inline: boolean): string {
165 private renderLine(tokenLine: TokenLine, _inline: boolean, colorViaClass = false): string {
158166 return tokenLine.tokens
159 .map(token => this.renderToken(token))
167 .map(token => this.renderToken(token, colorViaClass))
160168 .join('')
161169 }
162170
163171 /**
164172 * Render a single token
165173 */
166 private renderToken(token: Token): string {
174 private renderToken(token: Token, colorViaClass = false): string {
167175 const color = this.getColorForScopes(token.scopes)
168176 const escapedContent = this.escapeHtml(token.content)
169177 const classes = [this.getScopeClass(token.scopes)]
170178 const styles: string[] = []
171179
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) {
174194 styles.push(this.buildStyle(color))
175195 }
176196
@@ -283,6 +303,24 @@ export class Renderer {
283303 styles.push(`background-color: ${settings.background}`)
284304 }
285305
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
286324 if (settings.fontStyle) {
287325 if (settings.fontStyle.includes('italic')) {
288326 styles.push('font-style: italic')
@@ -295,7 +333,7 @@ export class Renderer {
295333 }
296334 }
297335
298 return styles.join('; ')
336 return styles
299337 }
300338
301339 /**
@@ -510,6 +548,33 @@ export class Renderer {
510548 return this.cssCache
511549 }
512550
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
513578 /**
514579 * Render tokens to ANSI terminal output
515580 */
@@ -544,5 +609,6 @@ export class Renderer {
544609 // Clear caches when theme changes
545610 this.colorCache.clear()
546611 this.cssCache = null
612 this.scopeColorRules.clear()
547613 }
548614}
packages/ts-syntax-highlighter/src/types.tsmodified+7-0
Changes to packages/ts-syntax-highlighter/src/types.ts
@@ -271,6 +271,13 @@ export interface RenderOptions {
271271 showCopyButton?: boolean
272272 lineTransformers?: LineTransformer[]
273273 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
274281}
275282
276283export interface RenderedCode {
packages/ts-syntax-highlighter/test/config.test.tsadded+32-0
Changes to packages/ts-syntax-highlighter/test/config.test.ts
@@ -0,0 +1,32 @@
1import { readFileSync } from 'node:fs'
2import { describe, expect, it } from 'bun:test'
3import { config, defaultConfig, getConfig, isVerbose, loadSyntaxHighlighterConfig } from '../src/config'
4
5describe('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})
packages/ts-syntax-highlighter/test/dual-theme.test.tsadded+76-0
Changes to packages/ts-syntax-highlighter/test/dual-theme.test.ts
@@ -0,0 +1,76 @@
1import type { TokenLine } from '../src/types'
2import { describe, expect, it } from 'bun:test'
3import { renderDualTheme } from '../src/dual-theme'
4import { githubDark } from '../src/themes/github-dark'
5import { githubLight } from '../src/themes/github-light'
6
7describe('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})