diff --git a/api-editor/gui/src/features/menuBar/SelectionBreadcrumbs.tsx b/api-editor/gui/src/features/menuBar/SelectionBreadcrumbs.tsx index a7093c34f..a29cefcd2 100644 --- a/api-editor/gui/src/features/menuBar/SelectionBreadcrumbs.tsx +++ b/api-editor/gui/src/features/menuBar/SelectionBreadcrumbs.tsx @@ -18,7 +18,7 @@ export const SelectionBreadcrumbs = function () { return ( {declarations.map((it) => ( - + {it.name} ))} diff --git a/api-editor/gui/src/features/packageData/model/PythonDeclaration.ts b/api-editor/gui/src/features/packageData/model/PythonDeclaration.ts index f0f37e4d7..a34157cc1 100644 --- a/api-editor/gui/src/features/packageData/model/PythonDeclaration.ts +++ b/api-editor/gui/src/features/packageData/model/PythonDeclaration.ts @@ -15,6 +15,17 @@ export abstract class PythonDeclaration { return this.name; } + root(): PythonDeclaration { + let current: PythonDeclaration = this; + while (true) { + const parent = current.parent(); + if (!parent) { + return current; + } + current = parent; + } + } + *ancestorsOrSelf(): Generator { let current: Optional = this; while (current) { diff --git a/api-editor/gui/src/features/packageData/selectionView/ClassView.tsx b/api-editor/gui/src/features/packageData/selectionView/ClassView.tsx index c1df2babb..5315c4e94 100644 --- a/api-editor/gui/src/features/packageData/selectionView/ClassView.tsx +++ b/api-editor/gui/src/features/packageData/selectionView/ClassView.tsx @@ -33,7 +33,7 @@ export const ClassView: React.FC = function ({ pythonClass }) { {pythonClass.description ? ( - + ) : ( There is no documentation for this class. )} diff --git a/api-editor/gui/src/features/packageData/selectionView/DocumentationText.tsx b/api-editor/gui/src/features/packageData/selectionView/DocumentationText.tsx index 9790ea0ca..ca1cdb48c 100644 --- a/api-editor/gui/src/features/packageData/selectionView/DocumentationText.tsx +++ b/api-editor/gui/src/features/packageData/selectionView/DocumentationText.tsx @@ -1,16 +1,35 @@ -import { Code, Flex, HStack, IconButton, Stack, Text as ChakraText, UnorderedList } from '@chakra-ui/react'; +import { + Code, + Flex, + HStack, + IconButton, + Link as ChakraLink, + Stack, + Text as ChakraText, + UnorderedList, +} from '@chakra-ui/react'; import 'katex/dist/katex.min.css'; import React, { ClassAttributes, FunctionComponent, HTMLAttributes, useState } from 'react'; import { FaChevronDown, FaChevronRight } from 'react-icons/fa'; import ReactMarkdown from 'react-markdown'; -import { CodeComponent, ReactMarkdownProps, UnorderedListComponent } from 'react-markdown/lib/ast-to-react'; +import { + CodeComponent, + ComponentPropsWithoutRef, + ComponentType, + ReactMarkdownProps, + UnorderedListComponent, +} from 'react-markdown/lib/ast-to-react'; import rehypeKatex from 'rehype-katex'; import remarkGfm from 'remark-gfm'; import remarkMath from 'remark-math'; import { useAppSelector } from '../../../app/hooks'; import { selectExpandDocumentationByDefault } from '../../ui/uiSlice'; +import { Link as RouterLink } from 'react-router-dom'; +import { PythonDeclaration } from '../model/PythonDeclaration'; +import { PythonPackage } from '../model/PythonPackage'; interface DocumentationTextProps { + declaration: PythonDeclaration; inputText: string; } @@ -18,25 +37,36 @@ type ParagraphComponent = FunctionComponent< ClassAttributes & HTMLAttributes & ReactMarkdownProps >; -const CustomText: ParagraphComponent = function ({ className, children }) { - return {children}; +type LinkComponent = ComponentType & ReactMarkdownProps>; + +const CustomLink: LinkComponent = function ({ className, children, href }) { + return ( + + {children} + + ); }; const CustomCode: CodeComponent = function ({ className, children }) { return {children}; }; +const CustomText: ParagraphComponent = function ({ className, children }) { + return {children}; +}; + const CustomUnorderedList: UnorderedListComponent = function ({ className, children }) { return {children}; }; const components = { - p: CustomText, + a: CustomLink, code: CustomCode, + p: CustomText, ul: CustomUnorderedList, }; -export const DocumentationText: React.FC = function ({ inputText = '' }) { +export const DocumentationText: React.FC = function ({ declaration, inputText = '' }) { const expandDocumentationByDefault = useAppSelector(selectExpandDocumentationByDefault); const preprocessedText = inputText @@ -45,7 +75,21 @@ export const DocumentationText: React.FC = function ({ i // replace inline math elements .replaceAll(/:math:`([^`]*)`/gu, '$$1$') // replace block math elements - .replaceAll(/\.\. math::\s*(\S.*)\n\n/gu, '$$\n$1\n$$\n\n'); + .replaceAll(/\.\. math::\s*(\S.*)\n\n/gu, '$$\n$1\n$$\n\n') + // replace relative links to classes + .replaceAll(/:class:`(\w*)`/gu, (_match, name) => resolveRelativeLink(declaration, name)) + // replace relative links to functions + .replaceAll(/:func:`(\w*)`/gu, (_match, name) => resolveRelativeLink(declaration, name)) + // replace absolute links to modules + .replaceAll(/:mod:`([\w.]*)`/gu, (_match, qualifiedName) => resolveAbsoluteLink(declaration, qualifiedName, 1)) + // replace absolute links to classes + .replaceAll(/:class:`~?([\w.]*)`/gu, (_match, qualifiedName) => + resolveAbsoluteLink(declaration, qualifiedName, 2), + ) + // replace absolute links to classes + .replaceAll(/:func:`~?([\w.]*)`/gu, (_match, qualifiedName) => + resolveAbsoluteLink(declaration, qualifiedName, 2), + ); const shortenedText = preprocessedText.split('\n\n')[0]; const hasMultipleLines = shortenedText !== preprocessedText; @@ -91,3 +135,49 @@ export const DocumentationText: React.FC = function ({ i ); }; + +const resolveRelativeLink = function (currentDeclaration: PythonDeclaration, linkedDeclarationName: string): string { + const parent = currentDeclaration.parent(); + if (!parent) { + return linkedDeclarationName; + } + + const sibling = parent.children().find((it) => it.name === linkedDeclarationName); + if (!sibling) { + return linkedDeclarationName; + } + + return `[${currentDeclaration.preferredQualifiedName()}](${sibling.id})`; +}; + +const resolveAbsoluteLink = function ( + currentDeclaration: PythonDeclaration, + linkedDeclarationQualifiedName: string, + segmentCount: number, +): string { + let segments = linkedDeclarationQualifiedName.split('.'); + if (segments.length < segmentCount) { + return linkedDeclarationQualifiedName; + } + + segments = [ + segments.slice(0, segments.length - segmentCount + 1).join('.'), + ...segments.slice(segments.length - segmentCount + 1), + ]; + + let current = currentDeclaration.root(); + if (!(current instanceof PythonPackage)) { + return linkedDeclarationQualifiedName; + } + + for (const segment of segments) { + const next = current.children().find((it) => it.name === segment); + if (!next) { + return linkedDeclarationQualifiedName; + } + + current = next; + } + + return `[${current.preferredQualifiedName()}](${current.id})`; +}; diff --git a/api-editor/gui/src/features/packageData/selectionView/FunctionView.tsx b/api-editor/gui/src/features/packageData/selectionView/FunctionView.tsx index 7ccb32baf..f148eee89 100644 --- a/api-editor/gui/src/features/packageData/selectionView/FunctionView.tsx +++ b/api-editor/gui/src/features/packageData/selectionView/FunctionView.tsx @@ -57,7 +57,7 @@ export const FunctionView: React.FC = function ({ pythonFunct {pythonFunction.description ? ( - + ) : ( There is no documentation for this function. )} diff --git a/api-editor/gui/src/features/packageData/selectionView/ParameterNode.tsx b/api-editor/gui/src/features/packageData/selectionView/ParameterNode.tsx index bbd2f15c4..9051299f2 100644 --- a/api-editor/gui/src/features/packageData/selectionView/ParameterNode.tsx +++ b/api-editor/gui/src/features/packageData/selectionView/ParameterNode.tsx @@ -55,7 +55,7 @@ export const ParameterNode: React.FC = function ({ isTitle, {pythonParameter.description ? ( - + ) : ( There is no documentation for this parameter. )}