vocs 2.5.2 → 2.6.0

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 (75) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/internal/llms.d.ts +5 -0
  6. package/dist/internal/llms.d.ts.map +1 -1
  7. package/dist/internal/llms.js +22 -2
  8. package/dist/internal/llms.js.map +1 -1
  9. package/dist/internal/mcp.d.ts.map +1 -1
  10. package/dist/internal/mcp.js +49 -0
  11. package/dist/internal/mcp.js.map +1 -1
  12. package/dist/internal/mdx.d.ts +9 -1
  13. package/dist/internal/mdx.d.ts.map +1 -1
  14. package/dist/internal/mdx.js +85 -1
  15. package/dist/internal/mdx.js.map +1 -1
  16. package/dist/internal/openapi/openapi.d.ts +3 -3
  17. package/dist/internal/openapi/sidebar.d.ts +2 -2
  18. package/dist/internal/openapi/sidebar.d.ts.map +1 -1
  19. package/dist/internal/openapi/sidebar.js +5 -6
  20. package/dist/internal/openapi/sidebar.js.map +1 -1
  21. package/dist/internal/search.d.ts.map +1 -1
  22. package/dist/internal/search.js +6 -2
  23. package/dist/internal/search.js.map +1 -1
  24. package/dist/internal/vite-plugins.d.ts.map +1 -1
  25. package/dist/internal/vite-plugins.js +1 -0
  26. package/dist/internal/vite-plugins.js.map +1 -1
  27. package/dist/mdx.d.ts.map +1 -1
  28. package/dist/mdx.js +4 -1
  29. package/dist/mdx.js.map +1 -1
  30. package/dist/react/Prompt.d.ts +8 -0
  31. package/dist/react/Prompt.d.ts.map +1 -0
  32. package/dist/react/Prompt.js +16 -0
  33. package/dist/react/Prompt.js.map +1 -0
  34. package/dist/react/internal/Prompt.client.d.ts +10 -0
  35. package/dist/react/internal/Prompt.client.d.ts.map +1 -0
  36. package/dist/react/internal/Prompt.client.js +50 -0
  37. package/dist/react/internal/Prompt.client.js.map +1 -0
  38. package/dist/react/internal/prompt.d.ts +9 -0
  39. package/dist/react/internal/prompt.d.ts.map +1 -0
  40. package/dist/react/internal/prompt.js +63 -0
  41. package/dist/react/internal/prompt.js.map +1 -0
  42. package/dist/server/openapi/assets.generated.js +3 -3
  43. package/dist/server/openapi/assets.generated.js.map +1 -1
  44. package/dist/styles/markdown.css +132 -0
  45. package/dist/vite.d.ts.map +1 -1
  46. package/dist/vite.js +2 -0
  47. package/dist/vite.js.map +1 -1
  48. package/dist/waku/internal/middleware/md-router.d.ts.map +1 -1
  49. package/dist/waku/internal/middleware/md-router.js +1 -0
  50. package/dist/waku/internal/middleware/md-router.js.map +1 -1
  51. package/package.json +2 -1
  52. package/src/index.ts +1 -0
  53. package/src/internal/llms.test.ts +43 -1
  54. package/src/internal/llms.ts +31 -3
  55. package/src/internal/mcp.test.ts +75 -1
  56. package/src/internal/mcp.ts +54 -0
  57. package/src/internal/mdx.test.ts +55 -2
  58. package/src/internal/mdx.ts +102 -2
  59. package/src/internal/openapi/openapi.ts +3 -3
  60. package/src/internal/openapi/sidebar.test.ts +3 -5
  61. package/src/internal/openapi/sidebar.ts +7 -8
  62. package/src/internal/search.test.ts +38 -0
  63. package/src/internal/search.ts +12 -3
  64. package/src/internal/vite-plugins.ts +1 -0
  65. package/src/mdx.tsx +9 -1
  66. package/src/react/Prompt.test.tsx +27 -0
  67. package/src/react/Prompt.tsx +47 -0
  68. package/src/react/internal/Prompt.client.test.tsx +115 -0
  69. package/src/react/internal/Prompt.client.tsx +160 -0
  70. package/src/react/internal/prompt.test.ts +45 -0
  71. package/src/react/internal/prompt.ts +78 -0
  72. package/src/server/openapi/assets.generated.ts +3 -3
  73. package/src/styles/markdown.css +132 -0
  74. package/src/vite.ts +2 -0
  75. package/src/waku/internal/middleware/md-router.ts +1 -0
