boltdocs 2.2.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 (124) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/bin/boltdocs.js +2 -2
  3. package/dist/base-ui/index.d.mts +4 -4
  4. package/dist/base-ui/index.d.ts +4 -4
  5. package/dist/base-ui/index.js +1 -1
  6. package/dist/base-ui/index.mjs +1 -1
  7. package/dist/{cache-CRAZ55X7.mjs → cache-P6WK424C.mjs} +1 -1
  8. package/dist/chunk-2DI3OGHV.mjs +1 -0
  9. package/dist/chunk-2Z5T6EAU.mjs +1 -0
  10. package/dist/chunk-64AJ5QLT.mjs +1 -0
  11. package/dist/chunk-DDX52BX4.mjs +1 -0
  12. package/dist/chunk-HRZDSFR5.mjs +1 -0
  13. package/dist/chunk-PPVDMDEL.mjs +1 -0
  14. package/dist/chunk-UBE4CKOA.mjs +1 -0
  15. package/dist/chunk-UWT4AJTH.mjs +73 -0
  16. package/dist/chunk-WWJ7WKDI.mjs +1 -0
  17. package/dist/chunk-Y4RRHPXC.mjs +1 -0
  18. package/dist/client/index.d.mts +15 -21
  19. package/dist/client/index.d.ts +15 -21
  20. package/dist/client/index.js +1 -1
  21. package/dist/client/index.mjs +1 -1
  22. package/dist/client/ssr.js +1 -1
  23. package/dist/client/ssr.mjs +1 -1
  24. package/dist/client/types.d.mts +1 -1
  25. package/dist/client/types.d.ts +1 -1
  26. package/dist/client/types.js +1 -1
  27. package/dist/{copy-markdown-CbS8X-qe.d.mts → copy-markdown--9yjpbyy.d.mts} +1 -1
  28. package/dist/{copy-markdown-C-90ixSe.d.ts → copy-markdown-l2MYkcG7.d.ts} +1 -1
  29. package/dist/hooks/index.d.mts +8 -16
  30. package/dist/hooks/index.d.ts +8 -16
  31. package/dist/hooks/index.js +1 -1
  32. package/dist/hooks/index.mjs +1 -1
  33. package/dist/integrations/index.d.mts +1 -1
  34. package/dist/integrations/index.d.ts +1 -1
  35. package/dist/{loading-chS3pm9W.d.ts → loading-BwUos0wZ.d.mts} +5 -16
  36. package/dist/{loading-BqGrFWO5.d.mts → loading-nlnUD01v.d.ts} +5 -16
  37. package/dist/mdx/index.d.mts +4 -2
  38. package/dist/mdx/index.d.ts +4 -2
  39. package/dist/mdx/index.js +1 -1
  40. package/dist/mdx/index.mjs +1 -1
  41. package/dist/node/cli-entry.js +25 -22
  42. package/dist/node/cli-entry.mjs +5 -1
  43. package/dist/node/index.d.mts +0 -9
  44. package/dist/node/index.d.ts +0 -9
  45. package/dist/node/index.js +14 -15
  46. package/dist/node/index.mjs +1 -1
  47. package/dist/primitives/index.d.mts +13 -22
  48. package/dist/primitives/index.d.ts +13 -22
  49. package/dist/primitives/index.js +1 -1
  50. package/dist/primitives/index.mjs +1 -1
  51. package/dist/search-dialog-OONKKC5H.mjs +1 -0
  52. package/dist/{types-j7jvWsJj.d.ts → types-opDA2E9-.d.mts} +4 -11
  53. package/dist/{types-j7jvWsJj.d.mts → types-opDA2E9-.d.ts} +4 -11
  54. package/dist/{use-routes-Cd806kGw.d.ts → use-routes-DNwgTRpU.d.ts} +1 -1
  55. package/dist/{use-routes-DDL0_jkQ.d.mts → use-routes-DrT80Eom.d.mts} +1 -1
  56. package/package.json +2 -1
  57. package/src/client/app/index.tsx +20 -9
  58. package/src/client/app/mdx-components-context.tsx +2 -2
  59. package/src/client/app/mdx-page.tsx +0 -1
  60. package/src/client/app/scroll-handler.tsx +21 -10
  61. package/src/client/app/theme-context.tsx +14 -7
  62. package/src/client/components/default-layout.tsx +6 -4
  63. package/src/client/components/docs-layout.tsx +34 -4
  64. package/src/client/components/icons-dev.tsx +154 -0
  65. package/src/client/components/mdx/code-block.tsx +57 -5
  66. package/src/client/components/mdx/component-preview.tsx +1 -0
  67. package/src/client/components/mdx/file-tree.tsx +35 -0
  68. package/src/client/components/primitives/helpers/observer.ts +30 -39
  69. package/src/client/components/primitives/index.ts +1 -0
  70. package/src/client/components/primitives/menu.tsx +18 -12
  71. package/src/client/components/primitives/navbar.tsx +34 -93
  72. package/src/client/components/primitives/on-this-page.tsx +7 -161
  73. package/src/client/components/primitives/popover.tsx +1 -2
  74. package/src/client/components/primitives/search-dialog.tsx +4 -4
  75. package/src/client/components/primitives/sidebar.tsx +3 -2
  76. package/src/client/components/primitives/skeleton.tsx +26 -0
  77. package/src/client/components/ui-base/copy-markdown.tsx +4 -10
  78. package/src/client/components/ui-base/index.ts +0 -1
  79. package/src/client/components/ui-base/loading.tsx +43 -73
  80. package/src/client/components/ui-base/navbar.tsx +18 -15
  81. package/src/client/components/ui-base/page-nav.tsx +2 -1
  82. package/src/client/components/ui-base/powered-by.tsx +4 -1
  83. package/src/client/components/ui-base/search-dialog.tsx +16 -5
  84. package/src/client/components/ui-base/sidebar.tsx +4 -2
  85. package/src/client/hooks/use-i18n.ts +3 -2
  86. package/src/client/hooks/use-localized-to.ts +6 -5
  87. package/src/client/hooks/use-navbar.ts +37 -6
  88. package/src/client/hooks/use-page-nav.ts +27 -6
  89. package/src/client/hooks/use-routes.ts +2 -1
  90. package/src/client/hooks/use-search.ts +81 -59
  91. package/src/client/hooks/use-sidebar.ts +2 -1
  92. package/src/client/index.ts +0 -1
  93. package/src/client/store/use-boltdocs-store.ts +6 -5
  94. package/src/client/theme/neutral.css +31 -3
  95. package/src/client/types.ts +2 -2
  96. package/src/node/{cli.ts → cli/build.ts} +17 -23
  97. package/src/node/cli/dev.ts +22 -0
  98. package/src/node/cli/doctor.ts +243 -0
  99. package/src/node/cli/index.ts +9 -0
  100. package/src/node/cli/ui.ts +54 -0
  101. package/src/node/cli-entry.ts +16 -16
  102. package/src/node/config.ts +1 -15
  103. package/src/node/mdx/cache.ts +1 -1
  104. package/src/node/mdx/index.ts +2 -0
  105. package/src/node/mdx/rehype-shiki.ts +9 -0
  106. package/src/node/mdx/remark-code-meta.ts +35 -0
  107. package/src/node/mdx/remark-shiki.ts +1 -1
  108. package/src/node/plugin/entry.ts +22 -15
  109. package/src/node/plugin/index.ts +46 -14
  110. package/src/node/routes/parser.ts +12 -9
  111. package/src/node/search/index.ts +55 -0
  112. package/src/node/ssg/index.ts +83 -15
  113. package/src/node/ssg/robots.ts +7 -4
  114. package/dist/chunk-5D6XPYQ3.mjs +0 -74
  115. package/dist/chunk-6QXCKZAT.mjs +0 -1
  116. package/dist/chunk-H4M6P3DM.mjs +0 -1
  117. package/dist/chunk-JXHNX2WN.mjs +0 -1
  118. package/dist/chunk-MZBG4N4W.mjs +0 -1
  119. package/dist/chunk-Q3MLYTIQ.mjs +0 -1
  120. package/dist/chunk-RSII2UPE.mjs +0 -1
  121. package/dist/chunk-ZK2266IZ.mjs +0 -1
  122. package/dist/chunk-ZRJ55GGF.mjs +0 -1
  123. package/dist/search-dialog-MA5AISC7.mjs +0 -1
  124. package/src/client/components/ui-base/progress-bar.tsx +0 -67
