Skip to content

Commit 4eb7c8b

Browse files
Jinjiangantfubot
andauthored
fix(cli): fall back to plaintext for unknown languages (#1319)
Co-authored-by: Anthony Fu (via agent) <reg-github-bot@antfu.me>
1 parent 63e6da8 commit 4eb7c8b

2 files changed

Lines changed: 143 additions & 29 deletions

File tree

‎packages/cli/src/cli.ts‎

Lines changed: 39 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
import type { BundledLanguage } from 'shiki'
1+
import type { Readable } from 'node:stream'
2+
import type { BundledLanguage, BundledTheme } from 'shiki'
23
import fs from 'node:fs/promises'
34
import { parse } from 'node:path'
45
import process from 'node:process'
56
import cac from 'cac'
7+
import { bundledLanguages, isSpecialLang } from 'shiki'
68
import { version } from '../package.json'
79
import { codeToANSI } from './code-to-ansi'
810

@@ -20,6 +22,34 @@ export function getExtFromUrl(url: string): string {
2022
}
2123
}
2224

25+
/**
26+
* Unknown languages fall back to plaintext so the CLI can be used as a `cat` replacement.
27+
* Known-language grammar load/parse errors are not swallowed.
28+
*/
29+
export function resolveLanguage(lang: string): string {
30+
const normalized = lang.toLowerCase()
31+
if (normalized in bundledLanguages || isSpecialLang(normalized))
32+
return normalized
33+
return 'text'
34+
}
35+
36+
async function render(
37+
content: string,
38+
lang: string,
39+
theme: BundledTheme,
40+
format: string,
41+
): Promise<string> {
42+
const resolved = resolveLanguage(lang) as BundledLanguage
43+
if (format === 'html') {
44+
const { codeToHtml } = await import('shiki')
45+
return await codeToHtml(content, {
46+
lang: resolved,
47+
theme,
48+
})
49+
}
50+
return await codeToANSI(content, resolved, theme)
51+
}
52+
2353
export async function readSource(path: string): Promise<{ content: string, ext: string }> {
2454
if (isUrl(path)) {
2555
const response = await fetch(path)
@@ -40,6 +70,7 @@ export async function readSource(path: string): Promise<{ content: string, ext:
4070
export async function run(
4171
argv = process.argv,
4272
log = console.log,
73+
stdin: Readable & { isTTY?: boolean } = process.stdin,
4374
): Promise<void> {
4475
const cli = cac('shiki')
4576

@@ -62,7 +93,6 @@ export async function run(
6293
}
6394

6495
if (options.listLangs) {
65-
const { bundledLanguages } = await import('shiki')
6696
for (const lang of Object.keys(bundledLanguages))
6797
log(lang)
6898
return
@@ -74,25 +104,15 @@ export async function run(
74104
// If no files provided, verify if we are in a TTY environment
75105
// If NOT in TTY (piped), read from stdin
76106
// If in TTY, show help
77-
if (!process.stdin.isTTY) {
107+
if (!stdin.isTTY) {
78108
const content = await new Promise<string>((resolve, reject) => {
79109
let data = ''
80-
process.stdin.on('data', chunk => data += chunk)
81-
process.stdin.on('end', () => resolve(data))
82-
process.stdin.on('error', reject)
110+
stdin.on('data', chunk => data += chunk)
111+
stdin.on('end', () => resolve(data))
112+
stdin.on('error', reject)
83113
})
84114

85-
const lang = options.lang || 'text'
86-
if (options.format === 'html') {
87-
const { codeToHtml } = await import('shiki')
88-
log(await codeToHtml(content, {
89-
lang: lang as BundledLanguage,
90-
theme: options.theme,
91-
}))
92-
}
93-
else {
94-
log(await codeToANSI(content, lang as BundledLanguage, options.theme))
95-
}
115+
log(await render(content, options.lang || 'text', options.theme, options.format))
96116
return
97117
}
98118

@@ -102,17 +122,8 @@ export async function run(
102122

103123
const codes = await Promise.all(files.map(async (path) => {
104124
const { content, ext } = await readSource(path)
105-
const lang = (options.lang || ext || 'text').toLowerCase()
106-
if (options.format === 'html') {
107-
const { codeToHtml } = await import('shiki')
108-
return await codeToHtml(content, {
109-
lang: lang as BundledLanguage,
110-
theme: options.theme,
111-
})
112-
}
113-
else {
114-
return await codeToANSI(content, lang as BundledLanguage, options.theme)
115-
}
125+
const lang = options.lang || ext || 'text'
126+
return await render(content, lang, options.theme, options.format)
116127
}))
117128

118129
for (const code of codes)

‎packages/cli/test/cli.test.ts‎

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import fs from 'node:fs/promises'
22
import path from 'node:path'
3+
import { Readable } from 'node:stream'
34
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4-
import { getExtFromUrl, isUrl, readSource, run } from '../src/cli'
5+
import { getExtFromUrl, isUrl, readSource, resolveLanguage, run } from '../src/cli'
56

67
describe('isUrl', () => {
78
it('valid URL', () => {
@@ -32,6 +33,32 @@ describe('getExtFromUrl', () => {
3233
})
3334
})
3435

36+
describe('resolveLanguage', () => {
37+
it('keeps bundled languages and aliases', () => {
38+
expect(resolveLanguage('ts')).toBe('ts')
39+
expect(resolveLanguage('typescript')).toBe('typescript')
40+
expect(resolveLanguage('md')).toBe('md')
41+
expect(resolveLanguage('dotenv')).toBe('dotenv')
42+
})
43+
44+
it('keeps special languages', () => {
45+
expect(resolveLanguage('text')).toBe('text')
46+
expect(resolveLanguage('txt')).toBe('txt')
47+
expect(resolveLanguage('plain')).toBe('plain')
48+
expect(resolveLanguage('ansi')).toBe('ansi')
49+
})
50+
51+
it('falls back to text for unknown languages', () => {
52+
expect(resolveLanguage('example')).toBe('text')
53+
expect(resolveLanguage('env.example')).toBe('text')
54+
})
55+
56+
it('normalizes casing before lookup', () => {
57+
expect(resolveLanguage('TS')).toBe('ts')
58+
expect(resolveLanguage('DOTENV')).toBe('dotenv')
59+
})
60+
})
61+
3562
describe('readSource', () => {
3663
const testDir = path.join(import.meta.dirname, '__fixtures__')
3764
const testFile = path.join(testDir, 'test.ts')
@@ -184,6 +211,12 @@ describe('run', () => {
184211

185212
expect(output.length).toBe(1)
186213
expect(output[0]).toContain('all')
214+
215+
const asHtml: string[] = []
216+
const plain: string[] = []
217+
await run(['node', 'shiki', '--format', 'html', noExtFile], msg => asHtml.push(msg))
218+
await run(['node', 'shiki', '--lang', 'text', '--format', 'html', noExtFile], msg => plain.push(msg))
219+
expect(asHtml[0]).toBe(plain[0])
187220
})
188221

189222
it('remote URL without file extension falls back to plaintext', async () => {
@@ -201,4 +234,74 @@ describe('run', () => {
201234

202235
vi.unstubAllGlobals()
203236
})
237+
238+
it('unknown extension falls back to plaintext without throwing', async () => {
239+
const envFile = path.join(testDir, '.env.example')
240+
await fs.writeFile(envFile, 'FOO=bar')
241+
242+
const output: string[] = []
243+
await run(['node', 'shiki', '--format', 'html', envFile], msg => output.push(msg))
244+
245+
const plain: string[] = []
246+
await run(['node', 'shiki', '--lang', 'text', '--format', 'html', envFile], msg => plain.push(msg))
247+
248+
expect(output.length).toBe(1)
249+
expect(output[0]).toContain('FOO=bar')
250+
expect(output[0]).toBe(plain[0])
251+
})
252+
253+
it('highlights known extensions', async () => {
254+
const mdFile = path.join(testDir, 'README.md')
255+
await fs.writeFile(mdFile, '# Title')
256+
257+
const md: string[] = []
258+
const mdPlain: string[] = []
259+
await run(['node', 'shiki', '--format', 'html', mdFile], msg => md.push(msg))
260+
await run(['node', 'shiki', '--lang', 'text', '--format', 'html', mdFile], msg => mdPlain.push(msg))
261+
expect(md[0]).toContain('Title')
262+
expect(md[0]).not.toBe(mdPlain[0])
263+
264+
const ts: string[] = []
265+
const tsPlain: string[] = []
266+
await run(['node', 'shiki', '--format', 'html', testFile], msg => ts.push(msg))
267+
await run(['node', 'shiki', '--lang', 'text', '--format', 'html', testFile], msg => tsPlain.push(msg))
268+
expect(ts[0]).toContain('const')
269+
expect(ts[0]).not.toBe(tsPlain[0])
270+
})
271+
272+
it('respects explicit --lang over unknown extension', async () => {
273+
const envFile = path.join(testDir, 'file.env.example')
274+
await fs.writeFile(envFile, 'FOO=bar')
275+
276+
const explicit: string[] = []
277+
const fallback: string[] = []
278+
await run(['node', 'shiki', '--lang', 'dotenv', '--format', 'html', envFile], msg => explicit.push(msg))
279+
await run(['node', 'shiki', '--format', 'html', envFile], msg => fallback.push(msg))
280+
281+
expect(explicit.length).toBe(1)
282+
expect(explicit[0]).toContain('FOO')
283+
expect(explicit[0]).not.toBe(fallback[0])
284+
})
285+
286+
it('stdin without --lang uses text', async () => {
287+
function createStdin() {
288+
const stdin = Readable.from(['const x = 1']) as Readable & { isTTY?: boolean }
289+
stdin.isTTY = false
290+
return stdin
291+
}
292+
293+
const output: string[] = []
294+
await run(['node', 'shiki', '--format', 'html'], msg => output.push(msg), createStdin())
295+
296+
const plain: string[] = []
297+
await run(['node', 'shiki', '--lang', 'text', '--format', 'html'], msg => plain.push(msg), createStdin())
298+
299+
const asTs: string[] = []
300+
await run(['node', 'shiki', '--lang', 'ts', '--format', 'html'], msg => asTs.push(msg), createStdin())
301+
302+
expect(output.length).toBe(1)
303+
expect(output[0]).toContain('const x = 1')
304+
expect(output[0]).toBe(plain[0])
305+
expect(output[0]).not.toBe(asTs[0])
306+
})
204307
})

0 commit comments

Comments
 (0)