boltdocs 2.4.1 → 2.5.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 (113) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/cache-3FOEPC2P.mjs +1 -0
  3. package/dist/chunk-ITFGVXPE.mjs +1 -0
  4. package/dist/chunk-TP5KMRD3.mjs +1 -0
  5. package/dist/chunk-UD2LQG2M.mjs +75 -0
  6. package/dist/chunk-Y4RE5KI7.mjs +1 -0
  7. package/dist/client/index.d.mts +1225 -14
  8. package/dist/client/index.d.ts +1225 -14
  9. package/dist/client/index.js +1 -1
  10. package/dist/client/index.mjs +1 -1
  11. package/dist/client/ssr.js +1 -1
  12. package/dist/client/ssr.mjs +1 -1
  13. package/dist/node/cli-entry.js +37 -35
  14. package/dist/node/cli-entry.mjs +1 -1
  15. package/dist/node/index.d.mts +263 -1
  16. package/dist/node/index.d.ts +263 -1
  17. package/dist/node/index.js +34 -32
  18. package/dist/node/index.mjs +1 -1
  19. package/dist/package-TWC3BMZ7.mjs +1 -0
  20. package/dist/search-dialog-YBM4GYDR.mjs +1 -0
  21. package/package.json +46 -76
  22. package/src/client/components/primitives/breadcrumbs.tsx +10 -10
  23. package/src/client/components/primitives/index.ts +17 -16
  24. package/src/client/components/primitives/menu.tsx +11 -14
  25. package/src/client/components/primitives/navbar.tsx +29 -29
  26. package/src/client/components/primitives/navigation-menu.tsx +7 -9
  27. package/src/client/components/primitives/on-this-page.tsx +16 -18
  28. package/src/client/components/primitives/page-nav.tsx +10 -13
  29. package/src/client/components/primitives/search-dialog.tsx +17 -19
  30. package/src/client/components/primitives/sidebar.tsx +8 -10
  31. package/src/client/components/primitives/tabs.tsx +10 -12
  32. package/src/client/components/primitives/tooltip.tsx +3 -5
  33. package/src/client/components/ui-base/breadcrumbs.tsx +12 -15
  34. package/src/client/components/ui-base/copy-markdown.tsx +8 -10
  35. package/src/client/components/ui-base/navbar.tsx +10 -10
  36. package/src/client/components/ui-base/on-this-page.tsx +13 -12
  37. package/src/client/components/ui-base/page-nav.tsx +15 -15
  38. package/src/client/components/ui-base/search-dialog.tsx +13 -20
  39. package/src/client/components/ui-base/sidebar.tsx +9 -9
  40. package/src/client/components/ui-base/tabs.tsx +6 -7
  41. package/src/client/components/ui-base/theme-toggle.tsx +11 -11
  42. package/src/client/hooks/index.ts +12 -12
  43. package/src/client/index.ts +34 -11
  44. package/src/node/config.ts +48 -6
  45. package/src/node/errors.ts +44 -0
  46. package/src/node/index.ts +29 -2
  47. package/src/node/mdx/index.ts +9 -2
  48. package/src/node/plugin/index.ts +72 -4
  49. package/src/node/plugins/index.ts +17 -0
  50. package/src/node/plugins/plugin-errors.ts +62 -0
  51. package/src/node/plugins/plugin-lifecycle.ts +117 -0
  52. package/src/node/plugins/plugin-sandbox.ts +59 -0
  53. package/src/node/plugins/plugin-store.ts +54 -0
  54. package/src/node/plugins/plugin-types.ts +107 -0
  55. package/src/node/plugins/plugin-validator.ts +105 -0
  56. package/src/node/routes/parser.ts +35 -5
  57. package/src/node/schema/config.ts +208 -0
  58. package/src/node/schema/frontmatter.ts +17 -0
  59. package/src/node/security/constants/index.ts +10 -0
  60. package/src/node/security/csp.ts +31 -0
  61. package/src/node/security/headers.ts +27 -0
  62. package/src/node/utils.ts +153 -5
  63. package/tsup.config.ts +0 -6
  64. package/dist/base-ui/index.d.mts +0 -25
  65. package/dist/base-ui/index.d.ts +0 -25
  66. package/dist/base-ui/index.js +0 -1
  67. package/dist/base-ui/index.mjs +0 -1
  68. package/dist/cache-P6WK424C.mjs +0 -1
  69. package/dist/chunk-2DI3OGHV.mjs +0 -1
  70. package/dist/chunk-2Z5T6EAU.mjs +0 -1
  71. package/dist/chunk-64AJ5QLT.mjs +0 -1
  72. package/dist/chunk-DDX52BX4.mjs +0 -1
  73. package/dist/chunk-HRZDSFR5.mjs +0 -1
  74. package/dist/chunk-JD3RSDE4.mjs +0 -1
  75. package/dist/chunk-JZXLCA2E.mjs +0 -1
  76. package/dist/chunk-NBCYHLAA.mjs +0 -1
  77. package/dist/chunk-PPVDMDEL.mjs +0 -1
  78. package/dist/chunk-T3W44KWY.mjs +0 -1
  79. package/dist/chunk-UBE4CKOA.mjs +0 -1
  80. package/dist/chunk-UWT4AJTH.mjs +0 -73
  81. package/dist/chunk-WWJ7WKDI.mjs +0 -1
  82. package/dist/chunk-Y4RRHPXC.mjs +0 -1
  83. package/dist/client/types.d.mts +0 -3
  84. package/dist/client/types.d.ts +0 -3
  85. package/dist/client/types.js +0 -1
  86. package/dist/client/types.mjs +0 -0
  87. package/dist/copy-markdown--9yjpbyy.d.mts +0 -15
  88. package/dist/copy-markdown-l2MYkcG7.d.ts +0 -15
  89. package/dist/hooks/index.d.mts +0 -137
  90. package/dist/hooks/index.d.ts +0 -137
  91. package/dist/hooks/index.js +0 -1
  92. package/dist/hooks/index.mjs +0 -1
  93. package/dist/integrations/index.d.mts +0 -48
  94. package/dist/integrations/index.d.ts +0 -48
  95. package/dist/integrations/index.js +0 -1
  96. package/dist/integrations/index.mjs +0 -1
  97. package/dist/link-DfBwCeZc.d.mts +0 -68
  98. package/dist/link-DfBwCeZc.d.ts +0 -68
  99. package/dist/loading-BwUos0wZ.d.mts +0 -57
  100. package/dist/loading-nlnUD01v.d.ts +0 -57
  101. package/dist/mdx/index.d.mts +0 -178
  102. package/dist/mdx/index.d.ts +0 -178
  103. package/dist/mdx/index.js +0 -1
  104. package/dist/mdx/index.mjs +0 -1
  105. package/dist/primitives/index.d.mts +0 -292
  106. package/dist/primitives/index.d.ts +0 -292
  107. package/dist/primitives/index.js +0 -1
  108. package/dist/primitives/index.mjs +0 -1
  109. package/dist/search-dialog-OONKKC5H.mjs +0 -1
  110. package/dist/types-opDA2E9-.d.mts +0 -394
  111. package/dist/types-opDA2E9-.d.ts +0 -394
  112. package/dist/use-routes-DNwgTRpU.d.ts +0 -29
  113. package/dist/use-routes-DrT80Eom.d.mts +0 -29
