vocs 2.1.12 → 2.2.3

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 (76) hide show
  1. package/dist/internal/config.d.ts +246 -9
  2. package/dist/internal/config.d.ts.map +1 -1
  3. package/dist/internal/config.js +38 -9
  4. package/dist/internal/config.js.map +1 -1
  5. package/dist/internal/llms.d.ts.map +1 -1
  6. package/dist/internal/llms.js +5 -1
  7. package/dist/internal/llms.js.map +1 -1
  8. package/dist/internal/markdown-imports.d.ts +2 -0
  9. package/dist/internal/markdown-imports.d.ts.map +1 -0
  10. package/dist/internal/markdown-imports.js +103 -0
  11. package/dist/internal/markdown-imports.js.map +1 -0
  12. package/dist/internal/openapi/parser.d.ts +7 -1
  13. package/dist/internal/openapi/parser.d.ts.map +1 -1
  14. package/dist/internal/openapi/parser.js +30 -2
  15. package/dist/internal/openapi/parser.js.map +1 -1
  16. package/dist/internal/openapi/sidebar.d.ts.map +1 -1
  17. package/dist/internal/openapi/sidebar.js +8 -1
  18. package/dist/internal/openapi/sidebar.js.map +1 -1
  19. package/dist/internal/search.client.d.ts +6 -0
  20. package/dist/internal/search.client.d.ts.map +1 -1
  21. package/dist/internal/search.client.js +23 -0
  22. package/dist/internal/search.client.js.map +1 -1
  23. package/dist/internal/search.d.ts +2 -1
  24. package/dist/internal/search.d.ts.map +1 -1
  25. package/dist/internal/search.js +10 -14
  26. package/dist/internal/search.js.map +1 -1
  27. package/dist/internal/sidebar.d.ts +8 -2
  28. package/dist/internal/sidebar.d.ts.map +1 -1
  29. package/dist/internal/sidebar.js.map +1 -1
  30. package/dist/internal/vite-plugins.js +1 -1
  31. package/dist/internal/vite-plugins.js.map +1 -1
  32. package/dist/react/Link.d.ts.map +1 -1
  33. package/dist/react/Link.js +11 -6
  34. package/dist/react/Link.js.map +1 -1
  35. package/dist/react/internal/Outline.d.ts.map +1 -1
  36. package/dist/react/internal/Outline.js +1 -1
  37. package/dist/react/internal/Outline.js.map +1 -1
  38. package/dist/react/internal/Search.d.ts.map +1 -1
  39. package/dist/react/internal/Search.js +5 -7
  40. package/dist/react/internal/Search.js.map +1 -1
  41. package/dist/react/internal/Sidebar.d.ts.map +1 -1
  42. package/dist/react/internal/Sidebar.js +10 -3
  43. package/dist/react/internal/Sidebar.js.map +1 -1
  44. package/dist/react/internal/TwoslashHover.client.d.ts.map +1 -1
  45. package/dist/react/internal/TwoslashHover.client.js +4 -1
  46. package/dist/react/internal/TwoslashHover.client.js.map +1 -1
  47. package/dist/react/internal/openapi/Operation.d.ts.map +1 -1
  48. package/dist/react/internal/openapi/Operation.js +4 -2
  49. package/dist/react/internal/openapi/Operation.js.map +1 -1
  50. package/dist/server/openapi/assets.generated.js +4 -4
  51. package/dist/server/openapi/assets.generated.js.map +1 -1
  52. package/dist/styles/openapi.css +11 -0
  53. package/dist/styles/twoslash.css +1 -1
  54. package/package.json +1 -1
  55. package/src/internal/config.ts +294 -21
  56. package/src/internal/llms.test.ts +35 -0
  57. package/src/internal/llms.ts +6 -1
  58. package/src/internal/markdown-imports.ts +154 -0
  59. package/src/internal/openapi/parser.test.ts +39 -0
  60. package/src/internal/openapi/parser.ts +48 -3
  61. package/src/internal/openapi/sidebar.test.ts +31 -0
  62. package/src/internal/openapi/sidebar.ts +10 -1
  63. package/src/internal/search.client.ts +29 -0
  64. package/src/internal/search.test.ts +195 -0
  65. package/src/internal/search.ts +14 -14
  66. package/src/internal/sidebar.ts +8 -2
  67. package/src/internal/vite-plugins.ts +1 -1
  68. package/src/react/Link.tsx +12 -7
  69. package/src/react/internal/Outline.tsx +4 -1
  70. package/src/react/internal/Search.tsx +7 -10
  71. package/src/react/internal/Sidebar.tsx +17 -2
  72. package/src/react/internal/TwoslashHover.client.tsx +3 -0
  73. package/src/react/internal/openapi/Operation.tsx +4 -1
  74. package/src/server/openapi/assets.generated.ts +4 -4
  75. package/src/styles/openapi.css +11 -0
  76. package/src/styles/twoslash.css +1 -1
