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
@@ -41,6 +41,7 @@ import { remarkVocsScope } from './remark-vocs-scope.js'
41
41
  import { remarkSandbox } from './sandbox.js'
42
42
  import * as ShikiTransformers from './shiki-transformers.js'
43
43
  import * as Snippets from './snippets.js'
44
+ import * as InlineCache from './twoslash/inline-cache.js'
44
45
  import type { ExactPartial, UnionOmit } from './types.js'
45
46
 
46
47
  export { default as remarkFrontmatter } from 'remark-frontmatter'
@@ -121,6 +122,51 @@ export declare namespace remarkMermaid {
121
122
  type ReturnType = (tree: MdAst.Root) => void
122
123
  }
123
124
 
125
+ /**
126
+ * Remark plugin that injects an ephemeral source-map comment into the body of
127
+ * each `twoslash` code block. The comment carries the block's source position
128
+ * (`{ path, from, to }`) so the inline types cache knows where to write the
129
+ * `// @twoslash-cache: ...` comment back to.
130
+ *
131
+ * The injected comment is stripped again by the `extractInlineCacheSourceMap`
132
+ * shiki transformer before twoslash/shiki run, so it never reaches the output
133
+ * or the on-disk file.
134
+ *
135
+ * Only top-level (non-indented) fences are supported, which covers the
136
+ * overwhelming majority of twoslash blocks.
137
+ */
138
+ export function remarkInlineCache(): remarkInlineCache.ReturnType {
139
+ return (tree: MdAst.Root, file: VFile) => {
140
+ const raw = typeof file.value === 'string' ? file.value : file.value?.toString()
141
+ const filePath = file.path
142
+ if (!raw || !filePath) return
143
+
144
+ UnistUtil.visit(tree, 'code', (node) => {
145
+ if (!node.meta?.includes('twoslash')) return
146
+
147
+ const start = node.position?.start
148
+ const end = node.position?.end
149
+ if (start?.offset == null || end?.offset == null) return
150
+ // Only top-level fences (not nested in lists/blockquotes) are supported.
151
+ if (start.column !== 1) return
152
+
153
+ const newlineIndex = raw.indexOf('\n', start.offset)
154
+ if (newlineIndex === -1) return
155
+ const bodyStart = newlineIndex + 1
156
+
157
+ node.value = InlineCache.injectSourceMapComment(node.value, {
158
+ path: filePath,
159
+ from: bodyStart,
160
+ to: end.offset,
161
+ })
162
+ })
163
+ }
164
+ }
165
+
166
+ export declare namespace remarkInlineCache {
167
+ type ReturnType = (tree: MdAst.Root, file: VFile) => void
168
+ }
169
+
124
170
  const extensions = ['.js', '.ts', '.tsx', '.jsx', '.mjs', '.cjs', '.md', '.mdx']
125
171
  const logger = createLogger(undefined, { allowClearScreen: false, prefix: '[vocs]' })
126
172
 
@@ -247,6 +293,11 @@ export function getCompileOptions(
247
293
  remarkSubheading,
248
294
  remarkVocsScope,
249
295
  ...(markdown?.remarkPlugins ?? []),
296
+ // Runs after snippet/include processing so the injected source-map
297
+ // comment doesn't interfere with snippet detection.
298
+ ...(twoslash && typeof twoslash === 'object' && InlineCache.enabled(twoslash.inlineCache)
299
+ ? [remarkInlineCache]
300
+ : []),
250
301
  remarkRestoreUnknownTextDirectives,
251
302
  ],
252
303
  recmaPlugins: [recmaMdxLayout(config), ...(markdown?.recmaPlugins ?? [])],