@@ -11,29 +11,40 @@ export function ScrollHandler() {
11
11
 
12
12
  // biome-ignore lint/correctness/useExhaustiveDependencies: pathname is used as a trigger for scroll-to-top on navigation
13
13
  useLayoutEffect(() => {
14
- const container = document.querySelector('.boltdocs-content')
15
- if (!container) return
14
+ const container = document.querySelector('.boltdocs-content') || window
15
+
16
+ // Helper to get scroll top
17
+ const getScrollTop = () => {
18
+ if (container === window) return window.scrollY
19
+ return (container as HTMLElement).scrollTop
20
+ }
21
+
22
+ // Helper to scroll
23
+ const scrollTo = (top: number, behavior: ScrollBehavior = 'auto') => {
24
+ if (container === window) {
25
+ window.scrollTo({ top, behavior })
26
+ } else {
27
+ (container as HTMLElement).scrollTo({ top, behavior })
28
+ }
29
+ }
16
30
 
17
31
  if (hash) {
18
32
  const id = hash.replace('#', '')
19
33
  const element = document.getElementById(id)
20
34
  if (element) {
21
35
  const offset = 80
22
- const containerRect = container.getBoundingClientRect().top
36
+ const containerTop = container === window ? 0 : (container as HTMLElement).getBoundingClientRect().top
23
37
  const elementRect = element.getBoundingClientRect().top
24
- const elementPosition = elementRect - containerRect
25
- const offsetPosition = elementPosition - offset + container.scrollTop
38
+ const elementPosition = elementRect - containerTop
39
+ const offsetPosition = elementPosition - offset + getScrollTop()
26
40
 
27
- container.scrollTo({
28
- top: offsetPosition,
29
- behavior: 'smooth',
30
- })
41
+ scrollTo(offsetPosition, 'smooth')
31
42
  return
32
43
  }
33
44
  }
34
45
 
35
46
  // Scroll to top on navigation when no hash is specified
36
- container.scrollTo(0, 0)
47
+ scrollTo(0)
37
48
  }, [pathname, hash])
38
49
 
39
50
  return null
@@ -19,12 +19,15 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
19
19
  useEffect(() => {
20
20
  setMounted(true)
21
21
  const stored = localStorage.getItem('boltdocs-theme') as Theme | null
22
- const initialTheme = (stored === 'light' || stored === 'dark' || stored === 'system') ? stored : 'system'
23
-
22
+ const initialTheme =
23
+ stored === 'light' || stored === 'dark' || stored === 'system'
24
+ ? stored
25
+ : 'system'
26
+
24
27
  setThemeState(initialTheme)
25
28
 
26
29
  const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
27
-
30
+
28
31
  const updateResolved = (currentTheme: Theme, isDark: boolean) => {
29
32
  if (currentTheme === 'system') {
30
33
  setResolvedTheme(isDark ? 'dark' : 'light')
@@ -37,10 +40,11 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
37
40
 
38
41
  const handleChange = (e: MediaQueryListEvent) => {
39
42
  // Re-read current theme state from some stable ref would be better, but we can capture it
40
- // actually, the second useEffect will handle the source of truth,
43
+ // actually, the second useEffect will handle the source of truth,
41
44
  // but this listener ensures 'system' updates instantly.
42
45
  setResolvedTheme((prevResolved) => {
43
- const currentTheme = localStorage.getItem('boltdocs-theme') as Theme || 'system'
46
+ const currentTheme =
47
+ (localStorage.getItem('boltdocs-theme') as Theme) || 'system'
44
48
  if (currentTheme === 'system') {
45
49
  return e.matches ? 'dark' : 'light'
46
50
  }
@@ -56,8 +60,11 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
56
60
  useEffect(() => {
57
61
  if (!mounted) return
58
62
 
59
- const isSystemDark = window.matchMedia('(prefers-color-scheme: dark)').matches
60
- const nextResolved = theme === 'system' ? (isSystemDark ? 'dark' : 'light') : theme
63
+ const isSystemDark = window.matchMedia(
64
+ '(prefers-color-scheme: dark)',
65
+ ).matches
66
+ const nextResolved =
67
+ theme === 'system' ? (isSystemDark ? 'dark' : 'light') : theme
61
68
 
62
69
  setResolvedTheme(nextResolved as 'light' | 'dark')
63
70
 
@@ -5,7 +5,6 @@ import { OnThisPage } from '@components/ui-base/on-this-page'
5
5
  import { Head } from '@components/ui-base/head'
6
6
  import { Breadcrumbs } from '@components/ui-base/breadcrumbs'
7
7
  import { PageNav } from '@components/ui-base/page-nav'
8
- import { ProgressBar } from '@components/ui-base/progress-bar'
9
8
  import { ErrorBoundary } from '@components/ui-base/error-boundary'
10
9
  import { CopyMarkdown } from '@components/ui-base/copy-markdown'
11
10
  import { useRoutes } from '@client/hooks/use-routes'
@@ -40,10 +39,13 @@ export function DefaultLayout({ children }: LayoutProps) {
40
39
 
41
40
  return (
42
41
  <DocsLayout>
43
- <ProgressBar />
44
42
  <Head
45
- siteTitle={getTranslated(config.theme?.title, currentLocale) || 'Boltdocs'}
46
- siteDescription={getTranslated(config.theme?.description, currentLocale) || ''}
43
+ siteTitle={
44
+ getTranslated(config.theme?.title, currentLocale) || 'Boltdocs'
45
+ }
46
+ siteDescription={
47
+ getTranslated(config.theme?.description, currentLocale) || ''
48
+ }
47
49
  routes={allRoutes}
48
50
  />
49
51
  <Navbar />
@@ -1,5 +1,6 @@
1
1
  import type React from 'react'
2
2
  import { cn } from '@client/utils/cn'
3
+ import { useLocation } from '../hooks'
3
4
 
4
5
  /**
5
6
  * Props shared by all layout slot components.
@@ -60,17 +61,37 @@ function Content({ children, className, style }: SlotProps) {
60
61
  <main
61
62
  className={cn(
62
63
  'boltdocs-content flex-1 min-w-0 overflow-y-auto',
64
+ 'contain-layout', // Optimization: isolate main content layout
63
65
  className,
64
66
  )}
65
67
  style={style}
66
68
  >
67
- <div className="boltdocs-page mx-auto max-w-content-max pt-4 pb-20 px-4 sm:px-8">
68
- {children}
69
- </div>
69
+ {children}
70
70
  </main>
71
71
  )
72
72
  }
73
73
 
74
+ /**
75
+ * MDX Content wrapper with standard page padding and max-width logic.
76
+ */
77
+ function ContentMdx({ children, className, style }: SlotProps) {
78
+ const { pathname } = useLocation()
79
+ return (
80
+ <div
81
+ className={cn(
82
+ 'boltdocs-page mx-auto pt-4 pb-20 px-4 sm:px-8',
83
+ {
84
+ 'max-w-content-max': pathname.includes('/docs/'),
85
+ },
86
+ className,
87
+ )}
88
+ style={style}
89
+ >
90
+ {children}
91
+ </div>
92
+ )
93
+ }
94
+
74
95
  /**
75
96
  * Content header row (breadcrumbs + copy markdown).
76
97
  */
@@ -96,10 +117,19 @@ function ContentFooter({ children, className, style }: SlotProps) {
96
117
  )
97
118
  }
98
119
 
120
+ interface DocsLayoutComponent extends React.FC<SlotProps> {
121
+ Body: typeof Body
122
+ Content: typeof Content
123
+ ContentMdx: typeof ContentMdx
124
+ ContentHeader: typeof ContentHeader
125
+ ContentFooter: typeof ContentFooter
126
+ }
127
+
99
128
  // Attach sub-components to the root
100
129
  export const DocsLayout = Object.assign(DocsLayoutRoot, {
101
130
  Body,
102
131
  Content,
132
+ ContentMdx,
103
133
  ContentHeader,
104
134
  ContentFooter,
105
- })
135
+ }) as DocsLayoutComponent
@@ -72,3 +72,157 @@ export const Bluesky = (props: WrapperProps) => (
72
72
  <path d="M5.202 2.857C7.954 4.922 10.913 9.11 12 11.358c1.087-2.247 4.046-6.436 6.798-8.501C20.783 1.366 24 .213 24 3.883c0 .732-.42 6.156-.667 7.037-.856 3.061-3.978 3.842-6.755 3.37 4.854.826 6.089 3.562 3.422 6.299-5.065 5.196-7.28-1.304-7.847-2.97-.104-.305-.152-.448-.153-.327 0-.121-.05.022-.153.327-.568 1.666-2.782 8.166-7.847 2.97-2.667-2.737-1.432-5.473 3.422-6.3-2.777.473-5.899-.308-6.755-3.369C.42 10.04 0 4.615 0 3.883c0-3.67 3.217-2.517 5.202-1.026" />
73
73
  </svg>
74
74
  )
75
+
76
+ // Icons file
77
+
78
+ export const TypeScript = (props: WrapperProps) => (
79
+ <svg
80
+ xmlns="http://www.w3.org/2000/svg"
81
+ fill="none"
82
+ viewBox="0 0 24 24"
83
+ {...wrapperProps(props)}
84
+ >
85
+ <title>{'TypeScript'}</title>
86
+ <path
87
+ fill="#2563EB"
88
+ d="M3.234 9.093V7.318h8.363v1.775H8.479V17.5H6.352V9.093H3.234zm15.263 1.153c-.04-.4-.21-.712-.512-.934-.301-.222-.71-.333-1.228-.333-.351 0-.648.05-.89.149-.242.096-.427.23-.557.403a.969.969 0 0 0-.189.586.838.838 0 0 0 .115.477c.086.136.204.254.353.353.149.097.321.181.517.254.195.07.404.13.626.179l.915.219c.444.1.852.232 1.223.397.371.166.693.37.965.612.271.242.482.527.631.855.152.328.23.704.234 1.129-.004.623-.163 1.163-.478 1.62-.311.454-.762.807-1.352 1.06-.587.248-1.294.372-2.123.372-.822 0-1.538-.126-2.147-.378-.607-.252-1.081-.624-1.422-1.118-.338-.497-.516-1.112-.532-1.845h2.083c.023.342.12.627.293.855.176.226.41.397.701.513a2.8 2.8 0 0 0 1 .168c.364 0 .68-.053.949-.159a1.45 1.45 0 0 0 .631-.442c.15-.189.224-.406.224-.651a.846.846 0 0 0-.204-.577c-.132-.156-.328-.288-.586-.398a5.964 5.964 0 0 0-.94-.298l-1.109-.278c-.858-.21-1.536-.536-2.033-.98-.497-.444-.744-1.042-.74-1.795-.004-.616.16-1.155.491-1.615.335-.461.794-.82 1.377-1.08.584-.258 1.247-.387 1.99-.387.755 0 1.414.13 1.978.388.567.258 1.007.618 1.322 1.079.315.46.477.994.488 1.6h-2.064z"
89
+ />
90
+ </svg>
91
+ )
92
+
93
+ export const JavaScript = (props: WrapperProps) => (
94
+ <svg
95
+ xmlns="http://www.w3.org/2000/svg"
96
+ fill="none"
97
+ viewBox="0 0 24 24"
98
+ {...wrapperProps(props)}
99
+ >
100
+ <title>{'JavaScript'}</title>
101
+ <path
102
+ fill="#F59E0B"
103
+ d="M8.383 7.318h2.127v7.1c0 .656-.147 1.226-.442 1.71a2.924 2.924 0 01-1.218 1.118c-.52.262-1.125.393-1.815.393-.613 0-1.17-.107-1.67-.323a2.67 2.67 0 01-1.183-.994c-.292-.448-.436-1.01-.433-1.686h2.143c.006.269.061.5.164.691.106.19.25.335.432.438.186.1.405.15.657.15.265 0 .488-.057.67-.17.186-.116.327-.285.423-.507.096-.222.145-.496.145-.82v-7.1zm9.43 2.928c-.04-.4-.21-.712-.511-.934-.302-.222-.711-.333-1.228-.333-.352 0-.648.05-.89.149-.242.096-.428.23-.557.403a.969.969 0 00-.19.586.838.838 0 00.115.477c.087.136.204.254.353.353.15.097.322.181.517.254.196.07.405.13.627.179l.915.219c.444.1.851.232 1.223.397.37.166.692.37.964.612s.482.527.631.855a2.7 2.7 0 01.234 1.129c-.003.623-.162 1.163-.477 1.62-.312.454-.763.807-1.353 1.06-.586.248-1.294.372-2.122.372-.822 0-1.538-.126-2.148-.378-.607-.252-1.08-.624-1.422-1.118-.338-.497-.515-1.112-.532-1.845h2.083c.023.342.121.627.293.855.176.226.41.397.702.513.295.112.628.168.999.168.364 0 .68-.053.95-.159.271-.106.482-.253.63-.442.15-.189.224-.406.224-.651a.846.846 0 00-.203-.577c-.133-.156-.329-.288-.587-.398a5.964 5.964 0 00-.94-.298l-1.108-.278c-.859-.21-1.537-.536-2.034-.98-.497-.444-.744-1.042-.74-1.795-.004-.616.16-1.155.492-1.615.334-.461.793-.82 1.377-1.08.583-.258 1.246-.387 1.989-.387.755 0 1.415.13 1.978.388.567.258 1.008.618 1.323 1.079.314.46.477.994.487 1.6h-2.063z"
104
+ ></path>
105
+ </svg>
106
+ )
107
+
108
+ export const Json = (props: WrapperProps) => (
109
+ <svg
110
+ xmlns="http://www.w3.org/2000/svg"
111
+ fill="none"
112
+ viewBox="0 0 24 24"
113
+ {...wrapperProps(props)}
114
+ >
115
+ <title>{'JSON'}</title>
116
+ <path
117
+ fill="#F59E0B"
118
+ d="M4.778 6.667A2.667 2.667 0 017.444 4a.889.889 0 010 1.778.889.889 0 00-.888.889v3.5c0 .701-.273 1.35-.73 1.833.457.483.73 1.132.73 1.832v3.501c0 .491.398.89.888.89a.889.889 0 010 1.777 2.667 2.667 0 01-2.666-2.667v-3.5a.889.889 0 00-.674-.863l-.43-.108a.889.889 0 010-1.724l.43-.108a.889.889 0 00.674-.862V6.667zm14.222 0A2.667 2.667 0 0016.333 4a.889.889 0 000 1.778c.491 0 .89.398.89.889v3.5c0 .701.272 1.35.729 1.833a2.664 2.664 0 00-.73 1.832v3.501a.889.889 0 01-.889.89.889.889 0 000 1.777A2.667 2.667 0 0019 17.333v-3.5c0-.408.278-.764.673-.863l.431-.108a.889.889 0 000-1.724l-.43-.108a.889.889 0 01-.674-.862V6.667z"
119
+ ></path>
120
+ </svg>
121
+ )
122
+
123
+ export const Css = (props: WrapperProps) => (
124
+ <svg
125
+ xmlns="http://www.w3.org/2000/svg"
126
+ fill="none"
127
+ viewBox="0 0 24 24"
128
+ {...wrapperProps(props)}
129
+ >
130
+ <title>{'CSS'}</title>
131
+ <path
132
+ fill="#0EA5E9"
133
+ d="M4.778 6.667A2.667 2.667 0 017.444 4a.889.889 0 010 1.778.889.889 0 00-.888.889v3.5c0 .701-.273 1.35-.73 1.833.457.483.73 1.132.73 1.832v3.501c0 .491.398.89.888.89a.889.889 0 010 1.777 2.667 2.667 0 01-2.666-2.667v-3.5a.889.889 0 00-.674-.863l-.43-.108a.889.889 0 010-1.724l.43-.108a.889.889 0 00.674-.862V6.667zm14.222 0A2.667 2.667 0 0016.333 4a.889.889 0 000 1.778c.491 0 .89.398.89.889v3.5c0 .701.272 1.35.729 1.833a2.664 2.664 0 00-.73 1.832v3.501a.889.889 0 01-.889.89.889.889 0 000 1.777A2.667 2.667 0 0019 17.333v-3.5c0-.408.278-.764.673-.863l.431-.108a.889.889 0 000-1.724l-.43-.108a.889.889 0 01-.674-.862V6.667z"
134
+ ></path>
135
+ </svg>
136
+ )
137
+
138
+ export const BracketsOrange = (props: WrapperProps) => (
139
+ <svg
140
+ xmlns="http://www.w3.org/2000/svg"
141
+ fill="none"
142
+ viewBox="0 0 24 24"
143
+ {...wrapperProps(props)}
144
+ >
145
+ <title>{'HTML'}</title>
146
+ <path
147
+ fill="#EA580C"
148
+ d="M4.778 6.667A2.667 2.667 0 017.444 4a.889.889 0 010 1.778.889.889 0 00-.888.889v3.5c0 .701-.273 1.35-.73 1.833.457.483.73 1.132.73 1.832v3.501c0 .491.398.89.888.89a.889.889 0 010 1.777 2.667 2.667 0 01-2.666-2.667v-3.5a.889.889 0 00-.674-.863l-.43-.108a.889.889 0 010-1.724l.43-.108a.889.889 0 00.674-.862V6.667zm14.222 0A2.667 2.667 0 0016.333 4a.889.889 0 000 1.778c.491 0 .89.398.89.889v3.5c0 .701.272 1.35.729 1.833a2.664 2.664 0 00-.73 1.832v3.501a.889.889 0 01-.889.89.889.889 0 000 1.777A2.667 2.667 0 0019 17.333v-3.5c0-.408.278-.764.673-.863l.431-.108a.889.889 0 000-1.724l-.43-.108a.889.889 0 01-.674-.862V6.667z"
149
+ ></path>
150
+ </svg>
151
+ )
152
+
153
+ export default BracketsOrange
154
+
155
+ export const React = (props: WrapperProps) => (
156
+ <svg
157
+ xmlns="http://www.w3.org/2000/svg"
158
+ fill="none"
159
+ viewBox="0 0 24 24"
160
+ {...wrapperProps(props)}
161
+ >
162
+ <title>{'React'}</title>
163
+ <path
164
+ fill="#0E8ADC"
165
+ d="M12 13.677a1.677 1.677 0 100-3.354 1.677 1.677 0 000 3.354z"
166
+ ></path>
167
+ <path
168
+ stroke="#0E8ADC"
169
+ d="M12 15.436c4.97 0 9-1.538 9-3.436s-4.03-3.436-9-3.436S3 10.102 3 12s4.03 3.436 9 3.436z"
170
+ ></path>
171
+ <path
172
+ stroke="#0E8ADC"
173
+ d="M9.024 13.718c2.485 4.305 5.832 7.025 7.476 6.076 1.644-.949.961-5.208-1.524-9.512C12.491 5.977 9.144 3.257 7.5 4.206c-1.644.949-.961 5.208 1.524 9.512z"
174
+ ></path>
175
+ <path
176
+ stroke="#0E8ADC"
177
+ d="M9.024 10.282c-2.485 4.304-3.168 8.563-1.524 9.512 1.644.95 4.99-1.771 7.476-6.076 2.485-4.304 3.168-8.563 1.524-9.512-1.644-.95-4.99 1.771-7.476 6.076z"
178
+ ></path>
179
+ </svg>
180
+ )
181
+
182
+ export const Markdown = (props: WrapperProps) => (
183
+ <svg
184
+ xmlns="http://www.w3.org/2000/svg"
185
+ fill="none"
186
+ viewBox="0 0 24 24"
187
+ {...wrapperProps(props)}
188
+ >
189
+ <title>{'Markdown'}</title>
190
+ <path
191
+ fill="#60A5FA"
192
+ d="M3 15.714V8h2.323l2.322 2.836L9.968 8h2.322v7.714H9.968V11.29l-2.323 2.836-2.322-2.836v4.424H3zm14.516 0l-3.484-3.743h2.323V8h2.322v3.97H21l-3.484 3.744z"
193
+ ></path>
194
+ </svg>
195
+ )
196
+
197
+ export const Shell = (props: WrapperProps) => (
198
+ <svg
199
+ xmlns="http://www.w3.org/2000/svg"
200
+ fill="none"
201
+ viewBox="0 0 25 24"
202
+ {...wrapperProps(props)}
203
+ >
204
+ <title>{'Shell'}</title>
205
+ <path
206
+ stroke="#14B8A6"
207
+ strokeLinecap="round"
208
+ strokeLinejoin="round"
209
+ strokeWidth="2"
210
+ d="M4.336 17l6-6-6-6M12.336 19h8"
211
+ ></path>
212
+ </svg>
213
+ )
214
+
215
+ export const Yaml = (props: WrapperProps) => (
216
+ <svg
217
+ xmlns="http://www.w3.org/2000/svg"
218
+ fill="none"
219
+ viewBox="0 0 24 24"
220
+ {...wrapperProps(props)}
221
+ >
222
+ <title>{'YAML'}</title>
223
+ <path
224
+ fill="#A78BFA"
225
+ d="M6.533 5.864h2.755l2.654 5.011h.113l2.654-5.011h2.756l-4.245 7.522V17.5h-2.443v-4.114L6.533 5.864z"
226
+ ></path>
227
+ </svg>
228
+ )
@@ -1,11 +1,38 @@
1
1
  import * as RAC from 'react-aria-components'
2
- import { Copy, Check } from 'lucide-react'
2
+ import { Copy, Check, File } from 'lucide-react'
3
3
  import { cn } from '@client/utils/cn'
4
4
  import { useCodeBlock } from './hooks/use-code-block'
5
5
  import { useConfig } from '@client/app/config-context'
6
- import { CodeSandbox } from '@components/icons-dev'
6
+ import {
7
+ CodeSandbox,
8
+ TypeScript,
9
+ JavaScript,
10
+ React as ReactIcon,
11
+ Json,
12
+ Css,
13
+ BracketsOrange,
14
+ Markdown,
15
+ Shell,
16
+ Yaml,
17
+ } from '@components/icons-dev'
7
18
  import { Tooltip } from '@components/primitives/tooltip'
8
19
 
20
+ const langIconMap: Record<string, React.ComponentType<{ size?: number }>> = {
21
+ ts: TypeScript,
22
+ tsx: ReactIcon,
23
+ js: JavaScript,
24
+ jsx: ReactIcon,
25
+ json: Json,
26
+ css: Css,
27
+ html: BracketsOrange,
28
+ md: Markdown,
29
+ mdx: Markdown,
30
+ bash: Shell,
31
+ sh: Shell,
32
+ yaml: Yaml,
33
+ yml: Yaml,
34
+ }
35
+
9
36
  export interface CodeBlockProps {
10
37
  children?: React.ReactNode
11
38
  className?: string
@@ -15,6 +42,8 @@ export interface CodeBlockProps {
15
42
  title?: string
16
43
  lang?: string
17
44
  highlightedHtml?: string
45
+ 'data-lang'?: string
46
+ plain?: boolean
18
47
  [key: string]: any
19
48
  }
20
49
 
@@ -25,11 +54,15 @@ export function CodeBlock(props: CodeBlockProps) {
25
54
  hideSandbox = true,
26
55
  hideCopy = false,
27
56
  highlightedHtml,
57
+ title,
58
+ 'data-lang': dataLang,
59
+ plain = false,
28
60
  ...rest
29
61
  } = props
30
62
  const config = useConfig()
31
63
  const globalSandbox = config?.integrations?.sandbox
32
64
  const isSandboxEnabled = !!globalSandbox?.enable && !hideSandbox
65
+ const lang = props.lang || dataLang || ''
33
66
  const {
34
67
  copied,
35
68
  isExpanded,
@@ -41,13 +74,32 @@ export function CodeBlock(props: CodeBlockProps) {
41
74
  shouldTruncate,
42
75
  } = useCodeBlock(props)
43
76
 
77
+ const LangIcon = langIconMap[lang]
78
+
44
79
  return (
45
80
  <div
46
81
  className={cn(
47
- 'group relative my-6 overflow-hidden rounded-lg border border-border-subtle bg-(--color-code-bg)',
48
- shouldTruncate && '[&>pre]:max-h-[250px] [&>pre]:overflow-hidden',
82
+ 'group relative overflow-hidden bg-(--color-code-bg)',
83
+ 'contain-layout contain-paint', // Optimization: isolate code block rendering
84
+ {
85
+ 'my-6 rounded-lg border border-border-subtle': !plain,
86
+ '[&>pre]:max-h-62.5 [&>pre]:overflow-hidden': shouldTruncate,
87
+ },
88
+ props.className,
49
89
  )}
50
90
  >
91
+ {/* Title Header */}
92
+ {title && (
93
+ <div className="flex items-center gap-2 border-b border-border-subtle bg-bg-surface/50 px-4 py-2 text-[13px] font-medium text-text-muted">
94
+ {LangIcon ? (
95
+ <LangIcon size={14} />
96
+ ) : (
97
+ <File size={14} className="opacity-60" />
98
+ )}
99
+ <span>{title}</span>
100
+ </div>
101
+ )}
102
+
51
103
  {/* Toolbar */}
52
104
  <div className="absolute top-3 right-4 z-50 flex items-center gap-2 transition-all duration-300 opacity-0 group-hover:opacity-100">
53
105
  {isSandboxEnabled && (
@@ -66,7 +118,7 @@ export function CodeBlock(props: CodeBlockProps) {
66
118
  <RAC.Button
67
119
  onPress={handleCopy}
68
120
  className={cn(
69
- 'grid place-items-center w-8 h-8 bg-transparent outline-none cursor-pointer transition-all duration-200 hover:scale-115 active:scale-95 [&>svg]:w-5 [&>svg]:h-5 [&>svg]:stroke-2',
121
+ 'grid place-items-center size-8 bg-transparent outline-none cursor-pointer transition-all duration-200 hover:scale-110 active:scale-95 [&>svg]:size-4 [&>svg]:stroke-2',
70
122
  copied
71
123
  ? 'text-emerald-400'
72
124
  : 'text-text-muted hover:text-text-main',
@@ -37,6 +37,7 @@ export function ComponentPreview(props: ComponentPreviewProps) {
37
37
  title={sandboxOptions.title}
38
38
  lang="tsx"
39
39
  highlightedHtml={highlightedHtml}
40
+ plain={true}
40
41
  >
41
42
  {initialCode}
42
43
  </CodeBlock>
@@ -10,11 +10,39 @@ import {
10
10
  } from 'lucide-react'
11
11
  import { cn } from '@client/utils/cn'
12
12
 
13
+ import {
14
+ TypeScript,
15
+ JavaScript,
16
+ React as ReactIcon,
17
+ Json,
18
+ Css,
19
+ BracketsOrange,
20
+ Markdown,
21
+ Shell,
22
+ Yaml,
23
+ } from '@components/icons-dev'
24
+
13
25
  // --- Constants & Types ---
14
26
 
15
27
  const ICON_SIZE = 16
16
28
  const STROKE_WIDTH = 2
17
29
 
30
+ const FILE_EXTENSION_MAP: Record<string, React.ComponentType<{ size?: number }>> = {
31
+ ts: TypeScript,
32
+ tsx: ReactIcon,
33
+ js: JavaScript,
34
+ jsx: ReactIcon,
35
+ json: Json,
36
+ css: Css,
37
+ html: BracketsOrange,
38
+ md: Markdown,
39
+ mdx: Markdown,
40
+ bash: Shell,
41
+ sh: Shell,
42
+ yaml: Yaml,
43
+ yml: Yaml,
44
+ }
45
+
18
46
  const FILE_REGEXES = {
19
47
  CODE: /\.(ts|tsx|js|jsx|json|mjs|cjs|astro|vue|svelte)$/i,
20
48
  TEXT: /\.(md|mdx|txt)$/i,
@@ -68,6 +96,13 @@ function getFileIcon(filename: string, isFolder: boolean) {
68
96
  )
69
97
  }
70
98
 
99
+ // Check for specialized language icons
100
+ const extension = name.split('.').pop() || ''
101
+ const LangIcon = FILE_EXTENSION_MAP[extension]
102
+ if (LangIcon) {
103
+ return <LangIcon size={ICON_SIZE} />
104
+ }
105
+
71
106
  const fileIconClass = cn(
72
107
  iconClass,
73
108
  'text-text-dim group-hover:text-text-main',
@@ -14,51 +14,42 @@ export class Observer {
14
14
  private callback(entries: IntersectionObserverEntry[]) {
15
15
  if (entries.length === 0) return
16
16
 
17
- let hasActive = false
18
- this.items = this.items.map((item) => {
19
- const entry = entries.find((entry) => entry.target.id === item.id)
20
- let active = entry ? entry.isIntersecting : item.active && !item.fallback
21
- if (this.single && hasActive) active = false
22
-
23
- if (item.active !== active) {
24
- item = {
25
- ...item,
26
- t: Date.now(),
27
- active,
28
- fallback: false,
29
- }
17
+ // 1. Update internal state based on current intersection and position
18
+ for (const entry of entries) {
19
+ const item = this.items.find((i) => i.id === entry.target.id)
20
+ if (item) {
21
+ // item.active will track if the heading is currently "on or below" the trigger line
22
+ item.active = entry.isIntersecting
23
+
24
+ // item.fallback will track if the heading has scrolled "above" the trigger line
25
+ // RootMargin top is -100px, so trigger line is at 100px.
26
+ const activationLine = 100
27
+ item.fallback =
28
+ !entry.isIntersecting && entry.boundingClientRect.top < activationLine
30
29
  }
30
+ }
31
31
 
32
- if (active) hasActive = true
33
- return item
34
- })
35
-
36
- if (!hasActive && entries[0].rootBounds) {
37
- const viewTop = entries[0].rootBounds.top
38
- let min = Number.MAX_VALUE
39
- let fallbackIdx = -1
40
-
41
- for (let i = 0; i < this.items.length; i++) {
42
- const element = document.getElementById(this.items[i].id)
43
- if (!element) continue
44
-
45
- const d = Math.abs(viewTop - element.getBoundingClientRect().top)
46
- if (d < min) {
47
- fallbackIdx = i
48
- min = d
49
- }
32
+ // 2. The active heading is the LAST one in document order that has scrolled past the line.
33
+ let highlightIdx = -1
34
+ for (let i = this.items.length - 1; i >= 0; i--) {
35
+ if (this.items[i].fallback) {
36
+ highlightIdx = i
37
+ break
50
38
  }
39
+ }
51
40
 
52
- if (fallbackIdx !== -1) {
53
- this.items[fallbackIdx] = {
54
- ...this.items[fallbackIdx],
55
- active: true,
56
- fallback: true,
57
- t: Date.now(),
58
- }
59
- }
41
+ // 3. Initial state: If no headings have passed the line yet, default to the first heading.
42
+ if (highlightIdx === -1 && this.items.length > 0) {
43
+ highlightIdx = 0
60
44
  }
61
45
 
46
+ // 4. Map back to UI state
47
+ this.items = this.items.map((item, idx) => ({
48
+ ...item,
49
+ active: idx === highlightIdx,
50
+ t: idx === highlightIdx ? Date.now() : item.t,
51
+ }))
52
+
62
53
  this.onChange?.()
63
54
  }
64
55
 
@@ -12,6 +12,7 @@ export * from './menu'
12
12
  export * from './popover'
13
13
  export * from './tooltip'
14
14
  export * from './link'
15
+ export * from './skeleton'
15
16
  export { Separator, ToggleButton } from 'react-aria-components'
16
17
 
17
18
  export { cn } from '../../utils/cn'