@@ -20,6 +20,7 @@ import {
20
20
  remarkFilename,
21
21
  remarkFileTree,
22
22
  remarkLangCommaAttrs,
23
+ remarkPrompt,
23
24
  remarkRestoreUnknownTextDirectives,
24
25
  remarkSubheading,
25
26
  } from './mdx.js'
@@ -128,11 +129,63 @@ describe('remarkSubheading', () => {
128
129
  })
129
130
  })
130
131
 
132
+ describe('remarkPrompt', () => {
133
+ it('turns prompt directives into opaque prompt blocks', async () => {
134
+ const value = [
135
+ 'Read https://vocs.dev.',
136
+ '',
137
+ 'Requirements:',
138
+ '- Run `pnpm build`.',
139
+ '- Replace <PROJECT_NAME>.',
140
+ '- Preserve {literal} braces.',
141
+ ].join('\n')
142
+ const tree = await runRemark(`:::prompt\n${value}\n:::`, [remarkDirective, remarkPrompt])
143
+
144
+ expect(tree.children[0]).toMatchObject({
145
+ type: 'paragraph',
146
+ children: [],
147
+ data: {
148
+ hName: 'pre',
149
+ hProperties: { 'data-v-prompt': value },
150
+ },
151
+ })
152
+ })
153
+
154
+ it('leaves prompt examples in code fences unchanged', async () => {
155
+ const value = ':::prompt\nReplace <PROJECT_NAME>.\n:::'
156
+ const tree = await runRemark(`\`\`\`md\n${value}\n\`\`\``, [remarkDirective, remarkPrompt])
157
+
158
+ expect(tree.children[0]).toMatchObject({ type: 'code', lang: 'md', value })
159
+ })
160
+
161
+ it('restores prompt directives as code blocks for markdown output', async () => {
162
+ const value = 'Replace <PROJECT_NAME> and preserve {literal} braces.'
163
+ const tree = await runRemark(`:::prompt\n${value}\n:::`, [
164
+ remarkDirective,
165
+ [remarkPrompt, { output: 'code' }],
166
+ ])
167
+
168
+ expect(tree.children[0]).toMatchObject({ type: 'code', lang: 'prompt', value })
169
+ })
170
+
171
+ it('leaves other directives unchanged', async () => {
172
+ const tree = await runRemark(':::note\nhello\n:::', [remarkDirective, remarkPrompt])
173
+
174
+ expect(tree.children[0]).toMatchObject({
175
+ type: 'containerDirective',
176
+ name: 'note',
177
+ })
178
+ })
179
+ })
180
+
131
181
  async function runRemark(markdown: string, plugins: unknown[]) {
132
182
  const processor = unified().use(remarkParse)
133
- for (const plugin of plugins) processor.use(plugin as never)
183
+ for (const plugin of plugins) {
184
+ if (Array.isArray(plugin)) processor.use(plugin[0] as never, plugin[1] as never)
185
+ else processor.use(plugin as never)
186
+ }
134
187
  const tree = processor.parse(markdown) as MdAst.Root
135
- await processor.run(tree)
188
+ await processor.run(tree, markdown)
136
189
  return tree
137
190
  }
138
191
 
@@ -29,7 +29,7 @@ import solidity from 'shiki/langs/solidity.mjs'
29
29
  import sql from 'shiki/langs/sql.mjs'
30
30
  import toml from 'shiki/langs/toml.mjs'
31
31
  import yamlLang from 'shiki/langs/yaml.mjs'
32
- import { type Pluggable, type PluggableList, unified } from 'unified'
32
+ import { type Pluggable, type PluggableList, type Processor, unified } from 'unified'
33
33
  import * as UnistUtil from 'unist-util-visit'
34
34
  import type { VFile } from 'vfile'
35
35
  import { createLogger } from 'vite'
@@ -125,6 +125,104 @@ export declare namespace remarkMermaid {
125
125
  type ReturnType = (tree: MdAst.Root) => void
126
126
  }
127
127
 
