boltdocs 1.10.2 → 2.0.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 (250) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/LICENSE +21 -0
  3. package/dist/cache-7G6D532T.mjs +1 -0
  4. package/dist/chunk-A4HQPEPU.mjs +1 -0
  5. package/dist/chunk-BA5NH5HU.mjs +1 -0
  6. package/dist/chunk-BQCD3DWG.mjs +1 -0
  7. package/dist/chunk-H63UMKYF.mjs +1 -0
  8. package/dist/chunk-IWHRQHS7.mjs +1 -0
  9. package/dist/chunk-JZXLCA2E.mjs +1 -0
  10. package/dist/chunk-MFU7Q6WF.mjs +1 -0
  11. package/dist/chunk-QYPNX5UN.mjs +1 -0
  12. package/dist/chunk-XEAPSFMB.mjs +1 -0
  13. package/dist/client/components/mdx/index.d.mts +209 -0
  14. package/dist/client/components/mdx/index.d.ts +209 -0
  15. package/dist/client/components/mdx/index.js +1 -0
  16. package/dist/client/components/mdx/index.mjs +1 -0
  17. package/dist/client/hooks/index.d.mts +133 -0
  18. package/dist/client/hooks/index.d.ts +133 -0
  19. package/dist/client/hooks/index.js +1 -0
  20. package/dist/client/hooks/index.mjs +1 -0
  21. package/dist/client/index.d.mts +138 -298
  22. package/dist/client/index.d.ts +138 -298
  23. package/dist/client/index.js +1 -3630
  24. package/dist/client/index.mjs +1 -697
  25. package/dist/client/ssr.d.mts +7 -3
  26. package/dist/client/ssr.d.ts +7 -3
  27. package/dist/client/ssr.js +1 -2928
  28. package/dist/client/ssr.mjs +1 -33
  29. package/dist/{config-BsFQ-ErD.d.ts → config-CX4l-ZNp.d.mts} +42 -35
  30. package/dist/{config-BsFQ-ErD.d.mts → config-CX4l-ZNp.d.ts} +42 -35
  31. package/dist/node/index.d.mts +2 -4
  32. package/dist/node/index.d.ts +2 -4
  33. package/dist/node/index.js +31 -1161
  34. package/dist/node/index.mjs +31 -736
  35. package/dist/search-dialog-EB3N4TYM.mjs +1 -0
  36. package/dist/types-BuZWFT7r.d.ts +159 -0
  37. package/dist/types-CvT-SGbK.d.mts +159 -0
  38. package/dist/use-routes-5bAtAAYX.d.mts +30 -0
  39. package/dist/use-routes-BefRXY3v.d.ts +30 -0
  40. package/package.json +34 -12
  41. package/src/client/app/config-context.tsx +18 -0
  42. package/src/client/app/docs-layout.tsx +14 -0
  43. package/src/client/app/index.tsx +137 -262
  44. package/src/client/app/mdx-component.tsx +52 -0
  45. package/src/client/app/mdx-components-context.tsx +23 -0
  46. package/src/client/app/mdx-page.tsx +20 -0
  47. package/src/client/app/preload.tsx +38 -30
  48. package/src/client/app/router.tsx +30 -0
  49. package/src/client/app/scroll-handler.tsx +40 -0
  50. package/src/client/app/theme-context.tsx +75 -0
  51. package/src/client/components/default-layout.tsx +80 -0
  52. package/src/client/components/docs-layout.tsx +105 -0
  53. package/src/client/components/icons-dev.tsx +74 -0
  54. package/src/client/components/mdx/admonition.tsx +107 -0
  55. package/src/client/components/mdx/badge.tsx +41 -0
  56. package/src/client/components/mdx/button.tsx +35 -0
  57. package/src/client/components/mdx/card.tsx +124 -0
  58. package/src/client/components/mdx/code-block.tsx +119 -0
  59. package/src/client/components/mdx/component-preview.tsx +47 -0
  60. package/src/client/components/mdx/component-props.tsx +83 -0
  61. package/src/client/components/mdx/field.tsx +66 -0
  62. package/src/client/components/mdx/file-tree.tsx +287 -0
  63. package/src/client/components/mdx/hooks/use-code-block.ts +56 -0
  64. package/src/client/components/mdx/hooks/use-component-preview.ts +16 -0
  65. package/src/client/components/mdx/hooks/useTable.ts +74 -0
  66. package/src/client/components/mdx/hooks/useTabs.ts +68 -0
  67. package/src/client/components/mdx/image.tsx +23 -0
  68. package/src/client/components/mdx/index.ts +53 -0
  69. package/src/client/components/mdx/link.tsx +38 -0
  70. package/src/client/components/mdx/list.tsx +192 -0
  71. package/src/client/components/mdx/table.tsx +156 -0
  72. package/src/client/components/mdx/tabs.tsx +135 -0
  73. package/src/client/components/mdx/video.tsx +68 -0
  74. package/src/client/components/primitives/breadcrumbs.tsx +79 -0
  75. package/src/client/components/primitives/button-group.tsx +54 -0
  76. package/src/client/components/primitives/button.tsx +145 -0
  77. package/src/client/components/primitives/helpers/observer.ts +120 -0
  78. package/src/client/components/primitives/index.ts +17 -0
  79. package/src/client/components/primitives/link.tsx +122 -0
  80. package/src/client/components/primitives/menu.tsx +159 -0
  81. package/src/client/components/primitives/navbar.tsx +359 -0
  82. package/src/client/components/primitives/navigation-menu.tsx +116 -0
  83. package/src/client/components/primitives/on-this-page.tsx +461 -0
  84. package/src/client/components/primitives/page-nav.tsx +87 -0
  85. package/src/client/components/primitives/popover.tsx +47 -0
  86. package/src/client/components/primitives/search-dialog.tsx +183 -0
  87. package/src/client/components/primitives/sidebar.tsx +154 -0
  88. package/src/client/components/primitives/tabs.tsx +90 -0
  89. package/src/client/components/primitives/tooltip.tsx +83 -0
  90. package/src/client/components/primitives/types.ts +11 -0
  91. package/src/client/components/ui-base/breadcrumbs.tsx +42 -0
  92. package/src/client/components/ui-base/copy-markdown.tsx +112 -0
  93. package/src/client/components/ui-base/error-boundary.tsx +52 -0
  94. package/src/client/components/ui-base/github-stars.tsx +27 -0
  95. package/src/client/components/ui-base/head.tsx +69 -0
  96. package/src/client/components/ui-base/loading.tsx +87 -0
  97. package/src/client/components/ui-base/navbar.tsx +138 -0
  98. package/src/client/components/ui-base/not-found.tsx +24 -0
  99. package/src/client/components/ui-base/on-this-page.tsx +152 -0
  100. package/src/client/components/ui-base/page-nav.tsx +39 -0
  101. package/src/client/components/ui-base/powered-by.tsx +19 -0
  102. package/src/client/components/ui-base/progress-bar.tsx +67 -0
  103. package/src/client/components/ui-base/search-dialog.tsx +82 -0
  104. package/src/client/components/ui-base/sidebar.tsx +104 -0
  105. package/src/client/components/ui-base/tabs.tsx +65 -0
  106. package/src/client/components/ui-base/theme-toggle.tsx +32 -0
  107. package/src/client/hooks/index.ts +12 -0
  108. package/src/client/hooks/use-breadcrumbs.ts +22 -0
  109. package/src/client/hooks/use-i18n.ts +84 -0
  110. package/src/client/hooks/use-localized-to.ts +95 -0
  111. package/src/client/hooks/use-location.ts +5 -0
  112. package/src/client/hooks/use-navbar.ts +60 -0
  113. package/src/client/hooks/use-onthispage.ts +23 -0
  114. package/src/client/hooks/use-page-nav.ts +22 -0
  115. package/src/client/hooks/use-routes.ts +72 -0
  116. package/src/client/hooks/use-search.ts +71 -0
  117. package/src/client/hooks/use-sidebar.ts +49 -0
  118. package/src/client/hooks/use-tabs.ts +43 -0
  119. package/src/client/hooks/use-version.ts +78 -0
  120. package/src/client/index.ts +55 -17
  121. package/src/client/integrations/codesandbox.ts +179 -0
  122. package/src/client/ssr.tsx +27 -16
  123. package/src/client/theme/neutral.css +360 -0
  124. package/src/client/types.ts +131 -27
  125. package/src/client/utils/cn.ts +6 -0
  126. package/src/client/utils/copy-clipboard.ts +22 -0
  127. package/src/client/utils/get-base-file-path.ts +21 -0
  128. package/src/client/utils/github.ts +121 -0
  129. package/src/client/utils/use-on-change.ts +15 -0
  130. package/src/client/virtual.d.ts +24 -0
  131. package/src/node/cache.ts +156 -156
  132. package/src/node/config.ts +159 -103
  133. package/src/node/index.ts +13 -13
  134. package/src/node/mdx.ts +213 -61
  135. package/src/node/plugin/entry.ts +29 -18
  136. package/src/node/plugin/html.ts +11 -11
  137. package/src/node/plugin/index.ts +161 -84
  138. package/src/node/plugin/types.ts +2 -4
  139. package/src/node/routes/cache.ts +6 -6
  140. package/src/node/routes/index.ts +206 -113
  141. package/src/node/routes/parser.ts +102 -82
  142. package/src/node/routes/sorter.ts +15 -15
  143. package/src/node/routes/types.ts +24 -24
  144. package/src/node/ssg/index.ts +73 -47
  145. package/src/node/ssg/meta.ts +4 -4
  146. package/src/node/ssg/options.ts +5 -5
  147. package/src/node/ssg/sitemap.ts +14 -14
  148. package/src/node/utils.ts +54 -31
  149. package/tsconfig.json +25 -20
  150. package/tsup.config.ts +23 -14
  151. package/dist/PackageManagerTabs-NVT7G625.mjs +0 -99
  152. package/dist/SearchDialog-AGVF6JBO.mjs +0 -194
  153. package/dist/SearchDialog-YPDOM7Q6.css +0 -2847
  154. package/dist/Video-KNTY5BNO.mjs +0 -6
  155. package/dist/cache-KNL5B4EE.mjs +0 -12
  156. package/dist/chunk-7SFUJWTB.mjs +0 -211
  157. package/dist/chunk-FFBNU6IJ.mjs +0 -386
  158. package/dist/chunk-FMTOYQLO.mjs +0 -37
  159. package/dist/chunk-TKLQWU7H.mjs +0 -1920
  160. package/dist/chunk-Z7JHYNAS.mjs +0 -57
  161. package/dist/client/index.css +0 -2847
  162. package/dist/client/ssr.css +0 -2847
  163. package/dist/types-Dj-bfnC3.d.mts +0 -74
  164. package/dist/types-Dj-bfnC3.d.ts +0 -74
  165. package/src/client/theme/components/CodeBlock/CodeBlock.tsx +0 -61
  166. package/src/client/theme/components/CodeBlock/index.ts +0 -1
  167. package/src/client/theme/components/PackageManagerTabs/PackageManagerTabs.tsx +0 -131
  168. package/src/client/theme/components/PackageManagerTabs/index.ts +0 -1
  169. package/src/client/theme/components/PackageManagerTabs/pkg-tabs.css +0 -64
  170. package/src/client/theme/components/Playground/Playground.tsx +0 -180
  171. package/src/client/theme/components/Playground/index.ts +0 -1
  172. package/src/client/theme/components/Playground/playground.css +0 -238
  173. package/src/client/theme/components/Video/Video.tsx +0 -84
  174. package/src/client/theme/components/Video/index.ts +0 -1
  175. package/src/client/theme/components/Video/video.css +0 -41
  176. package/src/client/theme/components/mdx/Admonition.tsx +0 -80
  177. package/src/client/theme/components/mdx/Badge.tsx +0 -31
  178. package/src/client/theme/components/mdx/Button.tsx +0 -50
  179. package/src/client/theme/components/mdx/Card.tsx +0 -80
  180. package/src/client/theme/components/mdx/Field.tsx +0 -60
  181. package/src/client/theme/components/mdx/FileTree.tsx +0 -229
  182. package/src/client/theme/components/mdx/List.tsx +0 -57
  183. package/src/client/theme/components/mdx/Table.tsx +0 -151
  184. package/src/client/theme/components/mdx/Tabs.tsx +0 -123
  185. package/src/client/theme/components/mdx/index.ts +0 -27
  186. package/src/client/theme/components/mdx/mdx-components.css +0 -764
  187. package/src/client/theme/icons/bun.tsx +0 -62
  188. package/src/client/theme/icons/deno.tsx +0 -20
  189. package/src/client/theme/icons/discord.tsx +0 -12
  190. package/src/client/theme/icons/github.tsx +0 -15
  191. package/src/client/theme/icons/npm.tsx +0 -13
  192. package/src/client/theme/icons/pnpm.tsx +0 -72
  193. package/src/client/theme/icons/twitter.tsx +0 -12
  194. package/src/client/theme/styles/markdown.css +0 -394
  195. package/src/client/theme/styles/variables.css +0 -175
  196. package/src/client/theme/styles.css +0 -39
  197. package/src/client/theme/ui/Breadcrumbs/Breadcrumbs.tsx +0 -68
  198. package/src/client/theme/ui/Breadcrumbs/index.ts +0 -1
  199. package/src/client/theme/ui/CopyMarkdown/CopyMarkdown.tsx +0 -82
  200. package/src/client/theme/ui/CopyMarkdown/copy-markdown.css +0 -112
  201. package/src/client/theme/ui/CopyMarkdown/index.ts +0 -1
  202. package/src/client/theme/ui/ErrorBoundary/ErrorBoundary.tsx +0 -50
  203. package/src/client/theme/ui/ErrorBoundary/error-boundary.css +0 -55
  204. package/src/client/theme/ui/ErrorBoundary/index.ts +0 -1
  205. package/src/client/theme/ui/Footer/footer.css +0 -32
  206. package/src/client/theme/ui/Head/Head.tsx +0 -69
  207. package/src/client/theme/ui/Head/index.ts +0 -1
  208. package/src/client/theme/ui/LanguageSwitcher/LanguageSwitcher.tsx +0 -125
  209. package/src/client/theme/ui/LanguageSwitcher/index.ts +0 -1
  210. package/src/client/theme/ui/LanguageSwitcher/language-switcher.css +0 -98
  211. package/src/client/theme/ui/Layout/Layout.tsx +0 -203
  212. package/src/client/theme/ui/Layout/base.css +0 -106
  213. package/src/client/theme/ui/Layout/index.ts +0 -2
  214. package/src/client/theme/ui/Layout/pagination.css +0 -72
  215. package/src/client/theme/ui/Layout/responsive.css +0 -47
  216. package/src/client/theme/ui/Link/Link.tsx +0 -392
  217. package/src/client/theme/ui/Link/LinkPreview.tsx +0 -59
  218. package/src/client/theme/ui/Link/index.ts +0 -2
  219. package/src/client/theme/ui/Link/link-preview.css +0 -48
  220. package/src/client/theme/ui/Loading/Loading.tsx +0 -10
  221. package/src/client/theme/ui/Loading/index.ts +0 -1
  222. package/src/client/theme/ui/Loading/loading.css +0 -30
  223. package/src/client/theme/ui/Navbar/GithubStars.tsx +0 -27
  224. package/src/client/theme/ui/Navbar/Navbar.tsx +0 -193
  225. package/src/client/theme/ui/Navbar/Tabs.tsx +0 -99
  226. package/src/client/theme/ui/Navbar/index.ts +0 -2
  227. package/src/client/theme/ui/Navbar/navbar.css +0 -347
  228. package/src/client/theme/ui/NotFound/NotFound.tsx +0 -19
  229. package/src/client/theme/ui/NotFound/index.ts +0 -1
  230. package/src/client/theme/ui/NotFound/not-found.css +0 -64
  231. package/src/client/theme/ui/OnThisPage/OnThisPage.tsx +0 -244
  232. package/src/client/theme/ui/OnThisPage/index.ts +0 -1
  233. package/src/client/theme/ui/OnThisPage/toc.css +0 -152
  234. package/src/client/theme/ui/PoweredBy/PoweredBy.tsx +0 -18
  235. package/src/client/theme/ui/PoweredBy/index.ts +0 -1
  236. package/src/client/theme/ui/PoweredBy/powered-by.css +0 -76
  237. package/src/client/theme/ui/ProgressBar/ProgressBar.css +0 -17
  238. package/src/client/theme/ui/ProgressBar/ProgressBar.tsx +0 -51
  239. package/src/client/theme/ui/ProgressBar/index.ts +0 -1
  240. package/src/client/theme/ui/SearchDialog/SearchDialog.tsx +0 -209
  241. package/src/client/theme/ui/SearchDialog/index.ts +0 -1
  242. package/src/client/theme/ui/SearchDialog/search.css +0 -152
  243. package/src/client/theme/ui/Sidebar/Sidebar.tsx +0 -244
  244. package/src/client/theme/ui/Sidebar/index.ts +0 -1
  245. package/src/client/theme/ui/Sidebar/sidebar.css +0 -230
  246. package/src/client/theme/ui/ThemeToggle/ThemeToggle.tsx +0 -69
  247. package/src/client/theme/ui/ThemeToggle/index.ts +0 -1
  248. package/src/client/theme/ui/VersionSwitcher/VersionSwitcher.tsx +0 -136
  249. package/src/client/theme/ui/VersionSwitcher/index.ts +0 -1
  250. package/src/client/utils.ts +0 -49
