Merge remote-tracking branch 'origin/main' into codex/issue-2683-preview-export-geometry

This commit is contained in:
Amruth Pillai
2026-09-06 06:33:08 +02:00
2 changed files with 297 additions and 35 deletions
@@ -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}