boltdocs 2.3.0 → 2.4.1

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 (93) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/base-ui/index.d.mts +3 -3
  3. package/dist/base-ui/index.d.ts +3 -3
  4. package/dist/base-ui/index.js +1 -1
  5. package/dist/base-ui/index.mjs +1 -1
  6. package/dist/chunk-2DI3OGHV.mjs +1 -0
  7. package/dist/chunk-64AJ5QLT.mjs +1 -0
  8. package/dist/chunk-DDX52BX4.mjs +1 -0
  9. package/dist/chunk-HRZDSFR5.mjs +1 -0
  10. package/dist/chunk-PPVDMDEL.mjs +1 -0
  11. package/dist/{chunk-HA6543SL.mjs → chunk-UBE4CKOA.mjs} +1 -1
  12. package/dist/chunk-UWT4AJTH.mjs +73 -0
  13. package/dist/chunk-WWJ7WKDI.mjs +1 -0
  14. package/dist/client/index.d.mts +15 -21
  15. package/dist/client/index.d.ts +15 -21
  16. package/dist/client/index.js +1 -1
  17. package/dist/client/index.mjs +1 -1
  18. package/dist/client/ssr.js +1 -1
  19. package/dist/client/ssr.mjs +1 -1
  20. package/dist/client/types.d.mts +1 -1
  21. package/dist/client/types.d.ts +1 -1
  22. package/dist/client/types.js +1 -1
  23. package/dist/{copy-markdown-CbS8X-qe.d.mts → copy-markdown--9yjpbyy.d.mts} +1 -1
  24. package/dist/{copy-markdown-C-90ixSe.d.ts → copy-markdown-l2MYkcG7.d.ts} +1 -1
  25. package/dist/hooks/index.d.mts +3 -3
  26. package/dist/hooks/index.d.ts +3 -3
  27. package/dist/hooks/index.js +1 -1
  28. package/dist/hooks/index.mjs +1 -1
  29. package/dist/integrations/index.d.mts +1 -1
  30. package/dist/integrations/index.d.ts +1 -1
  31. package/dist/{loading-B7X5Wchs.d.ts → loading-BwUos0wZ.d.mts} +2 -11
  32. package/dist/{loading-WuaQbsKb.d.mts → loading-nlnUD01v.d.ts} +2 -11
  33. package/dist/mdx/index.d.mts +4 -2
  34. package/dist/mdx/index.d.ts +4 -2
  35. package/dist/mdx/index.js +1 -1
  36. package/dist/mdx/index.mjs +1 -1
  37. package/dist/node/cli-entry.js +16 -17
  38. package/dist/node/cli-entry.mjs +1 -1
  39. package/dist/node/index.d.mts +0 -9
  40. package/dist/node/index.d.ts +0 -9
  41. package/dist/node/index.js +13 -14
  42. package/dist/node/index.mjs +1 -1
  43. package/dist/primitives/index.d.mts +11 -20
  44. package/dist/primitives/index.d.ts +11 -20
  45. package/dist/primitives/index.js +1 -1
  46. package/dist/primitives/index.mjs +1 -1
  47. package/dist/search-dialog-OONKKC5H.mjs +1 -0
  48. package/dist/{types-j7jvWsJj.d.ts → types-opDA2E9-.d.mts} +4 -11
  49. package/dist/{types-j7jvWsJj.d.mts → types-opDA2E9-.d.ts} +4 -11
  50. package/dist/{use-routes-Cd806kGw.d.ts → use-routes-DNwgTRpU.d.ts} +1 -1
  51. package/dist/{use-routes-DDL0_jkQ.d.mts → use-routes-DrT80Eom.d.mts} +1 -1
  52. package/package.json +1 -1
  53. package/src/client/app/index.tsx +20 -9
  54. package/src/client/app/mdx-components-context.tsx +2 -2
  55. package/src/client/app/mdx-page.tsx +0 -1
  56. package/src/client/app/scroll-handler.tsx +21 -10
  57. package/src/client/components/default-layout.tsx +0 -2
  58. package/src/client/components/docs-layout.tsx +34 -4
  59. package/src/client/components/icons-dev.tsx +154 -0
  60. package/src/client/components/mdx/code-block.tsx +57 -5
  61. package/src/client/components/mdx/component-preview.tsx +1 -0
  62. package/src/client/components/mdx/file-tree.tsx +35 -0
  63. package/src/client/components/primitives/helpers/observer.ts +30 -39
  64. package/src/client/components/primitives/index.ts +1 -0
  65. package/src/client/components/primitives/menu.tsx +18 -12
  66. package/src/client/components/primitives/navbar.tsx +31 -90
  67. package/src/client/components/primitives/on-this-page.tsx +7 -161
  68. package/src/client/components/primitives/popover.tsx +1 -2
  69. package/src/client/components/ui-base/copy-markdown.tsx +4 -10
  70. package/src/client/components/ui-base/index.ts +0 -1
  71. package/src/client/components/ui-base/navbar.tsx +10 -9
  72. package/src/client/hooks/use-navbar.ts +37 -6
  73. package/src/client/index.ts +0 -1
  74. package/src/client/theme/neutral.css +2 -3
  75. package/src/client/types.ts +2 -2
  76. package/src/node/config.ts +0 -14
  77. package/src/node/mdx/cache.ts +1 -1
  78. package/src/node/mdx/index.ts +2 -0
  79. package/src/node/mdx/rehype-shiki.ts +9 -0
  80. package/src/node/mdx/remark-code-meta.ts +35 -0
  81. package/src/node/mdx/remark-shiki.ts +1 -1
  82. package/src/node/plugin/entry.ts +21 -14
  83. package/src/node/plugin/index.ts +22 -4
  84. package/src/node/routes/parser.ts +3 -0
  85. package/src/node/ssg/index.ts +76 -16
  86. package/dist/chunk-22NXDNP4.mjs +0 -74
  87. package/dist/chunk-2HUVMMJU.mjs +0 -1
  88. package/dist/chunk-CRZGOE32.mjs +0 -1
  89. package/dist/chunk-RPUERTVC.mjs +0 -1
  90. package/dist/chunk-URTD6E6S.mjs +0 -1
  91. package/dist/chunk-W2NB4T6V.mjs +0 -1
  92. package/dist/search-dialog-ZRXBAQJ5.mjs +0 -1
  93. package/src/client/components/ui-base/progress-bar.tsx +0 -67
