unlumen-ui
uinotion-mention-linksource: ok · hit
Notion Mention Link
A Notion-inspired rich link mention with a securely fetched preview card on hover.
npx shadcn add "https://ui.unlumen.com/r/notion-mention-link.json"free
Where it goes
testimonial
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumframeworknext
Registry dependencies
hover-cardskeleton
Files3 · source cached 10/8/2026, 2:12:01 PM
registry/components/unlumen/notion-mention-link/index.tsx
"use client";
import * as React from "react";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
export type NotionMentionLinkMetadata = {
url: string;
title: string;
description?: string;
siteName: string;
domain: string;
image?: string;
favicon?: string;
};
export type NotionMentionLinkProps = Omit<
React.ComponentPropsWithRef<"a">,
"children" | "href"
> & {
url: string;
endpoint?: string;
metadata?: NotionMentionLinkMetadata;
prefetch?: "mount" | "hover";
openDelay?: number;
closeDelay?: number;
previewWidth?: number;
previewHeight?: number;
align?: "start" | "end";
showPreview?: boolean;
invalidLabel?: string;
previewClassName?: string;
};
const metadataCache = new Map<string, NotionMentionLinkMetadata>();
function normalizeUrl(value: string) {
const trimmed = value.trim();
if (!trimmed) return "";
return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
}
function isValidWebsiteUrl(value: string) {
if (!value || /\s/.test(value)) return false;
try {
const parsed = new URL(value);
const labels = parsed.hostname.split(".");
return (
["http:", "https:"].includes(parsed.protocol) &&
labels.length >= 2 &&
labels.every((label) =>
/^[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?$/i.test(label),
)
);
} catch {
return false;
}
}
function getDomain(url: string) {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return url;
}
}
function getFallbackMetadata(url: string): NotionMentionLinkMetadata {
const domain = getDomain(url);
return {
url,
title: domain,
siteName: domain.split(".")[0] || domain,
domain,
};
}
function getAssetUrl(endpoint: string, url?: string) {
if (!url) return undefined;
const params = new URLSearchParams({ asset: "1", url });
return `${endpoint}${endpoint.includes("?") ? "&" : "?"}${params.toString()}`;
}
function SiteIcon({
src,
label,
large = false,
}: {
src?: string;
label: string;
large?: boolean;
}) {
const [failed, setFailed] = React.useState(false);
React.useEffect(() => setFailed(false), [src]);
if (!src || failed) {
return (
<span
className={cn(
"inline-flex shrink-0 items-center justify-center rounded bg-foreground font-semibold uppercase text-background",
large ? "size-6 text-[10px]" : "size-[22px] text-[9px]",
)}
aria-hidden="true"
>
{label.charAt(0)}
</span>
);
}
return (
// The same-origin endpoint validates and proxies this remote asset.
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt=""
className={cn(
"shrink-0 rounded object-cover",
large ? "size-6" : "size-[22px]",
)}
onError={() => setFailed(true)}
aria-hidden="true"
/>
);
}
function PreviewSkeleton({ height }: { height: number }) {
return (
<div className="overflow-hidden rounded-xl border border-border bg-card shadow-xl shadow-foreground/10">
<Skeleton className="rounded-none" style={{ height }} />
<div className="flex flex-col gap-3 p-5">
<Skeleton className="h-5 w-4/5" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
</div>
);
}
function PreviewImage({ src, height }: { src: string; height: number }) {
const [failed, setFailed] = React.useState(false);
React.useEffect(() => setFailed(false), [src]);
if (failed) return null;
return (
// The same-origin endpoint validates and proxies this remote asset.
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt=""
className="block w-full bg-muted object-cover"
style={{ height }}
onError={() => setFailed(true)}
aria-hidden="true"
/>
);
}
function NotionMentionLink({
url,
endpoint = "/api/notion-mention-link",
metadata: suppliedMetadata,
prefetch = "mount",
openDelay = 180,
closeDelay = 120,
previewWidth = 310,
previewHeight = 140,
align = "start",
showPreview = true,
invalidLabel = "Enter a valid website URL.",
previewClassName,
className,
target = "_blank",
rel = "noopener noreferrer",
onMouseEnter,
onMouseLeave,
onFocus,
onBlur,
ref,
...props
}: NotionMentionLinkProps) {
const normalizedUrl = React.useMemo(() => normalizeUrl(url), [url]);
const isValidUrl = React.useMemo(
() => isValidWebsiteUrl(normalizedUrl),
[normalizedUrl],
);
const fallback = React.useMemo(
() => getFallbackMetadata(normalizedUrl),
[normalizedUrl],
);
const [loadedMetadata, setLoadedMetadata] = React.useState<
NotionMentionLinkMetadata | undefined
>(() => suppliedMetadata ?? metadataCache.get(normalizedUrl));
const [status, setStatus] = React.useState<
"idle" | "loading" | "ready" | "error" | "invalid"
>(
!isValidUrl
? "invalid"
: suppliedMetadata || metadataCache.has(normalizedUrl)
? "ready"
: "idle",
);
const requestRef = React.useRef<AbortController | null>(null);
const previewId = React.useId();
const currentMetadata = suppliedMetadata ?? loadedMetadata ?? fallback;
const loadMetadata = React.useCallback(async () => {
if (!isValidUrl) {
setStatus("invalid");
return;
}
if (suppliedMetadata || metadataCache.has(normalizedUrl)) {
const cached = metadataCache.get(normalizedUrl);
if (cached) {
setLoadedMetadata(cached);
setStatus("ready");
}
return;
}
requestRef.current?.abort();
const controller = new AbortController();
requestRef.current = controller;
setStatus("loading");
try {
const separator = endpoint.includes("?") ? "&" : "?";
const response = await fetch(
`${endpoint}${separator}${new URLSearchParams({ url: normalizedUrl }).toString()}`,
{ signal: controller.signal },
);
if (!response.ok) throw new Error("Preview request failed");
const data = (await response.json()) as NotionMentionLinkMetadata;
metadataCache.set(normalizedUrl, data);
setLoadedMetadata(data);
setStatus("ready");
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return;
setStatus("error");
}
}, [endpoint, isValidUrl, normalizedUrl, suppliedMetadata]);
React.useEffect(() => {
requestRef.current?.abort();
setLoadedMetadata(suppliedMetadata ?? metadataCache.get(normalizedUrl));
setStatus(
!isValidUrl
? "invalid"
: suppliedMetadata || metadataCache.has(normalizedUrl)
? "ready"
: "idle",
);
}, [isValidUrl, normalizedUrl, suppliedMetadata]);
React.useEffect(() => {
if (prefetch === "mount") void loadMetadata();
return () => {
requestRef.current?.abort();
};
}, [loadMetadata, prefetch]);
const faviconUrl = getAssetUrl(endpoint, currentMetadata.favicon);
const imageUrl = getAssetUrl(endpoint, currentMetadata.image);
if (!isValidUrl || status === "invalid") {
return (
<span
role="status"
className="inline-flex min-h-7 items-center text-sm text-destructive"
>
{invalidLabel}
</span>
);
}
const mention = (
<a
ref={ref}
href={normalizedUrl}
target={target}
rel={rel}
className={cn(
"inline-flex max-w-full items-center gap-1.5 rounded-[5px] px-1 py-0.5 text-[15px] leading-6 text-foreground outline-none transition-colors duration-100",
"hover:bg-muted focus-visible:bg-muted focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
onMouseEnter={(event) => {
if (prefetch === "hover" || status === "idle") void loadMetadata();
onMouseEnter?.(event);
}}
onMouseLeave={(event) => {
onMouseLeave?.(event);
}}
onFocus={(event) => {
if (prefetch === "hover" || status === "idle") void loadMetadata();
onFocus?.(event);
}}
onBlur={(event) => {
onBlur?.(event);
}}
{...props}
>
<SiteIcon src={faviconUrl} label={currentMetadata.siteName} />
<span className="min-w-0 truncate text-muted-foreground">
{currentMetadata.siteName}
</span>
<span className="min-w-0 truncate font-medium underline decoration-foreground/35 underline-offset-[3px]">
{currentMetadata.title}
</span>
</a>
);
if (!showPreview) return mention;
return (
<HoverCard
openDelay={openDelay}
closeDelay={closeDelay}
onOpenChange={(nextOpen) => {
if (nextOpen && (prefetch === "hover" || status === "idle")) {
void loadMetadata();
}
}}
>
<HoverCardTrigger asChild>{mention}</HoverCardTrigger>
<HoverCardContent
id={previewId}
align={align}
side="bottom"
sideOffset={8}
style={{ width: previewWidth }}
className={cn(
"max-w-[calc(100vw-2rem)] border-0 bg-transparent p-0 shadow-none ring-0 will-change-transform",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-1 data-[state=open]:duration-150",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:slide-out-to-top-1 data-[state=closed]:duration-150",
previewClassName,
)}
>
{status === "loading" || status === "idle" ? (
<PreviewSkeleton height={previewHeight} />
) : (
<span className="block overflow-hidden rounded-xl border border-border bg-card text-left shadow-xl shadow-foreground/10">
{imageUrl ? (
<PreviewImage src={imageUrl} height={previewHeight} />
) : null}
<span className="flex flex-col gap-2 px-5 pb-5 pt-4">
<strong className="line-clamp-2 text-lg font-semibold leading-6 text-foreground">
{currentMetadata.title}
</strong>
{status === "error" ? (
<span className="text-sm leading-5 text-muted-foreground">
Preview unavailable. The link is still safe to open directly.
</span>
) : currentMetadata.description ? (
<span className="line-clamp-3 text-sm leading-5 text-foreground/85">
{currentMetadata.description}
</span>
) : null}
<span className="mt-2 flex items-center gap-2 text-sm font-medium text-muted-foreground">
<SiteIcon
src={faviconUrl}
label={currentMetadata.siteName}
large
/>
<span className="truncate">{currentMetadata.domain}</span>
</span>
</span>
</span>
)}
</HoverCardContent>
</HoverCard>
);
}
export { NotionMentionLink };
registry/components/unlumen/notion-mention-link/link-preview.server.ts
import { lookup } from "node:dns/promises";
import http from "node:http";
import https from "node:https";
import { isIP } from "node:net";
export type SafeLinkPreview = {
url: string;
title: string;
description?: string;
siteName: string;
domain: string;
image?: string;
favicon?: string;
};
type SafeResponse = {
body: Buffer;
contentType: string;
finalUrl: URL;
};
const MAX_HTML_BYTES = 512_000;
const MAX_ASSET_BYTES = 5_000_000;
const MAX_REDIRECTS = 3;
const REQUEST_TIMEOUT_MS = 5_000;
function isPrivateIpv4(address: string) {
const octets = address.split(".").map(Number);
const [a, b] = octets;
return (
a === 0 ||
a === 10 ||
a === 127 ||
(a === 169 && b === 254) ||
(a === 172 && b !== undefined && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 100 && b !== undefined && b >= 64 && b <= 127) ||
(a === 192 && b === 0) ||
(a === 198 && (b === 18 || b === 19)) ||
(a === 198 && b === 51 && octets[2] === 100) ||
(a === 203 && b === 0 && octets[2] === 113) ||
(a !== undefined && a >= 224)
);
}
function isPrivateIpv6(address: string) {
const normalized = address.toLowerCase().split("%")[0] ?? address;
return (
normalized === "::" ||
normalized === "::1" ||
normalized.startsWith("fc") ||
normalized.startsWith("fd") ||
/^fe[89ab]/.test(normalized) ||
normalized.startsWith("ff") ||
normalized.startsWith("2001:db8:") ||
(normalized.startsWith("::ffff:") &&
isPrivateIpv4(normalized.slice("::ffff:".length)))
);
}
function isPublicAddress(address: string) {
const version = isIP(address);
if (version === 4) return !isPrivateIpv4(address);
if (version === 6) return !isPrivateIpv6(address);
return false;
}
async function validateUrl(input: string) {
const url = new URL(input);
if (!["http:", "https:"].includes(url.protocol)) {
throw new Error("Only HTTP and HTTPS URLs are supported");
}
if (url.username || url.password)
throw new Error("URL credentials are not allowed");
if (url.port && !["80", "443"].includes(url.port)) {
throw new Error("Non-standard ports are not allowed");
}
const hostname = url.hostname.toLowerCase().replace(/\.$/, "");
if (
hostname === "localhost" ||
hostname.endsWith(".localhost") ||
hostname.endsWith(".local") ||
hostname.endsWith(".internal")
) {
throw new Error("Local network hosts are not allowed");
}
const literalVersion = isIP(hostname);
const addresses = literalVersion
? [{ address: hostname, family: literalVersion }]
: await lookup(hostname, { all: true, verbatim: true });
if (
addresses.length === 0 ||
addresses.some(({ address }) => !isPublicAddress(address))
) {
throw new Error("Private or unresolved hosts are not allowed");
}
return { url, addresses };
}
async function requestUrl(
input: string,
options: { maxBytes: number; accept: string },
redirects = 0,
): Promise<SafeResponse> {
const { url, addresses } = await validateUrl(input);
const selected = addresses[0];
if (!selected) throw new Error("Host did not resolve");
const transport = url.protocol === "https:" ? https : http;
return new Promise((resolve, reject) => {
const request = transport.request(
url,
{
method: "GET",
headers: {
accept: options.accept,
"accept-encoding": "identity",
"user-agent": "UnlumenLinkPreview/1.0 (+https://ui.unlumen.com)",
},
lookup: (_hostname, lookupOptions, callback) => {
if (typeof lookupOptions === "object" && lookupOptions.all) {
callback(null, [selected]);
return;
}
callback(null, selected.address, selected.family as 4 | 6);
},
},
(response) => {
const status = response.statusCode ?? 500;
const location = response.headers.location;
if (status >= 300 && status < 400 && location) {
response.resume();
if (redirects >= MAX_REDIRECTS) {
reject(new Error("Too many redirects"));
return;
}
const redirectUrl = new URL(location, url).toString();
void requestUrl(redirectUrl, options, redirects + 1).then(
resolve,
reject,
);
return;
}
if (status < 200 || status >= 300) {
response.resume();
reject(new Error(`Remote server returned ${status}`));
return;
}
const declaredLength = Number(response.headers["content-length"] ?? 0);
if (declaredLength > options.maxBytes) {
response.destroy(new Error("Remote response is too large"));
return;
}
const chunks: Buffer[] = [];
let received = 0;
response.on("data", (chunk: Buffer) => {
received += chunk.length;
if (received > options.maxBytes) {
response.destroy(new Error("Remote response is too large"));
return;
}
chunks.push(chunk);
});
response.on("end", () => {
resolve({
body: Buffer.concat(chunks),
contentType: String(response.headers["content-type"] ?? ""),
finalUrl: url,
});
});
response.on("error", reject);
},
);
request.setTimeout(REQUEST_TIMEOUT_MS, () => {
request.destroy(new Error("Remote request timed out"));
});
request.on("error", reject);
request.end();
});
}
function decodeEntities(value: string) {
const entities: Record<string, string> = {
amp: "&",
apos: "'",
gt: ">",
lt: "<",
quot: '"',
};
return value
.replace(/&#(\d+);/g, (_, code: string) =>
String.fromCodePoint(Number(code)),
)
.replace(/&#x([\da-f]+);/gi, (_, code: string) =>
String.fromCodePoint(Number.parseInt(code, 16)),
)
.replace(
/&([a-z]+);/gi,
(entity, name: string) => entities[name.toLowerCase()] ?? entity,
)
.replace(/\s+/g, " ")
.trim();
}
function getAttribute(tag: string, name: string) {
const match = tag.match(new RegExp(`\\b${name}\\s*=\\s*(["'])(.*?)\\1`, "i"));
return match?.[2] ? decodeEntities(match[2]) : undefined;
}
function getMeta(html: string, keys: string[]) {
for (const tag of html.match(/<meta\b[^>]*>/gi) ?? []) {
const key = getAttribute(tag, "property") ?? getAttribute(tag, "name");
if (key && keys.includes(key.toLowerCase())) {
const content = getAttribute(tag, "content");
if (content) return content;
}
}
return undefined;
}
function getFavicon(html: string) {
for (const tag of html.match(/<link\b[^>]*>/gi) ?? []) {
const rel = getAttribute(tag, "rel")?.toLowerCase();
if (rel?.split(/\s+/).includes("icon")) return getAttribute(tag, "href");
}
return undefined;
}
function resolveRemoteUrl(value: string | undefined, base: URL) {
if (!value) return undefined;
try {
const url = new URL(value, base);
return ["http:", "https:"].includes(url.protocol)
? url.toString()
: undefined;
} catch {
return undefined;
}
}
export async function getSafeLinkPreview(
input: string,
): Promise<SafeLinkPreview> {
const response = await requestUrl(input, {
maxBytes: MAX_HTML_BYTES,
accept: "text/html,application/xhtml+xml",
});
if (!response.contentType.toLowerCase().includes("text/html")) {
throw new Error("The URL did not return HTML");
}
const html = response.body.toString("utf8");
const titleTag = html.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i)?.[1];
const domain = response.finalUrl.hostname.replace(/^www\./, "");
const title =
getMeta(html, ["og:title", "twitter:title"]) ??
(titleTag ? decodeEntities(titleTag.replace(/<[^>]+>/g, "")) : undefined) ??
domain;
const siteName =
getMeta(html, ["og:site_name"]) ?? domain.split(".")[0] ?? domain;
return {
url: response.finalUrl.toString(),
title: title.slice(0, 200),
description: getMeta(html, [
"og:description",
"twitter:description",
"description",
])?.slice(0, 500),
siteName: siteName.slice(0, 80),
domain,
image: resolveRemoteUrl(
getMeta(html, ["og:image:secure_url", "og:image", "twitter:image"]),
response.finalUrl,
),
favicon: resolveRemoteUrl(
getFavicon(html) ?? "/favicon.ico",
response.finalUrl,
),
};
}
export async function getSafeRemoteAsset(input: string) {
const response = await requestUrl(input, {
maxBytes: MAX_ASSET_BYTES,
accept:
"image/avif,image/webp,image/png,image/jpeg,image/gif,image/svg+xml",
});
if (!response.contentType.toLowerCase().startsWith("image/")) {
throw new Error("The URL did not return an image");
}
return { body: response.body, contentType: response.contentType };
}
registry/components/unlumen/notion-mention-link/route.ts
import { NextResponse, type NextRequest } from "next/server";
import {
getSafeLinkPreview,
getSafeRemoteAsset,
} from "@/lib/notion-link-preview.server";
export const runtime = "nodejs";
const RATE_LIMIT_WINDOW_MS = 60_000;
const RATE_LIMIT_MAX_REQUESTS = 60;
const rateLimitBuckets = new Map<string, number[]>();
function isRateLimited(request: NextRequest) {
const forwardedFor = request.headers.get("x-forwarded-for")?.split(",")[0];
const client =
forwardedFor?.trim() || request.headers.get("x-real-ip") || "anonymous";
const now = Date.now();
const recent = (rateLimitBuckets.get(client) ?? []).filter(
(timestamp) => now - timestamp < RATE_LIMIT_WINDOW_MS,
);
if (recent.length >= RATE_LIMIT_MAX_REQUESTS) return true;
rateLimitBuckets.set(client, [...recent, now]);
if (rateLimitBuckets.size > 1_000) {
for (const [key, timestamps] of rateLimitBuckets) {
if (
timestamps.every((timestamp) => now - timestamp >= RATE_LIMIT_WINDOW_MS)
) {
rateLimitBuckets.delete(key);
}
}
}
return false;
}
export async function GET(request: NextRequest) {
if (isRateLimited(request)) {
return NextResponse.json(
{ error: "Too many preview requests." },
{ status: 429, headers: { "Retry-After": "60" } },
);
}
const url = request.nextUrl.searchParams.get("url");
if (!url || url.length > 2_048) {
return NextResponse.json(
{ error: "A valid URL is required." },
{ status: 400 },
);
}
try {
if (request.nextUrl.searchParams.get("asset") === "1") {
const asset = await getSafeRemoteAsset(url);
return new NextResponse(new Uint8Array(asset.body), {
headers: {
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
"Content-Type": asset.contentType,
"X-Content-Type-Options": "nosniff",
},
});
}
const preview = await getSafeLinkPreview(url);
return NextResponse.json(preview, {
headers: {
"Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400",
"X-Content-Type-Options": "nosniff",
},
});
} catch (error) {
console.warn("Notion mention preview rejected", error);
return NextResponse.json(
{ error: "Preview unavailable." },
{ status: 422 },
);
}
}