Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions packages/twoslash-unocss/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# @shikijs/twoslash-unocss

UnoCSS-backed [twoslash](https://twoslash.netlify.app/) runner for [Shiki](https://shiki.style).

Hover over UnoCSS utility classes in your code blocks and see the generated CSS — just like how twoslash shows TypeScript types on hover.

## Install

```bash
npm i @shikijs/twoslash-unocss @shikijs/twoslash @unocss/core @unocss/preset-uno
```

## Usage

```ts
import { createTwoslasher } from '@shikijs/twoslash-unocss'
import { createTransformerFactory, rendererRich } from '@shikijs/twoslash/core'
import presetUno from '@unocss/preset-uno'
import { codeToHtml } from 'shiki'

const twoslasher = createTwoslasher({
config: {
presets: [presetUno()],
},
})

// Pre-resolve UnoCSS classes, then feed results to Shiki
const code = 'text-red-500 flex mt-4'
const resolved = await twoslasher(code, 'html')

const html = await codeToHtml(resolved.code, {
lang: 'html',
theme: 'vitesse-dark',
transformers: [
createTransformerFactory(
() => resolved,
rendererRich(),
)({
langs: ['html', 'css', 'vue'],
}),
],
})
```

> **Note:** Because UnoCSS operates asynchronously, the twoslash
> runner is async. Call it before `codeToHtml` and pass the pre-resolved
> result into `createTransformerFactory`.

## How It Works

For every whitespace-separated token in the code block the runner
passes it to UnoCSS's `createGenerator`. If the token is a valid
utility class, the generated CSS is surfaced as a hover popup using
the standard twoslash protocol.

## License

MIT
50 changes: 50 additions & 0 deletions packages/twoslash-unocss/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{
"name": "@shikijs/twoslash-unocss",
"type": "module",
"version": "0.1.0",
"description": "UnoCSS-backed twoslash runner for Shiki",
"author": "",
"license": "MIT",
"homepage": "https://github.com/shikijs/shiki#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/shikijs/shiki.git",
"directory": "packages/twoslash-unocss"
},
"bugs": "https://github.com/shikijs/shiki/issues",
"keywords": [
"shiki",
"twoslash",
"unocss"
],
"sideEffects": false,
"exports": {
".": "./dist/index.mjs",
"./*": "./dist/*"
},
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"files": [
"dist"
],
"engines": {
"node": ">=20"
},
"scripts": {
"build": "tsdown",
"dev": "tsdown --watch",
"prepublishOnly": "nr build",
"test": "vitest"
},
"peerDependencies": {
"@unocss/core": ">=0.58.0"
},
"dependencies": {
"twoslash-protocol": "catalog:integrations"
},
"devDependencies": {
"@unocss/core": "catalog:docs",
"@unocss/preset-uno": "catalog:docs"
}
}
116 changes: 116 additions & 0 deletions packages/twoslash-unocss/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import type { UserConfig } from '@unocss/core'
import type { NodeHover, NodeWithoutPosition, TwoslashGenericResult } from 'twoslash-protocol'
import { createGenerator } from '@unocss/core'
import { createPositionConverter, resolveNodePositions } from 'twoslash-protocol'

export interface CreateTwoslashUnoCSSOptions {
/**
* UnoCSS config used for generating styles.
*
* Provide at least one preset (e.g. `presetUno()`) for utility
* resolution to work.
*
* @example
* ```ts
* import presetUno from '@unocss/preset-uno'
*
* const twoslasher = createTwoslasher({
* config: {
* presets: [presetUno()],
* },
* })
* ```
*/
config?: UserConfig

/**
* Pattern used to split code into tokens that may be UnoCSS utilities.
*
* Defaults to splitting on whitespace and common quote characters.
*
* @default /[\s'"`]+/
*/
splitPattern?: RegExp
}

export type TwoslashUnoCSSFunction = (code: string, extension?: string) => Promise<TwoslashGenericResult>