@@ -1,6 +1,12 @@
1
1
  import path from 'path'
2
2
  import fs from 'fs'
3
3
  import { loadConfigFromFile, type Plugin as VitePlugin } from 'vite'
4
+ import { BoltdocsConfigSchema } from './schema/config'
5
+ import { ValidationError } from './errors'
6
+ import type {
7
+ PluginLifecycleHooks,
8
+ PluginPermission,
9
+ } from './plugins/plugin-types'
4
10
 
5
11
  /**
6
12
  * Represents a single social link in the configuration.
@@ -174,6 +180,12 @@ export interface BoltdocsPlugin {
174
180
  name: string
175
181
  /** Whether to run this plugin before or after default ones (optional) */
176
182
  enforce?: 'pre' | 'post'
183
+ /** Version of the plugin (optional) */
184
+ version?: string
185
+ /** Minimum compatible Boltdocs version (optional, semver range) */
186
+ boltdocsVersion?: string
187
+ /** List of permissions this plugin requires to operate */
188
+ permissions?: PluginPermission[]
177
189
  /** Optional remark plugins to add to the MDX pipeline */
178
190
  remarkPlugins?: unknown[]
179
191
  /** Optional rehype plugins to add to the MDX pipeline */
@@ -182,6 +194,8 @@ export interface BoltdocsPlugin {
182
194
  vitePlugins?: VitePlugin[]
183
195
  /** Optional custom React components to register in MDX. Map of Name -> Module Path. */
184
196
  components?: Record<string, string>
197
+ /** Implementation of lifecycle hooks */
198
+ hooks?: PluginLifecycleHooks
185
199
  }