@@ -0,0 +1,154 @@
1
+ import * as fs from 'node:fs'
2
+ import * as path from 'node:path'
3
+ import type * as Estree from 'estree'
4
+ import type * as MdAst from 'mdast'
5
+ import remarkMdx from 'remark-mdx'
6
+ import remarkParse from 'remark-parse'
7
+ import { unified } from 'unified'
8
+
9
+ type MdxEsm = MdAst.RootContent & {
10
+ type: 'mdxjsEsm'
11
+ data?: {
12
+ estree?: Estree.Program
13
+ }
14
+ }
15
+
16
+ type MdxJsxElement = MdAst.RootContent & {
17
+ type: 'mdxJsxFlowElement' | 'mdxJsxTextElement'
18
+ attributes?: unknown[]
19
+ children?: MdAst.RootContent[]
20
+ name?: string | null
21
+ }
22
+
23
+ type MarkdownImport = {
24
+ source: string
25
+ }
26
+
27
+ type Replacement = {
28
+ end: number
29
+ start: number
30
+ value: string
31
+ }
32
+
33
+ export function inlineMarkdownImports(source: string, filePath: string): string {
34
+ return inline(source, path.resolve(filePath), new Set())
35
+ }
36
+
37
+ function inline(source: string, filePath: string, seen: Set<string>): string {
38
+ if (seen.has(filePath)) return ''
39
+ seen.add(filePath)
40
+
41
+ let tree: MdAst.Root
42
+ try {
43
+ tree = unified().use(remarkParse).use(remarkMdx).parse(source)
44
+ } catch {
45
+ return source
46
+ }
47
+
48
+ const imports = new Map<string, MarkdownImport>()
49
+ const replacements: Replacement[] = []
50
+
51
+ collectImports(tree.children, imports, replacements)
52
+ collectComponentReplacements(tree, imports, filePath, seen, replacements)
53
+
54
+ if (replacements.length === 0) return source
55
+ return applyReplacements(source, replacements)
56
+ }
57
+
58
+ function collectImports(
59
+ children: MdAst.RootContent[],
60
+ imports: Map<string, MarkdownImport>,
61
+ replacements: Replacement[],
62
+ ) {
63
+ for (const node of children) {
64
+ if (node.type !== 'mdxjsEsm') continue
65
+
66
+ const mdxEsm = node as MdxEsm
67
+ const declarations = mdxEsm.data?.estree?.body.filter(
68
+ (statement): statement is Estree.ImportDeclaration => statement.type === 'ImportDeclaration',
69
+ )
70
+ if (!declarations || declarations.length === 0) continue
71
+
72
+ let markdownDeclarations = 0
73
+ for (const declaration of declarations) {
74
+ const source = declaration.source.value
75
+ if (typeof source !== 'string' || !isMarkdownImport(source)) continue
76
+ markdownDeclarations++
77
+
78
+ const specifier = declaration.specifiers.find(
79
+ (specifier): specifier is Estree.ImportDefaultSpecifier =>
80
+ specifier.type === 'ImportDefaultSpecifier',
81
+ )
82
+ if (!specifier) continue
83
+ imports.set(specifier.local.name, { source })
84
+ }
85
+
86
+ if (markdownDeclarations === declarations.length) {
87
+ const replacement = toReplacement(node, '')
88
+ if (replacement) replacements.push(replacement)
89
+ }
90
+ }
91
+ }
92
+
93
+ function collectComponentReplacements(
94
+ node: MdAst.Root | MdAst.RootContent,
95
+ imports: Map<string, MarkdownImport>,
96
+ filePath: string,
97
+ seen: Set<string>,
98
+ replacements: Replacement[],
99
+ ) {
100
+ if (isMdxJsxElement(node)) {
101
+ const name = node.name ?? undefined
102
+ const markdownImport = name ? imports.get(name) : undefined
103
+ if (markdownImport && isStaticMdxComponent(node)) {
104
+ const importedPath = resolveMarkdownImport(markdownImport.source, filePath)
105
+ if (importedPath) {
106
+ const importedSource = fs.readFileSync(importedPath, 'utf-8')
107
+ const value = inline(importedSource, importedPath, new Set(seen))
108
+ const replacement = toReplacement(node, value)
109
+ if (replacement) replacements.push(replacement)
110
+ }
111
+ }
112
+ }
113
+
114
+ if ('children' in node && Array.isArray(node.children))
115
+ for (const child of node.children)
116
+ collectComponentReplacements(child, imports, filePath, seen, replacements)
117
+ }
118
+
119
+ function isMdxJsxElement(node: MdAst.Root | MdAst.RootContent): node is MdxJsxElement {
120
+ return node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement'
121
+ }
122
+
123
+ function isStaticMdxComponent(node: MdxJsxElement): boolean {
124
+ return (node.attributes?.length ?? 0) === 0 && (node.children?.length ?? 0) === 0
125
+ }
126
+
127
+ function resolveMarkdownImport(source: string, filePath: string): string | undefined {
128
+ if (!source.startsWith('.') && !path.isAbsolute(source)) return undefined
129
+
130
+ const resolved = path.isAbsolute(source) ? source : path.resolve(path.dirname(filePath), source)
131
+ if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) return resolved
132
+ return undefined
133
+ }
134
+
135
+ function isMarkdownImport(source: string): boolean {
136
+ return /\.(md|mdx)$/.test(source)
137
+ }
138
+
139
+ function toReplacement(
140
+ node: MdAst.Root | MdAst.RootContent,
141
+ value: string,
142
+ ): Replacement | undefined {
143
+ const start = node.position?.start.offset
144
+ const end = node.position?.end.offset
145
+ if (start === undefined || end === undefined) return undefined
146
+ return { end, start, value }
147
+ }
148
+
149
+ function applyReplacements(source: string, replacements: Replacement[]): string {
150
+ let result = source
151
+ for (const replacement of replacements.sort((a, b) => b.start - a.start))
152
+ result = result.slice(0, replacement.start) + replacement.value + result.slice(replacement.end)
153
+ return result
154
+ }
@@ -226,6 +226,45 @@ describe('parse', () => {
226
226
  globalThis.fetch = fetch_
227
227
  }
228
228
  })
