boltdocs 1.0.4 → 1.3.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 (121) hide show
  1. package/dist/{SearchDialog-R36WKAQ7.mjs → SearchDialog-5EDRACEG.mjs} +1 -1
  2. package/dist/{SearchDialog-PYF3QMYG.css → SearchDialog-X57WPTNN.css} +54 -126
  3. package/dist/cache-EHR7SXRU.mjs +12 -0
  4. package/dist/chunk-GSYECEZY.mjs +381 -0
  5. package/dist/{chunk-TWSRXUFF.mjs → chunk-NS7WHDYA.mjs} +229 -418
  6. package/dist/client/index.css +54 -126
  7. package/dist/client/index.d.mts +5 -4
  8. package/dist/client/index.d.ts +5 -4
  9. package/dist/client/index.js +555 -580
  10. package/dist/client/index.mjs +304 -16
  11. package/dist/client/ssr.css +54 -126
  12. package/dist/client/ssr.js +257 -580
  13. package/dist/client/ssr.mjs +1 -1
  14. package/dist/{config-D2XmHJYe.d.mts → config-BD5ZHz15.d.mts} +7 -0
  15. package/dist/{config-D2XmHJYe.d.ts → config-BD5ZHz15.d.ts} +7 -0
  16. package/dist/node/index.d.mts +2 -2
  17. package/dist/node/index.d.ts +2 -2
  18. package/dist/node/index.js +477 -123
  19. package/dist/node/index.mjs +114 -142
  20. package/package.json +2 -2
  21. package/src/client/app/index.tsx +344 -373
  22. package/src/client/app/preload.tsx +56 -56
  23. package/src/client/index.ts +40 -40
  24. package/src/client/ssr.tsx +51 -51
  25. package/src/client/theme/components/CodeBlock/CodeBlock.tsx +76 -76
  26. package/src/client/theme/components/CodeBlock/index.ts +1 -1
  27. package/src/client/theme/components/PackageManagerTabs/PackageManagerTabs.tsx +154 -154
  28. package/src/client/theme/components/PackageManagerTabs/index.ts +1 -1
  29. package/src/client/theme/components/PackageManagerTabs/pkg-tabs.css +64 -64
  30. package/src/client/theme/components/Playground/Playground.tsx +124 -124
  31. package/src/client/theme/components/Playground/index.ts +1 -1
  32. package/src/client/theme/components/Playground/playground.css +168 -168
  33. package/src/client/theme/components/Video/Video.tsx +84 -84
  34. package/src/client/theme/components/Video/index.ts +1 -1
  35. package/src/client/theme/components/Video/video.css +41 -41
  36. package/src/client/theme/components/mdx/Admonition.tsx +80 -80
  37. package/src/client/theme/components/mdx/Badge.tsx +31 -31
  38. package/src/client/theme/components/mdx/Button.tsx +50 -50
  39. package/src/client/theme/components/mdx/Card.tsx +80 -80
  40. package/src/client/theme/components/mdx/List.tsx +57 -57
  41. package/src/client/theme/components/mdx/Tabs.tsx +94 -94
  42. package/src/client/theme/components/mdx/index.ts +18 -18
  43. package/src/client/theme/components/mdx/mdx-components.css +424 -405
  44. package/src/client/theme/icons/bun.tsx +62 -62
  45. package/src/client/theme/icons/deno.tsx +20 -20
  46. package/src/client/theme/icons/discord.tsx +12 -12
  47. package/src/client/theme/icons/github.tsx +15 -15
  48. package/src/client/theme/icons/npm.tsx +13 -13
  49. package/src/client/theme/icons/pnpm.tsx +72 -72
  50. package/src/client/theme/icons/twitter.tsx +12 -12
  51. package/src/client/theme/styles/markdown.css +343 -343
  52. package/src/client/theme/styles/variables.css +162 -162
  53. package/src/client/theme/styles.css +37 -38
  54. package/src/client/theme/ui/BackgroundGradient/BackgroundGradient.tsx +10 -10
  55. package/src/client/theme/ui/BackgroundGradient/index.ts +1 -1
  56. package/src/client/theme/ui/Breadcrumbs/Breadcrumbs.tsx +68 -68
  57. package/src/client/theme/ui/Breadcrumbs/index.ts +1 -1
  58. package/src/client/theme/ui/Footer/footer.css +32 -32
  59. package/src/client/theme/ui/Head/Head.tsx +69 -69
  60. package/src/client/theme/ui/Head/index.ts +1 -1
  61. package/src/client/theme/ui/LanguageSwitcher/LanguageSwitcher.tsx +125 -125
  62. package/src/client/theme/ui/LanguageSwitcher/index.ts +1 -1
  63. package/src/client/theme/ui/LanguageSwitcher/language-switcher.css +98 -98
  64. package/src/client/theme/ui/Layout/Layout.tsx +202 -213
  65. package/src/client/theme/ui/Layout/base.css +76 -76
  66. package/src/client/theme/ui/Layout/index.ts +2 -2
  67. package/src/client/theme/ui/Layout/pagination.css +72 -72
  68. package/src/client/theme/ui/Layout/responsive.css +36 -40
  69. package/src/client/theme/ui/Link/Link.tsx +254 -202
  70. package/src/client/theme/ui/Link/index.ts +2 -2
  71. package/src/client/theme/ui/Loading/Loading.tsx +10 -10
  72. package/src/client/theme/ui/Loading/index.ts +1 -1
  73. package/src/client/theme/ui/Loading/loading.css +30 -30
  74. package/src/client/theme/ui/Navbar/GithubStars.tsx +27 -27
  75. package/src/client/theme/ui/Navbar/Navbar.tsx +145 -145
  76. package/src/client/theme/ui/Navbar/index.ts +2 -2
  77. package/src/client/theme/ui/Navbar/navbar.css +233 -233
  78. package/src/client/theme/ui/NotFound/NotFound.tsx +19 -20
  79. package/src/client/theme/ui/NotFound/index.ts +1 -1
  80. package/src/client/theme/ui/NotFound/not-found.css +64 -64
  81. package/src/client/theme/ui/OnThisPage/OnThisPage.tsx +235 -192
  82. package/src/client/theme/ui/OnThisPage/index.ts +1 -1
  83. package/src/client/theme/ui/OnThisPage/toc.css +132 -132
  84. package/src/client/theme/ui/PoweredBy/PoweredBy.tsx +18 -18
  85. package/src/client/theme/ui/PoweredBy/index.ts +1 -1
  86. package/src/client/theme/ui/PoweredBy/powered-by.css +76 -76
  87. package/src/client/theme/ui/SearchDialog/SearchDialog.tsx +199 -199
  88. package/src/client/theme/ui/SearchDialog/index.ts +1 -1
  89. package/src/client/theme/ui/SearchDialog/search.css +152 -152
  90. package/src/client/theme/ui/Sidebar/Sidebar.tsx +204 -200
  91. package/src/client/theme/ui/Sidebar/index.ts +1 -1
  92. package/src/client/theme/ui/Sidebar/sidebar.css +236 -269
  93. package/src/client/theme/ui/ThemeToggle/ThemeToggle.tsx +69 -69
  94. package/src/client/theme/ui/ThemeToggle/index.ts +1 -1
  95. package/src/client/theme/ui/VersionSwitcher/VersionSwitcher.tsx +136 -136
  96. package/src/client/theme/ui/VersionSwitcher/index.ts +1 -1
  97. package/src/client/types.ts +50 -50
  98. package/src/client/utils.ts +26 -26
  99. package/src/node/cache.ts +408 -94
  100. package/src/node/config.ts +192 -185
  101. package/src/node/index.ts +21 -21
  102. package/src/node/mdx.ts +120 -41
  103. package/src/node/plugin/entry.ts +58 -58
  104. package/src/node/plugin/html.ts +55 -55
  105. package/src/node/plugin/index.ts +193 -190
  106. package/src/node/plugin/types.ts +11 -11
  107. package/src/node/routes/cache.ts +28 -24
  108. package/src/node/routes/index.ts +167 -152
  109. package/src/node/routes/parser.ts +153 -127
  110. package/src/node/routes/sorter.ts +42 -42
  111. package/src/node/routes/types.ts +49 -49
  112. package/src/node/ssg/index.ts +114 -110
  113. package/src/node/ssg/meta.ts +34 -34
  114. package/src/node/ssg/options.ts +13 -13
  115. package/src/node/ssg/sitemap.ts +54 -54
  116. package/src/node/utils.ts +134 -134
  117. package/tsconfig.json +20 -20
  118. package/tsup.config.ts +22 -22
  119. package/dist/Playground-B2FA34BC.mjs +0 -6
  120. package/dist/chunk-WPT4MWTQ.mjs +0 -89
  121. package/src/client/theme/styles/home.css +0 -60
