Skip to content

Commit 864c543

Browse files
authored
Add glossary tooltip feature (#681)
* Create index.tsx Swizzle component to add glossary tooltip function. * Add glossary tooltip function * Add styles for glossary tooltip * Add `scripts/generate-glossary-json.js` to build step * Add `/static/glossary.json` Ignore `/static/glossary.json` since we only need it when building the site in production. * Create GlossaryInjector.tsx * Create GlossaryTooltip.tsx * Create generate-glossary-json.js * Remove unnecessary ignore * Put glossary tooltip styles in ABC order * Refactor code to support tooltips in en-us and ja-jp * Enable i18n * Remove unnecessary code comments * Fix rendering of glossary tooltip On desktop: Increase padding around definitions. On mobile: Add styles to keep the tooltip within view on narrow screens. * Update naming for other tooltip style To avoid confusion with the glossary tooltip, this commit clarifies the naming for the tooltip for the question mark icon in the header that contains version and edition tags.
1 parent 9573ac5 commit 864c543

File tree

6 files changed

+307
-4
lines changed

6 files changed

+307
-4
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"scripts": {
66
"docusaurus": "docusaurus",
77
"start": "docusaurus start",
8-
"build": "docusaurus build",
8+
"build": "docusaurus build && node scripts/generate-glossary-json.js",
99
"swizzle": "docusaurus swizzle",
1010
"deploy": "docusaurus deploy",
1111
"clear": "docusaurus clear",

scripts/generate-glossary-json.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
const fs = require('fs');
2+
const path = require('path');
3+
4+
const glossaries = [
5+
{ src: '../docs/glossary.mdx', output: '../build/docs/glossary.json' },
6+
{ src: '../i18n/versioned_docs/ja-jp/docusaurus-plugin-content-docs/current/glossary.mdx', output: '../build/ja-jp/glossary.json' }
7+
];
8+
9+
const generateGlossaryJson = (glossaryFilePath, outputJsonPath) => {
10+
const glossaryContent = fs.readFileSync(glossaryFilePath, 'utf-8');
11+
const glossaryLines = glossaryContent.split('\n');
12+
13+
let glossary = {};
14+
let currentTerm = '';
15+
16+
glossaryLines.forEach((line) => {
17+
if (line.startsWith('## ')) {
18+
currentTerm = line.replace('## ', '').trim();
19+
} else if (line.startsWith('# ')) {
20+
currentTerm = ''; // Reset the term for heading 1 lines.
21+
} else if (line.trim() !== '' && currentTerm !== '') {
22+
glossary[currentTerm] = line.trim();
23+
}
24+
});
25+
26+
fs.writeFileSync(outputJsonPath, JSON.stringify(glossary, null, 2));
27+
console.log(`${outputJsonPath} generated successfully.`);
28+
};
29+
30+
// Generate both glossaries.
31+
glossaries.forEach(({ src, output }) => generateGlossaryJson(path.join(__dirname, src), path.join(__dirname, output)));
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import React, { useEffect, useState } from 'react';
2+
import ReactDOM from 'react-dom';
3+
import GlossaryTooltip from './GlossaryTooltip';
4+
5+
interface GlossaryInjectorProps {
6+
children: React.ReactNode;
7+
}
8+
9+
const GlossaryInjector: React.FC<GlossaryInjectorProps> = ({ children }) => {
10+
const [glossary, setGlossary] = useState<{ [key: string]: string }>({});
11+
12+
useEffect(() => {
13+
const url = window.location.pathname;
14+
let glossaryPath = '/docs/glossary.json'; // Use the English version as the default glossary.
15+
16+
if (process.env.NODE_ENV === 'production') { // The glossary tooltip works only in production environments.
17+
glossaryPath = url.startsWith('/ja-jp/docs') ? '/ja-jp/glossary.json' : '/docs/glossary.json';
18+
} else {
19+
glossaryPath = url.startsWith('/ja-jp/docs') ? '/ja-jp/glossary.json' : '/docs/glossary.json';
20+
}
21+
22+
fetch(glossaryPath)
23+
.then((res) => {
24+
if (!res.ok) {
25+
throw new Error(`HTTP error! status: ${res.status}`);
26+
}
27+
return res.json();
28+
})
29+
.then(setGlossary)
30+
.catch((err) => console.error('Failed to load glossary:', err));
31+
}, []);
32+
33+
useEffect(() => {
34+
if (Object.keys(glossary).length === 0) return;
35+
36+
// Sort terms in descending order by length to prioritize multi-word terms.
37+
const terms = Object.keys(glossary).sort((a, b) => b.length - a.length);
38+
const processedTerms = new Set<string>(); // Set to track processed terms.
39+
40+
const wrapTermsInTooltips = (node: HTMLElement) => {
41+
const textNodes = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, null, false);
42+
let currentNode: Node | null;
43+
44+
const modifications: { originalNode: Node; newNodes: Node[] }[] = [];
45+
46+
while ((currentNode = textNodes.nextNode())) {
47+
const parentElement = currentNode.parentElement;
48+
49+
// Check if the parent element is a tab title.
50+
const isTabTitle = parentElement && parentElement.closest('.tabs__item'); // Adjust the selector as necessary.
51+
52+
// Check if the parent element is a code block.
53+
const isCodeBlock = parentElement && parentElement.closest('.prism-code'); // Adjust the selector as necessary.
54+
55+
// Check if the parent element is a Card.
56+
const isCard = parentElement && parentElement.closest('.card__body'); // Adjust the selector as necessary.
57+
58+
// Check if the parent element is a Mermaid diagram.
59+
const isMermaidDiagram = parentElement && parentElement.closest('.docusaurus-mermaid-container'); // Adjust the selector as necessary.
60+
61+
// Only wrap terms in tooltips if the parent is within the target div and not in headings or tab titles.
62+
if (
63+
parentElement &&
64+
parentElement.closest('.theme-doc-markdown.markdown') &&
65+
!/^H[1-6]$/.test(parentElement.tagName) && // Skip headings (H1 to H6).
66+
!isTabTitle && // Skip tab titles.
67+
!isCodeBlock && // Skip code blocks.
68+
!isCard && // Skip Cards.
69+
!isMermaidDiagram // Skip Mermaid diagrams.
70+
) {
71+
let currentText = currentNode.textContent!;
72+
const newNodes: Node[] = [];
73+
let hasReplacements = false;
74+
75+
// Create a regex pattern to match all terms (case-sensitive).
76+
const regexPattern = terms.map(term => `(${term})`).join('|');
77+
const regex = new RegExp(regexPattern, 'g');
78+
79+
let lastIndex = 0;
80+
let match: RegExpExecArray | null;
81+
82+
while ((match = regex.exec(currentText))) {
83+
const matchedTerm = match[0];
84+
85+
if (lastIndex < match.index) {
86+
newNodes.push(document.createTextNode(currentText.slice(lastIndex, match.index)));
87+
}
88+
89+
const isFirstMention = !processedTerms.has(matchedTerm);
90+
const isLink = parentElement && parentElement.tagName === 'A'; // Check if the parent is a link.
91+
92+
if (isFirstMention && !isLink) {
93+
// Create a tooltip only if it's the first mention and not a link.
94+
const tooltipWrapper = document.createElement('span');
95+
tooltipWrapper.setAttribute('data-term', matchedTerm);
96+
tooltipWrapper.className = 'glossary-term';
97+
98+
const definition = glossary[matchedTerm]; // Exact match from glossary.
99+
100+
ReactDOM.render(
101+
<GlossaryTooltip term={matchedTerm} definition={definition}>
102+
{matchedTerm}
103+
</GlossaryTooltip>,
104+
tooltipWrapper
105+
);
106+
107+
newNodes.push(tooltipWrapper);
108+
processedTerms.add(matchedTerm); // Mark this term as processed.
109+
} else if (isLink) {
110+
// If it's a link, we skip this mention but do not mark it as processed.
111+
newNodes.push(document.createTextNode(matchedTerm));
112+
} else {
113+
// If it's not the first mention, just add the plain text.
114+
newNodes.push(document.createTextNode(matchedTerm));
115+
}
116+
117+
lastIndex = match.index + matchedTerm.length;
118+
hasReplacements = true;
119+
}
120+
121+
if (lastIndex < currentText.length) {
122+
newNodes.push(document.createTextNode(currentText.slice(lastIndex)));
123+
}
124+
125+
if (hasReplacements) {
126+
modifications.push({ originalNode: currentNode, newNodes });
127+
}
128+
}
129+
}
130+
131+
// Replace the original nodes with new nodes.
132+
modifications.forEach(({ originalNode, newNodes }) => {
133+
const parentElement = originalNode.parentElement;
134+
if (parentElement) {
135+
newNodes.forEach((newNode) => {
136+
parentElement.insertBefore(newNode, originalNode);
137+
});
138+
parentElement.removeChild(originalNode);
139+
}
140+
});
141+
};
142+
143+
// Target the specific div with the class "theme-doc-markdown markdown".
144+
const targetDiv = document.querySelector('.theme-doc-markdown.markdown');
145+
if (targetDiv) {
146+
wrapTermsInTooltips(targetDiv);
147+
}
148+
}, [glossary]);
149+
150+
return <>{children}</>;
151+
};
152+
153+
export default GlossaryInjector;

src/components/GlossaryTooltip.tsx

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import React, { useEffect, useRef, useState } from 'react';
2+
3+
interface GlossaryTooltipProps {
4+
term: string;
5+
definition: string;
6+
children: React.ReactNode;
7+
}
8+
9+
const GlossaryTooltip: React.FC<GlossaryTooltipProps> = ({ term, definition, children }) => {
10+
const tooltipRef = useRef<HTMLDivElement>(null);
11+
const [tooltipPosition, setTooltipPosition] = useState<{ top: number; left: number } | null>(null);
12+
13+
const handleMouseEnter = (event: React.MouseEvent) => {
14+
const target = event.currentTarget;
15+
16+
// Get the bounding rectangle of the target element.
17+
const rect = target.getBoundingClientRect();
18+
19+
// Calculate tooltip position.
20+
const tooltipTop = rect.bottom + window.scrollY; // Position below the term.
21+
const tooltipLeft = rect.left + window.scrollX; // Align with the left edge of the term.
22+
23+
setTooltipPosition({ top: tooltipTop, left: tooltipLeft });
24+
};
25+
26+
const handleMouseLeave = () => {
27+
setTooltipPosition(null);
28+
};
29+
30+
return (
31+
<>
32+
<span
33+
onMouseEnter={handleMouseEnter}
34+
onMouseLeave={handleMouseLeave}
35+
className="glossary-term"
36+
>
37+
{children}
38+
</span>
39+
40+
{tooltipPosition && (
41+
<div
42+
ref={tooltipRef}
43+
className="tooltip-glossary"
44+
style={{
45+
top: tooltipPosition.top,
46+
left: tooltipPosition.left,
47+
position: 'absolute',
48+
}}
49+
>
50+
{definition}
51+
</div>
52+
)}
53+
</>
54+
);
55+
};
56+
57+
export default GlossaryTooltip;

src/css/custom.css

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -173,14 +173,14 @@ html[data-theme="dark"] a[class^='fa-solid fa-circle-question'] {
173173
}
174174
}
175175

