From c2d8252b4f188947171d9025ef132e31fdd808fe Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:20:17 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=97=20fix:=20Resolve=20Relative=20Skil?= =?UTF-8?q?l=20Markdown=20Links=20(#14586)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/Skills/display/SkillDetail.tsx | 12 +- .../Skills/display/SkillFileViewer.tsx | 10 +- .../Skills/display/SkillMarkdownRenderer.tsx | 121 +++++++++++++++++- .../__tests__/SkillMarkdownRenderer.spec.tsx | 84 ++++++++++++ 4 files changed, 216 insertions(+), 11 deletions(-) create mode 100644 client/src/components/Skills/display/__tests__/SkillMarkdownRenderer.spec.tsx diff --git a/client/src/components/Skills/display/SkillDetail.tsx b/client/src/components/Skills/display/SkillDetail.tsx index bbd9dacf52..4a1c3117ea 100644 --- a/client/src/components/Skills/display/SkillDetail.tsx +++ b/client/src/components/Skills/display/SkillDetail.tsx @@ -1,13 +1,13 @@ import React, { useState, useMemo } from 'react'; import { format } from 'date-fns'; -import { Eye, Code, User, Calendar, EarthIcon, ScrollText } from 'lucide-react'; import { TooltipAnchor } from '@librechat/client'; +import { Eye, Code, User, Calendar, EarthIcon, ScrollText } from 'lucide-react'; import type { TSkill } from 'librechat-data-provider'; import { useLocalize, useAuthContext, useSkillPermissions, useSkillActiveState } from '~/hooks'; -import { ShareSkill, SkillToggle } from '../buttons'; import SkillMarkdownRenderer from './SkillMarkdownRenderer'; -import { parseFrontmatter } from '../utils'; +import { ShareSkill, SkillToggle } from '../buttons'; import DeleteSkill from '../dialogs/DeleteSkill'; +import { parseFrontmatter } from '../utils'; import { cn } from '~/utils'; interface SkillDetailProps { @@ -180,7 +180,11 @@ export default function SkillDetail({ skill, onEdit, onDelete }: SkillDetailProp {/* Content — fills remaining space, no card wrapper */}
{skill.body ?? ''}
diff --git a/client/src/components/Skills/display/SkillFileViewer.tsx b/client/src/components/Skills/display/SkillFileViewer.tsx
index 9f51193398..fd195eb920 100644
--- a/client/src/components/Skills/display/SkillFileViewer.tsx
+++ b/client/src/components/Skills/display/SkillFileViewer.tsx
@@ -1,8 +1,8 @@
import React, { memo, useMemo, useState, useCallback, useRef } from 'react';
-import { ArrowLeft, Eye, Code, Copy, Check, FileText, FileQuestion } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
-import { Spinner, TooltipAnchor, useToastContext } from '@librechat/client';
import { apiBaseUrl } from 'librechat-data-provider';
+import { Spinner, TooltipAnchor, useToastContext } from '@librechat/client';
+import { ArrowLeft, Eye, Code, Copy, Check, FileText, FileQuestion } from 'lucide-react';
import { useGetSkillFileContentQuery } from '~/data-provider';
import SkillMarkdownRenderer from './SkillMarkdownRenderer';
import { parseFrontmatter } from '../utils';
@@ -186,7 +186,11 @@ function SkillFileViewer({ skillId, relativePath }: SkillFileViewerProps) {
{data.content}
diff --git a/client/src/components/Skills/display/SkillMarkdownRenderer.tsx b/client/src/components/Skills/display/SkillMarkdownRenderer.tsx
index c79bebdba2..1b823f6c95 100644
--- a/client/src/components/Skills/display/SkillMarkdownRenderer.tsx
+++ b/client/src/components/Skills/display/SkillMarkdownRenderer.tsx
@@ -1,11 +1,14 @@
-import { memo } from 'react';
+import { memo, useMemo } from 'react';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import supersub from 'remark-supersub';
-import ReactMarkdown from 'react-markdown';
+import { Link } from 'react-router-dom';
import rehypeHighlight from 'rehype-highlight';
+import ReactMarkdown, { defaultUrlTransform } from 'react-markdown';
+import type { AnchorHTMLAttributes } from 'react';
import type { PluggableList } from 'unified';
+import type { Element } from 'hast';
import { codeNoExecution } from '~/components/Chat/Messages/Content/MarkdownComponents';
import { langSubset } from '~/utils';
@@ -28,20 +31,130 @@ const REHYPE_PLUGINS: PluggableList = [
];
const MARKDOWN_COMPONENTS = { code: codeNoExecution };
+const URI_SCHEME_PATTERN = /^[a-zA-Z][a-zA-Z\d+.-]*:/;
+const TEL_PROTOCOL_PATTERN = /^tel:/i;
+
+type MarkdownLinkProps = AnchorHTMLAttributes & { node?: Element };
+
+interface SkillMarkdownLinkProps extends MarkdownLinkProps {
+ skillId: string;
+ currentFilePath: string;
+}
+
+function isRelativeFileLink(href: string): boolean {
+ return (
+ href.length > 0 &&
+ !href.startsWith('/') &&
+ !href.startsWith('#') &&
+ !href.startsWith('?') &&
+ !URI_SCHEME_PATTERN.test(href)
+ );
+}
+
+function decodePath(path: string): string {
+ try {
+ return decodeURIComponent(path);
+ } catch {
+ return path;
+ }
+}
+
+function normalizeSkillPath(currentFilePath: string, targetPath: string): string {
+ const currentDirectory = currentFilePath.split('/').slice(0, -1);
+
+ return [...currentDirectory, ...decodePath(targetPath).split('/')]
+ .reduce((segments, segment) => {
+ if (!segment || segment === '.') {
+ return segments;
+ }
+ if (segment === '..') {
+ segments.pop();
+ return segments;
+ }
+ segments.push(segment);
+ return segments;
+ }, [])
+ .join('/');
+}
+
+function getSkillFileRoute(skillId: string, currentFilePath: string, href: string): string | null {
+ if (!isRelativeFileLink(href)) {
+ return null;
+ }
+
+ const suffixIndex = href.search(/[?#]/);
+ const targetPath = suffixIndex === -1 ? href : href.slice(0, suffixIndex);
+ const suffix = suffixIndex === -1 ? '' : href.slice(suffixIndex);
+ const relativePath = normalizeSkillPath(currentFilePath, targetPath);
+
+ if (!relativePath) {
+ return null;
+ }
+
+ const route = `/skills/${encodeURIComponent(skillId)}?file=${encodeURIComponent(relativePath)}`;
+ return suffix.startsWith('?') ? `${route}&${suffix.slice(1)}` : `${route}${suffix}`;
+}
+
+function SkillMarkdownLink({
+ node: _node,
+ href,
+ skillId,
+ currentFilePath,
+ children,
+ ...props
+}: SkillMarkdownLinkProps) {
+ const fileRoute = href ? getSkillFileRoute(skillId, currentFilePath, href) : null;
+
+ if (!fileRoute) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ return (
+
+ {children}
+
+ );
+}
+
+function skillUrlTransform(value: string): string {
+ return TEL_PROTOCOL_PATTERN.test(value) ? value : defaultUrlTransform(value);
+}
interface SkillMarkdownRendererProps {
content: string;
+ skillId: string;
+ currentFilePath: string;
className?: string;
}
-function SkillMarkdownRenderer({ content, className }: SkillMarkdownRendererProps) {
+function SkillMarkdownRenderer({
+ content,
+ skillId,
+ currentFilePath,
+ className,
+}: SkillMarkdownRendererProps) {
+ const components = useMemo(
+ () => ({
+ ...MARKDOWN_COMPONENTS,
+ a: (props: MarkdownLinkProps) => (
+
+ ),
+ }),
+ [skillId, currentFilePath],
+ );
+
return (
}
+ components={components as unknown as Record}
+ urlTransform={skillUrlTransform}
className={
className ??
'markdown prose dark:prose-invert light w-full break-words leading-[1.65rem] text-text-primary'
diff --git a/client/src/components/Skills/display/__tests__/SkillMarkdownRenderer.spec.tsx b/client/src/components/Skills/display/__tests__/SkillMarkdownRenderer.spec.tsx
new file mode 100644
index 0000000000..6422d15669
--- /dev/null
+++ b/client/src/components/Skills/display/__tests__/SkillMarkdownRenderer.spec.tsx
@@ -0,0 +1,84 @@
+import React from 'react';
+import { MemoryRouter, useLocation } from 'react-router-dom';
+import { fireEvent, render, screen } from '@testing-library/react';
+import SkillMarkdownRenderer from '../SkillMarkdownRenderer';
+
+function LocationProbe() {
+ const location = useLocation();
+
+ return ;
+}
+
+function renderMarkdown(content: string, currentFilePath = 'SKILL.md') {
+ return render(
+
+
+
+ ,
+ );
+}
+
+describe('SkillMarkdownRenderer', () => {
+ it('routes relative links from SKILL.md to the skill file viewer', () => {
+ renderMarkdown('[source selection](references/source-selection.md)');
+
+ const link = screen.getByRole('link', { name: 'source selection' });
+ expect(link).toHaveAttribute('href', '/skills/skill-id?file=references%2Fsource-selection.md');
+
+ fireEvent.click(link);
+
+ expect(screen.getByTestId('location')).toHaveTextContent(
+ '/skills/skill-id?file=references%2Fsource-selection.md',
+ );
+ });
+
+ it('normalizes relative links from a nested markdown file', () => {
+ renderMarkdown(
+ [
+ '[sibling](./source.md)',
+ '[parent](../overview.md)',
+ '[encoded](../source%20selection.md#details)',
+ ].join('\n\n'),
+ 'references/guides/current.md',
+ );
+
+ expect(screen.getByRole('link', { name: 'sibling' })).toHaveAttribute(
+ 'href',
+ '/skills/skill-id?file=references%2Fguides%2Fsource.md',
+ );
+ expect(screen.getByRole('link', { name: 'parent' })).toHaveAttribute(
+ 'href',
+ '/skills/skill-id?file=references%2Foverview.md',
+ );
+ expect(screen.getByRole('link', { name: 'encoded' })).toHaveAttribute(
+ 'href',
+ '/skills/skill-id?file=references%2Fsource%20selection.md#details',
+ );
+ });
+
+ it.each([
+ ['external URL', 'https://example.com/docs'],
+ ['mailto link', 'mailto:person@example.com'],
+ ['telephone link', 'tel:+1234567890'],
+ ['page anchor', '#section'],
+ ['absolute app path', '/settings'],
+ ['protocol-relative URL', '//cdn.example.com/file.md'],
+ ])('leaves %s unchanged', (label, href) => {
+ renderMarkdown(`[${label}](${href})`);
+
+ expect(screen.getByRole('link', { name: label })).toHaveAttribute('href', href);
+ });
+
+ it('continues to sanitize unsafe URL protocols', () => {
+ renderMarkdown('[unsafe](javascript:alert(1))');
+
+ expect(screen.getByRole('link', { name: 'unsafe' })).toHaveAttribute('href', '');
+ });
+});