docusaurus-plugin-glossary 1.1.0 → 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 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 | Type | Default | Description |
266
- | -------------- | ------- | -------------------------- | ----------------------------------------------------- |
267
- | `glossaryPath` | string | `'glossary/glossary.json'` | Path to glossary JSON file relative to site directory |
268
- | `routePath` | string | `'/glossary'` | URL path for glossary page |
269
- | `autoLinkTerms`| boolean | `true` | Enable automatic term detection in markdown (requires remark plugin configuration) |
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 open an issue or submit a pull request.
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 (Array.isArray(plugin) && plugin[0]) {
46
- // Check if it's our remark plugin by comparing the function reference
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
  }
@@ -81,11 +79,11 @@ function glossaryPlugin(context, options = {}) {
81
79
  },
82
80
 
83
81
  async contentLoaded({ content, actions }) {
84
- const { createData, addRoute } = actions;
82
+ const { createData, addRoute, setGlobalData } = actions;
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',
@@ -104,6 +102,12 @@ function glossaryPlugin(context, options = {}) {
104
102
  glossaryData: glossaryDataPath,
105
103
  },
106
104
  });
105
+
106
+ // Expose global data for runtime lookups (used by GlossaryTerm)
107
+ setGlobalData({
108
+ terms: content.terms || [],
109
+ routePath,
110
+ });
107
111
  },
108
112
 
