diff --git a/src/components/documents/__snapshots__/resume-document.test.tsx.snap b/src/components/documents/__snapshots__/resume-document.test.tsx.snap index b859abf2..f6233808 100644 --- a/src/components/documents/__snapshots__/resume-document.test.tsx.snap +++ b/src/components/documents/__snapshots__/resume-document.test.tsx.snap @@ -23,17 +23,31 @@ exports[`ResumeDocument > renders the demo profile in Atlas 1`] = `

- - jane.doe@example.com - + + jane.doe@example.com + + · - - +31 6 1234 5678 - + + +31 6 1234 5678 + + · renders the demo profile in Atlas 1`] = ` Rotterdam, Netherlands · - - example.com - + + example.com + + · - - linkedin.com/in/janedoe-example - + + linkedin.com/in/janedoe-example + +

renders the demo profile in Chronicle 1`] = `

- - jane.doe@example.com - + + jane.doe@example.com + + · - - +31 6 1234 5678 - + + +31 6 1234 5678 + + · renders the demo profile in Chronicle 1`] = ` Rotterdam, Netherlands · - - example.com - + + example.com + + · - - linkedin.com/in/janedoe-example - + + linkedin.com/in/janedoe-example + +

renders the demo profile in Classic 1`] = `

- - jane.doe@example.com - + + jane.doe@example.com + + · - - +31 6 1234 5678 - + + +31 6 1234 5678 + + · renders the demo profile in Classic 1`] = ` Rotterdam, Netherlands · - - example.com - + + example.com + + · - - linkedin.com/in/janedoe-example - + + linkedin.com/in/janedoe-example + +

renders the demo profile in Console 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 Console 1`] = `
  • - - example.com - + + example.com + +
  • - - linkedin.com/in/janedoe-example - + + linkedin.com/in/janedoe-example + +
  • @@ -1678,17 +1790,31 @@ exports[`ResumeDocument > renders the demo profile in Editorial 1`] = `

    - - jane.doe@example.com - + + jane.doe@example.com + + · - - +31 6 1234 5678 - + + +31 6 1234 5678 + + · renders the demo profile in Editorial 1`] = ` Rotterdam, Netherlands · - - example.com - + + example.com + + · - - linkedin.com/in/janedoe-example - + + linkedin.com/in/janedoe-example + +

    renders the demo profile in Folio 1`] = `

    - - jane.doe@example.com - + + jane.doe@example.com + + · - - +31 6 1234 5678 - + + +31 6 1234 5678 + + · renders the demo profile in Folio 1`] = ` Rotterdam, Netherlands · - - example.com - + + example.com + + · - - linkedin.com/in/janedoe-example - + + linkedin.com/in/janedoe-example + +

    renders the demo profile in Ledger 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 Ledger 1`] = `
  • - - example.com - + + example.com + +
  • - - linkedin.com/in/janedoe-example - + + linkedin.com/in/janedoe-example + +
  • @@ -2923,17 +3119,31 @@ exports[`ResumeDocument > renders the demo profile in Minimal 1`] = `

    - - jane.doe@example.com - + + jane.doe@example.com + + · - - +31 6 1234 5678 - + + +31 6 1234 5678 + + · renders the demo profile in Minimal 1`] = ` Rotterdam, Netherlands · - - example.com - + + example.com + + · - - linkedin.com/in/janedoe-example - + + linkedin.com/in/janedoe-example + +

    renders the demo profile in Modern 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 Modern 1`] = `
  • - - example.com - + + example.com + +
  • - - linkedin.com/in/janedoe-example - + + linkedin.com/in/janedoe-example + +
  • @@ -3758,17 +4010,31 @@ exports[`ResumeDocument > renders the demo profile in Signal 1`] = `

    - - jane.doe@example.com - + + jane.doe@example.com + + · - - +31 6 1234 5678 - + + +31 6 1234 5678 + + · renders the demo profile in Signal 1`] = ` Rotterdam, Netherlands · - - example.com - + + example.com + + · - - linkedin.com/in/janedoe-example - + + linkedin.com/in/janedoe-example + +

    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)),