@@ -1,24 +1,28 @@
1
- import { FileCache } from "../cache";
2
- import { ParsedDocFile } from "./types";
3
-
4
- const docCache = new FileCache<ParsedDocFile>();
5
-
6
- /**
7
- * Invalidate all cached routes.
8
- * Typically called when a file is added or deleted, requiring a complete route rebuild.
9
- */
10
- export function invalidateRouteCache(): void {
11
- docCache.invalidateAll();
12
- }
13
-
14
- /**
15
- * Invalidate a specific file from cache.
16
- * Called when a specific file is modified (changed).
17
- *
18
- * @param filePath - The absolute path of the file to invalidate
19
- */
20
- export function invalidateFile(filePath: string): void {
21
- docCache.invalidate(filePath);
22
- }
23
-
24
- export { docCache };
1
+ import { FileCache } from "../cache";
2
+ import { ParsedDocFile } from "./types";
3
+
4
+ /**
5
+ * Persistent cache for parsed documentation files.
6
+ * Saves data to `.boltdocs/routes.json`.
7
+ */
8
+ const docCache = new FileCache<ParsedDocFile>({ name: "routes" });
9
+
10
+ /**
11
+ * Invalidate all cached routes.
12
+ * Typically called when a file is added or deleted, requiring a complete route rebuild.
13
+ */
14
+ export function invalidateRouteCache(): void {
15
+ docCache.invalidateAll();
16
+ }
17
+
18
+ /**
19
+ * Invalidate a specific file from cache.
20
+ * Called when a specific file is modified (changed).
21
+ *
22
+ * @param filePath - The absolute path of the file to invalidate
23
+ */
24
+ export function invalidateFile(filePath: string): void {
25
+ docCache.invalidate(filePath);
26
+ }
27
+
28
+ export { docCache };
@@ -1,152 +1,167 @@
1
- import fastGlob from "fast-glob";
2
- import { BoltdocsConfig } from "../config";
3
- import { capitalize } from "../utils";
4
-
5
- import { RouteMeta, ParsedDocFile } from "./types";
6
- import { docCache, invalidateRouteCache, invalidateFile } from "./cache";
7
- import { parseDocFile } from "./parser";
8
- import { sortRoutes } from "./sorter";
9
-
10
- // Re-export public API
11
- export type { RouteMeta };
12
- export { invalidateRouteCache, invalidateFile };
13
-
14
- /**
15
- * 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
- *
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
- */
25
- export async function generateRoutes(
26
- docsDir: string,
27
- config?: BoltdocsConfig,
28
- basePath: string = "/docs",
29
- ): Promise<RouteMeta[]> {
30
- const files = await fastGlob(["**/*.md", "**/*.mdx"], {
31
- cwd: docsDir,
32
- absolute: true,
33
- });
34
-
35
- // Prune cache entries for deleted files
36
- docCache.pruneStale(new Set(files));
37
-
38
- // Invalidate all caches if config changes drastically (e.g. i18n enabled)
39
- if (config?.i18n) {
40
- docCache.invalidateAll();
41
- }
42
-
43
- // Parse files in parallel using Promise.all for increased efficiency
44
- const parsed: ParsedDocFile[] = await Promise.all(
45
- files.map(async (file) => {
46
- const cached = docCache.get(file);
47
- if (cached) return cached;
48
-
49
- const result = parseDocFile(file, docsDir, basePath, config);
50
- docCache.set(file, result);
51
- return result;
52
- }),
53
- );
54
-
55
- // Collect group metadata from directory names and index files
56
- const groupMeta = new Map<string, { title: string; position?: number }>();
57
- for (const p of parsed) {
58
- if (p.relativeDir) {
59
- if (!groupMeta.has(p.relativeDir)) {
60
- groupMeta.set(p.relativeDir, {
61
- title: capitalize(p.relativeDir),
62
- position: p.inferredGroupPosition,
63
- });
64
- } else {
65
- const entry = groupMeta.get(p.relativeDir)!;
66
- if (
67
- entry.position === undefined &&
68
- p.inferredGroupPosition !== undefined
69
- ) {
70
- entry.position = p.inferredGroupPosition;
71
- }
72
- }
73
- }
74
-
75
- if (p.isGroupIndex && p.relativeDir && p.groupMeta) {
76
- const entry = groupMeta.get(p.relativeDir)!;
77
- entry.title = p.groupMeta.title;
78
- if (p.groupMeta.position !== undefined) {
79
- entry.position = p.groupMeta.position;
80
- }
81
- }
82
- }
83
-
84
- // Build final routes with group info
85
- const routes: RouteMeta[] = parsed.map((p) => {
86
- const dir = p.relativeDir;
87
- const meta = dir ? groupMeta.get(dir) : undefined;
88
-
89
- return {
90
- ...p.route,
91
- group: dir,
92
- groupTitle: meta?.title || (dir ? capitalize(dir) : undefined),
93
- groupPosition: meta?.position,
94
- };
95
- });
96
-
97
- // Add fallbacks if i18n is enabled
98
- if (config?.i18n) {
99
- const defaultLocale = config.i18n.defaultLocale;
100
- const allLocales = Object.keys(config.i18n.locales);
101
-
102
- const fallbackRoutes: RouteMeta[] = [];
103
- const defaultRoutes = routes.filter(
104
- (r) => (r.locale || defaultLocale) === defaultLocale,
105
- );
106
-
107
- for (const locale of allLocales) {
108
- if (locale === defaultLocale) continue;
109
-
110
- const localeRoutePaths = new Set(
111
- routes.filter((r) => r.locale === locale).map((r) => r.path),
112
- );
113
-
114
- for (const defRoute of defaultRoutes) {
115
- let prefix = basePath;
116
- if (defRoute.version) {
117
- prefix += "/" + defRoute.version;
118
- }
119
-
120
- let pathAfterVersion = defRoute.path.substring(prefix.length);
121
-
122
- if (pathAfterVersion.startsWith("/" + defaultLocale + "/")) {
123
- pathAfterVersion = pathAfterVersion.substring(
124
- defaultLocale.length + 1,
125
- );
126
- } else if (pathAfterVersion === "/" + defaultLocale) {
127
- pathAfterVersion = "/";
128
- }
129
-
130
- const targetPath =
131
- prefix +
132
- "/" +
133
- locale +
134
- (pathAfterVersion === "/" || pathAfterVersion === ""
135
- ? ""
136
- : pathAfterVersion);
137
-
138
- if (!localeRoutePaths.has(targetPath)) {
139
- fallbackRoutes.push({
140
- ...defRoute,
141
- path: targetPath,
142
- locale: locale,
143
- });
144
- }
145
- }
146
- }
147
-
148
- return sortRoutes([...routes, ...fallbackRoutes]);
149
- }
150
-
151
- return sortRoutes(routes);
152
- }
1
+ import fastGlob from "fast-glob";
2
+ import { BoltdocsConfig } from "../config";
3
+ import { capitalize } from "../utils";
4
+
5
+ import { RouteMeta, ParsedDocFile } from "./types";
6
+ import { docCache, invalidateRouteCache, invalidateFile } from "./cache";
7
+ import { parseDocFile } from "./parser";
8
+ import { sortRoutes } from "./sorter";
9
+
10
+ // Re-export public API
11
+ export type { RouteMeta };
12
+ export { invalidateRouteCache, invalidateFile };
13
+
14
+ /**
15
+ * 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
+ *
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
+ */
25
+ export async function generateRoutes(
26
+ docsDir: string,
27
+ config?: BoltdocsConfig,
28
+ basePath: string = "/docs",
29
+ ): Promise<RouteMeta[]> {
30
+ // Load persistent cache on first call
31
+ docCache.load();
32
+
33
+ const files = await fastGlob(["**/*.md", "**/*.mdx"], {
34
+ cwd: docsDir,
35
+ absolute: true,
36
+ });
37
+
38
+ // Prune cache entries for deleted files
39
+ docCache.pruneStale(new Set(files));
40
+
41
+ // Invalidate all caches if config changes drastically (e.g. i18n enabled)
42
+ if (config?.i18n) {
43
+ docCache.invalidateAll();
44
+ }
45
+
46
+ // Parse files in parallel using Promise.all for increased efficiency
47
+ let cacheHits = 0;
48
+ const parsed: ParsedDocFile[] = await Promise.all(
49
+ files.map(async (file) => {
50
+ const cached = docCache.get(file);
51
+ if (cached) {
52
+ cacheHits++;
53
+ return cached;
54
+ }
55
+
56
+ const result = parseDocFile(file, docsDir, basePath, config);
57
+ docCache.set(file, result);
58
+ return result;
59
+ }),
60
+ );
61
+
62
+ if (files.length > 0) {
63
+ console.log(
64
+ `[boltdocs] Routes generated: ${files.length} files (${cacheHits} from cache, ${files.length - cacheHits} parsed)`,
65
+ );
66
+ }
67
+
68
+ // Save cache after batch processing
69
+ docCache.save();
70
+
71
+ // Collect group metadata from directory names and index files
72
+ const groupMeta = new Map<string, { title: string; position?: number }>();
73
+ for (const p of parsed) {
74
+ if (p.relativeDir) {
75
+ if (!groupMeta.has(p.relativeDir)) {
76
+ groupMeta.set(p.relativeDir, {
77
+ title: capitalize(p.relativeDir),
78
+ position: p.inferredGroupPosition,
79
+ });
80
+ } else {
81
+ const entry = groupMeta.get(p.relativeDir)!;
82
+ if (
83
+ entry.position === undefined &&
84
+ p.inferredGroupPosition !== undefined
85
+ ) {
86
+ entry.position = p.inferredGroupPosition;
87
+ }
88
+ }
89
+ }
90
+
91
+ if (p.isGroupIndex && p.relativeDir && p.groupMeta) {
92
+ const entry = groupMeta.get(p.relativeDir)!;
93
+ entry.title = p.groupMeta.title;
94
+ if (p.groupMeta.position !== undefined) {
95
+ entry.position = p.groupMeta.position;
96
+ }
97
+ }
98
+ }
99
+
100
+ // Build final routes with group info
101
+ const routes: RouteMeta[] = parsed.map((p) => {
102
+ const dir = p.relativeDir;
103
+ const meta = dir ? groupMeta.get(dir) : undefined;
104
+
105
+ return {
106
+ ...p.route,
107
+ group: dir,
108
+ groupTitle: meta?.title || (dir ? capitalize(dir) : undefined),
109
+ groupPosition: meta?.position,
110
+ };
111
+ });
112
+
113
+ // Add fallbacks if i18n is enabled
114
+ if (config?.i18n) {
115
+ const defaultLocale = config.i18n.defaultLocale;
116
+ const allLocales = Object.keys(config.i18n.locales);
117
+
118
+ const fallbackRoutes: RouteMeta[] = [];
119
+ const defaultRoutes = routes.filter(
120
+ (r) => (r.locale || defaultLocale) === defaultLocale,
121
+ );
122
+
123
+ for (const locale of allLocales) {
124
+ if (locale === defaultLocale) continue;
125
+
126
+ const localeRoutePaths = new Set(
127
+ routes.filter((r) => r.locale === locale).map((r) => r.path),
128
+ );
129
+
130
+ for (const defRoute of defaultRoutes) {
131
+ let prefix = basePath;
132
+ if (defRoute.version) {
133
+ prefix += "/" + defRoute.version;
134
+ }
135
+
136
+ let pathAfterVersion = defRoute.path.substring(prefix.length);
137
+
138
+ if (pathAfterVersion.startsWith("/" + defaultLocale + "/")) {
139
+ pathAfterVersion = pathAfterVersion.substring(
140
+ defaultLocale.length + 1,
141
+ );
142
+ } else if (pathAfterVersion === "/" + defaultLocale) {
143
+ pathAfterVersion = "/";
144
+ }
145
+ const targetPath =
146
+ prefix +
147
+ "/" +
148
+ locale +
149
+ (pathAfterVersion === "/" || pathAfterVersion === ""
150
+ ? ""
151
+ : pathAfterVersion);
152
+
153
+ if (!localeRoutePaths.has(targetPath)) {
154
+ fallbackRoutes.push({
155
+ ...defRoute,
156
+ path: targetPath,
157
+ locale: locale,
158
+ });
159
+ }
160
+ }
161
+ }
162
+
163
+ return sortRoutes([...routes, ...fallbackRoutes]);
164
+ }
165
+
166
+ return sortRoutes(routes);
167
+ }
@@ -1,127 +1,153 @@
1
- import path from "path";
2
- import GithubSlugger from "github-slugger";
3
- import { BoltdocsConfig } from "../config";
4
- import { ParsedDocFile } from "./types";
5
- import {
6
- normalizePath,
7
- parseFrontmatter,
8
- fileToRoutePath,
9
- capitalize,
10
- stripNumberPrefix,
11
- extractNumberPrefix,
12
- } from "../utils";
13
-
14
- /**
15
- * Parses a single Markdown/MDX file and extracts its metadata for routing.
16
- * Checks frontmatter for explicit titles, descriptions, and sidebar positions.
17
- *
18
- * @param file - The absolute path to the file
19
- * @param docsDir - The root documentation directory (e.g., 'docs')
20
- * @param basePath - The base URL path for the routes (default: '/docs')
21
- * @returns A parsed structure ready for route assembly and caching
22
- */
23
- export function parseDocFile(
24
- file: string,
25
- docsDir: string,
26
- basePath: string,
27
- config?: BoltdocsConfig,
28
- ): ParsedDocFile {
29
- const { data, content } = parseFrontmatter(file);
30
- const relativePath = normalizePath(path.relative(docsDir, file));
31
- let parts = relativePath.split("/");
32
-
33
- let locale: string | undefined;
34
- let version: string | undefined;
35
-
36
- // Level 1: Check for version
37
- if (config?.versions && parts.length > 0) {
38
- const potentialVersion = parts[0];
39
- if (config.versions.versions[potentialVersion]) {
40
- version = potentialVersion;
41
- parts = parts.slice(1);
42
- }
43
- }
44
-
45
- // Level 2: Check for locale
46
- if (config?.i18n && parts.length > 0) {
47
- const potentialLocale = parts[0];
48
- if (config.i18n.locales[potentialLocale]) {
49
- locale = potentialLocale;
50
- parts = parts.slice(1);
51
- }
52
- }
53
-
54
- const cleanRelativePath = parts.join("/");
55
- const cleanRoutePath = fileToRoutePath(cleanRelativePath || "index.md");
56
-
57
- let finalPath = basePath;
58
- if (version) {
59
- finalPath += "/" + version;
60
- }
61
- if (locale) {
62
- finalPath += "/" + locale;
63
- }
64
- finalPath += cleanRoutePath === "/" ? "" : cleanRoutePath;
65
-
66
- if (!finalPath || finalPath === "") finalPath = "/";
67
-
68
- const rawFileName = parts[parts.length - 1];
69
- const cleanFileName = stripNumberPrefix(rawFileName);
70
- const inferredTitle = stripNumberPrefix(
71
- path.basename(file, path.extname(file)),
72
- );
73
- const sidebarPosition =
74
- data.sidebarPosition ?? extractNumberPrefix(rawFileName);
75
-
76
- const rawDirName = parts.length >= 2 ? parts[0] : undefined;
77
- const cleanDirName = rawDirName ? stripNumberPrefix(rawDirName) : undefined;
78
-
79
- const isGroupIndex = parts.length >= 2 && /^index\.mdx?$/.test(cleanFileName);
80
-
81
- const headings: { level: number; text: string; id: string }[] = [];
82
- const slugger = new GithubSlugger();
83
- const headingsRegex = /^(#{2,4})\s+(.+)$/gm;
84
- let match;
85
- while ((match = headingsRegex.exec(content)) !== null) {
86
- const level = match[1].length;
87
- // Strip simple markdown formatting specifically for the plain-text search index
88
- const text = match[2]
89
- .replace(/\[([^\]]+)\]\([^\)]+\)/g, "$1")
90
- .replace(/[_*`]/g, "")
91
- .trim();
92
- const id = slugger.slug(text);
93
- headings.push({ level, text, id });
94
- }
95
-
96
- return {
97
- route: {
98
- path: finalPath,
99
- componentPath: file,
100
- filePath: relativePath,
101
- title: data.title || inferredTitle,
102
- description: data.description || "",
103
- sidebarPosition,
104
- headings,
105
- locale,
106
- version,
107
- badge: data.badge,
108
- },
109
- relativeDir: cleanDirName,
110
- isGroupIndex,
111
- groupMeta: isGroupIndex
112
- ? {
113
- title:
114
- data.groupTitle ||
115
- data.title ||
116
- (cleanDirName ? capitalize(cleanDirName) : ""),
117
- position:
118
- data.groupPosition ??
119
- data.sidebarPosition ??
120
- (rawDirName ? extractNumberPrefix(rawDirName) : undefined),
121
- }
122
- : undefined,
123
- inferredGroupPosition: rawDirName
124
- ? extractNumberPrefix(rawDirName)
125
- : undefined,
126
- };
127
- }
1
+ import path from "path";
2
+ import GithubSlugger from "github-slugger";
3
+ import { BoltdocsConfig } from "../config";
4
+ import { ParsedDocFile } from "./types";
5
+ import {
6
+ normalizePath,
7
+ parseFrontmatter,
8
+ fileToRoutePath,
9
+ capitalize,
10
+ stripNumberPrefix,
11
+ extractNumberPrefix,
12
+ escapeHtml,
13
+ } from "../utils";
14
+
15
+ /**
16
+ * Parses a single Markdown/MDX file and extracts its metadata for routing.
17
+ * Checks frontmatter for explicit titles, descriptions, and sidebar positions.
18
+ *
19
+ * @param file - The absolute path to the file
20
+ * @param docsDir - The root documentation directory (e.g., 'docs')
21
+ * @param basePath - The base URL path for the routes (default: '/docs')
22
+ * @returns A parsed structure ready for route assembly and caching
23
+ */
24
+ export function parseDocFile(
25
+ file: string,
26
+ docsDir: string,
27
+ basePath: string,
28
+ config?: BoltdocsConfig,
29
+ ): ParsedDocFile {
30
+ // Security: Prevent path traversal
31
+ const decodedFile = decodeURIComponent(file);
32
+ const absoluteFile = path.resolve(decodedFile);
33
+ const absoluteDocsDir = path.resolve(docsDir);
34
+ const relativePath = normalizePath(
35
+ path.relative(absoluteDocsDir, absoluteFile),
36
+ );
37
+
38
+ if (
39
+ relativePath.startsWith("../") ||
40
+ relativePath === ".." ||
41
+ absoluteFile.includes("\0")
42
+ ) {
43
+ throw new Error(
44
+ `Security breach: File is outside of docs directory or contains null bytes: ${file}`,
45
+ );
46
+ }
47
+
48
+ const { data, content } = parseFrontmatter(file);
49
+ let parts = relativePath.split("/");
50
+
51
+ let locale: string | undefined;
52
+ let version: string | undefined;
53
+
54
+ // Level 1: Check for version
55
+ if (config?.versions && parts.length > 0) {
56
+ const potentialVersion = parts[0];
57
+ if (config.versions.versions[potentialVersion]) {
58
+ version = potentialVersion;
59
+ parts = parts.slice(1);
60
+ }
61
+ }
62
+
63
+ // Level 2: Check for locale
64
+ if (config?.i18n && parts.length > 0) {
65
+ const potentialLocale = parts[0];
66
+ if (config.i18n.locales[potentialLocale]) {
67
+ locale = potentialLocale;
68
+ parts = parts.slice(1);
69
+ }
70
+ }
71
+
72
+ const cleanRelativePath = parts.join("/");
73
+ const cleanRoutePath = fileToRoutePath(cleanRelativePath || "index.md");
74
+
75
+ let finalPath = basePath;
76
+ if (version) {
77
+ finalPath += "/" + version;
78
+ }
79
+ if (locale) {
80
+ finalPath += "/" + locale;
81
+ }
82
+ finalPath += cleanRoutePath === "/" ? "" : cleanRoutePath;
83
+
84
+ if (!finalPath || finalPath === "") finalPath = "/";
85
+
86
+ const rawFileName = parts[parts.length - 1];
87
+ const cleanFileName = stripNumberPrefix(rawFileName);
88
+ const inferredTitle = stripNumberPrefix(
89
+ path.basename(file, path.extname(file)),
90
+ );
91
+ const sidebarPosition =
92
+ data.sidebarPosition ?? extractNumberPrefix(rawFileName);
93
+
94
+ const rawDirName = parts.length >= 2 ? parts[0] : undefined;
95
+ const cleanDirName = rawDirName ? stripNumberPrefix(rawDirName) : undefined;
96
+
97
+ const isGroupIndex = parts.length >= 2 && /^index\.mdx?$/.test(cleanFileName);
98
+
99
+ const headings: { level: number; text: string; id: string }[] = [];
100
+ const slugger = new GithubSlugger();
101
+ const headingsRegex = /^(#{2,4})\s+(.+)$/gm;
102
+ let match;
103
+ while ((match = headingsRegex.exec(content)) !== null) {
104
+ const level = match[1].length;
105
+ // Strip simple markdown formatting specifically for the plain-text search index
106
+ const text = match[2]
107
+ .replace(/\[([^\]]+)\]\([^\)]+\)/g, "$1")
108
+ .replace(/[_*`]/g, "")
109
+ .trim();
110
+ const id = slugger.slug(text);
111
+ // Security: Sanitize heading text for XSS
112
+ headings.push({ level, text: escapeHtml(text), id });
113
+ }
114
+
115
+ const sanitizedTitle = data.title ? escapeHtml(data.title) : inferredTitle;
116
+ const sanitizedDescription = data.description
117
+ ? escapeHtml(data.description)
118
+ : "";
119
+ const sanitizedBadge = data.badge ? escapeHtml(data.badge) : undefined;
120
+
121
+ return {
122
+ route: {
123
+ path: finalPath,
124
+ componentPath: file,
125
+ filePath: relativePath,
126
+ title: sanitizedTitle,
127
+ description: sanitizedDescription,
128
+ sidebarPosition,
129
+ headings,
130
+ locale,
131
+ version,
132
+ badge: sanitizedBadge,
133
+ },
134
+ relativeDir: cleanDirName,
135
+ isGroupIndex,
136
+ groupMeta: isGroupIndex
137
+ ? {
138
+ title: escapeHtml(
139
+ data.groupTitle ||
140
+ data.title ||
141
+ (cleanDirName ? capitalize(cleanDirName) : ""),
142
+ ),
143
+ position:
144
+ data.groupPosition ??
145
+ data.sidebarPosition ??
146
+ (rawDirName ? extractNumberPrefix(rawDirName) : undefined),
147
+ }
148
+ : undefined,
149
+ inferredGroupPosition: rawDirName
150
+ ? extractNumberPrefix(rawDirName)
151
+ : undefined,
152
+ };
153
+ }