@@ -1,179 +1,272 @@
1
- import fastGlob from "fast-glob";
2
- import { BoltdocsConfig } from "../config";
3
- import { capitalize } from "../utils";
1
+ import fastGlob from 'fast-glob'
2
+ import type { BoltdocsConfig } from '../config'
3
+ import { capitalize } from '../utils'
4
4
 
5
- import { RouteMeta, ParsedDocFile } from "./types";
6
- import { docCache, invalidateRouteCache, invalidateFile } from "./cache";
7
- import { parseDocFile } from "./parser";
8
- import { sortRoutes } from "./sorter";
5
+ import type { RouteMeta, ParsedDocFile } from './types'
6
+ import { docCache, invalidateRouteCache, invalidateFile } from './cache'
7
+ import { parseDocFile } from './parser'
8
+ import { sortRoutes } from './sorter'
9
9
 
10
10
  // Re-export public API
11
- export type { RouteMeta };
12
- export { invalidateRouteCache, invalidateFile };
11
+ export type { RouteMeta }
12
+ export { invalidateRouteCache, invalidateFile }
13
+
14
+ // Cache for localized path computations
15
+ const localizedPathCache = new Map<string, string>()
13
16
 
14
17
  /**
15
18
  * Generates the entire route map for the documentation site.
16
- * This reads all `.md` and `.mdx` files in the `docsDir`, parses them (using cache),
17
- * infers group hierarchies based on directory structure and `index.md` files,
18
- * and returns a sorted array of RouteMeta objects intended for the client.
19
+ * OPTIMIZED: Uses Map-based i18n lookups, chunked processing, and path caching.
20
+ *
21
+ * Automatically handles versioning and i18n routing, including fallback
22
+ * generation for missing translations.
19
23
  *
20
- * @param docsDir - The root directory containing markdown files
21
- * @param config - Optional configuration for i18n and versioning
22
- * @param basePath - The base URL path to prefix to generated routes (e.g., '/docs')
23
- * @returns A promise that resolves to the final list of RouteMeta objects
24
+ * @param docsDir - The root documentation directory
25
+ * @param config - The Boltdocs configuration
26
+ * @param basePath - The base URL path for the routes (default: '/docs')
27
+ * @returns A promise resolving to an array of RouteMeta objects
24
28
  */
