import glossaryStyles from "../../components/Glossary/styles.module.css"; import { formatTag, groupByFirstLetter, groupByTag } from "../../components/Glossary/utils"; import { type GlossaryHelperTerm, safeBooleanExtract, safeStringArrayExtract, safeStringExtract, } from "../utils/glossaryUtils"; import ErrorBoundary from "./ErrorBoundary"; import GlossaryHelper from "./GlossaryHelper"; import sharedStyles from "./shared.module.css"; import styles from "./styles.module.css"; import type { PropSidebarItem } from "@docusaurus/plugin-content-docs"; import clsx from "clsx"; import React, { ReactNode, useCallback, useMemo } from "react"; type SidebarDocLike = Extract; interface GlossaryDocCardListProps { glossaryPool: SidebarDocLike[]; className?: string; } /** * Cache structure for glossary term data - key is guaranteed to be a string docId */ interface TermCache { [docId: string]: { termName: string; tags: string[]; shortDescription: string; longDescription: string; authentikSpecific: boolean; }; } /** * Safely extracts label from sidebar item */ function getLabelFromItem(item: PropSidebarItem): string { if ("label" in item && typeof item.label === "string") return item.label; return ""; } /** * Splits text into paragraphs for better formatting in long descriptions */ function splitParagraphs(text: string): string[] { return text .split(/\n{2,}/) .map((part) => part.trim()) .filter((part) => part.length > 0); } /** * Highlights matching search terms in text */ function highlightText( text: string, searchFilter: string, keyPrefix: string, ): (string | React.ReactElement)[] { if (!searchFilter) return [text]; const escaped = searchFilter.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const regex = new RegExp(`(${escaped})`, "gi"); const matches = text.split(regex); return matches.map((part, i) => { if (part.toLowerCase() === searchFilter.toLowerCase()) { return ( {part} ); } return part; }); } /** * Converts backticks to code elements and highlights search matches */ function renderMarkdown( text: string, searchFilter: string = "", keyPrefix: string = "md", ): (string | React.ReactElement)[] { const parts: (string | React.ReactElement)[] = []; const regex = /`([^`]+)`/g; let lastIndex = 0; let match; while ((match = regex.exec(text)) !== null) { // Add text before the match (with highlighting) if (match.index > lastIndex) { const textBefore = text.substring(lastIndex, match.index); parts.push(...highlightText(textBefore, searchFilter, `${keyPrefix}-${lastIndex}`)); } // Add the code element (with highlighting inside) const codeContent = highlightText( match[1] ?? "", searchFilter, `${keyPrefix}-code-${match.index}`, ); parts.push({codeContent}); lastIndex = regex.lastIndex; } // Add remaining text (with highlighting) if (lastIndex < text.length) { const remaining = text.substring(lastIndex); parts.push(...highlightText(remaining, searchFilter, `${keyPrefix}-${lastIndex}`)); } return parts.length > 0 ? parts : highlightText(text, searchFilter, keyPrefix); } /** * Renders a single glossary term as a card with title, short description, and long description. */ function GlossaryTermCard({ item, termCache, searchFilter = "", }: { item: SidebarDocLike; termCache: TermCache; searchFilter?: string; }) { // Check if search matches ONLY in long description (not visible in title/short) // Only auto-expand if the match wouldn't be visible without expanding const cachedData = item.docId ? termCache[item.docId] : null; const shouldAutoExpand = React.useMemo(() => { if (!searchFilter) return false; const lowerFilter = searchFilter.toLowerCase(); const matchesTitle = cachedData?.termName?.toLowerCase().includes(lowerFilter); const matchesShort = cachedData?.shortDescription?.toLowerCase().includes(lowerFilter); const matchesLong = cachedData?.longDescription?.toLowerCase().includes(lowerFilter); return matchesLong && !matchesTitle && !matchesShort; }, [searchFilter, cachedData]); const [isExpanded, setIsExpanded] = React.useState(false); const userToggledRef = React.useRef(false); // Auto-expand only when match is exclusively in long description (not visible otherwise) // Once user manually toggles, never auto-control this term again React.useEffect(() => { if (userToggledRef.current) { return; } if (shouldAutoExpand) { setIsExpanded(true); } else if (!searchFilter) { setIsExpanded(false); } }, [shouldAutoExpand, searchFilter]); // Ensure item has a valid docId and cached data before rendering if (!item.docId) { console.error(`DocCardList: glossary item missing docId:`, item); return null; } if (!cachedData) { console.error(`DocCardList: no cached data found for glossary term '${item.docId}'.`); return null; } const { termName, tags, shortDescription, longDescription, authentikSpecific } = cachedData; const longParagraphs = splitParagraphs(longDescription); const hasLongDescription = longParagraphs.length > 0; // Create anchor ID from term name const anchorId = termName .toLowerCase() .replace(/[^a-z0-9\s-]/g, "") // Remove special characters except spaces and hyphens .replace(/\s+/g, "-") // Replace spaces with hyphens .replace(/-+/g, "-") // Replace multiple hyphens with single hyphen .replace(/^-|-$/g, ""); // Remove leading/trailing hyphens const handleCopyLink = () => { // We can't use anchors since Docusaurus will fail to build due to anchors being generated dynamically const url = `${window.location.origin}${window.location.pathname}?${anchorId}`; navigator.clipboard.writeText(url); }; return (

{highlightText(termName || "Glossary term", searchFilter, "term")} {authentikSpecific ? ( authentik specific ) : null}

{shortDescription ? renderMarkdown(shortDescription, searchFilter, "short") : "Short description not provided."}
{hasLongDescription ? ( <> {isExpanded ? (
{longParagraphs.map((paragraph, index) => (

{renderMarkdown( paragraph, searchFilter, `long-${index}`, )}

))}
) : null} ) : null}
); } /** * Component for rendering glossary terms with search, filtering, and view modes. */ export default function GlossaryDocCardList({ glossaryPool, className, }: GlossaryDocCardListProps): ReactNode { // Handle query parameter links on mount and ensure scroll to target React.useEffect(() => { let timeoutId: ReturnType | undefined; const queryString = window.location.search; if (queryString && queryString.length > 1) { const termId = queryString.substring(1); // Remove the '?' const element = document.getElementById(termId); if (element) { element.scrollIntoView({ behavior: "smooth", block: "start" }); } else { // If not found, try after a delay to allow DOM to render timeoutId = setTimeout(() => { document.getElementById(termId)?.scrollIntoView({ behavior: "smooth", block: "start", }); }, 100); } } return () => { if (timeoutId) clearTimeout(timeoutId); }; }, []); // Build term cache to avoid repeated useDocById calls with error handling const termCache = useMemo(() => { const cache: TermCache = {}; // Filter items to ensure docId is defined, then process them glossaryPool .filter((item): item is SidebarDocLike & { docId: string } => Boolean(item.docId)) .forEach((item) => { const sidebarProps = item.customProps ?? {}; const fallbackLabel = getLabelFromItem(item); cache[item.docId] = { termName: safeStringExtract(sidebarProps.termName) || fallbackLabel || item.docId || "Glossary term", tags: safeStringArrayExtract(sidebarProps.tags), shortDescription: safeStringExtract(sidebarProps.shortDescription), longDescription: safeStringExtract(sidebarProps.longDescription), authentikSpecific: safeBooleanExtract(sidebarProps.authentikSpecific), }; }); return cache; }, [glossaryPool]); // O(1) lookup map for sidebar items by docId const sidebarItemMap = useMemo( () => new Map(glossaryPool.map((item) => [item.docId, item])), [glossaryPool], ); // Transform cached data to standardized GlossaryHelperTerm format const glossaryTerms = useMemo(() => { return glossaryPool .filter((item): item is SidebarDocLike & { docId: string } => Boolean(item.docId)) .map((item) => { const cached = termCache[item.docId]; if (!cached) { console.warn(`No cached data for term ${item.docId}, skipping`); return null; } return { id: item.docId, term: cached.termName, shortDefinition: cached.shortDescription, fullDefinition: cached.longDescription || undefined, tags: cached.tags.length > 0 ? cached.tags : undefined, authentikSpecific: cached.authentikSpecific, }; }) .filter((term): term is NonNullable => term !== null); }, [glossaryPool, termCache]); // Optimized render function with memoized term lookup const renderTerms = useCallback( ( filteredTerms: GlossaryHelperTerm[], viewMode: "categorized" | "alphabetical", searchFilter: string, ) => { if (viewMode === "categorized") { // Categorized view: group terms by their tags into sections const termsByTag = groupByTag(filteredTerms); return termsByTag.map(([tag, tagTerms]) => (

{formatTag(tag)}

{tagTerms.map((term) => { const sidebarItem = sidebarItemMap.get(term.id); return sidebarItem ? ( ) : null; })}
)); } // Alphabetical view: group terms by first letter A-Z const termsByAlphabet = groupByFirstLetter(filteredTerms); return termsByAlphabet.map(([letter, letterTerms]) => (

{letter}

{letterTerms.map((term) => { const sidebarItem = sidebarItemMap.get(term.id); return sidebarItem ? ( ) : null; })}
)); }, [sidebarItemMap, termCache], ); return (

Glossary temporarily unavailable

There was an error loading the glossary terms. Please try refreshing the page.

} > {renderTerms}
); }