Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,22 @@ it.each([
expect(textWithMark(editor, "code")).toEqual(code);
});

it.each([true, false])("styles bare URLs as links (rich text: %s)", async (richTextEnabled) => {
await using screen = await mountEditor(
"see https://github.com/pingdotgg/t3code/pull/1. and `https://example.com`",
richTextEnabled,
);
const links = Array.from(
screen.container.querySelectorAll(".composer-link"),
(link) => link.textContent,
);
expect(links).toEqual(richTextEnabled ? ["https://github.com/pingdotgg/t3code/pull/1"] : []);
// github.com gets the brand icon rather than a fetched favicon.
expect(screen.container.querySelectorAll(".ProseMirror-widget svg")).toHaveLength(
richTextEnabled ? 1 : 0,
);
});

it.each([
{ mark: "bold", key: "b", marker: "**" },
{ mark: "italic", key: "i", marker: "*" },
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/ComposerPromptEditorTiptap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
import { ComposerCodeBlockNodeView } from "./chat/ComposerCodeBlockNodeView";
import { resolveAssistantCitationCommentDismissal } from "./chat/assistantCitationCommentDismissal";
import { composerCodeBlockHighlight } from "./composerCodeBlockHighlight";
import { ComposerLinkExtension } from "./composerLinkDecorations";
import { importPastedComposerText, readPastedPlainText } from "./composerInlineTokenPaste";
import { didComposerSelectionChangeVisibly } from "./composerSelection";
import type { ComposerDraftContextRecords } from "./composerContextPresentation";
Expand Down Expand Up @@ -839,6 +840,7 @@ function createComposerExtensions(richText: boolean) {
ComposerBoldExtension,
ComposerStrikeExtension,
ComposerCodeExtension,
ComposerLinkExtension,
ComposerCodeBlockExtension.extend({
addNodeView() {
return ReactNodeViewRenderer(ComposerCodeBlockNodeView, {
Expand Down
50 changes: 2 additions & 48 deletions apps/web/src/components/chat/MarkdownExternalLink.tsx
Original file line number Diff line number Diff line change
@@ -1,52 +1,6 @@
import { faviconUrlForOrigin } from "@t3tools/shared/favicon";
import { GlobeIcon } from "lucide-react";
import { Children, memo, useState, type ReactNode } from "react";
import { Children, type ReactNode } from "react";

import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";

import { cn } from "../../lib/utils";
import { sourceControlIcon } from "../../sourceControlPresentation";

const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none";

const failedFaviconHosts = new Set<string>();

function sourceControlHostIcon(host: string): ReactNode {
const definition = sourceControlClients.findByPublicDomain(host);
if (definition === undefined) return null;
const Icon = sourceControlIcon(definition);
return <Icon className={MARKDOWN_LINK_FAVICON_CLASS_NAME} />;
}

const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) {
const [failedHost, setFailedHost] = useState<string | null>(null);
const brandIcon = sourceControlHostIcon(host);
const faviconUrl = brandIcon ? null : faviconUrlForOrigin(`https://${host}`);
return (
<span
className="ms-[0.25em] me-[0.2em] inline-flex size-[14px] [vertical-align:-0.125em]"
aria-hidden
>
{brandIcon ? (
brandIcon
) : faviconUrl === null || failedHost === host || failedFaviconHosts.has(host) ? (
<GlobeIcon className={MARKDOWN_LINK_FAVICON_CLASS_NAME} />
) : (
<img
src={faviconUrl}
alt=""
loading="lazy"
draggable={false}
className={cn(MARKDOWN_LINK_FAVICON_CLASS_NAME, "rounded-sm")}
onError={() => {
failedFaviconHosts.add(host);
setFailedHost(host);
}}
/>
)}
</span>
);
});
import { MarkdownLinkFavicon } from "./MarkdownLinkFavicon";

function leadingExternalLinkTextLength(text: string): number {
const protocol = /^(?:https?:\/\/)/i.exec(text)?.[0];
Expand Down
49 changes: 49 additions & 0 deletions apps/web/src/components/chat/MarkdownLinkFavicon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { faviconUrlForOrigin } from "@t3tools/shared/favicon";
import { GlobeIcon } from "lucide-react";
import { memo, useState, type ReactNode } from "react";

import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";

import { cn } from "~/lib/utils";
import { sourceControlIcon } from "../../sourceControlPresentation";

const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none";

const failedFaviconHosts = new Set<string>();

function sourceControlHostIcon(host: string): ReactNode {
const definition = sourceControlClients.findByPublicDomain(host);
if (definition === undefined) return null;
const Icon = sourceControlIcon(definition);
return <Icon className={MARKDOWN_LINK_FAVICON_CLASS_NAME} />;
}

export const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) {
const [failedHost, setFailedHost] = useState<string | null>(null);
const brandIcon = sourceControlHostIcon(host);
const faviconUrl = brandIcon ? null : faviconUrlForOrigin(`https://${host}`);
return (
<span
className="ms-[0.25em] me-[0.2em] inline-flex size-[14px] [vertical-align:-0.125em]"
aria-hidden
>
{brandIcon ? (
brandIcon
) : faviconUrl === null || failedHost === host || failedFaviconHosts.has(host) ? (
<GlobeIcon className={MARKDOWN_LINK_FAVICON_CLASS_NAME} />
) : (
<img
src={faviconUrl}
alt=""
loading="lazy"
draggable={false}
className={cn(MARKDOWN_LINK_FAVICON_CLASS_NAME, "rounded-sm")}
onError={() => {
failedFaviconHosts.add(host);
setFailedHost(host);
}}
/>
)}
</span>
);
});
73 changes: 73 additions & 0 deletions apps/web/src/components/composerLinkDecorations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Decoration, Extension } from "@tiptap/core";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { ReactWidgetRenderer } from "@tiptap/react";

import { type ComposerLink, findComposerLinks } from "~/composer-links";
import { MARKDOWN_SOURCE_MARK } from "~/composer-rich-text";
import { MarkdownLinkFavicon } from "./chat/MarkdownLinkFavicon";

// Revealed Markdown source shows inline code as backtick text without the code mark.
const CODE_SPAN = /(?<!`)(`+)(?!`)[^]*?(?<!`)\1(?!`)/gu;

function collectComposerLinks(doc: ProseMirrorNode): ComposerLink[] {
const links: ComposerLink[] = [];
doc.descendants((node, pos) => {
if (!node.isTextblock) return true;
if (node.type.spec.code) return false;
// A URL can span several text nodes (part of it bold, say), but not a
// chip, a hard break, or inline code.
let run = "";
let runStart = 0;
const flush = () => {
for (const link of findComposerLinks(run)) {
links.push({ from: runStart + link.from, to: runStart + link.to, host: link.host });
}
run = "";
};
node.forEach((child, offset) => {
if (child.isText && !child.marks.some((mark) => mark.type.spec.code)) {
if (run === "") runStart = pos + 1 + offset;
run += child.marks.some((mark) => mark.type.name === MARKDOWN_SOURCE_MARK)
? child.text!.replace(CODE_SPAN, (span) => " ".repeat(span.length))
: child.text;
} else {
flush();
}
});
flush();
return false;
});
return links;
}

/** Colors bare URLs like the sent message's links and leads each with its site icon. */
export const ComposerLinkExtension = Extension.create({
name: "composerLinks",
addDecorations() {
const editor = this.editor;
return {
create: ({ state }) => {
const decorations: Decoration[] = [];
const occurrencesByHost = new Map<string, number>();
for (const link of collectComposerLinks(state.doc)) {
// Widget keys must be unique and should not follow positions, or
// typing before a link would remount its icon on every keystroke.
const occurrence = occurrencesByHost.get(link.host) ?? 0;
occurrencesByHost.set(link.host, occurrence + 1);
decorations.push(
ReactWidgetRenderer(MarkdownLinkFavicon, {
editor,
pos: link.from,
key: `composer-link-favicon:${link.host}:${occurrence}`,
props: { host: link.host },
className: "composer-link-icon",
side: -1,
}),
Decoration.Inline(link.from, link.to, { class: "composer-link" }),
);
}
return decorations;
},
};
},
});
53 changes: 53 additions & 0 deletions apps/web/src/composer-links.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vite-plus/test";

import { findComposerLinks } from "./composer-links";

function linkTexts(text: string): string[] {
return findComposerLinks(text).map(({ from, to }) => text.slice(from, to));
}

describe("findComposerLinks", () => {
it("finds http(s) URLs with their host", () => {
const url = "https://github.com/pingdotgg/t3code/pull/22426#pullrequestreview-1";
expect(findComposerLinks(`see ${url} now`)).toEqual([
{ from: 4, to: 4 + url.length, host: "github.com" },
]);
});

it("drops trailing punctuation and unbalanced closing parens", () => {
expect(
linkTexts("(https://example.com/a), https://example.com/b. [x](https://example.com/c)"),
).toEqual(["https://example.com/a", "https://example.com/b", "https://example.com/c"]);
expect(linkTexts("https://en.wikipedia.org/wiki/Foo_(bar)!")).toEqual([
"https://en.wikipedia.org/wiki/Foo_(bar)",
]);
});

it("drops entity-like suffixes whole, as GFM does", () => {
expect(
linkTexts(
"https://example.com?q=x&hl; https://example.com/a.&hl;&gt; https://example.com/b&1; https://example.com/c&;",
),
).toEqual([
"https://example.com?q=x",
"https://example.com/a",
"https://example.com/b&1",
"https://example.com/c&",
]);
});

it("matches GFM's boundaries and keeps quotes inside the path", () => {
expect(
linkTexts('[https://github.com] 1https://example.com/a\'b "https://example.com/c"'),
).toEqual(["https://github.com", "https://example.com/a'b", "https://example.com/c"]);
});

it("ignores URLs glued to a letter or without a host", () => {
expect(linkTexts("xhttps://example.com https:// ftp://example.com")).toEqual([]);
});

it("trims a long run of closing parens in linear time", () => {
const parens = ")".repeat(200_000);
expect(linkTexts(`https://example.com${parens}`)).toEqual(["https://example.com"]);
});
});
47 changes: 47 additions & 0 deletions apps/web/src/composer-links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { resolveExternalWebLinkHost } from "./components/chat/externalLinkContextMenu";

export interface ComposerLink {
readonly from: number;
readonly to: number;
readonly host: string;
}

// Mirrors GFM's autolink literals in the sent message: an http(s) URL not
// glued to a preceding letter, minus trailing punctuation and unbalanced `)`.
const URL_PATTERN = /(?<![A-Za-z])https?:\/\/[^\s<]+/giu;
const TRAILING_PUNCTUATION = new Set(["!", '"', "'", "*", ",", ".", ":", ";", "?", "]", "_", "~"]);

function trimLinkEnd(url: string): string {
let unbalancedCloses = 0;
for (const character of url) {
if (character === "(") unbalancedCloses -= 1;
else if (character === ")") unbalancedCloses += 1;
}
let end = url.length;
while (end > 0) {
const character = url[end - 1]!;
if (character === ";") {
// GFM drops an entity-like `&name;` suffix whole, not just its `;`.
let nameStart = end - 1;
while (nameStart > 0 && /[A-Za-z]/.test(url[nameStart - 1]!)) nameStart -= 1;
if (nameStart < end - 1 && url[nameStart - 1] === "&") {
end = nameStart - 1;
continue;
}
}
if (character === ")" && unbalancedCloses > 0) unbalancedCloses -= 1;
else if (!TRAILING_PUNCTUATION.has(character)) break;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
end -= 1;
}
return url.slice(0, end);
}

export function findComposerLinks(text: string): ComposerLink[] {
const links: ComposerLink[] = [];
for (const match of text.matchAll(URL_PATTERN)) {
const url = trimLinkEnd(match[0]);
const host = resolveExternalWebLinkHost(url);
if (host) links.push({ from: match.index, to: match.index + url.length, host });
}
return links;
}
5 changes: 5 additions & 0 deletions apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -2465,6 +2465,11 @@ code {
padding: 0.05em 0.25em;
}

.composer-tiptap .composer-link,
.composer-tiptap .composer-link-icon {
color: var(--info-foreground);
}

/* Markdown delimiters shown, as editable text, around styled text with the caret in it. */
.composer-rich-marker {
color: var(--muted-foreground);
Expand Down
Loading