25
29
  export async function generateRoutes(
26
30
  docsDir: string,
27
31
  config?: BoltdocsConfig,
28
- basePath: string = "/docs",
32
+ basePath: string = '/docs',
29
33
  ): Promise<RouteMeta[]> {
30
- // Load persistent cache on first call
31
- docCache.load();
32
- docCache.invalidateAll(); // FORCE RE-PARSE to pick up new _content field
34
+ const start = performance.now()
35
+
36
+ // Load persistent cache
37
+ docCache.load()
38
+
39
+ // Clear path computation cache between generations
40
+ localizedPathCache.clear()
41
+
42
+ // Force re-parse if specifically requested (e.g. for content/config changes)
43
+ if (process.env.BOLTDOCS_FORCE_REPARSE === 'true' || config?.i18n) {
44
+ docCache.invalidateAll()
45
+ }
33
46
 
34
- const files = await fastGlob(["**/*.md", "**/*.mdx"], {
47
+ // 1. FAST SCAN
48
+ const files = await fastGlob(['**/*.md', '**/*.mdx'], {
35
49
  cwd: docsDir,
36
50
  absolute: true,
37
- });
51
+ suppressErrors: true,
52
+ followSymbolicLinks: false,
53
+ })
38
54
 
39
55
  // Prune cache entries for deleted files
40
- docCache.pruneStale(new Set(files));
56
+ docCache.pruneStale(new Set(files))
41
57
 
42
- // Invalidate all caches if config changes drastically (e.g. i18n enabled)
43
- if (config?.i18n) {
44
- docCache.invalidateAll();
45
- }
58
+ // 2. CHUNKED PROCESSING (prevents blocking event loop)
59
+ const CHUNK_SIZE = 50
60
+ const parsed: ParsedDocFile[] = []
61
+ let cacheHits = 0
46
62
 
47
- // Parse files in parallel using Promise.all for increased efficiency
48
- let cacheHits = 0;
49
- const parsed: ParsedDocFile[] = await Promise.all(
50
- files.map(async (file) => {
51
- const cached = docCache.get(file);
52
- if (cached) {
53
- cacheHits++;
54
- return cached;
55
- }
63
+ for (let i = 0; i < files.length; i += CHUNK_SIZE) {
64
+ const chunk = files.slice(i, i + CHUNK_SIZE)
65
+
66
+ const chunkResults = await Promise.all(
67
+ chunk.map(async (file) => {
68
+ const cached = docCache.get(file)
69
+ if (cached) {
70
+ cacheHits++
71
+ return cached
72
+ }
73
+
74
+ const result = parseDocFile(file, docsDir, basePath, config)
75
+ docCache.set(file, result)
76
+ return result
77
+ }),
78
+ )
56
79
 
57
- const result = parseDocFile(file, docsDir, basePath, config);
58
- docCache.set(file, result);
59
- return result;
60
- }),
61
- );
80
+ parsed.push(...chunkResults)
62
81
 
63
- if (files.length > 0) {
64
- console.log(
65
- `[boltdocs] Routes generated: ${files.length} files (${cacheHits} from cache, ${files.length - cacheHits} parsed)`,
66
- );
82
+ // Yield to event loop between chunks if there's more to process
83
+ if (i + CHUNK_SIZE < files.length) {
84
+ await new Promise((resolve) => setImmediate(resolve))
85
+ }
67
86
  }
68
87
 
69
- // Save cache after batch processing
70
- docCache.save();
88
+ // Save cache after processing
89
+ docCache.save()
71
90
 
72
- // Collect group metadata from directory names and index files
91
+ // 3. OPTIMIZED METADATA COLLECTION
73
92
  const groupMeta = new Map<
74
93
  string,
75
94
  { title: string; position?: number; icon?: string }
76
- >();
95
+ >()
96
+ const groupIndexFiles: ParsedDocFile[] = []
97
+
77
98
  for (const p of parsed) {
99
+ if (p.isGroupIndex && p.relativeDir) {
100
+ groupIndexFiles.push(p)
101
+ }
102
+
78
103
  if (p.relativeDir) {
79
- if (!groupMeta.has(p.relativeDir)) {
80
- groupMeta.set(p.relativeDir, {
104
+ let entry = groupMeta.get(p.relativeDir)
105
+ if (!entry) {
106
+ entry = {
81
107
  title: capitalize(p.relativeDir),
82
108
  position: p.inferredGroupPosition,
83
109
  icon: p.route.icon,
84
- });
110
+ }
111
+ groupMeta.set(p.relativeDir, entry)
85
112
  } else {
86
- const entry = groupMeta.get(p.relativeDir)!;
87
113
  if (
88
114
  entry.position === undefined &&
89
115
  p.inferredGroupPosition !== undefined
90
116
  ) {
91
- entry.position = p.inferredGroupPosition;
117
+ entry.position = p.inferredGroupPosition
92
118
  }
93
119
  if (!entry.icon && p.route.icon) {
94
- entry.icon = p.route.icon;
120
+ entry.icon = p.route.icon
95
121
  }
96
122
  }
97
123
  }
124
+ }
98
125
 
99
- if (p.isGroupIndex && p.relativeDir && p.groupMeta) {
100
- const entry = groupMeta.get(p.relativeDir)!;
101
- entry.title = p.groupMeta.title;
102
- if (p.groupMeta.position !== undefined) {
103
- entry.position = p.groupMeta.position;
104
- }
105
- if (p.groupMeta.icon) {
106
- entry.icon = p.groupMeta.icon;
107
- }
126
+ // Override with explicit group index metadata
127
+ for (const p of groupIndexFiles) {
128
+ const entry = groupMeta.get(p.relativeDir!)!
129
+ if (p.groupMeta) {
130
+ entry.title = p.groupMeta.title
131
+ if (p.groupMeta.position !== undefined)
132
+ entry.position = p.groupMeta.position
133
+ if (p.groupMeta.icon) entry.icon = p.groupMeta.icon
108
134
  }
109
135
  }
110
136
 
111
- // Build final routes with group info
112
- const routes: RouteMeta[] = parsed.map((p) => {
113
- const dir = p.relativeDir;
114
- const meta = dir ? groupMeta.get(dir) : undefined;
137
+ // 4. BUILD BASE ROUTES
138
+ const routes: RouteMeta[] = new Array(parsed.length)
139
+ for (let i = 0; i < parsed.length; i++) {
140
+ const p = parsed[i]
141
+ const dir = p.relativeDir
142
+ const meta = dir ? groupMeta.get(dir) : undefined
115
143
 
116
- return {
144
+ routes[i] = {
117
145
  ...p.route,
118
146
  group: dir,
119
147
  groupTitle: meta?.title || (dir ? capitalize(dir) : undefined),
120
148
  groupPosition: meta?.position,
121
149
  groupIcon: meta?.icon,
122
- };
123
- });
150
+ }
151
+ }
124
152
 
125
- // Add fallbacks if i18n is enabled
153
+ // 5. OPTIMIZED I18N FALLBACKS
154
+ let finalRoutes = routes
126
155
  if (config?.i18n) {
127
- const defaultLocale = config.i18n.defaultLocale;
128
- const allLocales = Object.keys(config.i18n.locales);
156
+ const fallbacks = generateI18nFallbacks(routes, config, basePath)
157
+ finalRoutes = [...routes, ...fallbacks]
158
+ }
159
+
160
+ const sorted = sortRoutes(finalRoutes)
129
161
 
130
- const fallbackRoutes: RouteMeta[] = [];
131
- const defaultRoutes = routes.filter(
132
- (r) => (r.locale || defaultLocale) === defaultLocale,
133
- );
162
+ const duration = performance.now() - start
163
+ console.log(
164
+ `[boltdocs] Route generation: ${duration.toFixed(2)}ms (${files.length} files, ${cacheHits} cache hits)`,
165
+ )
134
166
 
135
- for (const locale of allLocales) {
136
- if (locale === defaultLocale) continue;
167
+ return sorted
168
+ }
137
169
 
138
- const localeRoutePaths = new Set(
139
- routes.filter((r) => r.locale === locale).map((r) => r.path),
140
- );
170
+ /**
171
+ * Generates fallback routes for missing translations.
172
+ * Optimization: Uses Map for O(1) existence checks instead of nested filters.
173
+ */
174
+ function generateI18nFallbacks(
175
+ routes: RouteMeta[],
176
+ config: BoltdocsConfig,
177
+ basePath: string,
178
+ ): RouteMeta[] {
179
+ const defaultLocale = config.i18n!.defaultLocale
180
+ const allLocales = Object.keys(config.i18n!.locales)
181
+ const fallbackRoutes: RouteMeta[] = []
141
182
 
142
- for (const defRoute of defaultRoutes) {
143
- let prefix = basePath;
144
- if (defRoute.version) {
145
- prefix += "/" + defRoute.version;
146
- }
183
+ // Index existing routes by locale for O(1) lookup
184
+ const routesByLocale = new Map<string, Set<string>>()
185
+ const defaultRoutes: RouteMeta[] = []
147
186
 
148
- let pathAfterVersion = defRoute.path.substring(prefix.length);
187
+ for (const r of routes) {
188
+ const locale = r.locale || defaultLocale
189
+ if (!routesByLocale.has(locale)) {
190
+ routesByLocale.set(locale, new Set())
191
+ }
192
+ routesByLocale.get(locale)!.add(r.path)
149
193
 
150
- if (pathAfterVersion.startsWith("/" + defaultLocale + "/")) {
151
- pathAfterVersion = pathAfterVersion.substring(
152
- defaultLocale.length + 1,
153
- );
154
- } else if (pathAfterVersion === "/" + defaultLocale) {
155
- pathAfterVersion = "/";
156
- }
157
- const targetPath =
158
- prefix +
159
- "/" +
160
- locale +
161
- (pathAfterVersion === "/" || pathAfterVersion === ""
162
- ? ""
163
- : pathAfterVersion);
164
-
165
- if (!localeRoutePaths.has(targetPath)) {
166
- fallbackRoutes.push({
167
- ...defRoute,
168
- path: targetPath,
169
- locale: locale,
170
- });
171
- }
194
+ if (locale === defaultLocale) {
195
+ defaultRoutes.push(r)
196
+ }
197
+ }
198
+
199
+ for (const locale of allLocales) {
200
+ if (locale === defaultLocale) continue
201
+
202
+ const localePaths = routesByLocale.get(locale) || new Set<string>()
203
+
204
+ for (const defRoute of defaultRoutes) {
205
+ const targetPath = computeLocalizedPath(
206
+ defRoute.path,
207
+ defaultLocale,
208
+ locale,
209
+ basePath,
210
+ )
211
+
212
+ if (!localePaths.has(targetPath)) {
213
+ fallbackRoutes.push({
214
+ ...defRoute,
215
+ path: targetPath,
216
+ locale,
217
+ })
172
218
  }
173
219
  }
220
+ }
221
+
222
+ return fallbackRoutes
223
+ }
224
+
225
+ /**
226
+ * Computes a localized path based on the default locale and target locale.
227
+ * Uses a cache to avoid redundant string manipulation.
228
+ */
229
+ function computeLocalizedPath(
230
+ path: string,
231
+ defaultLocale: string,
232
+ targetLocale: string,
233
+ basePath: string,
234
+ ): string {
235
+ const cacheKey = `${path}:${targetLocale}`
236
+ const cached = localizedPathCache.get(cacheKey)
237
+ if (cached) return cached
238
+
239
+ let prefix = basePath
240
+ const versionMatch = path.match(new RegExp(`^${basePath}/(v[0-9]+)`))
241
+ if (versionMatch) {
242
+ prefix += '/' + versionMatch[1]
243
+ }
174
244
 
175
- return sortRoutes([...routes, ...fallbackRoutes]);
245
+ let pathAfterVersion = path.substring(prefix.length)
246
+
247
+ // Handle case where path already has default locale
248
+ const defaultLocaleSegment = `/${defaultLocale}`
249
+ if (pathAfterVersion.startsWith(defaultLocaleSegment + '/')) {
250
+ pathAfterVersion =
251
+ '/' +
252
+ targetLocale +
253
+ '/' +
254
+ pathAfterVersion.substring(defaultLocaleSegment.length + 1)
255
+ } else if (pathAfterVersion === defaultLocaleSegment) {
256
+ pathAfterVersion = '/' + targetLocale
257
+ } else if (pathAfterVersion === '/' || pathAfterVersion === '') {
258
+ pathAfterVersion = '/' + targetLocale
259
+ } else {
260
+ // Ensure pathAfterVersion starts with a slash if not already
261
+ const pathPrefix = pathAfterVersion.startsWith('/') ? '' : '/'
262
+ pathAfterVersion = '/' + targetLocale + pathPrefix + pathAfterVersion
176
263
  }
177
264
 
178
- return sortRoutes(routes);
265
+ const result = prefix + pathAfterVersion
266
+
267
+ // Simple cache eviction to prevent memory leaks in extreme cases
268
+ if (localizedPathCache.size > 2000) localizedPathCache.clear()
269
+ localizedPathCache.set(cacheKey, result)
270
+
271
+ return result
179
272
  }
@@ -1,7 +1,7 @@
1
- import path from "path";
2
- import GithubSlugger from "github-slugger";
3
- import { BoltdocsConfig } from "../config";
4
- import { ParsedDocFile } from "./types";
1
+ import path from 'path'
2
+ import GithubSlugger from 'github-slugger'
3
+ import type { BoltdocsConfig } from '../config'
4
+ import type { ParsedDocFile } from './types'
5
5
  import {
6
6
  normalizePath,
7
7
  parseFrontmatter,
@@ -9,16 +9,21 @@ import {
9
9
  capitalize,
10
10
  stripNumberPrefix,
11
11
  extractNumberPrefix,
12
- escapeHtml,
13
- } from "../utils";
12
+ sanitizeHtml,
13
+ stripHtmlTags,
14
+ } from '../utils'
14
15
 
15
16
  /**
16
17
  * Parses a single Markdown/MDX file and extracts its metadata for routing.
17
18
  * Checks frontmatter for explicit titles, descriptions, and sidebar positions.
18
19
  *
20
+ * Also performs security validation to prevent path traversal and basic
21
+ * XSS sanitization for metadata and headings.
22
+ *
19
23
  * @param file - The absolute path to the file
20
24
  * @param docsDir - The root documentation directory (e.g., 'docs')
21
25
  * @param basePath - The base URL path for the routes (default: '/docs')
26
+ * @param config - The Boltdocs configuration for versions and i18n
22
27
  * @returns A parsed structure ready for route assembly and caching
23
28
  */
24
29
  export function parseDocFile(
@@ -28,138 +33,138 @@ export function parseDocFile(
28
33
  config?: BoltdocsConfig,
29
34
  ): ParsedDocFile {
30
35
  // Security: Prevent path traversal
31
- const decodedFile = decodeURIComponent(file);
32
- const absoluteFile = path.resolve(decodedFile);
33
- const absoluteDocsDir = path.resolve(docsDir);
36
+ const decodedFile = decodeURIComponent(file)
37
+ const absoluteFile = path.resolve(decodedFile)
38
+ const absoluteDocsDir = path.resolve(docsDir)
34
39
  const relativePath = normalizePath(
35
40
  path.relative(absoluteDocsDir, absoluteFile),
36
- );
41
+ )
37
42
 
38
43
  if (
39
- relativePath.startsWith("../") ||
40
- relativePath === ".." ||
41
- absoluteFile.includes("\0")
44
+ relativePath.startsWith('../') ||
45
+ relativePath === '..' ||
46
+ absoluteFile.includes('\0')
42
47
  ) {
43
48
  throw new Error(
44
49
  `Security breach: File is outside of docs directory or contains null bytes: ${file}`,
45
- );
50
+ )
46
51
  }
47
52
 
48
- const { data, content } = parseFrontmatter(file);
49
- let parts = relativePath.split("/");
53
+ const { data, content } = parseFrontmatter(file)
54
+ let parts = relativePath.split('/')
50
55
 
51
- let locale: string | undefined;
52
- let version: string | undefined;
56
+ let locale: string | undefined
57
+ let version: string | undefined
53
58
 
54
59
  // Level 1: Check for version
55
60
  if (config?.versions && parts.length > 0) {
56
- const potentialVersion = parts[0];
61
+ const potentialVersion = parts[0]
57
62
  if (config.versions.versions[potentialVersion]) {
58
- version = potentialVersion;
59
- parts = parts.slice(1);
63
+ version = potentialVersion
64
+ parts = parts.slice(1)
60
65
  }
61
66
  }
62
67
 
63
68
  // Level 2: Check for locale
64
69
  if (config?.i18n && parts.length > 0) {
65
- const potentialLocale = parts[0];
70
+ const potentialLocale = parts[0]
66
71
  if (config.i18n.locales[potentialLocale]) {
67
- locale = potentialLocale;
68
- parts = parts.slice(1);
72
+ locale = potentialLocale
73
+ parts = parts.slice(1)
69
74
  }
70
75
  }
71
76
 
72
77
  // Level 3: Check for Tab hierarchy (name)
73
- let inferredTab: string | undefined;
78
+ let inferredTab: string | undefined
74
79
  if (parts.length > 0) {
75
- const tabMatch = parts[0].match(/^\((.+)\)$/);
80
+ const tabMatch = parts[0].match(/^\((.+)\)$/)
76
81
  if (tabMatch) {
77
- inferredTab = tabMatch[1].toLowerCase();
78
- parts = parts.slice(1);
82
+ inferredTab = tabMatch[1].toLowerCase()
83
+ parts = parts.slice(1)
79
84
  }
80
85
  }
81
86
 
82
- const cleanRelativePath = parts.join("/");
87
+ const cleanRelativePath = parts.join('/')
83
88
 
84
- let cleanRoutePath: string;
89
+ let cleanRoutePath: string
85
90
  if (data.permalink) {
86
91
  // If a permalink is specified, ensure it starts with a slash
87
- cleanRoutePath = data.permalink.startsWith("/")
92
+ cleanRoutePath = data.permalink.startsWith('/')
88
93
  ? data.permalink
89
- : `/${data.permalink}`;
94
+ : `/${data.permalink}`
90
95
  } else {
91
- cleanRoutePath = fileToRoutePath(cleanRelativePath || "index.md");
96
+ cleanRoutePath = fileToRoutePath(cleanRelativePath || 'index.md')
92
97
  }
93
98
 
94
- let finalPath = basePath;
99
+ let finalPath = basePath
95
100
  if (version) {
96
- finalPath += "/" + version;
101
+ finalPath += '/' + version
97
102
  }
98
103
  if (locale) {
99
- finalPath += "/" + locale;
104
+ finalPath += '/' + locale
100
105
  }
101
- finalPath += cleanRoutePath === "/" ? "" : cleanRoutePath;
106
+ finalPath += cleanRoutePath === '/' ? '' : cleanRoutePath
102
107
 
103
- if (!finalPath || finalPath === "") finalPath = "/";
108
+ if (!finalPath || finalPath === '') finalPath = '/'
104
109
 
105
- const rawFileName = parts[parts.length - 1];
106
- const cleanFileName = stripNumberPrefix(rawFileName);
110
+ const rawFileName = parts[parts.length - 1]
111
+ const cleanFileName = stripNumberPrefix(rawFileName)
107
112
  const inferredTitle = stripNumberPrefix(
108
113
  path.basename(file, path.extname(file)),
109
- );
114
+ )
110
115
  const sidebarPosition =
111
- data.sidebarPosition ?? extractNumberPrefix(rawFileName);
116
+ data.sidebarPosition ?? extractNumberPrefix(rawFileName)
117
+
118
+ const rawDirName = parts.length >= 2 ? parts[0] : undefined
119
+ const cleanDirName = rawDirName ? stripNumberPrefix(rawDirName) : undefined
112
120
 
113
- const rawDirName = parts.length >= 2 ? parts[0] : undefined;
114
- const cleanDirName = rawDirName ? stripNumberPrefix(rawDirName) : undefined;
121
+ const isGroupIndex = parts.length >= 2 && /^index\.mdx?$/.test(cleanFileName)
115
122
 
116
- const isGroupIndex = parts.length >= 2 && /^index\.mdx?$/.test(cleanFileName);
123
+ const slugger = new GithubSlugger()
124
+ const headings: { level: number; text: string; id: string }[] = []
125
+ const headingsRegex = /^(#{2,4})\s+(.+)$/gm
117
126
 
118
- const headings: { level: number; text: string; id: string }[] = [];
119
- const slugger = new GithubSlugger();
120
- const headingsRegex = /^(#{2,4})\s+(.+)$/gm;
121
- let match;
127
+ let match
122
128
  while ((match = headingsRegex.exec(content)) !== null) {
123
- const level = match[1].length;
124
- // Strip simple markdown formatting specifically for the plain-text search index
125
- const text = match[2]
126
- .replace(/\[([^\]]+)\]\([^\)]+\)/g, "$1")
127
- .replace(/[_*`]/g, "")
128
- .trim();
129
- const id = slugger.slug(text);
130
- // Security: Sanitize heading text for XSS
131
- headings.push({ level, text, id });
129
+ const level = match[1].length
130
+ const rawText = match[2]
131
+ .replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1') // Strip markdown links
132
+ .replace(/[_*`]/g, '') // Strip markdown formatting
133
+ .trim()
134
+
135
+ const sanitizedText = sanitizeHtml(rawText).trim()
136
+ const id = slugger.slug(sanitizedText)
137
+
138
+ headings.push({ level, text: sanitizedText, id })
132
139
  }
133
140
 
134
- const sanitizedTitle = data.title ? data.title : inferredTitle;
141
+ const sanitizedTitle = data.title
142
+ ? sanitizeHtml(String(data.title))
143
+ : inferredTitle
135
144
  let sanitizedDescription = data.description
136
- ? data.description
137
- : "";
145
+ ? sanitizeHtml(String(data.description))
146
+ : ''
138
147
 
139
148
  // If no description is provided, extract a summary from the content
140
149
  if (!sanitizedDescription && content) {
141
- const summary = content
142
- .replace(/^#+.*$/gm, "") // Remove headers
143
- .replace(/\[([^\]]+)\]\([^\)]+\)/g, "$1") // Simplify links
144
- .replace(/[_*`]/g, "") // Remove formatting
145
- .replace(/\n+/g, " ") // Normalize whitespace
150
+ const plainExcerpt = stripHtmlTags(
151
+ content
152
+ .replace(/^#+.*$/gm, '') // Remove headers
153
+ .replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1') // Simplify links
154
+ .replace(/[_*`]/g, '') // Remove formatting
155
+ .replace(/\s+/g, ' '), // Normalize whitespace
156
+ )
146
157
  .trim()
147
- .slice(0, 160);
148
- sanitizedDescription = summary;
158
+ .slice(0, 160)
159
+
160
+ sanitizedDescription = plainExcerpt
149
161
  }
150
162
 
151
- const sanitizedBadge = data.badge ? data.badge : undefined;
152
- const icon = data.icon ? String(data.icon) : undefined;
163
+ const sanitizedBadge = data.badge ? sanitizeHtml(String(data.badge)) : undefined
164
+ const icon = data.icon ? String(data.icon) : undefined
153
165
 
154
166
  // Extract full content as plain text for search indexing
155
- const plainText = content
156
- .replace(/^#+.*$/gm, "") // Remove headers
157
- .replace(/\[([^\]]+)\]\([^\)]+\)/g, "$1") // Simplify links
158
- .replace(/<[^>]+>/g, "") // Remove HTML/JSX tags
159
- .replace(/\{[^\}]+\}/g, "") // Remove JS expressions/curly braces
160
- .replace(/[_*`]/g, "") // Remove formatting
161
- .replace(/\n+/g, " ") // Normalize whitespace
162
- .trim();
167
+ const plainText = parseContentToPlainText(content)
163
168
 
164
169
  return {
165
170
  route: {
@@ -183,10 +188,10 @@ export function parseDocFile(
183
188
  inferredTab,
184
189
  groupMeta: isGroupIndex
185
190
  ? {
186
- title:
191
+ title:
187
192
  data.groupTitle ||
188
- data.title ||
189
- (cleanDirName ? capitalize(cleanDirName) : ""),
193
+ data.title ||
194
+ (cleanDirName ? capitalize(cleanDirName) : ''),
190
195
  position:
191
196
  data.groupPosition ??
192
197
  data.sidebarPosition ??
@@ -197,5 +202,20 @@ export function parseDocFile(
197
202
  inferredGroupPosition: rawDirName
198
203
  ? extractNumberPrefix(rawDirName)
199
204
  : undefined,
200
- };
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Converts markdown content to plain text for search indexing.
210
+ * Strips headers, links, tags, and formatting.
211
+ */
212
+ function parseContentToPlainText(content: string): string {
213
+ const plainText = content
214
+ .replace(/^#+.*$/gm, '') // Remove headers
215
+ .replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1') // Simplify links
216
+ .replace(/\{[^\}]+\}/g, '') // Remove JS expressions/curly braces
217
+ .replace(/[_*`]/g, '') // Remove formatting
218
+ .replace(/\s+/g, ' ') // Normalize whitespace
219
+
220
+ return stripHtmlTags(plainText).trim()
201
221
  }