diff --git a/package.json b/package.json index 1dd3dcc1..23dcbe71 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "scripts": { "docusaurus": "docusaurus", "start": "docusaurus start", - "build": "docusaurus build", + "build": "docusaurus build && node scripts/generate-glossary-json.js", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", "clear": "docusaurus clear", diff --git a/scripts/generate-glossary-json.js b/scripts/generate-glossary-json.js new file mode 100644 index 00000000..c192c244 --- /dev/null +++ b/scripts/generate-glossary-json.js @@ -0,0 +1,31 @@ +const fs = require('fs'); +const path = require('path'); + +const glossaries = [ + { src: '../docs/glossary.mdx', output: '../build/docs/glossary.json' }, + { src: '../i18n/versioned_docs/ja-jp/docusaurus-plugin-content-docs/current/glossary.mdx', output: '../build/ja-jp/glossary.json' } +]; + +const generateGlossaryJson = (glossaryFilePath, outputJsonPath) => { + const glossaryContent = fs.readFileSync(glossaryFilePath, 'utf-8'); + const glossaryLines = glossaryContent.split('\n'); + + let glossary = {}; + let currentTerm = ''; + + glossaryLines.forEach((line) => { + if (line.startsWith('## ')) { + currentTerm = line.replace('## ', '').trim(); + } else if (line.startsWith('# ')) { + currentTerm = ''; // Reset the term for heading 1 lines. + } else if (line.trim() !== '' && currentTerm !== '') { + glossary[currentTerm] = line.trim(); + } + }); + + fs.writeFileSync(outputJsonPath, JSON.stringify(glossary, null, 2)); + console.log(`${outputJsonPath} generated successfully.`); +}; + +// Generate both glossaries. +glossaries.forEach(({ src, output }) => generateGlossaryJson(path.join(__dirname, src), path.join(__dirname, output))); diff --git a/src/components/GlossaryInjector.tsx b/src/components/GlossaryInjector.tsx new file mode 100644 index 00000000..18ab7518 --- /dev/null +++ b/src/components/GlossaryInjector.tsx @@ -0,0 +1,153 @@ +import React, { useEffect, useState } from 'react'; +import ReactDOM from 'react-dom'; +import GlossaryTooltip from './GlossaryTooltip'; + +interface GlossaryInjectorProps { + children: React.ReactNode; +} + +const GlossaryInjector: React.FC = ({ children }) => { + const [glossary, setGlossary] = useState<{ [key: string]: string }>({}); + + useEffect(() => { + const url = window.location.pathname; + let glossaryPath = '/docs/glossary.json'; // Use the English version as the default glossary. + + if (process.env.NODE_ENV === 'production') { // The glossary tooltip works only in production environments. + glossaryPath = url.startsWith('/ja-jp/docs') ? '/ja-jp/glossary.json' : '/docs/glossary.json'; + } else { + glossaryPath = url.startsWith('/ja-jp/docs') ? '/ja-jp/glossary.json' : '/docs/glossary.json'; + } + + fetch(glossaryPath) + .then((res) => { + if (!res.ok) { + throw new Error(`HTTP error! status: ${res.status}`); + } + return res.json(); + }) + .then(setGlossary) + .catch((err) => console.error('Failed to load glossary:', err)); + }, []); + + useEffect(() => { + if (Object.keys(glossary).length === 0) return; + + // Sort terms in descending order by length to prioritize multi-word terms. + const terms = Object.keys(glossary).sort((a, b) => b.length - a.length); + const processedTerms = new Set(); // Set to track processed terms. + + const wrapTermsInTooltips = (node: HTMLElement) => { + const textNodes = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, null, false); + let currentNode: Node | null; + + const modifications: { originalNode: Node; newNodes: Node[] }[] = []; + + while ((currentNode = textNodes.nextNode())) { + const parentElement = currentNode.parentElement; + + // Check if the parent element is a tab title. + const isTabTitle = parentElement && parentElement.closest('.tabs__item'); // Adjust the selector as necessary. + + // Check if the parent element is a code block. + const isCodeBlock = parentElement && parentElement.closest('.prism-code'); // Adjust the selector as necessary. + + // Check if the parent element is a Card. + const isCard = parentElement && parentElement.closest('.card__body'); // Adjust the selector as necessary. + + // Check if the parent element is a Mermaid diagram. + const isMermaidDiagram = parentElement && parentElement.closest('.docusaurus-mermaid-container'); // Adjust the selector as necessary. + + // Only wrap terms in tooltips if the parent is within the target div and not in headings or tab titles. + if ( + parentElement && + parentElement.closest('.theme-doc-markdown.markdown') && + !/^H[1-6]$/.test(parentElement.tagName) && // Skip headings (H1 to H6). + !isTabTitle && // Skip tab titles. + !isCodeBlock && // Skip code blocks. + !isCard && // Skip Cards. + !isMermaidDiagram // Skip Mermaid diagrams. + ) { + let currentText = currentNode.textContent!; + const newNodes: Node[] = []; + let hasReplacements = false; + + // Create a regex pattern to match all terms (case-sensitive). + const regexPattern = terms.map(term => `(${term})`).join('|'); + const regex = new RegExp(regexPattern, 'g'); + + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = regex.exec(currentText))) { + const matchedTerm = match[0]; + + if (lastIndex < match.index) { + newNodes.push(document.createTextNode(currentText.slice(lastIndex, match.index))); + } + + const isFirstMention = !processedTerms.has(matchedTerm); + const isLink = parentElement && parentElement.tagName === 'A'; // Check if the parent is a link. + + if (isFirstMention && !isLink) { + // Create a tooltip only if it's the first mention and not a link. + const tooltipWrapper = document.createElement('span'); + tooltipWrapper.setAttribute('data-term', matchedTerm); + tooltipWrapper.className = 'glossary-term'; + + const definition = glossary[matchedTerm]; // Exact match from glossary. + + ReactDOM.render( + + {matchedTerm} + , + tooltipWrapper + ); + + newNodes.push(tooltipWrapper); + processedTerms.add(matchedTerm); // Mark this term as processed. + } else if (isLink) { + // If it's a link, we skip this mention but do not mark it as processed. + newNodes.push(document.createTextNode(matchedTerm)); + } else { + // If it's not the first mention, just add the plain text. + newNodes.push(document.createTextNode(matchedTerm)); + } + + lastIndex = match.index + matchedTerm.length; + hasReplacements = true; + } + + if (lastIndex < currentText.length) { + newNodes.push(document.createTextNode(currentText.slice(lastIndex))); + } + + if (hasReplacements) { + modifications.push({ originalNode: currentNode, newNodes }); + } + } + } + + // Replace the original nodes with new nodes. + modifications.forEach(({ originalNode, newNodes }) => { + const parentElement = originalNode.parentElement; + if (parentElement) { + newNodes.forEach((newNode) => { + parentElement.insertBefore(newNode, originalNode); + }); + parentElement.removeChild(originalNode); + } + }); + }; + + // Target the specific div with the class "theme-doc-markdown markdown". + const targetDiv = document.querySelector('.theme-doc-markdown.markdown'); + if (targetDiv) { + wrapTermsInTooltips(targetDiv); + } + }, [glossary]); + + return <>{children}; +}; + +export default GlossaryInjector; diff --git a/src/components/GlossaryTooltip.tsx b/src/components/GlossaryTooltip.tsx new file mode 100644 index 00000000..eb4c66d0 --- /dev/null +++ b/src/components/GlossaryTooltip.tsx @@ -0,0 +1,57 @@ +import React, { useEffect, useRef, useState } from 'react'; + +interface GlossaryTooltipProps { + term: string; + definition: string; + children: React.ReactNode; +} + +const GlossaryTooltip: React.FC = ({ term, definition, children }) => { + const tooltipRef = useRef(null); + const [tooltipPosition, setTooltipPosition] = useState<{ top: number; left: number } | null>(null); + + const handleMouseEnter = (event: React.MouseEvent) => { + const target = event.currentTarget; + + // Get the bounding rectangle of the target element. + const rect = target.getBoundingClientRect(); + + // Calculate tooltip position. + const tooltipTop = rect.bottom + window.scrollY; // Position below the term. + const tooltipLeft = rect.left + window.scrollX; // Align with the left edge of the term. + + setTooltipPosition({ top: tooltipTop, left: tooltipLeft }); + }; + + const handleMouseLeave = () => { + setTooltipPosition(null); + }; + + return ( + <> + + {children} + + + {tooltipPosition && ( +
+ {definition} +
+ )} + + ); +}; + +export default GlossaryTooltip; diff --git a/src/css/custom.css b/src/css/custom.css index 3f900cd6..53e6c4c9 100644 --- a/src/css/custom.css +++ b/src/css/custom.css @@ -173,14 +173,14 @@ html[data-theme="dark"] a[class^='fa-solid fa-circle-question'] { } } -/* Tooltip container */ +/* Edition tag bar: Question-mark icon tooltip container */ .tooltip { position: relative; display: inline-block; /* border-bottom: 1px dotted black; */ /* If you want dots under the hoverable text */ } -/* Tooltip text */ +/* Question-mark icon tooltip text */ .tooltip .tooltiptext { background-color: #6c6c6c; border-radius: 5px; @@ -197,7 +197,51 @@ html[data-theme="dark"] a[class^='fa-solid fa-circle-question'] { left: 125%; } -/* Show the tooltip text when you mouse over the tooltip container */ +/* Show the Question-mark icon tooltip text when you mouse over the tooltip container */ .tooltip:hover .tooltiptext { visibility: visible; } + +/* Glossary tooltip styles */ +.glossary-term { + cursor: help; + text-decoration: underline dotted; +} + +.tooltip-glossary { + background-color: #f6f6f6; + border: 1px solid #ccc; + border-radius: 4px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); + left: 15px; + opacity: 1; + position: absolute; + padding: 10px 15px; + transform: translateY(5px); + visibility: visible; + white-space: normal; + width: 460px; + z-index: 10; +} + +html[data-theme="dark"] .tooltip-glossary { + background-color: var(--ifm-dropdown-background-color); + border: 1px solid var(--ifm-table-border-color); + border-radius: 4px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); + opacity: 1; + position: absolute; + padding: 10px 15px; + transform: translateY(5px); + visibility: visible; + white-space: normal; + width: 460px; + z-index: 10; +} + +@media (max-width: 997px) { + .tooltip-glossary { + left: 15px !important; + width: 333px !important; + } +} diff --git a/src/theme/MDXContent/index.tsx b/src/theme/MDXContent/index.tsx new file mode 100644 index 00000000..abed1cab --- /dev/null +++ b/src/theme/MDXContent/index.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import MDXContent from '@theme-original/MDXContent'; +import type MDXContentType from '@theme/MDXContent'; +import type {WrapperProps} from '@docusaurus/types'; +import GlossaryInjector from '../../../src/components/GlossaryInjector'; + +type Props = WrapperProps; + +export default function MDXContentWrapper(props: Props, { children }): JSX.Element { + return ( + <> + + + {children} + + + ); +}