Skip to content

Commit fb05a9e

Browse files
authored
feat(cli): support passing remote file url (#1139)
1 parent 3a367d5 commit fb05a9e

File tree

3 files changed

+170
-3
lines changed

3 files changed

+170
-3
lines changed

docs/packages/cli.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,17 @@ Shiki in the command line.
77
## Usage
88

99
The Shiki CLI works like `cat` command, but with syntax highlighting.
10+
It also supports remote files.
1011

1112
```bash
1213
npx @shikijs/cli README.md
1314
```
1415

16+
```bash
17+
npx @shikijs/cli \
18+
'https://github.com/shikijs/shiki/blob/main/taze.config.ts?raw=true'
19+
```
20+
1521
## Install
1622

1723
You can also install it globally. Command aliases `@shikijs/cli`, `shiki`, `skat` are registered.

packages/cli/src/cli.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,37 @@ import cac from 'cac'
66
import { version } from '../package.json'
77
import { codeToANSI } from './code-to-ansi'
88

9+
export function isUrl(path: string): boolean {
10+
return path.startsWith('http://') || path.startsWith('https://')
11+
}
12+
13+
export function getExtFromUrl(url: string): string {
14+
try {
15+
const pathname = new URL(url).pathname
16+
return parse(pathname).ext.slice(1)
17+
}
18+
catch {
19+
return ''
20+
}
21+
}
22+
23+
export async function readSource(path: string): Promise<{ content: string, ext: string }> {
24+
if (isUrl(path)) {
25+
const response = await fetch(path)
26+
if (!response.ok) {
27+
throw new Error(`Failed to fetch ${path}: ${response.status} ${response.statusText}`)
28+
}
29+
const content = await response.text()
30+
const ext = getExtFromUrl(path)
31+
return { content, ext }
32+
}
33+
else {
34+
const content = await fs.readFile(path, 'utf-8')
35+
const ext = parse(path).ext.slice(1)
36+
return { content, ext }
37+
}
38+
}
39+
940
export async function run(
1041
argv = process.argv,
1142
log = console.log,
@@ -22,9 +53,9 @@ export async function run(
2253
const files = args
2354

2455
const codes = await Promise.all(files.map(async (path) => {
25-
const content = await fs.readFile(path, 'utf-8')
26-
const ext = options.lang || parse(path).ext.slice(1)
27-
return await codeToANSI(content, ext as BundledLanguage, options.theme)
56+
const { content, ext } = await readSource(path)
57+
const lang = options.lang || ext
58+
return await codeToANSI(content, lang as BundledLanguage, options.theme)
2859
}))
2960

3061
for (const code of codes)

packages/cli/test/cli.test.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import fs from 'node:fs/promises'
2+
import path from 'node:path'
3+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4+
import { getExtFromUrl, isUrl, readSource, run } from '../src/cli'
5+
6+
describe('isUrl', () => {
7+
it('valid URL', () => {
8+
expect(isUrl('http://localhost:3000/file.ts')).toBe(true)
9+
expect(isUrl('https://raw.githubusercontent.com/shikijs/shiki/refs/heads/main/taze.config.ts')).toBe(true)
10+
expect(isUrl('/absolute/path/file.ts')).toBe(false)
11+
expect(isUrl('relative/path/file.ts')).toBe(false)
12+
expect(isUrl('file.ts')).toBe(false)
13+
})
14+
})
15+
16+
describe('getExtFromUrl', () => {
17+
it('extracts extension', () => {
18+
expect(getExtFromUrl('https://example.com/file.ts')).toBe('ts')
19+
expect(getExtFromUrl('https://shiki.style/guide.html')).toBe('html')
20+
})
21+
22+
it('handles query params', () => {
23+
expect(getExtFromUrl('https://github.com/shikijs/shiki/blob/main/taze.config.ts?raw=true')).toBe('ts')
24+
})
25+
26+
it('invalid URL', () => {
27+
expect(getExtFromUrl('not-a-url')).toBe('')
28+
})
29+
})
30+
31+
describe('readSource', () => {
32+
const testDir = path.join(import.meta.dirname, '__fixtures__')
33+
const testFile = path.join(testDir, 'test.ts')
34+
const testContent = 'const x: number = 1'
35+
36+
beforeEach(async () => {
37+
await fs.mkdir(testDir, { recursive: true })
38+
await fs.writeFile(testFile, testContent)
39+
})
40+
41+
afterEach(async () => {
42+
await fs.rm(testDir, { recursive: true, force: true })
43+
})
44+
45+
it('local file', async () => {
46+
const result = await readSource(testFile)
47+
expect(result.content).toBe(testContent)
48+
expect(result.ext).toBe('ts')
49+
})
50+
51+
it('remote URL', async () => {
52+
const mockContent = 'export const foo = "bar"'
53+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
54+
ok: true,
55+
text: () => Promise.resolve(mockContent),
56+
}))
57+
58+
const result = await readSource('https://example.com/file.js')
59+
expect(result.content).toBe(mockContent)
60+
expect(result.ext).toBe('js')
61+
62+
vi.unstubAllGlobals()
63+
})
64+
65+
it('failed fetch', async () => {
66+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
67+
ok: false,
68+
status: 404,
69+
statusText: 'Not Found',
70+
}))
71+
72+
await expect(readSource('https://example.com/missing.js'))
73+
.rejects
74+
.toThrowError('Failed to fetch https://example.com/missing.js: 404 Not Found')
75+
76+
vi.unstubAllGlobals()
77+
})
78+
})
79+
80+
describe('run', () => {
81+
const testDir = path.join(import.meta.dirname, '__fixtures__')
82+
const testFile = path.join(testDir, 'sample.ts')
83+
84+
beforeEach(async () => {
85+
await fs.mkdir(testDir, { recursive: true })
86+
await fs.writeFile(testFile, 'const x = 1')
87+
})
88+
89+
afterEach(async () => {
90+
await fs.rm(testDir, { recursive: true, force: true })
91+
})
92+
93+
it('local file', async () => {
94+
const output: string[] = []
95+
await run(['node', 'shiki', testFile], msg => output.push(msg))
96+
97+
expect(output.length).toBe(1)
98+
expect(output[0]).toContain('const')
99+
})
100+
101+
it('remote URL', async () => {
102+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
103+
ok: true,
104+
text: () => Promise.resolve('const y = 2'),
105+
}))
106+
107+
const output: string[] = []
108+
await run(['node', 'shiki', 'https://example.com/code.ts'], msg => output.push(msg))
109+
110+
expect(output.length).toBe(1)
111+
expect(output[0]).toContain('const')
112+
113+
vi.unstubAllGlobals()
114+
})
115+
116+
it('--lang option', async () => {
117+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
118+
ok: true,
119+
text: () => Promise.resolve('print("hello")'),
120+
}))
121+
122+
const output: string[] = []
123+
await run(['node', 'shiki', '--lang', 'python', 'https://example.com/code'], msg => output.push(msg))
124+
125+
expect(output.length).toBe(1)
126+
expect(output[0]).toContain('print')
127+
128+
vi.unstubAllGlobals()
129+
})
130+
})

0 commit comments

Comments
 (0)