229
+
230
+ test('parses OpenAPI 3.1 top-level `webhooks` as non-callable operations', async () => {
231
+ const webhookSpec = {
232
+ openapi: '3.1.0',
233
+ info: { title: 'Petstore', version: '1.0.0' },
234
+ tags: [{ name: 'Webhooks', description: 'Outbound deliveries.' }],
235
+ paths: {
236
+ '/pets': { get: { operationId: 'listPets', tags: ['pets'], responses: { '200': {} } } },
237
+ },
238
+ webhooks: {
239
+ event: {
240
+ post: {
241
+ summary: 'Webhook event delivery',
242
+ tags: ['Webhooks'],
243
+ parameters: [
244
+ { name: 'x-signature', in: 'header', required: true, schema: { type: 'string' } },
245
+ ],
246
+ requestBody: {
247
+ required: true,
248
+ content: { 'application/json': { schema: { type: 'object' } } },
249
+ },
250
+ responses: { '2xx': { description: 'Delivery acknowledged.' } },
251
+ },
252
+ },
253
+ },
254
+ }
255
+ const ir = await parse(OpenApi.from({ spec: webhookSpec, path: '/api' }))
256
+
257
+ const webhooks = ir.groups.find((group) => group.name === 'Webhooks')
258
+ expect(webhooks?.operations).toHaveLength(1)
259
+ const event = webhooks?.operations[0]
260
+ expect(event?.method).toBe('POST')
261
+ expect(event?.path).toBe('event')
262
+ expect(event?.isWebhook).toBe(true)
263
+ expect(event?.summary).toBe('Webhook event delivery')
264
+ expect(event?.requestBody?.content[0]?.schema).toEqual({ type: 'object' })
265
+ // Webhook ids are prefixed so they can't collide with real path operations.
266
+ expect(event?.id).toBe('webhook-post-event')
267
+ })
229
268
  })
