fix(import): escape plain-text fields from json resume

Summaries, highlights, courses, and award, publication and reference texts went into the HTML as written, so text such as "C<T>" was lost. They now go through the same escapeHtml helper as the LinkedIn and plain-text importers.
This commit is contained in:
Amruth Pillai
2026-09-29 22:10:14 +02:00
parent 54a9bfc307
commit acdb9d88d3
4 changed files with 29 additions and 14 deletions
+4 -5
View File
@@ -31,9 +31,8 @@ describe("toHtmlDescription", () => {
expect(toHtmlDescription("", ["a"])).toBe("<ul><li>a</li></ul>");
});
it("does not escape HTML in inputs (caller's responsibility)", () => {
// Document existing behavior: caller must sanitize before passing
expect(toHtmlDescription("<script>")).toBe("<p><script></p>");
it("escapes HTML in inputs", () => {
expect(toHtmlDescription("<script>", ["C<T>"])).toBe("<p>&lt;script&gt;</p><ul><li>C&lt;T&gt;</li></ul>");
});
});
@@ -56,7 +55,7 @@ describe("arrayToHtmlList", () => {
expect(result.indexOf("a")).toBeLessThan(result.indexOf("b"));
});
it("does not escape HTML in items", () => {
expect(arrayToHtmlList(["<b>bold</b>"])).toBe("<ul><li><b>bold</b></li></ul>");
it("escapes HTML in items", () => {
expect(arrayToHtmlList(["<b>bold</b>"])).toBe("<ul><li>&lt;b&gt;bold&lt;/b&gt;</li></ul>");
});
});
+5 -5
View File
@@ -1,20 +1,20 @@
import { escapeHtml } from "@reactive-resume/utils/string";
/**
* Converts a summary string and optional highlights array into an HTML description.
* Converts a plain-text summary and optional highlights array into an escaped HTML description.
* Summary becomes a <p> tag, highlights become a <ul> list.
*/
export function toHtmlDescription(summary?: string, highlights?: string[]): string {
const parts: string[] = [];
if (summary) {
parts.push(`<p>${summary}</p>`);
parts.push(`<p>${escapeHtml(summary)}</p>`);
}
if (highlights && highlights.length > 0) {
parts.push("<ul>");
for (const highlight of highlights) {
parts.push(`<li>${highlight}</li>`);
parts.push(`<li>${escapeHtml(highlight)}</li>`);
}
parts.push("</ul>");
@@ -24,11 +24,11 @@ export function toHtmlDescription(summary?: string, highlights?: string[]): stri
}
/**
* Converts an array of strings into an HTML unordered list.
* Converts an array of plain-text strings into an escaped HTML unordered list.
*/
export function arrayToHtmlList(items: string[]): string {
if (items.length === 0) return "";
return `<ul>${items.map((item) => `<li>${item}</li>`).join("")}</ul>`;
return `<ul>${items.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul>`;
}
export const BULLET_PATTERN = /^\s*[-–—•*◦‣·]\s+/;
+16
View File
@@ -196,4 +196,20 @@ describe("parseJSONResume", () => {
expect(next.sections.experience.items).toHaveLength(0);
expect(defaultResumeData.sections.experience.items).toHaveLength(0);
});
it("escapes plain-text fields so markup-like text survives as text", () => {
const result = parseJSONResume(
JSON.stringify({
basics: { summary: "Generics like C<T> & more" },
work: [{ name: "Acme", summary: "Used List<T>", highlights: ["Wrote <b> tags"] }],
awards: [{ title: "Prize", summary: "Best <T>" }],
}),
);
expect(result.summary.content).toBe("<p>Generics like C&lt;T&gt; &amp; more</p>");
expect(result.sections.experience.items[0]!.description).toBe(
"<p>Used List&lt;T&gt;</p><ul><li>Wrote &lt;b&gt; tags</li></ul>",
);
expect(result.sections.awards.items[0]!.description).toBe("<p>Best &lt;T&gt;</p>");
});
});
+4 -4
View File
@@ -203,7 +203,7 @@ function convertJSONResume(jsonResume: JSONResume): ResumeData {
if (jsonResume.basics?.summary) {
result.summary = {
...defaultResumeData.summary,
content: `<p>${jsonResume.basics.summary}</p>`,
content: toHtmlDescription(jsonResume.basics.summary),
hidden: false,
};
}
@@ -335,7 +335,7 @@ function convertJSONResume(jsonResume: JSONResume): ResumeData {
date: "",
dates: toSingleDates(award.date),
website: createItemWebsite(),
description: award.summary ? `<p>${award.summary}</p>` : "",
description: toHtmlDescription(award.summary),
})),
};
}
@@ -373,7 +373,7 @@ function convertJSONResume(jsonResume: JSONResume): ResumeData {
date: "",
dates: toSingleDates(pub.releaseDate),
website: createItemWebsite(pub.url),
description: pub.summary ? `<p>${pub.summary}</p>` : "",
description: toHtmlDescription(pub.summary),
})),
};
}
@@ -410,7 +410,7 @@ function convertJSONResume(jsonResume: JSONResume): ResumeData {
position: "",
website: createItemWebsite(),
phone: "",
description: ref.reference ? `<p>${ref.reference}</p>` : "",
description: toHtmlDescription(ref.reference),
})),
};
}