128
+ /** Masks prompt bodies during parsing so MDX syntax remains literal prompt text. */
129
+ function maskPromptBodies(markdown: string): string {
130
+ let codeFence: string | undefined
131
+ let promptFenceSize = 0
132
+
133
+ return markdown
134
+ .split(/(?<=\n)/)
135
+ .map((line) => {
136
+ const value = line.replace(/\r?\n$/, '')
137
+
138
+ if (promptFenceSize) {
139
+ const closing = value.match(/^ {0,3}(:{3,})[ \t]*$/)?.[1]
140
+ if (closing && closing.length >= promptFenceSize) {
141
+ promptFenceSize = 0
142
+ return line
143
+ }
144
+ return line.replace(/[^\r\n]/g, 'x')
145
+ }
146
+
147
+ if (codeFence) {
148
+ const closing = value.match(/^ {0,3}(`{3,}|~{3,})[ \t]*$/)?.[1]
149
+ if (closing && closing[0] === codeFence[0] && closing.length >= codeFence.length)
150
+ codeFence = undefined
151
+ return line
152
+ }
153
+
154
+ const nextCodeFence = value.match(/^ {0,3}(`{3,}|~{3,})/)?.[1]
155
+ if (nextCodeFence) {
156
+ const info = value.slice(value.indexOf(nextCodeFence) + nextCodeFence.length)
157
+ if (nextCodeFence[0] === '~' || !info.includes('`')) codeFence = nextCodeFence
158
+ return line
159
+ }
160
+
161
+ const promptFence = value.match(/^ {0,3}(:{3,})prompt[ \t]*$/)?.[1]
162
+ if (promptFence) promptFenceSize = promptFence.length
163
+ return line
164
+ })
165
+ .join('')
166
+ }
167
+
168
+ /** Transforms prompt directives into prompt blocks. */
169
+ export function remarkPrompt(
170
+ this: Processor,
171
+ options: remarkPrompt.Options = {},
172
+ ): remarkPrompt.ReturnType {
173
+ const parser = this.parser
174
+ if (!parser) throw new Error('`remarkPrompt` requires a parser')
175
+ this.parser = (document, file) => parser(maskPromptBodies(document), file)
176
+
177
+ return (tree: MdAst.Root, file: VFile) => {
178
+ const raw = typeof file.value === 'string' ? file.value : file.value?.toString()
179
+ if (!raw) return
180
+
181
+ UnistUtil.visit(tree, (node, index, parent) => {
182
+ if (node.type !== 'containerDirective') return
183
+ if (node.name !== 'prompt') return
184
+ if (index === undefined || !parent) return
185
+
186
+ const start = node.position?.start.offset
187
+ const end = node.position?.end.offset
188
+ if (start === undefined || end === undefined) return
189
+
190
+ const source = raw.slice(start, end)
191
+ const bodyStart = source.indexOf('\n')
192
+ if (bodyStart === -1) return
193
+
194
+ const closingStart = source.lastIndexOf('\n')
195
+ const closingLine = source.slice(closingStart + 1).replace(/\r$/, '')
196
+ const bodyEnd = /^[ \t]*:{3,}[ \t]*$/.test(closingLine) ? closingStart : source.length
197
+ const value = source.slice(bodyStart + 1, bodyEnd)
198
+
199
+ const replacement =
200
+ options.output === 'code'
201
+ ? ({ type: 'code', lang: 'prompt', value } satisfies MdAst.Code)
202
+ : ({
203
+ type: 'paragraph',
204
+ children: [],
205
+ data: {
206
+ hName: 'pre',
207
+ hProperties: {
208
+ 'data-v-prompt': value,
209
+ },
210
+ },
211
+ } satisfies MdAst.Paragraph)
212
+
213
+ parent.children.splice(index, 1, replacement)
214
+ return UnistUtil.SKIP
215
+ })
216
+ }
217
+ }
218
+
219
+ export declare namespace remarkPrompt {
220
+ type Options = {
221
+ output?: 'code' | 'element' | undefined
222
+ }
223
+ type ReturnType = (tree: MdAst.Root, file: VFile) => void
224
+ }
225
+
128
226
  /**
129
227
  * Remark plugin that injects an ephemeral source-map comment into the body of
130
228
  * each `twoslash` code block. The comment carries the block's source position
@@ -220,6 +318,7 @@ export function getCompileOptions(
220
318
  // directives can render real markdown below. Unhandled directives
221
319
  // round-trip back to their source form on stringify.
222
320
  remarkDirective,
321
+ [remarkPrompt, { output: 'code' }] as Pluggable,
223
322
  [remarkChangelogMarkdown, config] as Pluggable,
224
323
  // User plugins extend the parser (e.g. `remark-math`) so syntax
225
324
  // recognized in the React build also parses for llms/search.
@@ -287,15 +386,16 @@ export function getCompileOptions(
287
386
  remarkPlugins: [
288
387
  remarkMermaid,
289
388
  remarkFrontmatter,
389
+ remarkDirective,
290
390
  [remarkFileTree, config] as Pluggable,
291
391
  remarkCallout,
292
392
  remarkChangelog,
293
393
  remarkCodeGroup,
394
+ remarkPrompt,
294
395
  remarkLangCommaAttrs,
295
396
  [remarkCodeTitle, { additionalLanguages: additionalLanguageNames }] as Pluggable,
296
397
  remarkDefaultFrontmatter,
297
398
  remarkDetails,
298
- remarkDirective,
299
399
  remarkBadge,
300
400
  remarkFilename,
301
401
  remarkGfm,
@@ -55,9 +55,9 @@ export type SidebarExtras = {
55
55
  */
56
56
  intro?: SidebarItem[] | undefined
57
57
  /**
58
- * Collapse the generated category groups by default. The group matching the
59
- * active page still auto-expands, so deep links stay navigable. The generated
60
- * `Introduction` entry is unaffected.
58
+ * Collapse the generated category groups and `x-tagGroups` sections by default.
59
+ * Groups containing the active page still auto-expand, so deep links stay
60
+ * navigable. The generated `Introduction` entry is unaffected.
61
61
  *
62
62
  * @default false
63
63
  */
@@ -94,7 +94,7 @@ describe('toSidebar', () => {
94
94
  expect((sidebar[1] as { collapsed: boolean }).collapsed).toBe(true)
95
95
  })
96
96
 
97
- test('nests claimed groups under static section headers from tagGroups', () => {
97
+ test('nests claimed groups under collapsible section headers from tagGroups', () => {
98
98
  const sectioned: Ir = {
99
99
  ...ir,
100
100
  groups: [
@@ -121,13 +121,11 @@ describe('toSidebar', () => {
121
121
  }
122
122
  const sidebar = toSidebar(sectioned, { collapsed: true })
123
123
  expect(sidebar.map((item) => item.text)).toEqual(['Introduction', 'Data API', 'Platform API'])
124
- // Sections are static headers (not collapsible); nested groups keep the
125
- // `collapsed` behavior.
126
124
  const section = sidebar[1] as {
127
125
  collapsed?: boolean
128
126
  items: { text?: string; collapsed?: boolean }[]
129
127
  } // prettier-ignore
130
- expect(section.collapsed).toBeUndefined()
128
+ expect(section.collapsed).toBe(true)
131
129
  expect(section.items.map((item) => item.text)).toEqual(['pets'])
132
130
  expect(section.items[0]?.collapsed).toBe(true)
133
131
  })
@@ -171,7 +169,7 @@ describe('toSidebar', () => {
171
169
  }
172
170
  const sidebar = toSidebar(sectioned, { flatten: ['Data API'] })
173
171
  // `pets` renders top-level where its section would sit; `Platform API`
174
- // keeps its section header.
172
+ // keeps its static section header.
175
173
  expect(sidebar.map((item) => item.text)).toEqual(['Introduction', 'pets', 'Platform API'])
176
174
  const pets = sidebar[1] as { collapsed?: boolean }
177
175
  const section = sidebar[2] as { collapsed?: boolean; items: { text?: string }[] }
@@ -61,8 +61,8 @@ export function toSidebar(ir: Ir, options: toSidebar.Options = {}): SidebarItem<
61
61
  // group id, rendered after the group's "Overview" link.
62
62
  const groupExtras = options.groupExtras ?? new Map<string, SidebarItem[]>()
63
63
 
64
- // Generated category groups start collapsed when `collapsed` is set (the
65
- // active group still auto-expands at render). `Introduction` is unaffected.
64
+ // Generated category groups and sections start collapsed when `collapsed` is
65
+ // set. Active groups still auto-expand at render. `Introduction` is unaffected.
66
66
  const groupCollapsed = options.collapsed ?? false
67
67
 
68
68
  const groupItem = (group: Ir['groups'][number]): SidebarItem<true> => ({
@@ -89,9 +89,8 @@ export function toSidebar(ir: Ir, options: toSidebar.Options = {}): SidebarItem<
89
89
  const tagGroups = ir.tagGroups ?? []
90
90
  if (tagGroups.length === 0) return [introduction, ...ir.groups.map(groupItem)]
91
91
 
92
- // `x-tagGroups` sections render as static headers (no `collapsed`, so not
93
- // collapsible); the per-category `collapsed` behavior applies unchanged.
94
- // Sections named in `flatten` spread their categories in place instead.
92
+ // `x-tagGroups` sections render as collapsible headers. Sections named in
93
+ // `flatten` spread their categories in place instead.
95
94
  const flatten = new Set(options.flatten ?? [])
96
95
  const groupById = new Map(ir.groups.map((group) => [group.id, group]))
97
96
  const claimed = new Set(tagGroups.flatMap((tagGroup) => tagGroup.groupIds))
@@ -101,7 +100,7 @@ export function toSidebar(ir: Ir, options: toSidebar.Options = {}): SidebarItem<
101
100
  return group ? [groupItem(group)] : []
102
101
  })
103
102
  if (flatten.has(tagGroup.name)) return items
104
- return [{ text: tagGroup.name, items }]
103
+ return [{ text: tagGroup.name, collapsed: options.collapsed, items }]
105
104
  })
106
105
  return [
107
106
  introduction,
@@ -117,8 +116,8 @@ export declare namespace toSidebar {
117
116
  /** Extra items injected into a generated group, keyed by group id. */
118
117
  groupExtras?: Map<string, SidebarItem[]> | undefined
119
118
  /**
120
- * Collapse the generated category groups by default (the active group still
121
- * auto-expands). `Introduction` is unaffected. @default false
119
+ * Collapse generated category groups and `x-tagGroups` sections by default.
120
+ * Active groups still auto-expand. `Introduction` is unaffected. @default false
122
121
  */
123
122
  collapsed?: boolean | undefined
124
123
  /**
@@ -40,6 +40,22 @@ Some text.
40
40
  expect(sections.length).toBe(1)
41
41
  })
42
42
 
43
+ it('strips inline twoslash cache comments from code blocks', () => {
44
+ const content = `
45
+ # Getting Started
46
+
47
+ Set up your client.
48
+
49
+ \`\`\`ts twoslash
50
+ // @twoslash-cache: {"v":1,"hash":"abc123","data":"N4Igdg9gJgpgziAXAbVAFwJ4AcZJACwgDcYAnEAGhDRgA808AKAQwBsBLZuASgAIAzAK5gAxmnYQwvEaRjMaABUEAjDiIDCHGMBpQ4vACqHjps+YvT8zdlKvbbvdR5evAA+vMKw/F4wULwAvGE2MJFg0W7MIiIQwmgA8qQAglBQsnB2PjZ2+RlZOiG8hcXwdqHhSVEx8a3JqbykWCIAyiL4MAC2zJb0vnYASv1DI+N1Xe1xCRHtbpnGFmgQANbaZVMVDgdHy4ndHettKVAAfIxYzIajMDSkcIi8SqrsGi06nUiA"]}
51
+ import { createPublicClient } from 'viem'
52
+ \`\`\`
53
+ `
54
+ const [section] = Search.extract(content, config).sections
55
+ expect(section?.text).not.toContain('@twoslash-cache')
56
+ expect(section?.text).toContain("import { createPublicClient } from 'viem'")
57
+ })
58
+
43
59
  it('extracts sections from headings', () => {
44
60
  const content = `
45
61
  # Getting Started
@@ -843,6 +859,28 @@ const x = 1
843
859
  `)
844
860
  })
845
861
 
862
+ it('includes literal prompt directive content', () => {
863
+ const content = `
864
+ # Agent Setup
865
+
866
+ :::prompt
867
+ Replace <PROJECT_NAME> and preserve {literal} braces.
868
+ :::
869
+ `
870
+ expect(Search.extract(content, config).sections).toMatchInlineSnapshot(`
871
+ [
872
+ {
873
+ "anchor": "agent-setup",
874
+ "isPage": true,
875
+ "subtitle": "",
876
+ "text": " Replace <PROJECT_NAME> and preserve {literal} braces.",
877
+ "title": "Agent Setup",
878
+ "titles": [],
879
+ },
880
+ ]
881
+ `)
882
+ })
883
+
846
884
  it('skips imports at the top of MDX files', () => {
847
885
  const content = `import { Example } from '../components/Example'
848
886
  import { useState } from 'react'
@@ -9,12 +9,17 @@ import remarkFrontmatter from 'remark-frontmatter'
9
9
  import remarkGfm from 'remark-gfm'
10
10
  import remarkMdx from 'remark-mdx'
11
11
  import remarkParse from 'remark-parse'
12
- import { unified } from 'unified'
12
+ import { type Pluggable, unified } from 'unified'
13
13
  import * as UnistUtil from 'unist-util-visit'
14
14
  import * as yaml from 'yaml'
15
15
  import type * as Config from './config.js'
16
16
  import * as MarkdownImports from './markdown-imports.js'
17
- import { extractSubheading, getPhrasingContentText } from './mdx.js'
17
+ import {
18
+ extractSubheading,
19
+ getPhrasingContentText,
20
+ remarkPrompt,
21
+ remarkStripInlineCache,
22
+ } from './mdx.js'
18
23
  import * as OpenApiRegistry from './openapi/registry.js'
19
24
  import * as OpenApiSearch from './openapi/search.js'
20
25
  import * as Path from './path.js'
@@ -320,9 +325,13 @@ export function extract(source: string, config: Config.Config): extract.ReturnTy
320
325
  const remarkPlugins = [
321
326
  remarkFrontmatter,
322
327
  remarkDirective,
328
+ [remarkPrompt, { output: 'code' }] as Pluggable,
323
329
  remarkGfm,
324
330
  remarkStripJsx,
325
331
  remarkStripDirectives,
332
+ // Persisted twoslash cache comments are giant base64 blobs; without this
333
+ // they leak into section text (and search snippets/embeddings).
334
+ remarkStripInlineCache,
326
335
  // User plugins extend the parser (e.g. `remark-math`) so syntax
327
336
  // recognized in the React build also parses during indexing.
328
337
  ...(config.markdown?.remarkPlugins ?? []),
@@ -340,7 +349,7 @@ export function extract(source: string, config: Config.Config): extract.ReturnTy
340
349
  // Single parse for both positions and text extraction
341
350
  const processor = unified().use(remarkParse).use(remarkMdx).use(remarkPlugins)
342
351
  const tree = processor.parse(source)
343
- processor.runSync(tree)
352
+ processor.runSync(tree, source)
344
353
 
345
354
  // Collect headings (plugins strip JSX/directives but preserve headings)
346
355
  const headingPositions = tree.children
@@ -186,6 +186,7 @@ export function llms(config: Config.Config): PluginOption {
186
186
  pages: [...openapiPages, ...pages],
187
187
  title,
188
188
  description,
189
+ pagePrelude: Llms.getMarkdownPagePrelude(config),
189
190
  rehypePlugins,
190
191
  remarkPlugins,
191
192
  sidebar: config.sidebar,
package/src/mdx.tsx CHANGED
@@ -12,6 +12,7 @@ import { Terminal } from './react/internal/Terminal.mdx.js'
12
12
  import { TwoslashCompletionList } from './react/internal/TwoslashCompletionList.js'
13
13
  import { TwoslashHover } from './react/internal/TwoslashHover.js'
14
14
  import { Link } from './react/Link.js'
15
+ import { Prompt } from './react/Prompt.js'
15
16
 
16
17
  export const components: MDXComponents = {
17
18
  a(props: React.ComponentProps<'a'> & { children: React.ReactNode }) {
@@ -52,9 +53,16 @@ export const components: MDXComponents = {
52
53
  'data-v-codeToHtml'?: string | undefined
53
54
  'data-v-code'?: string | undefined
54
55
  'data-v-lang'?: string | undefined
56
+ 'data-v-prompt'?: string | undefined
55
57
  },
56
58
  ) {
57
- const { 'data-v-codeToHtml': codeToHtml, 'data-v-code': code, 'data-v-lang': lang } = props
59
+ const {
60
+ 'data-v-codeToHtml': codeToHtml,
61
+ 'data-v-code': code,
62
+ 'data-v-lang': lang,
63
+ 'data-v-prompt': prompt,
64
+ } = props
65
+ if (prompt !== undefined) return <Prompt value={prompt} />
58
66
  if (codeToHtml && code && lang) return <CodeToHtml code={code} lang={lang} />
59
67
  return <CodeBlock {...props} />
60
68
  },
@@ -0,0 +1,27 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { Prompt } from './Prompt.js'
4
+
5
+ describe('Prompt', () => {
6
+ it('renders detected tokens and copy controls', () => {
7
+ const html = renderToStaticMarkup(
8
+ <Prompt
9
+ className="custom"
10
+ value={'Read https://vocs.dev and update `vocs.config.ts`.\n\nRequirements:'}
11
+ />,
12
+ )
13
+
14
+ expect(html).toContain('class="custom"')
15
+ expect(html).toContain('data-v-prompt="true"')
16
+ expect(html).toContain('data-v-prompt-token="url"')
17
+ expect(html).toContain('href="https://vocs.dev"')
18
+ expect(html).toContain('data-v-prompt-token="code"')
19
+ expect(html).toContain('data-v-prompt-token="label"')
20
+ expect(html).toContain('data-v-prompt-icon="true"')
21
+ expect(html).toContain('aria-label="Copy instructions for agent"')
22
+ expect(html).toContain('aria-expanded="false"')
23
+ expect(html).toContain('aria-label="View Prompt"')
24
+ expect(html).toContain('https://chatgpt.com?hints=search&amp;q=')
25
+ expect(html).toContain('https://claude.ai/new?q=')
26
+ })
27
+ })
@@ -0,0 +1,47 @@
1
+ import { PromptFrame } from './internal/Prompt.client.js'
2
+ import * as PromptTokens from './internal/prompt.js'
3
+
4
+ export function Prompt(props: Prompt.Props) {
5
+ const { className, value } = props
6
+ const tokens = PromptTokens.tokenize(value)
7
+
8
+ return (
9
+ <PromptFrame className={className} value={value}>
10
+ {tokens.map((token, index) => {
11
+ const key = `${token.type}-${index.toString()}`
12
+ if (token.type === 'url')
13
+ return (
14
+ <a
15
+ data-v-prompt-token={token.type}
16
+ href={token.value}
17
+ key={key}
18
+ rel="noopener noreferrer"
19
+ target="_blank"
20
+ >
21
+ {token.value}
22
+ </a>
23
+ )
24
+ if (token.type === 'code')
25
+ return (
26
+ <span data-v-prompt-token={token.type} key={key}>
27
+ <span data-v-prompt-delimiter>`</span>
28
+ {token.value.slice(1, -1)}
29
+ <span data-v-prompt-delimiter>`</span>
30
+ </span>
31
+ )
32
+ return (
33
+ <span data-v-prompt-token={token.type} key={key}>
34
+ {token.value}
35
+ </span>
36
+ )
37
+ })}
38
+ </PromptFrame>
39
+ )
40
+ }
41
+
42
+ export declare namespace Prompt {
43
+ type Props = {
44
+ className?: string | undefined
45
+ value: string
46
+ }
47
+ }
@@ -0,0 +1,115 @@
1
+ /** @vitest-environment jsdom */
2
+
3
+ import { act } from 'react'
4
+ import { createRoot, type Root } from 'react-dom/client'
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6
+ import { PromptFrame } from './Prompt.client.js'
7
+
8
+ let container: HTMLDivElement
9
+ let root: Root
10
+
11
+ beforeEach(() => {
12
+ container = document.createElement('div')
13
+ document.body.append(container)
14
+ root = createRoot(container)
15
+ })
16
+
17
+ afterEach(() => {
18
+ act(() => root.unmount())
19
+ container.remove()
20
+ vi.restoreAllMocks()
21
+ })
22
+
23
+ describe('PromptFrame', () => {
24
+ it('starts collapsed and toggles prompt visibility without copying', async () => {
25
+ const writeText = vi.fn().mockResolvedValue(undefined)
26
+ setClipboard({ writeText })
27
+ await render('Prompt')
28
+
29
+ const content = container.querySelector<HTMLElement>('[data-v-prompt-content]')
30
+ const toggle = container.querySelector<HTMLButtonElement>('[data-v-prompt-toggle]')
31
+ expect(content?.hidden).toBe(true)
32
+ expect(toggle?.getAttribute('aria-expanded')).toBe('false')
33
+ expect(toggle?.getAttribute('aria-label')).toBe('View Prompt')
34
+ expect(container.querySelectorAll('[data-v-prompt-action]')).toHaveLength(3)
35
+
36
+ await act(async () => toggle?.click())
37
+
38
+ expect(content?.hidden).toBe(false)
39
+ expect(toggle?.getAttribute('aria-expanded')).toBe('true')
40
+ expect(toggle?.getAttribute('aria-label')).toBe('Hide Prompt')
41
+ expect(writeText).not.toHaveBeenCalled()
42
+
43
+ await act(async () => toggle?.click())
44
+
45
+ expect(content?.hidden).toBe(true)
46
+ expect(toggle?.getAttribute('aria-label')).toBe('View Prompt')
47
+ })
48
+
49
+ it('copies the exact prompt from the primary action and expanded body', async () => {
50
+ const writeText = vi.fn().mockResolvedValue(undefined)
51
+ setClipboard({ writeText })
52
+ await render('First line\n\nSecond line')
53
+
54
+ await act(async () =>
55
+ container.querySelector<HTMLButtonElement>('[data-v-prompt-copy]')?.click(),
56
+ )
57
+ await act(async () =>
58
+ container.querySelector<HTMLButtonElement>('[data-v-prompt-toggle]')?.click(),
59
+ )
60
+ await act(async () => container.querySelector('pre')?.click())
61
+
62
+ expect(writeText).toHaveBeenNthCalledWith(1, 'First line\n\nSecond line')
63
+ expect(writeText).toHaveBeenNthCalledWith(2, 'First line\n\nSecond line')
64
+ expect(container.querySelector('figure')?.getAttribute('data-state')).toBe('copied')
65
+ })
66
+
67
+ it('preserves links and text selection', async () => {
68
+ const writeText = vi.fn().mockResolvedValue(undefined)
69
+ setClipboard({ writeText })
70
+ await render('Prompt', <a href="#target">Link</a>)
71
+
72
+ await act(async () =>
73
+ container.querySelector<HTMLButtonElement>('[data-v-prompt-toggle]')?.click(),
74
+ )
75
+ await act(async () => container.querySelector('a')?.click())
76
+ vi.spyOn(window, 'getSelection').mockReturnValue({ isCollapsed: false } as Selection)
77
+ await act(async () => container.querySelector('pre')?.click())
78
+
79
+ expect(writeText).not.toHaveBeenCalled()
80
+ })
81
+
82
+ it('reports rejected copies', async () => {
83
+ setClipboard({ writeText: vi.fn().mockRejectedValue(new Error('denied')) })
84
+ await render('Prompt')
85
+
86
+ await act(async () =>
87
+ container.querySelector<HTMLButtonElement>('[data-v-prompt-copy]')?.click(),
88
+ )
89
+
90
+ expect(container.querySelector('[data-v-prompt-copy]')?.getAttribute('data-state')).toBe(
91
+ 'error',
92
+ )
93
+ })
94
+
95
+ it('reports unavailable clipboard access', async () => {
96
+ setClipboard(undefined)
97
+ await render('Prompt')
98
+
99
+ await act(async () =>
100
+ container.querySelector<HTMLButtonElement>('[data-v-prompt-copy]')?.click(),
101
+ )
102
+
103
+ expect(container.querySelector('[data-v-prompt-copy]')?.getAttribute('data-state')).toBe(
104
+ 'error',
105
+ )
106
+ })
107
+ })
108
+
109
+ async function render(value: string, children: React.ReactNode = value) {
110
+ await act(async () => root.render(<PromptFrame value={value}>{children}</PromptFrame>))
111
+ }
112
+
113
+ function setClipboard(clipboard: Pick<Clipboard, 'writeText'> | undefined) {
114
+ Object.defineProperty(navigator, 'clipboard', { configurable: true, value: clipboard })
115
+ }