renders the demo profile in Modern 1`] = `
class="_contactList_fa1ef7"
>
@@ -3758,17 +4010,31 @@ exports[`ResumeDocument > renders the demo profile in Signal 1`] = `
renders the demo profile in Studio 1`] = `
class="_contactList_fa1ef7"
>
-
- jane.doe@example.com
-
+
+ jane.doe@example.com
+
+
-
- +31 6 1234 5678
-
+
+ +31 6 1234 5678
+
+
renders the demo profile in Studio 1`] = `
-
- example.com
-
+
+ example.com
+
+
-
- linkedin.com/in/janedoe-example
-
+
+ linkedin.com/in/janedoe-example
+
+
@@ -4609,18 +4917,32 @@ exports[`ResumeDocument > renders the demo profile in Technical 1`] = `
class="_contactList_fa1ef7"
>
-
- jane.doe@example.com
-
+
+ jane.doe@example.com
+
+
-
- +31 6 1234 5678
-
+
+ +31 6 1234 5678
+
+
renders the demo profile in Technical 1`] = `
-
- example.com
-
+
+ example.com
+
+
-
- linkedin.com/in/janedoe-example
-
+
+ linkedin.com/in/janedoe-example
+
+
diff --git a/src/components/documents/document-page.tsx b/src/components/documents/document-page.tsx
index 569f4840..4c13267d 100644
--- a/src/components/documents/document-page.tsx
+++ b/src/components/documents/document-page.tsx
@@ -3,7 +3,7 @@ import { Fragment, type CSSProperties, type ReactNode } from "react";
import type { PaperSize } from "@/generated/prisma/enums";
import { cn } from "@/lib/cn";
import { hasRightToLeft, type TextDirection } from "@/lib/document-language";
-import { CONTACT_SEPARATOR } from "@/lib/resume-content";
+import { CONTACT_SEPARATOR, type ContactField } from "@/lib/resume-content";
import { PAPER_SIZES, SIDEBAR, type ResolvedTheme } from "@/lib/themes";
import styles from "./document.module.css";
@@ -120,21 +120,33 @@ export function DocumentSection({ title, children }: { title: string; children:
/**
* A contact detail, kept left to right when it has no right-to-left letters
* (email, phone, web address), so it reads right inside a Persian document.
+ * Linked when `href` is given (this fork: website, LinkedIn, email and
+ * phone), styled the same muted, underline-free way as a publication's link.
*/
-export function Contact({ children }: { children: string }) {
- return
{children};
+export function Contact({ children, href }: { children: string; href?: string | null }) {
+ const text =
{children};
+ return href ? (
+
+ {text}
+
+ ) : (
+ text
+ );
}
-/** Contact details on one line, separated by " · ". */
-export function ContactLine({ items }: { items: string[] }) {
+/** Contact details on one line, separated by " · "; a plain string has no link, a ContactField may. */
+export function ContactLine({ items }: { items: (string | ContactField)[] }) {
return (
- {items.map((item, index) => (
-
- {index > 0 && CONTACT_SEPARATOR}
- {item}
-
- ))}
+ {items.map((item, index) => {
+ const field = typeof item === "string" ? { text: item, href: null } : item;
+ return (
+
+ {index > 0 && CONTACT_SEPARATOR}
+ {field.text}
+
+ );
+ })}
);
}
diff --git a/src/components/documents/resume-document.test.tsx b/src/components/documents/resume-document.test.tsx
index 79195a1e..b54c5ca0 100644
--- a/src/components/documents/resume-document.test.tsx
+++ b/src/components/documents/resume-document.test.tsx
@@ -22,7 +22,8 @@ describe("ResumeDocument", () => {
it("puts contact details in the header of single-column layouts", () => {
render(
);
- expect(screen.getByText("jane.doe@example.com").parentElement?.textContent).toBe(
+ // Email, website and LinkedIn are links now (this fork), so the contact line is the text's closest
, not always its direct parent.
+ expect(screen.getByText("jane.doe@example.com").closest("p")?.textContent).toBe(
"jane.doe@example.com · +31 6 1234 5678 · Rotterdam, Netherlands · example.com · linkedin.com/in/janedoe-example",
);
expect(sectionTitles()).toEqual(["Profile", "Experience", "Projects", "Education", "Publications", "Skills", "Languages"]);
diff --git a/src/components/documents/resume-document.tsx b/src/components/documents/resume-document.tsx
index 91a8dc08..397ed84d 100644
--- a/src/components/documents/resume-document.tsx
+++ b/src/components/documents/resume-document.tsx
@@ -58,7 +58,7 @@ export function ResumeDocument({ theme, profile, paper, layout, language, render
{content.contacts.map((item, index) => (
-
- {item}
+ {item.text}
))}
diff --git a/src/lib/resume-content.test.ts b/src/lib/resume-content.test.ts
index 347e1541..b892f181 100644
--- a/src/lib/resume-content.test.ts
+++ b/src/lib/resume-content.test.ts
@@ -37,6 +37,17 @@ describe("contactItems", () => {
describe("resumeContent", () => {
const profile = demoProfile();
+ it("links the website, LinkedIn, email and phone (this fork); the rest has no link", () => {
+ const content = resumeContent(profile, { today });
+ expect(content.contacts).toEqual([
+ { text: "jane.doe@example.com", href: "mailto:jane.doe@example.com" },
+ { text: "+31 6 1234 5678", href: "tel:+31612345678" },
+ { text: "Rotterdam, Netherlands", href: null },
+ { text: "example.com", href: "https://example.com/" },
+ { text: "linkedin.com/in/janedoe-example", href: "https://linkedin.com/in/janedoe-example" },
+ ]);
+ });
+
it("builds the base resume from the profile", () => {
const content = resumeContent(profile, { today });
expect(content.name).toBe("Jane Doe");
diff --git a/src/lib/resume-content.ts b/src/lib/resume-content.ts
index b0c045b5..7ac3ad5a 100644
--- a/src/lib/resume-content.ts
+++ b/src/lib/resume-content.ts
@@ -11,8 +11,12 @@ export type ResumeContent = {
headline: string;
/** The profile photo's data URL, or null when the resume has none (#224). */
photo: string | null;
- /** Email, phone, "City, Country", website, LinkedIn and "Age N", whichever are filled in. */
- contacts: string[];
+ /**
+ * Email, phone, "City, Country", website, LinkedIn and "Age N", whichever
+ * are filled in. Website and LinkedIn link out (this fork); email and
+ * phone open the device's own app; the rest has no link.
+ */
+ contacts: ContactField[];
summary: string;
experience: {
id: string;
@@ -65,19 +69,34 @@ export type ResumeOptions = {
/** The separator between contact details on one line. */
export const CONTACT_SEPARATOR = " · ";
-/** Contact details in the order the resume header lists them. */
-export function contactItems(basics: Basics, today = new Date(), labels = ENGLISH_LABELS): string[] {
+/** One contact detail, with where it links to when it's the kind that does (this fork). */
+export type ContactField = { text: string; href: string | null };
+
+/** A telephone number as a "tel:" link opens it: digits and a leading "+" only. */
+const telHref = (phone: string) => `tel:${phone.replace(/[^\d+]/g, "")}`;
+
+/** Contact details in the order the resume header lists them, with a link where one makes sense. */
+function contactFields(basics: Basics, today: Date, labels: DocumentLabels): ContactField[] {
const age = basics.showAge ? ageOn(basics.dob, today) : null;
- return [
- basics.email,
- basics.phone,
- [basics.city, basics.country].map((part) => part.trim()).filter(Boolean).join(", "),
- basics.website,
- basics.linkedin,
- age === null ? "" : ageLabel(age, labels),
- ]
- .map((item) => item.trim())
- .filter(Boolean);
+ const email = basics.email.trim();
+ const phone = basics.phone.trim();
+ const website = basics.website.trim();
+ const linkedin = basics.linkedin.trim();
+ return (
+ [
+ { text: email, href: email ? `mailto:${email}` : null },
+ { text: phone, href: phone ? telHref(phone) : null },
+ { text: [basics.city, basics.country].map((part) => part.trim()).filter(Boolean).join(", "), href: null },
+ { text: website, href: website ? publicationHref(website) : null },
+ { text: linkedin, href: linkedin ? publicationHref(linkedin) : null },
+ { text: age === null ? "" : ageLabel(age, labels), href: null },
+ ] satisfies ContactField[]
+ ).filter((item) => item.text);
+}
+
+/** Contact details as plain text, for the cover letter's own sender block, which is free text the user can edit. */
+export function contactItems(basics: Basics, today = new Date(), labels = ENGLISH_LABELS): string[] {
+ return contactFields(basics, today, labels).map((item) => item.text);
}
/** A link as a resume shows it: "https://www.example.com/x/" is "example.com/x". */
@@ -138,7 +157,7 @@ export function resumeContent(
name: basics.name.trim() || "Your name",
headline: basics.headline.trim(),
photo: profile.photo && (showPhoto ?? profile.photo.onResumes) ? profile.photo.src : null,
- contacts: contactItems(basics, today, labels),
+ contacts: contactFields(basics, today, labels),
summary: basics.summary.trim(),
experience: profile.experience.map((entry) => experience(entry, entry.bullets)),
projects: profile.projects.map((entry) => project(entry, entry.bullets)),