186
200
 
187
201
  /**
@@ -197,6 +211,18 @@ export interface BoltdocsIntegrationsConfig {
197
211
  }
198
212
  }
199
213
 
214
+ /**
215
+ * Configuration for security-related settings.
216
+ */
217
+ export interface BoltdocsSecurityConfig {
218
+ /** Map of standard security headers to override or supplement defaults */
219
+ headers?: Record<string, string>
220
+ /** Whether to enable Content Security Policy (CSP) generation (default: false) */
221
+ enableCSP?: boolean
222
+ /** Additional custom headers to inject into responses */
223
+ customHeaders?: Record<string, string>
224
+ }
225
+
200
226
  /**
201
227
  * The root configuration object for Boltdocs.
202
228
  */
@@ -219,6 +245,8 @@ export interface BoltdocsConfig {
219
245
  integrations?: BoltdocsIntegrationsConfig
220
246
  /** Configuration for the robots.txt file */
221
247
  robots?: BoltdocsRobotsConfig
248
+ /** Security-related settings and headers */
249
+ security?: BoltdocsSecurityConfig
222
250
  /** Low-level Vite configuration overrides */
223
251
  vite?: import('vite').InlineConfig
224
252
  }
@@ -241,6 +269,7 @@ interface RawUserConfig
241
269
  Partial<BoltdocsThemeConfig> {
242
270
  favicon?: string
243
271
  ogImage?: string
272
+ security?: BoltdocsSecurityConfig
244
273
  }
245
274
 
246
275
  /**
@@ -297,7 +326,6 @@ export async function resolveConfig(
297
326
  }
298
327
  }
299
328
 
300
- // Robust merging strategy
301
329
  const themeConfigFromTop: BoltdocsThemeConfig = {
302
330
  title: userConfig.title,
303
331
  description: userConfig.description,
@@ -319,18 +347,14 @@ export async function resolveConfig(
319
347
  editLink: userConfig.editLink,
320
348
  }
321
349
 
322
- // User can define properties at top level or inside themeConfig/theme
323
350
  const userThemeConfig: BoltdocsThemeConfig = {
324
351
  ...themeConfigFromTop,
325
352
  ...(userConfig.theme || {}),
326
353
  }
327
354
 
328
- // Clean undefined properties
329
355
  const cleanThemeConfig = Object.fromEntries(
330
356
  Object.entries(userThemeConfig).filter(([_, v]) => v !== undefined),
331
357
  ) as BoltdocsThemeConfig
332
-
333
- // Transform old navbar items if necessary
334
358
  if (cleanThemeConfig.navbar) {
335
359
  cleanThemeConfig.navbar = cleanThemeConfig.navbar.map((item: any) => ({
336
360
  label: item.label || item.text || '',
@@ -338,7 +362,7 @@ export async function resolveConfig(
338
362
  }))
339
363
  }
340
364
 
341
- return {
365
+ const finalConfig: BoltdocsConfig = {
342
366
  docsDir: path.resolve(docsDir),
343
367
  homePage: userConfig.homePage,
344
368
  theme: {
@@ -351,6 +375,24 @@ export async function resolveConfig(
351
375
  plugins: userConfig.plugins || [],
352
376
  integrations: userConfig.integrations,
353
377
  robots: userConfig.robots,
378
+ security: userConfig.security,
354
379
  vite: userConfig.vite,
355
380
  }
381
+
382
+ // Validate the final configuration
383
+ const validation = BoltdocsConfigSchema.safeParse(finalConfig)
384
+ if (!validation.success) {
385
+ const errorMessages = validation.error.issues
386
+ .map((err: any) => {
387
+ const path = err.path.join('.')
388
+ return ` - ${path}: ${err.message}`
389
+ })
390
+ .join('\n')
391
+
392
+ throw new ValidationError(
393
+ `Invalid Boltdocs configuration:\n${errorMessages}`,
394
+ )
395
+ }
396
+
397
+ return validation.data as BoltdocsConfig
356
398
  }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Base class for all security-related violations in Boltdocs.
3
+ */
4
+ export class SecurityViolationError extends Error {
5
+ constructor(message: string) {
6
+ super(message);
7
+ this.name = 'SecurityViolationError';
8
+ // Ensure the prototype is set correctly for instanceof checks in older TS versions
9
+ Object.setPrototypeOf(this, SecurityViolationError.prototype);
10
+ }
11
+ }
12
+
13
+ /**
14
+ * Specifically for directory traversal attempts and related filesystem breaches.
15
+ */
16
+ export class PathTraversalError extends SecurityViolationError {
17
+ constructor(message: string) {
18
+ super(message);
19
+ this.name = 'PathTraversalError';
20
+ Object.setPrototypeOf(this, PathTraversalError.prototype);
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Specifically for invalid or malicious character encoding in paths.
26
+ */
27
+ export class EncodingSecurityError extends SecurityViolationError {
28
+ constructor(message: string) {
29
+ super(message);
30
+ this.name = 'EncodingSecurityError';
31
+ Object.setPrototypeOf(this, EncodingSecurityError.prototype);
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Specifically for schema or size validation failures in inputs (like frontmatter).
37
+ */
38
+ export class ValidationError extends SecurityViolationError {
39
+ constructor(message: string) {
40
+ super(message);
41
+ this.name = 'ValidationError';
42
+ Object.setPrototypeOf(this, ValidationError.prototype);
43
+ }
44
+ }
package/src/node/index.ts CHANGED
@@ -3,9 +3,11 @@ import react from '@vitejs/plugin-react'
3
3
  import tailwindcss from '@tailwindcss/vite'
4
4
  import { boltdocsPlugin } from './plugin/index'
5
5
  import { boltdocsMdxPlugin } from './mdx/index'
6
+ import { SECURITY_HEADERS } from './security/headers'
7
+ import { getCSPHeader } from './security/csp'
6
8
  import type { BoltdocsPluginOptions } from './plugin/index'
7
9
 
8
- import { resolveConfig, type BoltdocsConfig } from './config'
10
+ import { resolveConfig } from './config'
9
11
 
10
12
  export default async function boltdocs(
11
13
  options?: BoltdocsPluginOptions,
@@ -31,6 +33,15 @@ export async function createViteConfig(
31
33
  mode: 'development' | 'production' = 'development',
32
34
  ): Promise<InlineConfig> {
33
35
  const config = await resolveConfig('docs', root)
36
+ const isProd = mode === 'production'
37
+
38
+ // Prepare security headers
39
+ const securityHeaders: Record<string, string> = isProd
40
+ ? { ...SECURITY_HEADERS }
41
+ : {}
42
+ if (config.security?.enableCSP) {
43
+ securityHeaders['Content-Security-Policy'] = getCSPHeader(config)
44
+ }
34
45
 
35
46
  const viteConfig: InlineConfig = {
36
47
  root,
@@ -43,6 +54,20 @@ export async function createViteConfig(
43
54
  homePage: config.homePage,
44
55
  }),
45
56
  ],
57
+ server: {
58
+ headers: {
59
+ ...securityHeaders,
60
+ ...config.vite?.server?.headers,
61
+ },
62
+ ...config.vite?.server,
63
+ },
64
+ preview: {
65
+ headers: {
66
+ ...securityHeaders,
67
+ ...config.vite?.preview?.headers,
68
+ },
69
+ ...config.vite?.preview,
70
+ },
46
71
  ...config.vite,
47
72
  }
48
73
 
@@ -53,5 +78,7 @@ export type { BoltdocsPluginOptions }
53
78
  export { generateStaticPages } from './ssg'
54
79
  export type { SSGOptions } from './ssg'
55
80
  export type { RouteMeta } from './routes'
56
- export type { BoltdocsConfig, BoltdocsThemeConfig } from './config'
81
+ export type { BoltdocsConfig, BoltdocsThemeConfig, BoltdocsPlugin } from './config'
57
82
  export { resolveConfig, defineConfig } from './config'
83
+
84
+ export * from './plugins'
@@ -10,6 +10,7 @@ import { mdxCache, MDX_PLUGIN_VERSION } from './cache'
10
10
  import { remarkShiki } from './remark-shiki'
11
11
  import { rehypeShiki } from './rehype-shiki'
12
12
  import { remarkCodeMeta } from './remark-code-meta'
13
+ import { PluginSandbox } from '../plugins'
13
14
 
14
15
  let mdxCacheLoaded = false
15
16
  let hits = 0
@@ -31,9 +32,15 @@ export function boltdocsMdxPlugin(
31
32
  compiler = mdxPlugin,
32
33
  ): Plugin {
33
34
  const extraRemarkPlugins =
34
- config?.plugins?.flatMap((p) => p.remarkPlugins || []) || []
35
+ config?.plugins?.flatMap((p) => {
36
+ const caps = PluginSandbox.getSanitizedCapabilities(p as any)
37
+ return caps.remarkPlugins || []
38
+ }) || []
35
39
  const extraRehypePlugins =
36
- config?.plugins?.flatMap((p) => p.rehypePlugins || []) || []
40
+ config?.plugins?.flatMap((p) => {
41
+ const caps = PluginSandbox.getSanitizedCapabilities(p as any)
42
+ return caps.rehypePlugins || []
43
+ }) || []
37
44
 
38
45
  const baseMdxPlugin = compiler({
39
46
  remarkPlugins: [
@@ -10,7 +10,15 @@ import { generateEntryCode } from './entry'
10
10
  import { injectHtmlMeta, getHtmlTemplate } from './html'
11
11
  import { generateRobotsTxt } from '../ssg/robots'
12
12
  import { generateSearchData } from '../search'
13
+ import { SECURITY_HEADERS } from '../security/headers'
14
+ import { getCSPHeader } from '../security/csp'
13
15
  import fs from 'fs'
16
+ import {
17
+ PluginLifecycleManager,
18
+ validatePlugins,
19
+ PluginSandbox,
20
+ type SecureBoltdocsPlugin,
21
+ } from '../plugins'
14
22
 
15
23
  export * from './types'
16
24
 
@@ -32,9 +40,10 @@ export function boltdocsPlugin(
32
40
  let config: BoltdocsConfig = passedConfig!
33
41
  let viteConfig: ResolvedConfig
34
42
  let isBuild = false
43
+ let lifecycle: PluginLifecycleManager
35
44
 
36
- const extraVitePlugins =
37
- config?.plugins?.flatMap((p) => p.vitePlugins || []) || []
45
+ // Use a placeholder for extra plugins that will be populated once config is resolved
46
+ let resolvedExtraVitePlugins: Plugin[] = []
38
47
 
39
48
  return [
40
49
  {
@@ -54,6 +63,30 @@ export function boltdocsPlugin(
54
63
  config = await resolveConfig(docsDir)
55
64
  }
56
65
 
66
+ // --- NEW: Secure Plugin Initialization ---
67
+ const boltdocsVersion = (await import('../../../package.json')).version
68
+ const validatedPlugins = validatePlugins(
69
+ (config.plugins || []) as SecureBoltdocsPlugin[],
70
+ boltdocsVersion,
71
+ )
72
+
73
+ // Replace config plugins with validated ones
74
+ config.plugins = validatedPlugins as any
75
+
76
+ // Initialize lifecycle manager
77
+ lifecycle = new PluginLifecycleManager(validatedPlugins, config)
78
+
79
+ // Populate validated extra Vite plugins
80
+ resolvedExtraVitePlugins = validatedPlugins.flatMap((p) => {
81
+ const caps = PluginSandbox.getSanitizedCapabilities(p)
82
+ return (caps.vitePlugins || []) as Plugin[]
83
+ })
84
+
85
+ // Run beforeBuild if building
86
+ if (isBuild) {
87
+ await lifecycle.runHook('beforeBuild')
88
+ }
89
+
57
90
  return {
58
91
  optimizeDeps: {
59
92
  include: ['react', 'react-dom'],
@@ -74,9 +107,30 @@ export function boltdocsPlugin(
74
107
 
75
108
  configResolved(resolved) {
76
109
  viteConfig = resolved
110
+ lifecycle?.runHook('configResolved', config)
77
111
  },
78
112
 
79
- configureServer(server) {
113
+ async configureServer(server) {
114
+ await lifecycle?.runHook('beforeDev')
115
+
116
+ // Security: Apply hardened headers and CSP
117
+ server.middlewares.use((_req, res, next) => {
118
+ const isProd = process.env.NODE_ENV === 'production'
119
+
120
+ if (isProd) {
121
+ Object.entries(SECURITY_HEADERS).forEach(([header, value]) => {
122
+ res.setHeader(header, value)
123
+ })
124
+ }
125
+
126
+ // Inject CSP if enabled in configuration
127
+ if (config.security?.enableCSP) {
128
+ res.setHeader('Content-Security-Policy', getCSPHeader(config))
129
+ }
130
+
131
+ next()
132
+ })
133
+
80
134
  // Serve robots.txt from config
81
135
  server.middlewares.use((req, res, next) => {
82
136
  if (req.url === '/robots.txt') {
@@ -263,6 +317,8 @@ export function boltdocsPlugin(
263
317
  server.watcher.on('add', (f) => handleFileEvent(f, 'add'))
264
318
  server.watcher.on('unlink', (f) => handleFileEvent(f, 'unlink'))
265
319
  server.watcher.on('change', (f) => handleFileEvent(f, 'change'))
320
+
321
+ await lifecycle?.runHook('afterDev')
266
322
  },
267
323
 
268
324
  resolveId(id) {
@@ -368,6 +424,9 @@ export default DefaultLayout;`
368
424
 
369
425
  const { flushCache } = await import('../cache')
370
426
  await flushCache()
427
+
428
+ await lifecycle?.runHook('afterBuild')
429
+ await lifecycle?.runHook('buildEnd')
371
430
  },
372
431
  },
373
432
  ViteImageOptimizer({
@@ -386,6 +445,15 @@ export default DefaultLayout;`
386
445
  ] as any,
387
446
  },
388
447
  }),
389
- ...extraVitePlugins.filter((p): p is Plugin => !!p),
448
+ // Re-bind to use the lazily populated extra plugins
449
+ {
450
+ name: 'vite-plugin-boltdocs-extra-plugins',
451
+ async configResolved() {
452
+ // This is a dummy plugin to ensure the extra plugins are integrated
453
+ // Actually, Vite doesn't support changing the plugin list dynamically easily
454
+ // but we can return them in the original array if we initialize them early.
455
+ },
456
+ },
457
+ ...(() => resolvedExtraVitePlugins)(),
390
458
  ]
391
459
  }
@@ -0,0 +1,17 @@
1
+ export * from './plugin-types'
2
+ export * from './plugin-errors'
3
+ export * from './plugin-store'
4
+ export * from './plugin-validator'
5
+ export * from './plugin-sandbox'
6
+ export * from './plugin-lifecycle'
7
+
8
+ import type { SecureBoltdocsPlugin } from './plugin-types'
9
+
10
+ /**
11
+ * Utility to create a Boltdocs plugin with full type safety.
12
+ */
13
+ export function createPlugin(
14
+ plugin: SecureBoltdocsPlugin,
15
+ ): SecureBoltdocsPlugin {
16
+ return plugin
17
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Base class for all plugin-related errors in Boltdocs.
3
+ */
4
+ export class PluginError extends Error {
5
+ public readonly pluginName: string
6
+
7
+ constructor(pluginName: string, message: string) {
8
+ super(`[plugin:${pluginName}] ${message}`)
9
+ this.name = 'PluginError'
10
+ this.pluginName = pluginName
11
+ Object.setPrototypeOf(this, PluginError.prototype)
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Specifically for schema or structure validation failures.
17
+ */
18
+ export class PluginValidationError extends PluginError {
19
+ constructor(pluginName: string, message: string) {
20
+ super(pluginName, `Validation failed: ${message}`)
21
+ this.name = 'PluginValidationError'
22
+ Object.setPrototypeOf(this, PluginValidationError.prototype)
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Specifically for version mismatch or compatibility issues.
28
+ */
29
+ export class PluginCompatibilityError extends PluginError {
30
+ constructor(pluginName: string, message: string) {
31
+ super(pluginName, `Compatibility error: ${message}`)
32
+ this.name = 'PluginCompatibilityError'
33
+ Object.setPrototypeOf(this, PluginCompatibilityError.prototype)
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Specifically for attempts to use capabilities without proper permissions.
39
+ */
40
+ export class PluginPermissionError extends PluginError {
41
+ constructor(pluginName: string, permission: string) {
42
+ super(pluginName, `Missing required permission: '${permission}'`)
43
+ this.name = 'PluginPermissionError'
44
+ Object.setPrototypeOf(this, PluginPermissionError.prototype)
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Specifically for errors that occur during the execution of a lifecycle hook.
50
+ */
51
+ export class PluginHookError extends PluginError {
52
+ public readonly hookName: string
53
+
54
+ constructor(pluginName: string, hookName: string, originalError: Error) {
55
+ super(pluginName, `Error in hook '${hookName}': ${originalError.message}`)
56
+ this.name = 'PluginHookError'
57
+ this.hookName = hookName
58
+ // Prepend the hook name to the stack if possible
59
+ this.stack = originalError.stack
60
+ Object.setPrototypeOf(this, PluginHookError.prototype)
61
+ }
62
+ }
@@ -0,0 +1,117 @@
1
+ import type { BoltdocsConfig } from '../config'
2
+ import { PluginHookError } from './plugin-errors'
3
+ import {
4
+ PluginLifecycleHooks,
5
+ SecureBoltdocsPlugin,
6
+ PluginContext,
7
+ PluginLogger,
8
+ } from './plugin-types'
9
+ import { BoltdocsPluginStore } from './plugin-store'
10
+ import { PluginSandbox } from './plugin-sandbox'
11
+
12
+ /**
13
+ * Manages the lifecycle of all loaded plugins, ensuring hooks are executed
14
+ * in the correct order with proper error isolation and context.
15
+ */
16
+ export class PluginLifecycleManager {
17
+ private plugins: SecureBoltdocsPlugin[]
18
+ private config: BoltdocsConfig
19
+ private store: BoltdocsPluginStore
20
+
21
+ constructor(plugins: SecureBoltdocsPlugin[], config: BoltdocsConfig) {
22
+ this.plugins = plugins
23
+ this.config = config
24
+ this.store = new BoltdocsPluginStore()
25
+ }
26
+
27
+ /**
28
+ * Runs a specific hook for all plugins that implement it.
29
+ */
30
+ public async runHook(
31
+ hookName: keyof PluginLifecycleHooks,
32
+ ...args: any[]
33
+ ): Promise<void> {
34
+ const sortedPlugins = this.getSortedPlugins()
35
+
36
+ for (const plugin of sortedPlugins) {
37
+ if (!plugin.hooks?.[hookName]) continue
38
+
39
+ const context = this.createContext(plugin)
40
+ const isBuildHook = hookName.toLowerCase().includes('build')
41
+ const isDevHook = hookName.toLowerCase().includes('dev')
42
+ const permission = isBuildHook
43
+ ? 'hooks:build'
44
+ : isDevHook
45
+ ? 'hooks:dev'
46
+ : undefined
47
+
48
+ try {
49
+ if (permission) {
50
+ await PluginSandbox.executeWithIsolation(
51
+ plugin,
52
+ permission,
53
+ hookName,
54
+ () => (plugin.hooks![hookName] as any)(context, ...args),
55
+ )
56
+ } else {
57
+ // Hooks like configResolved might not require specific permissions or are always allowed
58
+ await (plugin.hooks![hookName] as any)(context, ...args)
59
+ }
60
+ } catch (error) {
61
+ // Isolate error: logging it but allowing other plugins to continue
62
+ const hookError = new PluginHookError(
63
+ plugin.name,
64
+ hookName,
65
+ error instanceof Error ? error : new Error(String(error)),
66
+ )
67
+ context.logger.error(hookError)
68
+ }
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Sorts plugins based on their 'enforce' property (pre -> normal -> post).
74
+ */
75
+ private getSortedPlugins(): SecureBoltdocsPlugin[] {
76
+ const pre = this.plugins.filter((p) => p.enforce === 'pre')
77
+ const normal = this.plugins.filter((p) => !p.enforce)
78
+ const post = this.plugins.filter((p) => p.enforce === 'post')
79
+ return [...pre, ...normal, ...post]
80
+ }
81
+
82
+ /**
83
+ * Creates a specialized context for a plugin.
84
+ */
85
+ private createContext(plugin: SecureBoltdocsPlugin): PluginContext {
86
+ return {
87
+ config: Object.freeze({ ...this.config }),
88
+ meta: {
89
+ name: plugin.name,
90
+ version: plugin.version,
91
+ boltdocsVersion: plugin.boltdocsVersion,
92
+ },
93
+ store: {
94
+ get: (p, k) => this.store.get(p, k),
95
+ set: (p, k, v) => this.store.set(p, k, v),
96
+ has: (p, k) => this.store.has(p, k),
97
+ },
98
+ logger: this.createLogger(plugin.name),
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Creates a namespaced logger for a plugin.
104
+ */
105
+ private createLogger(pluginName: string): PluginLogger {
106
+ const prefix = `[plugin:${pluginName}]`
107
+ return {
108
+ info: (msg) => console.log(`${prefix} INFO: ${msg}`),
109
+ warn: (msg) => console.warn(`${prefix} WARN: ${msg}`),
110
+ error: (msg) => {
111
+ const message = msg instanceof Error ? msg.message : msg
112
+ console.error(`${prefix} ERROR: ${message}`)
113
+ },
114
+ debug: (msg) => console.debug(`${prefix} DEBUG: ${msg}`),
115
+ }
116
+ }
117
+ }
@@ -0,0 +1,59 @@
1
+ import { PluginPermissionError } from './plugin-errors'
2
+ import { hasPermission } from './plugin-validator'
3
+ import type { SecureBoltdocsPlugin, PluginPermission } from './plugin-types'
4
+
5
+ /**
6
+ * The Sandbox provides a protective layer that ensures plugins only use
7
+ * the capabilities they have explicitly requested permissions for.
8
+ */
9
+ export class PluginSandbox {
10
+ /**
11
+ * Validates if a plugin has the required permission for a capability.
12
+ * Throws a PluginPermissionError if not granted.
13
+ */
14
+ public static checkPermission(
15
+ plugin: SecureBoltdocsPlugin,
16
+ permission: PluginPermission
17
+ ): void {
18
+ if (!hasPermission(plugin, permission)) {
19
+ throw new PluginPermissionError(plugin.name, permission)
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Filters a plugin's capabilities based on its declared permissions.
25
+ * This is used when aggregating plugins (remark, rehype, vite, components).
26
+ */
27
+ public static getSanitizedCapabilities(plugin: SecureBoltdocsPlugin) {
28
+ return {
29
+ remarkPlugins: hasPermission(plugin, 'mdx:remark') ? plugin.remarkPlugins : [],
30
+ rehypePlugins: hasPermission(plugin, 'mdx:rehype') ? plugin.rehypePlugins : [],
31
+ vitePlugins: hasPermission(plugin, 'vite:config') ? plugin.vitePlugins : [],
32
+ components: hasPermission(plugin, 'components') ? plugin.components : {},
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Wraps a hook execution with permission checks and error isolation.
38
+ */
39
+ public static async executeWithIsolation<T>(
40
+ plugin: SecureBoltdocsPlugin,
41
+ requiredPermission: PluginPermission,
42
+ hookName: string,
43
+ action: () => Promise<T> | T
44
+ ): Promise<T | undefined> {
45
+ try {
46
+ this.checkPermission(plugin, requiredPermission)
47
+ return await action()
48
+ } catch (error) {
49
+ if (error instanceof PluginPermissionError) {
50
+ // Log skip instead of failing hard for permissions in some contexts
51
+ console.warn(`[boltdocs] Skipping hook '${hookName}' for plugin '${plugin.name}': ${error.message}`)
52
+ return undefined
53
+ }
54
+
55
+ // Re-throw other errors to be caught by the LifecycleManager
56
+ throw error
57
+ }
58
+ }
59
+ }
@@ -0,0 +1,54 @@
1
+ import type { PluginStore } from './plugin-types'
2
+
3
+ /**
4
+ * Implementation of the shared plugin store.
5
+ * Uses a namespaced approach to prevent key collisions between plugins.
6
+ */
7
+ export class BoltdocsPluginStore implements PluginStore {
8
+ private data = new Map<string, unknown>()
9
+
10
+ /**
11
+ * Internal helper to create a namespaced key.
12
+ */
13
+ private getNamespaceKey(pluginName: string, key: string): string {
14
+ return `${pluginName}:${key}`
15
+ }
16
+
17
+ /**
18
+ * Retrieves a value from the store. Returns a deep clone to prevent mutations (side-effects).
19
+ */
20
+ public get<T = unknown>(pluginName: string, key: string): T | undefined {
21
+ const nsKey = this.getNamespaceKey(pluginName, key)
22
+ const value = this.data.get(nsKey)
23
+
24
+ if (value === undefined) return undefined
25
+
26
+ // For safety, return a deep clone if it's an object
27
+ if (typeof value === 'object' && value !== null) {
28
+ return JSON.parse(JSON.stringify(value)) as T
29
+ }
30
+
31
+ return value as T
32
+ }
33
+
34
+ /**
35
+ * Stores a value in the store. Key is automatically namespaced.
36
+ */
37
+ public set(pluginName: string, key: string, value: unknown): void {
38
+ const nsKey = this.getNamespaceKey(pluginName, key)
39
+ // We also store a clone to ensure the store state is immutable from the outside
40
+ const storedValue = typeof value === 'object' && value !== null
41
+ ? JSON.parse(JSON.stringify(value))
42
+ : value
43
+
44
+ this.data.set(nsKey, storedValue)
45
+ }
46
+
47
+ /**
48
+ * Checks for the existence of a key in the store.
49
+ */
50
+ public has(pluginName: string, key: string): boolean {
51
+ const nsKey = this.getNamespaceKey(pluginName, key)
52
+ return this.data.has(nsKey)
53
+ }
54
+ }