mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-20 17:13:44 +00:00
🔗 fix: Resolve Relative Skill Markdown Links (#14586)
This commit is contained in:
parent
96499f0765
commit
c2d8252b4f
4 changed files with 216 additions and 11 deletions
|
|
@ -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 ?? ''}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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', '');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue