boltdocs 2.1.1 → 2.2.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 (116) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/base-ui/index.d.mts +25 -0
  3. package/dist/base-ui/index.d.ts +25 -0
  4. package/dist/base-ui/index.js +1 -0
  5. package/dist/base-ui/index.mjs +1 -0
  6. package/dist/{cache-Q4T6VAUL.mjs → cache-CRAZ55X7.mjs} +1 -1
  7. package/dist/chunk-5D6XPYQ3.mjs +74 -0
  8. package/dist/chunk-6QXCKZAT.mjs +1 -0
  9. package/dist/chunk-H4M6P3DM.mjs +1 -0
  10. package/dist/chunk-JD3RSDE4.mjs +1 -0
  11. package/dist/chunk-JXHNX2WN.mjs +1 -0
  12. package/dist/chunk-JZXLCA2E.mjs +1 -0
  13. package/dist/chunk-MZBG4N4W.mjs +1 -0
  14. package/dist/chunk-NBCYHLAA.mjs +1 -0
  15. package/dist/chunk-Q3MLYTIQ.mjs +1 -0
  16. package/dist/chunk-RSII2UPE.mjs +1 -0
  17. package/dist/chunk-T3W44KWY.mjs +1 -0
  18. package/dist/chunk-ZK2266IZ.mjs +1 -0
  19. package/dist/chunk-ZRJ55GGF.mjs +1 -0
  20. package/dist/client/index.d.mts +13 -115
  21. package/dist/client/index.d.ts +13 -115
  22. package/dist/client/index.js +1 -1
  23. package/dist/client/index.mjs +1 -1
  24. package/dist/client/ssr.js +1 -1
  25. package/dist/client/ssr.mjs +1 -1
  26. package/dist/client/types.d.mts +3 -0
  27. package/dist/client/types.d.ts +3 -0
  28. package/dist/client/types.js +1 -0
  29. package/dist/client/types.mjs +0 -0
  30. package/dist/copy-markdown-C-90ixSe.d.ts +15 -0
  31. package/dist/copy-markdown-CbS8X-qe.d.mts +15 -0
  32. package/dist/{client/hooks → hooks}/index.d.mts +25 -12
  33. package/dist/{client/hooks → hooks}/index.d.ts +25 -12
  34. package/dist/hooks/index.js +1 -0
  35. package/dist/hooks/index.mjs +1 -0
  36. package/dist/integrations/index.d.mts +48 -0
  37. package/dist/integrations/index.d.ts +48 -0
  38. package/dist/integrations/index.js +1 -0
  39. package/dist/integrations/index.mjs +1 -0
  40. package/dist/link-DfBwCeZc.d.mts +68 -0
  41. package/dist/link-DfBwCeZc.d.ts +68 -0
  42. package/dist/loading-BqGrFWO5.d.mts +68 -0
  43. package/dist/loading-chS3pm9W.d.ts +68 -0
  44. package/dist/{client/components/mdx → mdx}/index.d.mts +6 -38
  45. package/dist/{client/components/mdx → mdx}/index.d.ts +6 -38
  46. package/dist/mdx/index.js +1 -0
  47. package/dist/mdx/index.mjs +1 -0
  48. package/dist/node/cli-entry.js +27 -27
  49. package/dist/node/cli-entry.mjs +1 -1
  50. package/dist/node/index.d.mts +44 -14
  51. package/dist/node/index.d.ts +44 -14
  52. package/dist/node/index.js +23 -23
  53. package/dist/node/index.mjs +1 -1
  54. package/dist/primitives/index.d.mts +301 -0
  55. package/dist/primitives/index.d.ts +301 -0
  56. package/dist/primitives/index.js +1 -0
  57. package/dist/primitives/index.mjs +1 -0
  58. package/dist/search-dialog-MA5AISC7.mjs +1 -0
  59. package/dist/{types-Cp21DHI6.d.mts → types-j7jvWsJj.d.mts} +63 -17
  60. package/dist/{types-Cp21DHI6.d.ts → types-j7jvWsJj.d.ts} +63 -17
  61. package/dist/{use-routes-xLhumjbV.d.ts → use-routes-Cd806kGw.d.ts} +1 -1
  62. package/dist/{use-routes-8Iei6jTp.d.mts → use-routes-DDL0_jkQ.d.mts} +1 -1
  63. package/package.json +34 -8
  64. package/src/client/app/index.tsx +155 -35
  65. package/src/client/app/mdx-component.tsx +7 -3
  66. package/src/client/app/theme-context.tsx +40 -23
  67. package/src/client/components/default-layout.tsx +12 -6
  68. package/src/client/components/primitives/breadcrumbs.tsx +1 -1
  69. package/src/client/components/primitives/navbar.tsx +5 -2
  70. package/src/client/components/primitives/search-dialog.tsx +12 -3
  71. package/src/client/components/ui-base/breadcrumbs.tsx +1 -1
  72. package/src/client/components/ui-base/index.ts +17 -0
  73. package/src/client/components/ui-base/navbar.tsx +66 -33
  74. package/src/client/components/ui-base/powered-by.tsx +8 -5
  75. package/src/client/components/ui-base/sidebar.tsx +31 -22
  76. package/src/client/components/ui-base/tabs.tsx +4 -1
  77. package/src/client/components/ui-base/theme-toggle.tsx +35 -15
  78. package/src/client/hooks/use-i18n.ts +37 -7
  79. package/src/client/hooks/use-localized-to.ts +45 -68
  80. package/src/client/hooks/use-navbar.ts +10 -3
  81. package/src/client/hooks/use-routes.ts +61 -17
  82. package/src/client/hooks/use-search.ts +21 -5
  83. package/src/client/hooks/use-sidebar.ts +5 -2
  84. package/src/client/hooks/use-version.ts +5 -0
  85. package/src/client/integrations/index.ts +1 -0
  86. package/src/client/store/use-boltdocs-store.ts +43 -0
  87. package/src/client/types.ts +4 -2
  88. package/src/client/utils/i18n.ts +23 -0
  89. package/src/node/config.ts +54 -17
  90. package/src/node/index.ts +1 -1
  91. package/src/node/mdx/cache.ts +12 -0
  92. package/src/node/mdx/highlighter.ts +47 -0
  93. package/src/node/mdx/index.ts +114 -0
  94. package/src/node/mdx/rehype-shiki.ts +53 -0
  95. package/src/node/mdx/remark-shiki.ts +61 -0
  96. package/src/node/plugin/html.ts +8 -4
  97. package/src/node/plugin/index.ts +117 -68
  98. package/src/node/routes/index.ts +34 -13
  99. package/src/node/routes/parser.ts +12 -4
  100. package/src/node/ssg/index.ts +3 -3
  101. package/src/node/utils.ts +32 -2
  102. package/tsup.config.ts +7 -2
  103. package/dist/chunk-52MVMZWS.mjs +0 -1
  104. package/dist/chunk-BVWWKXJH.mjs +0 -1
  105. package/dist/chunk-DVY3RDXD.mjs +0 -1
  106. package/dist/chunk-FUVYCYWC.mjs +0 -1
  107. package/dist/chunk-GBLMDJ2B.mjs +0 -1
  108. package/dist/chunk-ISPX45DF.mjs +0 -1
  109. package/dist/chunk-PNXZMUCO.mjs +0 -1
  110. package/dist/chunk-V2ZHKQSP.mjs +0 -74
  111. package/dist/client/components/mdx/index.js +0 -1
  112. package/dist/client/components/mdx/index.mjs +0 -1
  113. package/dist/client/hooks/index.js +0 -1
  114. package/dist/client/hooks/index.mjs +0 -1
  115. package/dist/search-dialog-TWGYKF2D.mjs +0 -1
  116. package/src/node/mdx.ts +0 -279
