vocs 2.0.8 → 2.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"TwoslashHover.client.d.ts","sourceRoot":"","sources":["../../../src/react/internal/TwoslashHover.client.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,KAAK,KAAK,MAAM,OAAO,CAAA;AAGnC,wBAAgB,aAAa,CAAC,KAAK,EAAE,aAAa,CAAC,KAAK,2CA+BvD;AAED,yBAAiB,aAAa,CAAC;IAC7B,KAAY,KAAK,GAAG;QAClB,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;QAC9B,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAA;QACzB,IAAI,CAAC,EAAE,OAAO,GAAG,SAAS,CAAA;QAC1B,OAAO,EAAE,KAAK,CAAC,SAAS,CAAA;KACzB,CAAA;IAED,SAAgB,eAAe;uBACK,cAAc,GAAG,IAAI;MAwBxD;CACF"}
1
+ {"version":3,"file":"TwoslashHover.client.d.ts","sourceRoot":"","sources":["../../../src/react/internal/TwoslashHover.client.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,KAAK,KAAK,MAAM,OAAO,CAAA;AAInC,wBAAgB,aAAa,CAAC,KAAK,EAAE,aAAa,CAAC,KAAK,2CAqCvD;AAED,yBAAiB,aAAa,CAAC;IAC7B,KAAY,KAAK,GAAG;QAClB,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;QAC9B,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAA;QACzB,IAAI,CAAC,EAAE,OAAO,GAAG,SAAS,CAAA;QAC1B,OAAO,EAAE,KAAK,CAAC,SAAS,CAAA;KACzB,CAAA;IAED,SAAgB,eAAe;uBACK,cAAc,GAAG,IAAI;MAwBxD;CACF"}
@@ -1,11 +1,17 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Popover } from '@base-ui/react/popover';
4
- import { useCallback } from 'react';
4
+ import { useCallback, useEffect } from 'react';
5
+ import { prewarm } from './CodeToHtml.client.js';
5
6
  export function TwoslashHover(props) {
6
7
  const { className = '', children, trigger } = props;
7
8
  const { ref } = TwoslashHover.useOverflowFade();
8
9
  const open = className?.includes('twoslash-query-persisted');
10
+ // Warm the highlighter so the code inside the popup highlights as quickly as
11
+ // possible (the popup shows a skeleton placeholder until then).
12
+ useEffect(() => {
13
+ prewarm();
14
+ }, []);
9
15
  return (_jsxs(Popover.Root, { ...(open ? { open } : {}), children: [_jsx(Popover.Trigger, { "data-v-twoslash-trigger": true, openOnHover: true, delay: 0, children: _jsx("span", { children: trigger }) }), _jsx(Popover.Portal, { children: _jsx(Popover.Positioner, { align: "start", side: "bottom", sideOffset: 4, collisionAvoidance: { side: 'none' }, children: _jsxs(Popover.Popup, { className: className, initialFocus: false, children: [_jsx(Popover.Arrow, { className: "vocs:data-[side=bottom]:top-[-8px] vocs:data-[side=left]:right-[-13px] vocs:data-[side=left]:rotate-90 vocs:data-[side=right]:left-[-13px] vocs:data-[side=right]:-rotate-90 vocs:data-[side=top]:bottom-[-8px] vocs:data-[side=top]:rotate-180", children: _jsx(ArrowSvg, {}) }), _jsx("div", { "data-v-content": true, ref: ref, children: children })] }) }) })] }));
10
16
  }
