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.
- package/dist/internal/shiki-transformers.d.ts.map +1 -1
- package/dist/internal/shiki-transformers.js +12 -4
- package/dist/internal/shiki-transformers.js.map +1 -1
- package/dist/internal/twoslash/inline-cache.d.ts.map +1 -1
- package/dist/internal/twoslash/inline-cache.js +34 -23
- package/dist/internal/twoslash/inline-cache.js.map +1 -1
- package/dist/react/internal/CodeToHtml.client.d.ts +27 -0
- package/dist/react/internal/CodeToHtml.client.d.ts.map +1 -0
- package/dist/react/internal/CodeToHtml.client.js +138 -0
- package/dist/react/internal/CodeToHtml.client.js.map +1 -0
- package/dist/react/internal/CodeToHtml.d.ts +1 -7
- package/dist/react/internal/CodeToHtml.d.ts.map +1 -1
- package/dist/react/internal/CodeToHtml.js +1 -56
- package/dist/react/internal/CodeToHtml.js.map +1 -1
- package/dist/react/internal/TwoslashHover.client.d.ts.map +1 -1
- package/dist/react/internal/TwoslashHover.client.js +7 -1
- package/dist/react/internal/TwoslashHover.client.js.map +1 -1
- package/dist/styles/twoslash.css +32 -0
- package/package.json +1 -1
- package/src/internal/shiki-transformers.test.ts +22 -0
- package/src/internal/shiki-transformers.ts +15 -4
- package/src/internal/twoslash/inline-cache.test.ts +66 -0
- package/src/internal/twoslash/inline-cache.ts +44 -29
- package/src/react/internal/CodeToHtml.client.tsx +176 -0
- package/src/react/internal/CodeToHtml.tsx +1 -74
- package/src/react/internal/TwoslashHover.client.tsx +8 -1
- package/src/styles/twoslash.css +32 -0
|
@@ -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;
|
|
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":"AAAA,YAAY,CAAA;;AAEZ,OAAO,EAAE,OAAO,EAAE,MAAM,wBAAwB,CAAA;AAEhD,OAAO,EAAE,WAAW,EAAE,MAAM,OAAO,CAAA;
|
|
1
|
+
{"version":3,"file":"TwoslashHover.client.js","sourceRoot":"","sources":["../../../src/react/internal/TwoslashHover.client.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAA;;AAEZ,OAAO,EAAE,OAAO,EAAE,MAAM,wBAAwB,CAAA;AAEhD,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAC9C,OAAO,EAAE,OAAO,EAAE,MAAM,wBAAwB,CAAA;AAEhD,MAAM,UAAU,aAAa,CAAC,KAA0B;IACtD,MAAM,EAAE,SAAS,GAAG,EAAE,EAAE,QAAQ,EAAE,OAAO,EAAE,GAAG,KAAK,CAAA;IAEnD,MAAM,EAAE,GAAG,EAAE,GAAG,aAAa,CAAC,eAAe,EAAE,CAAA;IAE/C,MAAM,IAAI,GAAG,SAAS,EAAE,QAAQ,CAAC,0BAA0B,CAAC,CAAA;IAE5D,6EAA6E;IAC7E,gEAAgE;IAChE,SAAS,CAAC,GAAG,EAAE;QACb,OAAO,EAAE,CAAA;IACX,CAAC,EAAE,EAAE,CAAC,CAAA;IAEN,OAAO,CACL,MAAC,OAAO,CAAC,IAAI,OAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,aACtC,KAAC,OAAO,CAAC,OAAO,qCAAyB,WAAW,QAAC,KAAK,EAAE,CAAC,YAC3D,yBAAO,OAAO,GAAQ,GACN,EAClB,KAAC,OAAO,CAAC,MAAM,cACb,KAAC,OAAO,CAAC,UAAU,IACjB,KAAK,EAAC,OAAO,EACb,IAAI,EAAC,QAAQ,EACb,UAAU,EAAE,CAAC,EACb,kBAAkB,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,YAEpC,MAAC,OAAO,CAAC,KAAK,IAAC,SAAS,EAAE,SAAS,EAAE,YAAY,EAAE,KAAK,aACtD,KAAC,OAAO,CAAC,KAAK,IAAC,SAAS,EAAC,iPAAiP,YACxQ,KAAC,QAAQ,KAAG,GACE,EAChB,sCAAoB,GAAG,EAAE,GAAG,YACzB,QAAQ,GACL,IACQ,GACG,GACN,IACJ,CAChB,CAAA;AACH,CAAC;AAED,WAAiB,aAAa;IAQ5B,SAAgB,eAAe;QAC7B,MAAM,GAAG,GAAG,WAAW,CAAC,CAAC,OAA8B,EAAE,EAAE;YACzD,IAAI,CAAC,OAAO;gBAAE,OAAM;YACpB,MAAM,QAAQ,GAAG,OAAO,CAAC,gBAAgB,CAAC,wBAAwB,CAAC,CAAA;YACnE,KAAK,MAAM,EAAE,IAAI,QAAQ,EAAE,CAAC;gBAC1B,IAAI,CAAC,CAAC,EAAE,YAAY,WAAW,CAAC;oBAAE,SAAQ;gBAC1C,IAAI,EAAE,CAAC,YAAY,IAAI,EAAE,CAAC,YAAY;oBAAE,SAAQ;gBAEhD,MAAM,QAAQ,GAAG,EAAE,CAAC,aAAa,CAAC,4BAA4B,CAAC,CAAA;gBAC/D,IAAI,CAAC,QAAQ;oBAAE,SAAQ;gBAEvB,EAAE,CAAC,OAAO,CAAC,WAAW,CAAC,GAAG,MAAM,CAAA;gBAEhC,MAAM,QAAQ,GAAG,IAAI,oBAAoB,CACvC,CAAC,CAAC,KAAK,CAAC,EAAE,EAAE;oBACV,IAAI,KAAK,EAAE,cAAc;wBAAE,OAAO,EAAE,CAAC,OAAO,CAAC,WAAW,CAAC,CAAA;;wBACpD,EAAE,CAAC,OAAO,CAAC,WAAW,CAAC,GAAG,MAAM,CAAA;gBACvC,CAAC,EACD,EAAE,IAAI,EAAE,EAAE,EAAE,SAAS,EAAE,GAAG,EAAE,CAC7B,CAAA;gBACD,QAAQ,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAA;YAC5B,CAAC;QACH,CAAC,EAAE,EAAE,CAAC,CAAA;QAEN,OAAO,EAAE,GAAG,EAAE,CAAA;IAChB,CAAC;IAzBe,6BAAe,kBAyB9B,CAAA;AACH,CAAC,EAlCgB,aAAa,KAAb,aAAa,QAkC7B;AAED,SAAS,QAAQ,CAAC,KAAkC;IAClD,OAAO,CACL,eAAK,KAAK,EAAC,IAAI,EAAC,MAAM,EAAC,IAAI,EAAC,OAAO,EAAC,WAAW,EAAC,IAAI,EAAC,MAAM,KAAK,KAAK,aACnE,oCAAoB,EACpB,eACE,SAAS,EAAC,6CAA6C,EACvD,CAAC,EAAC,iMAAiM,GACnM,EACF,eACE,SAAS,EAAC,yCAAyC,EACnD,CAAC,EAAC,sTAAsT,GACxT,IACE,CACP,CAAA;AACH,CAAC"}
|
package/dist/styles/twoslash.css
CHANGED
|
@@ -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
|
@@ -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
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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(`${
|
|
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:
|
|
125
|
-
hash: cacheHash(code, lang
|
|
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 ===
|
|
145
|
+
if (payload.v === CACHE_VERSION) {
|
|
139
146
|
return {
|
|
140
147
|
payload,
|
|
141
148
|
twoslash: () => {
|
|
142
149
|
try {
|
|
143
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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}>
|