109
113
  getThemePath() {
@@ -127,16 +131,16 @@ glossaryPlugin.remarkPlugin = require('./remark/glossary-terms');
127
131
  /**
128
132
  * Helper function to get the configured remark plugin
129
133
  * This can be used in docusaurus.config.js markdown configuration
130
- *
134
+ *
131
135
  * @param {object} pluginOptions - Plugin options from docusaurus.config.js
132
136
  * @param {object} context - Docusaurus context
133
137
  * @returns {function} Configured remark plugin
134
138
  */
135
- glossaryPlugin.getRemarkPlugin = function(pluginOptions, context) {
136
- const {
137
- glossaryPath = 'glossary/glossary.json',
139
+ glossaryPlugin.getRemarkPlugin = function (pluginOptions, context) {
140
+ const {
141
+ glossaryPath = 'glossary/glossary.json',
138
142
  routePath = '/glossary',
139
- siteDir = context.siteDir
143
+ siteDir = context.siteDir,
140
144
  } = pluginOptions;
141
145
 
142
146
  return [
@@ -145,7 +149,7 @@ glossaryPlugin.getRemarkPlugin = function(pluginOptions, context) {
145
149
  glossaryPath,
146
150
  routePath,
147
151
  siteDir,
148
- }
152
+ },
149
153
  ];
150
154
  };
151
155
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docusaurus-plugin-glossary",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "A Docusaurus plugin for creating and managing glossary terms with auto-generated pages and inline tooltips",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -15,6 +15,9 @@
15
15
  "test": "jest",
16
16
  "test:watch": "jest --watch",
17
17
  "test:coverage": "jest --coverage",
18
+ "example:start": "npm --prefix examples/docusaurus-v3 run start",
19
+ "example:build": "npm --prefix examples/docusaurus-v3 run build",
20
+ "example:serve": "npm --prefix examples/docusaurus-v3 run serve",
18
21
  "prepublishOnly": "npm test",
19
22
  "version": "npm version",
20
23
  "format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css,md}\"",
@@ -1,10 +1,12 @@
1
- const visit = require('unist-util-visit');
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 (tree) => tree;
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 afterChar = index + lowerTerm.length < textLower.length
82
- ? textLower[index + lowerTerm.length]
81
+ const afterIndex = index + lowerTerm.length;
82
+ const afterChar = afterIndex < textLower.length
83
+ ? textLower[afterIndex]
83
84
  : ' ';
84
85
 
85
- // Word boundary check (alphanumeric characters)
86
- const isWordBoundary =
87
- !/\w/.test(beforeChar) && !/\w/.test(afterChar);
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: term.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 + term.length)
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 (tree) => {
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 (replacements.length > 1 ||
189
- (replacements.length === 1 && replacements[0].type !== 'text')) {
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: "import GlossaryTerm from '@theme/GlossaryTerm';",
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 = Array.isArray(tree.children) && tree.children.some(
245
- (n) => n.type === 'mdxjsEsm' && typeof n.value === 'string' && n.value.includes("@theme/GlossaryTerm")
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,4 +1,5 @@
1
- import React, { useState } from 'react';
1
+ import React, { useMemo, useState, useRef, useEffect, useCallback } from 'react';
2
+ import { usePluginData } from '@docusaurus/useGlobalData';
2
3
  import styles from './styles.module.css';
3
4
 
4
5
  /**
@@ -19,14 +20,83 @@ import styles from './styles.module.css';
19
20
  */
20
21
  export default function GlossaryTerm({ term, definition, routePath = '/glossary', children }) {
21
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]);
74
+
75
+ // Pull definition/route from plugin global data if not provided
76
+ const pluginData = usePluginData('docusaurus-plugin-glossary');
77
+ const effectiveDefinition = useMemo(() => {
78
+ if (definition && typeof definition === 'string' && definition.length > 0) {
79
+ return definition;
80
+ }
81
+ const terms = (pluginData && pluginData.terms) || [];
82
+ const found = terms.find(
83
+ t => typeof t.term === 'string' && t.term.toLowerCase() === String(term).toLowerCase()
84
+ );
85
+ return found && found.definition ? found.definition : undefined;
86
+ }, [definition, pluginData, term]);
87
+
88
+ const effectiveRoutePath = useMemo(() => {
89
+ if (routePath && typeof routePath === 'string' && routePath.length > 0) return routePath;
90
+ return (pluginData && pluginData.routePath) || '/glossary';
91
+ }, [pluginData, routePath]);
22
92
 
23
93
  const displayText = children || term;
24
94
  const termId = term.toLowerCase().replace(/\s+/g, '-');
25
95
 
26
96
  return (
27
- <span className={styles.glossaryTermWrapper}>
97
+ <span ref={wrapperRef} className={styles.glossaryTermWrapper}>
28
98
  <a
29
- href={`${routePath}#${termId}`}
99
+ href={`${effectiveRoutePath}#${termId}`}
30
100
  className={styles.glossaryTerm}
31
101
  onMouseEnter={() => setShowTooltip(true)}
32
102
  onMouseLeave={() => setShowTooltip(false)}
@@ -36,13 +106,23 @@ export default function GlossaryTerm({ term, definition, routePath = '/glossary'
36
106
  >
37
107
  {displayText}
38
108
  </a>
39
- {definition && (
109
+ {effectiveDefinition && (
40
110
  <span
111
+ ref={tooltipRef}
41
112
  id={`tooltip-${termId}`}
42
- className={`${styles.tooltip} ${showTooltip ? styles.tooltipVisible : ''}`}
113
+ className={
114
+ `${styles.tooltip} ${showTooltip ? styles.tooltipVisible : ''} ` +
115
+ `${placement === 'top' ? styles.tooltipTop : styles.tooltipBottom} ` +
116
+ `${styles.tooltipFloating}`
117
+ }
43
118
  role="tooltip"
119
+ style={
120
+ showTooltip && tooltipStyle
121
+ ? { top: `${tooltipStyle.top}px`, left: `${tooltipStyle.left}px` }
122
+ : undefined
123
+ }
44
124
  >
45
- <strong>{term}:</strong> {definition}
125
+ <strong>{term}:</strong> {effectiveDefinition}
46
126
  </span>
47
127
  )}
48
128
  </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
- .tooltip::after {
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-top-color: var(--ifm-background-surface-color);
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
- .tooltip::before {
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
- [data-theme='dark'] .tooltip::after {
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