@@ -12,8 +12,8 @@ import React, {
12
12
  import scrollIntoView from 'scroll-into-view-if-needed'
13
13
  import { cn } from '../../utils/cn'
14
14
  import { useOnChange } from '../../utils/use-on-change'
15
- import type { ComponentBase, CompoundComponent } from './types'
16
- import { getItemId } from './helpers/observer'
15
+ import type { ComponentBase } from './types'
16
+ import { getItemId, Observer } from './helpers/observer'
17
17
 
18
18
  export interface TOCItemType {
19
19
  title: ReactNode
@@ -74,120 +74,6 @@ export interface OnThisPageIndicatorProps extends ComponentBase {
74
74
  const ItemsContext = createContext<TOCItemInfo[] | null>(null)
75
75
  const ScrollContext = createContext<RefObject<HTMLElement | null> | null>(null)
76
76
 
77
- class Observer {
78
- items: TOCItemInfo[] = []
79
- single = false
80
- private observer: IntersectionObserver | null = null
81
- onChange?: () => void
82
-
83
- private callback(entries: IntersectionObserverEntry[]) {
84
- if (entries.length === 0) return
85
-
86
- let hasActive = false
87
- this.items = this.items.map((item) => {
88
- const entry = entries.find((entry) => entry.target.id === item.id)
89
- let active = entry ? entry.isIntersecting : item.active && !item.fallback
90
- if (this.single && hasActive) active = false
91
-
92
- if (item.active !== active) {
93
- item = {
94
- ...item,
95
- t: Date.now(),
96
- active,
97
- fallback: false,
98
- }
99
- }
100
-
101
- if (active) hasActive = true
102
- return item
103
- })
104
-
105
- if (!hasActive && entries[0].rootBounds) {
106
- const viewTop = entries[0].rootBounds.top
107
- let min = Number.MAX_VALUE
108
- let fallbackIdx = -1
109
-
110
- for (let i = 0; i < this.items.length; i++) {
111
- const element = document.getElementById(this.items[i].id)
112
- if (!element) continue
113
-
114
- const d = Math.abs(viewTop - element.getBoundingClientRect().top)
115
- if (d < min) {
116
- fallbackIdx = i
117
- min = d
118
- }
119
- }
120
-
121
- if (fallbackIdx !== -1) {
122
- this.items[fallbackIdx] = {
123
- ...this.items[fallbackIdx],
124
- active: true,
125
- fallback: true,
126
- t: Date.now(),
127
- }
128
- }
129
- }
130
-
131
- this.onChange?.()
132
- }
133
-
134
- setItems(newItems: TOCItemType[]) {
135
- const observer = this.observer
136
- if (observer) {
137
- for (const item of this.items) {
138
- const element = document.getElementById(item.id)
139
- if (!element) continue
140
- observer.unobserve(element)
141
- }
142
- }
143
-
144
- this.items = []
145
- for (const item of newItems) {
146
- const id = getItemId(item.url)
147
- if (!id) continue
148
-
149
- this.items.push({
150
- id,
151
- active: false,
152
- fallback: false,
153
- t: 0,
154
- original: item,
155
- })
156
- }
157
- this.watchItems()
158
-
159
- // In an SPA, the TOC might update before the MDX content is in the DOM.
160
- // We perform a few delayed scans to ensure we catch those elements.
161
- if (typeof window !== 'undefined') {
162
- setTimeout(() => this.watchItems(), 100)
163
- setTimeout(() => this.watchItems(), 500)
164
- setTimeout(() => this.watchItems(), 1000)
165
- }
166
-
167
- this.onChange?.()
168
- }
169
-
170
- watch(options?: IntersectionObserverInit) {
171
- if (this.observer) return
172
- this.observer = new IntersectionObserver(this.callback.bind(this), options)
173
- this.watchItems()
174
- }
175
-
176
- private watchItems() {
177
- if (!this.observer) return
178
- for (const item of this.items) {
179
- const element = document.getElementById(item.id)
180
- if (!element) continue
181
- this.observer.observe(element)
182
- }
183
- }
184
-
185
- unwatch() {
186
- this.observer?.disconnect()
187
- this.observer = null
188
- }
189
- }
190
-
191
77
  export function useItems() {
192
78
  const ctx = use(ItemsContext)
193
79
  if (!ctx)
@@ -197,40 +83,6 @@ export function useItems() {
197
83
  return ctx
198
84
  }
199
85
 
200
- export function useScrollStatus(ref: RefObject<HTMLElement | null>) {
201
- const [status, setStatus] = useState({
202
- isOverflowing: false,
203
- isAtBottom: false,
204
- })
205
-
206
- useEffect(() => {
207
- const el = ref.current
208
- if (!el) return
209
-
210
- const checkStatus = () => {
211
- const isOverflowing = el.scrollHeight > el.clientHeight
212
- // We use a 2px threshold for floating point math issues
213
- const isAtBottom = el.scrollHeight - el.scrollTop <= el.clientHeight + 2
214
- setStatus({ isOverflowing, isAtBottom })
215
- }
216
-
217
- checkStatus()
218
- el.addEventListener('scroll', checkStatus, { passive: true })
219
- window.addEventListener('resize', checkStatus)
220
-
221
- const mutationObserver = new MutationObserver(checkStatus)
222
- mutationObserver.observe(el, { childList: true, subtree: true })
223
-
224
- return () => {
225
- el.removeEventListener('scroll', checkStatus)
226
- window.removeEventListener('resize', checkStatus)
227
- mutationObserver.disconnect()
228
- }
229
- }, [ref])
230
-
231
- return status
232
- }
233
-
234
86
  export function useActiveAnchor(): string | undefined {
235
87
  const items = useItems()
236
88
  return useMemo(() => {
@@ -282,9 +134,11 @@ export function AnchorProvider({
282
134
  }, [observer, toc])
283
135
 
284
136
  useEffect(() => {
137
+ // We use a rootMargin that acts as an activation "line" near the top.
138
+ // headings are "intersecting" (active=true) when they are BELOW this line.
285
139
  observer.watch({
286
- rootMargin: '0px',
287
- threshold: 0.98,
140
+ rootMargin: '-100px 0% 0% 0%',
141
+ threshold: 0,
288
142
  })
289
143
  observer.onChange = () => setItems([...observer.items])
290
144
 
@@ -339,18 +193,10 @@ export const OnThisPageContent = ({
339
193
 
340
194
  useImperativeHandle(ref, () => internalRef.current!)
341
195
 
342
- const { isOverflowing, isAtBottom } = useScrollStatus(internalRef)
343
-
344
196
  return (
345
197
  <div
346
198
  ref={internalRef}
347
- className={cn(
348
- 'relative overflow-y-auto boltdocs-otp-content',
349
- isOverflowing &&
350
- !isAtBottom &&
351
- 'mask-[linear-gradient(to_bottom,black_85%,transparent_100%)]',
352
- className,
353
- )}
199
+ className={cn('relative overflow-y-auto boltdocs-otp-content', className)}
354
200
  {...props}
355
201
  >
356
202
  {children}
@@ -24,8 +24,7 @@ export const Popover = ({
24
24
  {...props}
25
25
  className={RAC.composeRenderProps(className, (className) =>
26
26
  cn(
27
- 'z-50 overflow-auto rounded-xl border border-border-subtle bg-bg-surface/80 shadow-xl backdrop-blur-md outline-none',
28
- 'entering:animate-in entering:fade-in entering:zoom-in-95 exiting:animate-out exiting:fade-out exiting:zoom-out-95 fill-mode-forwards',
27
+ 'z-50 overflow-auto rounded-xl border border-border-subtle bg-bg-surface/80 shadow-xl backdrop-blur-md outline-none transition-none',
29
28
  className,
30
29
  ),
31
30
  )}
@@ -80,25 +80,19 @@ export function CopyMarkdown({ content, mdxRaw, config }: CopyMarkdownProps) {
80
80
  )}
81
81
  />
82
82
  <Menu className="w-52">
83
- <MenuItem
84
- onAction={handleCopy}
85
- className="flex flex-row items-start gap-2.5 group"
86
- >
83
+ <MenuItem onAction={handleCopy}>
87
84
  <Copy
88
85
  size={16}
89
- className="w-4 h-4 shrink-0 mt-0.5 transition-transform duration-200 group-hover:-translate-y-0.5 text-text-muted group-hover:text-primary-500"
86
+ className="size-4 mt-0.5 text-text-muted group-hover:text-primary-500"
90
87
  />
91
88
  <span className="font-medium text-[0.8125rem]">
92
89
  Copy Markdown
93
90
  </span>
94
91
  </MenuItem>
95
- <MenuItem
96
- onAction={handleOpenRaw}
97
- className="flex flex-row items-start gap-2.5 group"
98
- >
92
+ <MenuItem onAction={handleOpenRaw}>
99
93
  <ExternalLink
100
94
  size={16}
101
- className="w-4 h-4 shrink-0 mt-0.5 transition-transform duration-200 group-hover:-translate-y-0.5 text-text-muted group-hover:text-primary-500"
95
+ className="size-4 mt-0.5 text-text-muted group-hover:text-primary-500"
102
96
  />
103
97
  <span className="font-medium text-[0.8125rem]">
104
98
  View as Markdown
@@ -10,7 +10,6 @@ export { NotFound } from './not-found'
10
10
  export { OnThisPage } from './on-this-page'
11
11
  export { PageNav } from './page-nav'
12
12
  export { PoweredBy } from './powered-by'
13
- export { ProgressBar } from './progress-bar'
14
13
  export { SearchDialog } from './search-dialog'
15
14
  export { Sidebar } from './sidebar'
16
15
  export { Tabs } from './tabs'
@@ -26,7 +26,7 @@ export function Navbar() {
26
26
  const { routes, allRoutes, currentVersion, currentLocale } = useRoutes()
27
27
  const { pathname } = useLocation()
28
28
  const themeConfig = config.theme || {}
29
-
29
+ const isDocs = pathname.startsWith('/docs')
30
30
  const hasTabs = themeConfig?.tabs && themeConfig.tabs.length > 0
31
31
 
32
32
  return (
@@ -44,12 +44,6 @@ export function Navbar() {
44
44
  <NavbarPrimitive.Title>{title}</NavbarPrimitive.Title>
45
45
 
46
46
  {config.versions && currentVersion && <NavbarVersion />}
47
-
48
- <NavbarPrimitive.Links>
49
- {links.map((link) => (
50
- <NavbarLinkItem key={link.href} link={link} />
51
- ))}
52
- </NavbarPrimitive.Links>
53
47
  </NavbarPrimitive.NavbarLeft>
54
48
  <NavbarPrimitive.NavbarCenter>
55
49
  <Suspense
@@ -61,6 +55,13 @@ export function Navbar() {
61
55
  </Suspense>
62
56
  </NavbarPrimitive.NavbarCenter>
63
57
  <NavbarPrimitive.NavbarRight>
58
+ <NavbarPrimitive.Links>
59
+ {links.map((link) => (
60
+ <>
61
+ <NavbarLinkItem key={link.href} link={link} />
62
+ </>
63
+ ))}
64
+ </NavbarPrimitive.Links>
64
65
  {config.i18n && currentLocale && <NavbarI18n />}
65
66
  <NavbarPrimitive.Split />
66
67
  <ThemeToggle />
@@ -79,7 +80,7 @@ export function Navbar() {
79
80
  </NavbarPrimitive.NavbarRight>
80
81
  </NavbarPrimitive.Content>
81
82
 
82
- {pathname !== '/' && hasTabs && themeConfig?.tabs && (
83
+ {isDocs && hasTabs && themeConfig?.tabs && (
83
84
  <div className="w-full border-b border-border-subtle bg-bg-main">
84
85
  <Tabs tabs={themeConfig.tabs} routes={allRoutes || routes || []} />
85
86
  </div>
@@ -89,7 +90,7 @@ export function Navbar() {
89
90
  }
90
91
 
91
92
  function NavbarLinkItem({ link }: { link: NavbarLinkType }) {
92
- const localizedHref = useLocalizedTo(link.href)
93
+ const localizedHref = useLocalizedTo(link.href || '')
93
94
  return <NavbarPrimitive.Link {...(link as any)} href={localizedHref} />
94
95
  }
95
96
 
@@ -19,19 +19,50 @@ export function useNavbar() {
19
19
 
20
20
  // Transform links to the new NavbarLink structure
21
21
  const links: NavbarLink[] = rawLinks.map((item: any) => {
22
- const href = item.href || item.to || item.link || ''
22
+ const href = (item.href || item.to || item.link || '') as string
23
+
24
+ // Robust active state calculation
25
+ const getIsActive = (h: string) => {
26
+ const activePath = location.pathname
27
+ if (activePath === h) return true
28
+ if (!h || h === '/') return activePath === '/'
29
+
30
+ const cleanPathParts = (p: string) => {
31
+ const parts = p.split('/').filter(Boolean)
32
+ let i = 0
33
+ // Skip locale
34
+ if (config.i18n?.locales && parts[i] && config.i18n.locales[parts[i]]) {
35
+ i++
36
+ }
37
+ // Skip version
38
+ if (config.versions?.versions && parts[i]) {
39
+ if (config.versions.versions.some((v) => v.path === parts[i])) {
40
+ i++
41
+ }
42
+ }
43
+ return parts.slice(i)
44
+ }
45
+
46
+ const hParts = cleanPathParts(h)
47
+ const pParts = cleanPathParts(activePath)
48
+
49
+ if (hParts.length === 0) return pParts.length === 0
50
+
51
+ // Must match at least as many parts as the candidate link
52
+ if (pParts.length < hParts.length) return false
53
+
54
+ // Every part of hParts must match pParts at the same position
55
+ return hParts.every((part, i) => pParts[i] === part)
56
+ }
57
+
23
58
  return {
24
59
  label: getTranslated(item.label || item.text, currentLocale),
25
60
  href,
26
- active: location.pathname === href,
61
+ active: getIsActive(href),
27
62
  to:
28
63
  href.startsWith('http') || href.startsWith('//')
29
64
  ? 'external'
30
65
  : undefined,
31
- items: item.items?.map((sub: any) => ({
32
- label: getTranslated(sub.label || sub.text, currentLocale),
33
- href: sub.href || sub.link || sub.to || '',
34
- })),
35
66
  }
36
67
  })
37
68
 
@@ -21,7 +21,6 @@ export { OnThisPage } from '@components/ui-base/on-this-page'
21
21
  export { Head } from '@components/ui-base/head'
22
22
  export { Breadcrumbs } from '@components/ui-base/breadcrumbs'
23
23
  export { PageNav } from '@components/ui-base/page-nav'
24
- export { ProgressBar } from '@components/ui-base/progress-bar'
25
24
  export { ErrorBoundary } from '@components/ui-base/error-boundary'
26
25
  export { CopyMarkdown } from '@components/ui-base/copy-markdown'
27
26
 
@@ -66,7 +66,7 @@
66
66
  --spacing-navbar: 3.5rem;
67
67
  --spacing-sidebar: 16rem;
68
68
  --spacing-toc: 14rem;
69
- --spacing-content-max: 48rem;
69
+ --spacing-content-max: 54rem;
70
70
 
71
71
  @keyframes pulse {
72
72
  0%,
@@ -123,9 +123,8 @@
123
123
  body {
124
124
  margin: 0;
125
125
  padding: 0;
126
- height: 100%;
126
+ min-height: 100%;
127
127
  overflow-x: hidden;
128
- overflow-y: hidden;
129
128
  }
130
129
 
131
130
  body {
@@ -74,6 +74,8 @@ export interface CreateBoltdocsAppOptions {
74
74
  homePage?: React.ComponentType
75
75
  /** Custom external pages mapped by their route path */
76
76
  externalPages?: Record<string, React.ComponentType>
77
+ /** Optional custom layout for external pages */
78
+ externalLayout?: React.ComponentType<{ children: React.ReactNode }>
77
79
  /** Optional custom MDX components provided by plugins */
78
80
  components?: Record<string, React.ComponentType>
79
81
  }
@@ -167,6 +169,4 @@ export interface NavbarLink {
167
169
  active: boolean
168
170
  /** Optional icon or string for external link indication */
169
171
  to?: string
170
- /** Nested items for NavigationMenu */
171
- items?: NavbarLink[]
172
172
  }
@@ -44,13 +44,6 @@ export interface BoltdocsThemeConfig {
44
44
  label: string | Record<string, string>
45
45
  /** URL path or external link */
46
46
  href: string
47
- /** Nested items for NavigationMenu */
48
- items?: Array<{
49
- /** Text to display (can be a string or a map of translations) */
50
- label: string | Record<string, string>
51
- /** URL path or external link */
52
- href: string
53
- }>
54
47
  }>
55
48
  /** Items to display in the sidebar, organized optionally by group URLs */
56
49
  sidebar?: Record<string, Array<{ text: string; link: string }>>
@@ -222,8 +215,6 @@ export interface BoltdocsConfig {
222
215
  versions?: BoltdocsVersionsConfig
223
216
  /** Custom plugins for extending functionality */
224
217
  plugins?: BoltdocsPlugin[]
225
- /** Map of custom external route paths to component file paths */
226
- external?: Record<string, string>
227
218
  /** External integrations configuration */
228
219
  integrations?: BoltdocsIntegrationsConfig
229
220
  /** Configuration for the robots.txt file */
@@ -344,10 +335,6 @@ export async function resolveConfig(
344
335
  cleanThemeConfig.navbar = cleanThemeConfig.navbar.map((item: any) => ({
345
336
  label: item.label || item.text || '',
346
337
  href: item.href || item.link || item.to || '',
347
- items: item.items?.map((sub: any) => ({
348
- label: sub.label || sub.text || '',
349
- href: sub.href || sub.link || sub.to || '',
350
- })),
351
338
  }))
352
339
  }
353
340
 
@@ -362,7 +349,6 @@ export async function resolveConfig(
362
349
  versions: userConfig.versions,
363
350
  siteUrl: userConfig.siteUrl,
364
351
  plugins: userConfig.plugins || [],
365
- external: userConfig.external,
366
352
  integrations: userConfig.integrations,
367
353
  robots: userConfig.robots,
368
354
  vite: userConfig.vite,
@@ -3,7 +3,7 @@ import { TransformCache } from '../cache'
3
3
  /**
4
4
  * Version identifier for the MDX plugin to invalidate cache if logic changes.
5
5
  */
6
- export const MDX_PLUGIN_VERSION = 'v3'
6
+ export const MDX_PLUGIN_VERSION = 'v4'
7
7
 
8
8
  /**
9
9
  * Persistent cache for MDX transformations.
@@ -9,6 +9,7 @@ import type { BoltdocsConfig } from '../config'
9
9
  import { mdxCache, MDX_PLUGIN_VERSION } from './cache'
10
10
  import { remarkShiki } from './remark-shiki'
11
11
  import { rehypeShiki } from './rehype-shiki'
12
+ import { remarkCodeMeta } from './remark-code-meta'
12
13
 
13
14
  let mdxCacheLoaded = false
14
15
  let hits = 0
@@ -38,6 +39,7 @@ export function boltdocsMdxPlugin(
38
39
  remarkPlugins: [
39
40
  remarkGfm,
40
41
  remarkFrontmatter,
42
+ remarkCodeMeta,
41
43
  [remarkShiki, config],
42
44
  ...(extraRemarkPlugins as any[]),
43
45
  ],
@@ -31,6 +31,11 @@ export function rehypeShiki(config?: BoltdocsConfig) {
31
31
  const lang = langMatch ? langMatch.slice(9) : 'text'
32
32
  const code = codeNode.children[0]?.value || ''
33
33
 
34
+ // Extract title from meta string (e.g., ```ts title="app.ts")
35
+ const meta: string = codeNode.data?.meta || codeNode.properties?.metastring || ''
36
+ const titleMatch = meta.match(/title\s*=\s*"([^"]*)"/)
37
+ const title = titleMatch ? titleMatch[1] : undefined
38
+
34
39
  const options: any = { lang }
35
40
  if (typeof codeTheme === 'object') {
36
41
  options.themes = {
@@ -46,6 +51,10 @@ export function rehypeShiki(config?: BoltdocsConfig) {
46
51
  // Inject highlighted HTML and mark as highlighted for CodeBlock component
47
52
  node.properties.dataHighlighted = 'true'
48
53
  node.properties.highlightedHtml = html
54
+ node.properties['data-lang'] = lang
55
+ if (title) {
56
+ node.properties.title = title
57
+ }
49
58
  node.children = []
50
59
  }
51
60
  })
@@ -0,0 +1,35 @@
1
+ import { visit } from 'unist-util-visit'
2
+
3
+ /**
4
+ * Remark plugin that preserves code fence meta strings (e.g., title="file.ts")
5
+ * and the language identifier by copying them to hProperties so they survive
6
+ * the remark → rehype conversion and are accessible as props on the `<pre>` element.
7
+ *
8
+ * Usage in MDX: ```ts title="app.ts"
9
+ */
10
+ export function remarkCodeMeta() {
11
+ return (tree: any) => {
12
+ visit(tree, 'code', (node: any) => {
13
+ node.data = node.data || {}
14
+ node.data.hProperties = node.data.hProperties || {}
15
+
16
+ // Always pass the lang through
17
+ if (node.lang) {
18
+ node.data.hProperties['data-lang'] = node.lang
19
+ }
20
+
21
+ if (!node.meta) return
22
+
23
+ const meta: string = node.meta
24
+
25
+ // Extract title="..." from the meta string
26
+ const titleMatch = meta.match(/title\s*=\s*"([^"]*)"/)
27
+ if (titleMatch) {
28
+ node.data.hProperties.title = titleMatch[1]
29
+ }
30
+
31
+ // Preserve the full meta string for other plugins
32
+ node.data.hProperties.metastring = meta
33
+ })
34
+ }
35
+ }
@@ -32,7 +32,7 @@ export function remarkShiki(config?: BoltdocsConfig) {
32
32
  code = codeAttr.value
33
33
  } else if (codeAttr.value?.type === 'mdxJsxAttributeValueExpression') {
34
34
  const expr = codeAttr.value.value ?? ''
35
- code = expr.match(/^[`'"](.+)[`'"]$/)?.[1] ?? expr
35
+ code = expr.match(/^[`'"]([\s\S]+)[`'"]$/)?.[1] ?? expr
36
36
  }
37
37
  }
38
38
 
@@ -24,7 +24,7 @@ export function generateEntryCode(
24
24
  const cssPath = path.resolve(process.cwd(), 'index.css')
25
25
  const cssImport = fs.existsSync(cssPath) ? "import './index.css';" : ''
26
26
 
27
- const homeOption = options.homePage ? 'homePage: HomePage,' : ''
27
+ let homeOption = options.homePage ? 'homePage: HomePage,' : ''
28
28
  const pluginComponents =
29
29
  config?.plugins?.flatMap((p) => Object.entries(p.components || {})) || []
30
30
 
@@ -40,21 +40,28 @@ const ${name} = _comp_${name}.default || _comp_${name}['${name}'] || _comp_${nam
40
40
  const componentMap = pluginComponents.map(([name]) => name).join(', ')
41
41
 
42
42
  const docsDirName = path.basename(options.docsDir || 'docs')
43
+ const docsDir = path.resolve(process.cwd(), options.docsDir || 'docs')
43
44
 
44
- const externalEntries = Object.entries(config?.external || {})
45
- const externalImports = externalEntries
46
- .map(
47
- ([_routePath, compPath], i) =>
48
- `import _ext_${i} from '${normalizePath(compPath)}';`,
49
- )
50
- .join('\n')
51
- const externalOption =
52
- externalEntries.length > 0
53
- ? `externalPages: { ${externalEntries
54
- .map(([path], i) => `"${path}": _ext_${i}`)
55
- .join(', ')} },`
45
+ // Detect external pages module
46
+ const externalModulePath = ['tsx', 'ts', 'jsx', 'js']
47
+ .map((ext) => path.resolve(docsDir, `pages-external/index.${ext}`))
48
+ .find((p) => fs.existsSync(p))
49
+
50
+ const externalModuleImport = externalModulePath
51
+ ? `import * as _external_module from '${normalizePath(externalModulePath)}';`
52
+ : ''
53
+
54
+ // Prioritize homePage from external module if it exists
55
+ homeOption = externalModulePath
56
+ ? 'homePage: _external_module.homePage || HomePage,'
57
+ : options.homePage
58
+ ? 'homePage: HomePage,'
56
59
  : ''
57
60
 
61
+ const externalOption = externalModulePath
62
+ ? 'externalPages: _external_module.pages, externalLayout: _external_module.layout,'
63
+ : ''
64
+
58
65
  return `
59
66
  import { createBoltdocsApp as _createApp } from 'boltdocs/client';
60
67
  import _routes from 'virtual:boltdocs-routes';
@@ -63,7 +70,7 @@ import _user_mdx_components from 'virtual:boltdocs-mdx-components';
63
70
  ${cssImport}
64
71
  ${homeImport}
65
72
  ${componentImports}
66
- ${externalImports}
73
+ ${externalModuleImport}
67
74
 
68
75
  _createApp({
69
76
  target: '#root',
@@ -102,10 +102,10 @@ export function boltdocsPlugin(
102
102
  (locale) =>
103
103
  url.startsWith(`/${locale}/docs`) || url === `/${locale}`,
104
104
  )) ||
105
- (config.external &&
106
- Object.keys(config.external).some((extPath) =>
107
- url.startsWith(extPath),
108
- ))
105
+ // Handle any HTML request that isn't a known static file or docs,
106
+ // as it potentially could be an external page.
107
+ // (The client-side router will handle 404s if it doesn't match anything)
108
+ true
109
109
 
110
110
  // Improved check: If it's a doc route, serve HTML even if it has a dot (e.g. version 1.1)
111
111
  // We only skip if it has a known asset extension to prevent serving HTML for images/js/etc.
@@ -140,11 +140,15 @@ export function boltdocsPlugin(
140
140
  const mdxCompPaths = mdxCompExtensions.map((ext) =>
141
141
  path.resolve(docsDir, `mdx-components.${ext}`),
142
142
  )
143
+ const extPagesPaths = mdxCompExtensions.map((ext) =>
144
+ path.resolve(docsDir, `pages-external/index.${ext}`),
145
+ )
143
146
 
144
147
  server.watcher.add([
145
148
  ...configPaths,
146
149
  ...mdxCompPaths,
147
150
  ...layoutCompPaths,
151
+ ...extPagesPaths,
148
152
  ])
149
153
 
150
154
  const handleFileEvent = async (
@@ -186,6 +190,19 @@ export function boltdocsPlugin(
186
190
  return
187
191
  }
188
192
 
193
+ // If any pages-external file changes, invalidate the entry module
194
+ if (
195
+ normalized.includes('/pages-external/') ||
196
+ normalized.includes('\\pages-external\\')
197
+ ) {
198
+ const mod = server.moduleGraph.getModuleById(
199
+ '\0virtual:boltdocs-entry',
200
+ )
201
+ if (mod) server.moduleGraph.invalidateModule(mod)
202
+ server.ws.send({ type: 'full-reload' })
203
+ return
204
+ }
205
+
189
206
  if (
190
207
  !normalized.startsWith(normalizedDocsDir) ||
191
208
  !isDocFile(normalized)
@@ -273,6 +290,7 @@ export function boltdocsPlugin(
273
290
  i18n: config?.i18n,
274
291
  versions: config?.versions,
275
292
  siteUrl: config?.siteUrl,
293
+ plugins: config?.plugins?.map((p) => ({ name: p.name })),
276
294
  }
277
295
  return `export default ${JSON.stringify(clientConfig, null, 2)};`
278
296
  }
@@ -110,6 +110,9 @@ export function parseDocFile(
110
110
  if (locale) {
111
111
  finalPath += '/' + locale
112
112
  }
113
+ if (inferredTab) {
114
+ finalPath += '/' + inferredTab
115
+ }
113
116
  finalPath += cleanRoutePath === '/' ? '' : cleanRoutePath
114
117
 
115
118
  if (!finalPath || finalPath === '') finalPath = '/'