| 1 | import type { MouseEvent as ReactMouseEvent, ReactNode } from "react"; |
| 2 | import { ExternalLink, Mail } from "lucide-react"; |
| 3 | import { app, openExternal } from "../lib/bridge"; |
| 4 | |
| 5 | export interface GitHubLinkInfo { |
| 6 | kind: "issue" | "pull" | "commit"; |
| 7 | owner: string; |
| 8 | repo: string; |
| 9 | value: string; |
| 10 | compactLabel: string; |
| 11 | } |
| 12 | |
| 13 | export type LinkIconKind = "github" | "external" | "mail"; |
| 14 | |
| 15 | function linkText(children: ReactNode): string { |
| 16 | if (typeof children === "string") return children; |
| 17 | if (typeof children === "number") return String(children); |
| 18 | if (!Array.isArray(children)) return ""; |
| 19 | return children |
| 20 | .map((child) => typeof child === "string" || typeof child === "number" ? String(child) : "") |
| 21 | .join(""); |
| 22 | } |
| 23 | |
| 24 | function githubAccessibleLabel(info: GitHubLinkInfo): string { |
| 25 | const resource = info.kind === "pull" |
| 26 | ? `pull request #${info.value}` |
| 27 | : info.kind === "issue" |
| 28 | ? `issue #${info.value}` |
| 29 | : `commit ${info.compactLabel}`; |
| 30 | return `GitHub ${info.owner}/${info.repo} ${resource}`; |
| 31 | } |
| 32 | |
| 33 | export function classifyLinkIcon(href?: string): LinkIconKind | null { |
| 34 | if (!href) return null; |
| 35 | let url: URL; |
| 36 | try { |
| 37 | url = new URL(href); |
| 38 | } catch { |
| 39 | return null; |
| 40 | } |
| 41 | |
| 42 | if (url.protocol === "mailto:") return "mail"; |
| 43 | if (url.protocol !== "https:" && url.protocol !== "http:") return null; |
| 44 | if (url.protocol === "https:" && ["github.com", "www.github.com"].includes(url.hostname.toLowerCase())) { |
| 45 | return "github"; |
| 46 | } |
| 47 | return "external"; |
| 48 | } |
| 49 | |
| 50 | export function parseGitHubLink(href?: string): GitHubLinkInfo | null { |
| 51 | if (!href) return null; |
| 52 | let url: URL; |
| 53 | try { |
| 54 | url = new URL(href); |
| 55 | } catch { |
| 56 | return null; |
| 57 | } |
| 58 | if (url.protocol !== "https:" || !["github.com", "www.github.com"].includes(url.hostname.toLowerCase())) { |
| 59 | return null; |
| 60 | } |
| 61 | |
| 62 | const parts = url.pathname.split("/").filter(Boolean); |
| 63 | if (parts.length !== 4) return null; |
| 64 | const [owner, repo, resource, value] = parts; |
| 65 | if (!owner || !repo || !value) return null; |
| 66 | |
| 67 | if (resource === "issues" && /^\d+$/.test(value)) { |
| 68 | return { kind: "issue", owner, repo, value, compactLabel: `#${value}` }; |
| 69 | } |
| 70 | if (resource === "pull" && /^\d+$/.test(value)) { |
| 71 | return { kind: "pull", owner, repo, value, compactLabel: `PR #${value}` }; |
| 72 | } |
| 73 | if (resource === "commit" && /^[0-9a-f]{7,40}$/i.test(value)) { |
| 74 | return { kind: "commit", owner, repo, value, compactLabel: value.slice(0, 7) }; |
| 75 | } |
| 76 | return null; |
| 77 | } |
| 78 | |
| 79 | function LinkMark({ kind }: { kind: LinkIconKind }) { |
| 80 | if (kind === "github") { |
| 81 | return ( |
| 82 | <svg aria-hidden="true" fill="none" height="13" viewBox="0 0 24 24" width="13"> |
| 83 | <path |
| 84 | d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3.3-.4 6.8-1.6 6.8-7A5.4 5.4 0 0 0 19.4 4 5 5 0 0 0 19.3.5S18.2.1 15 1.8a13.4 13.4 0 0 0-7 0C4.8.1 3.7.5 3.7.5A5 5 0 0 0 3.6 4a5.4 5.4 0 0 0-1.4 3.7c0 5.4 3.5 6.6 6.8 7A4.8 4.8 0 0 0 8 18v4" |
| 85 | stroke="currentColor" |
| 86 | strokeLinecap="round" |
| 87 | strokeLinejoin="round" |
| 88 | strokeWidth="2" |
| 89 | /> |
| 90 | <path |
| 91 | d="M8 19c-3 .9-3-1.5-4-2" |
| 92 | stroke="currentColor" |
| 93 | strokeLinecap="round" |
| 94 | strokeLinejoin="round" |
| 95 | strokeWidth="2" |
| 96 | /> |
| 97 | </svg> |
| 98 | ); |
| 99 | } |
| 100 | if (kind === "mail") return <Mail aria-hidden="true" size={13} strokeWidth={2} />; |
| 101 | return <ExternalLink aria-hidden="true" size={13} strokeWidth={2} />; |
| 102 | } |
| 103 | |
| 104 | function openLink(href: string | undefined) { |
| 105 | const local = localPathFromHref(href); |
| 106 | if (local !== null) { |
| 107 | // Local paths (linkified plain text or explicit file:/// links) open in |
| 108 | // the OS default app via the native binding, never in the system browser. |
| 109 | void app.OpenLocalPath(local).catch(() => {}); |
| 110 | return; |
| 111 | } |
| 112 | if (href) openExternal(href); |
| 113 | } |
| 114 | |
| 115 | // localPathFromHref returns the decoded local filesystem path when href is a |
| 116 | // file:/// URL (the form remarkLocalPathLinks emits), or null otherwise. |
| 117 | export function localPathFromHref(href?: string): string | null { |
| 118 | if (!href || !href.startsWith("file:///")) return null; |
| 119 | try { |
| 120 | return decodeURIComponent(href.slice("file:///".length)); |
| 121 | } catch { |
| 122 | return null; |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | export function RichMarkdownLink({ |
| 127 | href, |
| 128 | children, |
| 129 | }: { |
| 130 | href?: string; |
| 131 | children: ReactNode; |
| 132 | }) { |
| 133 | const github = parseGitHubLink(href); |
| 134 | const iconKind = classifyLinkIcon(href); |
| 135 | const compactLabel = github && linkText(children) === href ? github.compactLabel : undefined; |
| 136 | const accessibleLabel = github ? githubAccessibleLabel(github) : undefined; |
| 137 | const handlers = { |
| 138 | onClick: (event: ReactMouseEvent<HTMLAnchorElement>) => { |
| 139 | event.preventDefault(); |
| 140 | openLink(href); |
| 141 | }, |
| 142 | onAuxClick: (event: ReactMouseEvent<HTMLAnchorElement>) => { |
| 143 | event.preventDefault(); |
| 144 | openLink(href); |
| 145 | }, |
| 146 | onMouseDown: (event: ReactMouseEvent<HTMLAnchorElement>) => { |
| 147 | if (event.button === 1) event.preventDefault(); |
| 148 | }, |
| 149 | }; |
| 150 | |
| 151 | if (!iconKind) { |
| 152 | return <a href={href} {...handlers}>{children}</a>; |
| 153 | } |
| 154 | |
| 155 | return ( |
| 156 | <a |
| 157 | aria-label={compactLabel ? accessibleLabel : undefined} |
| 158 | className={`md-rich-link md-rich-link--${iconKind}`} |
| 159 | href={href} |
| 160 | title={github ? accessibleLabel : undefined} |
| 161 | {...handlers} |
| 162 | > |
| 163 | <LinkMark kind={iconKind} /> |
| 164 | <span |
| 165 | className="md-rich-link__label" |
| 166 | data-display-label={compactLabel} |
| 167 | > |
| 168 | {children} |
| 169 | </span> |
| 170 | </a> |
| 171 | ); |
| 172 | } |
| 173 |