diff --git a/packages/twoslash-unocss/README.md b/packages/twoslash-unocss/README.md new file mode 100644 index 000000000..75c91c6ba --- /dev/null +++ b/packages/twoslash-unocss/README.md @@ -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 diff --git a/packages/twoslash-unocss/package.json b/packages/twoslash-unocss/package.json new file mode 100644 index 000000000..cbd1468dc --- /dev/null +++ b/packages/twoslash-unocss/package.json @@ -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" + } +} diff --git a/packages/twoslash-unocss/src/index.ts b/packages/twoslash-unocss/src/index.ts new file mode 100644 index 000000000..eb229c465 --- /dev/null +++ b/packages/twoslash-unocss/src/index.ts @@ -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 + +/** + * 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 | undefined + + return async (code: string, _extension?: string): Promise => { + 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 = { + 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 } + } +} diff --git a/packages/twoslash-unocss/test/index.test.ts b/packages/twoslash-unocss/test/index.test.ts new file mode 100644 index 000000000..c3b446a90 --- /dev/null +++ b/packages/twoslash-unocss/test/index.test.ts @@ -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) + }) +}) diff --git a/packages/twoslash-unocss/tsdown.config.ts b/packages/twoslash-unocss/tsdown.config.ts new file mode 100644 index 000000000..408990c1e --- /dev/null +++ b/packages/twoslash-unocss/tsdown.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + dts: true, + clean: true, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1035cb3fb..80419d75b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,6 +98,12 @@ catalogs: specifier: ^4.68.1 version: 4.68.1 docs: + '@unocss/core': + specifier: ^66.6.0 + version: 66.6.0 + '@unocss/preset-uno': + specifier: ^66.6.0 + version: 66.6.0 '@unocss/reset': specifier: ^66.6.0 version: 66.6.0 @@ -203,6 +209,9 @@ catalogs: twoslash: specifier: ^0.3.6 version: 0.3.6 + twoslash-protocol: + specifier: ^0.3.6 + version: 0.3.6 twoslash-vue: specifier: ^0.3.6 version: 0.3.6 @@ -854,6 +863,19 @@ importers: specifier: ^5.9.3 version: 5.9.3 + packages/twoslash-unocss: + dependencies: + twoslash-protocol: + specifier: catalog:integrations + version: 0.3.6 + devDependencies: + '@unocss/core': + specifier: catalog:docs + version: 66.6.0 + '@unocss/preset-uno': + specifier: catalog:docs + version: 66.6.0 + packages/types: dependencies: '@shikijs/vscode-textmate': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8cd021c7d..870e253b0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -46,6 +46,8 @@ catalogs: vue-tsc: ^3.2.5 wrangler: ^4.68.1 docs: + '@unocss/core': ^66.6.0 + '@unocss/preset-uno': ^66.6.0 '@unocss/reset': ^66.6.0 '@vueuse/core': ^14.2.1 floating-vue: ^5.2.2 @@ -85,6 +87,7 @@ catalogs: remark-parse: ^11.0.0 remark-rehype: ^11.1.2 twoslash: ^0.3.6 + twoslash-protocol: ^0.3.6 twoslash-vue: ^0.3.6 unified: ^11.0.5 unist-util-visit: ^5.1.0 diff --git a/test/exports/@shikijs/twoslash-unocss.yaml b/test/exports/@shikijs/twoslash-unocss.yaml new file mode 100644 index 000000000..79d27d5e9 --- /dev/null +++ b/test/exports/@shikijs/twoslash-unocss.yaml @@ -0,0 +1,2 @@ +.: + createTwoslasher: function diff --git a/tsconfig.json b/tsconfig.json index f24124a05..6509d433f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,37 +1,92 @@ { "compilerOptions": { "target": "esnext", - "lib": ["esnext", "DOM"], + "lib": [ + "esnext", + "DOM" + ], "rootDir": ".", "module": "esnext", "moduleResolution": "Bundler", "paths": { - "@shikijs/core/types": ["./packages/core/src/types.ts"], - "@shikijs/core/wasm-inlined": ["./packages/core/src/wasm.ts"], - "@shikijs/core": ["./packages/core/src/index.ts"], - "@shikijs/primitive": ["./packages/primitive/src/index.ts"], - "@shikijs/transformers": ["./packages/transformers/src/index.ts"], - "@shikijs/twoslash/core": ["./packages/twoslash/src/core.ts"], - "@shikijs/twoslash": ["./packages/twoslash/src/index.ts"], - "@shikijs/vitepress-twoslash/cache-fs": ["./packages/vitepress-twoslash/src/cache-fs.ts"], - "@shikijs/vitepress-twoslash/client": ["./packages/vitepress-twoslash/src/client.ts"], - "@shikijs/vitepress-twoslash": ["./packages/vitepress-twoslash/src/index.ts"], - "@shikijs/markdown-it": ["./packages/markdown-it/src/index.ts"], - "@shikijs/markdown-exit": ["./packages/markdown-exit/src/index.ts"], - "@shikijs/types": ["./packages/types/src/index.ts"], - "@shikijs/engine-javascript": ["./packages/engine-javascript/src/index.ts"], - "@shikijs/engine-oniguruma/wasm-inline": ["./packages/engine-oniguruma/src/wasm-inline.ts"], - "@shikijs/engine-oniguruma": ["./packages/engine-oniguruma/src/index.ts"], - "shiki/core": ["./packages/shiki/src/core.ts"], - "shiki/wasm": ["./packages/shiki/src/wasm.ts"], - "shiki/langs": ["./packages/shiki/src/langs.ts"], - "shiki/themes": ["./packages/shiki/src/themes.ts"], - "shiki/bundle/web": ["./packages/shiki/src/bundle-web.ts"], - "shiki/bundle/full": ["./packages/shiki/src/bundle-full.ts"], - "shiki": ["./packages/shiki/src/index.ts"] + "@shikijs/core/types": [ + "./packages/core/src/types.ts" + ], + "@shikijs/core/wasm-inlined": [ + "./packages/core/src/wasm.ts" + ], + "@shikijs/core": [ + "./packages/core/src/index.ts" + ], + "@shikijs/primitive": [ + "./packages/primitive/src/index.ts" + ], + "@shikijs/transformers": [ + "./packages/transformers/src/index.ts" + ], + "@shikijs/twoslash-unocss": [ + "./packages/twoslash-unocss/src/index.ts" + ], + "@shikijs/twoslash/core": [ + "./packages/twoslash/src/core.ts" + ], + "@shikijs/twoslash": [ + "./packages/twoslash/src/index.ts" + ], + "@shikijs/vitepress-twoslash/cache-fs": [ + "./packages/vitepress-twoslash/src/cache-fs.ts" + ], + "@shikijs/vitepress-twoslash/client": [ + "./packages/vitepress-twoslash/src/client.ts" + ], + "@shikijs/vitepress-twoslash": [ + "./packages/vitepress-twoslash/src/index.ts" + ], + "@shikijs/markdown-it": [ + "./packages/markdown-it/src/index.ts" + ], + "@shikijs/markdown-exit": [ + "./packages/markdown-exit/src/index.ts" + ], + "@shikijs/types": [ + "./packages/types/src/index.ts" + ], + "@shikijs/engine-javascript": [ + "./packages/engine-javascript/src/index.ts" + ], + "@shikijs/engine-oniguruma/wasm-inline": [ + "./packages/engine-oniguruma/src/wasm-inline.ts" + ], + "@shikijs/engine-oniguruma": [ + "./packages/engine-oniguruma/src/index.ts" + ], + "shiki/core": [ + "./packages/shiki/src/core.ts" + ], + "shiki/wasm": [ + "./packages/shiki/src/wasm.ts" + ], + "shiki/langs": [ + "./packages/shiki/src/langs.ts" + ], + "shiki/themes": [ + "./packages/shiki/src/themes.ts" + ], + "shiki/bundle/web": [ + "./packages/shiki/src/bundle-web.ts" + ], + "shiki/bundle/full": [ + "./packages/shiki/src/bundle-full.ts" + ], + "shiki": [ + "./packages/shiki/src/index.ts" + ] }, "resolveJsonModule": true, - "types": ["vite/client", "node"], + "types": [ + "vite/client", + "node" + ], "allowJs": true, "strict": true, "strictNullChecks": true,