docusaurus-plugin-glossary 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,320 @@
1
+ # docusaurus-plugin-glossary
2
+
3
+ A comprehensive Docusaurus plugin that provides glossary functionality with an auto-generated glossary page, searchable terms, and inline term tooltips.
4
+
5
+ ## Features
6
+
7
+ - **Auto-generated Glossary Page**: Displays all terms alphabetically with letter navigation
8
+ - **Search Functionality**: Real-time search across terms and definitions
9
+ - **GlossaryTerm Component**: Inline component for linking terms with tooltip previews
10
+ - **Responsive Design**: Mobile-friendly UI with dark mode support
11
+ - **Related Terms**: Link between related glossary terms
12
+ - **Abbreviation Support**: Display full form of abbreviated terms
13
+ - **Customizable**: Configure glossary path and route
14
+
15
+ ## Installation
16
+
17
+ ### For Local Use (Same Repository)
18
+
19
+ 1. Copy the plugin directory to your Docusaurus site:
20
+
21
+ ```
22
+ src/plugins/docusaurus-plugin-glossary/
23
+ ```
24
+
25
+ 2. Add the plugin to your `docusaurus.config.js`:
26
+ ```javascript
27
+ plugins: [
28
+ [
29
+ require.resolve('./src/plugins/docusaurus-plugin-glossary'),
30
+ {
31
+ glossaryPath: 'glossary/glossary.json', // optional, default: 'glossary/glossary.json'
32
+ routePath: '/glossary', // optional, default: '/glossary'
33
+ },
34
+ ],
35
+ ];
36
+ ```
37
+
38
+ ### For Separate Package (To Publish)
39
+
40
+ To publish this as a separate npm package:
41
+
42
+ 1. Create a new directory for the package:
43
+
44
+ ```bash
45
+ mkdir docusaurus-plugin-glossary
46
+ cd docusaurus-plugin-glossary
47
+ ```
48
+
49
+ 2. Copy the plugin files:
50
+
51
+ ```
52
+ docusaurus-plugin-glossary/
53
+ ├── index.js
54
+ ├── components/
55
+ │ ├── GlossaryPage.js
56
+ │ └── GlossaryPage.module.css
57
+ ├── theme/
58
+ │ └── GlossaryTerm/
59
+ │ ├── index.js
60
+ │ └── styles.module.css
61
+ ├── package.json
62
+ └── README.md
63
+ ```
64
+
65
+ 3. Create a `package.json`:
66
+
67
+ ```json
68
+ {
69
+ "name": "docusaurus-plugin-glossary",
70
+ "version": "1.0.0",
71
+ "description": "A Docusaurus plugin for creating and managing glossary terms",
72
+ "main": "index.js",
73
+ "keywords": ["docusaurus", "glossary", "plugin", "documentation"],
74
+ "peerDependencies": {
75
+ "@docusaurus/core": "^3.0.0",
76
+ "react": "^18.0.0",
77
+ "react-dom": "^18.0.0"
78
+ },
79
+ "dependencies": {
80
+ "fs-extra": "^11.0.0"
81
+ }
82
+ }
83
+ ```
84
+
85
+ 4. Publish to npm:
86
+
87
+ ```bash
88
+ npm publish
89
+ ```
90
+
91
+ 5. Install in your Docusaurus site:
92
+
93
+ ```bash
94
+ npm install docusaurus-plugin-glossary
95
+ ```
96
+
97
+ 6. Add to your `docusaurus.config.js`:
98
+ ```javascript
99
+ plugins: [
100
+ [
101
+ 'docusaurus-plugin-glossary',
102
+ {
103
+ glossaryPath: 'glossary/glossary.json',
104
+ routePath: '/glossary',
105
+ },
106
+ ],
107
+ ];
108
+ ```
109
+
110
+ ## Usage
111
+
112
+ ### 1. Create a Glossary Data File
113
+
114
+ Create a JSON file at `glossary/glossary.json` (or your configured path):
115
+
116
+ ```json
117
+ {
118
+ "description": "A collection of technical terms and their definitions",
119
+ "terms": [
120
+ {
121
+ "term": "API",
122
+ "abbreviation": "Application Programming Interface",
123
+ "definition": "A set of rules and protocols that allows different software applications to communicate with each other.",
124
+ "relatedTerms": ["REST", "GraphQL"]
125
+ },
126
+ {
127
+ "term": "REST",
128
+ "abbreviation": "Representational State Transfer",
129
+ "definition": "An architectural style for designing networked applications.",
130
+ "relatedTerms": ["API", "HTTP"]
131
+ }
132
+ ]
133
+ }
134
+ ```
135
+
136
+ ### 2. Glossary Data Structure
137
+
138
+ Each term object can include:
139
+
140
+ - `term` (required): The glossary term
141
+ - `definition` (required): The term's definition
142
+ - `abbreviation` (optional): The full form if the term is an abbreviation
143
+ - `relatedTerms` (optional): Array of related term names
144
+ - `id` (optional): Custom ID for linking (auto-generated from term if not provided)
145
+
146
+ ### 3. Using the GlossaryTerm Component
147
+
148
+ Import and use the `GlossaryTerm` component in your MDX files:
149
+
150
+ ```jsx
151
+ import GlossaryTerm from '@theme/GlossaryTerm';
152
+
153
+ This website uses an <GlossaryTerm term="API" definition="Application Programming Interface">API</GlossaryTerm> to fetch data.
154
+
155
+ // Or with default display (term name):
156
+ We use <GlossaryTerm term="REST" definition="Representational State Transfer" /> for our web services.
157
+ ```
158
+
159
+ The component features:
160
+
161
+ - Dotted underline styling
162
+ - Tooltip showing definition on hover
163
+ - Link to full glossary page entry
164
+ - Accessible with keyboard navigation
165
+
166
+ ### 4. Accessing the Glossary Page
167
+
168
+ The glossary page is automatically available at `/glossary` (or your configured `routePath`).
169
+
170
+ Features:
171
+
172
+ - Alphabetical grouping with letter navigation
173
+ - Real-time search
174
+ - Related terms linking
175
+ - Responsive design
176
+ - Dark mode support
177
+
178
+ ## Configuration Options
179
+
180
+ | Option | Type | Default | Description |
181
+ | -------------- | ------ | -------------------------- | ----------------------------------------------------- |
182
+ | `glossaryPath` | string | `'glossary/glossary.json'` | Path to glossary JSON file relative to site directory |
183
+ | `routePath` | string | `'/glossary'` | URL path for glossary page |
184
+
185
+ ## Customization
186
+
187
+ ### Styling
188
+
189
+ The plugin uses CSS modules for styling. You can override styles by:
190
+
191
+ 1. Creating custom CSS in your site's `src/css/custom.css`:
192
+
193
+ ```css
194
+ /* Override glossary term styles */
195
+ .glossaryTermWrapper .glossaryTerm {
196
+ border-bottom-color: #your-color;
197
+ }
198
+
199
+ /* Override tooltip styles */
200
+ .glossaryTermWrapper .tooltip {
201
+ background: #your-background;
202
+ }
203
+ ```
204
+
205
+ 2. For advanced customization, you can swizzle the components:
206
+
207
+ ```bash
208
+ npm run swizzle docusaurus-plugin-glossary GlossaryPage -- --wrap
209
+ npm run swizzle docusaurus-plugin-glossary GlossaryTerm -- --wrap
210
+ ```
211
+
212
+ ### Adding to Navbar
213
+
214
+ To add the glossary to your navbar, update your `docusaurus.config.js`:
215
+
216
+ ```javascript
217
+ themeConfig: {
218
+ navbar: {
219
+ items: [
220
+ {to: '/glossary', label: 'Glossary', position: 'left'},
221
+ // ... other items
222
+ ],
223
+ },
224
+ }
225
+ ```
226
+
227
+ ## Examples
228
+
229
+ ### Example 1: Technical Documentation
230
+
231
+ ```json
232
+ {
233
+ "description": "Technical terms used in our documentation",
234
+ "terms": [
235
+ {
236
+ "term": "Webhook",
237
+ "definition": "An HTTP callback that occurs when something happens; a simple event-notification via HTTP POST.",
238
+ "relatedTerms": ["API", "HTTP"]
239
+ }
240
+ ]
241
+ }
242
+ ```
243
+
244
+ ### Example 2: Using in MDX
245
+
246
+ ```mdx
247
+ ---
248
+ title: API Documentation
249
+ ---
250
+
251
+ import GlossaryTerm from '@theme/GlossaryTerm';
252
+
253
+ # Getting Started with Our API
254
+
255
+ Our <GlossaryTerm term="API" definition="Application Programming Interface" />
256
+ uses <GlossaryTerm term="REST" definition="Representational State Transfer">RESTful</GlossaryTerm>
257
+ principles to provide a simple and consistent interface.
258
+ ```
259
+
260
+ ## Development
261
+
262
+ ### File Structure
263
+
264
+ ```
265
+ docusaurus-plugin-glossary/
266
+ ├── index.js # Main plugin file
267
+ ├── components/
268
+ │ ├── GlossaryPage.js # Glossary page component
269
+ │ └── GlossaryPage.module.css # Glossary page styles
270
+ ├── theme/
271
+ │ └── GlossaryTerm/
272
+ │ ├── index.js # Term component
273
+ │ └── styles.module.css # Term styles
274
+ └── README.md
275
+ ```
276
+
277
+ ### Plugin Lifecycle
278
+
279
+ 1. **loadContent**: Reads glossary JSON file
280
+ 2. **contentLoaded**: Creates data file and adds route
281
+ 3. **getThemePath**: Exposes theme components
282
+ 4. **getPathsToWatch**: Watches glossary file for changes
283
+
284
+ ## Troubleshooting
285
+
286
+ ### Glossary page returns 404
287
+
288
+ - Ensure the plugin is properly configured in `docusaurus.config.js`
289
+ - Check that the `routePath` doesn't conflict with existing routes
290
+ - Run `npm run clear` to clear Docusaurus cache
291
+
292
+ ### Glossary terms not showing
293
+
294
+ - Verify `glossary/glossary.json` exists at the correct path
295
+ - Check JSON syntax is valid
296
+ - Ensure `terms` array is properly formatted
297
+
298
+ ### GlossaryTerm component not found
299
+
300
+ - Make sure you're importing from `@theme/GlossaryTerm`
301
+ - Try clearing cache with `npm run clear`
302
+ - Restart dev server
303
+
304
+ ### Styles not applying
305
+
306
+ - Check for CSS conflicts in your custom CSS
307
+ - Ensure CSS modules are loading correctly
308
+ - Try clearing cache and rebuilding
309
+
310
+ ## License
311
+
312
+ MIT
313
+
314
+ ## Contributing
315
+
316
+ Contributions are welcome! Please open an issue or submit a pull request.
317
+
318
+ ## Credits
319
+
320
+ Built for Docusaurus v3.x
@@ -0,0 +1,138 @@
1
+ import React, { useState, useMemo } from 'react';
2
+ import Layout from '@theme/Layout';
3
+ import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
4
+ import styles from './GlossaryPage.module.css';
5
+
6
+ /**
7
+ * Groups glossary terms by their first letter
8
+ */
9
+ function groupTermsByLetter(terms) {
10
+ const grouped = {};
11
+
12
+ terms.forEach(term => {
13
+ const firstLetter = term.term.charAt(0).toUpperCase();
14
+ if (!grouped[firstLetter]) {
15
+ grouped[firstLetter] = [];
16
+ }
17
+ grouped[firstLetter].push(term);
18
+ });
19
+
20
+ // Sort each group alphabetically
21
+ Object.keys(grouped).forEach(letter => {
22
+ grouped[letter].sort((a, b) => a.term.localeCompare(b.term));
23
+ });
24
+
25
+ return grouped;
26
+ }
27
+
28
+ /**
29
+ * GlossaryPage component - displays all glossary terms
30
+ */
31
+ export default function GlossaryPage({ glossaryData }) {
32
+ const { siteConfig } = useDocusaurusContext();
33
+ const [searchTerm, setSearchTerm] = useState('');
34
+
35
+ const terms = glossaryData?.terms || [];
36
+
37
+ // Filter terms based on search
38
+ const filteredTerms = useMemo(() => {
39
+ if (!searchTerm) return terms;
40
+
41
+ const lowerSearch = searchTerm.toLowerCase();
42
+ return terms.filter(
43
+ term =>
44
+ term.term.toLowerCase().includes(lowerSearch) ||
45
+ term.definition.toLowerCase().includes(lowerSearch)
46
+ );
47
+ }, [terms, searchTerm]);
48
+
49
+ // Group terms by first letter
50
+ const groupedTerms = useMemo(() => {
51
+ return groupTermsByLetter(filteredTerms);
52
+ }, [filteredTerms]);
53
+
54
+ const letters = Object.keys(groupedTerms).sort();
55
+
56
+ return (
57
+ <Layout title="Glossary" description="A glossary of terms and definitions">
58
+ <div className={styles.glossaryContainer}>
59
+ <header className={styles.glossaryHeader}>
60
+ <h1>Glossary</h1>
61
+ <p className={styles.glossaryDescription}>
62
+ {glossaryData?.description || 'A collection of terms and their definitions'}
63
+ </p>
64
+
65
+ <div className={styles.searchContainer}>
66
+ <input
67
+ type="text"
68
+ placeholder="Search terms..."
69
+ className={styles.searchInput}
70
+ value={searchTerm}
71
+ onChange={e => setSearchTerm(e.target.value)}
72
+ />
73
+ </div>
74
+ </header>
75
+
76
+ {filteredTerms.length === 0 ? (
77
+ <div className={styles.noResults}>
78
+ <p>No terms found matching "{searchTerm}"</p>
79
+ </div>
80
+ ) : (
81
+ <div className={styles.glossaryContent}>
82
+ {/* Letter navigation */}
83
+ <nav className={styles.letterNav}>
84
+ {letters.map(letter => (
85
+ <a key={letter} href={`#letter-${letter}`} className={styles.letterLink}>
86
+ {letter}
87
+ </a>
88
+ ))}
89
+ </nav>
90
+
91
+ {/* Terms grouped by letter */}
92
+ {letters.map(letter => (
93
+ <section key={letter} id={`letter-${letter}`} className={styles.letterSection}>
94
+ <h2 className={styles.letterHeading}>{letter}</h2>
95
+ <dl className={styles.termList}>
96
+ {groupedTerms[letter].map((term, index) => (
97
+ <div
98
+ key={`${letter}-${index}`}
99
+ className={styles.termItem}
100
+ id={term.id || term.term.toLowerCase().replace(/\s+/g, '-')}
101
+ >
102
+ <dt className={styles.termName}>
103
+ {term.term}
104
+ {term.abbreviation && (
105
+ <span className={styles.abbreviation}> ({term.abbreviation})</span>
106
+ )}
107
+ </dt>
108
+ <dd className={styles.termDefinition}>
109
+ {term.definition}
110
+ {term.relatedTerms && term.relatedTerms.length > 0 && (
111
+ <div className={styles.relatedTerms}>
112
+ <strong>Related terms:</strong>{' '}
113
+ {term.relatedTerms.map((related, idx) => (
114
+ <React.Fragment key={idx}>
115
+ {idx > 0 && ', '}
116
+ <a href={`#${related.toLowerCase().replace(/\s+/g, '-')}`}>
117
+ {related}
118
+ </a>
119
+ </React.Fragment>
120
+ ))}
121
+ </div>
122
+ )}
123
+ </dd>
124
+ </div>
125
+ ))}
126
+ </dl>
127
+ </section>
128
+ ))}
129
+ </div>
130
+ )}
131
+
132
+ <footer className={styles.glossaryFooter}>
133
+ <p>Total terms: {terms.length}</p>
134
+ </footer>
135
+ </div>
136
+ </Layout>
137
+ );
138
+ }
@@ -0,0 +1,189 @@
1
+ .glossaryContainer {
2
+ max-width: 1200px;
3
+ margin: 0 auto;
4
+ padding: 2rem 1rem;
5
+ }
6
+
7
+ .glossaryHeader {
8
+ text-align: center;
9
+ margin-bottom: 3rem;
10
+ }
11
+
12
+ .glossaryHeader h1 {
13
+ font-size: 2.5rem;
14
+ margin-bottom: 1rem;
15
+ }
16
+
17
+ .glossaryDescription {
18
+ font-size: 1.1rem;
19
+ color: var(--ifm-color-emphasis-700);
20
+ margin-bottom: 2rem;
21
+ }
22
+
23
+ .searchContainer {
24
+ max-width: 500px;
25
+ margin: 0 auto;
26
+ }
27
+
28
+ .searchInput {
29
+ width: 100%;
30
+ padding: 0.75rem 1rem;
31
+ font-size: 1rem;
32
+ border: 2px solid var(--ifm-color-emphasis-300);
33
+ border-radius: 0.5rem;
34
+ background: var(--ifm-background-color);
35
+ color: var(--ifm-font-color-base);
36
+ transition: border-color 0.2s;
37
+ }
38
+
39
+ .searchInput:focus {
40
+ outline: none;
41
+ border-color: var(--ifm-color-primary);
42
+ }
43
+
44
+ .noResults {
45
+ text-align: center;
46
+ padding: 3rem 1rem;
47
+ color: var(--ifm-color-emphasis-600);
48
+ }
49
+
50
+ .glossaryContent {
51
+ margin-top: 2rem;
52
+ }
53
+
54
+ .letterNav {
55
+ display: flex;
56
+ flex-wrap: wrap;
57
+ gap: 0.5rem;
58
+ justify-content: center;
59
+ margin-bottom: 2rem;
60
+ padding: 1rem;
61
+ background: var(--ifm-color-emphasis-100);
62
+ border-radius: 0.5rem;
63
+ }
64
+
65
+ .letterLink {
66
+ display: inline-flex;
67
+ align-items: center;
68
+ justify-content: center;
69
+ width: 2rem;
70
+ height: 2rem;
71
+ font-weight: bold;
72
+ color: var(--ifm-color-primary);
73
+ text-decoration: none;
74
+ border-radius: 0.25rem;
75
+ transition: all 0.2s;
76
+ }
77
+
78
+ .letterLink:hover {
79
+ background: var(--ifm-color-primary);
80
+ color: white;
81
+ }
82
+
83
+ .letterSection {
84
+ margin-bottom: 3rem;
85
+ }
86
+
87
+ .letterHeading {
88
+ font-size: 2rem;
89
+ color: var(--ifm-color-primary);
90
+ border-bottom: 2px solid var(--ifm-color-emphasis-300);
91
+ padding-bottom: 0.5rem;
92
+ margin-bottom: 1.5rem;
93
+ }
94
+
95
+ .termList {
96
+ display: grid;
97
+ gap: 1.5rem;
98
+ }
99
+
100
+ .termItem {
101
+ padding: 1.5rem;
102
+ background: var(--ifm-card-background-color);
103
+ border: 1px solid var(--ifm-color-emphasis-200);
104
+ border-radius: 0.5rem;
105
+ transition: all 0.2s;
106
+ }
107
+
108
+ .termItem:hover {
109
+ border-color: var(--ifm-color-primary);
110
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
111
+ }
112
+
113
+ .termName {
114
+ font-size: 1.25rem;
115
+ font-weight: bold;
116
+ color: var(--ifm-heading-color);
117
+ margin-bottom: 0.75rem;
118
+ }
119
+
120
+ .abbreviation {
121
+ font-size: 0.9rem;
122
+ color: var(--ifm-color-emphasis-600);
123
+ font-weight: normal;
124
+ }
125
+
126
+ .termDefinition {
127
+ margin: 0;
128
+ line-height: 1.6;
129
+ color: var(--ifm-color-emphasis-800);
130
+ }
131
+
132
+ .relatedTerms {
133
+ margin-top: 1rem;
134
+ padding-top: 1rem;
135
+ border-top: 1px solid var(--ifm-color-emphasis-200);
136
+ font-size: 0.9rem;
137
+ color: var(--ifm-color-emphasis-700);
138
+ }
139
+
140
+ .relatedTerms a {
141
+ color: var(--ifm-color-primary);
142
+ text-decoration: none;
143
+ }
144
+
145
+ .relatedTerms a:hover {
146
+ text-decoration: underline;
147
+ }
148
+
149
+ .glossaryFooter {
150
+ margin-top: 3rem;
151
+ padding-top: 2rem;
152
+ border-top: 1px solid var(--ifm-color-emphasis-200);
153
+ text-align: center;
154
+ color: var(--ifm-color-emphasis-600);
155
+ }
156
+
157
+ /* Dark mode adjustments */
158
+ [data-theme='dark'] .termItem {
159
+ background: var(--ifm-background-surface-color);
160
+ }
161
+
162
+ [data-theme='dark'] .searchInput {
163
+ background: var(--ifm-background-surface-color);
164
+ }
165
+
166
+ /* Responsive design */
167
+ @media (max-width: 768px) {
168
+ .glossaryHeader h1 {
169
+ font-size: 2rem;
170
+ }
171
+
172
+ .letterNav {
173
+ gap: 0.25rem;
174
+ }
175
+
176
+ .letterLink {
177
+ width: 1.75rem;
178
+ height: 1.75rem;
179
+ font-size: 0.9rem;
180
+ }
181
+
182
+ .termItem {
183
+ padding: 1rem;
184
+ }
185
+
186
+ .termName {
187
+ font-size: 1.1rem;
188
+ }
189
+ }
@@ -0,0 +1,205 @@
1
+ import React from 'react';
2
+ import { render, screen, within } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import GlossaryPage from './GlossaryPage';
5
+
6
+ // Mock CSS modules
7
+ jest.mock('./GlossaryPage.module.css', () => ({
8
+ glossaryContainer: 'glossaryContainer',
9
+ glossaryHeader: 'glossaryHeader',
10
+ glossaryDescription: 'glossaryDescription',
11
+ searchContainer: 'searchContainer',
12
+ searchInput: 'searchInput',
13
+ noResults: 'noResults',
14
+ glossaryContent: 'glossaryContent',
15
+ letterNav: 'letterNav',
16
+ letterLink: 'letterLink',
17
+ letterSection: 'letterSection',
18
+ letterHeading: 'letterHeading',
19
+ termList: 'termList',
20
+ termItem: 'termItem',
21
+ termName: 'termName',
22
+ abbreviation: 'abbreviation',
23
+ termDefinition: 'termDefinition',
24
+ relatedTerms: 'relatedTerms',
25
+ glossaryFooter: 'glossaryFooter',
26
+ }));
27
+
28
+ const mockGlossaryData = {
29
+ description: 'Test glossary',
30
+ terms: [
31
+ {
32
+ id: 'api',
33
+ term: 'API',
34
+ definition: 'Application Programming Interface',
35
+ abbreviation: 'API',
36
+ relatedTerms: ['REST'],
37
+ },
38
+ {
39
+ id: 'rest',
40
+ term: 'REST',
41
+ definition: 'Representational State Transfer',
42
+ abbreviation: 'REST',
43
+ relatedTerms: ['API'],
44
+ },
45
+ {
46
+ id: 'ml',
47
+ term: 'Machine Learning',
48
+ definition: 'A type of artificial intelligence',
49
+ },
50
+ ],
51
+ };
52
+
53
+ describe('GlossaryPage', () => {
54
+ it('should render glossary with all terms', () => {
55
+ render(<GlossaryPage glossaryData={mockGlossaryData} />);
56
+
57
+ expect(screen.getByText('Glossary')).toBeInTheDocument();
58
+ expect(screen.getByText('Test glossary')).toBeInTheDocument();
59
+ // Use getAllBy since terms can appear multiple times (in term names and related links)
60
+ expect(screen.getAllByText('API').length).toBeGreaterThan(0);
61
+ expect(screen.getAllByText('REST').length).toBeGreaterThan(0);
62
+ expect(screen.getByText('Machine Learning')).toBeInTheDocument();
63
+ });
64
+
65
+ it('should show search input', () => {
66
+ render(<GlossaryPage glossaryData={mockGlossaryData} />);
67
+
68
+ const searchInput = screen.getByPlaceholderText('Search terms...');
69
+ expect(searchInput).toBeInTheDocument();
70
+ });
71
+
72
+ it('should filter terms by search query', async () => {
73
+ const user = userEvent.setup();
74
+ render(<GlossaryPage glossaryData={mockGlossaryData} />);
75
+
76
+ const searchInput = screen.getByPlaceholderText('Search terms...');
77
+ await user.type(searchInput, 'API');
78
+
79
+ expect(screen.getByText('API')).toBeInTheDocument();
80
+ expect(screen.queryByText('Machine Learning')).not.toBeInTheDocument();
81
+ });
82
+
83
+ it('should filter by definition text', async () => {
84
+ const user = userEvent.setup();
85
+ render(<GlossaryPage glossaryData={mockGlossaryData} />);
86
+
87
+ const searchInput = screen.getByPlaceholderText('Search terms...');
88
+ await user.type(searchInput, 'artificial');
89
+
90
+ expect(screen.getByText('Machine Learning')).toBeInTheDocument();
91
+ expect(screen.queryByText('API')).not.toBeInTheDocument();
92
+ expect(screen.queryByText('REST')).not.toBeInTheDocument();
93
+ });
94
+
95
+ it('should show no results message when no matches', async () => {
96
+ const user = userEvent.setup();
97
+ render(<GlossaryPage glossaryData={mockGlossaryData} />);
98
+
99
+ const searchInput = screen.getByPlaceholderText('Search terms...');
100
+ await user.type(searchInput, 'NonexistentTerm');
101
+
102
+ expect(screen.getByText(/No terms found matching/)).toBeInTheDocument();
103
+ });
104
+
105
+ it('should group terms by first letter', () => {
106
+ render(<GlossaryPage glossaryData={mockGlossaryData} />);
107
+
108
+ // Check that terms are grouped under their first letter (multiple elements per letter)
109
+ expect(screen.getAllByText('A').length).toBeGreaterThan(0);
110
+ expect(screen.getAllByText('M').length).toBeGreaterThan(0);
111
+ expect(screen.getAllByText('R').length).toBeGreaterThan(0);
112
+ });
113
+
114
+ it('should sort terms alphabetically within groups', () => {
115
+ const dataWithMultipleTerms = {
116
+ ...mockGlossaryData,
117
+ terms: [
118
+ { id: 'zebra', term: 'Zebra', definition: 'An animal' },
119
+ { id: 'alpha', term: 'Alpha', definition: 'First letter' },
120
+ { id: 'beta', term: 'Beta', definition: 'Second letter' },
121
+ ],
122
+ };
123
+
124
+ render(<GlossaryPage glossaryData={dataWithMultipleTerms} />);
125
+
126
+ const terms = screen.getAllByText(/Zebra|Alpha|Beta/);
127
+ expect(terms).toHaveLength(3);
128
+ // Check Alpha is before Beta, and Beta is before Zebra
129
+ expect(terms[0]).toHaveTextContent('Alpha');
130
+ expect(terms[1]).toHaveTextContent('Beta');
131
+ expect(terms[2]).toHaveTextContent('Zebra');
132
+ });
133
+
134
+ it('should display abbreviations', () => {
135
+ render(<GlossaryPage glossaryData={mockGlossaryData} />);
136
+
137
+ // Check abbreviation appears in document
138
+ expect(screen.getAllByText('(API)').length).toBeGreaterThan(0);
139
+ expect(screen.getAllByText('(REST)').length).toBeGreaterThan(0);
140
+ });
141
+
142
+ it('should display related terms', () => {
143
+ render(<GlossaryPage glossaryData={mockGlossaryData} />);
144
+
145
+ // Related terms header appears multiple times (once per term with related terms)
146
+ expect(screen.getAllByText('Related terms:').length).toBeGreaterThan(0);
147
+ // REST appears as a term and in related links
148
+ expect(screen.getAllByText('REST').length).toBeGreaterThan(0);
149
+ });
150
+
151
+ it('should show total term count in footer', () => {
152
+ render(<GlossaryPage glossaryData={mockGlossaryData} />);
153
+
154
+ expect(screen.getByText('Total terms: 3')).toBeInTheDocument();
155
+ });
156
+
157
+ it('should handle empty terms array', () => {
158
+ const emptyData = { terms: [] };
159
+ render(<GlossaryPage glossaryData={emptyData} />);
160
+
161
+ expect(screen.getByText('Glossary')).toBeInTheDocument();
162
+ expect(screen.getByText('Total terms: 0')).toBeInTheDocument();
163
+ });
164
+
165
+ it('should handle missing glossaryData gracefully', () => {
166
+ render(<GlossaryPage glossaryData={null} />);
167
+
168
+ expect(screen.getByText('Glossary')).toBeInTheDocument();
169
+ expect(screen.getByText('Total terms: 0')).toBeInTheDocument();
170
+ });
171
+
172
+ it('should handle missing description gracefully', () => {
173
+ const dataWithoutDescription = {
174
+ terms: mockGlossaryData.terms,
175
+ };
176
+ render(<GlossaryPage glossaryData={dataWithoutDescription} />);
177
+
178
+ expect(screen.getByText(/A collection of terms and their definitions/)).toBeInTheDocument();
179
+ });
180
+
181
+ it('should generate correct anchor links for terms', () => {
182
+ render(<GlossaryPage glossaryData={mockGlossaryData} />);
183
+
184
+ // Check navigation links are generated
185
+ const navLinks = screen.getAllByRole('link', { name: /^[AMR]$/ });
186
+ expect(navLinks.length).toBeGreaterThan(0);
187
+ expect(navLinks[0]).toHaveAttribute('href', '#letter-A');
188
+ });
189
+
190
+ it('should clear search when input is cleared', async () => {
191
+ const user = userEvent.setup();
192
+ render(<GlossaryPage glossaryData={mockGlossaryData} />);
193
+
194
+ const searchInput = screen.getByPlaceholderText('Search terms...');
195
+ await user.type(searchInput, 'Machine');
196
+
197
+ expect(screen.getByText('Machine Learning')).toBeInTheDocument();
198
+
199
+ await user.clear(searchInput);
200
+
201
+ expect(screen.getAllByText('API').length).toBeGreaterThan(0);
202
+ expect(screen.getAllByText('REST').length).toBeGreaterThan(0);
203
+ expect(screen.getByText('Machine Learning')).toBeInTheDocument();
204
+ });
205
+ });
package/index.js ADDED
@@ -0,0 +1,70 @@
1
+ const path = require('path');
2
+ const fs = require('fs-extra');
3
+
4
+ /**
5
+ * Docusaurus Glossary Plugin
6
+ *
7
+ * A plugin that provides glossary functionality with:
8
+ * - Glossary terms defined in a JSON file
9
+ * - Auto-generated glossary page
10
+ * - GlossaryTerm component for inline definitions
11
+ * - Tooltips on hover
12
+ *
13
+ * @param {object} context - Docusaurus context
14
+ * @param {object} options - Plugin options
15
+ * @param {string} options.glossaryPath - Path to glossary JSON file (default: 'glossary/glossary.json')
16
+ * @param {string} options.routePath - Route path for glossary page (default: '/glossary')
17
+ * @returns {object} Plugin object
18
+ */
19
+ function glossaryPlugin(context, options = {}) {
20
+ const { glossaryPath = 'glossary/glossary.json', routePath = '/glossary' } = options;
21
+
22
+ return {
23
+ name: 'docusaurus-plugin-glossary',
24
+
25
+ async loadContent() {
26
+ // Load glossary terms from JSON file
27
+ const glossaryFilePath = path.resolve(context.siteDir, glossaryPath);
28
+
29
+ if (await fs.pathExists(glossaryFilePath)) {
30
+ const glossaryData = await fs.readJson(glossaryFilePath);
31
+ return glossaryData;
32
+ }
33
+
34
+ console.warn(`Glossary file not found at ${glossaryFilePath}. Using empty glossary.`);
35
+ return { terms: [] };
36
+ },
37
+
38
+ async contentLoaded({ content, actions }) {
39
+ const { createData, addRoute } = actions;
40
+
41
+ // Create data file that can be imported by components
42
+ const glossaryDataPath = await createData('glossary-data.json', JSON.stringify(content));
43
+
44
+ // Add glossary page route
45
+ addRoute({
46
+ path: routePath,
47
+ component: '@site/src/plugins/docusaurus-plugin-glossary/components/GlossaryPage.js',
48
+ exact: true,
49
+ modules: {
50
+ glossaryData: glossaryDataPath,
51
+ },
52
+ });
53
+ },
54
+
55
+ getThemePath() {
56
+ return path.resolve(__dirname, './theme');
57
+ },
58
+
59
+ getPathsToWatch() {
60
+ return [path.resolve(context.siteDir, glossaryPath)];
61
+ },
62
+
63
+ async postBuild({ outDir }) {
64
+ // You can add any post-build steps here if needed
65
+ console.log('Glossary plugin: Build completed');
66
+ },
67
+ };
68
+ }
69
+
70
+ module.exports = glossaryPlugin;
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "docusaurus-plugin-glossary",
3
+ "version": "1.0.0",
4
+ "description": "A Docusaurus plugin for creating and managing glossary terms with auto-generated pages and inline tooltips",
5
+ "main": "index.js",
6
+ "files": [
7
+ "index.js",
8
+ "components/",
9
+ "theme/",
10
+ "README.md",
11
+ "LICENSE"
12
+ ],
13
+ "scripts": {
14
+ "test": "jest",
15
+ "test:watch": "jest --watch",
16
+ "test:coverage": "jest --coverage",
17
+ "prepublishOnly": "npm test",
18
+ "version": "npm version",
19
+ "format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css,md}\"",
20
+ "format:check": "prettier --check \"**/*.{js,jsx,ts,tsx,json,css,md}\""
21
+ },
22
+ "keywords": [
23
+ "docusaurus",
24
+ "glossary",
25
+ "plugin",
26
+ "documentation",
27
+ "terms",
28
+ "definitions",
29
+ "tooltip"
30
+ ],
31
+ "author": "mcclowes",
32
+ "license": "MIT",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/mcclowes/docusaurus-plugin-glossary.git"
36
+ },
37
+ "bugs": {
38
+ "url": "https://github.com/mcclowes/docusaurus-plugin-glossary/issues"
39
+ },
40
+ "homepage": "https://github.com/mcclowes/docusaurus-plugin-glossary#readme",
41
+ "peerDependencies": {
42
+ "@docusaurus/core": "^3.0.0",
43
+ "react": "^18.0.0",
44
+ "react-dom": "^18.0.0"
45
+ },
46
+ "dependencies": {
47
+ "fs-extra": "^11.0.0"
48
+ },
49
+ "engines": {
50
+ "node": ">=16.14"
51
+ },
52
+ "devDependencies": {
53
+ "@babel/preset-env": "^7.28.5",
54
+ "@babel/preset-react": "^7.28.5",
55
+ "@testing-library/jest-dom": "^6.9.1",
56
+ "@testing-library/react": "^16.3.0",
57
+ "@testing-library/user-event": "^14.6.1",
58
+ "babel-jest": "^30.2.0",
59
+ "identity-obj-proxy": "^3.0.0",
60
+ "jest": "^30.2.0",
61
+ "jest-environment-jsdom": "^30.2.0",
62
+ "prettier": "^3.6.2"
63
+ }
64
+ }
@@ -0,0 +1,49 @@
1
+ import React, { useState } from 'react';
2
+ import styles from './styles.module.css';
3
+
4
+ /**
5
+ * GlossaryTerm component - displays an inline term with tooltip
6
+ *
7
+ * Usage:
8
+ * import GlossaryTerm from '@theme/GlossaryTerm';
9
+ *
10
+ * <GlossaryTerm term="API" definition="Application Programming Interface" />
11
+ * or
12
+ * <GlossaryTerm term="API">custom display text</GlossaryTerm>
13
+ *
14
+ * @param {object} props
15
+ * @param {string} props.term - The glossary term
16
+ * @param {string} props.definition - The definition to show in tooltip
17
+ * @param {React.ReactNode} props.children - Optional custom display text
18
+ */
19
+ export default function GlossaryTerm({ term, definition, children }) {
20
+ const [showTooltip, setShowTooltip] = useState(false);
21
+
22
+ const displayText = children || term;
23
+ const termId = term.toLowerCase().replace(/\s+/g, '-');
24
+
25
+ return (
26
+ <span className={styles.glossaryTermWrapper}>
27
+ <a
28
+ href={`/glossary#${termId}`}
29
+ className={styles.glossaryTerm}
30
+ onMouseEnter={() => setShowTooltip(true)}
31
+ onMouseLeave={() => setShowTooltip(false)}
32
+ onFocus={() => setShowTooltip(true)}
33
+ onBlur={() => setShowTooltip(false)}
34
+ aria-describedby={`tooltip-${termId}`}
35
+ >
36
+ {displayText}
37
+ </a>
38
+ {definition && (
39
+ <span
40
+ id={`tooltip-${termId}`}
41
+ className={`${styles.tooltip} ${showTooltip ? styles.tooltipVisible : ''}`}
42
+ role="tooltip"
43
+ >
44
+ <strong>{term}:</strong> {definition}
45
+ </span>
46
+ )}
47
+ </span>
48
+ );
49
+ }
@@ -0,0 +1,116 @@
1
+ import React from 'react';
2
+ import { render, screen, within } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import GlossaryTerm from './index';
5
+
6
+ describe('GlossaryTerm', () => {
7
+ it('should render term text', () => {
8
+ render(<GlossaryTerm term="API" definition="Application Programming Interface" />);
9
+
10
+ const link = screen.getByRole('link', { name: 'API' });
11
+ expect(link).toBeInTheDocument();
12
+ expect(link).toHaveAttribute('href', '/glossary#api');
13
+ });
14
+
15
+ it('should render custom children text', () => {
16
+ render(
17
+ <GlossaryTerm term="API" definition="Application Programming Interface">
18
+ Application Programming Interface
19
+ </GlossaryTerm>
20
+ );
21
+
22
+ const link = screen.getByRole('link', { name: 'Application Programming Interface' });
23
+ expect(link).toBeInTheDocument();
24
+ expect(link).toHaveAttribute('href', '/glossary#api');
25
+ });
26
+
27
+ it('should show tooltip on hover', async () => {
28
+ const user = userEvent.setup();
29
+ render(<GlossaryTerm term="API" definition="Application Programming Interface" />);
30
+
31
+ const link = screen.getByRole('link');
32
+ await user.hover(link);
33
+
34
+ const tooltip = screen.getByRole('tooltip');
35
+ expect(tooltip).toBeInTheDocument();
36
+ expect(tooltip).toHaveClass('tooltipVisible');
37
+ expect(tooltip).toHaveTextContent('API: Application Programming Interface');
38
+ });
39
+
40
+ it('should hide tooltip on mouse leave', async () => {
41
+ const user = userEvent.setup();
42
+ render(<GlossaryTerm term="API" definition="Application Programming Interface" />);
43
+
44
+ const link = screen.getByRole('link');
45
+ await user.hover(link);
46
+
47
+ let tooltip = screen.getByRole('tooltip');
48
+ expect(tooltip).toBeInTheDocument();
49
+ expect(tooltip).toHaveClass('tooltipVisible');
50
+
51
+ await user.unhover(link);
52
+ // Tooltip is still in DOM but hidden via CSS
53
+ tooltip = screen.getByRole('tooltip');
54
+ expect(tooltip).not.toHaveClass('tooltipVisible');
55
+ });
56
+
57
+ it('should show tooltip on focus', async () => {
58
+ const user = userEvent.setup();
59
+ render(<GlossaryTerm term="API" definition="Application Programming Interface" />);
60
+
61
+ const link = screen.getByRole('link');
62
+ await user.tab();
63
+ expect(link).toHaveFocus();
64
+
65
+ const tooltip = screen.getByRole('tooltip');
66
+ expect(tooltip).toBeInTheDocument();
67
+ expect(tooltip).toHaveClass('tooltipVisible');
68
+ });
69
+
70
+ it('should hide tooltip on blur', async () => {
71
+ const user = userEvent.setup();
72
+ render(<GlossaryTerm term="API" definition="Application Programming Interface" />);
73
+
74
+ const link = screen.getByRole('link');
75
+ await user.tab();
76
+ expect(link).toHaveFocus();
77
+
78
+ let tooltip = screen.getByRole('tooltip');
79
+ expect(tooltip).toBeInTheDocument();
80
+ expect(tooltip).toHaveClass('tooltipVisible');
81
+
82
+ await user.tab();
83
+ // Tooltip is still in DOM but hidden via CSS
84
+ tooltip = screen.getByRole('tooltip');
85
+ expect(tooltip).not.toHaveClass('tooltipVisible');
86
+ });
87
+
88
+ it('should generate correct ID from term with spaces', () => {
89
+ render(<GlossaryTerm term="Machine Learning" definition="A type of AI" />);
90
+
91
+ const link = screen.getByRole('link');
92
+ expect(link).toHaveAttribute('href', '/glossary#machine-learning');
93
+ });
94
+
95
+ it('should work without definition prop', () => {
96
+ render(<GlossaryTerm term="TestTerm" />);
97
+
98
+ const link = screen.getByRole('link', { name: 'TestTerm' });
99
+ expect(link).toBeInTheDocument();
100
+ expect(link).toHaveAttribute('href', '/glossary#testterm');
101
+
102
+ // No tooltip should be rendered when no definition
103
+ const tooltip = screen.queryByRole('tooltip');
104
+ expect(tooltip).not.toBeInTheDocument();
105
+ });
106
+
107
+ it('should have proper ARIA attributes', () => {
108
+ render(<GlossaryTerm term="API" definition="Application Programming Interface" />);
109
+
110
+ const link = screen.getByRole('link');
111
+ expect(link).toHaveAttribute('aria-describedby', 'tooltip-api');
112
+
113
+ const tooltip = screen.getByRole('tooltip');
114
+ expect(tooltip).toHaveAttribute('id', 'tooltip-api');
115
+ });
116
+ });
@@ -0,0 +1,95 @@
1
+ .glossaryTermWrapper {
2
+ position: relative;
3
+ display: inline;
4
+ }
5
+
6
+ .glossaryTerm {
7
+ color: var(--ifm-color-primary);
8
+ text-decoration: none;
9
+ border-bottom: 1px dotted var(--ifm-color-primary);
10
+ cursor: help;
11
+ font-weight: 500;
12
+ transition: all 0.2s;
13
+ }
14
+
15
+ .glossaryTerm:hover {
16
+ color: var(--ifm-color-primary-dark);
17
+ border-bottom-style: solid;
18
+ }
19
+
20
+ .tooltip {
21
+ position: absolute;
22
+ bottom: 100%;
23
+ left: 50%;
24
+ transform: translateX(-50%) translateY(-8px);
25
+ padding: 0.75rem 1rem;
26
+ background: var(--ifm-background-surface-color);
27
+ color: var(--ifm-font-color-base);
28
+ border: 1px solid var(--ifm-color-emphasis-300);
29
+ border-radius: 0.5rem;
30
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
31
+ font-size: 0.875rem;
32
+ line-height: 1.4;
33
+ white-space: normal;
34
+ max-width: 300px;
35
+ min-width: 200px;
36
+ z-index: 1000;
37
+ opacity: 0;
38
+ pointer-events: none;
39
+ transition: opacity 0.2s;
40
+ }
41
+
42
+ .tooltip::after {
43
+ content: '';
44
+ position: absolute;
45
+ top: 100%;
46
+ left: 50%;
47
+ transform: translateX(-50%);
48
+ border: 6px solid transparent;
49
+ border-top-color: var(--ifm-background-surface-color);
50
+ }
51
+
52
+ .tooltip::before {
53
+ content: '';
54
+ position: absolute;
55
+ top: 100%;
56
+ left: 50%;
57
+ transform: translateX(-50%);
58
+ border: 7px solid transparent;
59
+ border-top-color: var(--ifm-color-emphasis-300);
60
+ margin-top: 1px;
61
+ }
62
+
63
+ .tooltipVisible {
64
+ opacity: 1;
65
+ pointer-events: auto;
66
+ }
67
+
68
+ .tooltip strong {
69
+ display: block;
70
+ margin-bottom: 0.25rem;
71
+ color: var(--ifm-color-primary);
72
+ }
73
+
74
+ /* Dark mode adjustments */
75
+ [data-theme='dark'] .tooltip {
76
+ background: var(--ifm-background-surface-color);
77
+ border-color: var(--ifm-color-emphasis-400);
78
+ }
79
+
80
+ [data-theme='dark'] .tooltip::after {
81
+ border-top-color: var(--ifm-background-surface-color);
82
+ }
83
+
84
+ [data-theme='dark'] .tooltip::before {
85
+ border-top-color: var(--ifm-color-emphasis-400);
86
+ }
87
+
88
+ /* Responsive adjustments */
89
+ @media (max-width: 768px) {
90
+ .tooltip {
91
+ max-width: 250px;
92
+ min-width: 180px;
93
+ font-size: 0.8rem;
94
+ }
95
+ }