Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions packages/web/src/components/markdown.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import {cleanup, fireEvent, render} from '@testing-library/react';
import {afterEach, describe, expect, test} from 'bun:test';

import {Markdown} from './markdown';

afterEach(cleanup);

describe('Markdown', () => {
test('shows a decorative favicon before absolute HTTPS links', () => {
const {container} = render(
<Markdown text="Read [the secure example](https://www.example.com/a-page?part=one) or [another page](https://plain.example.com/another-page)." />,
);
const links = container.querySelectorAll('a');
const favicons = container.querySelectorAll('img');

expect(favicons).toHaveLength(2);
expect(favicons[0]?.getAttribute('src')).toBe('/api/favicons/www.example.com');
expect(favicons[1]?.getAttribute('src')).toBe('/api/favicons/plain.example.com');
expect(favicons[0]?.getAttribute('alt')).toBe('');
expect(favicons[0]?.getAttribute('loading')).toBe('lazy');
expect(links[0]?.firstElementChild).toBe(favicons[0]);
});

test('leaves relative, email and insecure links without a favicon', () => {
const {container} = render(
<Markdown text="[Email us](mailto:hello@example.com), [open settings](/settings), [use HTTP](http://example.com), or use malformed HTTPS links such as [one slash](https:/example.com) and [no slashes](https:example.com)." />,
);

expect(container.querySelectorAll('a')).toHaveLength(5);
expect(container.querySelector('img')).toBeNull();
});

test('hides a favicon that cannot be loaded', () => {
const {container, rerender} = render(<Markdown text="[Example](https://example.com)" />);
const favicon = container.querySelector('img');

expect(favicon).not.toBeNull();
fireEvent.error(favicon as HTMLImageElement);
expect(favicon?.hidden).toBe(true);

rerender(<Markdown text="[Example](https://example.com) with more detail" />);
expect(container.querySelector('img')).toBe(favicon);
expect(favicon?.hidden).toBe(true);
});

test('limits one reply to eight distinct favicon hosts', () => {
const firstEight = Array.from(
{length: 8},
(_, index) => `[Site ${index + 1}](https://site-${index + 1}.example.com)`,
).join(' ');
const links = `${firstEight} [Site 1 again](https://site-1.example.com/again) [Site 9](https://site-9.example.com)`;
const {container} = render(<Markdown text={links} />);

expect(container.querySelectorAll('a')).toHaveLength(10);
expect(container.querySelectorAll('img')).toHaveLength(9);
expect(container.querySelector('a:last-of-type img')).toBeNull();
});
});
73 changes: 62 additions & 11 deletions packages/web/src/components/markdown.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,53 @@
import {createContext, useContext, useState} from 'react';
import ReactMarkdown, {type Components} from 'react-markdown';
import remarkGfm from 'remark-gfm';
import {cn} from '@proxy/ui/lib/utils';

const MAX_FAVICON_HOSTS = 8;
const FaviconHostsContext = createContext<Set<string> | null>(null);

// What a persona answers, as Markdown: paragraphs, lists, tables, code and links, styled to sit
// in a chat bubble. Every link opens in a new tab, since the conversation stays here. Each
// component takes the element's own props, without the syntax tree node react-markdown hands
// along, which is no attribute of an element.

const components: Components = {
p: ({node: _node, className, ...props}) => (
<p className={cn('leading-relaxed', className)} {...props} />
),
a: ({node: _node, className, ...props}) => (
const MarkdownLink: NonNullable<Components['a']> = ({
node: _node,
className,
href,
children,
...props
}) => {
const hosts = useContext(FaviconHostsContext);
const host = hosts ? faviconHost(href, hosts) : null;
return (
<a
className={cn('underline underline-offset-4 hover:text-foreground', className)}
href={href}
target="_blank"
rel="noreferrer"
{...props}
/>
>
{host && (
<img
src={`/api/favicons/${encodeURIComponent(host)}`}
alt=""
loading="lazy"
className="inline-block h-3.5 w-[18px] rounded-[3px] pr-1 align-[-2px] object-contain"
onError={(event) => {
event.currentTarget.hidden = true;
}}
/>
)}
{children}
</a>
);
};

const components: Components = {
a: MarkdownLink,
p: ({node: _node, className, ...props}) => (
<p className={cn('leading-relaxed', className)} {...props} />
),
ul: ({node: _node, className, ...props}) => (
<ul className={cn('flex list-disc flex-col gap-1 pl-5', className)} {...props} />
Expand Down Expand Up @@ -77,12 +107,33 @@ const components: Components = {
),
};

function faviconHost(href: string | undefined, hosts: Set<string>): string | null {
if (!href || !/^https:\/\//i.test(href) || !URL.canParse(href)) {
return null;
}
const url = new URL(href);
if (url.protocol !== 'https:') {
return null;
}
if (hosts.has(url.hostname)) {
return url.hostname;
}
if (hosts.size >= MAX_FAVICON_HOSTS) {
return null;
}
hosts.add(url.hostname);
return url.hostname;
}

export function Markdown({text, className}: {text: string; className?: string}) {
const [faviconHosts] = useState(() => new Set<string>());
return (
<div className={cn('flex flex-col gap-2 wrap-break-word', className)}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{text}
</ReactMarkdown>
</div>
<FaviconHostsContext.Provider value={faviconHosts}>
<div className={cn('flex flex-col gap-2 wrap-break-word', className)}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{text}
</ReactMarkdown>
</div>
</FaviconHostsContext.Provider>
);
}
Loading