docusaurus-plugin-glossary 3.0.0 → 3.0.2

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.
package/README.md CHANGED
@@ -636,6 +636,10 @@ MIT
636
636
 
637
637
  Contributions are welcome! Please see our [Contributing Guide](CONTRIBUTING.md) for details on how to get started.
638
638
 
639
+ ## Changelog
640
+
641
+ See [CHANGELOG.md](CHANGELOG.md) for version history and release notes.
642
+
639
643
  ## Credits
640
644
 
641
645
  Built for Docusaurus v3.x
@@ -29,10 +29,10 @@ function groupTermsByLetter(terms) {
29
29
  * GlossaryPage component - displays all glossary terms
30
30
  */
31
31
  export default function GlossaryPage({ glossaryData }) {
32
- const { siteConfig } = useDocusaurusContext();
32
+ useDocusaurusContext();
33
33
  const [searchTerm, setSearchTerm] = useState('');
34
34
 
35
- const terms = glossaryData?.terms || [];
35
+ const terms = useMemo(() => glossaryData?.terms || [], [glossaryData?.terms]);
36
36
 
37
37
  // Filter terms based on search
38
38
  const filteredTerms = useMemo(() => {
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { render, screen, within } from '@testing-library/react';
2
+ import { render, screen } from '@testing-library/react';
3
3
  import userEvent from '@testing-library/user-event';
4
4
  import GlossaryPage from './GlossaryPage';
5
5
 
@@ -0,0 +1,101 @@
1
+ import type { LoadContext, Plugin } from '@docusaurus/types';
2
+ import remarkGlossaryTerms from './remark/glossary-terms.js';
3
+ export interface GlossaryPluginOptions {
4
+ glossaryPath?: string;
5
+ routePath?: string;
6
+ autoLinkTerms?: boolean;
7
+ }
8
+ export interface GlossaryTerm {
9
+ term: string;
10
+ definition: string;
11
+ abbreviation?: string;
12
+ relatedTerms?: string[];
13
+ id?: string;
14
+ }
15
+ export interface GlossaryData {
16
+ terms: GlossaryTerm[];
17
+ }
18
+ /**
19
+ * Docusaurus Glossary Plugin
20
+ *
21
+ * A plugin that provides glossary functionality with:
22
+ * - Glossary terms defined in a JSON file
23
+ * - Auto-generated glossary page with term definitions
24
+ * - GlossaryTerm component for inline definitions with interactive tooltips
25
+ * - Automatic client-side initialization via getClientModules() (no manual imports needed)
26
+ * - Optional automatic glossary term detection in markdown files via remark plugin
27
+ *
28
+ * ## Basic Usage (Manual Term Markup)
29
+ *
30
+ * Just install the plugin - the GlossaryTerm component is automatically available:
31
+ * ```javascript
32
+ * module.exports = {
33
+ * plugins: [
34
+ * ['docusaurus-plugin-glossary', {
35
+ * glossaryPath: 'glossary/glossary.json',
36
+ * routePath: '/glossary',
37
+ * }],
38
+ * ],
39
+ * };
40
+ * ```
41
+ *
42
+ * Then use `<GlossaryTerm>` in your MDX files without importing:
43
+ * ```mdx
44
+ * <GlossaryTerm term="API">API</GlossaryTerm>
45
+ * ```
46
+ *
47
+ * ## Advanced Usage (Automatic Term Detection)
48
+ *
49
+ * To automatically detect and link glossary terms in markdown, add the remark plugin:
50
+ * ```javascript
51
+ * const glossaryPlugin = require('docusaurus-plugin-glossary');
52
+ *
53
+ * module.exports = {
54
+ * presets: [
55
+ * ['@docusaurus/preset-classic', {
56
+ * docs: {
57
+ * remarkPlugins: [
58
+ * glossaryPlugin.getRemarkPlugin({
59
+ * glossaryPath: 'glossary/glossary.json',
60
+ * routePath: '/glossary',
61
+ * }, { siteDir: __dirname }),
62
+ * ],
63
+ * },
64
+ * }],
65
+ * ],
66
+ * plugins: [
67
+ * ['docusaurus-plugin-glossary', {
68
+ * glossaryPath: 'glossary/glossary.json',
69
+ * routePath: '/glossary',
70
+ * }],
71
+ * ],
72
+ * };
73
+ * ```
74
+ *
75
+ * @param context - Docusaurus context
76
+ * @param options - Plugin options
77
+ * @param options.glossaryPath - Path to glossary JSON file (default: 'glossary/glossary.json')
78
+ * @param options.routePath - Route path for glossary page (default: '/glossary')
79
+ * @param options.autoLinkTerms - Legacy option, kept for compatibility but no longer used (configure remark plugin manually instead)
80
+ * @returns Plugin object
81
+ */
82
+ export default function glossaryPlugin(context: LoadContext, options?: GlossaryPluginOptions): Plugin;
83
+ export declare const remarkPlugin: typeof remarkGlossaryTerms;
84
+ export { clearGlossaryCache } from './remark/glossary-terms.js';
85
+ export { validateGlossaryData, GlossaryValidationError, formatValidationErrors, type ValidationError, type ValidationResult, } from './validation.js';
86
+ /**
87
+ * Helper function to get the configured remark plugin
88
+ * This can be used in docusaurus.config.js markdown configuration
89
+ *
90
+ * @param pluginOptions - Plugin options from docusaurus.config.js
91
+ * @param context - Context with siteDir
92
+ * @returns Configured remark plugin
93
+ */
94
+ export declare function getRemarkPlugin(pluginOptions: GlossaryPluginOptions, context?: {
95
+ siteDir?: string;
96
+ }): [typeof remarkGlossaryTerms, {
97
+ glossaryPath: string;
98
+ routePath: string;
99
+ siteDir?: string;
100
+ }];
101
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAE7D,OAAO,mBAAmB,MAAM,4BAA4B,CAAC;AAU7D,MAAM,WAAW,qBAAqB;IACpC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,EAAE,CAAC,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,YAAY,EAAE,CAAC;CACvB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+DG;AACH,MAAM,CAAC,OAAO,UAAU,cAAc,CACpC,OAAO,EAAE,WAAW,EACpB,OAAO,GAAE,qBAA0B,GAClC,MAAM,CAgGR;AAGD,eAAO,MAAM,YAAY,4BAAsB,CAAC;AAGhD,OAAO,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AAGhE,OAAO,EACL,oBAAoB,EACpB,uBAAuB,EACvB,sBAAsB,EACtB,KAAK,eAAe,EACpB,KAAK,gBAAgB,GACtB,MAAM,iBAAiB,CAAC;AAEzB;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAC7B,aAAa,EAAE,qBAAqB,EACpC,OAAO,CAAC,EAAE;IAAE,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,GAC7B,CAAC,OAAO,mBAAmB,EAAE;IAAE,YAAY,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAa7F"}
package/dist/index.js CHANGED
@@ -1,159 +1,14 @@
1
- var _a, _b;
2
1
  import path from 'path';
2
+ import { fileURLToPath } from 'url';
3
3
  import fs from 'fs-extra';
4
- import { createRequire } from 'module';
5
4
  import validatePeerDependencies from 'validate-peer-dependencies';
6
5
  import remarkGlossaryTerms from './remark/glossary-terms.js';
7
- // Helper function to compute __dirname lazily when needed
8
- // This avoids webpack bundling issues by not using fileURLToPath at module load time
9
- function getDirname() {
10
- var _a;
11
- // Check if we're in a Node.js environment
12
- if (typeof process === 'undefined' || !((_a = process.versions) === null || _a === void 0 ? void 0 : _a.node)) {
13
- return '';
14
- }
15
- // Use cached value if available
16
- const global = globalThis;
17
- if (global.__dirnameCache) {
18
- return global.__dirnameCache;
19
- }
20
- // Use a lock to prevent race conditions when multiple calls happen concurrently
21
- // This ensures only one execution path computes and sets the cache
22
- if (global.__dirnameComputing) {
23
- // Another call is already computing __dirname, wait and retry
24
- // In practice, this should be rare since module initialization is typically sequential
25
- let retries = 0;
26
- while (global.__dirnameComputing && retries < 10) {
27
- retries++;
28
- // Busy wait with a small delay (not ideal but works for module init)
29
- }
30
- // Return cached value if available after waiting
31
- if (global.__dirnameCache) {
32
- return global.__dirnameCache;
33
- }
34
- // If still computing after retries, return empty to avoid deadlock
35
- return '';
36
- }
37
- // Set computing flag to prevent concurrent execution
38
- global.__dirnameComputing = true;
39
- try {
40
- // In Jest/Babel transformed environment, __filename is available
41
- // @ts-ignore - __filename is available after Babel transforms ES modules to CommonJS
42
- if (typeof __filename !== 'undefined') {
43
- const computedDirname = path.dirname(__filename);
44
- global.__dirnameCache = computedDirname;
45
- return computedDirname;
46
- }
47
- // Check if import.meta.url is available
48
- // Use try-catch to handle cases where import.meta is undefined (e.g., in Jest before transform)
49
- let hasImportMetaUrl = false;
50
- try {
51
- hasImportMetaUrl = typeof import.meta.url !== 'undefined';
52
- }
53
- catch {
54
- // import.meta is undefined (e.g., in Jest environment before Babel transform)
55
- return '';
56
- }
57
- if (!hasImportMetaUrl) {
58
- return '';
59
- }
60
- // Try to compute __dirname using fileURLToPath via createRequire
61
- // This avoids webpack trying to bundle fileURLToPath at module load time
62
- try {
63
- const require = createRequire(import.meta.url);
64
- const urlModule = require('url');
65
- // Check if fileURLToPath is actually a function (webpack may provide a broken polyfill)
66
- if (urlModule && typeof urlModule.fileURLToPath === 'function') {
67
- const __filename = urlModule.fileURLToPath(import.meta.url);
68
- const computedDirname = path.dirname(__filename);
69
- global.__dirnameCache = computedDirname;
70
- return computedDirname;
71
- }
72
- }
73
- catch (error) {
74
- // If webpack provides a broken polyfill or require fails, return empty
75
- // __dirname will be computed when the plugin function is called (server-side only)
76
- return '';
77
- }
78
- return '';
79
- }
80
- finally {
81
- // Always clear the computing flag
82
- global.__dirnameComputing = false;
83
- }
84
- }
85
- // Initialize __dirname at module load time, but handle webpack bundling gracefully
86
- let __dirname = '';
87
- let peerDepsValidated = false;
88
- try {
89
- // Only compute __dirname if we're in Node.js (not during webpack bundling)
90
- if (typeof process !== 'undefined' && ((_a = process.versions) === null || _a === void 0 ? void 0 : _a.node)) {
91
- const global = globalThis;
92
- // Set lock to prevent concurrent getDirname() calls during module init
93
- if (!global.__dirnameComputing) {
94
- global.__dirnameComputing = true;
95
- try {
96
- // In Jest/Babel transformed environment, __filename is available
97
- // @ts-ignore - __filename is available after Babel transforms ES modules to CommonJS
98
- if (typeof __filename !== 'undefined') {
99
- __dirname = path.dirname(__filename);
100
- global.__dirnameCache = __dirname;
101
- validatePeerDependencies(__dirname);
102
- peerDepsValidated = true;
103
- }
104
- else {
105
- // Check if import.meta.url is available - use try-catch since import.meta might be undefined
106
- let hasImportMetaUrl = false;
107
- try {
108
- hasImportMetaUrl = typeof import.meta.url !== 'undefined';
109
- }
110
- catch {
111
- // import.meta is undefined (e.g., in Jest environment before Babel transform)
112
- hasImportMetaUrl = false;
113
- }
114
- if (hasImportMetaUrl) {
115
- const require = createRequire(import.meta.url);
116
- const urlModule = require('url');
117
- // Check if fileURLToPath is actually a function (not a webpack polyfill)
118
- if (urlModule && typeof urlModule.fileURLToPath === 'function') {
119
- const __filename = urlModule.fileURLToPath(import.meta.url);
120
- __dirname = path.dirname(__filename);
121
- global.__dirnameCache = __dirname;
122
- validatePeerDependencies(__dirname);
123
- peerDepsValidated = true;
124
- }
125
- }
126
- }
127
- }
128
- finally {
129
- global.__dirnameComputing = false;
130
- }
131
- }
132
- else {
133
- // Another module init is already computing, use cached value if available
134
- if (global.__dirnameCache) {
135
- __dirname = global.__dirnameCache;
136
- }
137
- }
138
- }
139
- }
140
- catch {
141
- // If initialization fails (e.g., during webpack bundling), __dirname will be empty
142
- // and will be computed lazily via getDirname() when needed
143
- }
144
- // Validate peer dependencies lazily if not already validated
145
- if (!peerDepsValidated && typeof process !== 'undefined' && ((_b = process.versions) === null || _b === void 0 ? void 0 : _b.node)) {
146
- try {
147
- const dirname = getDirname();
148
- if (dirname) {
149
- validatePeerDependencies(dirname);
150
- peerDepsValidated = true;
151
- }
152
- }
153
- catch {
154
- // Ignore validation errors during webpack bundling
155
- }
156
- }
6
+ import { validateGlossaryData, GlossaryValidationError } from './validation.js';
7
+ // Standard ES module directory resolution
8
+ const currentFilePath = fileURLToPath(import.meta.url);
9
+ const currentDir = path.dirname(currentFilePath);
10
+ // Validate peer dependencies at module load time
11
+ validatePeerDependencies(currentDir);
157
12
  /**
158
13
  * Docusaurus Glossary Plugin
159
14
  *
@@ -219,25 +74,38 @@ if (!peerDepsValidated && typeof process !== 'undefined' && ((_b = process.versi
219
74
  * @returns Plugin object
220
75
  */
221
76
  export default function glossaryPlugin(context, options = {}) {
222
- const { glossaryPath = 'glossary/glossary.json', routePath = '/glossary', autoLinkTerms = true, } = options;
223
- let glossaryDataCache = { terms: [] };
77
+ const { glossaryPath = 'glossary/glossary.json', routePath = '/glossary' } = options;
224
78
  return {
225
79
  name: 'docusaurus-plugin-glossary',
226
80
  getClientModules() {
227
- // Compute __dirname if not already set (for webpack bundling compatibility)
228
- const pluginDirname = __dirname || getDirname();
229
- return [path.resolve(pluginDirname, './client/index.js')];
81
+ return [path.resolve(currentDir, './client/index.js')];
230
82
  },
231
83
  async loadContent() {
232
84
  // Load glossary terms from JSON file
233
85
  const glossaryFilePath = path.resolve(context.siteDir, glossaryPath);
234
86
  if (await fs.pathExists(glossaryFilePath)) {
235
- const glossaryData = (await fs.readJson(glossaryFilePath));
236
- glossaryDataCache = glossaryData;
237
- return glossaryData;
87
+ try {
88
+ const rawData = await fs.readJson(glossaryFilePath);
89
+ // Validate glossary data structure
90
+ const validationResult = validateGlossaryData(rawData, { throwOnError: false });
91
+ if (!validationResult.valid) {
92
+ console.warn(`[glossary-plugin] Glossary file has validation errors at ${glossaryFilePath}:`);
93
+ validationResult.errors.forEach(err => {
94
+ console.warn(` - [${err.field}] ${err.message}`);
95
+ });
96
+ console.warn('[glossary-plugin] Proceeding with valid terms only.');
97
+ }
98
+ return validationResult.data;
99
+ }
100
+ catch (error) {
101
+ if (error instanceof GlossaryValidationError) {
102
+ throw error;
103
+ }
104
+ // JSON parsing error
105
+ throw new Error(`Failed to parse glossary file at ${glossaryFilePath}: ${error instanceof Error ? error.message : String(error)}`);
106
+ }
238
107
  }
239
108
  console.warn(`Glossary file not found at ${glossaryFilePath}. Using empty glossary.`);
240
- glossaryDataCache = { terms: [] };
241
109
  return { terms: [] };
242
110
  },
243
111
  async contentLoaded({ content, actions }) {
@@ -246,16 +114,14 @@ export default function glossaryPlugin(context, options = {}) {
246
114
  // Create data file that can be imported by components
247
115
  const glossaryDataPath = await createData('glossary-data.json', JSON.stringify(glossaryContent));
248
116
  // Create a data file for the remark plugin to access glossary terms
249
- const remarkGlossaryDataPath = await createData('remark-glossary-data.json', JSON.stringify({
117
+ await createData('remark-glossary-data.json', JSON.stringify({
250
118
  terms: glossaryContent.terms || [],
251
119
  routePath: routePath,
252
120
  }));
253
121
  // Add glossary page route
254
- // Compute __dirname if not already set (for webpack bundling compatibility)
255
- const pluginDirname = __dirname || getDirname();
256
122
  addRoute({
257
123
  path: routePath,
258
- component: path.join(pluginDirname, 'components/GlossaryPage.js'),
124
+ component: path.join(currentDir, 'components/GlossaryPage.js'),
259
125
  exact: true,
260
126
  modules: {
261
127
  glossaryData: glossaryDataPath,
@@ -268,14 +134,12 @@ export default function glossaryPlugin(context, options = {}) {
268
134
  });
269
135
  },
270
136
  getThemePath() {
271
- // Compute __dirname if not already set (for webpack bundling compatibility)
272
- const pluginDirname = __dirname || getDirname();
273
- return path.resolve(pluginDirname, './theme');
137
+ return path.resolve(currentDir, './theme');
274
138
  },
275
139
  getPathsToWatch() {
276
140
  return [path.resolve(context.siteDir, glossaryPath)];
277
141
  },
278
- async postBuild({ outDir }) {
142
+ async postBuild() {
279
143
  // You can add any post-build steps here if needed
280
144
  console.log('Glossary plugin: Build completed');
281
145
  },
@@ -285,6 +149,8 @@ export default function glossaryPlugin(context, options = {}) {
285
149
  export const remarkPlugin = remarkGlossaryTerms;
286
150
  // Export cache clearing utility
287
151
  export { clearGlossaryCache } from './remark/glossary-terms.js';
152
+ // Export validation utilities
153
+ export { validateGlossaryData, GlossaryValidationError, formatValidationErrors, } from './validation.js';
288
154
  /**
289
155
  * Helper function to get the configured remark plugin
290
156
  * This can be used in docusaurus.config.js markdown configuration
@@ -0,0 +1,97 @@
1
+ import type { Preset, LoadContext } from '@docusaurus/types';
2
+ import type { GlossaryPluginOptions } from './index.js';
3
+ /**
4
+ * Configuration for @docusaurus/plugin-content-docs
5
+ * Using Record<string, unknown> to allow any valid docs options without coupling to specific version
6
+ */
7
+ type DocsConfig = Record<string, unknown> & {
8
+ remarkPlugins?: unknown[];
9
+ };
10
+ /**
11
+ * Configuration for @docusaurus/plugin-content-blog
12
+ * Can be false to disable, or configuration object
13
+ */
14
+ type BlogConfig = false | (Record<string, unknown> & {
15
+ remarkPlugins?: unknown[];
16
+ });
17
+ /**
18
+ * Configuration for @docusaurus/plugin-content-pages
19
+ */
20
+ type PagesConfig = Record<string, unknown> & {
21
+ remarkPlugins?: unknown[];
22
+ };
23
+ /**
24
+ * Configuration for @docusaurus/theme-classic
25
+ */
26
+ type ThemeConfig = Record<string, unknown>;
27
+ /**
28
+ * Configuration for analytics plugins
29
+ */
30
+ type AnalyticsConfig = Record<string, unknown>;
31
+ /**
32
+ * Configuration for @docusaurus/plugin-sitemap
33
+ * Can be false to disable, or configuration object
34
+ */
35
+ type SitemapConfig = false | Record<string, unknown>;
36
+ /**
37
+ * Classic preset options (simplified to avoid direct dependency on @docusaurus/preset-classic)
38
+ * These mirror the options available in the classic preset
39
+ */
40
+ export interface ClassicPresetOptions {
41
+ docs?: DocsConfig;
42
+ blog?: BlogConfig;
43
+ pages?: PagesConfig;
44
+ theme?: ThemeConfig;
45
+ gtag?: AnalyticsConfig;
46
+ googleAnalytics?: AnalyticsConfig;
47
+ googleTagManager?: AnalyticsConfig;
48
+ sitemap?: SitemapConfig;
49
+ debug?: boolean;
50
+ }
51
+ export interface GlossaryPresetOptions extends ClassicPresetOptions {
52
+ glossary?: GlossaryPluginOptions;
53
+ /** Internal: Docusaurus adds this, we need to exclude it from classic options */
54
+ id?: string;
55
+ }
56
+ /**
57
+ * Docusaurus Glossary Preset
58
+ *
59
+ * A preset that extends @docusaurus/preset-classic with automatic glossary functionality.
60
+ * This preset automatically configures the remark plugin for docs and pages, so you don't
61
+ * need to manually add it to remarkPlugins.
62
+ *
63
+ * @example
64
+ * ```javascript
65
+ * export default {
66
+ * presets: [
67
+ * [
68
+ * 'docusaurus-plugin-glossary/preset',
69
+ * {
70
+ * // Glossary options
71
+ * glossary: {
72
+ * glossaryPath: 'glossary/glossary.json',
73
+ * routePath: '/glossary',
74
+ * },
75
+ * // Classic preset options
76
+ * docs: {
77
+ * sidebarPath: './sidebars.js',
78
+ * },
79
+ * blog: {
80
+ * showReadingTime: true,
81
+ * },
82
+ * theme: {
83
+ * customCss: './src/css/custom.css',
84
+ * },
85
+ * },
86
+ * ],
87
+ * ],
88
+ * };
89
+ * ```
90
+ *
91
+ * @param context - Docusaurus context
92
+ * @param options - Preset options including glossary and classic preset options
93
+ * @returns Preset configuration
94
+ */
95
+ export default function preset(context: LoadContext, options?: GlossaryPresetOptions): Preset;
96
+ export {};
97
+ //# sourceMappingURL=preset.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"preset.d.ts","sourceRoot":"","sources":["../src/preset.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAE7D,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAExD;;;GAGG;AACH,KAAK,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG;IAC1C,aAAa,CAAC,EAAE,OAAO,EAAE,CAAC;CAC3B,CAAC;AAEF;;;GAGG;AACH,KAAK,UAAU,GACX,KAAK,GACL,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG;IACzB,aAAa,CAAC,EAAE,OAAO,EAAE,CAAC;CAC3B,CAAC,CAAC;AAEP;;GAEG;AACH,KAAK,WAAW,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG;IAC3C,aAAa,CAAC,EAAE,OAAO,EAAE,CAAC;CAC3B,CAAC;AAEF;;GAEG;AACH,KAAK,WAAW,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAE3C;;GAEG;AACH,KAAK,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAE/C;;;GAGG;AACH,KAAK,aAAa,GAAG,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAErD;;;GAGG;AACH,MAAM,WAAW,oBAAoB;IACnC,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,IAAI,CAAC,EAAE,eAAe,CAAC;IACvB,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC,gBAAgB,CAAC,EAAE,eAAe,CAAC;IACnC,OAAO,CAAC,EAAE,aAAa,CAAC;IACxB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,qBAAsB,SAAQ,oBAAoB;IACjE,QAAQ,CAAC,EAAE,qBAAqB,CAAC;IACjC,iFAAiF;IACjF,EAAE,CAAC,EAAE,MAAM,CAAC;CACb;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,MAAM,CAAC,OAAO,UAAU,MAAM,CAAC,OAAO,EAAE,WAAW,EAAE,OAAO,GAAE,qBAA0B,GAAG,MAAM,CAgGhG"}
package/dist/preset.js CHANGED
@@ -40,9 +40,12 @@ import glossaryPlugin, { getRemarkPlugin } from './index.js';
40
40
  */
41
41
  export default function preset(context, options = {}) {
42
42
  // Explicitly extract glossary and any Docusaurus-added properties that shouldn't go to classic preset
43
- const { glossary = {}, id, ...restOptions } = options;
43
+ const { glossary = {}, id: _id, ...restOptions } = options;
44
44
  // Extract only valid classic preset options
45
- const { docs, blog, pages, theme, gtag, googleAnalytics, googleTagManager, sitemap, debug, } = restOptions;
45
+ // Explicitly type blog and sitemap to preserve union types (can be false)
46
+ const { docs, pages, theme, gtag, googleAnalytics, googleTagManager, debug } = restOptions;
47
+ const blog = restOptions.blog;
48
+ const sitemap = restOptions.sitemap;
46
49
  // Build classic options object with only defined properties
47
50
  const classicOptions = {};
48
51
  if (docs !== undefined)
@@ -63,7 +66,7 @@ export default function preset(context, options = {}) {
63
66
  classicOptions.sitemap = sitemap;
64
67
  if (debug !== undefined)
65
68
  classicOptions.debug = debug;
66
- const { glossaryPath = 'glossary/glossary.json', routePath = '/glossary', } = glossary;
69
+ const { glossaryPath = 'glossary/glossary.json', routePath = '/glossary' } = glossary;
67
70
  // Get the remark plugin configuration
68
71
  const remarkPlugin = getRemarkPlugin({ glossaryPath, routePath }, { siteDir: context.siteDir });
69
72
  // Extend docs configuration with glossary remark plugin
@@ -81,17 +84,14 @@ export default function preset(context, options = {}) {
81
84
  remarkPlugins: [...pagesRemarkPlugins, remarkPlugin],
82
85
  };
83
86
  // Extend blog configuration with glossary remark plugin (optional)
84
- const blogConfig = classicOptions.blog;
85
- let extendedBlogConfig = blogConfig;
86
- if (blogConfig && blogConfig !== false) {
87
- const blogRemarkPlugins = typeof blogConfig === 'object' ? blogConfig.remarkPlugins || [] : [];
88
- extendedBlogConfig =
89
- typeof blogConfig === 'object'
90
- ? {
91
- ...blogConfig,
92
- remarkPlugins: [...blogRemarkPlugins, remarkPlugin],
93
- }
94
- : blogConfig;
87
+ // Use typeof check for proper type narrowing (blog can be false, object, or undefined)
88
+ let extendedBlogConfig = blog;
89
+ if (typeof blog === 'object' && blog !== null) {
90
+ const blogRemarkPlugins = blog.remarkPlugins || [];
91
+ extendedBlogConfig = {
92
+ ...blog,
93
+ remarkPlugins: [...blogRemarkPlugins, remarkPlugin],
94
+ };
95
95
  }
96
96
  // Build the final classic preset options
97
97
  const finalClassicOptions = {};
@@ -115,14 +115,14 @@ export default function preset(context, options = {}) {
115
115
  finalClassicOptions.debug = debug;
116
116
  const plugins = [
117
117
  // Add the glossary plugin first
118
- function glossaryPluginWrapper(context) {
119
- return glossaryPlugin(context, glossary);
118
+ function glossaryPluginWrapper(ctx) {
119
+ return glossaryPlugin(ctx, glossary);
120
120
  },
121
121
  ];
122
122
  // Add classic preset plugins individually
123
123
  if (extendedDocsConfig)
124
124
  plugins.push(['@docusaurus/plugin-content-docs', extendedDocsConfig]);
125
- if (extendedBlogConfig && extendedBlogConfig !== false)
125
+ if (typeof extendedBlogConfig === 'object' && extendedBlogConfig !== null)
126
126
  plugins.push(['@docusaurus/plugin-content-blog', extendedBlogConfig]);
127
127
  if (extendedPagesConfig)
128
128
  plugins.push(['@docusaurus/plugin-content-pages', extendedPagesConfig]);
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Creates a remark plugin that automatically detects and replaces glossary terms in markdown
3
+ *
4
+ * This plugin transforms plain text terms into <GlossaryTerm> JSX elements.
5
+ * The GlossaryTerm component is globally available via the MDXComponents theme wrapper,
6
+ * so no import injection is needed - MDX files can use it without explicit imports.
7
+ *
8
+ * @param {object} options - Plugin options
9
+ * @param {Array} options.terms - Array of glossary term objects with {term, definition}
10
+ * @param {string} options.glossaryPath - Path to glossary JSON file (optional, if terms not provided)
11
+ * @param {string} options.routePath - Route path to glossary page (default: '/glossary')
12
+ * @param {string} options.siteDir - Docusaurus site directory (required if using glossaryPath)
13
+ * @returns {function} Remark plugin function
14
+ */
15
+ export default function remarkGlossaryTerms({ terms, glossaryPath, routePath, siteDir, }?: {
16
+ terms: any[];
17
+ glossaryPath: string;
18
+ routePath: string;
19
+ siteDir: string;
20
+ }): Function;
21
+ /**
22
+ * Clears the glossary cache
23
+ * Useful for testing or when you want to force a reload of glossary data
24
+ *
25
+ * @param {string} [filePath] - Optional specific file path to clear. If not provided, clears entire cache.
26
+ */
27
+ export function clearGlossaryCache(filePath?: string): void;
28
+ //# sourceMappingURL=glossary-terms.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"glossary-terms.d.ts","sourceRoot":"","sources":["../../src/remark/glossary-terms.js"],"names":[],"mappings":"AA+DA;;;;;;;;;;;;;GAaG;AACH,2FANG;IAAuB,KAAK;IACJ,YAAY,EAA5B,MAAM;IACU,SAAS,EAAzB,MAAM;IACU,OAAO,EAAvB,MAAM;CACd,YA8VF;AAED;;;;;GAKG;AACH,8CAFW,MAAM,QAQhB"}
@@ -2,6 +2,60 @@ import { visit } from 'unist-util-visit';
2
2
  import path from 'path';
3
3
  import fs from 'fs';
4
4
 
5
+ /**
6
+ * Simple validation for glossary terms loaded from file
7
+ * Returns only valid terms with required fields
8
+ *
9
+ * @param {unknown} data - The parsed JSON data
10
+ * @param {string} filePath - Path to the file (for error messages)
11
+ * @returns {{ terms: Array<{term: string, definition: string}>, errors: string[] }}
12
+ */
13
+ function validateGlossaryTerms(data, _filePath) {
14
+ const errors = [];
15
+ const validTerms = [];
16
+
17
+ if (data === null || data === undefined) {
18
+ errors.push(`Glossary data is null or undefined`);
19
+ return { terms: [], errors };
20
+ }
21
+
22
+ if (typeof data !== 'object') {
23
+ errors.push(`Glossary data must be an object, got ${typeof data}`);
24
+ return { terms: [], errors };
25
+ }
26
+
27
+ if (!('terms' in data)) {
28
+ errors.push(`Glossary data must contain a "terms" array`);
29
+ return { terms: [], errors };
30
+ }
31
+
32
+ if (!Array.isArray(data.terms)) {
33
+ errors.push(`Field "terms" must be an array, got ${typeof data.terms}`);
34
+ return { terms: [], errors };
35
+ }
36
+
37
+ data.terms.forEach((term, index) => {
38
+ if (term === null || term === undefined || typeof term !== 'object') {
39
+ errors.push(`terms[${index}]: Term must be an object`);
40
+ return;
41
+ }
42
+
43
+ if (typeof term.term !== 'string' || term.term.trim() === '') {
44
+ errors.push(`terms[${index}]: Missing or invalid "term" field`);
45
+ return;
46
+ }
47
+
48
+ if (typeof term.definition !== 'string') {
49
+ errors.push(`terms[${index}]: Missing or invalid "definition" field`);
50
+ return;
51
+ }
52
+
53
+ validTerms.push(term);
54
+ });
55
+
56
+ return { terms: validTerms, errors };
57
+ }
58
+
5
59
  // Cache for glossary data to avoid repeated synchronous file reads
6
60
  // Key: absolute file path, Value: { terms, loadedAt }
7
61
  const glossaryCache = new Map();
@@ -45,8 +99,33 @@ export default function remarkGlossaryTerms({
45
99
  // Consider passing terms directly to avoid this
46
100
  if (fs.existsSync(glossaryFilePath)) {
47
101
  const fileContent = fs.readFileSync(glossaryFilePath, 'utf8');
48
- const glossaryData = JSON.parse(fileContent);
49
- glossaryTerms = glossaryData.terms || [];
102
+ let glossaryData;
103
+ try {
104
+ glossaryData = JSON.parse(fileContent);
105
+ } catch (parseError) {
106
+ console.error(
107
+ `[glossary-plugin] Failed to parse glossary JSON at ${glossaryPath}:`,
108
+ parseError.message
109
+ );
110
+ glossaryCache.set(glossaryFilePath, {
111
+ terms: [],
112
+ loadedAt: now,
113
+ });
114
+ return tree => tree;
115
+ }
116
+
117
+ // Validate glossary data
118
+ const { terms: validTerms, errors } = validateGlossaryTerms(glossaryData, glossaryPath);
119
+
120
+ if (errors.length > 0) {
121
+ console.warn(`[glossary-plugin] Glossary validation errors in ${glossaryPath}:`);
122
+ errors.forEach(err => console.warn(` - ${err}`));
123
+ if (validTerms.length > 0) {
124
+ console.warn(`[glossary-plugin] Proceeding with ${validTerms.length} valid term(s).`);
125
+ }
126
+ }
127
+
128
+ glossaryTerms = validTerms;
50
129
 
51
130
  // Update cache
52
131
  glossaryCache.set(glossaryFilePath, {
@@ -109,14 +188,14 @@ export default function remarkGlossaryTerms({
109
188
  * Recursively replace glossary terms in text
110
189
  * Returns an array of text nodes and MDX components
111
190
  */
112
- function replaceTermsInText(text, position) {
191
+ function replaceTermsInText(text) {
113
192
  if (!text || !sortedTerms.length) {
114
193
  return [{ type: 'text', value: text }];
115
194
  }
116
195
 
117
196
  const result = [];
118
197
  let lastIndex = 0;
119
- let textLower = text.toLowerCase();
198
+ const textLower = text.toLowerCase();
120
199
 
121
200
  // Find all matches
122
201
  const matches = [];
@@ -61,12 +61,23 @@ export default function GlossaryTerm({ term, definition, routePath = '/glossary'
61
61
 
62
62
  useEffect(() => {
63
63
  if (!showTooltip) return;
64
- updatePosition();
64
+
65
+ // Use double requestAnimationFrame to ensure DOM is fully rendered and layout is complete
66
+ // This ensures tooltipRef.current is available and has proper dimensions
67
+ let rafId2;
68
+ const rafId1 = requestAnimationFrame(() => {
69
+ rafId2 = requestAnimationFrame(() => {
70
+ updatePosition();
71
+ });
72
+ });
73
+
65
74
  const onScroll = () => updatePosition();
66
75
  const onResize = () => updatePosition();
67
76
  window.addEventListener('scroll', onScroll, true);
68
77
  window.addEventListener('resize', onResize);
69
78
  return () => {
79
+ cancelAnimationFrame(rafId1);
80
+ if (rafId2) cancelAnimationFrame(rafId2);
70
81
  window.removeEventListener('scroll', onScroll, true);
71
82
  window.removeEventListener('resize', onResize);
72
83
  };
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { render, screen, within } from '@testing-library/react';
2
+ import { render, screen, act } from '@testing-library/react';
3
3
  import userEvent from '@testing-library/user-event';
4
4
  import GlossaryTerm from './index';
5
5
 
@@ -128,6 +128,14 @@ describe('GlossaryTerm', () => {
128
128
  const hasPlacement =
129
129
  tooltip.classList.contains('tooltipTop') || tooltip.classList.contains('tooltipBottom');
130
130
  expect(hasPlacement).toBe(true);
131
+ // Wait for the double requestAnimationFrame position update to complete
132
+ await act(async () => {
133
+ await new Promise(resolve => {
134
+ requestAnimationFrame(() => {
135
+ requestAnimationFrame(resolve);
136
+ });
137
+ });
138
+ });
131
139
  // Inline style should include computed top/left
132
140
  expect(tooltip.style.top).toMatch(/px$/);
133
141
  expect(tooltip.style.left).toMatch(/px$/);
@@ -31,6 +31,9 @@
31
31
  border-radius: 0.5rem;
32
32
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
33
33
  font-size: 0.875rem;
34
+ font-weight: normal;
35
+ font-style: normal;
36
+ text-decoration: none;
34
37
  line-height: 1.4;
35
38
  white-space: normal;
36
39
  max-width: 300px;
@@ -0,0 +1,44 @@
1
+ import type { GlossaryData } from './index.js';
2
+ export interface ValidationError {
3
+ field: string;
4
+ message: string;
5
+ value?: unknown;
6
+ }
7
+ export interface ValidationResult {
8
+ valid: boolean;
9
+ errors: ValidationError[];
10
+ data: GlossaryData;
11
+ }
12
+ /**
13
+ * Validates glossary data structure
14
+ *
15
+ * Ensures the glossary data conforms to the expected schema:
16
+ * - Must be an object with a "terms" array
17
+ * - Each term must have "term" (string) and "definition" (string)
18
+ * - Optional fields: abbreviation (string), relatedTerms (string[]), id (string)
19
+ *
20
+ * @param data - The data to validate
21
+ * @param options - Validation options
22
+ * @param options.throwOnError - If true, throws an error on validation failure (default: true)
23
+ * @returns Validation result with errors and sanitized data
24
+ * @throws Error if data is invalid and throwOnError is true
25
+ */
26
+ export declare function validateGlossaryData(data: unknown, options?: {
27
+ throwOnError?: boolean;
28
+ }): ValidationResult;
29
+ /**
30
+ * Custom error class for glossary validation errors
31
+ * Provides detailed error messages for debugging
32
+ */
33
+ export declare class GlossaryValidationError extends Error {
34
+ readonly errors: ValidationError[];
35
+ constructor(errors: ValidationError[]);
36
+ }
37
+ /**
38
+ * Formats validation errors into a readable string
39
+ *
40
+ * @param errors - Array of validation errors
41
+ * @returns Formatted error message
42
+ */
43
+ export declare function formatValidationErrors(errors: ValidationError[]): string;
44
+ //# sourceMappingURL=validation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../src/validation.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAgB,MAAM,YAAY,CAAC;AAE7D,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,OAAO,CAAC;IACf,MAAM,EAAE,eAAe,EAAE,CAAC;IAC1B,IAAI,EAAE,YAAY,CAAC;CACpB;AAiHD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,OAAO,EACb,OAAO,GAAE;IAAE,YAAY,CAAC,EAAE,OAAO,CAAA;CAAO,GACvC,gBAAgB,CAoGlB;AAED;;;GAGG;AACH,qBAAa,uBAAwB,SAAQ,KAAK;IAChD,SAAgB,MAAM,EAAE,eAAe,EAAE,CAAC;gBAE9B,MAAM,EAAE,eAAe,EAAE;CAWtC;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,eAAe,EAAE,GAAG,MAAM,CAqBxE"}
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Validates a single glossary term object
3
+ *
4
+ * @param term - The term object to validate
5
+ * @param index - The index in the terms array (for error messages)
6
+ * @returns Array of validation errors (empty if valid)
7
+ */
8
+ function validateTerm(term, index) {
9
+ const errors = [];
10
+ const prefix = `terms[${index}]`;
11
+ if (term === null || term === undefined) {
12
+ errors.push({
13
+ field: prefix,
14
+ message: 'Term cannot be null or undefined',
15
+ value: term,
16
+ });
17
+ return errors;
18
+ }
19
+ if (typeof term !== 'object') {
20
+ errors.push({
21
+ field: prefix,
22
+ message: `Term must be an object, got ${typeof term}`,
23
+ value: term,
24
+ });
25
+ return errors;
26
+ }
27
+ const termObj = term;
28
+ // Required: term (string)
29
+ if (!('term' in termObj)) {
30
+ errors.push({
31
+ field: `${prefix}.term`,
32
+ message: 'Missing required field "term"',
33
+ });
34
+ }
35
+ else if (typeof termObj.term !== 'string') {
36
+ errors.push({
37
+ field: `${prefix}.term`,
38
+ message: `Field "term" must be a string, got ${typeof termObj.term}`,
39
+ value: termObj.term,
40
+ });
41
+ }
42
+ else if (termObj.term.trim() === '') {
43
+ errors.push({
44
+ field: `${prefix}.term`,
45
+ message: 'Field "term" cannot be empty',
46
+ value: termObj.term,
47
+ });
48
+ }
49
+ // Required: definition (string)
50
+ if (!('definition' in termObj)) {
51
+ errors.push({
52
+ field: `${prefix}.definition`,
53
+ message: 'Missing required field "definition"',
54
+ });
55
+ }
56
+ else if (typeof termObj.definition !== 'string') {
57
+ errors.push({
58
+ field: `${prefix}.definition`,
59
+ message: `Field "definition" must be a string, got ${typeof termObj.definition}`,
60
+ value: termObj.definition,
61
+ });
62
+ }
63
+ // Optional: abbreviation (string)
64
+ if ('abbreviation' in termObj && termObj.abbreviation !== undefined) {
65
+ if (typeof termObj.abbreviation !== 'string') {
66
+ errors.push({
67
+ field: `${prefix}.abbreviation`,
68
+ message: `Field "abbreviation" must be a string, got ${typeof termObj.abbreviation}`,
69
+ value: termObj.abbreviation,
70
+ });
71
+ }
72
+ }
73
+ // Optional: relatedTerms (string[])
74
+ if ('relatedTerms' in termObj && termObj.relatedTerms !== undefined) {
75
+ if (!Array.isArray(termObj.relatedTerms)) {
76
+ errors.push({
77
+ field: `${prefix}.relatedTerms`,
78
+ message: `Field "relatedTerms" must be an array, got ${typeof termObj.relatedTerms}`,
79
+ value: termObj.relatedTerms,
80
+ });
81
+ }
82
+ else {
83
+ termObj.relatedTerms.forEach((relatedTerm, relatedIndex) => {
84
+ if (typeof relatedTerm !== 'string') {
85
+ errors.push({
86
+ field: `${prefix}.relatedTerms[${relatedIndex}]`,
87
+ message: `Related term must be a string, got ${typeof relatedTerm}`,
88
+ value: relatedTerm,
89
+ });
90
+ }
91
+ });
92
+ }
93
+ }
94
+ // Optional: id (string)
95
+ if ('id' in termObj && termObj.id !== undefined) {
96
+ if (typeof termObj.id !== 'string') {
97
+ errors.push({
98
+ field: `${prefix}.id`,
99
+ message: `Field "id" must be a string, got ${typeof termObj.id}`,
100
+ value: termObj.id,
101
+ });
102
+ }
103
+ }
104
+ return errors;
105
+ }
106
+ /**
107
+ * Validates glossary data structure
108
+ *
109
+ * Ensures the glossary data conforms to the expected schema:
110
+ * - Must be an object with a "terms" array
111
+ * - Each term must have "term" (string) and "definition" (string)
112
+ * - Optional fields: abbreviation (string), relatedTerms (string[]), id (string)
113
+ *
114
+ * @param data - The data to validate
115
+ * @param options - Validation options
116
+ * @param options.throwOnError - If true, throws an error on validation failure (default: true)
117
+ * @returns Validation result with errors and sanitized data
118
+ * @throws Error if data is invalid and throwOnError is true
119
+ */
120
+ export function validateGlossaryData(data, options = {}) {
121
+ const { throwOnError = true } = options;
122
+ const errors = [];
123
+ // Check if data is null or undefined
124
+ if (data === null || data === undefined) {
125
+ errors.push({
126
+ field: 'root',
127
+ message: 'Glossary data cannot be null or undefined',
128
+ value: data,
129
+ });
130
+ if (throwOnError && errors.length > 0) {
131
+ throw new GlossaryValidationError(errors);
132
+ }
133
+ return { valid: false, errors, data: { terms: [] } };
134
+ }
135
+ // Check if data is an object
136
+ if (typeof data !== 'object') {
137
+ errors.push({
138
+ field: 'root',
139
+ message: `Glossary data must be an object, got ${typeof data}`,
140
+ value: data,
141
+ });
142
+ if (throwOnError && errors.length > 0) {
143
+ throw new GlossaryValidationError(errors);
144
+ }
145
+ return { valid: false, errors, data: { terms: [] } };
146
+ }
147
+ const glossaryData = data;
148
+ // Check for terms array
149
+ if (!('terms' in glossaryData)) {
150
+ errors.push({
151
+ field: 'terms',
152
+ message: 'Glossary data must contain a "terms" array',
153
+ });
154
+ if (throwOnError && errors.length > 0) {
155
+ throw new GlossaryValidationError(errors);
156
+ }
157
+ return { valid: false, errors, data: { terms: [] } };
158
+ }
159
+ if (!Array.isArray(glossaryData.terms)) {
160
+ errors.push({
161
+ field: 'terms',
162
+ message: `Field "terms" must be an array, got ${typeof glossaryData.terms}`,
163
+ value: glossaryData.terms,
164
+ });
165
+ if (throwOnError && errors.length > 0) {
166
+ throw new GlossaryValidationError(errors);
167
+ }
168
+ return { valid: false, errors, data: { terms: [] } };
169
+ }
170
+ // Validate each term
171
+ const validTerms = [];
172
+ glossaryData.terms.forEach((term, index) => {
173
+ const termErrors = validateTerm(term, index);
174
+ if (termErrors.length > 0) {
175
+ errors.push(...termErrors);
176
+ }
177
+ else {
178
+ // Term is valid, add to valid terms
179
+ validTerms.push(term);
180
+ }
181
+ });
182
+ // Check for duplicate terms
183
+ const termNames = new Map();
184
+ validTerms.forEach((term, index) => {
185
+ const lowerName = term.term.toLowerCase();
186
+ if (termNames.has(lowerName)) {
187
+ errors.push({
188
+ field: `terms[${index}].term`,
189
+ message: `Duplicate term "${term.term}" (first occurrence at index ${termNames.get(lowerName)})`,
190
+ value: term.term,
191
+ });
192
+ }
193
+ else {
194
+ termNames.set(lowerName, index);
195
+ }
196
+ });
197
+ if (throwOnError && errors.length > 0) {
198
+ throw new GlossaryValidationError(errors);
199
+ }
200
+ return {
201
+ valid: errors.length === 0,
202
+ errors,
203
+ data: { terms: validTerms },
204
+ };
205
+ }
206
+ /**
207
+ * Custom error class for glossary validation errors
208
+ * Provides detailed error messages for debugging
209
+ */
210
+ export class GlossaryValidationError extends Error {
211
+ constructor(errors) {
212
+ const message = formatValidationErrors(errors);
213
+ super(message);
214
+ this.name = 'GlossaryValidationError';
215
+ this.errors = errors;
216
+ // Maintains proper stack trace for where error was thrown (V8 engines)
217
+ if (Error.captureStackTrace) {
218
+ Error.captureStackTrace(this, GlossaryValidationError);
219
+ }
220
+ }
221
+ }
222
+ /**
223
+ * Formats validation errors into a readable string
224
+ *
225
+ * @param errors - Array of validation errors
226
+ * @returns Formatted error message
227
+ */
228
+ export function formatValidationErrors(errors) {
229
+ if (errors.length === 0) {
230
+ return 'No validation errors';
231
+ }
232
+ const header = `Glossary validation failed with ${errors.length} error${errors.length > 1 ? 's' : ''}:`;
233
+ const errorList = errors
234
+ .map((err, index) => {
235
+ let msg = ` ${index + 1}. [${err.field}] ${err.message}`;
236
+ if (err.value !== undefined) {
237
+ const valueStr = typeof err.value === 'object' ? JSON.stringify(err.value) : String(err.value);
238
+ // Truncate long values
239
+ const truncated = valueStr.length > 50 ? valueStr.substring(0, 50) + '...' : valueStr;
240
+ msg += ` (got: ${truncated})`;
241
+ }
242
+ return msg;
243
+ })
244
+ .join('\n');
245
+ return `${header}\n${errorList}`;
246
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docusaurus-plugin-glossary",
3
- "version": "3.0.0",
3
+ "version": "3.0.2",
4
4
  "description": "A Docusaurus plugin for creating and managing glossary terms with auto-generated pages and inline tooltips",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -38,10 +38,13 @@
38
38
  "example:build": "npm --prefix examples/docusaurus-v3 run build",
39
39
  "example:serve": "npm --prefix examples/docusaurus-v3 run serve",
40
40
  "example:clear": "npm --prefix examples/docusaurus-v3 run clear",
41
+ "prepare": "husky",
41
42
  "prepublishOnly": "npm run build && npm test",
42
43
  "version": "npm version",
43
44
  "format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css,md}\"",
44
- "format:check": "prettier --check \"**/*.{js,jsx,ts,tsx,json,css,md}\""
45
+ "format:check": "prettier --check \"**/*.{js,jsx,ts,tsx,json,css,md}\"",
46
+ "lint": "eslint \"src/**/*.{js,jsx,ts,tsx}\" \"__tests__/**/*.js\"",
47
+ "lint:fix": "eslint \"src/**/*.{js,jsx,ts,tsx}\" \"__tests__/**/*.js\" --fix"
45
48
  },
46
49
  "keywords": [
47
50
  "docusaurus",
@@ -73,12 +76,18 @@
73
76
  "validate-peer-dependencies": "^2.2.0"
74
77
  },
75
78
  "engines": {
76
- "node": ">=16.14"
79
+ "node": ">=18.0"
77
80
  },
78
81
  "devDependencies": {
79
82
  "@babel/preset-env": "^7.28.5",
80
83
  "@babel/preset-react": "^7.28.5",
81
84
  "@babel/preset-typescript": "^7.28.5",
85
+ "@eslint/js": "^9.28.0",
86
+ "@typescript-eslint/eslint-plugin": "^8.33.0",
87
+ "@typescript-eslint/parser": "^8.33.0",
88
+ "eslint": "^9.28.0",
89
+ "eslint-plugin-react": "^7.37.5",
90
+ "eslint-plugin-react-hooks": "^5.2.0",
82
91
  "@docusaurus/tsconfig": "^3.9.2",
83
92
  "@docusaurus/types": "^3.9.2",
84
93
  "@playwright/test": "^1.56.1",
@@ -89,9 +98,20 @@
89
98
  "identity-obj-proxy": "^3.0.0",
90
99
  "jest": "^30.2.0",
91
100
  "jest-environment-jsdom": "^30.2.0",
101
+ "husky": "^9.1.7",
102
+ "lint-staged": "^15.5.1",
92
103
  "prettier": "^3.6.2",
93
104
  "typescript": "^5.7.3"
94
105
  },
106
+ "lint-staged": {
107
+ "*.{js,jsx,ts,tsx}": [
108
+ "eslint --fix",
109
+ "prettier --write"
110
+ ],
111
+ "*.{json,css,md}": [
112
+ "prettier --write"
113
+ ]
114
+ },
95
115
  "browser": {
96
116
  "path": false,
97
117
  "url": false,