docusaurus-plugin-glossary 1.1.2 → 1.2.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 +21 -11
- package/index.js +18 -20
- package/package.json +1 -1
- package/remark/glossary-terms.js +89 -43
- package/theme/GlossaryTerm/index.js +66 -5
- package/theme/GlossaryTerm/index.test.js +19 -0
- package/theme/GlossaryTerm/styles.module.css +45 -9
package/README.md
CHANGED
|
@@ -18,11 +18,13 @@ A comprehensive Docusaurus plugin that provides glossary functionality with an a
|
|
|
18
18
|
## Quick Start
|
|
19
19
|
|
|
20
20
|
1. **Install the plugin:**
|
|
21
|
+
|
|
21
22
|
```bash
|
|
22
23
|
npm install docusaurus-plugin-glossary
|
|
23
24
|
```
|
|
24
25
|
|
|
25
26
|
2. **Add to your `docusaurus.config.js`:**
|
|
27
|
+
|
|
26
28
|
```javascript
|
|
27
29
|
module.exports = {
|
|
28
30
|
// ... other config
|
|
@@ -39,10 +41,11 @@ A comprehensive Docusaurus plugin that provides glossary functionality with an a
|
|
|
39
41
|
// ... other config
|
|
40
42
|
};
|
|
41
43
|
```
|
|
42
|
-
|
|
44
|
+
|
|
43
45
|
**That’s it!** On Docusaurus v3, the remark plugin is automatically configured via the plugin’s `configureMarkdown` hook — no manual `markdown.remarkPlugins` setup needed.
|
|
44
46
|
|
|
45
47
|
3. **Create your glossary file at `glossary/glossary.json`:**
|
|
48
|
+
|
|
46
49
|
```json
|
|
47
50
|
{
|
|
48
51
|
"description": "A collection of technical terms and their definitions",
|
|
@@ -64,11 +67,12 @@ A comprehensive Docusaurus plugin that provides glossary functionality with an a
|
|
|
64
67
|
```
|
|
65
68
|
|
|
66
69
|
4. **Start your dev server:**
|
|
70
|
+
|
|
67
71
|
```bash
|
|
68
72
|
npm run start
|
|
69
73
|
```
|
|
70
74
|
|
|
71
|
-
5. **That's it!**
|
|
75
|
+
5. **That's it!**
|
|
72
76
|
- Visit `/glossary` to see your glossary page
|
|
73
77
|
- Write markdown normally - terms will automatically be linked with tooltips
|
|
74
78
|
- Use `<GlossaryTerm>` component in MDX for manual control
|
|
@@ -108,10 +112,12 @@ Create a JSON file at `glossary/glossary.json` (or your configured path) in your
|
|
|
108
112
|
```
|
|
109
113
|
|
|
110
114
|
**Required fields:**
|
|
115
|
+
|
|
111
116
|
- `term` (string): The glossary term name
|
|
112
117
|
- `definition` (string): The term's definition
|
|
113
118
|
|
|
114
119
|
**Optional fields:**
|
|
120
|
+
|
|
115
121
|
- `abbreviation` (string): The full form if the term is an abbreviation
|
|
116
122
|
- `relatedTerms` (string[]): Array of related term names that link to other glossary entries
|
|
117
123
|
- `id` (string): Custom ID for linking (auto-generated from term name if not provided)
|
|
@@ -173,10 +179,10 @@ module.exports = {
|
|
|
173
179
|
## Docusaurus v3 Notes and Troubleshooting
|
|
174
180
|
|
|
175
181
|
- **MDX imports**: The plugin injects `import GlossaryTerm from '@theme/GlossaryTerm';` automatically when it auto-links a term. If you’re writing MDX manually, you can also import and use it yourself:
|
|
176
|
-
|
|
182
|
+
|
|
177
183
|
```mdx
|
|
178
184
|
import GlossaryTerm from '@theme/GlossaryTerm';
|
|
179
|
-
|
|
185
|
+
|
|
180
186
|
Our <GlossaryTerm term="API" /> uses <GlossaryTerm term="REST">RESTful</GlossaryTerm> principles.
|
|
181
187
|
```
|
|
182
188
|
|
|
@@ -201,12 +207,14 @@ This project supports webhooks for real-time notifications.
|
|
|
201
207
|
```
|
|
202
208
|
|
|
203
209
|
Terms like "API", "REST", and "webhooks" will automatically be:
|
|
210
|
+
|
|
204
211
|
- Detected if they're defined in your glossary
|
|
205
212
|
- Styled with a dotted underline
|
|
206
213
|
- Display a tooltip with the definition on hover
|
|
207
214
|
- Link to the full glossary page entry
|
|
208
215
|
|
|
209
216
|
**Limitations:**
|
|
217
|
+
|
|
210
218
|
- Only whole words are matched (respects word boundaries)
|
|
211
219
|
- Terms inside code blocks, links, or existing MDX components are **not** processed
|
|
212
220
|
- Matching is case-insensitive
|
|
@@ -228,6 +236,7 @@ Our <GlossaryTerm term="API" definition="Application Programming Interface">REST
|
|
|
228
236
|
```
|
|
229
237
|
|
|
230
238
|
**Component props:**
|
|
239
|
+
|
|
231
240
|
- `term` (required): The term name (used to look up definition from glossary)
|
|
232
241
|
- `definition` (optional): Override the definition from the glossary file
|
|
233
242
|
- `children` (optional): Custom text to display (defaults to term name)
|
|
@@ -237,6 +246,7 @@ Our <GlossaryTerm term="API" definition="Application Programming Interface">REST
|
|
|
237
246
|
The glossary page is automatically available at `/glossary` (or your configured `routePath`).
|
|
238
247
|
|
|
239
248
|
**Features:**
|
|
249
|
+
|
|
240
250
|
- Alphabetical grouping with letter navigation
|
|
241
251
|
- Real-time search across terms and definitions
|
|
242
252
|
- Clickable related terms
|
|
@@ -252,7 +262,7 @@ module.exports = {
|
|
|
252
262
|
themeConfig: {
|
|
253
263
|
navbar: {
|
|
254
264
|
items: [
|
|
255
|
-
{to: '/glossary', label: 'Glossary', position: 'left'},
|
|
265
|
+
{ to: '/glossary', label: 'Glossary', position: 'left' },
|
|
256
266
|
// ... other items
|
|
257
267
|
],
|
|
258
268
|
},
|
|
@@ -262,11 +272,11 @@ module.exports = {
|
|
|
262
272
|
|
|
263
273
|
## Configuration Options
|
|
264
274
|
|
|
265
|
-
| Option
|
|
266
|
-
|
|
|
267
|
-
| `glossaryPath`
|
|
268
|
-
| `routePath`
|
|
269
|
-
| `autoLinkTerms
|
|
275
|
+
| Option | Type | Default | Description |
|
|
276
|
+
| --------------- | ------- | -------------------------- | ---------------------------------------------------------------------------------- |
|
|
277
|
+
| `glossaryPath` | string | `'glossary/glossary.json'` | Path to glossary JSON file relative to site directory |
|
|
278
|
+
| `routePath` | string | `'/glossary'` | URL path for glossary page |
|
|
279
|
+
| `autoLinkTerms` | boolean | `true` | Enable automatic term detection in markdown (requires remark plugin configuration) |
|
|
270
280
|
|
|
271
281
|
## Customization
|
|
272
282
|
|
|
@@ -449,7 +459,7 @@ MIT
|
|
|
449
459
|
|
|
450
460
|
## Contributing
|
|
451
461
|
|
|
452
|
-
Contributions are welcome! Please
|
|
462
|
+
Contributions are welcome! Please see our [Contributing Guide](CONTRIBUTING.md) for details on how to get started.
|
|
453
463
|
|
|
454
464
|
## Credits
|
|
455
465
|
|
package/index.js
CHANGED
|
@@ -19,10 +19,10 @@ const fs = require('fs-extra');
|
|
|
19
19
|
* @returns {object} Plugin object
|
|
20
20
|
*/
|
|
21
21
|
function glossaryPlugin(context, options = {}) {
|
|
22
|
-
const {
|
|
23
|
-
glossaryPath = 'glossary/glossary.json',
|
|
22
|
+
const {
|
|
23
|
+
glossaryPath = 'glossary/glossary.json',
|
|
24
24
|
routePath = '/glossary',
|
|
25
|
-
autoLinkTerms = true
|
|
25
|
+
autoLinkTerms = true,
|
|
26
26
|
} = options;
|
|
27
27
|
|
|
28
28
|
let glossaryDataCache = { terms: [] };
|
|
@@ -40,16 +40,14 @@ function glossaryPlugin(context, options = {}) {
|
|
|
40
40
|
const remarkPlugin = require('./remark/glossary-terms');
|
|
41
41
|
|
|
42
42
|
// Check if the remark plugin is already configured
|
|
43
|
-
const isAlreadyConfigured = markdownConfig.remarkPlugins.some(
|
|
44
|
-
(plugin)
|
|
45
|
-
if
|
|
46
|
-
|
|
47
|
-
return plugin[0] === remarkPlugin;
|
|
48
|
-
}
|
|
49
|
-
// Also check if it's directly the remark plugin function
|
|
50
|
-
return plugin === remarkPlugin;
|
|
43
|
+
const isAlreadyConfigured = markdownConfig.remarkPlugins.some(plugin => {
|
|
44
|
+
if (Array.isArray(plugin) && plugin[0]) {
|
|
45
|
+
// Check if it's our remark plugin by comparing the function reference
|
|
46
|
+
return plugin[0] === remarkPlugin;
|
|
51
47
|
}
|
|
52
|
-
|
|
48
|
+
// Also check if it's directly the remark plugin function
|
|
49
|
+
return plugin === remarkPlugin;
|
|
50
|
+
});
|
|
53
51
|
|
|
54
52
|
// Only add if not already configured
|
|
55
53
|
if (!isAlreadyConfigured) {
|
|
@@ -59,7 +57,7 @@ function glossaryPlugin(context, options = {}) {
|
|
|
59
57
|
glossaryPath,
|
|
60
58
|
routePath,
|
|
61
59
|
siteDir: context.siteDir,
|
|
62
|
-
}
|
|
60
|
+
},
|
|
63
61
|
]);
|
|
64
62
|
}
|
|
65
63
|
}
|
|
@@ -85,7 +83,7 @@ function glossaryPlugin(context, options = {}) {
|
|
|
85
83
|
|
|
86
84
|
// Create data file that can be imported by components
|
|
87
85
|
const glossaryDataPath = await createData('glossary-data.json', JSON.stringify(content));
|
|
88
|
-
|
|
86
|
+
|
|
89
87
|
// Create a data file for the remark plugin to access glossary terms
|
|
90
88
|
const remarkGlossaryDataPath = await createData(
|
|
91
89
|
'remark-glossary-data.json',
|
|
@@ -133,16 +131,16 @@ glossaryPlugin.remarkPlugin = require('./remark/glossary-terms');
|
|
|
133
131
|
/**
|
|
134
132
|
* Helper function to get the configured remark plugin
|
|
135
133
|
* This can be used in docusaurus.config.js markdown configuration
|
|
136
|
-
*
|
|
134
|
+
*
|
|
137
135
|
* @param {object} pluginOptions - Plugin options from docusaurus.config.js
|
|
138
136
|
* @param {object} context - Docusaurus context
|
|
139
137
|
* @returns {function} Configured remark plugin
|
|
140
138
|
*/
|
|
141
|
-
glossaryPlugin.getRemarkPlugin = function(pluginOptions, context) {
|
|
142
|
-
const {
|
|
143
|
-
glossaryPath = 'glossary/glossary.json',
|
|
139
|
+
glossaryPlugin.getRemarkPlugin = function (pluginOptions, context) {
|
|
140
|
+
const {
|
|
141
|
+
glossaryPath = 'glossary/glossary.json',
|
|
144
142
|
routePath = '/glossary',
|
|
145
|
-
siteDir = context.siteDir
|
|
143
|
+
siteDir = context.siteDir,
|
|
146
144
|
} = pluginOptions;
|
|
147
145
|
|
|
148
146
|
return [
|
|
@@ -151,7 +149,7 @@ glossaryPlugin.getRemarkPlugin = function(pluginOptions, context) {
|
|
|
151
149
|
glossaryPath,
|
|
152
150
|
routePath,
|
|
153
151
|
siteDir,
|
|
154
|
-
}
|
|
152
|
+
},
|
|
155
153
|
];
|
|
156
154
|
};
|
|
157
155
|
|
package/package.json
CHANGED
package/remark/glossary-terms.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
|
|
1
|
+
// Support both CJS and ESM exports of unist-util-visit
|
|
2
|
+
let visit = require('unist-util-visit');
|
|
3
|
+
visit = visit && visit.visit ? visit.visit : visit;
|
|
2
4
|
const path = require('path');
|
|
3
5
|
const fs = require('fs');
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Creates a remark plugin that automatically detects and replaces glossary terms in markdown
|
|
7
|
-
*
|
|
9
|
+
*
|
|
8
10
|
* @param {object} options - Plugin options
|
|
9
11
|
* @param {Array} options.terms - Array of glossary term objects with {term, definition}
|
|
10
12
|
* @param {string} options.glossaryPath - Path to glossary JSON file (optional, if terms not provided)
|
|
@@ -12,11 +14,11 @@ const fs = require('fs');
|
|
|
12
14
|
* @param {string} options.siteDir - Docusaurus site directory (required if using glossaryPath)
|
|
13
15
|
* @returns {function} Remark plugin function
|
|
14
16
|
*/
|
|
15
|
-
function remarkGlossaryTerms({
|
|
16
|
-
terms = [],
|
|
17
|
+
function remarkGlossaryTerms({
|
|
18
|
+
terms = [],
|
|
17
19
|
glossaryPath = null,
|
|
18
20
|
routePath = '/glossary',
|
|
19
|
-
siteDir = null
|
|
21
|
+
siteDir = null,
|
|
20
22
|
} = {}) {
|
|
21
23
|
let glossaryTerms = terms;
|
|
22
24
|
|
|
@@ -44,13 +46,11 @@ function remarkGlossaryTerms({
|
|
|
44
46
|
|
|
45
47
|
// Sort terms by length (longest first) to avoid partial matches
|
|
46
48
|
// e.g., "Application Programming Interface" should match before "API"
|
|
47
|
-
const sortedTerms = Array.from(termMap.entries()).sort(
|
|
48
|
-
(a, b) => b[0].length - a[0].length
|
|
49
|
-
);
|
|
49
|
+
const sortedTerms = Array.from(termMap.entries()).sort((a, b) => b[0].length - a[0].length);
|
|
50
50
|
|
|
51
51
|
// If no terms, return a no-op transformer
|
|
52
52
|
if (sortedTerms.length === 0) {
|
|
53
|
-
return
|
|
53
|
+
return tree => tree;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
/**
|
|
@@ -71,29 +71,47 @@ function remarkGlossaryTerms({
|
|
|
71
71
|
for (const [lowerTerm, termObj] of sortedTerms) {
|
|
72
72
|
const term = termObj.term;
|
|
73
73
|
let searchIndex = 0;
|
|
74
|
-
|
|
74
|
+
|
|
75
75
|
while (searchIndex < textLower.length) {
|
|
76
76
|
const index = textLower.indexOf(lowerTerm, searchIndex);
|
|
77
77
|
if (index === -1) break;
|
|
78
78
|
|
|
79
|
-
// Check if it's a whole word match
|
|
79
|
+
// Check if it's a whole word match, with simple plural tolerance ('s' or 'es')
|
|
80
80
|
const beforeChar = index > 0 ? textLower[index - 1] : ' ';
|
|
81
|
-
const
|
|
82
|
-
|
|
81
|
+
const afterIndex = index + lowerTerm.length;
|
|
82
|
+
const afterChar = afterIndex < textLower.length
|
|
83
|
+
? textLower[afterIndex]
|
|
83
84
|
: ' ';
|
|
84
85
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
let matchLength = term.length;
|
|
87
|
+
let isWordBoundary = !/\w/.test(beforeChar) && !/\w/.test(afterChar);
|
|
88
|
+
|
|
89
|
+
// Allow trailing 's' plural (e.g., webhook -> webhooks)
|
|
90
|
+
if (!isWordBoundary && afterChar === 's') {
|
|
91
|
+
const nextChar = afterIndex + 1 < textLower.length ? textLower[afterIndex + 1] : ' ';
|
|
92
|
+
if (!/\w/.test(nextChar)) {
|
|
93
|
+
isWordBoundary = true;
|
|
94
|
+
matchLength = term.length + 1;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Allow trailing 'es' plural (e.g., API -> APIs, box -> boxes)
|
|
99
|
+
if (!isWordBoundary && afterChar === 'e' && afterIndex + 1 < textLower.length && textLower[afterIndex + 1] === 's') {
|
|
100
|
+
const nextChar = afterIndex + 2 < textLower.length ? textLower[afterIndex + 2] : ' ';
|
|
101
|
+
if (!/\w/.test(nextChar)) {
|
|
102
|
+
isWordBoundary = true;
|
|
103
|
+
matchLength = term.length + 2;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
88
106
|
|
|
89
107
|
if (isWordBoundary) {
|
|
90
108
|
matches.push({
|
|
91
109
|
index,
|
|
92
|
-
length:
|
|
110
|
+
length: matchLength,
|
|
93
111
|
term: term,
|
|
94
112
|
termObj: termObj,
|
|
95
113
|
// Store original case from the text
|
|
96
|
-
originalText: text.substring(index, index +
|
|
114
|
+
originalText: text.substring(index, index + matchLength)
|
|
97
115
|
});
|
|
98
116
|
}
|
|
99
117
|
|
|
@@ -120,7 +138,7 @@ function remarkGlossaryTerms({
|
|
|
120
138
|
if (match.index > lastIndex) {
|
|
121
139
|
result.push({
|
|
122
140
|
type: 'text',
|
|
123
|
-
value: text.substring(lastIndex, match.index)
|
|
141
|
+
value: text.substring(lastIndex, match.index),
|
|
124
142
|
});
|
|
125
143
|
}
|
|
126
144
|
|
|
@@ -132,25 +150,25 @@ function remarkGlossaryTerms({
|
|
|
132
150
|
{
|
|
133
151
|
type: 'mdxJsxAttribute',
|
|
134
152
|
name: 'term',
|
|
135
|
-
value: match.termObj.term
|
|
153
|
+
value: match.termObj.term,
|
|
136
154
|
},
|
|
137
155
|
{
|
|
138
156
|
type: 'mdxJsxAttribute',
|
|
139
157
|
name: 'definition',
|
|
140
|
-
value: match.termObj.definition || ''
|
|
158
|
+
value: match.termObj.definition || '',
|
|
141
159
|
},
|
|
142
160
|
{
|
|
143
161
|
type: 'mdxJsxAttribute',
|
|
144
162
|
name: 'routePath',
|
|
145
|
-
value: routePath
|
|
146
|
-
}
|
|
163
|
+
value: routePath,
|
|
164
|
+
},
|
|
147
165
|
],
|
|
148
166
|
children: [
|
|
149
167
|
{
|
|
150
168
|
type: 'text',
|
|
151
|
-
value: match.originalText
|
|
152
|
-
}
|
|
153
|
-
]
|
|
169
|
+
value: match.originalText,
|
|
170
|
+
},
|
|
171
|
+
],
|
|
154
172
|
});
|
|
155
173
|
|
|
156
174
|
lastIndex = match.index + match.length;
|
|
@@ -160,14 +178,14 @@ function remarkGlossaryTerms({
|
|
|
160
178
|
if (lastIndex < text.length) {
|
|
161
179
|
result.push({
|
|
162
180
|
type: 'text',
|
|
163
|
-
value: text.substring(lastIndex)
|
|
181
|
+
value: text.substring(lastIndex),
|
|
164
182
|
});
|
|
165
183
|
}
|
|
166
184
|
|
|
167
185
|
return result.length > 0 ? result : [{ type: 'text', value: text }];
|
|
168
186
|
}
|
|
169
187
|
|
|
170
|
-
return
|
|
188
|
+
return tree => {
|
|
171
189
|
let usedGlossaryTerm = false;
|
|
172
190
|
visit(tree, 'text', (node, index, parent) => {
|
|
173
191
|
// Skip text nodes inside code blocks, links, or existing MDX components
|
|
@@ -185,8 +203,10 @@ function remarkGlossaryTerms({
|
|
|
185
203
|
const replacements = replaceTermsInText(node.value);
|
|
186
204
|
|
|
187
205
|
// If we have replacements, replace the single text node with multiple nodes
|
|
188
|
-
if (
|
|
189
|
-
|
|
206
|
+
if (
|
|
207
|
+
replacements.length > 1 ||
|
|
208
|
+
(replacements.length === 1 && replacements[0].type !== 'text')
|
|
209
|
+
) {
|
|
190
210
|
// Convert to text elements for paragraph context if needed
|
|
191
211
|
const newNodes = replacements.map(replacement => {
|
|
192
212
|
if (replacement.type === 'mdxJsxFlowElement') {
|
|
@@ -196,7 +216,7 @@ function remarkGlossaryTerms({
|
|
|
196
216
|
type: 'mdxJsxTextElement',
|
|
197
217
|
name: replacement.name,
|
|
198
218
|
attributes: replacement.attributes,
|
|
199
|
-
children: replacement.children
|
|
219
|
+
children: replacement.children,
|
|
200
220
|
};
|
|
201
221
|
}
|
|
202
222
|
}
|
|
@@ -217,9 +237,10 @@ function remarkGlossaryTerms({
|
|
|
217
237
|
|
|
218
238
|
// Inject MDX import for GlossaryTerm if we used it anywhere in this file
|
|
219
239
|
if (usedGlossaryTerm) {
|
|
240
|
+
// Create import node matching MDX expectations (empty value + estree)
|
|
220
241
|
const importNode = {
|
|
221
242
|
type: 'mdxjsEsm',
|
|
222
|
-
value:
|
|
243
|
+
value: '',
|
|
223
244
|
data: {
|
|
224
245
|
estree: {
|
|
225
246
|
type: 'Program',
|
|
@@ -230,26 +251,51 @@ function remarkGlossaryTerms({
|
|
|
230
251
|
specifiers: [
|
|
231
252
|
{
|
|
232
253
|
type: 'ImportDefaultSpecifier',
|
|
233
|
-
local: { type: 'Identifier', name: 'GlossaryTerm' }
|
|
234
|
-
}
|
|
254
|
+
local: { type: 'Identifier', name: 'GlossaryTerm' },
|
|
255
|
+
},
|
|
235
256
|
],
|
|
236
|
-
source: { type: 'Literal', value: '@theme/GlossaryTerm' }
|
|
237
|
-
}
|
|
238
|
-
]
|
|
239
|
-
}
|
|
240
|
-
}
|
|
257
|
+
source: { type: 'Literal', value: '@theme/GlossaryTerm' },
|
|
258
|
+
},
|
|
259
|
+
],
|
|
260
|
+
},
|
|
261
|
+
},
|
|
241
262
|
};
|
|
242
263
|
|
|
243
264
|
// Avoid duplicate imports if already present
|
|
244
|
-
const hasExistingImport =
|
|
245
|
-
(
|
|
246
|
-
|
|
265
|
+
const hasExistingImport =
|
|
266
|
+
Array.isArray(tree.children) &&
|
|
267
|
+
tree.children.some(n => {
|
|
268
|
+
if (n.type !== 'mdxjsEsm') return false;
|
|
269
|
+
// Check value string (for older MDX format)
|
|
270
|
+
if (typeof n.value === 'string' && n.value.includes('@theme/GlossaryTerm')) {
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
// Check estree data (for newer MDX format)
|
|
274
|
+
if (n.data?.estree?.body) {
|
|
275
|
+
return n.data.estree.body.some(
|
|
276
|
+
stmt =>
|
|
277
|
+
stmt.type === 'ImportDeclaration' &&
|
|
278
|
+
stmt.source?.value === '@theme/GlossaryTerm'
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
return false;
|
|
282
|
+
});
|
|
283
|
+
|
|
247
284
|
if (!hasExistingImport) {
|
|
285
|
+
// Place import at the very beginning of the file (before all other nodes)
|
|
286
|
+
// This ensures it's available when MDX compiles the JSX elements
|
|
287
|
+
if (!Array.isArray(tree.children)) {
|
|
288
|
+
tree.children = [];
|
|
289
|
+
}
|
|
290
|
+
// Insert at the very beginning (index 0) to ensure it's processed first
|
|
248
291
|
tree.children.unshift(importNode);
|
|
292
|
+
// Debug: verify import was added (remove in production)
|
|
293
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
294
|
+
console.log('[glossary-plugin] Injected GlossaryTerm import');
|
|
295
|
+
}
|
|
249
296
|
}
|
|
250
297
|
}
|
|
251
298
|
};
|
|
252
299
|
}
|
|
253
300
|
|
|
254
301
|
module.exports = remarkGlossaryTerms;
|
|
255
|
-
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import React, { useMemo, useState } from 'react';
|
|
2
|
-
import {usePluginData} from '@docusaurus/useGlobalData';
|
|
1
|
+
import React, { useMemo, useState, useRef, useEffect, useCallback } from 'react';
|
|
2
|
+
import { usePluginData } from '@docusaurus/useGlobalData';
|
|
3
3
|
import styles from './styles.module.css';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -20,6 +20,57 @@ import styles from './styles.module.css';
|
|
|
20
20
|
*/
|
|
21
21
|
export default function GlossaryTerm({ term, definition, routePath = '/glossary', children }) {
|
|
22
22
|
const [showTooltip, setShowTooltip] = useState(false);
|
|
23
|
+
const [tooltipStyle, setTooltipStyle] = useState(null);
|
|
24
|
+
const [placement, setPlacement] = useState('top'); // 'top' | 'bottom'
|
|
25
|
+
const wrapperRef = useRef(null);
|
|
26
|
+
const tooltipRef = useRef(null);
|
|
27
|
+
|
|
28
|
+
const updatePosition = useCallback(() => {
|
|
29
|
+
if (!wrapperRef.current || !tooltipRef.current) return;
|
|
30
|
+
const wrapperRect = wrapperRef.current.getBoundingClientRect();
|
|
31
|
+
const tooltipRect = tooltipRef.current.getBoundingClientRect();
|
|
32
|
+
|
|
33
|
+
const viewportWidth = window.innerWidth;
|
|
34
|
+
const viewportHeight = window.innerHeight;
|
|
35
|
+
|
|
36
|
+
const preferredGap = 8; // px
|
|
37
|
+
|
|
38
|
+
// Decide top vs bottom based on available space
|
|
39
|
+
const hasSpaceAbove = wrapperRect.top >= tooltipRect.height + preferredGap;
|
|
40
|
+
const hasSpaceBelow = viewportHeight - wrapperRect.bottom >= tooltipRect.height + preferredGap;
|
|
41
|
+
const nextPlacement = hasSpaceAbove || !hasSpaceBelow ? 'top' : 'bottom';
|
|
42
|
+
|
|
43
|
+
let top;
|
|
44
|
+
if (nextPlacement === 'top') {
|
|
45
|
+
top = wrapperRect.top - tooltipRect.height - preferredGap;
|
|
46
|
+
} else {
|
|
47
|
+
top = wrapperRect.bottom + preferredGap;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Center horizontally on the wrapper, then clamp within viewport with margin
|
|
51
|
+
const horizontalMargin = 8;
|
|
52
|
+
let left = wrapperRect.left + wrapperRect.width / 2 - tooltipRect.width / 2;
|
|
53
|
+
left = Math.max(
|
|
54
|
+
horizontalMargin,
|
|
55
|
+
Math.min(left, viewportWidth - tooltipRect.width - horizontalMargin)
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
setPlacement(nextPlacement);
|
|
59
|
+
setTooltipStyle({ top: Math.max(4, top), left });
|
|
60
|
+
}, []);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!showTooltip) return;
|
|
64
|
+
updatePosition();
|
|
65
|
+
const onScroll = () => updatePosition();
|
|
66
|
+
const onResize = () => updatePosition();
|
|
67
|
+
window.addEventListener('scroll', onScroll, true);
|
|
68
|
+
window.addEventListener('resize', onResize);
|
|
69
|
+
return () => {
|
|
70
|
+
window.removeEventListener('scroll', onScroll, true);
|
|
71
|
+
window.removeEventListener('resize', onResize);
|
|
72
|
+
};
|
|
73
|
+
}, [showTooltip, updatePosition]);
|
|
23
74
|
|
|
24
75
|
// Pull definition/route from plugin global data if not provided
|
|
25
76
|
const pluginData = usePluginData('docusaurus-plugin-glossary');
|
|
@@ -29,7 +80,7 @@ export default function GlossaryTerm({ term, definition, routePath = '/glossary'
|
|
|
29
80
|
}
|
|
30
81
|
const terms = (pluginData && pluginData.terms) || [];
|
|
31
82
|
const found = terms.find(
|
|
32
|
-
|
|
83
|
+
t => typeof t.term === 'string' && t.term.toLowerCase() === String(term).toLowerCase()
|
|
33
84
|
);
|
|
34
85
|
return found && found.definition ? found.definition : undefined;
|
|
35
86
|
}, [definition, pluginData, term]);
|
|
@@ -43,7 +94,7 @@ export default function GlossaryTerm({ term, definition, routePath = '/glossary'
|
|
|
43
94
|
const termId = term.toLowerCase().replace(/\s+/g, '-');
|
|
44
95
|
|
|
45
96
|
return (
|
|
46
|
-
<span className={styles.glossaryTermWrapper}>
|
|
97
|
+
<span ref={wrapperRef} className={styles.glossaryTermWrapper}>
|
|
47
98
|
<a
|
|
48
99
|
href={`${effectiveRoutePath}#${termId}`}
|
|
49
100
|
className={styles.glossaryTerm}
|
|
@@ -57,9 +108,19 @@ export default function GlossaryTerm({ term, definition, routePath = '/glossary'
|
|
|
57
108
|
</a>
|
|
58
109
|
{effectiveDefinition && (
|
|
59
110
|
<span
|
|
111
|
+
ref={tooltipRef}
|
|
60
112
|
id={`tooltip-${termId}`}
|
|
61
|
-
className={
|
|
113
|
+
className={
|
|
114
|
+
`${styles.tooltip} ${showTooltip ? styles.tooltipVisible : ''} ` +
|
|
115
|
+
`${placement === 'top' ? styles.tooltipTop : styles.tooltipBottom} ` +
|
|
116
|
+
`${styles.tooltipFloating}`
|
|
117
|
+
}
|
|
62
118
|
role="tooltip"
|
|
119
|
+
style={
|
|
120
|
+
showTooltip && tooltipStyle
|
|
121
|
+
? { top: `${tooltipStyle.top}px`, left: `${tooltipStyle.left}px` }
|
|
122
|
+
: undefined
|
|
123
|
+
}
|
|
63
124
|
>
|
|
64
125
|
<strong>{term}:</strong> {effectiveDefinition}
|
|
65
126
|
</span>
|
|
@@ -113,4 +113,23 @@ describe('GlossaryTerm', () => {
|
|
|
113
113
|
const tooltip = screen.getByRole('tooltip');
|
|
114
114
|
expect(tooltip).toHaveAttribute('id', 'tooltip-api');
|
|
115
115
|
});
|
|
116
|
+
|
|
117
|
+
it('positions tooltip within viewport and adds placement classes', async () => {
|
|
118
|
+
const user = userEvent.setup();
|
|
119
|
+
render(<GlossaryTerm term="Edge" definition="Near the boundary of the viewport" />);
|
|
120
|
+
|
|
121
|
+
const link = screen.getByRole('link');
|
|
122
|
+
await user.hover(link);
|
|
123
|
+
|
|
124
|
+
const tooltip = screen.getByRole('tooltip');
|
|
125
|
+
expect(tooltip).toHaveClass('tooltipVisible');
|
|
126
|
+
expect(tooltip).toHaveClass('tooltipFloating');
|
|
127
|
+
// One of the placement classes should be present
|
|
128
|
+
const hasPlacement =
|
|
129
|
+
tooltip.classList.contains('tooltipTop') || tooltip.classList.contains('tooltipBottom');
|
|
130
|
+
expect(hasPlacement).toBe(true);
|
|
131
|
+
// Inline style should include computed top/left
|
|
132
|
+
expect(tooltip.style.top).toMatch(/px$/);
|
|
133
|
+
expect(tooltip.style.left).toMatch(/px$/);
|
|
134
|
+
});
|
|
116
135
|
});
|
|
@@ -40,25 +40,55 @@
|
|
|
40
40
|
visibility: hidden;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
/* When we compute viewport-safe placement via JS */
|
|
44
|
+
.tooltipFloating {
|
|
45
|
+
position: fixed;
|
|
46
|
+
bottom: auto;
|
|
47
|
+
transform: none;
|
|
48
|
+
left: 0; /* overridden inline */
|
|
49
|
+
top: 0; /* overridden inline */
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* Base arrow removed; arrows are defined per-placement to avoid inversion */
|
|
53
|
+
|
|
54
|
+
/* Arrow for top placement (tooltip above anchor) */
|
|
55
|
+
.tooltipTop::after {
|
|
44
56
|
content: '';
|
|
45
57
|
position: absolute;
|
|
46
|
-
top: 100%;
|
|
47
58
|
left: 50%;
|
|
48
59
|
transform: translateX(-50%);
|
|
60
|
+
bottom: -6px;
|
|
49
61
|
border: 6px solid transparent;
|
|
50
|
-
border-
|
|
62
|
+
border-bottom-color: var(--ifm-background-surface-color);
|
|
63
|
+
}
|
|
64
|
+
.tooltipTop::before {
|
|
65
|
+
content: '';
|
|
66
|
+
position: absolute;
|
|
67
|
+
left: 50%;
|
|
68
|
+
transform: translateX(-50%);
|
|
69
|
+
bottom: -7px;
|
|
70
|
+
border: 7px solid transparent;
|
|
71
|
+
border-bottom-color: var(--ifm-color-emphasis-300);
|
|
51
72
|
}
|
|
52
73
|
|
|
53
|
-
|
|
74
|
+
/* Arrow for bottom placement (tooltip below anchor) */
|
|
75
|
+
.tooltipBottom::after {
|
|
54
76
|
content: '';
|
|
55
77
|
position: absolute;
|
|
56
|
-
top: 100%;
|
|
57
78
|
left: 50%;
|
|
58
79
|
transform: translateX(-50%);
|
|
80
|
+
top: -6px;
|
|
81
|
+
border: 6px solid transparent;
|
|
82
|
+
border-top-color: var(--ifm-background-surface-color);
|
|
83
|
+
}
|
|
84
|
+
.tooltipBottom::before {
|
|
85
|
+
content: '';
|
|
86
|
+
position: absolute;
|
|
87
|
+
left: 50%;
|
|
88
|
+
transform: translateX(-50%);
|
|
89
|
+
top: -7px;
|
|
59
90
|
border: 7px solid transparent;
|
|
60
91
|
border-top-color: var(--ifm-color-emphasis-300);
|
|
61
|
-
margin-top: 1px;
|
|
62
92
|
}
|
|
63
93
|
|
|
64
94
|
.tooltipVisible {
|
|
@@ -79,11 +109,17 @@
|
|
|
79
109
|
border-color: var(--ifm-color-emphasis-400);
|
|
80
110
|
}
|
|
81
111
|
|
|
82
|
-
|
|
112
|
+
/* Dark mode arrow overrides scoped to placement to avoid upside-down arrows */
|
|
113
|
+
[data-theme='dark'] .tooltipTop::after {
|
|
114
|
+
border-bottom-color: var(--ifm-background-surface-color);
|
|
115
|
+
}
|
|
116
|
+
[data-theme='dark'] .tooltipTop::before {
|
|
117
|
+
border-bottom-color: var(--ifm-color-emphasis-400);
|
|
118
|
+
}
|
|
119
|
+
[data-theme='dark'] .tooltipBottom::after {
|
|
83
120
|
border-top-color: var(--ifm-background-surface-color);
|
|
84
121
|
}
|
|
85
|
-
|
|
86
|
-
[data-theme='dark'] .tooltip::before {
|
|
122
|
+
[data-theme='dark'] .tooltipBottom::before {
|
|
87
123
|
border-top-color: var(--ifm-color-emphasis-400);
|
|
88
124
|
}
|
|
89
125
|
|