Skip to content

Commit b968ed4

Browse files
feat: emit an explicit color level from codeToANSI (#1320)
1 parent b542364 commit b968ed4

3 files changed

Lines changed: 113 additions & 7 deletions

File tree

‎docs/packages/cli.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,18 @@ console.log(highlighted)
123123
1. `code: string`
124124
2. `lang: BundledLanguage`
125125
3. `theme: BundledTheme`
126+
127+
and one optional parameter:
128+
129+
4. `options?: CodeToANSIOptions`
130+
131+
The color level of the emitted escape codes is explicit instead of being inferred from the environment, and defaults to `3` (truecolor). Use `options.colorLevel` to target a different level — pass the level reported by `ansis` to follow the current terminal, or `0` to get plain text:
132+
133+
```ts
134+
import { codeToANSI } from '@shikijs/cli'
135+
import ansis from 'ansis'
136+
137+
const highlighted = await codeToANSI(source, 'typescript', 'nord', {
138+
colorLevel: ansis.level,
139+
})
140+
```

‎packages/cli/src/code-to-ansi.ts‎

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,40 @@
11
import type { BundledLanguage, BundledTheme } from 'shiki'
22
import { FontStyle } from '@shikijs/vscode-textmate'
3-
import c from 'ansis'
3+
import { Ansis } from 'ansis'
44
import { codeToTokensBase, getSingletonHighlighter } from 'shiki'
55
import { hexApplyAlpha } from './colors'
66

7-
export async function codeToANSI(code: string, lang: BundledLanguage, theme: BundledTheme): Promise<string> {
7+
/**
8+
* The ANSI color level of the emitted escape codes, matching the levels
9+
* understood by `ansis`: `0` no color, `1` basic (16 colors), `2` 256 colors,
10+
* `3` truecolor (24-bit).
11+
*/
12+
export type ANSIColorLevel = 0 | 1 | 2 | 3
13+
14+
export interface CodeToANSIOptions {
15+
/**
16+
* The color level to emit.
17+
*
18+
* Defaults to `3` (truecolor), so the result does not depend on the
19+
* environment this code happens to run in.
20+
*/
21+
colorLevel?: ANSIColorLevel
22+
}
23+
24+
/**
25+
* Converts code to a string containing ANSI escape codes.
26+
*
27+
* The color level is explicit and does not depend on the current environment,
28+
* as the result is not necessarily printed to the terminal that runs this
29+
* code. Pass {@link CodeToANSIOptions.colorLevel} to target a different level.
30+
*/
31+
export async function codeToANSI(code: string, lang: BundledLanguage, theme: BundledTheme, options: CodeToANSIOptions = {}): Promise<string> {
32+
// The default `ansis` instance infers color support from the environment
33+
// (`NO_COLOR`, `FORCE_COLOR`, TTY detection, ...), which silently strips all
34+
// highlighting when it guesses "wrong". Use an instance with an explicit
35+
// level instead.
36+
const ansi = new Ansis(options.colorLevel ?? 3)
37+
838
let output = ''
939

1040
const lines = await codeToTokensBase(code, {
@@ -20,16 +50,16 @@ export async function codeToANSI(code: string, lang: BundledLanguage, theme: Bun
2050
let text = token.content
2151
const color = token.color || themeReg.fg
2252
if (color)
23-
text = c.hex(hexApplyAlpha(color, themeReg.type))(text)
53+
text = ansi.hex(hexApplyAlpha(color, themeReg.type))(text)
2454
if (token.fontStyle) {
2555
if (token.fontStyle & FontStyle.Bold)
26-
text = c.bold(text)
56+
text = ansi.bold(text)
2757
if (token.fontStyle & FontStyle.Italic)
28-
text = c.italic(text)
58+
text = ansi.italic(text)
2959
if (token.fontStyle & FontStyle.Underline)
30-
text = c.underline(text)
60+
text = ansi.underline(text)
3161
if (token.fontStyle & FontStyle.Strikethrough)
32-
text = c.strikethrough(text)
62+
text = ansi.strikethrough(text)
3363
}
3464
output += text
3565
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest'
2+
3+
const ESCAPE = '\u001B'
4+
const ANSI_PREFIX = `${ESCAPE}[`
5+
const TRUECOLOR_FOREGROUND = `${ESCAPE}[38;2;`
6+
const ANSI256_FOREGROUND = `${ESCAPE}[38;5;`
7+
8+
/**
9+
* `codeToANSI` must not inherit the color support that the default `ansis`
10+
* instance infers from the current environment: its result is not necessarily
11+
* printed to the terminal running this code, so a "no color" environment used
12+
* to silently strip all highlighting (#1274).
13+
*
14+
* The environment is set before importing the module under test, because
15+
* `ansis` resolves color support at import time.
16+
*/
17+
async function importCodeToANSI() {
18+
vi.resetModules()
19+
return await import('../src/code-to-ansi')
20+
}
21+
22+
describe('codeToANSI', () => {
23+
afterEach(() => {
24+
vi.unstubAllEnvs()
25+
vi.resetModules()
26+
})
27+
28+
it('emits truecolor escape codes when the environment disables color', async () => {
29+
vi.stubEnv('NO_COLOR', '1')
30+
vi.stubEnv('FORCE_COLOR', undefined)
31+
32+
const { codeToANSI } = await importCodeToANSI()
33+
const output = await codeToANSI('# hi', 'markdown', 'github-dark-default')
34+
35+
expect(output).toContain(ANSI_PREFIX)
36+
expect(output).toContain(TRUECOLOR_FOREGROUND)
37+
expect(output).toContain('# hi')
38+
})
39+
40+
it('emits plain text when the caller asks for no color', async () => {
41+
vi.stubEnv('NO_COLOR', '1')
42+
vi.stubEnv('FORCE_COLOR', undefined)
43+
44+
const { codeToANSI } = await importCodeToANSI()
45+
const output = await codeToANSI('# hi', 'markdown', 'github-dark-default', { colorLevel: 0 })
46+
47+
expect(output).not.toContain(ANSI_PREFIX)
48+
expect(output).toContain('# hi')
49+
})
50+
51+
it('honors an explicit lower color level', async () => {
52+
vi.stubEnv('NO_COLOR', '1')
53+
vi.stubEnv('FORCE_COLOR', undefined)
54+
55+
const { codeToANSI } = await importCodeToANSI()
56+
const output = await codeToANSI('# hi', 'markdown', 'github-dark-default', { colorLevel: 2 })
57+
58+
expect(output).toContain(ANSI256_FOREGROUND)
59+
expect(output).not.toContain(TRUECOLOR_FOREGROUND)
60+
})
61+
})

0 commit comments

Comments
 (0)