/**
* Create a twoslash-like runner backed by UnoCSS.
*
* For every utility-class token found in the input code, the runner
* generates the corresponding CSS via `@unocss/core` and returns a
* hover node that Shiki can render as an inline popup.
*
* Since UnoCSS is async, the returned function is async as well.
* You can use the result directly or feed it into
* `createTransformerFactory` from `@shikijs/twoslash/core` by
* pre-resolving before highlighting.
*/
export function createTwoslasher(options: CreateTwoslashUnoCSSOptions = {}): TwoslashUnoCSSFunction {
const {
config = {},
splitPattern = /[\s'"`]+/,
} = options

let unoPromise: ReturnType<typeof createGenerator> | undefined

return async (code: string, _extension?: string): Promise<TwoslashGenericResult> => {
if (!unoPromise)
unoPromise = createGenerator(config)
const uno = await unoPromise
const pc = createPositionConverter(code)

// Collect whitespace-separated tokens and their positions
const tokens: { value: string, start: number }[] = []
let offset = 0

for (const part of code.split(splitPattern)) {
if (!part.length)
continue

const idx = code.indexOf(part, offset)
if (idx !== -1) {
tokens.push({ value: part, start: idx })
offset = idx + part.length
}
}

if (!tokens.length) {
return { code, nodes: [] }
}

// Generate CSS for all tokens at once to determine which are valid
const { matched } = await uno.generate(
tokens.map(t => t.value).join(' '),
{ preflights: false },
)

// Build hover nodes for each matched token
const raws: NodeWithoutPosition[] = []

for (const token of tokens) {
if (!matched.has(token.value))
continue

const result = await uno.generate(token.value, { preflights: false })
const css = result.css.trim()
if (!css)
continue

const node: Omit<NodeHover, 'line' | 'character'> = {
type: 'hover',
text: css,
target: token.value,
start: token.start,
length: token.value.length,
}
raws.push(node)
}

const nodes = resolveNodePositions(raws, code)
.filter(n => n.line < pc.lines.length)

return { code, nodes }
}
}
113 changes: 113 additions & 0 deletions packages/twoslash-unocss/test/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import type { NodeHover } from 'twoslash-protocol'
import presetUno from '@unocss/preset-uno'
import { describe, expect, it } from 'vitest'
import { createTwoslasher } from '../src/index'

describe('twoslash-unocss', () => {
it('should resolve a single utility class', async () => {
const twoslasher = createTwoslasher({
config: { presets: [presetUno()] },
})

const result = await twoslasher('text-red-500', 'html')

expect(result.code).toBe('text-red-500')
expect(result.nodes.length).toBeGreaterThan(0)
expect(result.nodes[0].type).toBe('hover')

const node = result.nodes[0] as NodeHover
expect(node.target).toBe('text-red-500')
expect(node.text).toContain('color')
expect(node.start).toBe(0)
expect(node.length).toBe('text-red-500'.length)
})

it('should resolve multiple utility classes', async () => {
const twoslasher = createTwoslasher({
config: { presets: [presetUno()] },
})

const code = 'flex mt-4 text-sm'
const result = await twoslasher(code, 'html')

expect(result.nodes.length).toBe(3)

const targets = result.nodes
.filter((n): n is NodeHover => n.type === 'hover')
.map(n => n.target)

expect(targets).toContain('flex')
expect(targets).toContain('mt-4')
expect(targets).toContain('text-sm')
})

it('should skip unknown classes', async () => {
const twoslasher = createTwoslasher({
config: { presets: [presetUno()] },
})

const code = 'not-a-real-class flex'
const result = await twoslasher(code, 'html')

const targets = result.nodes
.filter((n): n is NodeHover => n.type === 'hover')
.map(n => n.target)

expect(targets).not.toContain('not-a-real-class')
expect(targets).toContain('flex')
})

it('should preserve correct positions', async () => {
const twoslasher = createTwoslasher({
config: { presets: [presetUno()] },
})

const code = 'flex mt-4'
const result = await twoslasher(code, 'html')

const flexNode = result.nodes.find(
(n): n is NodeHover => n.type === 'hover' && n.target === 'flex',
)
const mtNode = result.nodes.find(
(n): n is NodeHover => n.type === 'hover' && n.target === 'mt-4',
)

expect(flexNode).toBeDefined()
expect(flexNode!.start).toBe(0)
expect(flexNode!.length).toBe(4)

expect(mtNode).toBeDefined()
expect(mtNode!.start).toBe(5)
expect(mtNode!.length).toBe(4)
})

it('should handle empty input', async () => {
const twoslasher = createTwoslasher({
config: { presets: [presetUno()] },
})

const result = await twoslasher('', 'html')

expect(result.code).toBe('')
expect(result.nodes).toEqual([])
})

it('should handle multiline input', async () => {
const twoslasher = createTwoslasher({
config: { presets: [presetUno()] },
})

const code = 'flex\nmt-4'
const result = await twoslasher(code, 'html')

expect(result.nodes.length).toBe(2)

const mtNode = result.nodes.find(
(n): n is NodeHover => n.type === 'hover' && n.target === 'mt-4',
)

expect(mtNode).toBeDefined()
// mt-4 starts on line 1 (0-indexed)
expect(mtNode!.line).toBe(1)
})
})
8 changes: 8 additions & 0 deletions packages/twoslash-unocss/tsdown.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineConfig } from 'tsdown'

export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
clean: true,
})
22 changes: 22 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading