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.
Files changed (31) hide show
  1. package/dist/internal/mdx.d.ts +17 -0
  2. package/dist/internal/mdx.d.ts.map +1 -1
  3. package/dist/internal/mdx.js +56 -1
  4. package/dist/internal/mdx.js.map +1 -1
  5. package/dist/internal/shiki-transformers.d.ts +17 -0
  6. package/dist/internal/shiki-transformers.d.ts.map +1 -1
  7. package/dist/internal/shiki-transformers.js +30 -4
  8. package/dist/internal/shiki-transformers.js.map +1 -1
  9. package/dist/internal/twoslash/file-patcher.d.ts +24 -0
  10. package/dist/internal/twoslash/file-patcher.d.ts.map +1 -0
  11. package/dist/internal/twoslash/file-patcher.js +58 -0
  12. package/dist/internal/twoslash/file-patcher.js.map +1 -0
  13. package/dist/internal/twoslash/index.d.ts +1 -0
  14. package/dist/internal/twoslash/index.d.ts.map +1 -1
  15. package/dist/internal/twoslash/index.js +1 -0
  16. package/dist/internal/twoslash/index.js.map +1 -1
  17. package/dist/internal/twoslash/inline-cache.d.ts +71 -0
  18. package/dist/internal/twoslash/inline-cache.d.ts.map +1 -0
  19. package/dist/internal/twoslash/inline-cache.js +233 -0
  20. package/dist/internal/twoslash/inline-cache.js.map +1 -0
  21. package/dist/internal/vite-plugins.d.ts.map +1 -1
  22. package/dist/internal/vite-plugins.js +4 -0
  23. package/dist/internal/vite-plugins.js.map +1 -1
  24. package/package.json +3 -1
  25. package/src/internal/mdx.ts +60 -1
  26. package/src/internal/shiki-transformers.ts +40 -4
  27. package/src/internal/twoslash/file-patcher.ts +60 -0
  28. package/src/internal/twoslash/index.ts +1 -0
  29. package/src/internal/twoslash/inline-cache.test.ts +212 -0
  30. package/src/internal/twoslash/inline-cache.ts +313 -0
  31. 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) {