docusaurus-plugin-glossary 2.1.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 +4 -0
- package/dist/components/GlossaryPage.js +2 -2
- package/dist/components/GlossaryPage.test.js +1 -1
- package/dist/index.d.ts +101 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +35 -169
- package/dist/preset.d.ts +97 -0
- package/dist/preset.d.ts.map +1 -0
- package/dist/preset.js +19 -19
- package/dist/remark/glossary-terms.d.ts +28 -0
- package/dist/remark/glossary-terms.d.ts.map +1 -0
- package/dist/remark/glossary-terms.js +83 -4
- package/dist/theme/GlossaryTerm/index.js +12 -1
- package/dist/theme/GlossaryTerm/index.test.js +9 -1
- package/dist/theme/GlossaryTerm/styles.module.css +3 -0
- package/dist/validation.d.ts +44 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +246 -0
- package/package.json +23 -3
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
|
-
|
|
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(() => {
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
-
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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'
|
|
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
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
package/dist/preset.d.ts
ADDED
|
@@ -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
|
-
|
|
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'
|
|
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
|
-
|
|
85
|
-
let extendedBlogConfig =
|
|
86
|
-
if (
|
|
87
|
-
const blogRemarkPlugins =
|
|
88
|
-
extendedBlogConfig =
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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(
|
|
119
|
-
return glossaryPlugin(
|
|
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 !==
|
|
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]);
|
|
@@ -138,8 +138,8 @@ export default function preset(context, options = {}) {
|
|
|
138
138
|
plugins.push(['@docusaurus/plugin-debug', {}]);
|
|
139
139
|
return {
|
|
140
140
|
themes: [
|
|
141
|
-
//
|
|
142
|
-
'@docusaurus/theme-classic',
|
|
141
|
+
// Pass theme options (including customCss) to theme-classic
|
|
142
|
+
['@docusaurus/theme-classic', theme || {}],
|
|
143
143
|
],
|
|
144
144
|
plugins,
|
|
145
145
|
};
|
|
@@ -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
|
-
|
|
49
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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$/);
|
|
@@ -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
|
+
"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": ">=
|
|
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,
|