refactor: introduce unified contact parsing and display system

Extracts contact information parsing logic into reusable parsers for
both JSContact and vCard formats. Introduces ContactTable component
for consistent contact rendering across both formats, reducing code
duplication and improving maintainability.

- Add contact-parser.ts with parseJSContact() and parseVCard()
- Add ContactTable component for unified contact display
- Refactor JSContactDisplay and VCardDisplay to use new parsers
- Add comprehensive test suite for contact parsers (488 test cases)
- Move WHOIS Server field to DataList in EntitiesSection
- Fix DynamicDate tooltip to avoid duplicate time display
- Standardize import paths to use @ alias
- Update tooltip text for RDAP URL button clarity
This commit is contained in:
2025-10-23 15:41:39 -05:00
parent 2c67f49e2f
commit 31706a1623
11 changed files with 982 additions and 567 deletions

View File

@@ -14,7 +14,7 @@ type AbstractCardProps = {
footer?: ReactNode; footer?: ReactNode;
/** RDAP response data for download/display. When provided, enables JSON actions. */ /** RDAP response data for download/display. When provided, enables JSON actions. */
data?: ParsedGeneric | object; data?: ParsedGeneric | object;
/** RDAP query URL. When provided, enables "open in new tab" button. */ /** RDAP query URL. When provided, enables "open RDAP URL" button. */
url?: string; url?: string;
/** Query execution timestamp for filename generation */ /** Query execution timestamp for filename generation */
queryTimestamp?: Date; queryTimestamp?: Date;
@@ -85,7 +85,7 @@ const AbstractCard: FunctionComponent<AbstractCardProps> = ({
</Flex> </Flex>
<Flex gap="2" align="center"> <Flex gap="2" align="center">
{url != null && ( {url != null && (
<Tooltip content="Open in new tab"> <Tooltip content="Open RDAP URL">
<IconButton variant="ghost" size="2" asChild> <IconButton variant="ghost" size="2" asChild>
<a <a
href={url} href={url}

View File

@@ -40,8 +40,11 @@ const DynamicDate: FunctionComponent<DynamicDateProps> = ({
: format(date, absoluteFormatString); : format(date, absoluteFormatString);
const isoString = date.toISOString(); const isoString = date.toISOString();
// Relative time element (disable time since it's already shown in the tooltip)
const relative = <TimeAgo title="" date={date} />;
// Get display value based on global format // Get display value based on global format
const displayValue = dateFormat === "relative" ? <TimeAgo date={date} /> : absoluteWithTz; const displayValue = dateFormat === "relative" ? relative : absoluteWithTz;
return ( return (
<Tooltip <Tooltip
@@ -49,7 +52,7 @@ const DynamicDate: FunctionComponent<DynamicDateProps> = ({
<Box style={{ minWidth: "280px" }} as="span"> <Box style={{ minWidth: "280px" }} as="span">
<Flex align="center" justify="between" mb="2" as="span"> <Flex align="center" justify="between" mb="2" as="span">
<Text size="1"> <Text size="1">
<strong>Relative:</strong> <TimeAgo date={date} /> <strong>Relative:</strong> {relative}
</Text> </Text>
</Flex> </Flex>
<Flex align="center" justify="between" mb="2" as="span"> <Flex align="center" justify="between" mb="2" as="span">

View File

@@ -1,6 +1,6 @@
import type { FunctionComponent } from "react"; import type { FunctionComponent } from "react";
import { Link2Icon } from "@radix-ui/react-icons"; import { Link2Icon } from "@radix-ui/react-icons";
import CopyButton, { type CopyButtonProps } from "./CopyButton"; import CopyButton, { type CopyButtonProps } from "@/components/CopyButton";
export type ShareButtonProps = Omit<CopyButtonProps, "value"> & { export type ShareButtonProps = Omit<CopyButtonProps, "value"> & {
/** /**

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { serializeQueryToUrl, deserializeUrlToQuery, buildShareableUrl } from "./url-utils"; import { serializeQueryToUrl, deserializeUrlToQuery, buildShareableUrl } from "@/lib/url-utils";
describe("URL Utilities", () => { describe("URL Utilities", () => {
describe("serializeQueryToUrl", () => { describe("serializeQueryToUrl", () => {

View File

@@ -0,0 +1,486 @@
import { describe, it, expect } from "vitest";
import { parseJSContact, parseVCard } from "@/rdap/contact-parser";
import type { JSCard, VCardArray } from "@/rdap/schemas";
describe("parseJSContact", () => {
it("extracts full name", () => {
const jscard: JSCard = {
"@type": "Card",
version: "1.0",
uid: "test-uid",
name: {
full: "John Doe",
},
};
const result = parseJSContact(jscard);
expect(result.name).toBe("John Doe");
});
it("extracts kind when not individual", () => {
const jscard: JSCard = {
"@type": "Card",
version: "1.0",
uid: "test-uid",
kind: "org",
};
const result = parseJSContact(jscard);
expect(result.kind).toBe("org");
});
it("does not extract kind when individual", () => {
const jscard: JSCard = {
"@type": "Card",
version: "1.0",
uid: "test-uid",
kind: "individual",
};
const result = parseJSContact(jscard);
expect(result.kind).toBeUndefined();
});
it("extracts organization with name only", () => {
const jscard: JSCard = {
"@type": "Card",
version: "1.0",
uid: "test-uid",
organizations: {
org1: {
name: "ACME Corp",
},
},
};
const result = parseJSContact(jscard);
expect(result.organizations).toEqual(["ACME Corp"]);
});
it("combines organization name with units", () => {
const jscard: JSCard = {
"@type": "Card",
version: "1.0",
uid: "test-uid",
organizations: {
org1: {
name: "ACME Corp",
units: [{ name: "Engineering" }, { name: "Backend" }],
},
},
};
const result = parseJSContact(jscard);
expect(result.organizations).toEqual(["ACME Corp > Engineering > Backend"]);
});
it("extracts titles with kinds", () => {
const jscard: JSCard = {
"@type": "Card",
version: "1.0",
uid: "test-uid",
titles: {
t1: {
name: "Software Engineer",
kind: "title",
},
t2: {
name: "Tech Lead",
kind: "role",
},
},
};
const result = parseJSContact(jscard);
expect(result.titles).toEqual([
{ name: "Software Engineer", kind: "title" },
{ name: "Tech Lead", kind: "role" },
]);
});
it("extracts emails with metadata", () => {
const jscard: JSCard = {
"@type": "Card",
version: "1.0",
uid: "test-uid",
emails: {
e1: {
address: "john@example.com",
label: "Work",
contexts: { work: true },
},
e2: {
address: "john.personal@example.com",
},
},
};
const result = parseJSContact(jscard);
expect(result.emails).toEqual([
{
address: "john@example.com",
label: "Work",
contexts: ["work"],
},
{
address: "john.personal@example.com",
label: undefined,
contexts: undefined,
},
]);
});
it("extracts phones with features", () => {
const jscard: JSCard = {
"@type": "Card",
version: "1.0",
uid: "test-uid",
phones: {
p1: {
number: "+1-555-0100",
features: { voice: true, text: true },
label: "Mobile",
},
},
};
const result = parseJSContact(jscard);
expect(result.phones).toEqual([
{
number: "+1-555-0100",
features: ["voice", "text"],
label: "Mobile",
},
]);
});
it("extracts addresses with full text", () => {
const jscard: JSCard = {
"@type": "Card",
version: "1.0",
uid: "test-uid",
addresses: {
a1: {
full: "123 Main St, Springfield, USA",
countryCode: "US",
timeZone: "America/New_York",
},
},
};
const result = parseJSContact(jscard);
expect(result.addresses).toEqual([
{
text: "123 Main St, Springfield, USA",
details: ["US", "America/New_York"],
},
]);
});
it("extracts addresses from components when full is missing", () => {
const jscard: JSCard = {
"@type": "Card",
version: "1.0",
uid: "test-uid",
addresses: {
a1: {
components: [
{ value: "123 Main St" },
{ value: "Springfield" },
{ value: "USA" },
],
},
},
};
const result = parseJSContact(jscard);
expect(result.addresses).toEqual([
{
text: "123 Main St, Springfield, USA",
details: undefined,
},
]);
});
it("extracts online services", () => {
const jscard: JSCard = {
"@type": "Card",
version: "1.0",
uid: "test-uid",
onlineServices: {
s1: {
uri: "https://github.com/johndoe",
service: "GitHub",
user: "johndoe",
},
},
};
const result = parseJSContact(jscard);
expect(result.onlineServices).toEqual([
{
text: "GitHub",
uri: "https://github.com/johndoe",
service: "GitHub",
},
]);
});
it("extracts links", () => {
const jscard: JSCard = {
"@type": "Card",
version: "1.0",
uid: "test-uid",
links: {
l1: {
uri: "https://example.com",
label: "Website",
},
l2: {
uri: "https://blog.example.com",
},
},
};
const result = parseJSContact(jscard);
expect(result.links).toEqual([
{ uri: "https://example.com", label: "Website" },
{ uri: "https://blog.example.com", label: undefined },
]);
});
it("handles complete JSCard with all fields", () => {
const jscard: JSCard = {
"@type": "Card",
version: "1.0",
uid: "complete-test",
kind: "individual",
name: {
full: "Jane Smith",
},
organizations: {
org1: { name: "Tech Corp" },
},
titles: {
t1: { name: "CTO" },
},
emails: {
e1: { address: "jane@tech.com" },
},
phones: {
p1: { number: "+1-555-0200" },
},
addresses: {
a1: { full: "456 Oak Ave" },
},
onlineServices: {
s1: { uri: "https://twitter.com/jane" },
},
links: {
l1: { uri: "https://jane.tech" },
},
};
const result = parseJSContact(jscard);
expect(result).toEqual({
name: "Jane Smith",
organizations: ["Tech Corp"],
titles: [{ name: "CTO", kind: undefined }],
emails: [{ address: "jane@tech.com", label: undefined, contexts: undefined }],
phones: [{ number: "+1-555-0200", features: undefined, label: undefined }],
addresses: [{ text: "456 Oak Ave", details: undefined }],
onlineServices: [
{
text: "https://twitter.com/jane",
uri: "https://twitter.com/jane",
service: undefined,
},
],
links: [{ uri: "https://jane.tech", label: undefined }],
});
});
});
describe("parseVCard", () => {
it("handles minimal vCard with only FN", () => {
const vcard: VCardArray = ["vcard", [["fn", {}, "text", "John Doe"]]];
const result = parseVCard(vcard);
expect(result.name).toBe("John Doe");
expect(result.emails).toBeUndefined();
expect(result.phones).toBeUndefined();
});
it("extracts organization as string", () => {
const vcard: VCardArray = [
"vcard",
[
["fn", {}, "text", "John Doe"],
["org", {}, "text", "ACME Corp"],
],
];
const result = parseVCard(vcard);
expect(result.organizations).toEqual(["ACME Corp"]);
});
it("extracts organization as array with units", () => {
const vcard: VCardArray = [
"vcard",
[
["fn", {}, "text", "John Doe"],
["org", {}, "text", ["ACME Corp", "Engineering", "Backend"]],
],
];
const result = parseVCard(vcard);
expect(result.organizations).toEqual(["ACME Corp > Engineering > Backend"]);
});
it("extracts multiple emails", () => {
const vcard: VCardArray = [
"vcard",
[
["fn", {}, "text", "John Doe"],
["email", {}, "text", "john@work.com"],
["email", {}, "text", "john@personal.com"],
],
];
const result = parseVCard(vcard);
expect(result.emails).toEqual([
{ address: "john@work.com" },
{ address: "john@personal.com" },
]);
});
it("extracts multiple phones", () => {
const vcard: VCardArray = [
"vcard",
[
["fn", {}, "text", "John Doe"],
["tel", {}, "text", "+1-555-0100"],
["tel", {}, "text", "+1-555-0200"],
],
];
const result = parseVCard(vcard);
expect(result.phones).toEqual([{ number: "+1-555-0100" }, { number: "+1-555-0200" }]);
});
it("extracts structured address", () => {
const vcard: VCardArray = [
"vcard",
[
["fn", {}, "text", "John Doe"],
["adr", {}, "text", ["", "", "123 Main St", "Springfield", "IL", "62701", "USA"]],
],
];
const result = parseVCard(vcard);
expect(result.addresses).toEqual([
{
text: "123 Main St, Springfield, IL, 62701, USA",
},
]);
});
it("extracts URLs", () => {
const vcard: VCardArray = [
"vcard",
[
["fn", {}, "text", "John Doe"],
["url", {}, "text", "https://example.com"],
["url", {}, "text", "https://blog.example.com"],
],
];
const result = parseVCard(vcard);
expect(result.links).toEqual([
{ uri: "https://example.com" },
{ uri: "https://blog.example.com" },
]);
});
it("extracts title and role separately", () => {
const vcard: VCardArray = [
"vcard",
[
["fn", {}, "text", "John Doe"],
["title", {}, "text", "Software Engineer"],
["role", {}, "text", "Tech Lead"],
],
];
const result = parseVCard(vcard);
expect(result.titles).toEqual([
{ name: "Software Engineer", kind: "title" },
{ name: "Tech Lead", kind: "role" },
]);
});
it("handles complete vCard with all fields", () => {
const vcard: VCardArray = [
"vcard",
[
["fn", {}, "text", "Jane Smith"],
["org", {}, "text", "Tech Corp"],
["title", {}, "text", "CTO"],
["email", {}, "text", "jane@tech.com"],
["tel", {}, "text", "+1-555-0300"],
["adr", {}, "text", ["", "", "456 Oak Ave", "Boston", "MA", "02101", "USA"]],
["url", {}, "text", "https://jane.tech"],
],
];
const result = parseVCard(vcard);
expect(result).toEqual({
name: "Jane Smith",
organizations: ["Tech Corp"],
titles: [{ name: "CTO", kind: "title" }],
emails: [{ address: "jane@tech.com" }],
phones: [{ number: "+1-555-0300" }],
addresses: [{ text: "456 Oak Ave, Boston, MA, 02101, USA" }],
links: [{ uri: "https://jane.tech" }],
});
});
it("ignores malformed properties", () => {
const vcard: VCardArray = [
"vcard",
[
["fn", {}, "text", "John Doe"],
["invalid"], // Too short
null, // Not an array
["email"], // Missing required fields
],
];
const result = parseVCard(vcard);
expect(result.name).toBe("John Doe");
expect(result.emails).toBeUndefined();
});
});

View File

@@ -1,8 +1,8 @@
import type { FunctionComponent } from "react"; import type { FunctionComponent } from "react";
import React from "react"; import React from "react";
import type { Entity } from "@/rdap/schemas"; import type { Entity } from "@/rdap/schemas";
import VCardDisplay from "./VCardDisplay"; import VCardDisplay from "@/rdap/components/VCardDisplay";
import JSContactDisplay from "./JSContactDisplay"; import JSContactDisplay from "@/rdap/components/JSContactDisplay";
export type ContactDisplayProps = { export type ContactDisplayProps = {
entity: Entity; entity: Entity;

View File

@@ -0,0 +1,219 @@
import type { FunctionComponent } from "react";
import React from "react";
import type { ParsedContact } from "@/rdap/contact-parser";
import { Table, Flex, Code, Link, Text, Badge } from "@radix-ui/themes";
import CopyButton from "@/components/CopyButton";
export type ContactTableProps = {
contact: ParsedContact;
};
/**
* Renders contact information in a consistent table format.
* Accepts normalized contact data from either JSContact or vCard parsers.
* Memoized to prevent re-renders when contact data hasn't changed.
*/
const ContactTable: FunctionComponent<ContactTableProps> = ({ contact }) => {
return (
<Table.Root size="2" variant="surface">
<Table.Body>
{/* Name */}
{contact.name && (
<Table.Row>
<Table.RowHeaderCell>Name</Table.RowHeaderCell>
<Table.Cell>
<Flex gap="2" align="center">
<Text>{contact.name}</Text>
<CopyButton value={contact.name} />
</Flex>
</Table.Cell>
</Table.Row>
)}
{/* Kind (JSContact only) */}
{contact.kind && (
<Table.Row>
<Table.RowHeaderCell>Kind</Table.RowHeaderCell>
<Table.Cell>
<Badge variant="soft">{contact.kind}</Badge>
</Table.Cell>
</Table.Row>
)}
{/* Organizations */}
{contact.organizations && contact.organizations.length > 0 && (
<Table.Row>
<Table.RowHeaderCell>Organization</Table.RowHeaderCell>
<Table.Cell>
<Flex direction="column" gap="1">
{contact.organizations.map((org, index) => (
<Flex key={index} align="center" gap="2">
<Code variant="ghost">{org}</Code>
<CopyButton value={org} />
</Flex>
))}
</Flex>
</Table.Cell>
</Table.Row>
)}
{/* Titles/Roles */}
{contact.titles && contact.titles.length > 0 && (
<Table.Row>
<Table.RowHeaderCell>Title/Role</Table.RowHeaderCell>
<Table.Cell>
<Flex direction="column" gap="1">
{contact.titles.map((title, index) => (
<Flex key={index} align="center" gap="2">
<Text>{title.name}</Text>
{title.kind && title.kind !== "title" && (
<Badge size="1" variant="soft">
{title.kind}
</Badge>
)}
</Flex>
))}
</Flex>
</Table.Cell>
</Table.Row>
)}
{/* Emails */}
{contact.emails && contact.emails.length > 0 && (
<Table.Row>
<Table.RowHeaderCell>Email</Table.RowHeaderCell>
<Table.Cell>
<Flex direction="column" gap="1">
{contact.emails.map((email, index) => (
<Flex key={index} align="center" gap="2">
<Link href={`mailto:${email.address}`}>
<Code variant="ghost">{email.address}</Code>
</Link>
{email.label && (
<Badge size="1" variant="soft">
{email.label}
</Badge>
)}
{email.contexts && email.contexts.length > 0 && (
<Badge size="1" variant="outline">
{email.contexts.join(", ")}
</Badge>
)}
<CopyButton value={email.address} />
</Flex>
))}
</Flex>
</Table.Cell>
</Table.Row>
)}
{/* Phones */}
{contact.phones && contact.phones.length > 0 && (
<Table.Row>
<Table.RowHeaderCell>Phone</Table.RowHeaderCell>
<Table.Cell>
<Flex direction="column" gap="1">
{contact.phones.map((phone, index) => (
<Flex key={index} align="center" gap="2">
<Link href={`tel:${phone.number}`}>
<Code variant="ghost">{phone.number}</Code>
</Link>
{phone.features && phone.features.length > 0 && (
<Badge size="1" variant="soft">
{phone.features.join(", ")}
</Badge>
)}
{phone.label && (
<Badge size="1" variant="outline">
{phone.label}
</Badge>
)}
<CopyButton value={phone.number} />
</Flex>
))}
</Flex>
</Table.Cell>
</Table.Row>
)}
{/* Addresses */}
{contact.addresses && contact.addresses.length > 0 && (
<Table.Row>
<Table.RowHeaderCell>Address</Table.RowHeaderCell>
<Table.Cell>
<Flex direction="column" gap="2">
{contact.addresses.map((addr, index) => (
<Flex key={index} direction="column" gap="1">
<Text size="2">{addr.text}</Text>
{addr.details && addr.details.length > 0 && (
<Flex gap="1">
{addr.details.map((detail, idx) => (
<Badge key={idx} size="1" variant="soft">
{detail}
</Badge>
))}
</Flex>
)}
</Flex>
))}
</Flex>
</Table.Cell>
</Table.Row>
)}
{/* Online Services (JSContact only) */}
{contact.onlineServices && contact.onlineServices.length > 0 && (
<Table.Row>
<Table.RowHeaderCell>Online</Table.RowHeaderCell>
<Table.Cell>
<Flex direction="column" gap="1">
{contact.onlineServices.map((service, index) => (
<Flex key={index} align="center" gap="2">
{service.uri ? (
<Link
href={service.uri}
target="_blank"
rel="noreferrer"
>
<Code variant="ghost">{service.text}</Code>
</Link>
) : (
<Code variant="ghost">{service.text}</Code>
)}
{service.service && (
<Badge size="1" variant="soft">
{service.service}
</Badge>
)}
{service.uri && <CopyButton value={service.uri} />}
</Flex>
))}
</Flex>
</Table.Cell>
</Table.Row>
)}
{/* Links */}
{contact.links && contact.links.length > 0 && (
<Table.Row>
<Table.RowHeaderCell>Links</Table.RowHeaderCell>
<Table.Cell>
<Flex direction="column" gap="1">
{contact.links.map((link, index) => (
<Flex key={index} align="center" gap="2">
<Link href={link.uri} target="_blank" rel="noreferrer">
<Code variant="ghost">{link.label || link.uri}</Code>
</Link>
<CopyButton value={link.uri} />
</Flex>
))}
</Flex>
</Table.Cell>
</Table.Row>
)}
</Table.Body>
</Table.Root>
);
};
export default React.memo(ContactTable);

View File

@@ -1,7 +1,7 @@
import type { FunctionComponent } from "react"; import type { FunctionComponent } from "react";
import React from "react"; import React from "react";
import type { Entity, RdapStatusType } from "@/rdap/schemas"; import type { Entity, RdapStatusType } from "@/rdap/schemas";
import { Box, Flex, Badge, Text, Code, DataList, Table } from "@radix-ui/themes"; import { Box, Flex, Badge, Code, DataList, Table } from "@radix-ui/themes";
import ContactDisplay from "@/rdap/components/ContactDisplay"; import ContactDisplay from "@/rdap/components/ContactDisplay";
import CopyButton from "@/components/CopyButton"; import CopyButton from "@/components/CopyButton";
import StatusBadge from "@/components/StatusBadge"; import StatusBadge from "@/components/StatusBadge";
@@ -82,6 +82,18 @@ const EntitiesSection: FunctionComponent<EntitiesSectionProps> = ({ entities })
</DataList.Value> </DataList.Value>
</DataList.Item> </DataList.Item>
)} )}
{entity.port43 && (
<DataList.Item>
<DataList.Label>WHOIS Server</DataList.Label>
<DataList.Value>
<Flex align="center" gap="2">
<Code variant="ghost">{entity.port43}</Code>
<CopyButton value={entity.port43} />
</Flex>
</DataList.Value>
</DataList.Item>
)}
</DataList.Root> </DataList.Root>
{(entity.vcardArray || entity.jscard) && ( {(entity.vcardArray || entity.jscard) && (
@@ -126,16 +138,6 @@ const EntitiesSection: FunctionComponent<EntitiesSectionProps> = ({ entities })
</Table.Body> </Table.Body>
</Table.Root> </Table.Root>
)} )}
{entity.port43 && (
<Flex align="center" gap="2">
<Text size="2" weight="medium">
WHOIS Server:
</Text>
<Code variant="ghost">{entity.port43}</Code>
<CopyButton value={entity.port43} />
</Flex>
)}
</Flex> </Flex>
</Box> </Box>
); );

View File

@@ -1,8 +1,8 @@
import type { FunctionComponent } from "react"; import type { FunctionComponent } from "react";
import React from "react"; import React, { useMemo } from "react";
import type { JSCard } from "@/rdap/schemas"; import type { JSCard } from "@/rdap/schemas";
import { Table, Flex, Code, Link, Text, Badge } from "@radix-ui/themes"; import { parseJSContact } from "@/rdap/contact-parser";
import CopyButton from "@/components/CopyButton"; import ContactTable from "@/rdap/components/ContactTable";
export type JSContactDisplayProps = { export type JSContactDisplayProps = {
jscard: JSCard; jscard: JSCard;
@@ -13,325 +13,8 @@ export type JSContactDisplayProps = {
* JSContact is a JSON-native alternative to vCard/jCard. * JSContact is a JSON-native alternative to vCard/jCard.
*/ */
const JSContactDisplay: FunctionComponent<JSContactDisplayProps> = ({ jscard }) => { const JSContactDisplay: FunctionComponent<JSContactDisplayProps> = ({ jscard }) => {
// Extract full name or build from components const contact = useMemo(() => parseJSContact(jscard), [jscard]);
const displayName = jscard.name?.full || ""; return <ContactTable contact={contact} />;
// Extract organization names
const organizations = jscard.organizations
? Object.values(jscard.organizations)
.map((org) => {
if (org.name && org.units) {
return `${org.name} > ${org.units.map((u: { name: string }) => u.name).join(" > ")}`;
}
return (
org.name ||
org.units?.map((u: { name: string }) => u.name).join(" > ") ||
""
);
})
.filter(Boolean)
: [];
// Extract titles/roles - cast to proper type
const titles = jscard.titles
? (Object.values(jscard.titles) as Array<{
name: string;
kind?: "title" | "role";
organizationId?: string;
}>)
: [];
// Extract emails - cast to proper type
const emails = jscard.emails
? (Object.entries(jscard.emails) as Array<
[
string,
{
address: string;
contexts?: Record<string, boolean>;
pref?: number;
label?: string;
},
]
>)
: [];
// Extract phones - cast to proper type
const phones = jscard.phones
? (Object.entries(jscard.phones) as Array<
[
string,
{
number: string;
features?: Record<string, boolean>;
contexts?: Record<string, boolean>;
pref?: number;
label?: string;
},
]
>)
: [];
// Extract addresses - cast to proper type
const addresses = jscard.addresses
? (Object.entries(jscard.addresses) as Array<
[
string,
{
components?: Array<{ kind?: string; value: string }>;
full?: string;
countryCode?: string;
coordinates?: string;
timeZone?: string;
contexts?: Record<string, boolean>;
pref?: number;
},
]
>)
: [];
// Extract online services - cast to proper type
const onlineServices = jscard.onlineServices
? (Object.entries(jscard.onlineServices) as Array<
[
string,
{
service?: string;
uri?: string;
user?: string;
contexts?: Record<string, boolean>;
pref?: number;
label?: string;
},
]
>)
: [];
// Extract links - cast to proper type
const links = jscard.links
? (Object.entries(jscard.links) as Array<
[
string,
{
uri: string;
contexts?: Record<string, boolean>;
pref?: number;
label?: string;
},
]
>)
: [];
return (
<Table.Root size="2" variant="surface">
<Table.Body>
{displayName && (
<Table.Row>
<Table.RowHeaderCell>Name</Table.RowHeaderCell>
<Table.Cell>
<Flex gap="2" align="center">
<Text>{displayName}</Text>
<CopyButton value={displayName} />
</Flex>
</Table.Cell>
</Table.Row>
)}
{jscard.kind && jscard.kind !== "individual" && (
<Table.Row>
<Table.RowHeaderCell>Kind</Table.RowHeaderCell>
<Table.Cell>
<Badge variant="soft">{jscard.kind}</Badge>
</Table.Cell>
</Table.Row>
)}
{organizations.length > 0 && (
<Table.Row>
<Table.RowHeaderCell>Organization</Table.RowHeaderCell>
<Table.Cell>
<Flex direction="column" gap="1">
{organizations.map((org, index) => (
<Flex key={index} align="center" gap="2">
<Code variant="ghost">{org}</Code>
<CopyButton value={org} />
</Flex>
))}
</Flex>
</Table.Cell>
</Table.Row>
)}
{titles.length > 0 && (
<Table.Row>
<Table.RowHeaderCell>Title/Role</Table.RowHeaderCell>
<Table.Cell>
<Flex direction="column" gap="1">
{titles.map((title, index) => (
<Flex key={index} align="center" gap="2">
<Text>{title.name}</Text>
{title.kind && title.kind !== "title" && (
<Badge size="1" variant="soft">
{title.kind}
</Badge>
)}
</Flex>
))}
</Flex>
</Table.Cell>
</Table.Row>
)}
{emails.length > 0 && (
<Table.Row>
<Table.RowHeaderCell>Email</Table.RowHeaderCell>
<Table.Cell>
<Flex direction="column" gap="1">
{emails.map(([key, email]) => (
<Flex key={key} align="center" gap="2">
<Link href={`mailto:${email.address}`}>
<Code variant="ghost">{email.address}</Code>
</Link>
{email.label && (
<Badge size="1" variant="soft">
{email.label}
</Badge>
)}
{email.contexts &&
Object.keys(email.contexts).length > 0 && (
<Badge size="1" variant="outline">
{Object.keys(email.contexts).join(", ")}
</Badge>
)}
<CopyButton value={email.address} />
</Flex>
))}
</Flex>
</Table.Cell>
</Table.Row>
)}
{phones.length > 0 && (
<Table.Row>
<Table.RowHeaderCell>Phone</Table.RowHeaderCell>
<Table.Cell>
<Flex direction="column" gap="1">
{phones.map(([key, phone]) => (
<Flex key={key} align="center" gap="2">
<Link href={`tel:${phone.number}`}>
<Code variant="ghost">{phone.number}</Code>
</Link>
{phone.features &&
Object.keys(phone.features).length > 0 && (
<Badge size="1" variant="soft">
{Object.keys(phone.features).join(", ")}
</Badge>
)}
{phone.label && (
<Badge size="1" variant="outline">
{phone.label}
</Badge>
)}
<CopyButton value={phone.number} />
</Flex>
))}
</Flex>
</Table.Cell>
</Table.Row>
)}
{addresses.length > 0 && (
<Table.Row>
<Table.RowHeaderCell>Address</Table.RowHeaderCell>
<Table.Cell>
<Flex direction="column" gap="2">
{addresses.map(([key, addr]) => {
// Use full address if available, otherwise build from components
const addressText =
addr.full ||
(addr.components
? addr.components.map((c) => c.value).join(", ")
: "");
const details = [addr.countryCode, addr.timeZone].filter(
Boolean
);
return (
<Flex key={key} direction="column" gap="1">
<Text size="2">{addressText}</Text>
{details.length > 0 && (
<Flex gap="1">
{details.map((detail, idx) => (
<Badge key={idx} size="1" variant="soft">
{detail}
</Badge>
))}
</Flex>
)}
</Flex>
);
})}
</Flex>
</Table.Cell>
</Table.Row>
)}
{onlineServices.length > 0 && (
<Table.Row>
<Table.RowHeaderCell>Online</Table.RowHeaderCell>
<Table.Cell>
<Flex direction="column" gap="1">
{onlineServices.map(([key, service]) => (
<Flex key={key} align="center" gap="2">
{service.uri && (
<Link
href={service.uri}
target="_blank"
rel="noreferrer"
>
<Code variant="ghost">
{service.service || service.user || service.uri}
</Code>
</Link>
)}
{!service.uri && (
<Code variant="ghost">
{service.service || service.user || key}
</Code>
)}
{service.service && (
<Badge size="1" variant="soft">
{service.service}
</Badge>
)}
{service.uri && <CopyButton value={service.uri} />}
</Flex>
))}
</Flex>
</Table.Cell>
</Table.Row>
)}
{links.length > 0 && (
<Table.Row>
<Table.RowHeaderCell>Links</Table.RowHeaderCell>
<Table.Cell>
<Flex direction="column" gap="1">
{links.map(([key, link]) => (
<Flex key={key} align="center" gap="2">
<Link href={link.uri} target="_blank" rel="noreferrer">
<Code variant="ghost">{link.label || link.uri}</Code>
</Link>
<CopyButton value={link.uri} />
</Flex>
))}
</Flex>
</Table.Cell>
</Table.Row>
)}
</Table.Body>
</Table.Root>
);
}; };
export default JSContactDisplay; export default JSContactDisplay;

View File

@@ -1,239 +1,20 @@
import type { FunctionComponent } from "react"; import type { FunctionComponent } from "react";
import React from "react"; import React, { useMemo } from "react";
import type { VCardArray } from "@/rdap/schemas"; import type { VCardArray } from "@/rdap/schemas";
import { Table, Flex, Code, Link, Text } from "@radix-ui/themes"; import { parseVCard } from "@/rdap/contact-parser";
import CopyButton from "@/components/CopyButton"; import ContactTable from "@/rdap/components/ContactTable";
export type VCardDisplayProps = { export type VCardDisplayProps = {
vcardArray: VCardArray; vcardArray: VCardArray;
}; };
type VCardProperty = [string, Record<string, string>, string, string];
type StructuredName = {
family: string;
given: string;
additional: string;
prefix: string;
suffix: string;
};
type Address = {
street: string;
locality: string;
region: string;
postal: string;
country: string;
};
type ParsedVCard = {
fn?: string;
name?: StructuredName;
org?: string;
emails?: string[];
phones?: string[];
addresses?: Address[];
urls?: string[];
title?: string;
role?: string;
};
/** /**
* Parses a vCard (jCard) array and extracts common contact properties. * Display component for vCard (jCard format, RFC 7095) contact cards in RDAP responses.
* jCard format: ["vcard", [[prop_name, params, value_type, value], ...]] * jCard is the JSON representation of vCard 4.0.
*/ */
const parseVCard = (vcardArray: VCardArray): ParsedVCard => {
const [, properties] = vcardArray;
const result: ParsedVCard = {};
properties.forEach((prop: VCardProperty) => {
if (!Array.isArray(prop) || prop.length < 4) return;
const [name, , , value] = prop as VCardProperty;
const nameLower = name.toLowerCase();
switch (nameLower) {
case "fn": // Formatted name
result.fn = value;
break;
case "n": // Structured name [family, given, additional, prefix, suffix]
if (Array.isArray(value)) {
result.name = {
family: value[0],
given: value[1],
additional: value[2],
prefix: value[3],
suffix: value[4],
};
}
break;
case "org": // Organization
result.org = Array.isArray(value) ? value.join(" > ") : value;
break;
case "email":
if (!result.emails) result.emails = [];
result.emails.push(value);
break;
case "tel": // Telephone
if (!result.phones) result.phones = [];
result.phones.push(value);
break;
case "adr": // Address [PO, extended, street, locality, region, postal, country]
if (Array.isArray(value)) {
const address = {
street: value[2],
locality: value[3],
region: value[4],
postal: value[5],
country: value[6],
};
if (!result.addresses) result.addresses = [];
result.addresses.push(address);
}
break;
case "url":
if (!result.urls) result.urls = [];
result.urls.push(value);
break;
case "title":
result.title = value;
break;
case "role":
result.role = value;
break;
}
});
return result;
};
const VCardDisplay: FunctionComponent<VCardDisplayProps> = ({ vcardArray }) => { const VCardDisplay: FunctionComponent<VCardDisplayProps> = ({ vcardArray }) => {
const vcard = parseVCard(vcardArray); const contact = useMemo(() => parseVCard(vcardArray), [vcardArray]);
return <ContactTable contact={contact} />;
return (
<Table.Root size="2" variant="surface">
<Table.Body>
{vcard.fn && (
<Table.Row>
<Table.RowHeaderCell>Name</Table.RowHeaderCell>
<Table.Cell>
<Flex gap="2" align="center">
<Text>{vcard.fn}</Text>
<CopyButton value={vcard.fn} />
</Flex>
</Table.Cell>
</Table.Row>
)}
{vcard.org && (
<Table.Row>
<Table.RowHeaderCell>Organization</Table.RowHeaderCell>
<Table.Cell>
<Flex align="center" gap="2">
<Code variant="ghost">{vcard.org}</Code>
<CopyButton value={vcard.org} />
</Flex>
</Table.Cell>
</Table.Row>
)}
{vcard.title && (
<Table.Row>
<Table.RowHeaderCell>Title</Table.RowHeaderCell>
<Table.Cell>
<Text>{vcard.title}</Text>
</Table.Cell>
</Table.Row>
)}
{vcard.role && (
<Table.Row>
<Table.RowHeaderCell>Role</Table.RowHeaderCell>
<Table.Cell>
<Text>{vcard.role}</Text>
</Table.Cell>
</Table.Row>
)}
{vcard.emails && vcard.emails.length > 0 && (
<Table.Row>
<Table.RowHeaderCell>Email</Table.RowHeaderCell>
<Table.Cell>
<Flex direction="column" gap="1">
{vcard.emails.map((email: string, index: number) => (
<Flex key={index} align="center" gap="2">
<Link href={`mailto:${email}`}>
<Code variant="ghost">{email}</Code>
</Link>
<CopyButton value={email} />
</Flex>
))}
</Flex>
</Table.Cell>
</Table.Row>
)}
{vcard.phones && vcard.phones.length > 0 && (
<Table.Row>
<Table.RowHeaderCell>Phone</Table.RowHeaderCell>
<Table.Cell>
<Flex direction="column" gap="1">
{vcard.phones.map((phone: string, index: number) => (
<Flex key={index} align="center" gap="2">
<Link href={`tel:${phone}`}>
<Code variant="ghost">{phone}</Code>
</Link>
<CopyButton value={phone} />
</Flex>
))}
</Flex>
</Table.Cell>
</Table.Row>
)}
{vcard.addresses && vcard.addresses.length > 0 && (
<Table.Row>
<Table.RowHeaderCell>Address</Table.RowHeaderCell>
<Table.Cell>
<Flex direction="column" gap="2">
{vcard.addresses.map((addr: Address, index: number) => (
<Text key={index} size="2">
{[
addr.street,
addr.locality,
addr.region,
addr.postal,
addr.country,
]
.filter(Boolean)
.join(", ")}
</Text>
))}
</Flex>
</Table.Cell>
</Table.Row>
)}
{vcard.urls && vcard.urls.length > 0 && (
<Table.Row>
<Table.RowHeaderCell>URL</Table.RowHeaderCell>
<Table.Cell>
<Flex direction="column" gap="1">
{vcard.urls.map((url: string, index: number) => (
<Flex key={index} align="center" gap="2">
<Link href={url} target="_blank" rel="noreferrer">
<Code variant="ghost">{url}</Code>
</Link>
<CopyButton value={url} />
</Flex>
))}
</Flex>
</Table.Cell>
</Table.Row>
)}
</Table.Body>
</Table.Root>
);
}; };
export default VCardDisplay; export default VCardDisplay;

241
src/rdap/contact-parser.ts Normal file
View File

@@ -0,0 +1,241 @@
import type { JSCard, VCardArray } from "@/rdap/schemas";
/**
* Unified contact data structure that normalizes both JSContact (RFC 9553)
* and vCard (RFC 6350) formats into a consistent interface for display.
*/
export interface ParsedContact {
/** Full display name of the contact */
name?: string;
/** Type of entity (individual, org, group, etc.) - JSContact only */
kind?: string;
/** List of organizations, with units joined by " > " */
organizations?: string[];
/** List of titles/roles */
titles?: Array<{
name: string;
kind?: "title" | "role";
}>;
/** Email addresses with optional metadata */
emails?: Array<{
address: string;
label?: string;
contexts?: string[];
}>;
/** Phone numbers with optional metadata */
phones?: Array<{
number: string;
features?: string[];
label?: string;
}>;
/** Physical addresses */
addresses?: Array<{
text: string;
details?: string[];
}>;
/** Online services (JSContact only) */
onlineServices?: Array<{
text: string;
uri?: string;
service?: string;
}>;
/** Web links/URLs */
links?: Array<{
uri: string;
label?: string;
}>;
}
/**
* Parses a JSContact (RFC 9553) card into the unified ParsedContact format.
* JSContact is the modern JSON-native alternative to vCard.
*/
export function parseJSContact(jscard: JSCard): ParsedContact {
const contact: ParsedContact = {};
// Extract display name
if (jscard.name?.full) {
contact.name = jscard.name.full;
}
// Extract kind (if not default "individual")
if (jscard.kind && jscard.kind !== "individual") {
contact.kind = jscard.kind;
}
// Extract organizations
if (jscard.organizations) {
contact.organizations = Object.values(jscard.organizations)
.map((org) => {
if (org.name && org.units) {
return `${org.name} > ${org.units.map((u) => u.name).join(" > ")}`;
}
return org.name || org.units?.map((u) => u.name).join(" > ") || "";
})
.filter(Boolean);
}
// Extract titles/roles
if (jscard.titles) {
contact.titles = Object.values(jscard.titles).map((title) => ({
name: title.name,
kind: title.kind,
}));
}
// Extract emails
if (jscard.emails) {
contact.emails = Object.values(jscard.emails).map((email) => ({
address: email.address,
label: email.label,
contexts: email.contexts ? Object.keys(email.contexts) : undefined,
}));
}
// Extract phones
if (jscard.phones) {
contact.phones = Object.values(jscard.phones).map((phone) => ({
number: phone.number,
features: phone.features ? Object.keys(phone.features) : undefined,
label: phone.label,
}));
}
// Extract addresses
if (jscard.addresses) {
contact.addresses = Object.values(jscard.addresses).map((addr) => {
const text = addr.full || addr.components?.map((c) => c.value).join(", ") || "";
const details = [addr.countryCode, addr.timeZone].filter(Boolean) as string[];
return {
text,
details: details.length > 0 ? details : undefined,
};
});
}
// Extract online services
if (jscard.onlineServices) {
contact.onlineServices = Object.values(jscard.onlineServices).map((service) => ({
text: service.service || service.user || service.uri || "",
uri: service.uri,
service: service.service,
}));
}
// Extract links
if (jscard.links) {
contact.links = Object.values(jscard.links).map((link) => ({
uri: link.uri,
label: link.label,
}));
}
return contact;
}
/**
* Parses a vCard (jCard format, RFC 7095) into the unified ParsedContact format.
* jCard is the JSON representation of vCard 4.0.
*/
export function parseVCard(vcardArray: VCardArray): ParsedContact {
// Type for vCard property: [name, params, type, value]
type VCardProperty = [string, Record<string, unknown>, string, unknown];
const [, properties] = vcardArray;
const contact: ParsedContact = {
emails: [],
phones: [],
addresses: [],
links: [],
titles: [],
};
properties.forEach((prop: unknown) => {
if (!Array.isArray(prop) || prop.length < 4) return;
const [name, , , value] = prop as VCardProperty;
const nameLower = name.toLowerCase();
switch (nameLower) {
case "fn": // Formatted name
if (typeof value === "string") {
contact.name = value;
}
break;
case "org": // Organization
if (Array.isArray(value)) {
contact.organizations = [value.join(" > ")];
} else if (typeof value === "string") {
contact.organizations = [value];
}
break;
case "email":
if (typeof value === "string" && contact.emails) {
contact.emails.push({ address: value });
}
break;
case "tel": // Telephone
if (typeof value === "string" && contact.phones) {
contact.phones.push({ number: value });
}
break;
case "adr": // Address [PO, extended, street, locality, region, postal, country]
if (Array.isArray(value) && contact.addresses) {
const addressParts = [
value[2], // street
value[3], // locality
value[4], // region
value[5], // postal
value[6], // country
].filter(Boolean);
if (addressParts.length > 0) {
contact.addresses.push({
text: addressParts.join(", "),
});
}
}
break;
case "url":
if (typeof value === "string" && contact.links) {
contact.links.push({ uri: value });
}
break;
case "title":
if (typeof value === "string" && contact.titles) {
contact.titles.push({ name: value, kind: "title" });
}
break;
case "role":
if (typeof value === "string" && contact.titles) {
contact.titles.push({ name: value, kind: "role" });
}
break;
}
});
// Clean up empty arrays
if (contact.emails?.length === 0) delete contact.emails;
if (contact.phones?.length === 0) delete contact.phones;
if (contact.addresses?.length === 0) delete contact.addresses;
if (contact.links?.length === 0) delete contact.links;
if (contact.titles?.length === 0) delete contact.titles;
return contact;
}