vocs 2.0.6 → 2.0.8
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/mdx.d.ts +17 -0
- package/dist/internal/mdx.d.ts.map +1 -1
- package/dist/internal/mdx.js +56 -1
- package/dist/internal/mdx.js.map +1 -1
- package/dist/internal/shiki-transformers.d.ts +17 -0
- package/dist/internal/shiki-transformers.d.ts.map +1 -1
- package/dist/internal/shiki-transformers.js +30 -4
- package/dist/internal/shiki-transformers.js.map +1 -1
- package/dist/internal/twoslash/file-patcher.d.ts +24 -0
- package/dist/internal/twoslash/file-patcher.d.ts.map +1 -0
- package/dist/internal/twoslash/file-patcher.js +58 -0
- package/dist/internal/twoslash/file-patcher.js.map +1 -0
- package/dist/internal/twoslash/index.d.ts +1 -0
- package/dist/internal/twoslash/index.d.ts.map +1 -1
- package/dist/internal/twoslash/index.js +1 -0
- package/dist/internal/twoslash/index.js.map +1 -1
- package/dist/internal/twoslash/inline-cache.d.ts +71 -0
- package/dist/internal/twoslash/inline-cache.d.ts.map +1 -0
- package/dist/internal/twoslash/inline-cache.js +233 -0
- package/dist/internal/twoslash/inline-cache.js.map +1 -0
- package/dist/internal/vite-plugins.d.ts.map +1 -1
- package/dist/internal/vite-plugins.js +4 -0
- package/dist/internal/vite-plugins.js.map +1 -1
- package/package.json +3 -1
- package/src/internal/mdx.ts +60 -1
- package/src/internal/shiki-transformers.ts +40 -4
- package/src/internal/twoslash/file-patcher.ts +60 -0
- package/src/internal/twoslash/index.ts +1 -0
- package/src/internal/twoslash/inline-cache.test.ts +212 -0
- package/src/internal/twoslash/inline-cache.ts +313 -0
- package/src/internal/vite-plugins.ts +4 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import * as crypto from 'node:crypto'
|
|
2
|
+
import type { TwoslashShikiReturn, TwoslashTypesCache } from '@shikijs/twoslash'
|
|
3
|
+
import LZString from 'lz-string'
|
|
4
|
+
import { getObjectHash, type TwoslashExecuteOptions } from 'twoslash/core'
|
|
5
|
+
import { FilePatcher } from './file-patcher.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Inline twoslash cache.
|
|
9
|
+
*
|
|
10
|
+
* Persists the serialized twoslash result directly into the markdown source as a
|
|
11
|
+
* `// @twoslash-cache: ...` comment inside the fenced code block. On the next
|
|
12
|
+
* build the comment is read, validated against a hash of the source, and used
|
|
13
|
+
* directly — skipping the TypeScript compiler entirely.
|
|
14
|
+
*
|
|
15
|
+
* Unlike the filesystem types cache (which lives in a separate, usually
|
|
16
|
+
* gitignored directory), the inline cache travels with the source files, so it
|
|
17
|
+
* stays warm across fresh clones and CI runs.
|
|
18
|
+
*
|
|
19
|
+
* Ported from `@shikijs/vitepress-twoslash`'s inline cache, adapted to vocs's
|
|
20
|
+
* MDX/remark pipeline:
|
|
21
|
+
* - source-map injection happens via a remark plugin (see `remarkInlineCache` in
|
|
22
|
+
* `mdx.ts`) instead of a Vite `load` hook + markdown-it.
|
|
23
|
+
* - the queued patches are flushed by the `vocs:mdx` Vite plugin after each file
|
|
24
|
+
* is transformed.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Source position of a fenced code block's body within its markdown file. */
|
|
28
|
+
export type SourceMap = {
|
|
29
|
+
path: string
|
|
30
|
+
from: number
|
|
31
|
+
to: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
declare module '@shikijs/types' {
|
|
35
|
+
interface ShikiTransformerContextMeta {
|
|
36
|
+
sourceMap?: SourceMap | null
|
|
37
|
+
__cache?: TwoslashShikiReturn
|
|
38
|
+
__patch?: (newCache: string) => void
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Tag used for the ephemeral source-map comment. This comment is injected into
|
|
44
|
+
* the in-memory code value during compilation and stripped again before
|
|
45
|
+
* twoslash/shiki run — it is never written to disk.
|
|
46
|
+
*/
|
|
47
|
+
const SOURCE_MAP_KEY = '@vocs-twoslash-source'
|
|
48
|
+
const SOURCE_MAP_REGEX = new RegExp(`// ${SOURCE_MAP_KEY}:(.*)(?:\n|$)`)
|
|
49
|
+
|
|
50
|
+
export function injectSourceMapComment(value: string, sourceMap: SourceMap): string {
|
|
51
|
+
return `// ${SOURCE_MAP_KEY}:${JSON.stringify(sourceMap)}\n${value}`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function extractSourceMapComment(code: string): {
|
|
55
|
+
code: string
|
|
56
|
+
sourceMap: SourceMap | null
|
|
57
|
+
} {
|
|
58
|
+
let sourceMap: SourceMap | null = null
|
|
59
|
+
try {
|
|
60
|
+
code = code.replace(SOURCE_MAP_REGEX, (_, p1: string) => {
|
|
61
|
+
sourceMap = JSON.parse(p1) as SourceMap
|
|
62
|
+
return ''
|
|
63
|
+
})
|
|
64
|
+
} catch {
|
|
65
|
+
// ignore malformed source map
|
|
66
|
+
}
|
|
67
|
+
return { code, sourceMap }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type CachePayload = {
|
|
71
|
+
v: number
|
|
72
|
+
hash: string
|
|
73
|
+
data: string
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const CODE_INLINE_CACHE_KEY = '@twoslash-cache'
|
|
77
|
+
const CODE_INLINE_CACHE_REGEX = new RegExp(`// ${CODE_INLINE_CACHE_KEY}: (.*)(?:\n|$)`, 'g')
|
|
78
|
+
/** 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|$)`)
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Remove all `// @twoslash-cache: ...` comments from a code string.
|
|
83
|
+
*
|
|
84
|
+
* Used when a virtual file's content is injected into another twoslash block
|
|
85
|
+
* (via `[!include]` or `import` resolution). Without stripping, the included
|
|
86
|
+
* file's own cache comment would be inlined ahead of the host block's code and
|
|
87
|
+
* picked up as the host's cache, causing a permanent hash mismatch (and a
|
|
88
|
+
* spurious cache comment to be re-appended on every build).
|
|
89
|
+
*/
|
|
90
|
+
export function stripInlineCacheComments(code: string): string {
|
|
91
|
+
return code.replace(CODE_INLINE_CACHE_REGEX, '')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function createInlineTypesCache(
|
|
95
|
+
options: { remove?: boolean | undefined; ignoreCache?: boolean | undefined } = {},
|
|
96
|
+
): { typesCache: TwoslashTypesCache; patcher: FilePatcher } {
|
|
97
|
+
const { remove, ignoreCache } = options
|
|
98
|
+
const patcher = new FilePatcher()
|
|
99
|
+
|
|
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 {
|
|
111
|
+
return crypto
|
|
112
|
+
.createHash('sha256')
|
|
113
|
+
.update(`${getOptionsHash(options)}:${lang ?? ''}:${code}`)
|
|
114
|
+
.digest('hex')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function stringifyCachePayload(
|
|
118
|
+
data: TwoslashShikiReturn,
|
|
119
|
+
code: string,
|
|
120
|
+
lang?: string,
|
|
121
|
+
options?: TwoslashExecuteOptions,
|
|
122
|
+
): string {
|
|
123
|
+
const payload: CachePayload = {
|
|
124
|
+
v: 1,
|
|
125
|
+
hash: cacheHash(code, lang, options),
|
|
126
|
+
data: LZString.compressToBase64(JSON.stringify(data)),
|
|
127
|
+
}
|
|
128
|
+
return JSON.stringify(payload)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function resolveCachePayload(cache: string): {
|
|
132
|
+
payload: CachePayload
|
|
133
|
+
twoslash: () => TwoslashShikiReturn | null
|
|
134
|
+
} | null {
|
|
135
|
+
if (!cache) return null
|
|
136
|
+
try {
|
|
137
|
+
const payload = JSON.parse(cache) as CachePayload
|
|
138
|
+
if (payload.v === 1) {
|
|
139
|
+
return {
|
|
140
|
+
payload,
|
|
141
|
+
twoslash: () => {
|
|
142
|
+
try {
|
|
143
|
+
return JSON.parse(LZString.decompressFromBase64(payload.data))
|
|
144
|
+
} catch {
|
|
145
|
+
return null
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
// ignore malformed payload
|
|
152
|
+
}
|
|
153
|
+
return null
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function resolveSourcePatcher(
|
|
157
|
+
source: SourceMap,
|
|
158
|
+
search?: string,
|
|
159
|
+
): ((newCache: string) => void) | undefined {
|
|
160
|
+
const file = patcher.load(source.path)
|
|
161
|
+
if (file === null) return undefined
|
|
162
|
+
|
|
163
|
+
const range: { from: number; to?: number } = { from: source.from }
|
|
164
|
+
let linebreak = true
|
|
165
|
+
|
|
166
|
+
let located = false
|
|
167
|
+
if (search) {
|
|
168
|
+
const cachePos = file.content.indexOf(search, source.from)
|
|
169
|
+
if (cachePos !== -1 && cachePos < source.to) {
|
|
170
|
+
range.from = cachePos
|
|
171
|
+
range.to = cachePos + search.length
|
|
172
|
+
linebreak = search.endsWith('\n')
|
|
173
|
+
located = true
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Fallback: if the existing cache comment wasn't located via `search` (e.g.
|
|
178
|
+
// a concurrent build environment already wrote one, or the in-memory code
|
|
179
|
+
// was stale), detect a cache comment at the block body start and replace it
|
|
180
|
+
// in place rather than appending a duplicate.
|
|
181
|
+
if (!located) {
|
|
182
|
+
const body = file.content.slice(source.from, source.to)
|
|
183
|
+
const match = body.match(CODE_INLINE_CACHE_LINE_REGEX)
|
|
184
|
+
if (match) {
|
|
185
|
+
range.from = source.from
|
|
186
|
+
range.to = source.from + match[0].length
|
|
187
|
+
linebreak = match[0].endsWith('\n')
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const patchKey = FilePatcher.key(range.from, range.to)
|
|
192
|
+
return (newCache: string) => {
|
|
193
|
+
if (newCache === '') {
|
|
194
|
+
// remove the existing cache comment if one was found
|
|
195
|
+
if (range.to !== undefined) file.patches.set(patchKey, '')
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
file.patches.set(patchKey, newCache + (linebreak ? '\n' : ''))
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const typesCache: TwoslashTypesCache = {
|
|
203
|
+
preprocess(code, lang, options, meta) {
|
|
204
|
+
if (!meta) return
|
|
205
|
+
|
|
206
|
+
let rawCache = ''
|
|
207
|
+
let cacheString = ''
|
|
208
|
+
|
|
209
|
+
code = code.replaceAll(CODE_INLINE_CACHE_REGEX, (full, p1: string) => {
|
|
210
|
+
// keep only the first occurrence (duplicates may appear via @include)
|
|
211
|
+
if (!rawCache.length) {
|
|
212
|
+
cacheString = p1
|
|
213
|
+
rawCache = full
|
|
214
|
+
}
|
|
215
|
+
return ''
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
const shouldLoadCache = !ignoreCache && !remove
|
|
219
|
+
if (shouldLoadCache) {
|
|
220
|
+
const cache = resolveCachePayload(cacheString)
|
|
221
|
+
if (cache?.payload.hash === cacheHash(code, lang, options)) {
|
|
222
|
+
const twoslash = cache.twoslash()
|
|
223
|
+
if (twoslash) meta.__cache = twoslash
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (meta.sourceMap) {
|
|
228
|
+
const patch = resolveSourcePatcher(meta.sourceMap, rawCache)
|
|
229
|
+
if (patch) meta.__patch = patch
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return code
|
|
233
|
+
},
|
|
234
|
+
read(_code, _lang, _options, meta) {
|
|
235
|
+
return meta?.__cache ?? null
|
|
236
|
+
},
|
|
237
|
+
write(code, data, lang, options, meta) {
|
|
238
|
+
if (remove) {
|
|
239
|
+
meta?.__patch?.('')
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
const twoslashShiki = simplifyTwoslashReturn(data)
|
|
243
|
+
const cacheStr = `// ${CODE_INLINE_CACHE_KEY}: ${stringifyCachePayload(twoslashShiki, code, lang, options)}`
|
|
244
|
+
meta?.__patch?.(cacheStr)
|
|
245
|
+
},
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return { typesCache, patcher }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Keep only the fields shiki needs when serializing a twoslash result. */
|
|
252
|
+
function simplifyTwoslashReturn(ret: TwoslashShikiReturn): TwoslashShikiReturn {
|
|
253
|
+
return {
|
|
254
|
+
nodes: ret.nodes,
|
|
255
|
+
code: ret.code,
|
|
256
|
+
...(ret.meta?.extension !== undefined ? { meta: { extension: ret.meta.extension } } : {}),
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function isEnabledEnv(key: string): boolean | null {
|
|
261
|
+
const val = process.env?.[key]?.toLowerCase()
|
|
262
|
+
if (val)
|
|
263
|
+
return (
|
|
264
|
+
(
|
|
265
|
+
{
|
|
266
|
+
true: true,
|
|
267
|
+
false: false,
|
|
268
|
+
1: true,
|
|
269
|
+
0: false,
|
|
270
|
+
yes: true,
|
|
271
|
+
no: false,
|
|
272
|
+
y: true,
|
|
273
|
+
n: false,
|
|
274
|
+
} as Record<string, boolean>
|
|
275
|
+
)[val] ?? null
|
|
276
|
+
)
|
|
277
|
+
return null
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Resolve whether the inline cache is enabled. The `TWOSLASH_INLINE_CACHE`
|
|
282
|
+
* environment variable takes precedence over the config flag.
|
|
283
|
+
*/
|
|
284
|
+
export function enabled(configFlag?: boolean | undefined): boolean {
|
|
285
|
+
const env = isEnabledEnv('TWOSLASH_INLINE_CACHE')
|
|
286
|
+
if (env !== null) return env
|
|
287
|
+
return Boolean(configFlag)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function envOptions(): { remove: boolean; ignoreCache: boolean } {
|
|
291
|
+
return {
|
|
292
|
+
remove: isEnabledEnv('TWOSLASH_INLINE_CACHE_REMOVE') === true,
|
|
293
|
+
ignoreCache: isEnabledEnv('TWOSLASH_INLINE_CACHE_IGNORE') === true,
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
let current: { typesCache: TwoslashTypesCache; patcher: FilePatcher } | undefined
|
|
298
|
+
|
|
299
|
+
/** Get (or lazily create) the process-wide inline cache instance. */
|
|
300
|
+
export function getOrCreate(): { typesCache: TwoslashTypesCache; patcher: FilePatcher } {
|
|
301
|
+
if (!current) current = createInlineTypesCache(envOptions())
|
|
302
|
+
return current
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Flush any queued write-backs for `path` to disk. No-op if not initialized. */
|
|
306
|
+
export function flush(path: string): void {
|
|
307
|
+
current?.patcher.patch(path)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Reset the singleton (primarily for tests). */
|
|
311
|
+
export function reset(): void {
|
|
312
|
+
current = undefined
|
|
313
|
+
}
|
|
@@ -15,6 +15,7 @@ import * as Mdx from './mdx.js'
|
|
|
15
15
|
import { SearchDocuments, SearchIndex } from './search.js'
|
|
16
16
|
import * as ShikiTransformers from './shiki-transformers.js'
|
|
17
17
|
import * as TaskRunner from './task-runner.js'
|
|
18
|
+
import * as InlineCache from './twoslash/inline-cache.js'
|
|
18
19
|
|
|
19
20
|
export { default as icons } from 'unplugin-icons/vite'
|
|
20
21
|
export { default as arraybuffer } from 'vite-plugin-arraybuffer'
|
|
@@ -282,6 +283,9 @@ export function mdx(config: Config.Config): PluginOption {
|
|
|
282
283
|
|
|
283
284
|
try {
|
|
284
285
|
const result = normalizeTransformResult(await plugin.transform.call(this, code, id))
|
|
286
|
+
// Flush any inline twoslash cache write-backs queued during this
|
|
287
|
+
// file's transform (no-op when the inline cache is disabled).
|
|
288
|
+
InlineCache.flush(id)
|
|
285
289
|
if (result) cache.set(id, { code, result })
|
|
286
290
|
return result
|
|
287
291
|
} catch (error) {
|