11
17
  (function (TwoslashHover) {
@@ -1 +1 @@
1
- {"version":3,"file":"TwoslashHover.client.js","sourceRoot":"","sources":["../../../src/react/internal/TwoslashHover.client.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"TwoslashHover.client.js","sourceRoot":"","sources":["../../../src/react/internal/TwoslashHover.client.tsx"],"names":[],"mappings":"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"}
@@ -63,6 +63,38 @@
63
63
  }
64
64
  }
65
65
 
66
+ [data-v-code-skeleton] [data-v-skeleton-bar] {
67
+ display: inline-block;
68
+ height: 0.72em;
69
+ vertical-align: middle;
70
+ border-radius: 0.1875rem;
71
+ background-color: var(--vocs-background-color-surfaceTint);
72
+ background-image: linear-gradient(
73
+ 90deg,
74
+ transparent 0%,
75
+ color-mix(in srgb, var(--vocs-color-gray10) 22%, transparent) 50%,
76
+ transparent 100%
77
+ );
78
+ background-repeat: no-repeat;
79
+ background-size: 200% 100%;
80
+ animation: vocs-skeleton-shimmer 1.25s ease-in-out infinite;
81
+ }
82
+
83
+ @keyframes vocs-skeleton-shimmer {
84
+ from {
85
+ background-position: 180% 0;
86
+ }
87
+ to {
88
+ background-position: -80% 0;
89
+ }
90
+ }
91
+
92
+ @media (prefers-reduced-motion: reduce) {
93
+ [data-v-code-skeleton] [data-v-skeleton-bar] {
94
+ animation: none;
95
+ }
96
+ }
97
+
66
98
  .twoslash-popup-docs {
67
99
  @apply vocs:max-h-40 vocs:overflow-y-scroll;
68
100
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "vocs",
3
3
  "type": "module",
4
- "version": "2.0.8",
4
+ "version": "2.0.11",
5
5
  "main": "./dist/index.js",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -241,6 +241,28 @@ const element = <div />`,
241
241
  highlighter.dispose()
242
242
  })
243
243
 
244
+ it('should hide committed inline cache comments when inline cache is disabled', async () => {
245
+ const highlighter = await createHighlighter({
246
+ themes: ['github-dark'],
247
+ langs: ['typescript'],
248
+ })
249
+
250
+ const html = highlighter.codeToHtml(
251
+ `// @twoslash-cache: {"v":1,"hash":"old","data":"x"}
252
+ const value = 1`,
253
+ {
254
+ lang: 'typescript',
255
+ theme: 'github-dark',
256
+ transformers: [twoslash({ explicitTrigger: false })],
257
+ },
258
+ )
259
+
260
+ expect(html).toContain('const')
261
+ expect(html).not.toContain('twoslash-cache')
262
+
263
+ highlighter.dispose()
264
+ })
265
+
244
266
  it('should typecheck virtual json files in check-only mode', async () => {
245
267
  const highlighter = await createHighlighter({
246
268
  themes: ['github-dark'],
@@ -253,16 +253,22 @@ export function twoslash(options: twoslash.Options): ShikiTransformer {
253
253
  renderer = Renderer.rich(),
254
254
  throws = true,
255
255
  twoslashOptions,
256
- typesCache = InlineCache.enabled(inlineCache)
257
- ? InlineCache.getOrCreate().typesCache
258
- : TypesCache.fs({ dir: cacheDir ? path.join(cacheDir, 'twoslash') : undefined }),
256
+ typesCache: configuredTypesCache,
259
257
  } = options
258
+ const inlineCacheStore = InlineCache.enabled(inlineCache) ? InlineCache.getOrCreate() : undefined
259
+ const typesCache =
260
+ configuredTypesCache ??
261
+ inlineCacheStore?.typesCache ??
262
+ TypesCache.fs({ dir: cacheDir ? path.join(cacheDir, 'twoslash') : undefined })
263
+ const shouldStripInlineCache = typesCache !== inlineCacheStore?.typesCache
260
264
 
261
265
  const lazyTwoslasher = (
262
266
  code: string,
263
267
  lang?: string,
264
268
  executeOptions?: Parameters<TwoslashInstance>[2],
265
269
  ) => {
270
+ code = InlineCache.stripInlineCacheComments(code)
271
+
266
272
  if (!twoslasher) {
267
273
  const tsModule = twoslashOptions?.tsModule ?? getTypeScript()
268
274
 
@@ -312,7 +318,10 @@ export function twoslash(options: twoslash.Options): ShikiTransformer {
312
318
  // normalized output). Without this, snippets containing consecutive
313
319
  // custom tag lines (`@log`/`@error`/`@warn`/`@annotate`) would never
314
320
  // hit the cache.
315
- const normalized = Renderer.normalizeCustomTagBlocks(code)
321
+ const stripped = shouldStripInlineCache
322
+ ? InlineCache.stripInlineCacheComments(code)
323
+ : code
324
+ const normalized = Renderer.normalizeCustomTagBlocks(stripped)
316
325
  return typesCache.preprocess?.(normalized, lang, executeOptions, meta) ?? normalized
317
326
  },
318
327
  }
@@ -393,6 +402,8 @@ function checkOnlyTwoslasher(options: {
393
402
  executeOptions?: Parameters<TwoslashInstance>[2],
394
403
  meta?: { __raw?: string | undefined },
395
404
  ) => {
405
+ code = InlineCache.stripInlineCacheComments(code)
406
+
396
407
  const twoslashOptions = {
397
408
  ...options.twoslashOptions,
398
409
  ...executeOptions,
@@ -36,6 +36,14 @@ describe('stripInlineCacheComments', () => {
36
36
  expect(stripInlineCacheComments(code)).toBe('export const client = 1')
37
37
  })
38
38
 
39
+ it('removes cache comments without a space after the colon', () => {
40
+ const code = [
41
+ '// @twoslash-cache:{"v":1,"hash":"abc","data":"x"}',
42
+ 'export const client = 1',
43
+ ].join('\n')
44
+ expect(stripInlineCacheComments(code)).toBe('export const client = 1')
45
+ })
46
+
39
47
  it('leaves code without cache comments untouched', () => {
40
48
  const code = 'export const client = 1\nconst a = 2'
41
49
  expect(stripInlineCacheComments(code)).toBe(code)
@@ -141,6 +149,32 @@ describe('inline types cache', () => {
141
149
  expect(cacheLines()).toHaveLength(1)
142
150
  })
143
151
 
152
+ it('hits regardless of twoslash options (portable across environments)', () => {
153
+ const code = 'const a: string = "x"'
154
+ const { file, from, to } = createMarkdown(code)
155
+ const data = { nodes: [], code: 'compiled-output' }
156
+
157
+ // Seed with one set of options (e.g. machine A, with `paths`).
158
+ {
159
+ const { typesCache, patcher } = createInlineTypesCache()
160
+ const meta = { sourceMap: { path: file, from, to } } as never
161
+ const options = { compilerOptions: { paths: { foo: ['/abs/a'] } } }
162
+ typesCache.preprocess?.(code, 'ts', options as never, meta)
163
+ typesCache.write(code, data as never, 'ts', options as never, meta)
164
+ patcher.patch(file)
165
+ }
166
+
167
+ // Read with different options (e.g. machine B / Vercel, without `paths`).
168
+ {
169
+ const { typesCache } = createInlineTypesCache()
170
+ const body = readBody(file, from)
171
+ const meta = { sourceMap: { path: file, from, to } } as never
172
+ const otherOptions = { compilerOptions: {} }
173
+ typesCache.preprocess?.(body, 'ts', otherOptions as never, meta)
174
+ expect(typesCache.read(body, 'ts', otherOptions as never, meta)).toEqual(data)
175
+ }
176
+ })
177
+
144
178
  it('invalidates the cache when the hash no longer matches', () => {
145
179
  const code = 'const a: string = "x"'
146
180
  const { file, from, to } = createMarkdown(code)
@@ -165,6 +199,38 @@ describe('inline types cache', () => {
165
199
  }
166
200
  })
167
201
 
202
+ it('invalidates cached results that would render the cache comment', () => {
203
+ const code = 'const a: string = "x"'
204
+ const { file, from, to } = createMarkdown(code)
205
+
206
+ {
207
+ const { typesCache, patcher } = createInlineTypesCache()
208
+ const meta = { sourceMap: { path: file, from, to } } as never
209
+ typesCache.preprocess?.(code, 'ts', {}, meta)
210
+ typesCache.write(
211
+ code,
212
+ {
213
+ nodes: [],
214
+ code: `// @twoslash-cache: {"v":1,"hash":"old","data":"x"}\n${code}`,
215
+ } as never,
216
+ 'ts',
217
+ {},
218
+ meta,
219
+ )
220
+ patcher.patch(file)
221
+ }
222
+
223
+ {
224
+ const { typesCache } = createInlineTypesCache()
225
+ const body = readBody(file, from)
226
+ const meta = { sourceMap: { path: file, from, to } } as never
227
+
228
+ const preprocessed = typesCache.preprocess?.(body, 'ts', {}, meta)
229
+ expect(preprocessed).toBe(code)
230
+ expect(typesCache.read(body, 'ts', {}, meta)).toBeNull()
231
+ }
232
+ })
233
+
168
234
  it('ignoreCache skips reading existing cache', () => {
169
235
  const code = 'const a: string = "x"'
170
236
  const { file, from, to } = createMarkdown(code)
@@ -1,7 +1,6 @@
1
1
  import * as crypto from 'node:crypto'
2
2
  import type { TwoslashShikiReturn, TwoslashTypesCache } from '@shikijs/twoslash'
3
3
  import LZString from 'lz-string'
4
- import { getObjectHash, type TwoslashExecuteOptions } from 'twoslash/core'
5
4
  import { FilePatcher } from './file-patcher.js'
6
5
 
7
6
  /**
@@ -73,10 +72,25 @@ type CachePayload = {
73
72
  data: string
74
73
  }
75
74
 
75
+ /**
76
+ * Cache payload format version. Kept at `1` even though the cache key changed
77
+ * from `optionsHash:lang:code` to `lang:code` (see `cacheHash`): stale
78
+ * payloads from the old key naturally fail the hash check on `preprocess` and
79
+ * are transparently re-seeded on the next write, so a version bump (which
80
+ * would force a one-shot invalidation of every committed cache comment) is
81
+ * unnecessary.
82
+ */
83
+ const CACHE_VERSION = 1
84
+
76
85
  const CODE_INLINE_CACHE_KEY = '@twoslash-cache'
77
- const CODE_INLINE_CACHE_REGEX = new RegExp(`// ${CODE_INLINE_CACHE_KEY}: (.*)(?:\n|$)`, 'g')
86
+ const CODE_INLINE_CACHE_REGEX = new RegExp(
87
+ `^// ${CODE_INLINE_CACHE_KEY}:[ \\t]*([^\\r\\n]*)(?:\\r?\\n|$)`,
88
+ 'gm',
89
+ )
78
90
  /** Matches a cache comment anchored to the start of a code block body. */
79
- const CODE_INLINE_CACHE_LINE_REGEX = new RegExp(`^// ${CODE_INLINE_CACHE_KEY}: .*(?:\n|$)`)
91
+ const CODE_INLINE_CACHE_LINE_REGEX = new RegExp(
92
+ `^// ${CODE_INLINE_CACHE_KEY}:[ \\t]*[^\\r\\n]*(?:\\r?\\n|$)`,
93
+ )
80
94
 
81
95
  /**
82
96
  * Remove all `// @twoslash-cache: ...` comments from a code string.
@@ -97,32 +111,25 @@ export function createInlineTypesCache(
97
111
  const { remove, ignoreCache } = options
98
112
  const patcher = new FilePatcher()
99
113
 
100
- const optionsHashCache = new WeakMap<TwoslashExecuteOptions, string>()
101
- function getOptionsHash(options: TwoslashExecuteOptions = {}): string {
102
- let hash = optionsHashCache.get(options)
103
- if (!hash) {
104
- hash = getObjectHash(options)
105
- optionsHashCache.set(options, hash)
106
- }
107
- return hash
108
- }
109
-
110
- function cacheHash(code: string, lang?: string, options?: TwoslashExecuteOptions): string {
114
+ // Key the cache on the (already fully-composed) snippet code and language
115
+ // only — deliberately NOT on the twoslash options. The whole point of the
116
+ // inline cache is to travel with the repo across machines and CI, but
117
+ // `twoslashOptions` frequently contain environment-specific or volatile
118
+ // values (absolute paths, config that branches on whether built `.d.ts`
119
+ // files exist, etc). Hashing them in makes the committed cache miss on any
120
+ // machine other than the one that seeded it. This matches the filesystem
121
+ // types cache, which also keys on code alone.
122
+ function cacheHash(code: string, lang?: string): string {
111
123
  return crypto
112
124
  .createHash('sha256')
113
- .update(`${getOptionsHash(options)}:${lang ?? ''}:${code}`)
125
+ .update(`${lang ?? ''}:${code}`)
114
126
  .digest('hex')
115
127
  }
116
128
 
117
- function stringifyCachePayload(
118
- data: TwoslashShikiReturn,
119
- code: string,
120
- lang?: string,
121
- options?: TwoslashExecuteOptions,
122
- ): string {
129
+ function stringifyCachePayload(data: TwoslashShikiReturn, code: string, lang?: string): string {
123
130
  const payload: CachePayload = {
124
- v: 1,
125
- hash: cacheHash(code, lang, options),
131
+ v: CACHE_VERSION,
132
+ hash: cacheHash(code, lang),
126
133
  data: LZString.compressToBase64(JSON.stringify(data)),
127
134
  }
128
135
  return JSON.stringify(payload)
@@ -135,12 +142,20 @@ export function createInlineTypesCache(
135
142
  if (!cache) return null
136
143
  try {
137
144
  const payload = JSON.parse(cache) as CachePayload
138
- if (payload.v === 1) {
145
+ if (payload.v === CACHE_VERSION) {
139
146
  return {
140
147
  payload,
141
148
  twoslash: () => {
142
149
  try {
143
- return JSON.parse(LZString.decompressFromBase64(payload.data))
150
+ const twoslash = JSON.parse(
151
+ LZString.decompressFromBase64(payload.data),
152
+ ) as TwoslashShikiReturn
153
+ if (
154
+ typeof twoslash.code === 'string' &&
155
+ stripInlineCacheComments(twoslash.code) !== twoslash.code
156
+ )
157
+ return null
158
+ return twoslash
144
159
  } catch {
145
160
  return null
146
161
  }
@@ -200,7 +215,7 @@ export function createInlineTypesCache(
200
215
  }
201
216
 
202
217
  const typesCache: TwoslashTypesCache = {
203
- preprocess(code, lang, options, meta) {
218
+ preprocess(code, lang, _options, meta) {
204
219
  if (!meta) return
205
220
 
206
221
  let rawCache = ''
@@ -218,7 +233,7 @@ export function createInlineTypesCache(
218
233
  const shouldLoadCache = !ignoreCache && !remove
219
234
  if (shouldLoadCache) {
220
235
  const cache = resolveCachePayload(cacheString)
221
- if (cache?.payload.hash === cacheHash(code, lang, options)) {
236
+ if (cache?.payload.hash === cacheHash(code, lang)) {
222
237
  const twoslash = cache.twoslash()
223
238
  if (twoslash) meta.__cache = twoslash
224
239
  }
@@ -234,13 +249,13 @@ export function createInlineTypesCache(
234
249
  read(_code, _lang, _options, meta) {
235
250
  return meta?.__cache ?? null
236
251
  },
237
- write(code, data, lang, options, meta) {
252
+ write(code, data, lang, _options, meta) {
238
253
  if (remove) {
239
254
  meta?.__patch?.('')
240
255
  return
241
256
  }
242
257
  const twoslashShiki = simplifyTwoslashReturn(data)
243
- const cacheStr = `// ${CODE_INLINE_CACHE_KEY}: ${stringifyCachePayload(twoslashShiki, code, lang, options)}`
258
+ const cacheStr = `// ${CODE_INLINE_CACHE_KEY}: ${stringifyCachePayload(twoslashShiki, code, lang)}`
244
259
  meta?.__patch?.(cacheStr)
245
260
  },
246
261
  }
@@ -0,0 +1,176 @@
1
+ 'use client'
2
+
3
+ import { config } from 'virtual:vocs/config'
4
+ import { useEffect, useState } from 'react'
5
+
6
+ /**
7
+ * Lazily highlights code on the client.
8
+ *
9
+ * Twoslash hover popups embed a code snippet (the resolved type) for every
10
+ * hoverable token. Highlighting these with Shiki on the server forces RSC to
11
+ * render and serialize a fully highlighted snippet for every token across every
12
+ * page at build time — which dominates build memory and time even though the
13
+ * popups are only ever seen on hover.
14
+ *
15
+ * Instead we render the snippet as plain text in the RSC payload and highlight
16
+ * it on the client. Because the popup only mounts when it opens, the Shiki
17
+ * bundle is dynamically imported and the snippet highlighted on demand.
18
+ */
19
+ export function CodeToHtml(props: CodeToHtml.Props) {
20
+ const { code, lang } = props
21
+ // Initialize from the cache so a snippet that was already highlighted (e.g.
22
+ // pre-highlighted by the popover before opening, or shown previously) renders
23
+ // highlighted immediately without a plain-text flash.
24
+ const [html, setHtml] = useState<string | null>(() => getCached(code, lang) ?? null)
25
+
26
+ useEffect(() => {
27
+ if (html) return
28
+ let cancelled = false
29
+ highlight(code, lang)
30
+ .then((result) => {
31
+ if (!cancelled) setHtml(result)
32
+ })
33
+ .catch(() => {})
34
+ return () => {
35
+ cancelled = true
36
+ }
37
+ }, [code, lang, html])
38
+
39
+ if (html)
40
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: highlighted by Shiki
41
+ return <div dangerouslySetInnerHTML={{ __html: html }} />
42
+
43
+ // While highlighting, render a skeleton that mirrors the snippet's line
44
+ // structure so the popup is shown immediately without a layout shift.
45
+ return <Skeleton code={code} />
46
+ }
47
+
48
+ function Skeleton(props: { code: string }) {
49
+ const lines = props.code.split('\n')
50
+ return (
51
+ <div data-v-code-skeleton aria-hidden="true">
52
+ {/* `shiki` class mirrors the real highlighted markup so shared styles
53
+ (e.g. code padding) apply identically and there is no layout shift. */}
54
+ <pre className="shiki" data-v-overflow-fade>
55
+ <code>
56
+ {lines.map((line, index) => {
57
+ const indent = line.length - line.trimStart().length
58
+ const length = line.trim().length
59
+ return (
60
+ // biome-ignore lint/suspicious/noArrayIndexKey: static, never reordered
61
+ <span className="line" key={index}>
62
+ {length > 0 && (
63
+ <span
64
+ data-v-skeleton-bar
65
+ style={{
66
+ marginLeft: `${indent}ch`,
67
+ width: `${Math.min(length, 48)}ch`,
68
+ }}
69
+ />
70
+ )}
71
+ </span>
72
+ )
73
+ })}
74
+ </code>
75
+ <div data-v-overflow-sentinel />
76
+ </pre>
77
+ </div>
78
+ )
79
+ }
80
+
81
+ export namespace CodeToHtml {
82
+ export type Props = {
83
+ code: string
84
+ lang: string
85
+ }
86
+ }
87
+
88
+ let highlighterPromise:
89
+ | Promise<{
90
+ codeToHtml: (code: string, lang: string) => Promise<string>
91
+ }>
92
+ | undefined
93
+
94
+ function getHighlighter() {
95
+ if (!highlighterPromise)
96
+ highlighterPromise = (async () => {
97
+ const { bundledLanguages, createHighlighter, hastToHtml } = await import('shiki/bundle/web')
98
+ const { codeHighlight } = config
99
+ const { langAlias = {}, themes } = codeHighlight
100
+ // Note: `langAlias` is intentionally not passed to the highlighter.
101
+ // Passing a custom `langAlias` registers languages under their alias name
102
+ // when lazily loaded (e.g. `loadLanguage('ts')` registers as `ts` instead
103
+ // of `typescript`), which then makes `codeToHast({ lang })` fail to resolve
104
+ // the grammar. We resolve aliases to their base language ourselves below.
105
+ const highlighter = await createHighlighter({
106
+ themes: Object.values(themes) as never,
107
+ langs: [],
108
+ })
109
+ return {
110
+ async codeToHtml(code: string, lang: string) {
111
+ const base = langAlias[lang] ?? lang
112
+ const resolvedLang = base in bundledLanguages ? base : 'txt'
113
+ if (!highlighter.getLoadedLanguages().includes(resolvedLang))
114
+ await highlighter.loadLanguage(resolvedLang as never)
115
+ const hast = highlighter.codeToHast(code, {
116
+ defaultColor: 'light-dark()',
117
+ lang: resolvedLang,
118
+ rootStyle: false,
119
+ meta: { 'data-v-overflow-fade': true },
120
+ themes,
121
+ transformers: [transformerShrinkIndent()],
122
+ })
123
+ const pre = hast.children[0]
124
+ if (pre && pre.type === 'element' && pre.tagName === 'pre')
125
+ pre.children.push({
126
+ type: 'element',
127
+ tagName: 'div',
128
+ properties: { 'data-v-overflow-sentinel': true },
129
+ children: [],
130
+ })
131
+ return hastToHtml(hast)
132
+ },
133
+ }
134
+ })()
135
+ return highlighterPromise
136
+ }
137
+
138
+ const cache = new Map<string, string>()
139
+
140
+ function cacheKey(code: string, lang: string) {
141
+ return `${lang}\n${code}`
142
+ }
143
+
144
+ /** Returns the highlighted HTML for a snippet if it has already been computed. */
145
+ export function getCached(code: string, lang: string) {
146
+ return cache.get(cacheKey(code, lang))
147
+ }
148
+
149
+ /** Highlights a snippet (memoized), loading the Shiki bundle on first use. */
150
+ export async function highlight(code: string, lang: string) {
151
+ const key = cacheKey(code, lang)
152
+ const cached = cache.get(key)
153
+ if (cached !== undefined) return cached
154
+ const highlighter = await getHighlighter()
155
+ const html = await highlighter.codeToHtml(code, lang)
156
+ cache.set(key, html)
157
+ return html
158
+ }
159
+
160
+ /** Eagerly loads the Shiki bundle so the first real highlight is fast. */
161
+ export function prewarm() {
162
+ void getHighlighter()
163
+ }
164
+
165
+ function transformerShrinkIndent() {
166
+ return {
167
+ name: 'indent',
168
+ span(hast: { children: { type: string; value?: string }[] }) {
169
+ const child = hast.children[0]
170
+ if (!child) return
171
+ if (child.type !== 'text') return
172
+ if (!child.value) return
173
+ hast.children[0] = { type: 'text', value: child.value.replace(/\s\s/g, ' ') }
174
+ },
175
+ }
176
+ }
@@ -1,74 +1 @@
1
- import { config } from 'virtual:vocs/config'
2
- import { langs as virtualLangs } from 'virtual:vocs/langs'
3
- import {
4
- bundledLanguages,
5
- createHighlighter,
6
- hastToHtml,
7
- makeSingletonHighlighter,
8
- type ShikiTransformer,
9
- } from 'shiki/bundle/web'
10
-
11
- const getHighlighter = makeSingletonHighlighter(createHighlighter)
12
-
13
- export async function CodeToHtml(props: CodeToHtml.Props) {
14
- const { code, lang } = props
15
- const { codeHighlight } = config
16
- const { langAlias = {}, themes } = codeHighlight
17
-
18
- const highlighter = await getHighlighter({
19
- themes: import.meta.env.DEV ? ['none'] : (Object.values(themes) as never),
20
- langs: import.meta.env.DEV
21
- ? ['txt']
22
- : ([...Object.keys(bundledLanguages), ...virtualLangs] as never),
23
- langAlias,
24
- })
25
-
26
- const loadedLangs = highlighter.getLoadedLanguages()
27
- const resolvedLang = loadedLangs.includes(lang) ? lang : 'txt'
28
-
29
- const hast = highlighter.codeToHast(code, {
30
- defaultColor: 'light-dark()',
31
- lang: import.meta.env.DEV ? 'txt' : resolvedLang,
32
- rootStyle: false,
33
- meta: {
34
- 'data-v-overflow-fade': true,
35
- },
36
- ...(import.meta.env.DEV ? { theme: 'none' } : { themes }),
37
- transformers: [transformerShrinkIndent()],
38
- })
39
-
40
- // Add overflow sentinel
41
- const pre = hast.children[0]
42
- if (pre && pre.type === 'element' && pre.tagName === 'pre')
43
- pre.children.push({
44
- type: 'element',
45
- tagName: 'div',
46
- properties: { 'data-v-overflow-sentinel': true },
47
- children: [],
48
- })
49
-
50
- const html = hastToHtml(hast)
51
-
52
- // biome-ignore lint/security/noDangerouslySetInnerHtml: _
53
- return <div dangerouslySetInnerHTML={{ __html: html }} />
54
- }
55
-
56
- export namespace CodeToHtml {
57
- export type Props = {
58
- code: string
59
- lang: string
60
- }
61
- }
62
-
63
- function transformerShrinkIndent(): ShikiTransformer {
64
- return {
65
- name: 'indent',
66
- span(hast) {
67
- const child = hast.children[0]
68
- if (!child) return
69
- if (child.type !== 'text') return
70
- if (!child.value) return
71
- hast.children[0] = { type: 'text', value: child.value.replace(/\s\s/g, ' ') }
72
- },
73
- }
74
- }
1
+ export { CodeToHtml } from './CodeToHtml.client.js'
@@ -2,7 +2,8 @@
2
2
 
3
3
  import { Popover } from '@base-ui/react/popover'
4
4
  import type * as React from 'react'
5
- import { useCallback } from 'react'
5
+ import { useCallback, useEffect } from 'react'
6
+ import { prewarm } from './CodeToHtml.client.js'
6
7
 
7
8
  export function TwoslashHover(props: TwoslashHover.Props) {
8
9
  const { className = '', children, trigger } = props
@@ -11,6 +12,12 @@ export function TwoslashHover(props: TwoslashHover.Props) {
11
12
 
12
13
  const open = className?.includes('twoslash-query-persisted')
13
14
 
15
+ // Warm the highlighter so the code inside the popup highlights as quickly as
16
+ // possible (the popup shows a skeleton placeholder until then).
17
+ useEffect(() => {
18
+ prewarm()
19
+ }, [])
20
+
14
21
  return (
15
22
  <Popover.Root {...(open ? { open } : {})}>
16
23
  <Popover.Trigger data-v-twoslash-trigger openOnHover delay={0}>