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 */}
{viewMode === 'rendered' ? ( - + ) : (
             {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) {
                   
)} {viewMode === 'rendered' ? ( - + ) : (
                     {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 {`${location.pathname}${location.search}`};
+}
+
+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', '');
+  });
+});