diff --git a/apps/web/src/features/resume/preview/resume-accessible-text.test.tsx b/apps/web/src/features/resume/preview/resume-accessible-text.test.tsx index 94437a3df..57a5ce66f 100644 --- a/apps/web/src/features/resume/preview/resume-accessible-text.test.tsx +++ b/apps/web/src/features/resume/preview/resume-accessible-text.test.tsx @@ -4,10 +4,19 @@ import { beforeAll, expect, it, vi } from "vitest"; import { i18n } from "@lingui/core"; import { I18nProvider } from "@lingui/react"; import { defaultResumeData } from "@reactive-resume/schema/resume/default"; +import { sampleResumeData } from "@reactive-resume/schema/resume/sample"; import { ResumeAccessibleText } from "./resume-accessible-text"; vi.mock("@/features/resume/builder/draft", () => ({ useResumeData: () => undefined })); beforeAll(() => i18n.loadAndActivate({ locale: "en", messages: {} })); + +function renderAccessibleText(data: typeof sampleResumeData) { + return render( + + + , + ); +} it.each([false, true])("exposes keyword list semantics for custom=%s", (custom) => { const data = structuredClone(defaultResumeData); Object.assign(data.sections.skills, { keywordLayout: "list" }); @@ -63,3 +72,149 @@ it("retains section labels when visual heading is disabled", () => { expect(screen.getByRole("heading", { level: 2, name: "Skills" })).toBeInTheDocument(); }); + +it("characterizes stable heading, hidden-content, contact, and reading-order behavior", () => { + const data = structuredClone(sampleResumeData); + data.sections.skills.hidden = true; + data.sections.experience.items[0].company = "Visible Company"; + data.sections.experience.items[0].position = "Visible Position"; + data.sections.experience.items[0].description = "

Visible description token.

"; + data.sections.projects.items = []; + data.customSections = [ + { + id: "custom-visible", + type: "projects", + title: "Unplaced Custom Section", + icon: "", + columns: 1, + hidden: false, + showHeading: false, + keepTogether: false, + startOnNewPage: false, + items: [ + { + id: "custom-visible-item", + hidden: false, + name: "Unplaced Item", + period: "", + website: { url: "", label: "", inlineLink: false }, + description: "

Unplaced description token.

", + }, + ], + }, + { + id: "custom-hidden-item", + type: "projects", + title: "Hidden Custom Section", + icon: "", + columns: 1, + hidden: false, + showHeading: true, + keepTogether: false, + startOnNewPage: false, + items: [ + { + id: "custom-hidden-item-value", + hidden: true, + name: "Hidden Custom Item", + period: "", + website: { url: "", label: "", inlineLink: false }, + description: "

Hidden custom description.

", + }, + ], + }, + ]; + + renderAccessibleText(data); + + expect(screen.getByRole("heading", { level: 1, name: data.basics.name })).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 2, name: "Experience" })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "davidkowalski.games" })).toHaveAttribute("href", data.basics.website.url); + expect(screen.queryByText("Performance Optimization")).not.toBeInTheDocument(); + expect(screen.queryByText("Hidden Custom Item")).not.toBeInTheDocument(); + expect(screen.getByText("Unplaced Item")).toBeInTheDocument(); +}); + +it("exposes entry and subordinate-role headings with safe nested rich-text lists", () => { + // Characterization before this change: item labels had no heading elements and rich-text lists were flattened into one paragraph. + const data = structuredClone(sampleResumeData); + data.sections.experience.items = [ + { + ...data.sections.experience.items[0], + id: "hierarchy-item", + company: "Acme Company", + position: "", + description: + '

Summary emphasis.

Unsafe link

', + roles: [ + { + id: "hierarchy-role", + position: "Lead Role", + period: "2020 - 2022", + description: "

Role detail.

", + }, + ], + }, + { + ...data.sections.experience.items[0], + id: "blank-primary-item", + company: "", + position: "", + description: "

Blank primary body.

", + roles: [], + }, + ]; + data.sections.projects.items = []; + + renderAccessibleText(data); + + expect(screen.getByRole("heading", { level: 3, name: "Acme Company" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 4, name: "Lead Role" })).toBeInTheDocument(); + const experience = screen.getAllByRole("heading", { level: 2, name: "Experience" })[0]?.parentElement; + expect(experience?.querySelectorAll("h3")).toHaveLength(1); + expect(screen.getByText("First bullet").tagName).toBe("LI"); + expect(screen.getByText("Nested bullet").tagName).toBe("LI"); + expect(screen.getByText("emphasis").tagName).toBe("STRONG"); + expect(screen.getByText("Second bullet").tagName).toBe("EM"); + expect(screen.queryByRole("link", { name: "Unsafe link" })).not.toBeInTheDocument(); + expect(screen.queryByText("alert(1)")).not.toBeInTheDocument(); + expect(screen.getByText("Acme Company")).toHaveTextContent("Acme Company"); +}); + +it.each([" ", "", "", ""])( + "omits summary section when content has no renderable text: %s", + (content) => { + const data = structuredClone(sampleResumeData); + data.summary.content = content; + + renderAccessibleText(data); + + const summaryTitle = data.summary.title?.trim() || "Summary"; + expect(screen.queryByRole("heading", { level: 2, name: summaryTitle })).not.toBeInTheDocument(); + }, +); + +it("promotes role heading to H3 when experience company is blank", () => { + const data = structuredClone(sampleResumeData); + data.sections.experience.items = [ + { + ...data.sections.experience.items[0], + id: "blank-company-item", + company: "", + position: "", + roles: [ + { + id: "blank-company-role", + position: "Role Without Company", + period: "2020 - 2022", + description: "

Role detail.

", + }, + ], + }, + ]; + + renderAccessibleText(data); + + expect(screen.getByRole("heading", { level: 3, name: "Role Without Company" })).toBeInTheDocument(); + expect(screen.queryByRole("heading", { level: 4, name: "Role Without Company" })).not.toBeInTheDocument(); +}); diff --git a/apps/web/src/features/resume/preview/resume-accessible-text.tsx b/apps/web/src/features/resume/preview/resume-accessible-text.tsx index 03f1549a1..627d82caa 100644 --- a/apps/web/src/features/resume/preview/resume-accessible-text.tsx +++ b/apps/web/src/features/resume/preview/resume-accessible-text.tsx @@ -20,6 +20,7 @@ import type { } from "@reactive-resume/schema/resume/data"; import type { ReactNode } from "react"; import { t } from "@lingui/core/macro"; +import { Fragment } from "react"; import { stripHtml } from "@reactive-resume/utils/string"; import { useResumeData } from "@/features/resume/builder/draft"; import { getSectionTitle } from "@/libs/resume/section"; @@ -44,23 +45,127 @@ const SECTION_ORDER: SectionType[] = [ const joinInline = (...parts: (string | undefined | null | false)[]): string => parts.filter((part): part is string => typeof part === "string" && part.trim().length > 0).join(" ยท "); +const isSafeHref = (value: string | undefined): string | undefined => { + const href = value?.trim(); + if (!href || /^(?:javascript|data|vbscript):/i.test(href)) return undefined; + if (/^(?:https?:|mailto:|tel:|\/|#|\?)/i.test(href)) return href; + return undefined; +}; + +const RICH_TEXT_OMIT_TAGS = new Set([ + "base", + "button", + "embed", + "form", + "iframe", + "input", + "link", + "meta", + "object", + "script", + "select", + "style", + "template", + "textarea", +]); + +function renderRichTextNode(node: ChildNode, key: string): ReactNode { + if (node.nodeType === 3) return node.textContent; + if (node.nodeType !== 1) return null; + + const element = node as Element; + const tagName = element.tagName.toLowerCase(); + if (RICH_TEXT_OMIT_TAGS.has(tagName)) return null; + + const children = Array.from(element.childNodes).map((child, index) => renderRichTextNode(child, `${key}-${index}`)); + + switch (tagName) { + case "a": { + const href = isSafeHref(element.getAttribute("href") ?? undefined); + return href ? ( + + {children} + + ) : ( + {children} + ); + } + case "br": + return
; + case "em": + case "i": + return {children}; + case "strong": + case "b": + return {children}; + case "del": + case "s": + return {children}; + case "mark": + return {children}; + case "code": + return {children}; + case "blockquote": + return
{children}
; + case "ol": + return
    {children}
; + case "ul": + return ; + case "li": + return
  • {children}
  • ; + case "p": + return

    {children}

    ; + default: + return {children}; + } +} + +function RichText({ html }: { html: string }) { + if (!html.trim()) return null; + if (typeof DOMParser === "undefined") return stripHtml(html); + + const body = new DOMParser().parseFromString(html, "text/html").body; + return Array.from(body.childNodes).map((node, index) => renderRichTextNode(node, `rich-text-${index}`)); +} + +function hasRenderableRichText(html: string): boolean { + if (!html.trim()) return false; + if (typeof DOMParser === "undefined") return stripHtml(html).trim().length > 0; + + const body = new DOMParser().parseFromString(html, "text/html").body; + const getText = (node: ChildNode): string => { + if (node.nodeType === 3) return node.textContent ?? ""; + if (node.nodeType !== 1) return ""; + + const element = node as Element; + if (RICH_TEXT_OMIT_TAGS.has(element.tagName.toLowerCase())) return ""; + return Array.from(element.childNodes).map(getText).join(""); + }; + + return Array.from(body.childNodes).some((node) => getText(node).trim().length > 0); +} + type ItemWebsite = { url?: string; label?: string }; type ItemBodyProps = { + heading?: string; + headingLevel?: 3 | 4; primary?: string; details?: string; description?: string; website?: ItemWebsite; }; -function ItemBody({ primary, details, description, website }: ItemBodyProps) { +function ItemBody({ heading, headingLevel = 3, primary, details, description, website }: ItemBodyProps) { const header = joinInline(primary, details); - const websiteUrl = website?.url?.trim(); + const headingText = heading?.trim(); + const websiteUrl = isSafeHref(website?.url); return ( <> + {headingText ? headingLevel === 4 ?

    {headingText}

    :

    {headingText}

    : null} {header ?

    {header}

    : null} - {description ?

    {description}

    : null} + {description ? : null} {websiteUrl ? {website?.label?.trim() || websiteUrl} : null} ); @@ -75,9 +180,9 @@ function renderItem(type: CustomSectionType, item: CustomSectionItem, keywordLay return ( <> 0 ? it.company : joinInline(it.position, it.company)} details={joinInline(it.location, it.period)} - description={stripHtml(it.description)} + description={it.description} website={it.website} /> {roles.length > 0 ? ( @@ -85,8 +190,10 @@ function renderItem(type: CustomSectionType, item: CustomSectionItem, keywordLay {roles.map((role) => (
  • ))} @@ -100,9 +207,9 @@ function renderItem(type: CustomSectionType, item: CustomSectionItem, keywordLay return ( ); @@ -113,7 +220,7 @@ function renderItem(type: CustomSectionType, item: CustomSectionItem, keywordLay if (keywordLayout === "list") { return ( <> - + {it.keywords.length > 0 && (
      {it.keywords.map((keyword, index) => ( @@ -124,38 +231,36 @@ function renderItem(type: CustomSectionType, item: CustomSectionItem, keywordLay ); } - return ; + return ; } case "interests": { const it = item as InterestItem; - return ; + return ; } case "languages": { const it = item as LanguageItem; - return ; + return ; } case "profiles": { const it = item as ProfileItem; - return ; + return ; } case "projects": { const it = item as ProjectItem; - return ( - - ); + return ; } case "awards": { const it = item as AwardItem; return ( ); @@ -165,9 +270,9 @@ function renderItem(type: CustomSectionType, item: CustomSectionItem, keywordLay return ( ); @@ -177,9 +282,9 @@ function renderItem(type: CustomSectionType, item: CustomSectionItem, keywordLay return ( ); @@ -189,9 +294,9 @@ function renderItem(type: CustomSectionType, item: CustomSectionItem, keywordLay return ( ); @@ -201,9 +306,9 @@ function renderItem(type: CustomSectionType, item: CustomSectionItem, keywordLay return ( ); @@ -216,7 +321,7 @@ function renderItem(type: CustomSectionType, item: CustomSectionItem, keywordLay case "summary": { const it = item as SummaryItem; - return ; + return ; } default: return null; @@ -267,17 +372,19 @@ export function ResumeAccessibleText({ data }: ResumeAccessibleTextProps) { if (!resumeData) return null; const { basics, summary, sections, customSections } = resumeData; - const summaryText = summary && !summary.hidden ? stripHtml(summary.content) : ""; + const summaryText = summary && !summary.hidden ? summary.content : ""; const website = basics.website; const contact: ReactNode[] = []; - if (basics.email) contact.push({basics.email}); - if (basics.phone) contact.push({basics.phone}); + if (basics.email) contact.push({basics.email}); + if (basics.phone) contact.push({basics.phone}); if (basics.location) contact.push(basics.location); - if (website?.url?.trim()) contact.push({website.label?.trim() || website.url}); + const websiteUrl = isSafeHref(website?.url); + if (websiteUrl) contact.push({website.label?.trim() || websiteUrl}); for (const field of basics.customFields ?? []) { if (!field.text?.trim()) continue; - contact.push(field.link?.trim() ? {field.text} : field.text); + const fieldLink = isSafeHref(field.link); + contact.push(fieldLink ? {field.text} : field.text); } return ( @@ -294,10 +401,10 @@ export function ResumeAccessibleText({ data }: ResumeAccessibleTextProps) { ) : null} - {summaryText ? ( + {hasRenderableRichText(summaryText) ? (

      {summary.title?.trim() || getSectionTitle("summary")}

      -

      {summaryText}

      +
      ) : null}