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.
- package/dist/internal/mdx.d.ts +17 -0
- package/dist/internal/mdx.d.ts.map +1 -1
- package/dist/internal/mdx.js +52 -0
- 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 +23 -2
- 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 +61 -0
- package/dist/internal/twoslash/inline-cache.d.ts.map +1 -0
- package/dist/internal/twoslash/inline-cache.js +204 -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 +56 -0
- package/src/internal/shiki-transformers.ts +33 -2
- 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 +158 -0
- package/src/internal/twoslash/inline-cache.ts +282 -0
- 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
|
+
}
|
|
@@ -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) {
|