@@ -0,0 +1,47 @@
1
+ import { createHighlighter } from 'shiki'
2
+ import type { Highlighter } from 'shiki'
3
+
4
+ let shikiHighlighter: Highlighter | null = null
5
+
6
+ /**
7
+ * Retrieves or initializes the Shiki highlighter instance.
8
+ * Supports dual-theme configurations (light/dark).
9
+ *
10
+ * @param codeTheme - Theme configuration (string for single, object for dual).
11
+ * @returns A promise resolving to the highlighter instance.
12
+ */
13
+ export async function getShikiHighlighter(codeTheme: any) {
14
+ if (shikiHighlighter) return shikiHighlighter
15
+
16
+ const themes =
17
+ typeof codeTheme === 'object'
18
+ ? [codeTheme.light, codeTheme.dark]
19
+ : [codeTheme ?? 'github-dark']
20
+
21
+ // Fallbacks for standard themes
22
+ ;['github-light', 'github-dark'].forEach((t) => {
23
+ if (!themes.includes(t)) themes.push(t)
24
+ })
25
+
26
+ // Initialize with a core set of languages first to speed up boot
27
+ shikiHighlighter = await createHighlighter({
28
+ themes,
29
+ langs: [
30
+ 'tsx',
31
+ 'jsx',
32
+ 'ts',
33
+ 'js',
34
+ 'json',
35
+ 'md',
36
+ 'mdx',
37
+ 'css',
38
+ 'html',
39
+ 'bash',
40
+ 'sh',
41
+ 'yaml',
42
+ 'yml',
43
+ ],
44
+ })
45
+
46
+ return shikiHighlighter
47
+ }
@@ -0,0 +1,114 @@
1
+ import mdxPlugin from '@mdx-js/rollup'
2
+ import remarkGfm from 'remark-gfm'
3
+ import remarkFrontmatter from 'remark-frontmatter'
4
+ import rehypeSlug from 'rehype-slug'
5
+ import type { Plugin } from 'vite'
6
+ import crypto from 'crypto'
7
+
8
+ import type { BoltdocsConfig } from '../config'
9
+ import { mdxCache, MDX_PLUGIN_VERSION } from './cache'
10
+ import { remarkShiki } from './remark-shiki'
11
+ import { rehypeShiki } from './rehype-shiki'
12
+
13
+ let mdxCacheLoaded = false
14
+ let hits = 0
15
+ let total = 0
16
+
17
+ /**
18
+ * Configures the MDX compiler for Vite using `@mdx-js/rollup`.
19
+ * Includes standard remark and rehype plugins for GitHub Flavored Markdown (GFM),
20
+ * frontmatter extraction, and auto-linking headers.
21
+ *
22
+ * Also wraps the plugin with a persistent cache to avoid re-compiling unchanged MDX files.
23
+ *
24
+ * @param config - The Boltdocs configuration containing custom plugins
25
+ * @param compiler - The MDX compiler plugin (for testing)
26
+ * @returns A Vite plugin configured for MDX parsing with caching
27
+ */
28
+ export function boltdocsMdxPlugin(
29
+ config?: BoltdocsConfig,
30
+ compiler = mdxPlugin,
31
+ ): Plugin {
32
+ const extraRemarkPlugins =
33
+ config?.plugins?.flatMap((p) => p.remarkPlugins || []) || []
34
+ const extraRehypePlugins =
35
+ config?.plugins?.flatMap((p) => p.rehypePlugins || []) || []
36
+
37
+ const baseMdxPlugin = compiler({
38
+ remarkPlugins: [
39
+ remarkGfm,
40
+ remarkFrontmatter,
41
+ [remarkShiki, config],
42
+ ...(extraRemarkPlugins as any[]),
43
+ ],
44
+ rehypePlugins: [
45
+ rehypeSlug,
46
+ [rehypeShiki, config],
47
+ ...(extraRehypePlugins as any[]),
48
+ ],
49
+ jsxRuntime: 'automatic',
50
+ providerImportSource: '@mdx-js/react',
51
+ }) as Plugin
52
+
53
+ return {
54
+ ...baseMdxPlugin,
55
+ name: 'vite-plugin-boltdocs-mdx',
56
+
57
+ async buildStart() {
58
+ hits = 0
59
+ total = 0
60
+ if (!mdxCacheLoaded) {
61
+ mdxCache.load()
62
+ mdxCacheLoaded = true
63
+ }
64
+ // @ts-ignore
65
+ if (baseMdxPlugin.buildStart) {
66
+ // @ts-ignore
67
+ await baseMdxPlugin.buildStart.call(this)
68
+ }
69
+ },
70
+
71
+ async transform(code, id, options) {
72
+ if (!id.endsWith('.md') && !id.endsWith('.mdx')) {
73
+ // @ts-ignore
74
+ return baseMdxPlugin.transform?.call(this, code, id, options)
75
+ }
76
+
77
+ console.log(`[boltdocs] Transforming MDX: ${id}`)
78
+ total++
79
+ // Create a cache key based on path, content, and plugin version
80
+ const contentHash = crypto.createHash('md5').update(code).digest('hex')
81
+ const cacheKey = `${id}:${contentHash}:${MDX_PLUGIN_VERSION}`
82
+
83
+ const cached = mdxCache.get(cacheKey)
84
+ if (cached) {
85
+ hits++
86
+ return { code: cached, map: null }
87
+ }
88
+
89
+ // @ts-ignore
90
+ const result = await baseMdxPlugin.transform.call(this, code, id, options)
91
+
92
+ if (result && typeof result === 'object' && result.code) {
93
+ mdxCache.set(cacheKey, result.code)
94
+ }
95
+
96
+ return result
97
+ },
98
+
99
+ async buildEnd() {
100
+ if (total > 0) {
101
+ console.log(
102
+ `[boltdocs] MDX Cache Performance: ${hits}/${total} hits (${Math.round((hits / total) * 100) || 0}%)`,
103
+ )
104
+ }
105
+ mdxCache.save()
106
+ await mdxCache.flush()
107
+ // @ts-ignore
108
+ if (baseMdxPlugin.buildEnd) {
109
+ // @ts-ignore
110
+ await baseMdxPlugin.buildEnd.call(this)
111
+ }
112
+ },
113
+ }
114
+ }
@@ -0,0 +1,53 @@
1
+ import { visit } from 'unist-util-visit'
2
+ import type { BoltdocsConfig } from '../config'
3
+ import { getShikiHighlighter } from './highlighter'
4
+
5
+ /**
6
+ * Custom rehype plugin to perform syntax highlighting at build time for
7
+ * standard Markdown code blocks.
8
+ *
9
+ * Injects 'data-highlighted="true"' and 'highlightedHtml' into the 'pre' tag properties,
10
+ * which are then consumed by the client-side CodeBlock component.
11
+ *
12
+ * @param config - The Boltdocs configuration
13
+ * @returns A rehype plugin function
14
+ */
15
+ export function rehypeShiki(config?: BoltdocsConfig) {
16
+ return async (tree: any) => {
17
+ const codeTheme = config?.theme?.codeTheme || {
18
+ light: 'github-light',
19
+ dark: 'github-dark',
20
+ }
21
+ const highlighter = await getShikiHighlighter(codeTheme)
22
+
23
+ visit(tree, 'element', (node: any) => {
24
+ // Handle standard Markdown code blocks
25
+ if (node.tagName === 'pre' && node.children?.[0]?.tagName === 'code') {
26
+ const codeNode = node.children[0]
27
+ const className = codeNode.properties?.className || []
28
+ const langMatch = className.find((c: string) =>
29
+ c.startsWith('language-'),
30
+ )
31
+ const lang = langMatch ? langMatch.slice(9) : 'text'
32
+ const code = codeNode.children[0]?.value || ''
33
+
34
+ const options: any = { lang }
35
+ if (typeof codeTheme === 'object') {
36
+ options.themes = {
37
+ light: codeTheme.light,
38
+ dark: codeTheme.dark,
39
+ }
40
+ } else {
41
+ options.theme = codeTheme
42
+ }
43
+
44
+ const html = highlighter.codeToHtml(code, options)
45
+
46
+ // Inject highlighted HTML and mark as highlighted for CodeBlock component
47
+ node.properties.dataHighlighted = 'true'
48
+ node.properties.highlightedHtml = html
49
+ node.children = []
50
+ }
51
+ })
52
+ }
53
+ }
@@ -0,0 +1,61 @@
1
+ import { visit } from 'unist-util-visit'
2
+ import type { BoltdocsConfig } from '../config'
3
+ import { getShikiHighlighter } from './highlighter'
4
+
5
+ /**
6
+ * Custom remark plugin to highlight code in ComponentPreview components.
7
+ * This runs before rehype, ensuring that the 'highlightedHtml' prop is correctly
8
+ * attached to the MDX component as a JSX attribute.
9
+ *
10
+ * Supports both string literals and MDX expression values (template literals)
11
+ * for the 'code' attribute.
12
+ *
13
+ * @param config - The Boltdocs configuration
14
+ * @returns A remark plugin function
15
+ */
16
+ export function remarkShiki(config?: BoltdocsConfig) {
17
+ return async (tree: any) => {
18
+ const codeTheme = config?.theme?.codeTheme ?? {
19
+ light: 'github-light',
20
+ dark: 'github-dark',
21
+ }
22
+ const highlighter = await getShikiHighlighter(codeTheme)
23
+
24
+ visit(tree, ['mdxJsxFlowElement', 'mdxJsxTextElement'], (node: any) => {
25
+ if (node.name !== 'ComponentPreview') return
26
+
27
+ const codeAttr = node.attributes?.find((a: any) => a.name === 'code')
28
+ let code = ''
29
+
30
+ if (codeAttr) {
31
+ if (typeof codeAttr.value === 'string') {
32
+ code = codeAttr.value
33
+ } else if (codeAttr.value?.type === 'mdxJsxAttributeValueExpression') {
34
+ const expr = codeAttr.value.value ?? ''
35
+ code = expr.match(/^[`'"](.+)[`'"]$/)?.[1] ?? expr
36
+ }
37
+ }
38
+
39
+ if (!code) return
40
+
41
+ const options: any =
42
+ typeof codeTheme === 'object'
43
+ ? {
44
+ themes: { light: codeTheme.light, dark: codeTheme.dark },
45
+ lang: 'tsx',
46
+ }
47
+ : { theme: codeTheme, lang: 'tsx' }
48
+
49
+ const html = highlighter.codeToHtml(code, options)
50
+
51
+ node.attributes = (node.attributes ?? []).filter(
52
+ (a: any) => a.name !== 'highlightedHtml',
53
+ )
54
+ node.attributes.push({
55
+ type: 'mdxJsxAttribute',
56
+ name: 'highlightedHtml',
57
+ value: html,
58
+ })
59
+ })
60
+ }
61
+ }
@@ -4,7 +4,7 @@ import type { BoltdocsConfig } from '../config'
4
4
  * Provides a default HTML template if none is found in the project root.
5
5
  */
6
6
  export function getHtmlTemplate(config: BoltdocsConfig): string {
7
- const title = config.theme?.title || config.themeConfig?.title || 'Boltdocs'
7
+ const title = config.theme?.title || 'Boltdocs'
8
8
  return `<!doctype html>
9
9
  <html lang="en">
10
10
  <head>
@@ -27,7 +27,7 @@ export function getHtmlTemplate(config: BoltdocsConfig): string {
27
27
  * @returns {string} The modified HTML string with injected tags
28
28
  */
29
29
  export function injectHtmlMeta(html: string, config: BoltdocsConfig): string {
30
- const theme = config.theme || config.themeConfig
30
+ const theme = config.theme
31
31
  const title = theme?.title || 'Boltdocs'
32
32
  const description = theme?.description || ''
33
33
 
@@ -46,12 +46,16 @@ export function injectHtmlMeta(html: string, config: BoltdocsConfig): string {
46
46
  `<meta name="description" content="${description}">`,
47
47
  `<meta property="og:title" content="${title}">`,
48
48
  `<meta property="og:description" content="${description}">`,
49
- theme?.ogImage ? `<meta property="og:image" content="${theme.ogImage}">` : '',
49
+ theme?.ogImage
50
+ ? `<meta property="og:image" content="${theme.ogImage}">`
51
+ : '',
50
52
  `<meta property="og:type" content="website">`,
51
53
  `<meta name="twitter:card" content="summary_large_image">`,
52
54
  `<meta name="twitter:title" content="${title}">`,
53
55
  `<meta name="twitter:description" content="${description}">`,
54
- theme?.ogImage ? `<meta name="twitter:image" content="${theme.ogImage}">` : '',
56
+ theme?.ogImage
57
+ ? `<meta name="twitter:image" content="${theme.ogImage}">`
58
+ : '',
55
59
  `<meta name="generator" content="Boltdocs">`,
56
60
  ]
57
61
  .filter(Boolean)
@@ -11,7 +11,6 @@ import { injectHtmlMeta, getHtmlTemplate } from './html'
11
11
  import { generateRobotsTxt } from '../ssg/robots'
12
12
  import fs from 'fs'
13
13
 
14
-
15
14
  export * from './types'
16
15
 
17
16
  /**
@@ -55,7 +54,20 @@ export function boltdocsPlugin(
55
54
  }
56
55
 
57
56
  return {
58
- optimizeDeps: { include: ['react', 'react-dom'] },
57
+ optimizeDeps: {
58
+ include: ['react', 'react-dom'],
59
+ exclude: [
60
+ 'boltdocs',
61
+ 'boltdocs/client',
62
+ 'boltdocs/hooks',
63
+ 'boltdocs/primitives',
64
+ 'boltdocs/base-ui',
65
+ 'boltdocs/mdx',
66
+ 'boltdocs/integrations',
67
+ 'boltdocs/client/hooks',
68
+ 'boltdocs/client/primitives',
69
+ ],
70
+ },
59
71
  }
60
72
  },
61
73
 
@@ -76,25 +88,40 @@ export function boltdocsPlugin(
76
88
  next()
77
89
  })
78
90
 
79
- // Serve default HTML if index.html is missing
91
+ // Serve default HTML for documentation routes or if index.html is missing
80
92
  server.middlewares.use(async (req, res, next) => {
81
93
  const url = req.url?.split('?')[0] || '/'
82
94
  const accept = req.headers.accept || ''
83
95
 
96
+ const isDocRoute =
97
+ url === '/' ||
98
+ url.startsWith('/docs') ||
99
+ (config.i18n &&
100
+ Object.keys(config.i18n.locales).some(
101
+ (locale) =>
102
+ url.startsWith(`/${locale}/docs`) || url === `/${locale}`,
103
+ )) ||
104
+ (config.external &&
105
+ Object.keys(config.external).some((extPath) =>
106
+ url.startsWith(extPath),
107
+ ))
108
+
109
+ // Improved check: If it's a doc route, serve HTML even if it has a dot (e.g. version 1.1)
110
+ // We only skip if it has a known asset extension to prevent serving HTML for images/js/etc.
111
+ const isAsset = /\.(js|css|png|jpe?g|gif|svg|ico|webp|woff2?|ttf|otf|mp4|webm|ogg|mp3|wav|flac|aac|pdf|zip|gz|map|json)$/i.test(url)
112
+
84
113
  if (
85
114
  accept.includes('text/html') &&
86
- !url.includes('.') // Simple check for assets
115
+ !isAsset &&
116
+ isDocRoute
87
117
  ) {
88
- const indexPath = path.resolve(process.cwd(), 'index.html')
89
- if (!fs.existsSync(indexPath)) {
90
- let html = getHtmlTemplate(config)
91
- html = injectHtmlMeta(html, config)
92
- html = await server.transformIndexHtml(req.url || '/', html)
93
- res.statusCode = 200
94
- res.setHeader('Content-Type', 'text/html')
95
- res.end(html)
96
- return
97
- }
118
+ let html = getHtmlTemplate(config)
119
+ html = injectHtmlMeta(html, config)
120
+ html = await server.transformIndexHtml(req.url || '/', html)
121
+ res.statusCode = 200
122
+ res.setHeader('Content-Type', 'text/html')
123
+ res.end(html)
124
+ return
98
125
  }
99
126
 
100
127
  next()
@@ -124,66 +151,89 @@ export function boltdocsPlugin(
124
151
  file: string,
125
152
  type: 'add' | 'unlink' | 'change',
126
153
  ) => {
127
- const normalized = normalizePath(file)
128
-
129
- // Restart the Vite server if the Boltdocs config changes
130
- if (CONFIG_FILES.some((c) => normalized.endsWith(c))) {
131
- server.restart()
132
- return
133
- }
154
+ try {
155
+ const normalized = normalizePath(file)
134
156
 
135
- // If mdx-components file changes, invalidate the virtual module
136
- if (
137
- mdxCompExtensions.some((ext) =>
138
- normalized.endsWith(`mdx-components.${ext}`),
139
- )
140
- ) {
141
- const mod = server.moduleGraph.getModuleById(
142
- '\0virtual:boltdocs-mdx-components',
143
- )
144
- if (mod) server.moduleGraph.invalidateModule(mod)
145
- server.ws.send({ type: 'full-reload' })
146
- return
147
- }
157
+ // Restart the Vite server if the Boltdocs config changes
158
+ if (CONFIG_FILES.some((c) => normalized.endsWith(c))) {
159
+ server.restart()
160
+ return
161
+ }
148
162
 
149
- // If layout.tsx/jsx file changes, invalidate the virtual module
150
- if (
151
- compExtensions.some((ext) => normalized.endsWith(`layout.${ext}`))
152
- ) {
153
- const mod = server.moduleGraph.getModuleById(
154
- '\0virtual:boltdocs-layout',
155
- )
156
- if (mod) server.moduleGraph.invalidateModule(mod)
157
- server.ws.send({ type: 'full-reload' })
158
- return
159
- }
163
+ // If mdx-components file changes, invalidate the virtual module
164
+ if (
165
+ mdxCompExtensions.some((ext) =>
166
+ normalized.endsWith(`mdx-components.${ext}`),
167
+ )
168
+ ) {
169
+ const mod = server.moduleGraph.getModuleById(
170
+ '\0virtual:boltdocs-mdx-components',
171
+ )
172
+ if (mod) server.moduleGraph.invalidateModule(mod)
173
+ server.ws.send({ type: 'full-reload' })
174
+ return
175
+ }
160
176
 
161
- if (
162
- !normalized.startsWith(normalizedDocsDir) ||
163
- !isDocFile(normalized)
164
- )
165
- return
177
+ // If layout.tsx/jsx file changes, invalidate the virtual module
178
+ if (
179
+ compExtensions.some((ext) => normalized.endsWith(`layout.${ext}`))
180
+ ) {
181
+ const mod = server.moduleGraph.getModuleById(
182
+ '\0virtual:boltdocs-layout',
183
+ )
184
+ if (mod) server.moduleGraph.invalidateModule(mod)
185
+ server.ws.send({ type: 'full-reload' })
186
+ return
187
+ }
166
188
 
167
- // Invalidate appropriately
168
- if (type === 'add' || type === 'unlink') {
169
- invalidateRouteCache()
170
- } else {
171
- invalidateFile(file)
172
- }
189
+ if (
190
+ !normalized.startsWith(normalizedDocsDir) ||
191
+ !isDocFile(normalized)
192
+ )
193
+ return
173
194
 
174
- // Regenerate and push to client
175
- const newRoutes = await generateRoutes(docsDir, config)
195
+ // Invalidate appropriately
196
+ if (type === 'add' || type === 'unlink') {
197
+ invalidateRouteCache()
198
+ // Re-resolve config as it might affect versions/routes
199
+ config = await resolveConfig(docsDir)
200
+
201
+ const configMod = server.moduleGraph.getModuleById('\0virtual:boltdocs-config')
202
+ if (configMod) server.moduleGraph.invalidateModule(configMod)
203
+
204
+ server.ws.send({
205
+ type: 'custom',
206
+ event: 'boltdocs:config-update',
207
+ data: {
208
+ theme: config?.theme,
209
+ integrations: config?.integrations,
210
+ i18n: config?.i18n,
211
+ versions: config?.versions,
212
+ siteUrl: config?.siteUrl,
213
+ },
214
+ })
215
+ } else {
216
+ invalidateFile(file)
217
+ }
176
218
 
177
- const routesMod = server.moduleGraph.getModuleById(
178
- '\0virtual:boltdocs-routes',
179
- )
180
- if (routesMod) server.moduleGraph.invalidateModule(routesMod)
219
+ // Regenerate and push to client
220
+ // Optimization: generateRoutes is mostly incremental thanks to docCache
221
+ // We only force a full disk scan on add/unlink events
222
+ const newRoutes = await generateRoutes(docsDir, config, '/docs', type !== 'change')
181
223
 
182
- server.ws.send({
183
- type: 'custom',
184
- event: 'boltdocs:routes-update',
185
- data: newRoutes,
186
- })
224
+ const routesMod = server.moduleGraph.getModuleById(
225
+ '\0virtual:boltdocs-routes',
226
+ )
227
+ if (routesMod) server.moduleGraph.invalidateModule(routesMod)
228
+
229
+ server.ws.send({
230
+ type: 'custom',
231
+ event: 'boltdocs:routes-update',
232
+ data: newRoutes,
233
+ })
234
+ } catch (e) {
235
+ console.error(`[boltdocs] HMR error during ${type} event:`, e)
236
+ }
187
237
  }
188
238
 
189
239
  server.watcher.on('add', (f) => handleFileEvent(f, 'add'))
@@ -211,7 +261,6 @@ export function boltdocsPlugin(
211
261
  if (id === '\0virtual:boltdocs-config') {
212
262
  const clientConfig = {
213
263
  theme: config?.theme,
214
- themeConfig: config?.themeConfig,
215
264
  integrations: config?.integrations,
216
265
  i18n: config?.i18n,
217
266
  versions: config?.versions,
@@ -11,7 +11,8 @@ import { sortRoutes } from './sorter'
11
11
  export type { RouteMeta }
12
12
  export { invalidateRouteCache, invalidateFile }
13
13
 
14
- // Cache for localized path computations
14
+ // Cache for file list and localized path computations
15
+ let cachedFileList: string[] | null = null
15
16
  const localizedPathCache = new Map<string, string>()
16
17
 
17
18
  /**
@@ -30,6 +31,7 @@ export async function generateRoutes(
30
31
  docsDir: string,
31
32
  config?: BoltdocsConfig,
32
33
  basePath: string = '/docs',
34
+ forceScan: boolean = true,
33
35
  ): Promise<RouteMeta[]> {
34
36
  const start = performance.now()
35
37
 
@@ -44,13 +46,19 @@ export async function generateRoutes(
44
46
  docCache.invalidateAll()
45
47
  }
46
48
 
47
- // 1. FAST SCAN
48
- const files = await fastGlob(['**/*.md', '**/*.mdx'], {
49
- cwd: docsDir,
50
- absolute: true,
51
- suppressErrors: true,
52
- followSymbolicLinks: false,
53
- })
49
+ // 1. FAST SCAN (Skip if incremental and we have a cache)
50
+ let files: string[]
51
+ if (!forceScan && cachedFileList) {
52
+ files = cachedFileList
53
+ } else {
54
+ files = await fastGlob(['**/*.md', '**/*.mdx'], {
55
+ cwd: docsDir,
56
+ absolute: true,
57
+ suppressErrors: true,
58
+ followSymbolicLinks: false,
59
+ })
60
+ cachedFileList = files
61
+ }
54
62
 
55
63
  // Prune cache entries for deleted files
56
64
  docCache.pruneStale(new Set(files))
@@ -197,8 +205,6 @@ function generateI18nFallbacks(
197
205
  }
198
206
 
199
207
  for (const locale of allLocales) {
200
- if (locale === defaultLocale) continue
201
-
202
208
  const localePaths = routesByLocale.get(locale) || new Set<string>()
203
209
 
204
210
  for (const defRoute of defaultRoutes) {
@@ -207,8 +213,12 @@ function generateI18nFallbacks(
207
213
  defaultLocale,
208
214
  locale,
209
215
  basePath,
216
+ config,
210
217
  )
211
218
 
219
+ // Skip if the path is already the same (e.g. for default locale unprefixed)
220
+ if (targetPath === defRoute.path) continue
221
+
212
222
  if (!localePaths.has(targetPath)) {
213
223
  fallbackRoutes.push({
214
224
  ...defRoute,
@@ -231,15 +241,26 @@ function computeLocalizedPath(
231
241
  defaultLocale: string,
232
242
  targetLocale: string,
233
243
  basePath: string,
244
+ config?: BoltdocsConfig,
234
245
  ): string {
235
246
  const cacheKey = `${path}:${targetLocale}`
236
247
  const cached = localizedPathCache.get(cacheKey)
237
248
  if (cached) return cached
238
249
 
239
250
  let prefix = basePath
240
- const versionMatch = path.match(new RegExp(`^${basePath}/(v[0-9]+)`))
241
- if (versionMatch) {
242
- prefix += '/' + versionMatch[1]
251
+ if (config?.versions) {
252
+ const vPrefix = config.versions.prefix || ''
253
+ for (const vConfig of config.versions.versions) {
254
+ const fullVPath = vPrefix + vConfig.path
255
+ if (path.startsWith(`${basePath}/${fullVPath}`)) {
256
+ prefix += '/' + fullVPath
257
+ break
258
+ }
259
+ if (path.startsWith(`${basePath}/${vConfig.path}`)) {
260
+ prefix += '/' + vConfig.path
261
+ break
262
+ }
263
+ }
243
264
  }
244
265
 
245
266
  let pathAfterVersion = path.substring(prefix.length)
@@ -59,8 +59,17 @@ export function parseDocFile(
59
59
  // Level 1: Check for version
60
60
  if (config?.versions && parts.length > 0) {
61
61
  const potentialVersion = parts[0]
62
- if (config.versions.versions[potentialVersion]) {
63
- version = potentialVersion
62
+ const prefix = config.versions.prefix || ''
63
+
64
+ const versionMatch = config.versions.versions.find(
65
+ (v) => {
66
+ const fullPath = prefix + v.path
67
+ return potentialVersion === fullPath || potentialVersion === v.path
68
+ }
69
+ )
70
+
71
+ if (versionMatch) {
72
+ version = versionMatch.path
64
73
  parts = parts.slice(1)
65
74
  }
66
75
  }
@@ -124,8 +133,7 @@ export function parseDocFile(
124
133
  const headings: { level: number; text: string; id: string }[] = []
125
134
  const headingsRegex = /^(#{2,4})\s+(.+)$/gm
126
135
 
127
- let match
128
- while ((match = headingsRegex.exec(content)) !== null) {
136
+ for (const match of content.matchAll(headingsRegex)) {
129
137
  const level = match[1].length
130
138
  const rawText = match[2]
131
139
  .replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1') // Strip markdown links