vocs 2.0.6 → 2.0.7

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 +52 -0
  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 +23 -2
  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 +61 -0
  18. package/dist/internal/twoslash/inline-cache.d.ts.map +1 -0
  19. package/dist/internal/twoslash/inline-cache.js +204 -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 +56 -0
  26. package/src/internal/shiki-transformers.ts +33 -2
  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 +158 -0
  30. package/src/internal/twoslash/inline-cache.ts +282 -0
  31. package/src/internal/vite-plugins.ts +4 -0
@@ -0,0 +1,60 @@
1
+ import * as node_fs from 'node:fs'
2
+ import MagicString from 'magic-string'
3
+
4
+ /**
5
+ * Accumulates character-range patches per file and flushes them all at once.
6
+ *
7
+ * Used by the twoslash inline cache to write `// @twoslash-cache: ...` comments
8
+ * back into the original markdown source after a code block has been processed.
9
+ *
10
+ * Ported from `@shikijs/vitepress-twoslash`'s `FilePatcher`.
11
+ */
12
+ export class FilePatcher {
13
+ private files = new Map<string, { content: string; patches: Map<string, string> } | null>()
14
+
15
+ /** Build a patch key from a character range. Omitting `to` denotes an insertion. */
16
+ static key(from: number, to?: number): string {
17
+ return `${from}${to ? `:${to}` : ''}`
18
+ }
19
+
20
+ /**
21
+ * Load a file's contents (cached for the lifetime of the patcher, until
22
+ * flushed via {@link patch}). Returns `null` if the file does not exist.
23
+ */
24
+ load(path: string): { content: string; patches: Map<string, string> } | null {
25
+ let file = this.files.get(path)
26
+ if (file === undefined) {
27
+ if (node_fs.existsSync(path)) {
28
+ const content = node_fs.readFileSync(path, { encoding: 'utf-8' })
29
+ file = { content, patches: new Map() }
30
+ } else {
31
+ file = null
32
+ }
33
+ this.files.set(path, file)
34
+ }
35
+ return file
36
+ }
37
+
38
+ /** Apply all queued patches for `path` and write the result back to disk. */
39
+ patch(path: string): void {
40
+ const file = this.files.get(path)
41
+ if (!file) return
42
+
43
+ if (file.patches.size) {
44
+ const s = new MagicString(file.content)
45
+
46
+ for (const [key, value] of file.patches) {
47
+ const [from, to] = key.split(':').map((x) => (x !== '' ? Number(x) : undefined))
48
+ if (from === undefined) continue
49
+
50
+ if (to !== undefined) s.update(from, to, value)
51
+ else s.appendRight(from, value)
52
+ }
53
+
54
+ const content = s.toString()
55
+ if (content !== file.content) node_fs.writeFileSync(path, content, { encoding: 'utf-8' })
56
+ }
57
+
58
+ this.files.delete(path)
59
+ }
60
+ }
@@ -1,3 +1,4 @@
1
1
  export * from './experimental_rust.js'
2
+ export * as InlineCache from './inline-cache.js'
2
3
  export * as Renderer from './renderer.js'
3
4
  export * as TypesCache from './types-cache.js'