@@ -487,6 +538,11 @@ export function rehypeShiki(
487
538
  langs: mergedLangs,
488
539
  // TODO: infer `langs` for faster cold start.
489
540
  transformers: [
541
+ // Must run before the twoslash transformer so `this.meta.sourceMap` is
542
+ // available to the inline types cache.
543
+ twoslash && typeof twoslash === 'object' && InlineCache.enabled(twoslash.inlineCache)
544
+ ? ShikiTransformers.extractInlineCacheSourceMap()
545
+ : undefined,
490
546
  rootDir && srcDir ? ShikiTransformers.notationInclude({ srcDir, rootDir }) : undefined,
491
547
  twoslash
492
548
  ? ShikiTransformers.twoslash({
@@ -1062,7 +1118,10 @@ function getVirtualFiles(codeNodes: MdAst.Code[]): Map<string, string> {
1062
1118
  for (const node of codeNodes) {
1063
1119
  const fileName = getCodeFileName(node)
1064
1120
  if (!fileName) continue
1065
- virtualFiles.set(fileName, node.value)
1121
+ // Strip any inline twoslash cache comment so it isn't inlined ahead of a
1122
+ // host block's code when this virtual file is imported/included. The
1123
+ // original node keeps its comment for its own rendering.
1124
+ virtualFiles.set(fileName, InlineCache.stripInlineCacheComments(node.value))
1066
1125
  }
1067
1126
  return virtualFiles
1068
1127
  }
@@ -19,6 +19,7 @@ type TransformerTwoslashIndexOptions = TransformerTwoslashOptions
19
19
 
20
20
  import * as Snippets from './snippets.js'
21
21
  import * as TwoslashChecker from './twoslash/checker.js'
22
+ import * as InlineCache from './twoslash/inline-cache.js'
22
23
  import * as Renderer from './twoslash/renderer.js'
23
24
  import * as TypesCache from './twoslash/types-cache.js'
24
25
 
@@ -240,7 +241,7 @@ let typescript: TS | undefined
240
241
  const twoslasherResetInterval = 200
241
242
 
242
243
  export function twoslash(options: twoslash.Options): ShikiTransformer {
243
- const { cacheDir } = options
244
+ const { cacheDir, inlineCache } = options
244
245
  const {
245
246
  checkOnly = false,
246
247
  explicitTrigger = true,
@@ -252,7 +253,9 @@ export function twoslash(options: twoslash.Options): ShikiTransformer {
252
253
  renderer = Renderer.rich(),
253
254
  throws = true,
254
255
  twoslashOptions,
255
- typesCache = TypesCache.fs({ dir: cacheDir ? path.join(cacheDir, 'twoslash') : undefined }),
256
+ typesCache = InlineCache.enabled(inlineCache)
257
+ ? InlineCache.getOrCreate().typesCache
258
+ : TypesCache.fs({ dir: cacheDir ? path.join(cacheDir, 'twoslash') : undefined }),
256
259
  } = options
257
260
 
258
261
  const lazyTwoslasher = (
@@ -304,8 +307,13 @@ export function twoslash(options: twoslash.Options): ShikiTransformer {
304
307
  executeOptions?: Parameters<TwoslashInstance>[2],
305
308
  meta?: Parameters<NonNullable<typeof typesCache.preprocess>>[3],
306
309
  ) {
307
- const preprocessed = typesCache.preprocess?.(code, lang, executeOptions, meta)
308
- return Renderer.normalizeCustomTagBlocks(preprocessed ?? code)
310
+ // Normalize before the inner preprocess so the cache key hashed on
311
+ // read matches the code hashed on write (which runs against the
312
+ // normalized output). Without this, snippets containing consecutive
313
+ // custom tag lines (`@log`/`@error`/`@warn`/`@annotate`) would never
314
+ // hit the cache.
315
+ const normalized = Renderer.normalizeCustomTagBlocks(code)
316
+ return typesCache.preprocess?.(normalized, lang, executeOptions, meta) ?? normalized
309
317
  },
310
318
  }
311
319
  : undefined
@@ -344,6 +352,34 @@ export declare namespace twoslash {
344
352
  */
345
353
  checkOnly?: boolean | undefined
346
354
  cacheDir?: string | undefined
355
+ /**
356
+ * Persist twoslash results inline in the markdown source as
357
+ * `// @twoslash-cache: ...` comments so the cache travels with the repo
358
+ * (e.g. for fresh clones and CI). Overridden by the `TWOSLASH_INLINE_CACHE`
359
+ * environment variable.
360
+ *
361
+ * @default false
362
+ */
363
+ inlineCache?: boolean | undefined
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Shiki transformer that extracts the ephemeral source-map comment injected by
369
+ * the inline-cache remark plugin, exposing it on `this.meta.sourceMap` for the
370
+ * inline types cache and stripping it from the code before twoslash runs.
371
+ *
372
+ * Must run before the twoslash transformer.
373
+ */
374
+ export function extractInlineCacheSourceMap(): ShikiTransformer {
375
+ return {
376
+ name: 'vocs:twoslash-inline-cache-source-map',
377
+ enforce: 'pre',
378
+ preprocess(code) {
379
+ const { code: stripped, sourceMap } = InlineCache.extractSourceMapComment(code)
380
+ this.meta.sourceMap = sourceMap
381
+ return stripped
382
+ },
347
383
  }
348
384
  }
349
385
 
@@ -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,212 @@
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
+ stripInlineCacheComments,
10
+ } from './inline-cache.js'
11
+
12
+ describe('source map codec', () => {
13
+ it('round-trips a source map through inject/extract', () => {
14
+ const sourceMap = { path: '/docs/a.md', from: 15, to: 42 }
15
+ const injected = injectSourceMapComment('const a = 1', sourceMap)
16
+ expect(injected.startsWith('// @vocs-twoslash-source:')).toBe(true)
17
+
18
+ const result = extractSourceMapComment(injected)
19
+ expect(result.code).toBe('const a = 1')
20
+ expect(result.sourceMap).toEqual(sourceMap)
21
+ })
22
+
23
+ it('returns null source map when none present', () => {
24
+ const result = extractSourceMapComment('const a = 1')
25
+ expect(result.code).toBe('const a = 1')
26
+ expect(result.sourceMap).toBeNull()
27
+ })
28
+ })
29
+
30
+ describe('stripInlineCacheComments', () => {
31
+ it('removes all cache comments', () => {
32
+ const code = [
33
+ '// @twoslash-cache: {"v":1,"hash":"abc","data":"x"}',
34
+ 'export const client = 1',
35
+ ].join('\n')
36
+ expect(stripInlineCacheComments(code)).toBe('export const client = 1')
37
+ })
38
+
39
+ it('leaves code without cache comments untouched', () => {
40
+ const code = 'export const client = 1\nconst a = 2'
41
+ expect(stripInlineCacheComments(code)).toBe(code)
42
+ })
43
+ })
44
+
45
+ describe('inline types cache', () => {
46
+ const tmpFiles: string[] = []
47
+
48
+ afterEach(() => {
49
+ for (const file of tmpFiles.splice(0)) fs.rmSync(file, { force: true })
50
+ })
51
+
52
+ function createMarkdown(body: string) {
53
+ const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'vocs-inline-cache-')), 'page.md')
54
+ const md = `\`\`\`ts twoslash\n${body}\n\`\`\`\n`
55
+ fs.writeFileSync(file, md, 'utf-8')
56
+ tmpFiles.push(file)
57
+ // body starts right after the first newline (the opening fence line).
58
+ const from = md.indexOf('\n') + 1
59
+ return { file, md, from, to: md.length }
60
+ }
61
+
62
+ // Pull the on-disk fence body the way shiki would see it (everything between
63
+ // the opening fence line and the closing fence).
64
+ function readBody(file: string, from: number) {
65
+ const content = fs.readFileSync(file, 'utf-8')
66
+ return content.slice(from, content.lastIndexOf('\n```'))
67
+ }
68
+
69
+ it('writes a cache comment back to the source and reads it on the next pass', () => {
70
+ const code = 'const a: string = "x"'
71
+ const { file, from, to } = createMarkdown(code)
72
+
73
+ const data = { nodes: [], code: 'compiled-output' }
74
+
75
+ // --- Pass 1: cache miss -> twoslash runs -> write back ---
76
+ {
77
+ const { typesCache, patcher } = createInlineTypesCache()
78
+ const meta = { sourceMap: { path: file, from, to } } as never
79
+
80
+ const preprocessed = typesCache.preprocess?.(code, 'ts', {}, meta)
81
+ expect(preprocessed).toBe(code)
82
+ expect(typesCache.read(code, 'ts', {}, meta)).toBeNull()
83
+
84
+ typesCache.write(code, data as never, 'ts', {}, meta)
85
+ patcher.patch(file)
86
+ }
87
+
88
+ const written = fs.readFileSync(file, 'utf-8')
89
+ expect(written).toContain('// @twoslash-cache:')
90
+
91
+ // --- Pass 2: cache hit -> twoslash skipped ---
92
+ {
93
+ const { typesCache } = createInlineTypesCache()
94
+ const body = readBody(file, from)
95
+ const meta = { sourceMap: { path: file, from, to } } as never
96
+
97
+ const preprocessed = typesCache.preprocess?.(body, 'ts', {}, meta)
98
+ // the cache comment is stripped before twoslash would run
99
+ expect(preprocessed).toBe(code)
100
+
101
+ const cached = typesCache.read(body, 'ts', {}, meta)
102
+ expect(cached).toEqual(data)
103
+ }
104
+ })
105
+
106
+ it('replaces an existing cache comment in place instead of appending a duplicate', () => {
107
+ const code = 'const a: string = "x"'
108
+ const { file, from, to } = createMarkdown(code)
109
+ const data = { nodes: [], code: 'compiled-output' }
110
+
111
+ // --- Pass 1: cold write ---
112
+ {
113
+ const { typesCache, patcher } = createInlineTypesCache()
114
+ const meta = { sourceMap: { path: file, from, to } } as never
115
+ typesCache.preprocess?.(code, 'ts', {}, meta)
116
+ typesCache.write(code, data as never, 'ts', {}, meta)
117
+ patcher.patch(file)
118
+ }
119
+
120
+ const cacheLines = () =>
121
+ fs
122
+ .readFileSync(file, 'utf-8')
123
+ .split('\n')
124
+ .filter((l) => l.includes('// @twoslash-cache:'))
125
+ expect(cacheLines()).toHaveLength(1)
126
+
127
+ // --- Pass 2: a writer with stale in-memory code (no cache comment, so the
128
+ // existing comment can't be located via `search`) must still replace the
129
+ // on-disk comment rather than append a second one. ---
130
+ {
131
+ const content = fs.readFileSync(file, 'utf-8')
132
+ const currentTo = content.lastIndexOf('\n```')
133
+ const { typesCache, patcher } = createInlineTypesCache()
134
+ const meta = { sourceMap: { path: file, from, to: currentTo } } as never
135
+ // Pass the original (comment-free) code to simulate a stale read.
136
+ typesCache.preprocess?.(code, 'ts', {}, meta)
137
+ typesCache.write(code, data as never, 'ts', {}, meta)
138
+ patcher.patch(file)
139
+ }
140
+
141
+ expect(cacheLines()).toHaveLength(1)
142
+ })
143
+
144
+ it('invalidates the cache when the hash no longer matches', () => {
145
+ const code = 'const a: string = "x"'
146
+ const { file, from, to } = createMarkdown(code)
147
+
148
+ // Seed a cache entry.
149
+ {
150
+ const { typesCache, patcher } = createInlineTypesCache()
151
+ const meta = { sourceMap: { path: file, from, to } } as never
152
+ typesCache.preprocess?.(code, 'ts', {}, meta)
153
+ typesCache.write(code, { nodes: [], code: 'x' } as never, 'ts', {}, meta)
154
+ patcher.patch(file)
155
+ }
156
+
157
+ // Read with different code -> hash mismatch -> miss.
158
+ {
159
+ const { typesCache } = createInlineTypesCache()
160
+ const body = readBody(file, from)
161
+ const meta = { sourceMap: { path: file, from, to } } as never
162
+ // The body still has the old cache line, but we validate against new code.
163
+ typesCache.preprocess?.(`${body}\nconst b = 2`, 'ts', {}, meta)
164
+ expect(typesCache.read(body, 'ts', {}, meta)).toBeNull()
165
+ }
166
+ })
167
+
168
+ it('ignoreCache skips reading existing cache', () => {
169
+ const code = 'const a: string = "x"'
170
+ const { file, from, to } = createMarkdown(code)
171
+
172
+ {
173
+ const { typesCache, patcher } = createInlineTypesCache()
174
+ const meta = { sourceMap: { path: file, from, to } } as never
175
+ typesCache.preprocess?.(code, 'ts', {}, meta)
176
+ typesCache.write(code, { nodes: [], code: 'x' } as never, 'ts', {}, meta)
177
+ patcher.patch(file)
178
+ }
179
+
180
+ {
181
+ const { typesCache } = createInlineTypesCache({ ignoreCache: true })
182
+ const body = readBody(file, from)
183
+ const meta = { sourceMap: { path: file, from, to } } as never
184
+ typesCache.preprocess?.(body, 'ts', {}, meta)
185
+ expect(typesCache.read(body, 'ts', {}, meta)).toBeNull()
186
+ }
187
+ })
188
+
189
+ it('remove mode strips the cache comment from the source', () => {
190
+ const code = 'const a: string = "x"'
191
+ const { file, from, to } = createMarkdown(code)
192
+
193
+ {
194
+ const { typesCache, patcher } = createInlineTypesCache()
195
+ const meta = { sourceMap: { path: file, from, to } } as never
196
+ typesCache.preprocess?.(code, 'ts', {}, meta)
197
+ typesCache.write(code, { nodes: [], code: 'x' } as never, 'ts', {}, meta)
198
+ patcher.patch(file)
199
+ }
200
+ expect(fs.readFileSync(file, 'utf-8')).toContain('// @twoslash-cache:')
201
+
202
+ {
203
+ const { typesCache, patcher } = createInlineTypesCache({ remove: true })
204
+ const body = readBody(file, from)
205
+ const meta = { sourceMap: { path: file, from, to } } as never
206
+ typesCache.preprocess?.(body, 'ts', {}, meta)
207
+ typesCache.write(body, { nodes: [], code: 'x' } as never, 'ts', {}, meta)
208
+ patcher.patch(file)
209
+ }
210
+ expect(fs.readFileSync(file, 'utf-8')).not.toContain('// @twoslash-cache:')
211
+ })
212
+ })