vocs 2.0.4 → 2.0.6
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/config.d.ts +7 -1
- package/dist/internal/config.d.ts.map +1 -1
- package/dist/internal/config.js +14 -1
- package/dist/internal/config.js.map +1 -1
- package/dist/internal/icons.d.ts.map +1 -1
- package/dist/internal/icons.js +2 -1
- package/dist/internal/icons.js.map +1 -1
- package/dist/internal/mdx.d.ts.map +1 -1
- package/dist/internal/mdx.js +42 -0
- package/dist/internal/mdx.js.map +1 -1
- package/dist/internal/shiki-transformers.d.ts +9 -0
- package/dist/internal/shiki-transformers.d.ts.map +1 -1
- package/dist/internal/shiki-transformers.js +37 -1
- package/dist/internal/shiki-transformers.js.map +1 -1
- package/dist/internal/sidebar.d.ts +11 -0
- package/dist/internal/sidebar.d.ts.map +1 -1
- package/dist/internal/sidebar.js.map +1 -1
- package/dist/internal/twoslash/checker.js +5 -0
- package/dist/internal/twoslash/checker.js.map +1 -1
- package/dist/react/internal/FileTree.client.d.ts +16 -0
- package/dist/react/internal/FileTree.client.d.ts.map +1 -1
- package/dist/react/internal/FileTree.client.js +23 -3
- package/dist/react/internal/FileTree.client.js.map +1 -1
- package/dist/react/internal/FileTree.mdx.d.ts +1 -0
- package/dist/react/internal/FileTree.mdx.d.ts.map +1 -1
- package/dist/react/internal/FileTree.mdx.js +2 -2
- package/dist/react/internal/FileTree.mdx.js.map +1 -1
- package/dist/react/internal/Sidebar.d.ts.map +1 -1
- package/dist/react/internal/Sidebar.js +13 -7
- package/dist/react/internal/Sidebar.js.map +1 -1
- package/package.json +1 -1
- package/src/internal/config.ts +21 -2
- package/src/internal/icons.test.ts +9 -2
- package/src/internal/icons.ts +2 -1
- package/src/internal/mdx.test.ts +123 -0
- package/src/internal/mdx.ts +44 -0
- package/src/internal/shiki-transformers.test.ts +29 -0
- package/src/internal/shiki-transformers.ts +40 -2
- package/src/internal/sidebar.test.ts +28 -0
- package/src/internal/sidebar.ts +14 -0
- package/src/internal/twoslash/checker.ts +5 -0
- package/src/react/internal/FileTree.client.tsx +113 -16
- package/src/react/internal/FileTree.mdx.tsx +31 -12
- package/src/react/internal/Sidebar.tsx +30 -6
|
@@ -34,8 +34,15 @@ describe('resolveIconSync', () => {
|
|
|
34
34
|
expect(resolveIconSync('lucide:nonexistent-icon-xyz')).toBeUndefined()
|
|
35
35
|
})
|
|
36
36
|
|
|
37
|
-
test('
|
|
38
|
-
|
|
37
|
+
test('resolves bare icon name as lucide', () => {
|
|
38
|
+
const bare = resolveIconSync('arrow-right')
|
|
39
|
+
const prefixed = resolveIconSync('lucide:arrow-right')
|
|
40
|
+
expect(bare).toBeDefined()
|
|
41
|
+
expect(bare).toBe(prefixed)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('returns undefined for bare name not in lucide', () => {
|
|
45
|
+
expect(resolveIconSync('nonexistent-icon-xyz')).toBeUndefined()
|
|
39
46
|
})
|
|
40
47
|
|
|
41
48
|
test('returns undefined for empty string parts', () => {
|
package/src/internal/icons.ts
CHANGED
|
@@ -182,7 +182,8 @@ export function matchIcon(
|
|
|
182
182
|
export function resolveIconSync(icon: string): string | undefined {
|
|
183
183
|
if (icon.startsWith('<svg')) return icon
|
|
184
184
|
|
|
185
|
-
|
|
185
|
+
// Default to `lucide` collection when no `collection:` prefix is provided.
|
|
186
|
+
const [collection, iconName] = icon.includes(':') ? icon.split(':') : ['lucide', icon]
|
|
186
187
|
if (!collection || !iconName) return undefined
|
|
187
188
|
|
|
188
189
|
const iconSet = iconSets[collection]
|
package/src/internal/mdx.test.ts
CHANGED
|
@@ -267,6 +267,129 @@ describe('remarkRestoreUnknownTextDirectives', () => {
|
|
|
267
267
|
})
|
|
268
268
|
|
|
269
269
|
describe('remarkFileTree', () => {
|
|
270
|
+
async function parse(items: { text: string }[]) {
|
|
271
|
+
const tree = {
|
|
272
|
+
type: 'root',
|
|
273
|
+
children: [
|
|
274
|
+
{
|
|
275
|
+
type: 'containerDirective',
|
|
276
|
+
name: 'file-tree',
|
|
277
|
+
children: [
|
|
278
|
+
{
|
|
279
|
+
type: 'list',
|
|
280
|
+
children: items.map((item) => ({
|
|
281
|
+
type: 'listItem',
|
|
282
|
+
children: [
|
|
283
|
+
{
|
|
284
|
+
type: 'paragraph',
|
|
285
|
+
children: [{ type: 'text', value: item.text }],
|
|
286
|
+
},
|
|
287
|
+
],
|
|
288
|
+
})),
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
},
|
|
292
|
+
],
|
|
293
|
+
}
|
|
294
|
+
await remarkFileTree(Config.define({ rootDir: process.cwd() }))(tree as never)
|
|
295
|
+
const fileTree = tree.children[0] as {
|
|
296
|
+
data?: { hProperties?: Record<string, string> }
|
|
297
|
+
}
|
|
298
|
+
return JSON.parse(fileTree.data?.hProperties?.['data-v-file-tree-items'] ?? '[]')
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
it('extracts {info="..."} tooltip from a plain row', async () => {
|
|
302
|
+
const items = await parse([{ text: 'vocs.config.ts{info="Site config"}' }])
|
|
303
|
+
expect(items[0]).toMatchObject({
|
|
304
|
+
name: 'vocs.config.ts',
|
|
305
|
+
type: 'file',
|
|
306
|
+
tooltip: 'Site config',
|
|
307
|
+
})
|
|
308
|
+
expect(items[0].comment).toBeUndefined()
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('extracts tooltip from a row that also has an inline comment', async () => {
|
|
312
|
+
const items = await parse([{ text: 'layout.tsx the root layout{info="Wraps every page"}' }])
|
|
313
|
+
expect(items[0]).toMatchObject({
|
|
314
|
+
name: 'layout.tsx',
|
|
315
|
+
type: 'file',
|
|
316
|
+
comment: 'the root layout',
|
|
317
|
+
tooltip: 'Wraps every page',
|
|
318
|
+
})
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('extracts tooltip from a folder row', async () => {
|
|
322
|
+
const items = await parse([{ text: '+app the app dir{info="App router root"}' }])
|
|
323
|
+
expect(items[0]).toMatchObject({
|
|
324
|
+
name: 'app',
|
|
325
|
+
type: 'folder',
|
|
326
|
+
comment: 'the app dir',
|
|
327
|
+
tooltip: 'App router root',
|
|
328
|
+
})
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it('supports unquoted {info=...} values', async () => {
|
|
332
|
+
const items = await parse([{ text: 'page.tsx{info=Home route}' }])
|
|
333
|
+
expect(items[0]).toMatchObject({
|
|
334
|
+
name: 'page.tsx',
|
|
335
|
+
tooltip: 'Home route',
|
|
336
|
+
})
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it('omits tooltip when value is empty', async () => {
|
|
340
|
+
const items = await parse([{ text: 'page.tsx{info=""}' }])
|
|
341
|
+
expect(items[0]).toMatchObject({ name: 'page.tsx' })
|
|
342
|
+
expect(items[0].tooltip).toBeUndefined()
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it('omits tooltip when no {info} present', async () => {
|
|
346
|
+
const items = await parse([{ text: 'page.tsx' }])
|
|
347
|
+
expect(items[0]).toMatchObject({ name: 'page.tsx' })
|
|
348
|
+
expect(items[0].tooltip).toBeUndefined()
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it('extracts tooltip from a trailing mdxTextExpression (MDX form)', async () => {
|
|
352
|
+
// Simulate what the MDX pipeline emits for `vocs.config.ts{info="Site config"}`:
|
|
353
|
+
// a text node followed by an mdxTextExpression whose value is the contents
|
|
354
|
+
// between the braces (no braces).
|
|
355
|
+
const tree = {
|
|
356
|
+
type: 'root',
|
|
357
|
+
children: [
|
|
358
|
+
{
|
|
359
|
+
type: 'containerDirective',
|
|
360
|
+
name: 'file-tree',
|
|
361
|
+
children: [
|
|
362
|
+
{
|
|
363
|
+
type: 'list',
|
|
364
|
+
children: [
|
|
365
|
+
{
|
|
366
|
+
type: 'listItem',
|
|
367
|
+
children: [
|
|
368
|
+
{
|
|
369
|
+
type: 'paragraph',
|
|
370
|
+
children: [
|
|
371
|
+
{ type: 'text', value: 'vocs.config.ts' },
|
|
372
|
+
{ type: 'mdxTextExpression', value: 'info="Site config: nav, theme"' },
|
|
373
|
+
],
|
|
374
|
+
},
|
|
375
|
+
],
|
|
376
|
+
},
|
|
377
|
+
],
|
|
378
|
+
},
|
|
379
|
+
],
|
|
380
|
+
},
|
|
381
|
+
],
|
|
382
|
+
}
|
|
383
|
+
await remarkFileTree(Config.define({ rootDir: process.cwd() }))(tree as never)
|
|
384
|
+
const fileTree = tree.children[0] as { data?: { hProperties?: Record<string, string> } }
|
|
385
|
+
const items = JSON.parse(fileTree.data?.hProperties?.['data-v-file-tree-items'] ?? '[]')
|
|
386
|
+
expect(items[0]).toMatchObject({
|
|
387
|
+
name: 'vocs.config.ts',
|
|
388
|
+
type: 'file',
|
|
389
|
+
tooltip: 'Site config: nav, theme',
|
|
390
|
+
})
|
|
391
|
+
})
|
|
392
|
+
|
|
270
393
|
it('preserves inline code in file comments', async () => {
|
|
271
394
|
const tree = {
|
|
272
395
|
type: 'root',
|
package/src/internal/mdx.ts
CHANGED
|
@@ -1110,9 +1110,21 @@ export function remarkFileTree(config: Config.Config): remarkFileTree.ReturnType
|
|
|
1110
1110
|
comment?: string | undefined
|
|
1111
1111
|
highlighted?: boolean | undefined
|
|
1112
1112
|
icon?: string | undefined
|
|
1113
|
+
tooltip?: string | undefined
|
|
1113
1114
|
items?: FileTreeItem[] | undefined
|
|
1114
1115
|
}
|
|
1115
1116
|
|
|
1117
|
+
// Matches a trailing `{info="..."}` or `{info=...}` (quotes optional, value
|
|
1118
|
+
// may be empty) on a file-tree row.
|
|
1119
|
+
//
|
|
1120
|
+
// Used in two places: against a raw text node when the row is parsed as
|
|
1121
|
+
// Markdown, and against the body of an `mdxTextExpression` (which contains
|
|
1122
|
+
// the contents *between* the braces) when the row is parsed as MDX. MDX
|
|
1123
|
+
// intercepts `{...}` as a JS expression embed, so the braces aren't part of
|
|
1124
|
+
// the captured value in that case.
|
|
1125
|
+
const tooltipRegex = /\s*\{info(?:=(?:"([^"]*)"|([^}]*)))?\}\s*$/
|
|
1126
|
+
const tooltipBodyRegex = /^\s*info(?:=(?:"([^"]*)"|([^}]*)))?\s*$/
|
|
1127
|
+
|
|
1116
1128
|
async function resolveAllIcons(items: FileTreeItem[]): Promise<void> {
|
|
1117
1129
|
await Promise.all(
|
|
1118
1130
|
items.map(async (item) => {
|
|
@@ -1159,6 +1171,37 @@ export function remarkFileTree(config: Config.Config): remarkFileTree.ReturnType
|
|
|
1159
1171
|
)
|
|
1160
1172
|
if (!paragraph) continue
|
|
1161
1173
|
|
|
1174
|
+
// Pre-extract trailing `{info="..."}` so its (possibly space-
|
|
1175
|
+
// containing) value doesn't get mis-split into name/comment below.
|
|
1176
|
+
//
|
|
1177
|
+
// MDX intercepts `{...}` as a JS expression embed (mdxTextExpression
|
|
1178
|
+
// with body `info="..."`); plain Markdown leaves it as text. Handle
|
|
1179
|
+
// both: a trailing mdxTextExpression takes precedence, otherwise
|
|
1180
|
+
// look at the last text node.
|
|
1181
|
+
let tooltip: string | undefined
|
|
1182
|
+
const lastChild = paragraph.children[paragraph.children.length - 1]
|
|
1183
|
+
if (lastChild?.type === 'mdxTextExpression') {
|
|
1184
|
+
const expr = lastChild as MdAst.PhrasingContent & { value: string }
|
|
1185
|
+
const match = expr.value.match(tooltipBodyRegex)
|
|
1186
|
+
if (match) {
|
|
1187
|
+
tooltip = (match[1] ?? match[2] ?? '').trim()
|
|
1188
|
+
// Drop the expression entirely; an mdxTextExpression has no
|
|
1189
|
+
// text representation in the row.
|
|
1190
|
+
paragraph.children.pop()
|
|
1191
|
+
}
|
|
1192
|
+
} else {
|
|
1193
|
+
const lastText = [...paragraph.children]
|
|
1194
|
+
.reverse()
|
|
1195
|
+
.find((c): c is MdAst.Text => c.type === 'text')
|
|
1196
|
+
if (lastText) {
|
|
1197
|
+
const match = lastText.value.match(tooltipRegex)
|
|
1198
|
+
if (match) {
|
|
1199
|
+
tooltip = (match[1] ?? match[2] ?? '').trim()
|
|
1200
|
+
lastText.value = lastText.value.replace(tooltipRegex, '')
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1162
1205
|
let name = ''
|
|
1163
1206
|
let comment = ''
|
|
1164
1207
|
let folder = false
|
|
@@ -1211,6 +1254,7 @@ export function remarkFileTree(config: Config.Config): remarkFileTree.ReturnType
|
|
|
1211
1254
|
type: folder ? 'folder' : 'file',
|
|
1212
1255
|
...(comment && { comment }),
|
|
1213
1256
|
...(highlighted && { highlighted }),
|
|
1257
|
+
...(tooltip && { tooltip }),
|
|
1214
1258
|
}
|
|
1215
1259
|
|
|
1216
1260
|
// Check for nested lists (children of this list item)
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
notationBlock,
|
|
7
7
|
notationInclude,
|
|
8
8
|
removeNotationEscape,
|
|
9
|
+
resetTwoslasher,
|
|
9
10
|
resetTwoslashSnippets,
|
|
10
11
|
shellNotation,
|
|
11
12
|
shellPrompt,
|
|
@@ -212,6 +213,34 @@ const element = <div />`,
|
|
|
212
213
|
highlighter.dispose()
|
|
213
214
|
})
|
|
214
215
|
|
|
216
|
+
it('should keep producing correct output after the singleton twoslasher is reset', async () => {
|
|
217
|
+
const highlighter = await createHighlighter({
|
|
218
|
+
themes: ['github-dark'],
|
|
219
|
+
langs: ['typescript'],
|
|
220
|
+
})
|
|
221
|
+
const transformer = twoslash({ explicitTrigger: false })
|
|
222
|
+
|
|
223
|
+
const first = highlighter.codeToHtml('const a: number = 1', {
|
|
224
|
+
lang: 'typescript',
|
|
225
|
+
theme: 'github-dark',
|
|
226
|
+
transformers: [transformer],
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
// Force the singleton to drop; the next call must lazily reconstruct it
|
|
230
|
+
// and still produce equivalent rendered HTML.
|
|
231
|
+
resetTwoslasher()
|
|
232
|
+
|
|
233
|
+
const second = highlighter.codeToHtml('const a: number = 1', {
|
|
234
|
+
lang: 'typescript',
|
|
235
|
+
theme: 'github-dark',
|
|
236
|
+
transformers: [transformer],
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
expect(second).toBe(first)
|
|
240
|
+
|
|
241
|
+
highlighter.dispose()
|
|
242
|
+
})
|
|
243
|
+
|
|
215
244
|
it('should typecheck virtual json files in check-only mode', async () => {
|
|
216
245
|
const highlighter = await createHighlighter({
|
|
217
246
|
themes: ['github-dark'],
|
|
@@ -222,9 +222,23 @@ export const twoslashErrors: TwoslashError[] = []
|
|
|
222
222
|
const cwd = typeof process !== 'undefined' && typeof process.cwd === 'function' ? process.cwd() : ''
|
|
223
223
|
const require = createRequire(import.meta.url)
|
|
224
224
|
|
|
225
|
-
let twoslasher: TwoslashInstance
|
|
225
|
+
let twoslasher: TwoslashInstance | undefined
|
|
226
|
+
let twoslasherCalls = 0
|
|
226
227
|
let typescript: TS | undefined
|
|
227
228
|
|
|
229
|
+
/**
|
|
230
|
+
* How often to recreate the singleton `twoslasher`. The underlying
|
|
231
|
+
* `LanguageService` retains every `SourceFile`/`Symbol`/`Type` it has ever
|
|
232
|
+
* resolved (including the user's full dependency graph when snippets import
|
|
233
|
+
* library types), and there's no way to evict individual entries. Without a
|
|
234
|
+
* periodic reset, peak RSS on a site with 1000+ snippets can exceed 5 GB.
|
|
235
|
+
*
|
|
236
|
+
* Recreating it costs ~1.5s of TS lib re-init per reset; with the default of
|
|
237
|
+
* 200, that's ≤ a few seconds extra on the largest sites in exchange for
|
|
238
|
+
* GB-scale memory savings.
|
|
239
|
+
*/
|
|
240
|
+
const twoslasherResetInterval = 200
|
|
241
|
+
|
|
228
242
|
export function twoslash(options: twoslash.Options): ShikiTransformer {
|
|
229
243
|
const { cacheDir } = options
|
|
230
244
|
const {
|
|
@@ -259,13 +273,24 @@ export function twoslash(options: twoslash.Options): ShikiTransformer {
|
|
|
259
273
|
ignoreDeprecations: Number.parseInt(tsModule.versionMajorMinor, 10) >= 6 ? '6.0' : '5.0',
|
|
260
274
|
moduleResolution: 100, // bundler (default, can be overridden)
|
|
261
275
|
preserveSymlinks: false, // needed for monorepo/workspace symlinks
|
|
276
|
+
// Skip checking lib + .d.ts files. Twoslash snippets aren't authoring
|
|
277
|
+
// libraries — we only care about errors inside the snippet itself.
|
|
278
|
+
// Materially reduces peak RSS on large docs sites (avoids retaining
|
|
279
|
+
// ASTs for every lib.*.d.ts and @types/* file in TS's caches).
|
|
280
|
+
skipDefaultLibCheck: true,
|
|
281
|
+
skipLibCheck: true,
|
|
262
282
|
types: ['node'], // include node types by default (process.env, etc.)
|
|
263
283
|
...(twoslashOptions?.compilerOptions ?? {}),
|
|
264
284
|
},
|
|
265
285
|
})
|
|
266
286
|
}
|
|
267
287
|
|
|
268
|
-
|
|
288
|
+
const result = twoslasher(code, lang, executeOptions)
|
|
289
|
+
|
|
290
|
+
// Periodically drop the singleton so the underlying `LanguageService` (and
|
|
291
|
+
// its retained source-file / symbol caches) becomes GC-eligible.
|
|
292
|
+
if (++twoslasherCalls >= twoslasherResetInterval) resetTwoslasher()
|
|
293
|
+
return result
|
|
269
294
|
}
|
|
270
295
|
const activeTwoslasher = checkOnly
|
|
271
296
|
? checkOnlyTwoslasher({ throws, twoslashOptions })
|
|
@@ -369,6 +394,19 @@ export function resetTwoslashSnippets() {
|
|
|
369
394
|
TwoslashChecker.reset()
|
|
370
395
|
}
|
|
371
396
|
|
|
397
|
+
/**
|
|
398
|
+
* Drop the singleton `twoslasher` instance so its underlying `LanguageService`
|
|
399
|
+
* and the `SourceFile`/`Symbol`/`Type` caches it retains become eligible for
|
|
400
|
+
* garbage collection. The next twoslash call will lazily reconstruct it.
|
|
401
|
+
*
|
|
402
|
+
* Called automatically every `twoslasherResetInterval` snippets during a
|
|
403
|
+
* build; also safe to invoke explicitly between build phases.
|
|
404
|
+
*/
|
|
405
|
+
export function resetTwoslasher() {
|
|
406
|
+
twoslasher = undefined
|
|
407
|
+
twoslasherCalls = 0
|
|
408
|
+
}
|
|
409
|
+
|
|
372
410
|
function getTypeScript(): TS {
|
|
373
411
|
if (typescript) return typescript
|
|
374
412
|
|
|
@@ -130,6 +130,34 @@ describe('flatten', () => {
|
|
|
130
130
|
]
|
|
131
131
|
`)
|
|
132
132
|
})
|
|
133
|
+
|
|
134
|
+
test('preserves badge on flattened items', () => {
|
|
135
|
+
expect(
|
|
136
|
+
flatten([
|
|
137
|
+
{ text: 'A', link: '/a', badge: 'New' },
|
|
138
|
+
{
|
|
139
|
+
text: 'Group',
|
|
140
|
+
items: [{ text: 'B', link: '/b', badge: { text: 'Beta', variant: 'warning' } }],
|
|
141
|
+
},
|
|
142
|
+
]),
|
|
143
|
+
).toMatchInlineSnapshot(`
|
|
144
|
+
[
|
|
145
|
+
{
|
|
146
|
+
"badge": "New",
|
|
147
|
+
"link": "/a",
|
|
148
|
+
"text": "A",
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
"badge": {
|
|
152
|
+
"text": "Beta",
|
|
153
|
+
"variant": "warning",
|
|
154
|
+
},
|
|
155
|
+
"link": "/b",
|
|
156
|
+
"text": "B",
|
|
157
|
+
},
|
|
158
|
+
]
|
|
159
|
+
`)
|
|
160
|
+
})
|
|
133
161
|
})
|
|
134
162
|
|
|
135
163
|
describe('fromConfig', () => {
|
package/src/internal/sidebar.ts
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
import type { Config } from './config.js'
|
|
2
2
|
|
|
3
|
+
export type SidebarItemBadge =
|
|
4
|
+
| string
|
|
5
|
+
| {
|
|
6
|
+
/** Text displayed inside the badge. */
|
|
7
|
+
text: string
|
|
8
|
+
/** Visual variant. Defaults to `'info'`. */
|
|
9
|
+
variant?: 'note' | 'info' | 'warning' | 'danger' | 'tip' | 'success' | undefined
|
|
10
|
+
}
|
|
11
|
+
|
|
3
12
|
export type SidebarItem<strict extends boolean = false> = {
|
|
13
|
+
/**
|
|
14
|
+
* Badge rendered to the right of the item text.
|
|
15
|
+
* Pass a string for a default `info` badge, or an object to pick a variant.
|
|
16
|
+
*/
|
|
17
|
+
badge?: SidebarItemBadge | undefined
|
|
4
18
|
/** Whether or not to disable the sidebar item. */
|
|
5
19
|
disabled?: boolean | undefined
|
|
6
20
|
/** Whether to open the link in a new tab. */
|
|
@@ -168,6 +168,11 @@ function parseSnippet(snippet: collect.Snippet, tsModule: TS, key: string): Pars
|
|
|
168
168
|
ignoreDeprecations: Number.parseInt(tsModule.versionMajorMinor, 10) >= 6 ? '6.0' : '5.0',
|
|
169
169
|
moduleResolution: 100,
|
|
170
170
|
preserveSymlinks: false,
|
|
171
|
+
// Skip checking lib + .d.ts files. Twoslash snippets aren't authoring
|
|
172
|
+
// libraries — we only want errors inside the snippet itself. Avoids
|
|
173
|
+
// walking every lib.*.d.ts / @types/* AST per Program.
|
|
174
|
+
skipDefaultLibCheck: true,
|
|
175
|
+
skipLibCheck: true,
|
|
171
176
|
types: ['node'],
|
|
172
177
|
...(snippet.twoslashOptions?.compilerOptions ?? {}),
|
|
173
178
|
} satisfies TypeScript.CompilerOptions
|
|
@@ -1,6 +1,69 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
+
import { Popover } from '@base-ui/react/popover'
|
|
3
4
|
import * as React from 'react'
|
|
5
|
+
import LucideInfo from '~icons/lucide/info'
|
|
6
|
+
|
|
7
|
+
const triggerBaseClassName =
|
|
8
|
+
'vocs:flex vocs:items-center vocs:gap-2 vocs:whitespace-nowrap vocs:justify-self-start vocs:rounded-md vocs:px-2 vocs:py-0.5 vocs:-mx-2 vocs:-my-0.5 vocs:border vocs:border-transparent vocs:transition-colors vocs:data-[highlighted=true]:bg-surfaceTint vocs:data-[highlighted=true]:border-primary'
|
|
9
|
+
|
|
10
|
+
const tooltipTriggerClassName =
|
|
11
|
+
'vocs:hover:bg-surfaceTint vocs:hover:border-primary vocs:data-[popup-open]:bg-surfaceTint vocs:data-[popup-open]:border-primary'
|
|
12
|
+
|
|
13
|
+
const folderButtonClassName = 'vocs:bg-transparent vocs:m-0 vocs:not-disabled:cursor-pointer'
|
|
14
|
+
|
|
15
|
+
const popupClassName =
|
|
16
|
+
'vocs:max-w-[300px] vocs:bg-surface vocs:border vocs:border-primary vocs:rounded-md vocs:px-3 vocs:py-2 vocs:text-sm vocs:text-primary vocs:font-sans vocs:leading-snug vocs:shadow-md vocs:z-50'
|
|
17
|
+
|
|
18
|
+
function InfoIndicator() {
|
|
19
|
+
return (
|
|
20
|
+
<span
|
|
21
|
+
aria-hidden="true"
|
|
22
|
+
className="vocs:shrink-0 vocs:flex vocs:items-center vocs:justify-center vocs:size-4 vocs:text-muted"
|
|
23
|
+
>
|
|
24
|
+
<LucideInfo className="vocs:size-3.5" />
|
|
25
|
+
</span>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Wraps a file row's label area (icon + name) so the entire row becomes a
|
|
31
|
+
* hover-activated popover trigger. The trigger gets the active-style
|
|
32
|
+
* background and border while hovered or while the popover is open, and a
|
|
33
|
+
* decorative info icon is appended to make the affordance discoverable.
|
|
34
|
+
*/
|
|
35
|
+
export function FileRowTrigger(props: FileRowTrigger.Props) {
|
|
36
|
+
const { children, content, highlighted, label } = props
|
|
37
|
+
return (
|
|
38
|
+
<Popover.Root>
|
|
39
|
+
<Popover.Trigger
|
|
40
|
+
render={<span />}
|
|
41
|
+
openOnHover
|
|
42
|
+
delay={150}
|
|
43
|
+
aria-label={`More info about ${label}`}
|
|
44
|
+
data-highlighted={highlighted ? 'true' : undefined}
|
|
45
|
+
className={`${triggerBaseClassName} ${tooltipTriggerClassName}`}
|
|
46
|
+
>
|
|
47
|
+
{children}
|
|
48
|
+
<InfoIndicator />
|
|
49
|
+
</Popover.Trigger>
|
|
50
|
+
<Popover.Portal>
|
|
51
|
+
<Popover.Positioner side="top" align="center" sideOffset={6}>
|
|
52
|
+
<Popover.Popup className={popupClassName}>{content}</Popover.Popup>
|
|
53
|
+
</Popover.Positioner>
|
|
54
|
+
</Popover.Portal>
|
|
55
|
+
</Popover.Root>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export declare namespace FileRowTrigger {
|
|
60
|
+
type Props = {
|
|
61
|
+
children: React.ReactNode
|
|
62
|
+
content: string
|
|
63
|
+
label: string
|
|
64
|
+
highlighted?: boolean | undefined
|
|
65
|
+
}
|
|
66
|
+
}
|
|
4
67
|
|
|
5
68
|
export function FolderToggle(props: FolderToggle.Props) {
|
|
6
69
|
const {
|
|
@@ -12,6 +75,7 @@ export function FolderToggle(props: FolderToggle.Props) {
|
|
|
12
75
|
highlighted,
|
|
13
76
|
labelColumnOffset,
|
|
14
77
|
name,
|
|
78
|
+
tooltip,
|
|
15
79
|
} = props
|
|
16
80
|
const [isOpen, setIsOpen] = React.useState(true)
|
|
17
81
|
const rowStyle = comment
|
|
@@ -20,28 +84,60 @@ export function FolderToggle(props: FolderToggle.Props) {
|
|
|
20
84
|
}
|
|
21
85
|
: undefined
|
|
22
86
|
|
|
87
|
+
const folderInner = (
|
|
88
|
+
<>
|
|
89
|
+
<span className="vocs:shrink-0">{isOpen && hasChildren ? folderOpenIcon : folderIcon}</span>
|
|
90
|
+
<span>{name}</span>
|
|
91
|
+
</>
|
|
92
|
+
)
|
|
93
|
+
|
|
23
94
|
return (
|
|
24
95
|
<div className="vocs:flex vocs:flex-col vocs:overflow-visible">
|
|
25
96
|
<div className="vocs:relative vocs:flex vocs:items-center vocs:py-1">
|
|
26
|
-
<
|
|
27
|
-
className="vocs:grid vocs:items-center vocs:gap-x-4 vocs:w-fit vocs:
|
|
28
|
-
data-open={isOpen}
|
|
29
|
-
disabled={!hasChildren}
|
|
30
|
-
onClick={() => setIsOpen(!isOpen)}
|
|
97
|
+
<div
|
|
98
|
+
className="vocs:grid vocs:items-center vocs:gap-x-4 vocs:w-fit vocs:text-primary"
|
|
31
99
|
style={rowStyle}
|
|
32
|
-
type="button"
|
|
33
100
|
>
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
101
|
+
{tooltip ? (
|
|
102
|
+
<Popover.Root>
|
|
103
|
+
<Popover.Trigger
|
|
104
|
+
render={
|
|
105
|
+
<button
|
|
106
|
+
disabled={!hasChildren}
|
|
107
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
108
|
+
type="button"
|
|
109
|
+
/>
|
|
110
|
+
}
|
|
111
|
+
openOnHover
|
|
112
|
+
delay={150}
|
|
113
|
+
aria-label={`More info about ${name}`}
|
|
114
|
+
data-highlighted={highlighted ? 'true' : undefined}
|
|
115
|
+
data-open={isOpen}
|
|
116
|
+
className={`${triggerBaseClassName} ${folderButtonClassName} ${tooltipTriggerClassName}`}
|
|
117
|
+
>
|
|
118
|
+
{folderInner}
|
|
119
|
+
<InfoIndicator />
|
|
120
|
+
</Popover.Trigger>
|
|
121
|
+
<Popover.Portal>
|
|
122
|
+
<Popover.Positioner side="top" align="center" sideOffset={6}>
|
|
123
|
+
<Popover.Popup className={popupClassName}>{tooltip}</Popover.Popup>
|
|
124
|
+
</Popover.Positioner>
|
|
125
|
+
</Popover.Portal>
|
|
126
|
+
</Popover.Root>
|
|
127
|
+
) : (
|
|
128
|
+
<button
|
|
129
|
+
className={`${triggerBaseClassName} ${folderButtonClassName}`}
|
|
130
|
+
data-highlighted={highlighted}
|
|
131
|
+
data-open={isOpen}
|
|
132
|
+
disabled={!hasChildren}
|
|
133
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
134
|
+
type="button"
|
|
135
|
+
>
|
|
136
|
+
{folderInner}
|
|
137
|
+
</button>
|
|
138
|
+
)}
|
|
43
139
|
{comment && <span className="vocs:text-muted vocs:whitespace-nowrap">{comment}</span>}
|
|
44
|
-
</
|
|
140
|
+
</div>
|
|
45
141
|
</div>
|
|
46
142
|
{hasChildren && isOpen && folderContent}
|
|
47
143
|
</div>
|
|
@@ -54,6 +150,7 @@ export declare namespace FolderToggle {
|
|
|
54
150
|
folderOpenIcon: React.ReactNode
|
|
55
151
|
name: string
|
|
56
152
|
comment?: string | undefined
|
|
153
|
+
tooltip?: string | undefined
|
|
57
154
|
hasChildren: boolean
|
|
58
155
|
highlighted?: boolean | undefined
|
|
59
156
|
labelColumnOffset: number
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import LucideFile from '~icons/lucide/file'
|
|
2
2
|
import LucideFolder from '~icons/lucide/folder'
|
|
3
3
|
import LucideFolderOpen from '~icons/lucide/folder-open'
|
|
4
|
-
import { FolderToggle } from './FileTree.client.js'
|
|
4
|
+
import { FileRowTrigger, FolderToggle } from './FileTree.client.js'
|
|
5
5
|
|
|
6
6
|
export function FileTree(props: FileTree.Props) {
|
|
7
7
|
const items: FileTree.Item[] = JSON.parse(props['data-v-file-tree-items'] ?? '[]')
|
|
@@ -33,6 +33,7 @@ export namespace FileTree {
|
|
|
33
33
|
comment?: string
|
|
34
34
|
highlighted?: boolean
|
|
35
35
|
icon?: string
|
|
36
|
+
tooltip?: string
|
|
36
37
|
items?: Item[]
|
|
37
38
|
}
|
|
38
39
|
|
|
@@ -90,6 +91,7 @@ export namespace FileTree {
|
|
|
90
91
|
<FolderToggle
|
|
91
92
|
name={item.name}
|
|
92
93
|
comment={item.comment}
|
|
94
|
+
tooltip={item.tooltip}
|
|
93
95
|
hasChildren={!!hasChildren}
|
|
94
96
|
highlighted={item.highlighted}
|
|
95
97
|
labelColumnOffset={depth * indentSize}
|
|
@@ -105,19 +107,36 @@ export namespace FileTree {
|
|
|
105
107
|
className="vocs:grid vocs:items-center vocs:gap-x-4 vocs:text-primary"
|
|
106
108
|
style={rowStyle}
|
|
107
109
|
>
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
110
|
+
{item.tooltip ? (
|
|
111
|
+
<FileRowTrigger
|
|
112
|
+
content={item.tooltip}
|
|
113
|
+
label={item.name}
|
|
114
|
+
highlighted={item.highlighted}
|
|
115
|
+
>
|
|
116
|
+
{item.name !== '...' && (
|
|
117
|
+
<span className="vocs:shrink-0">
|
|
118
|
+
<FileIcon icon={item.icon} />
|
|
119
|
+
</span>
|
|
120
|
+
)}
|
|
121
|
+
<span className={item.name === '...' ? 'vocs:text-muted' : undefined}>
|
|
122
|
+
{item.name}
|
|
123
|
+
</span>
|
|
124
|
+
</FileRowTrigger>
|
|
125
|
+
) : (
|
|
126
|
+
<span
|
|
127
|
+
className="vocs:flex vocs:items-center vocs:gap-2 vocs:whitespace-nowrap vocs:justify-self-start vocs:data-[highlighted=true]:bg-surfaceTint vocs:data-[highlighted=true]:border vocs:data-[highlighted=true]:border-primary vocs:rounded-md vocs:px-2 vocs:py-0.5 vocs:-mx-2 vocs:-my-0.5"
|
|
128
|
+
data-highlighted={item.highlighted}
|
|
129
|
+
>
|
|
130
|
+
{item.name !== '...' && (
|
|
131
|
+
<span className="vocs:shrink-0">
|
|
132
|
+
<FileIcon icon={item.icon} />
|
|
133
|
+
</span>
|
|
134
|
+
)}
|
|
135
|
+
<span className={item.name === '...' ? 'vocs:text-muted' : undefined}>
|
|
136
|
+
{item.name}
|
|
115
137
|
</span>
|
|
116
|
-
)}
|
|
117
|
-
<span className={item.name === '...' ? 'vocs:text-muted' : undefined}>
|
|
118
|
-
{item.name}
|
|
119
138
|
</span>
|
|
120
|
-
|
|
139
|
+
)}
|
|
121
140
|
{item.comment && (
|
|
122
141
|
<span className="vocs:text-muted vocs:whitespace-nowrap">{item.comment}</span>
|
|
123
142
|
)}
|