mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-02 17:54:22 +10:00
Merge remote-tracking branch 'origin/main' into codex/issue-2683-preview-export-geometry
This commit is contained in:
@@ -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(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<ResumeAccessibleText data={data} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
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 = "<p>Visible description token.</p>";
|
||||
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: "<p>Unplaced description token.</p>",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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: "<p>Hidden custom description.</p>",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
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:
|
||||
'<p>Summary <strong>emphasis</strong>.</p><ul><li>First bullet</li><li><em>Second bullet</em><ol><li>Nested bullet</li></ol></li></ul><p><a href="javascript:alert(1)">Unsafe link</a></p><script>alert(1)</script>',
|
||||
roles: [
|
||||
{
|
||||
id: "hierarchy-role",
|
||||
position: "Lead Role",
|
||||
period: "2020 - 2022",
|
||||
description: "<p>Role <strong>detail</strong>.</p>",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...data.sections.experience.items[0],
|
||||
id: "blank-primary-item",
|
||||
company: "",
|
||||
position: "",
|
||||
description: "<p>Blank primary body.</p>",
|
||||
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([" ", "<script>alert(1)</script>", "<style>.hidden { display: none; }</style>", "<iframe>ignored</iframe>"])(
|
||||
"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: "<p>Role detail.</p>",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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 ? (
|
||||
<a key={key} href={href}>
|
||||
{children}
|
||||
</a>
|
||||
) : (
|
||||
<Fragment key={key}>{children}</Fragment>
|
||||
);
|
||||
}
|
||||
case "br":
|
||||
return <br key={key} />;
|
||||
case "em":
|
||||
case "i":
|
||||
return <em key={key}>{children}</em>;
|
||||
case "strong":
|
||||
case "b":
|
||||
return <strong key={key}>{children}</strong>;
|
||||
case "del":
|
||||
case "s":
|
||||
return <del key={key}>{children}</del>;
|
||||
case "mark":
|
||||
return <mark key={key}>{children}</mark>;
|
||||
case "code":
|
||||
return <code key={key}>{children}</code>;
|
||||
case "blockquote":
|
||||
return <blockquote key={key}>{children}</blockquote>;
|
||||
case "ol":
|
||||
return <ol key={key}>{children}</ol>;
|
||||
case "ul":
|
||||
return <ul key={key}>{children}</ul>;
|
||||
case "li":
|
||||
return <li key={key}>{children}</li>;
|
||||
case "p":
|
||||
return <p key={key}>{children}</p>;
|
||||
default:
|
||||
return <Fragment key={key}>{children}</Fragment>;
|
||||
}
|
||||
}
|
||||
|
||||
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 ? <h4>{headingText}</h4> : <h3>{headingText}</h3> : null}
|
||||
{header ? <p>{header}</p> : null}
|
||||
{description ? <p>{description}</p> : null}
|
||||
{description ? <RichText html={description} /> : null}
|
||||
{websiteUrl ? <a href={websiteUrl}>{website?.label?.trim() || websiteUrl}</a> : null}
|
||||
</>
|
||||
);
|
||||
@@ -75,9 +180,9 @@ function renderItem(type: CustomSectionType, item: CustomSectionItem, keywordLay
|
||||
return (
|
||||
<>
|
||||
<ItemBody
|
||||
primary={joinInline(it.position, it.company)}
|
||||
heading={roles.length > 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) => (
|
||||
<li key={role.id}>
|
||||
<ItemBody
|
||||
primary={joinInline(role.position, role.period)}
|
||||
description={stripHtml(role.description)}
|
||||
heading={role.position}
|
||||
headingLevel={it.company.trim() ? 4 : 3}
|
||||
details={role.period}
|
||||
description={role.description}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
@@ -100,9 +207,9 @@ function renderItem(type: CustomSectionType, item: CustomSectionItem, keywordLay
|
||||
|
||||
return (
|
||||
<ItemBody
|
||||
primary={it.school}
|
||||
heading={it.school}
|
||||
details={joinInline(it.degree, it.area, it.grade, it.location, it.period)}
|
||||
description={stripHtml(it.description)}
|
||||
description={it.description}
|
||||
website={it.website}
|
||||
/>
|
||||
);
|
||||
@@ -113,7 +220,7 @@ function renderItem(type: CustomSectionType, item: CustomSectionItem, keywordLay
|
||||
if (keywordLayout === "list") {
|
||||
return (
|
||||
<>
|
||||
<ItemBody primary={it.name} details={it.proficiency} />
|
||||
<ItemBody heading={it.name} details={it.proficiency} />
|
||||
{it.keywords.length > 0 && (
|
||||
<ul>
|
||||
{it.keywords.map((keyword, index) => (
|
||||
@@ -124,38 +231,36 @@ function renderItem(type: CustomSectionType, item: CustomSectionItem, keywordLay
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <ItemBody primary={it.name} details={joinInline(it.proficiency, (it.keywords ?? []).join(", "))} />;
|
||||
return <ItemBody heading={it.name} details={joinInline(it.proficiency, (it.keywords ?? []).join(", "))} />;
|
||||
}
|
||||
case "interests": {
|
||||
const it = item as InterestItem;
|
||||
|
||||
return <ItemBody primary={it.name} details={(it.keywords ?? []).join(", ")} />;
|
||||
return <ItemBody heading={it.name} details={(it.keywords ?? []).join(", ")} />;
|
||||
}
|
||||
case "languages": {
|
||||
const it = item as LanguageItem;
|
||||
|
||||
return <ItemBody primary={it.language} details={it.fluency} />;
|
||||
return <ItemBody heading={it.language} details={it.fluency} />;
|
||||
}
|
||||
case "profiles": {
|
||||
const it = item as ProfileItem;
|
||||
|
||||
return <ItemBody primary={joinInline(it.network, it.username)} website={it.website} />;
|
||||
return <ItemBody heading={joinInline(it.network, it.username)} website={it.website} />;
|
||||
}
|
||||
case "projects": {
|
||||
const it = item as ProjectItem;
|
||||
|
||||
return (
|
||||
<ItemBody primary={it.name} details={it.period} description={stripHtml(it.description)} website={it.website} />
|
||||
);
|
||||
return <ItemBody heading={it.name} details={it.period} description={it.description} website={it.website} />;
|
||||
}
|
||||
case "awards": {
|
||||
const it = item as AwardItem;
|
||||
|
||||
return (
|
||||
<ItemBody
|
||||
primary={it.title}
|
||||
heading={it.title}
|
||||
details={joinInline(it.awarder, it.date)}
|
||||
description={stripHtml(it.description)}
|
||||
description={it.description}
|
||||
website={it.website}
|
||||
/>
|
||||
);
|
||||
@@ -165,9 +270,9 @@ function renderItem(type: CustomSectionType, item: CustomSectionItem, keywordLay
|
||||
|
||||
return (
|
||||
<ItemBody
|
||||
primary={it.title}
|
||||
heading={it.title}
|
||||
details={joinInline(it.issuer, it.date)}
|
||||
description={stripHtml(it.description)}
|
||||
description={it.description}
|
||||
website={it.website}
|
||||
/>
|
||||
);
|
||||
@@ -177,9 +282,9 @@ function renderItem(type: CustomSectionType, item: CustomSectionItem, keywordLay
|
||||
|
||||
return (
|
||||
<ItemBody
|
||||
primary={it.title}
|
||||
heading={it.title}
|
||||
details={joinInline(it.publisher, it.date)}
|
||||
description={stripHtml(it.description)}
|
||||
description={it.description}
|
||||
website={it.website}
|
||||
/>
|
||||
);
|
||||
@@ -189,9 +294,9 @@ function renderItem(type: CustomSectionType, item: CustomSectionItem, keywordLay
|
||||
|
||||
return (
|
||||
<ItemBody
|
||||
primary={it.organization}
|
||||
heading={it.organization}
|
||||
details={joinInline(it.location, it.period)}
|
||||
description={stripHtml(it.description)}
|
||||
description={it.description}
|
||||
website={it.website}
|
||||
/>
|
||||
);
|
||||
@@ -201,9 +306,9 @@ function renderItem(type: CustomSectionType, item: CustomSectionItem, keywordLay
|
||||
|
||||
return (
|
||||
<ItemBody
|
||||
primary={it.name}
|
||||
heading={it.name}
|
||||
details={joinInline(it.position, it.phone)}
|
||||
description={stripHtml(it.description)}
|
||||
description={it.description}
|
||||
website={it.website}
|
||||
/>
|
||||
);
|
||||
@@ -216,7 +321,7 @@ function renderItem(type: CustomSectionType, item: CustomSectionItem, keywordLay
|
||||
case "summary": {
|
||||
const it = item as SummaryItem;
|
||||
|
||||
return <ItemBody description={stripHtml(it.content)} />;
|
||||
return <ItemBody description={it.content} />;
|
||||
}
|
||||
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(<a href={`mailto:${basics.email}`}>{basics.email}</a>);
|
||||
if (basics.phone) contact.push(<a href={`tel:${basics.phone}`}>{basics.phone}</a>);
|
||||
if (basics.email) contact.push(<a href={isSafeHref(`mailto:${basics.email}`)}>{basics.email}</a>);
|
||||
if (basics.phone) contact.push(<a href={isSafeHref(`tel:${basics.phone}`)}>{basics.phone}</a>);
|
||||
if (basics.location) contact.push(basics.location);
|
||||
if (website?.url?.trim()) contact.push(<a href={website.url}>{website.label?.trim() || website.url}</a>);
|
||||
const websiteUrl = isSafeHref(website?.url);
|
||||
if (websiteUrl) contact.push(<a href={websiteUrl}>{website.label?.trim() || websiteUrl}</a>);
|
||||
for (const field of basics.customFields ?? []) {
|
||||
if (!field.text?.trim()) continue;
|
||||
contact.push(field.link?.trim() ? <a href={field.link}>{field.text}</a> : field.text);
|
||||
const fieldLink = isSafeHref(field.link);
|
||||
contact.push(fieldLink ? <a href={fieldLink}>{field.text}</a> : field.text);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -294,10 +401,10 @@ export function ResumeAccessibleText({ data }: ResumeAccessibleTextProps) {
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{summaryText ? (
|
||||
{hasRenderableRichText(summaryText) ? (
|
||||
<section>
|
||||
<h2>{summary.title?.trim() || getSectionTitle("summary")}</h2>
|
||||
<p>{summaryText}</p>
|
||||
<RichText html={summaryText} />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user