Fix markdown mention chips

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-21 14:48:10 -05:00
parent cd7c6ee751
commit 8232456ce8
14 changed files with 527 additions and 264 deletions

View file

@ -535,10 +535,15 @@ export { API_PREFIX, API } from "./api.js";
export { normalizeAgentUrlKey, deriveAgentUrlKey, isUuidLike } from "./agent-url-key.js";
export { deriveProjectUrlKey, normalizeProjectUrlKey } from "./project-url-key.js";
export {
AGENT_MENTION_SCHEME,
PROJECT_MENTION_SCHEME,
buildAgentMentionHref,
buildProjectMentionHref,
extractAgentMentionIds,
parseAgentMentionHref,
parseProjectMentionHref,
extractProjectMentionIds,
type ParsedAgentMention,
type ParsedProjectMention,
} from "./project-mentions.js";

View file

@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import {
buildAgentMentionHref,
buildProjectMentionHref,
extractAgentMentionIds,
extractProjectMentionIds,
parseAgentMentionHref,
parseProjectMentionHref,
} from "./project-mentions.js";
describe("project-mentions", () => {
it("round-trips project mentions with color metadata", () => {
const href = buildProjectMentionHref("project-123", "#336699");
expect(parseProjectMentionHref(href)).toEqual({
projectId: "project-123",
color: "#336699",
});
expect(extractProjectMentionIds(`[@Paperclip App](${href})`)).toEqual(["project-123"]);
});
it("round-trips agent mentions with icon metadata", () => {
const href = buildAgentMentionHref("agent-123", "code");
expect(parseAgentMentionHref(href)).toEqual({
agentId: "agent-123",
icon: "code",
});
expect(extractAgentMentionIds(`[@CodexCoder](${href})`)).toEqual(["agent-123"]);
});
});

View file

@ -1,16 +1,24 @@
export const PROJECT_MENTION_SCHEME = "project://";
export const AGENT_MENTION_SCHEME = "agent://";
const HEX_COLOR_RE = /^[0-9a-f]{6}$/i;
const HEX_COLOR_SHORT_RE = /^[0-9a-f]{3}$/i;
const HEX_COLOR_WITH_HASH_RE = /^#[0-9a-f]{6}$/i;
const HEX_COLOR_SHORT_WITH_HASH_RE = /^#[0-9a-f]{3}$/i;
const PROJECT_MENTION_LINK_RE = /\[[^\]]*]\((project:\/\/[^)\s]+)\)/gi;
const AGENT_MENTION_LINK_RE = /\[[^\]]*]\((agent:\/\/[^)\s]+)\)/gi;
const AGENT_ICON_NAME_RE = /^[a-z0-9-]+$/i;
export interface ParsedProjectMention {
projectId: string;
color: string | null;
}
export interface ParsedAgentMention {
agentId: string;
icon: string | null;
}
function normalizeHexColor(input: string | null | undefined): string | null {
if (!input) return null;
const trimmed = input.trim();
@ -65,6 +73,36 @@ export function parseProjectMentionHref(href: string): ParsedProjectMention | nu
};
}
export function buildAgentMentionHref(agentId: string, icon?: string | null): string {
const trimmedAgentId = agentId.trim();
const normalizedIcon = normalizeAgentIcon(icon ?? null);
if (!normalizedIcon) {
return `${AGENT_MENTION_SCHEME}${trimmedAgentId}`;
}
return `${AGENT_MENTION_SCHEME}${trimmedAgentId}?i=${encodeURIComponent(normalizedIcon)}`;
}
export function parseAgentMentionHref(href: string): ParsedAgentMention | null {
if (!href.startsWith(AGENT_MENTION_SCHEME)) return null;
let url: URL;
try {
url = new URL(href);
} catch {
return null;
}
if (url.protocol !== "agent:") return null;
const agentId = `${url.hostname}${url.pathname}`.replace(/^\/+/, "").trim();
if (!agentId) return null;
return {
agentId,
icon: normalizeAgentIcon(url.searchParams.get("i") ?? url.searchParams.get("icon")),
};
}
export function extractProjectMentionIds(markdown: string): string[] {
if (!markdown) return [];
const ids = new Set<string>();
@ -76,3 +114,22 @@ export function extractProjectMentionIds(markdown: string): string[] {
}
return [...ids];
}
export function extractAgentMentionIds(markdown: string): string[] {
if (!markdown) return [];
const ids = new Set<string>();
const re = new RegExp(AGENT_MENTION_LINK_RE);
let match: RegExpExecArray | null;
while ((match = re.exec(markdown)) !== null) {
const parsed = parseAgentMentionHref(match[1]);
if (parsed) ids.add(parsed.agentId);
}
return [...ids];
}
function normalizeAgentIcon(input: string | null | undefined): string | null {
if (!input) return null;
const trimmed = input.trim().toLowerCase();
if (!trimmed || !AGENT_ICON_NAME_RE.test(trimmed)) return null;
return trimmed;
}