@@ -0,0 +1,158 @@
1
+ import * as fs from 'node:fs'
2
+ import * as os from 'node:os'
3
+ import * as path from 'node:path'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+ import {
6
+ createInlineTypesCache,
7
+ extractSourceMapComment,
8
+ injectSourceMapComment,
9
+ } from './inline-cache.js'
10
+
11
+ describe('source map codec', () => {
12
+ it('round-trips a source map through inject/extract', () => {
13
+ const sourceMap = { path: '/docs/a.md', from: 15, to: 42 }
14
+ const injected = injectSourceMapComment('const a = 1', sourceMap)
15
+ expect(injected.startsWith('// @vocs-twoslash-source:')).toBe(true)
16
+
17
+ const result = extractSourceMapComment(injected)
18
+ expect(result.code).toBe('const a = 1')
19
+ expect(result.sourceMap).toEqual(sourceMap)
20
+ })
21
+
22
+ it('returns null source map when none present', () => {
23
+ const result = extractSourceMapComment('const a = 1')
24
+ expect(result.code).toBe('const a = 1')
25
+ expect(result.sourceMap).toBeNull()
26
+ })
27
+ })
28
+
29
+ describe('inline types cache', () => {
30
+ const tmpFiles: string[] = []
31
+
32
+ afterEach(() => {
33
+ for (const file of tmpFiles.splice(0)) fs.rmSync(file, { force: true })
34
+ })
35
+
36
+ function createMarkdown(body: string) {
37
+ const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'vocs-inline-cache-')), 'page.md')
38
+ const md = `\`\`\`ts twoslash\n${body}\n\`\`\`\n`
39
+ fs.writeFileSync(file, md, 'utf-8')
40
+ tmpFiles.push(file)
41
+ // body starts right after the first newline (the opening fence line).
42
+ const from = md.indexOf('\n') + 1
43
+ return { file, md, from, to: md.length }
44
+ }
45
+
46
+ // Pull the on-disk fence body the way shiki would see it (everything between
47
+ // the opening fence line and the closing fence).
48
+ function readBody(file: string, from: number) {
49
+ const content = fs.readFileSync(file, 'utf-8')
50
+ return content.slice(from, content.lastIndexOf('\n```'))
51
+ }
52
+
53
+ it('writes a cache comment back to the source and reads it on the next pass', () => {
54
+ const code = 'const a: string = "x"'
55
+ const { file, from, to } = createMarkdown(code)
56
+
57
+ const data = { nodes: [], code: 'compiled-output' }
58
+
59
+ // --- Pass 1: cache miss -> twoslash runs -> write back ---
60
+ {
61
+ const { typesCache, patcher } = createInlineTypesCache()
62
+ const meta = { sourceMap: { path: file, from, to } } as never
63
+
64
+ const preprocessed = typesCache.preprocess?.(code, 'ts', {}, meta)
65
+ expect(preprocessed).toBe(code)
66
+ expect(typesCache.read(code, 'ts', {}, meta)).toBeNull()
67
+
68
+ typesCache.write(code, data as never, 'ts', {}, meta)
69
+ patcher.patch(file)
70
+ }
71
+
72
+ const written = fs.readFileSync(file, 'utf-8')
73
+ expect(written).toContain('// @twoslash-cache:')
74
+
75
+ // --- Pass 2: cache hit -> twoslash skipped ---
76
+ {
77
+ const { typesCache } = createInlineTypesCache()
78
+ const body = readBody(file, from)
79
+ const meta = { sourceMap: { path: file, from, to } } as never
80
+
81
+ const preprocessed = typesCache.preprocess?.(body, 'ts', {}, meta)
82
+ // the cache comment is stripped before twoslash would run
83
+ expect(preprocessed).toBe(code)
84
+
85
+ const cached = typesCache.read(body, 'ts', {}, meta)
86
+ expect(cached).toEqual(data)
87
+ }
88
+ })
89
+
90
+ it('invalidates the cache when the hash no longer matches', () => {
91
+ const code = 'const a: string = "x"'
92
+ const { file, from, to } = createMarkdown(code)
93
+
94
+ // Seed a cache entry.
95
+ {
96
+ const { typesCache, patcher } = createInlineTypesCache()
97
+ const meta = { sourceMap: { path: file, from, to } } as never
98
+ typesCache.preprocess?.(code, 'ts', {}, meta)
99
+ typesCache.write(code, { nodes: [], code: 'x' } as never, 'ts', {}, meta)
100
+ patcher.patch(file)
101
+ }
102
+
103
+ // Read with different code -> hash mismatch -> miss.
104
+ {
105
+ const { typesCache } = createInlineTypesCache()
106
+ const body = readBody(file, from)
107
+ const meta = { sourceMap: { path: file, from, to } } as never
108
+ // The body still has the old cache line, but we validate against new code.
109
+ typesCache.preprocess?.(`${body}\nconst b = 2`, 'ts', {}, meta)
110
+ expect(typesCache.read(body, 'ts', {}, meta)).toBeNull()
111
+ }
112
+ })
113
+
114
+ it('ignoreCache skips reading existing cache', () => {
115
+ const code = 'const a: string = "x"'
116
+ const { file, from, to } = createMarkdown(code)
117
+
118
+ {
119
+ const { typesCache, patcher } = createInlineTypesCache()
120
+ const meta = { sourceMap: { path: file, from, to } } as never
121
+ typesCache.preprocess?.(code, 'ts', {}, meta)
122
+ typesCache.write(code, { nodes: [], code: 'x' } as never, 'ts', {}, meta)
123
+ patcher.patch(file)
124
+ }
125
+
126
+ {
127
+ const { typesCache } = createInlineTypesCache({ ignoreCache: true })
128
+ const body = readBody(file, from)
129
+ const meta = { sourceMap: { path: file, from, to } } as never
130
+ typesCache.preprocess?.(body, 'ts', {}, meta)
131
+ expect(typesCache.read(body, 'ts', {}, meta)).toBeNull()
132
+ }
133
+ })
134
+
135
+ it('remove mode strips the cache comment from the source', () => {
136
+ const code = 'const a: string = "x"'
137
+ const { file, from, to } = createMarkdown(code)
138
+
139
+ {
140
+ const { typesCache, patcher } = createInlineTypesCache()
141
+ const meta = { sourceMap: { path: file, from, to } } as never
142
+ typesCache.preprocess?.(code, 'ts', {}, meta)
143
+ typesCache.write(code, { nodes: [], code: 'x' } as never, 'ts', {}, meta)
144
+ patcher.patch(file)
145
+ }
146
+ expect(fs.readFileSync(file, 'utf-8')).toContain('// @twoslash-cache:')
147
+
148
+ {
149
+ const { typesCache, patcher } = createInlineTypesCache({ remove: true })
150
+ const body = readBody(file, from)
151
+ const meta = { sourceMap: { path: file, from, to } } as never
152
+ typesCache.preprocess?.(body, 'ts', {}, meta)
153
+ typesCache.write(body, { nodes: [], code: 'x' } as never, 'ts', {}, meta)
154
+ patcher.patch(file)
155
+ }
156
+ expect(fs.readFileSync(file, 'utf-8')).not.toContain('// @twoslash-cache:')
157
+ })
158
+ })
@@ -0,0 +1,282 @@
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
+
79
+ export function createInlineTypesCache(
80
+ options: { remove?: boolean | undefined; ignoreCache?: boolean | undefined } = {},
81
+ ): { typesCache: TwoslashTypesCache; patcher: FilePatcher } {
82
+ const { remove, ignoreCache } = options
83
+ const patcher = new FilePatcher()
84
+
85
+ const optionsHashCache = new WeakMap<TwoslashExecuteOptions, string>()
86
+ function getOptionsHash(options: TwoslashExecuteOptions = {}): string {
87
+ let hash = optionsHashCache.get(options)
88
+ if (!hash) {
89
+ hash = getObjectHash(options)
90
+ optionsHashCache.set(options, hash)
91
+ }
92
+ return hash
93
+ }
94
+
95
+ function cacheHash(code: string, lang?: string, options?: TwoslashExecuteOptions): string {
96
+ return crypto
97
+ .createHash('sha256')
98
+ .update(`${getOptionsHash(options)}:${lang ?? ''}:${code}`)
99
+ .digest('hex')
100
+ }
101
+
102
+ function stringifyCachePayload(
103
+ data: TwoslashShikiReturn,
104
+ code: string,
105
+ lang?: string,
106
+ options?: TwoslashExecuteOptions,
107
+ ): string {
108
+ const payload: CachePayload = {
109
+ v: 1,
110
+ hash: cacheHash(code, lang, options),
111
+ data: LZString.compressToBase64(JSON.stringify(data)),
112
+ }
113
+ return JSON.stringify(payload)
114
+ }
115
+
116
+ function resolveCachePayload(cache: string): {
117
+ payload: CachePayload
118
+ twoslash: () => TwoslashShikiReturn | null
119
+ } | null {
120
+ if (!cache) return null
121
+ try {
122
+ const payload = JSON.parse(cache) as CachePayload
123
+ if (payload.v === 1) {
124
+ return {
125
+ payload,
126
+ twoslash: () => {
127
+ try {
128
+ return JSON.parse(LZString.decompressFromBase64(payload.data))
129
+ } catch {
130
+ return null
131
+ }
132
+ },
133
+ }
134
+ }
135
+ } catch {
136
+ // ignore malformed payload
137
+ }
138
+ return null
139
+ }
140
+
141
+ function resolveSourcePatcher(
142
+ source: SourceMap,
143
+ search?: string,
144
+ ): ((newCache: string) => void) | undefined {
145
+ const file = patcher.load(source.path)
146
+ if (file === null) return undefined
147
+
148
+ const range: { from: number; to?: number } = { from: source.from }
149
+ let linebreak = true
150
+
151
+ if (search) {
152
+ const cachePos = file.content.indexOf(search, source.from)
153
+ if (cachePos !== -1 && cachePos < source.to) {
154
+ range.from = cachePos
155
+ range.to = cachePos + search.length
156
+ linebreak = search.endsWith('\n')
157
+ }
158
+ }
159
+
160
+ const patchKey = FilePatcher.key(range.from, range.to)
161
+ return (newCache: string) => {
162
+ if (newCache === '') {
163
+ // remove the existing cache comment if one was found
164
+ if (range.to !== undefined) file.patches.set(patchKey, '')
165
+ return
166
+ }
167
+ file.patches.set(patchKey, newCache + (linebreak ? '\n' : ''))
168
+ }
169
+ }
170
+
171
+ const typesCache: TwoslashTypesCache = {
172
+ preprocess(code, lang, options, meta) {
173
+ if (!meta) return
174
+
175
+ let rawCache = ''
176
+ let cacheString = ''
177
+
178
+ code = code.replaceAll(CODE_INLINE_CACHE_REGEX, (full, p1: string) => {
179
+ // keep only the first occurrence (duplicates may appear via @include)
180
+ if (!rawCache.length) {
181
+ cacheString = p1
182
+ rawCache = full
183
+ }
184
+ return ''
185
+ })
186
+
187
+ const shouldLoadCache = !ignoreCache && !remove
188
+ if (shouldLoadCache) {
189
+ const cache = resolveCachePayload(cacheString)
190
+ if (cache?.payload.hash === cacheHash(code, lang, options)) {
191
+ const twoslash = cache.twoslash()
192
+ if (twoslash) meta.__cache = twoslash
193
+ }
194
+ }
195
+
196
+ if (meta.sourceMap) {
197
+ const patch = resolveSourcePatcher(meta.sourceMap, rawCache)
198
+ if (patch) meta.__patch = patch
199
+ }
200
+
201
+ return code
202
+ },
203
+ read(_code, _lang, _options, meta) {
204
+ return meta?.__cache ?? null
205
+ },
206
+ write(code, data, lang, options, meta) {
207
+ if (remove) {
208
+ meta?.__patch?.('')
209
+ return
210
+ }
211
+ const twoslashShiki = simplifyTwoslashReturn(data)
212
+ const cacheStr = `// ${CODE_INLINE_CACHE_KEY}: ${stringifyCachePayload(twoslashShiki, code, lang, options)}`
213
+ meta?.__patch?.(cacheStr)
214
+ },
215
+ }
216
+
217
+ return { typesCache, patcher }
218
+ }
219
+
220
+ /** Keep only the fields shiki needs when serializing a twoslash result. */
221
+ function simplifyTwoslashReturn(ret: TwoslashShikiReturn): TwoslashShikiReturn {
222
+ return {
223
+ nodes: ret.nodes,
224
+ code: ret.code,
225
+ ...(ret.meta?.extension !== undefined ? { meta: { extension: ret.meta.extension } } : {}),
226
+ }
227
+ }
228
+
229
+ function isEnabledEnv(key: string): boolean | null {
230
+ const val = process.env?.[key]?.toLowerCase()
231
+ if (val)
232
+ return (
233
+ (
234
+ {
235
+ true: true,
236
+ false: false,
237
+ 1: true,
238
+ 0: false,
239
+ yes: true,
240
+ no: false,
241
+ y: true,
242
+ n: false,
243
+ } as Record<string, boolean>
244
+ )[val] ?? null
245
+ )
246
+ return null
247
+ }
248
+
249
+ /**
250
+ * Resolve whether the inline cache is enabled. The `TWOSLASH_INLINE_CACHE`
251
+ * environment variable takes precedence over the config flag.
252
+ */
253
+ export function enabled(configFlag?: boolean | undefined): boolean {
254
+ const env = isEnabledEnv('TWOSLASH_INLINE_CACHE')
255
+ if (env !== null) return env
256
+ return Boolean(configFlag)
257
+ }
258
+
259
+ function envOptions(): { remove: boolean; ignoreCache: boolean } {
260
+ return {
261
+ remove: isEnabledEnv('TWOSLASH_INLINE_CACHE_REMOVE') === true,
262
+ ignoreCache: isEnabledEnv('TWOSLASH_INLINE_CACHE_IGNORE') === true,
263
+ }
264
+ }
265
+
266
+ let current: { typesCache: TwoslashTypesCache; patcher: FilePatcher } | undefined
267
+
268
+ /** Get (or lazily create) the process-wide inline cache instance. */
269
+ export function getOrCreate(): { typesCache: TwoslashTypesCache; patcher: FilePatcher } {
270
+ if (!current) current = createInlineTypesCache(envOptions())
271
+ return current
272
+ }
273
+
274
+ /** Flush any queued write-backs for `path` to disk. No-op if not initialized. */
275
+ export function flush(path: string): void {
276
+ current?.patcher.patch(path)
277
+ }
278
+
279
+ /** Reset the singleton (primarily for tests). */
280
+ export function reset(): void {
281
+ current = undefined
282
+ }
@@ -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) {