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.
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/internal/llms.d.ts +5 -0
- package/dist/internal/llms.d.ts.map +1 -1
- package/dist/internal/llms.js +22 -2
- package/dist/internal/llms.js.map +1 -1
- package/dist/internal/mcp.d.ts.map +1 -1
- package/dist/internal/mcp.js +49 -0
- package/dist/internal/mcp.js.map +1 -1
- package/dist/internal/mdx.d.ts +9 -1
- package/dist/internal/mdx.d.ts.map +1 -1
- package/dist/internal/mdx.js +85 -1
- package/dist/internal/mdx.js.map +1 -1
- package/dist/internal/openapi/openapi.d.ts +3 -3
- package/dist/internal/openapi/sidebar.d.ts +2 -2
- package/dist/internal/openapi/sidebar.d.ts.map +1 -1
- package/dist/internal/openapi/sidebar.js +5 -6
- package/dist/internal/openapi/sidebar.js.map +1 -1
- package/dist/internal/search.d.ts.map +1 -1
- package/dist/internal/search.js +6 -2
- package/dist/internal/search.js.map +1 -1
- package/dist/internal/vite-plugins.d.ts.map +1 -1
- package/dist/internal/vite-plugins.js +1 -0
- package/dist/internal/vite-plugins.js.map +1 -1
- package/dist/mdx.d.ts.map +1 -1
- package/dist/mdx.js +4 -1
- package/dist/mdx.js.map +1 -1
- package/dist/react/Prompt.d.ts +8 -0
- package/dist/react/Prompt.d.ts.map +1 -0
- package/dist/react/Prompt.js +16 -0
- package/dist/react/Prompt.js.map +1 -0
- package/dist/react/internal/Prompt.client.d.ts +10 -0
- package/dist/react/internal/Prompt.client.d.ts.map +1 -0
- package/dist/react/internal/Prompt.client.js +50 -0
- package/dist/react/internal/Prompt.client.js.map +1 -0
- package/dist/react/internal/prompt.d.ts +9 -0
- package/dist/react/internal/prompt.d.ts.map +1 -0
- package/dist/react/internal/prompt.js +63 -0
- package/dist/react/internal/prompt.js.map +1 -0
- package/dist/server/openapi/assets.generated.js +3 -3
- package/dist/server/openapi/assets.generated.js.map +1 -1
- package/dist/styles/markdown.css +132 -0
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +2 -0
- package/dist/vite.js.map +1 -1
- package/dist/waku/internal/middleware/md-router.d.ts.map +1 -1
- package/dist/waku/internal/middleware/md-router.js +1 -0
- package/dist/waku/internal/middleware/md-router.js.map +1 -1
- package/package.json +2 -1
- package/src/index.ts +1 -0
- package/src/internal/llms.test.ts +43 -1
- package/src/internal/llms.ts +31 -3
- package/src/internal/mcp.test.ts +75 -1
- package/src/internal/mcp.ts +54 -0
- package/src/internal/mdx.test.ts +55 -2
- package/src/internal/mdx.ts +102 -2
- package/src/internal/openapi/openapi.ts +3 -3
- package/src/internal/openapi/sidebar.test.ts +3 -5
- package/src/internal/openapi/sidebar.ts +7 -8
- package/src/internal/search.test.ts +38 -0
- package/src/internal/search.ts +12 -3
- package/src/internal/vite-plugins.ts +1 -0
- package/src/mdx.tsx +9 -1
- package/src/react/Prompt.test.tsx +27 -0
- package/src/react/Prompt.tsx +47 -0
- package/src/react/internal/Prompt.client.test.tsx +115 -0
- package/src/react/internal/Prompt.client.tsx +160 -0
- package/src/react/internal/prompt.test.ts +45 -0
- package/src/react/internal/prompt.ts +78 -0
- package/src/server/openapi/assets.generated.ts +3 -3
- package/src/styles/markdown.css +132 -0
- package/src/vite.ts +2 -0
- package/src/waku/internal/middleware/md-router.ts +1 -0
package/src/internal/mdx.test.ts
CHANGED
|
@@ -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)
|
|
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
|
|
package/src/internal/mdx.ts
CHANGED
|
@@ -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.
|
|
59
|
-
* active page still auto-
|
|
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
|
|
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).
|
|
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
|
|
65
|
-
//
|
|
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
|
|
93
|
-
//
|
|
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
|
|
121
|
-
* auto-
|
|
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'
|
package/src/internal/search.ts
CHANGED
|
@@ -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 {
|
|
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
|
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 {
|
|
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&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
|
+
}
|