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(
+
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.
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 "blockquote":
+ return {children}; + case "ol": + return
{children}
; + default: + return{header}
: null} - {description ?{description}
: null} + {description ?{summaryText}
+