🔗 fix: Resolve Relative Skill Markdown Links (#14586)

This commit is contained in:
Marco Beretta 2026-08-01 23:20:17 +02:00 • committed by GitHub
parent 96499f0765
commit c2d8252b4f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 216 additions and 11 deletions

View file

@ -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 */}
<div className="min-h-0 flex-1 overflow-auto">
{viewMode === 'rendered' ? (
<SkillMarkdownRenderer content={cleanBody} />
<SkillMarkdownRenderer
content={cleanBody}
skillId={skill._id}
currentFilePath="SKILL.md"
/>
) : (
<pre className="whitespace-pre-wrap font-mono text-sm leading-relaxed text-text-primary">
{skill.body ?? ''}

View file

@ -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) {
</div>
)}
{viewMode === 'rendered' ? (
<SkillMarkdownRenderer content={parsed.body} />
<SkillMarkdownRenderer
content={parsed.body}
skillId={skillId}
currentFilePath={relativePath}
/>
) : (
<pre className="whitespace-pre-wrap font-mono text-sm leading-relaxed text-text-primary">
{data.content}

View file

@ -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<HTMLAnchorElement> & { 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<string[]>((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 (
<a href={href} {...props}>
{children}
</a>
);
}
return (
<Link to={fileRoute} {...props}>
{children}
</Link>
);
}
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) => (
<SkillMarkdownLink {...props} skillId={skillId} currentFilePath={currentFilePath} />
),
}),
[skillId, currentFilePath],
);
return (
<ReactMarkdown
/** @ts-ignore - PluggableList vs Pluggable[] shape drift */
remarkPlugins={REMARK_PLUGINS}
/** @ts-ignore - PluggableList vs Pluggable[] shape drift */
rehypePlugins={REHYPE_PLUGINS}
components={MARKDOWN_COMPONENTS as unknown as Record<string, React.ElementType>}
components={components as unknown as Record<string, React.ElementType>}
urlTransform={skillUrlTransform}
className={
className ??
'markdown prose dark:prose-invert light w-full break-words leading-[1.65rem] text-text-primary'

View file

@ -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 <output data-testid="location">{`${location.pathname}${location.search}`}</output>;
}
function renderMarkdown(content: string, currentFilePath = 'SKILL.md') {
return render(
<MemoryRouter
initialEntries={['/skills/skill-id']}
future={{ v7_startTransition: true, v7_relativeSplatPath: true }}
>
<SkillMarkdownRenderer
content={content}
skillId="skill-id"
currentFilePath={currentFilePath}
/>
<LocationProbe />
</MemoryRouter>,
);
}
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', '');
});
});