230
269
 
231
270
  describe('from', () => {
@@ -95,8 +95,14 @@ export type IrOperation = {
95
95
  id: string
96
96
  /** HTTP method (uppercased for display, e.g. `GET`). */
97
97
  method: string
98
- /** Templated path (e.g. `/pets/{petId}`). */
98
+ /** Templated path (e.g. `/pets/{petId}`), or the event name for webhooks. */
99
99
  path: string
100
+ /**
101
+ * `true` for an OpenAPI 3.1 outbound webhook delivery (from the document's
102
+ * top-level `webhooks`). The renderer omits the interactive client and code
103
+ * samples for these, since there's no endpoint to call.
104
+ */
105
+ isWebhook?: boolean | undefined
100
106
  /**
101
107
  * Name of the host operation's request example to preselect in the
102
108
  * interactive client. Set for JSON-RPC operations expanded from an OpenRPC
@@ -192,6 +198,12 @@ type Document = {
192
198
  'x-parent'?: string
193
199
  }[]
194
200
  paths?: Record<string, PathItem>
201
+ /**
202
+ * OpenAPI 3.1 outbound webhook deliveries. Keyed by event name (an arbitrary
203
+ * label, not a URL); each value is a Path Item with the same operation shape
204
+ * as `paths`. Rendered as non-callable operations (no server endpoint).
205
+ */
206
+ webhooks?: Record<string, PathItem>
195
207
  components?: { securitySchemes?: Record<string, IrSecurityScheme> }
196
208
  security?: Record<string, string[]>[]
197
209
  }
@@ -551,6 +563,35 @@ async function buildGroups(
551
563
  }
552
564
  }
553
565
 
566
+ // OpenAPI 3.1 outbound webhooks: same Path Item shape as `paths`, but keyed by
567
+ // event name and rendered as non-callable operations.
568
+ for (const [name, item] of Object.entries(document.webhooks ?? {})) {
569
+ if (!item || typeof item !== 'object') continue
570
+ const pathParameters = item.parameters ?? []
571
+
572
+ for (const method of methods) {
573
+ const operation = item[method]
574
+ if (!operation) continue
575
+
576
+ const groupName = operation.tags?.[0] ?? 'Webhooks'
577
+ if (!byName.has(groupName)) {
578
+ byName.set(groupName, [])
579
+ order.push(groupName)
580
+ }
581
+
582
+ byName.get(groupName)?.push(
583
+ buildOperation({
584
+ method,
585
+ pathname: name,
586
+ operation,
587
+ pathParameters,
588
+ slugger,
589
+ isWebhook: true,
590
+ }),
591
+ )
592
+ }
593
+ }
594
+
554
595
  const groups = order
555
596
  .map((name) => ({
556
597
  id: slugger.slug(name),
@@ -569,10 +610,13 @@ function buildOperation(options: {
569
610
  operation: Operation
570
611
  pathParameters: RawParameter[]
571
612
  slugger: GithubSlugger
613
+ isWebhook?: boolean
572
614
  }): IrOperation {
573
- const { method, pathname, operation, pathParameters, slugger } = options
615
+ const { method, pathname, operation, pathParameters, slugger, isWebhook = false } = options
574
616
 
575
- const idSource = operation.operationId || `${method}-${pathname}`
617
+ // Webhook keys are arbitrary labels (not URLs) and can collide with real
618
+ // paths, so prefix the id source to keep anchors unique.
619
+ const idSource = operation.operationId || `${isWebhook ? 'webhook-' : ''}${method}-${pathname}`
576
620
 
577
621
  // Merge path-level parameters with operation-level (operation wins on conflict).
578
622
  const merged = new Map<string, RawParameter>()
@@ -585,6 +629,7 @@ function buildOperation(options: {
585
629
  id: slugger.slug(idSource),
586
630
  method: method.toUpperCase(),
587
631
  path: pathname,
632
+ ...(isWebhook ? { isWebhook: true } : {}),
588
633
  summary: operation.summary,
589
634
  description: operation.description,
590
635
  deprecated: operation.deprecated,
@@ -119,6 +119,37 @@ describe('toSidebar', () => {
119
119
  })
120
120
  })
121
121
 
122
+ describe('webhook badges', () => {
123
+ const webhookIr: Ir = {
124
+ ...ir,
125
+ groups: [
126
+ {
127
+ id: 'webhooks',
128
+ name: 'Webhooks',
129
+ operations: [
130
+ {
131
+ id: 'payment-succeeded',
132
+ method: 'POST',
133
+ path: 'payment.succeeded',
134
+ summary: 'Payment succeeded',
135
+ parameters: [],
136
+ responses: [],
137
+ isWebhook: true,
138
+ },
139
+ ],
140
+ },
141
+ ],
142
+ }
143
+
144
+ test('webhook operations get an icon badge instead of the POST method text', () => {
145
+ const sidebar = toSidebar(webhookIr)
146
+ const group = sidebar[1] as { items: { badge?: { icon?: string; text?: string } }[] }
147
+ const badge = group.items[1]?.badge
148
+ expect(badge?.text).toBeUndefined()
149
+ expect(badge?.icon).toContain('<svg')
150
+ })
151
+ })
152
+
122
153
  describe('methodVariant', () => {
123
154
  test('maps methods to badge variants', () => {
124
155
  expect(methodVariant('GET')).toBe('info')
@@ -1,6 +1,10 @@
1
+ import { resolveIconSync } from '../icons.js'
1
2
  import type { SidebarItem } from '../sidebar.js'
2
3
  import type { Ir, IrOperation } from './parser.js'
3
4
 
5
+ /** Inline SVG for the webhook badge, resolved once at module load (server-side). */
6
+ const webhookIcon = resolveIconSync('lucide:webhook')
7
+
4
8
  export type Badge = NonNullable<SidebarItem['badge']>
5
9
  export type BadgeVariant = NonNullable<Exclude<Badge, string>['variant']>
6
10
 
@@ -72,7 +76,12 @@ export function toSidebar(ir: Ir, options: toSidebar.Options = {}): SidebarItem<
72
76
  ...group.operations.map((operation) => ({
73
77
  text: operation.summary || `${operation.method} ${operation.path}`,
74
78
  link: operationLink(operation, group.id),
75
- badge: { text: operation.method, variant: methodVariant(operation.method) },
79
+ // Webhooks are inbound deliveries, not callable endpoints — show a
80
+ // webhook glyph instead of the HTTP method (which is always POST).
81
+ badge:
82
+ operation.isWebhook && webhookIcon
83
+ ? { icon: webhookIcon, variant: methodVariant(operation.method) }
84
+ : { text: operation.method, variant: methodVariant(operation.method) },
76
85
  })),
77
86
  ],
78
87
  })),
@@ -1,3 +1,9 @@
1
+ import type {
2
+ Options as MiniSearchOptions,
3
+ SearchOptions as MiniSearchSearchOptions,
4
+ } from 'minisearch'
5
+ import type * as Config from './config.js'
6
+
1
7
  export const searchFields = ['category', 'subtitle', 'text', 'title', 'titles'] as const
2
8
  export const storeFields = [
3
9
  'category',
@@ -40,3 +46,26 @@ export function tokenize(text: string): string[] {
40
46
 
41
47
  return tokens.filter((w) => w.length > 0)
42
48
  }
49
+
50
+ export namespace SearchConfig {
51
+ export function getIndexOptions(config?: Config.Config): MiniSearchOptions {
52
+ const index = config?.search.index ?? {}
53
+ return {
54
+ ...index,
55
+ fields: index.fields ? [...index.fields] : [...searchFields],
56
+ storeFields: mergeStoreFields(index.storeFields),
57
+ tokenize: index.tokenize ?? tokenize,
58
+ }
59
+ }
60
+
61
+ export function getQueryOptions(config: Config.Config): MiniSearchSearchOptions {
62
+ return {
63
+ ...config.search.query,
64
+ tokenize: config.search.query?.tokenize ?? config.search.index?.tokenize ?? tokenize,
65
+ }
66
+ }
67
+
68
+ function mergeStoreFields(fields: readonly string[] | undefined): string[] {
69
+ return [...new Set([...storeFields, ...(fields ?? [])])]
70
+ }
71
+ }
@@ -1,3 +1,6 @@
1
+ import * as fs from 'node:fs'
2
+ import * as os from 'node:os'
3
+ import * as path from 'node:path'
1
4
  import type * as MdAst from 'mdast'
2
5
  import { describe, expect, it } from 'vitest'
3
6
  import * as Config from './config.js'
@@ -859,6 +862,40 @@ Some content here.
859
862
  })
860
863
  })
861
864
 
865
+ describe('SearchDocuments.fromConfig', () => {
866
+ it('indexes imported markdown content from file-backed pages', async () => {
867
+ const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vocs-search-'))
868
+ const pagesDir = path.join(rootDir, 'src/pages')
869
+ fs.mkdirSync(pagesDir, { recursive: true })
870
+
871
+ fs.writeFileSync(
872
+ path.join(rootDir, 'notes.md'),
873
+ '# Notes\n\nSome indexable prose about widgets and links.',
874
+ )
875
+ fs.writeFileSync(
876
+ path.join(pagesDir, 'notes.mdx'),
877
+ `---
878
+ title: Notes
879
+ ---
880
+
881
+ import Notes from '../../notes.md'
882
+
883
+ <Notes />`,
884
+ )
885
+
886
+ const documents = await Search.SearchDocuments.fromConfig(Config.define({ rootDir }))
887
+
888
+ expect(documents).toContainEqual(
889
+ expect.objectContaining({
890
+ href: '/notes#notes',
891
+ text: ' Some indexable prose about widgets and links.',
892
+ title: 'Notes',
893
+ type: 'page',
894
+ }),
895
+ )
896
+ })
897
+ })
898
+
862
899
  describe('Search fields and storeFields', () => {
863
900
  it('exports search fields', () => {
864
901
  expect(Search.searchFields).toMatchInlineSnapshot(`
@@ -902,6 +939,65 @@ describe('Config.search defaults', () => {
902
939
  expect(config.search.fuzzy).toBe(0.2)
903
940
  expect(config.search.prefix).toBe(true)
904
941
  expect(typeof config.search.boostDocument).toBe('function')
942
+ expect(config.search.query?.boost).toEqual(config.search.boost)
943
+ expect(config.search.query?.fuzzy).toBe(0.2)
944
+ expect(config.search.query?.prefix).toBe(true)
945
+ })
946
+
947
+ it('normalizes legacy search options into query options', () => {
948
+ const config = Config.define({
949
+ search: {
950
+ boost: { text: 10 },
951
+ combineWith: 'OR',
952
+ fuzzy: false,
953
+ prefix: false,
954
+ },
955
+ })
956
+
957
+ expect(config.search.query?.boost).toMatchObject({ title: 4, text: 10 })
958
+ expect(config.search.query?.combineWith).toBe('OR')
959
+ expect(config.search.query?.fuzzy).toBe(false)
960
+ expect(config.search.query?.prefix).toBe(false)
961
+ })
962
+ })
963
+
964
+ describe('SearchConfig', () => {
965
+ it('returns default index options', () => {
966
+ expect(Search.SearchConfig.getIndexOptions(config)).toMatchObject({
967
+ fields: ['category', 'subtitle', 'text', 'title', 'titles'],
968
+ storeFields: [
969
+ 'category',
970
+ 'href',
971
+ 'searchPriority',
972
+ 'subtitle',
973
+ 'text',
974
+ 'title',
975
+ 'titles',
976
+ 'type',
977
+ ],
978
+ })
979
+ })
980
+
981
+ it('keeps required store fields when custom store fields are configured', () => {
982
+ const config = Config.define({
983
+ search: {
984
+ index: {
985
+ storeFields: ['href', 'custom'],
986
+ },
987
+ },
988
+ })
989
+
990
+ expect(Search.SearchConfig.getIndexOptions(config).storeFields).toEqual([
991
+ 'category',
992
+ 'href',
993
+ 'searchPriority',
994
+ 'subtitle',
995
+ 'text',
996
+ 'title',
997
+ 'titles',
998
+ 'type',
999
+ 'custom',
1000
+ ])
905
1001
  })
906
1002
  })
907
1003
 
@@ -1006,6 +1102,105 @@ describe('SearchIndex.fromSearchDocuments', () => {
1006
1102
  expect(results[0]?.score).toBeGreaterThan(results[1]?.score ?? 0)
1007
1103
  })
1008
1104
 
1105
+ it('indexes custom virtual fields', () => {
1106
+ const docs: SearchDocuments.Document[] = [
1107
+ {
1108
+ category: '',
1109
+ href: '/docs/runtime-api',
1110
+ id: '/docs/runtime-api.mdx#runtime-api',
1111
+ searchPriority: undefined,
1112
+ subtitle: '',
1113
+ text: 'Reference content.',
1114
+ title: 'Runtime API',
1115
+ titles: [],
1116
+ type: 'page',
1117
+ },
1118
+ ]
1119
+ const config = Config.define({
1120
+ search: {
1121
+ index: {
1122
+ fields: ['path'],
1123
+ extractField(document, fieldName) {
1124
+ if (fieldName === 'path') return document.href
1125
+ return document[fieldName as keyof typeof document]
1126
+ },
1127
+ },
1128
+ },
1129
+ })
1130
+
1131
+ const index = SearchIndex.fromSearchDocuments(docs, config)
1132
+
1133
+ expect(index.search('runtime', Search.SearchConfig.getQueryOptions(config))[0]?.id).toBe(
1134
+ '/docs/runtime-api.mdx#runtime-api',
1135
+ )
1136
+ })
1137
+
1138
+ it('stores custom fields', () => {
1139
+ const docs: SearchDocuments.Document[] = [
1140
+ {
1141
+ category: 'Docs',
1142
+ href: '/guide',
1143
+ id: '/docs/guide.mdx#guide',
1144
+ searchPriority: undefined,
1145
+ subtitle: '',
1146
+ text: 'Install instructions.',
1147
+ title: 'Guide',
1148
+ titles: [],
1149
+ type: 'page',
1150
+ },
1151
+ ]
1152
+ const config = Config.define({
1153
+ search: {
1154
+ index: {
1155
+ storeFields: ['path'],
1156
+ extractField(document, fieldName) {
1157
+ if (fieldName === 'path') return document.href
1158
+ return document[fieldName as keyof typeof document]
1159
+ },
1160
+ },
1161
+ },
1162
+ })
1163
+
1164
+ const index = SearchIndex.fromSearchDocuments(docs, config)
1165
+ const result = index.search('guide', Search.SearchConfig.getQueryOptions(config))[0]
1166
+
1167
+ expect(result).toMatchObject({ href: '/guide', path: '/guide', title: 'Guide' })
1168
+ })
1169
+
1170
+ it('applies query fields, weights, and filter at runtime', () => {
1171
+ const docs = [
1172
+ ...buildDoc(
1173
+ '/docs/title.mdx',
1174
+ '/title',
1175
+ `# Alpha
1176
+
1177
+ No keyword.
1178
+ `,
1179
+ ),
1180
+ ...buildDoc(
1181
+ '/docs/text.mdx',
1182
+ '/text',
1183
+ `# Other
1184
+
1185
+ Alpha keyword.
1186
+ `,
1187
+ ),
1188
+ ]
1189
+ const config = Config.define({
1190
+ search: {
1191
+ query: {
1192
+ fields: ['text'],
1193
+ filter: (result) => result.id !== '/docs/text.mdx#other',
1194
+ weights: { fuzzy: 0.5, prefix: 0.5 },
1195
+ },
1196
+ },
1197
+ })
1198
+
1199
+ const index = SearchIndex.fromSearchDocuments(docs, config)
1200
+
1201
+ expect(index.search('alpha', Search.SearchConfig.getQueryOptions(config))).toEqual([])
1202
+ })
1203
+
1009
1204
  it('boosts shallow paths over deep paths', () => {
1010
1205
  const shallowDoc: SearchDocuments.Document = {
1011
1206
  category: '',
@@ -14,11 +14,12 @@ import { unified } from 'unified'
14
14
  import * as UnistUtil from 'unist-util-visit'
15
15
  import * as yaml from 'yaml'
16
16
  import type * as Config from './config.js'
17
+ import * as MarkdownImports from './markdown-imports.js'
17
18
  import { extractSubheading, getPhrasingContentText } from './mdx.js'
18
19
  import * as OpenApiRegistry from './openapi/registry.js'
19
20
  import * as OpenApiSearch from './openapi/search.js'
20
21
  import * as Path from './path.js'
21
- import { searchFields, storeFields, tokenize } from './search.client.js'
22
+ import { SearchConfig } from './search.client.js'
22
23
  import * as Sidebar from './sidebar.js'
23
24
  import * as TaskRunner from './task-runner.js'
24
25
  import * as TopNav from './topNav.js'
@@ -61,7 +62,8 @@ export namespace SearchDocuments {
61
62
  for (const page of mdxPages)
62
63
  taskRunner.run(async () => {
63
64
  const filePath = path.join(pagesDirPath, page)
64
- const content = await fs.promises.readFile(filePath, 'utf-8')
65
+ const rawContent = await fs.promises.readFile(filePath, 'utf-8')
66
+ const content = MarkdownImports.inlineMarkdownImports(rawContent, filePath)
65
67
 
66
68
  const { searchPriority, sections } = extract(content, config)
67
69
  if (sections.length === 0) return
@@ -206,12 +208,11 @@ export namespace SearchIndex {
206
208
  /**
207
209
  * Create a search index from documents.
208
210
  */
209
- export function fromSearchDocuments(documents: SearchDocuments.Document[]): SearchIndex {
210
- const index = new MiniSearch<SearchDocuments.Document>({
211
- fields: [...searchFields],
212
- storeFields: [...storeFields],
213
- tokenize,
214
- })
211
+ export function fromSearchDocuments(
212
+ documents: SearchDocuments.Document[],
213
+ config?: Config.Config,
214
+ ): SearchIndex {
215
+ const index = new MiniSearch<SearchDocuments.Document>(SearchConfig.getIndexOptions(config))
215
216
  index.addAll(documents)
216
217
  return index
217
218
  }
@@ -220,17 +221,15 @@ export namespace SearchIndex {
220
221
  * Load a search index from a JSON file.
221
222
  */
222
223
  export function loadFromFile(options: loadFromFile.Options): SearchIndex {
223
- const { filePath } = options
224
+ const { config, filePath } = options
224
225
 
225
226
  const json = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
226
- return MiniSearch.loadJSON(json, {
227
- fields: [...searchFields],
228
- storeFields: [...storeFields],
229
- })
227
+ return MiniSearch.loadJSON(json, SearchConfig.getIndexOptions(config))
230
228
  }
231
229
 
232
230
  export declare namespace loadFromFile {
233
231
  type Options = {
232
+ config?: Config.Config
234
233
  filePath: string
235
234
  }
236
235
  }
@@ -260,7 +259,8 @@ export namespace SearchIndex {
260
259
  ): string[] {
261
260
  const { pagesDir, previousIds = [], config } = options
262
261
 
263
- const content = fs.readFileSync(filePath, 'utf-8')
262
+ const rawContent = fs.readFileSync(filePath, 'utf-8')
263
+ const content = MarkdownImports.inlineMarkdownImports(rawContent, filePath)
264
264
  const { searchPriority, sections } = extract(content, config)
265
265
 
266
266
  // Remove old entries for this file
@@ -3,8 +3,14 @@ import type { Config } from './config.js'
3
3
  export type SidebarItemBadge =
4
4
  | string
5
5
  | {
6
- /** Text displayed inside the badge. */
7
- text: string
6
+ /**
7
+ * Inline SVG markup rendered before the badge text. Used for icon badges
8
+ * (e.g. the generated OpenAPI webhook badge). Resolve an icon name to SVG
9
+ * server-side (via the icon helpers) before assigning.
10
+ */
11
+ icon?: string | undefined
12
+ /** Text displayed inside the badge. Optional when an `icon` is provided. */
13
+ text?: string | undefined
8
14
  /** Visual variant. Defaults to `'info'`. */
9
15
  variant?: 'note' | 'info' | 'warning' | 'danger' | 'tip' | 'success' | undefined
10
16
  }