Skip to content

Commit 22cc9cd

Browse files
authored
Glossary terms popover (#464)
checkpoint but without turbopack
1 parent c46e691 commit 22cc9cd

File tree

7 files changed

+536
-9
lines changed

7 files changed

+536
-9
lines changed

app/_components/glossary-term.tsx

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"use client";
2+
3+
import {
4+
Popover,
5+
PopoverContent,
6+
PopoverTrigger,
7+
} from "@arcadeai/design-system";
8+
import Link from "next/link";
9+
import { useState } from "react";
10+
11+
const MAX_DEFINITION_LENGTH = 200;
12+
13+
type GlossaryTermProps = {
14+
term: string;
15+
definition: string;
16+
link: string;
17+
children?: React.ReactNode;
18+
};
19+
20+
export function GlossaryTerm({
21+
term,
22+
definition,
23+
link,
24+
children,
25+
}: GlossaryTermProps) {
26+
const [open, setOpen] = useState(false);
27+
28+
// Use children if provided, otherwise use term
29+
const displayText = children || term;
30+
31+
// Truncate long definitions for popover display
32+
const truncatedDef =
33+
definition.length > MAX_DEFINITION_LENGTH
34+
? `${definition.slice(0, MAX_DEFINITION_LENGTH)}...`
35+
: definition;
36+
37+
return (
38+
<Popover onOpenChange={setOpen} open={open}>
39+
<PopoverTrigger asChild>
40+
<button
41+
className="cursor-help border-gray-400 border-b border-dotted bg-transparent p-0 font-inherit text-inherit transition-colors hover:border-gray-600 dark:border-gray-600 dark:hover:border-gray-400"
42+
onMouseEnter={() => setOpen(true)}
43+
onMouseLeave={() => setOpen(false)}
44+
type="button"
45+
>
46+
{displayText}
47+
</button>
48+
</PopoverTrigger>
49+
<PopoverContent
50+
align="center"
51+
className="w-80 rounded-lg border border-gray-200 bg-white p-4 shadow-lg dark:border-gray-700 dark:bg-gray-900"
52+
onMouseEnter={() => setOpen(true)}
53+
onMouseLeave={() => setOpen(false)}
54+
side="top"
55+
>
56+
<div className="space-y-2">
57+
<h4 className="font-semibold text-gray-900 text-sm dark:text-gray-100">
58+
💡 {term}
59+
</h4>
60+
<p className="text-gray-600 text-xs leading-relaxed dark:text-gray-400">
61+
{truncatedDef}
62+
</p>
63+
<Link
64+
className="inline-block text-blue-600 text-xs hover:underline dark:text-blue-400"
65+
href={link}
66+
onClick={() => setOpen(false)}
67+
>
68+
View in glossary →
69+
</Link>
70+
</div>
71+
</PopoverContent>
72+
</Popover>
73+
);
74+
}

lib/glossary-parser.ts

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
4+
export type GlossaryTerm = {
5+
term: string;
6+
aliases: string[];
7+
definition: string;
8+
section: string;
9+
link: string;
10+
isSubTerm: boolean;
11+
parentTerm?: string;
12+
};
13+
14+
// Regex patterns for parsing markdown headers
15+
const SECTION_HEADER_REGEX = /^## /;
16+
const MAIN_TERM_HEADER_REGEX = /^### /;
17+
const SUB_TERM_HEADER_REGEX = /^#### /;
18+
const ACRONYM_REGEX = /^([A-Z]+)\s*\(([^)]+)\)/;
19+
const QUOTED_TERM_REGEX = /^['"](.+)['"]$/;
20+
21+
/**
22+
* Parse the glossary MDX file and extract all terms with their definitions
23+
*/
24+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: parsing markdown structure requires complex logic
25+
export function parseGlossary(glossaryPath: string): GlossaryTerm[] {
26+
const absolutePath = path.isAbsolute(glossaryPath)
27+
? glossaryPath
28+
: path.join(process.cwd(), glossaryPath);
29+
30+
const content = fs.readFileSync(absolutePath, "utf-8");
31+
const terms: GlossaryTerm[] = [];
32+
33+
// Split content by lines
34+
const lines = content.split("\n");
35+
36+
let currentSection = "";
37+
let currentTerm: string | null = null;
38+
let currentParentTerm: string | null = null;
39+
let currentDefinition: string[] = [];
40+
let isSubTerm = false;
41+
let inCodeBlock = false;
42+
43+
for (const line of lines) {
44+
// Track code blocks to skip them
45+
if (line.trim().startsWith("```")) {
46+
inCodeBlock = !inCodeBlock;
47+
continue;
48+
}
49+
50+
if (inCodeBlock) {
51+
continue;
52+
}
53+
54+
// Check for section headers (## Section)
55+
if (line.startsWith("## ") && !line.startsWith("### ")) {
56+
// Save previous term if exists
57+
if (currentTerm) {
58+
terms.push(
59+
createGlossaryTerm(
60+
currentTerm,
61+
currentDefinition,
62+
currentSection,
63+
isSubTerm,
64+
currentParentTerm
65+
)
66+
);
67+
}
68+
69+
currentSection = line.replace(SECTION_HEADER_REGEX, "").trim();
70+
currentTerm = null;
71+
currentDefinition = [];
72+
isSubTerm = false;
73+
currentParentTerm = null;
74+
continue;
75+
}
76+
77+
// Check for main term headers (### Term)
78+
if (line.startsWith("### ")) {
79+
// Save previous term if exists
80+
if (currentTerm) {
81+
terms.push(
82+
createGlossaryTerm(
83+
currentTerm,
84+
currentDefinition,
85+
currentSection,
86+
isSubTerm,
87+
currentParentTerm
88+
)
89+
);
90+
}
91+
92+
currentTerm = line.replace(MAIN_TERM_HEADER_REGEX, "").trim();
93+
currentDefinition = [];
94+
isSubTerm = false;
95+
currentParentTerm = null;
96+
continue;
97+
}
98+
99+
// Check for sub-term headers (#### Sub-term)
100+
if (line.startsWith("#### ")) {
101+
// Save previous term if exists
102+
if (currentTerm) {
103+
terms.push(
104+
createGlossaryTerm(
105+
currentTerm,
106+
currentDefinition,
107+
currentSection,
108+
isSubTerm,
109+
currentParentTerm
110+
)
111+
);
112+
}
113+
114+
const parentTerm = currentTerm;
115+
currentTerm = line.replace(SUB_TERM_HEADER_REGEX, "").trim();
116+
currentDefinition = [];
117+
isSubTerm = true;
118+
currentParentTerm = parentTerm;
119+
continue;
120+
}
121+
122+
// Collect definition content
123+
if (
124+
currentTerm &&
125+
line.trim() &&
126+
!line.startsWith("#") &&
127+
!(
128+
line.trim().startsWith("```") ||
129+
line.trim().startsWith("graph ") ||
130+
line.trim().startsWith("_Learn more")
131+
)
132+
) {
133+
currentDefinition.push(line.trim());
134+
}
135+
}
136+
137+
// Save the last term
138+
if (currentTerm) {
139+
terms.push(
140+
createGlossaryTerm(
141+
currentTerm,
142+
currentDefinition,
143+
currentSection,
144+
isSubTerm,
145+
currentParentTerm
146+
)
147+
);
148+
}
149+
150+
return terms;
151+
}
152+
153+
/**
154+
* Create a glossary term object with all metadata
155+
*/
156+
// biome-ignore lint/nursery/useMaxParams: all parameters necessary for term metadata
157+
function createGlossaryTerm(
158+
term: string,
159+
definitionLines: string[],
160+
section: string,
161+
isSubTerm: boolean,
162+
parentTerm: string | null
163+
): GlossaryTerm {
164+
// Clean up the term (remove quotes, parentheses content for main term)
165+
const cleanTerm = term.replace(/^['"]|['"]$/g, "");
166+
167+
// Extract aliases
168+
const aliases = generateAliases(cleanTerm);
169+
170+
// Create definition from collected lines
171+
const definition = definitionLines.join(" ").trim();
172+
173+
// Generate anchor link
174+
const anchor = cleanTerm
175+
.toLowerCase()
176+
.replace(/[^a-z0-9\s-]/g, "")
177+
.replace(/\s+/g, "-");
178+
179+
const link = `/home/glossary#${anchor}`;
180+
181+
return {
182+
term: cleanTerm,
183+
aliases,
184+
definition,
185+
section,
186+
link,
187+
isSubTerm,
188+
parentTerm: parentTerm || undefined,
189+
};
190+
}
191+
192+
/**
193+
* Generate aliases for a term (singular/plural, case variations, etc.)
194+
*/
195+
function generateAliases(term: string): string[] {
196+
const aliases = new Set<string>();
197+
198+
// Add the original term
199+
aliases.add(term);
200+
201+
// Add lowercase version
202+
aliases.add(term.toLowerCase());
203+
204+
// Handle acronyms with full form (e.g., "MCP (Model Context Protocol)")
205+
const acronymMatch = term.match(ACRONYM_REGEX);
206+
if (acronymMatch) {
207+
aliases.add(acronymMatch[1]); // Acronym only
208+
aliases.add(acronymMatch[2]); // Full form
209+
aliases.add(acronymMatch[1].toLowerCase());
210+
aliases.add(acronymMatch[2].toLowerCase());
211+
}
212+
213+
// Handle quoted terms (e.g., 'agent' or "tool")
214+
const quotedMatch = term.match(QUOTED_TERM_REGEX);
215+
if (quotedMatch) {
216+
const unquoted = quotedMatch[1];
217+
aliases.add(unquoted);
218+
aliases.add(unquoted.toLowerCase());
219+
}
220+
221+
// Add plural/singular variations
222+
if (term.endsWith("s") && !term.endsWith("ss")) {
223+
// If term ends in 's', try removing it for singular
224+
aliases.add(term.slice(0, -1));
225+
aliases.add(term.slice(0, -1).toLowerCase());
226+
} else {
227+
// Add plural forms
228+
aliases.add(`${term}s`);
229+
aliases.add(`${term}s`.toLowerCase());
230+
}
231+
232+
// Handle compound terms with spaces
233+
if (term.includes(" ")) {
234+
// Add version with different capitalization
235+
aliases.add(term.toLowerCase());
236+
aliases.add(term.toUpperCase());
237+
238+
// Title case
239+
const titleCase = term
240+
.split(" ")
241+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
242+
.join(" ");
243+
aliases.add(titleCase);
244+
}
245+
246+
return Array.from(aliases);
247+
}
248+
249+
/**
250+
* Sort terms by length (longest first) to match longer terms before shorter ones
251+
* This prevents "Tool" from matching before "Tool Context"
252+
*/
253+
export function sortTermsByLength(terms: GlossaryTerm[]): GlossaryTerm[] {
254+
return [...terms].sort((a, b) => {
255+
const maxLengthA = Math.max(...a.aliases.map((alias) => alias.length));
256+
const maxLengthB = Math.max(...b.aliases.map((alias) => alias.length));
257+
return maxLengthB - maxLengthA;
258+
});
259+
}

0 commit comments

Comments
 (0)