176-
/* Tooltip container */
176+
/* Edition tag bar: Question-mark icon tooltip container */
177177
.tooltip {
178178
position: relative;
179179
display: inline-block;
180180
/* border-bottom: 1px dotted black; */ /* If you want dots under the hoverable text */
181181
}
182182

183-
/* Tooltip text */
183+
/* Question-mark icon tooltip text */
184184
.tooltip .tooltiptext {
185185
background-color: #6c6c6c;
186186
border-radius: 5px;
@@ -197,7 +197,51 @@ html[data-theme="dark"] a[class^='fa-solid fa-circle-question'] {
197197
left: 125%;
198198
}
199199

200-
/* Show the tooltip text when you mouse over the tooltip container */
200+
/* Show the Question-mark icon tooltip text when you mouse over the tooltip container */
201201
.tooltip:hover .tooltiptext {
202202
visibility: visible;
203203
}
204+
205+
/* Glossary tooltip styles */
206+
.glossary-term {
207+
cursor: help;
208+
text-decoration: underline dotted;
209+
}
210+
211+
.tooltip-glossary {
212+
background-color: #f6f6f6;
213+
border: 1px solid #ccc;
214+
border-radius: 4px;
215+
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
216+
left: 15px;
217+
opacity: 1;
218+
position: absolute;
219+
padding: 10px 15px;
220+
transform: translateY(5px);
221+
visibility: visible;
222+
white-space: normal;
223+
width: 460px;
224+
z-index: 10;
225+
}
226+
227+
html[data-theme="dark"] .tooltip-glossary {
228+
background-color: var(--ifm-dropdown-background-color);
229+
border: 1px solid var(--ifm-table-border-color);
230+
border-radius: 4px;
231+
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
232+
opacity: 1;
233+
position: absolute;
234+
padding: 10px 15px;
235+
transform: translateY(5px);
236+
visibility: visible;
237+
white-space: normal;
238+
width: 460px;
239+
z-index: 10;
240+
}
241+
242+
@media (max-width: 997px) {
243+
.tooltip-glossary {
244+
left: 15px !important;
245+
width: 333px !important;
246+
}
247+
}

src/theme/MDXContent/index.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import React from 'react';
2+
import MDXContent from '@theme-original/MDXContent';
3+
import type MDXContentType from '@theme/MDXContent';
4+
import type {WrapperProps} from '@docusaurus/types';
5+
import GlossaryInjector from '../../../src/components/GlossaryInjector';
6+
7+
type Props = WrapperProps<typeof MDXContentType>;
8+
9+
export default function MDXContentWrapper(props: Props, { children }): JSX.Element {
10+
return (
11+
<>
12+
<MDXContent {...props} />
13+
<GlossaryInjector>
14+
{children}
15+
</GlossaryInjector>
16+
</>
17+
);
18+
}

0 commit comments

Comments
 (0)