feat(import): add LinkedIn data export as a resume import source (#3538)

* feat(import): add LinkedIn data export as a resume import source

LinkedIn's "Get a copy of your data" export ships a ZIP of per-topic
CSVs (Profile, Positions, Education, Skills, Languages,
Certifications). Reading these directly gives structured data without
needing a connected AI provider, unlike the existing PDF/DOCX import
path.

Also fixes a latent bug found while building this: parseJSONResume
(and the new LinkedIn parser) built their result via a shallow spread
of the shared `defaultResumeData` singleton, so assigning into
`result.sections.x` mutated that singleton in place and leaked section
data into the next unrelated import call in the same process. Both now
start from a structuredClone.

* fix(import): escape LinkedIn text, harden zip parsing and date handling

Move escapeHtml and toHtml from the plain-text importer into html.ts and
use them for LinkedIn summary, position descriptions and education notes,
so CSV text is HTML-escaped and line breaks become paragraphs or bullet
lists instead of collapsing into one run-on paragraph.

Only an empty end date now marks an entry as ongoing. Date cells that are
not "Mon YYYY" are kept verbatim, so a finished role with an unexpected
date format no longer reads as "Present".

Unzip only the six CSVs the importer reads, matched by exact file name,
and reject any of them larger than 5 MB. This avoids inflating the rest
of a complete LinkedIn export in the browser and stops Learning_Profile.csv
being read as Profile.csv.

Map LinkedIn's five language proficiency options onto levels 5 to 1,
falling back to parseLevel for anything else. Drop the literal BOM strip,
which TextDecoder already handles.

The import dialog no longer mentions an AI provider in the loading toast
for LinkedIn imports, which are parsed entirely in the browser.

Add a regression test for the JSON Resume importer leaking section data
through the shared defaultResumeData object, plus LinkedIn tests for HTML
escaping, unrecognised end dates, BOM headers, exact file name matching,
language levels and oversized entries.

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
This commit is contained in:
Syed Ali Abbas Zaidi
2026-09-28 08:57:20 +02:00
committed by GitHub
co-authored by Amruth Pillai
parent 73e7a3cb6d
commit fb756026fa
10 changed files with 535 additions and 30 deletions
+35 -4
View File
@@ -57,6 +57,17 @@ const formSchema = z.discriminatedUnion("type", [
{ message: "File must be a Microsoft Word document" }, { message: "File must be a Microsoft Word document" },
), ),
}), }),
z.object({
type: z.literal("linkedin"),
file: z
.instanceof(File)
.refine(
(file) => file.type === "" || file.type === "application/zip" || file.name.toLowerCase().endsWith(".zip"),
{
message: "File must be a ZIP archive",
},
),
}),
z.object({ z.object({
type: z.literal("reactive-resume-json"), type: z.literal("reactive-resume-json"),
file: z file: z
@@ -101,6 +112,11 @@ async function detectImportType(file: File): Promise<ImportType> {
const isZip = header[0] === 0x50 && header[1] === 0x4b && header[2] === 0x03 && header[3] === 0x04; // "PK\x03\x04" const isZip = header[0] === 0x50 && header[1] === 0x4b && header[2] === 0x03 && header[3] === 0x04; // "PK\x03\x04"
if (isPdf || mime === "application/pdf" || name.endsWith(".pdf")) return "pdf"; if (isPdf || mime === "application/pdf" || name.endsWith(".pdf")) return "pdf";
// Word documents are also ZIPs, so a bare "PK" header is ambiguous. LinkedIn's export is
// only ever named with a .zip extension, so check that first and let it win the tie.
if (name.endsWith(".zip") || mime === "application/zip") return "linkedin";
if ( if (
isZip || isZip ||
mime === "application/msword" || mime === "application/msword" ||
@@ -144,13 +160,13 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
setIsImporting(true); setIsImporting(true);
// A PDF parsed in the browser never touches a provider, so promising one would be a lie. // A LinkedIn export, or a PDF parsed in the browser, never touches a provider, so promising one would be a lie.
const isLocalPdf = value.type === "pdf" && !hasUsableProvider; const isLocalParse = value.type === "linkedin" || (value.type === "pdf" && !hasUsableProvider);
const toastId = toast.add({ const toastId = toast.add({
type: "loading", type: "loading",
title: t`Importing your resume...`, title: t`Importing your resume...`,
description: isLocalPdf description: isLocalParse
? t`This may take a moment. Please do not close the window or refresh the page.` ? t`This may take a moment. Please do not close the window or refresh the page.`
: t`This may take a few minutes, depending on the response of the AI provider. Please do not close the window or refresh the page.`, : t`This may take a few minutes, depending on the response of the AI provider. Please do not close the window or refresh the page.`,
}); });
@@ -195,6 +211,12 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
} }
} }
if (value.type === "linkedin") {
const { parseLinkedInExport } = await import("@reactive-resume/import/linkedin");
const bytes = new Uint8Array(await value.file.arrayBuffer());
data = parseLinkedInExport(bytes);
}
if (value.type === "docx") { if (value.type === "docx") {
if (isLoadingAiProviders) throw new Error(t`Loading AI providers. Please try again in a moment.`); if (isLoadingAiProviders) throw new Error(t`Loading AI providers. Please try again in a moment.`);
if (!hasUsableProvider) if (!hasUsableProvider)
@@ -315,7 +337,8 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
<DialogDescription> <DialogDescription>
<Trans> <Trans>
Continue where you left off by importing a resume you built in Reactive Resume or another resume builder. Continue where you left off by importing a resume you built in Reactive Resume or another resume builder.
Supported formats are PDF, Microsoft Word, and JSON files from Reactive Resume or JSON Resume. Supported formats are PDF, Microsoft Word, a LinkedIn data export, and JSON files from Reactive Resume or
JSON Resume.
</Trans> </Trans>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -401,6 +424,14 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
message: "JSON Resume", message: "JSON Resume",
}), }),
}, },
{
value: "linkedin",
textValue: "LinkedIn",
label: t({
comment: "Import source option for a LinkedIn data export ZIP",
message: "LinkedIn (Data Export)",
}),
},
{ {
value: "pdf", value: "pdf",
textValue: "PDF", textValue: "PDF",
+8 -1
View File
@@ -1,4 +1,11 @@
export type ImportType = "" | "pdf" | "docx" | "reactive-resume-json" | "reactive-resume-v4-json" | "json-resume-json"; export type ImportType =
| ""
| "pdf"
| "docx"
| "linkedin"
| "reactive-resume-json"
| "reactive-resume-v4-json"
| "json-resume-json";
export function detectJsonImportType(parsed: unknown): ImportType { export function detectJsonImportType(parsed: unknown): ImportType {
if (!parsed || typeof parsed !== "object") return ""; if (!parsed || typeof parsed !== "object") return "";
+2
View File
@@ -5,6 +5,7 @@
"private": true, "private": true,
"exports": { "exports": {
"./json-resume": "./src/json-resume.tsx", "./json-resume": "./src/json-resume.tsx",
"./linkedin": "./src/linkedin.ts",
"./plain-text": "./src/plain-text.ts", "./plain-text": "./src/plain-text.ts",
"./reactive-resume-json": "./src/reactive-resume-json.tsx", "./reactive-resume-json": "./src/reactive-resume-json.tsx",
"./reactive-resume-v4-json": "./src/reactive-resume-v4-json.tsx" "./reactive-resume-v4-json": "./src/reactive-resume-v4-json.tsx"
@@ -20,6 +21,7 @@
"@reactive-resume/resume": "workspace:*", "@reactive-resume/resume": "workspace:*",
"@reactive-resume/schema": "workspace:*", "@reactive-resume/schema": "workspace:*",
"@reactive-resume/utils": "workspace:*", "@reactive-resume/utils": "workspace:*",
"fflate": "^0.8.3",
"zod": "^4.6.5" "zod": "^4.6.5"
}, },
"devDependencies": { "devDependencies": {
+26
View File
@@ -29,3 +29,29 @@ export function arrayToHtmlList(items: string[]): string {
if (items.length === 0) return ""; if (items.length === 0) return "";
return `<ul>${items.map((item) => `<li>${item}</li>`).join("")}</ul>`; return `<ul>${items.map((item) => `<li>${item}</li>`).join("")}</ul>`;
} }
export const BULLET_PATTERN = /^\s*[-–—•*◦‣·]\s+/;
const escapeHtml = (value: string) =>
value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
/**
* Converts plain-text lines into escaped HTML: a <ul> when most lines are bullets, otherwise one <p> per line.
*/
export function toHtml(lines: string[]): string {
const cleaned = lines.map((line) => line.trim()).filter(Boolean);
if (cleaned.length === 0) return "";
const bulleted = cleaned.filter((line) => BULLET_PATTERN.test(line));
if (bulleted.length >= 2 && bulleted.length * 2 >= cleaned.length) {
const items = cleaned.map((line) => `<li>${escapeHtml(line.replace(BULLET_PATTERN, ""))}</li>`).join(""); // nosemgrep
return `<ul>${items}</ul>`; // nosemgrep
}
return cleaned.map((line) => `<p>${escapeHtml(line.replace(BULLET_PATTERN, ""))}</p>`).join(""); // nosemgrep
}
+9
View File
@@ -1,5 +1,6 @@
// biome-ignore-all lint/style/noNonNullAssertion: These tests assert imported section lengths before inspecting the first item. // biome-ignore-all lint/style/noNonNullAssertion: These tests assert imported section lengths before inspecting the first item.
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { parseJSONResume } from "./json-resume"; import { parseJSONResume } from "./json-resume";
describe("parseJSONResume", () => { describe("parseJSONResume", () => {
@@ -187,4 +188,12 @@ describe("parseJSONResume", () => {
expect(project.name).toBe("Open source CLI"); expect(project.name).toBe("Open source CLI");
expect(project.description).toContain("10k stars"); expect(project.description).toContain("10k stars");
}); });
it("does not leak section data from one import into the next", () => {
parseJSONResume(JSON.stringify({ work: [{ name: "Acme", position: "Engineer" }] }));
const next = parseJSONResume("{}");
expect(next.sections.experience.items).toHaveLength(0);
expect(defaultResumeData.sections.experience.items).toHaveLength(0);
});
}); });
+4 -3
View File
@@ -169,9 +169,10 @@ type JSONResume = z.infer<typeof jsonResumeSchema>;
// ponytail: stateless two-method class → two plain functions // ponytail: stateless two-method class → two plain functions
function convertJSONResume(jsonResume: JSONResume): ResumeData { function convertJSONResume(jsonResume: JSONResume): ResumeData {
const result: ResumeData = { // A shallow spread would leave `sections`/`picture`/etc. as the same object as
...defaultResumeData, // defaultResumeData; every `result.sections.x = ...` below would then mutate that shared
}; // singleton and leak into the next unrelated import call. Clone it instead.
const result: ResumeData = structuredClone(defaultResumeData);
// Map basics // Map basics
if (jsonResume.basics) { if (jsonResume.basics) {
+163
View File
@@ -0,0 +1,163 @@
// biome-ignore-all lint/style/noNonNullAssertion: These tests assert imported section lengths before inspecting the first item.
import { describe, expect, it } from "vitest";
import { zipSync } from "fflate";
import { parseLinkedInExport } from "./linkedin";
function makeZip(files: Record<string, string>): Uint8Array {
const encoder = new TextEncoder();
const entries: Record<string, Uint8Array> = {};
for (const [name, content] of Object.entries(files)) entries[name] = encoder.encode(content);
return zipSync(entries);
}
describe("parseLinkedInExport", () => {
it("throws when the file is not a valid ZIP", () => {
expect(() => parseLinkedInExport(new Uint8Array([1, 2, 3]))).toThrow(/ZIP archive/);
});
it("throws when the ZIP has none of the expected LinkedIn CSVs", () => {
const zip = makeZip({ "Random.csv": "a,b\n1,2\n" });
expect(() => parseLinkedInExport(zip)).toThrow(/doesn't look like a LinkedIn data export/);
});
it("imports basics and summary from Profile.csv", () => {
const zip = makeZip({
"Profile.csv":
'First Name,Last Name,Headline,Summary,Geo Location\nJane,Doe,Engineer,Builds things,"Berlin, Germany"\n',
});
const result = parseLinkedInExport(zip);
expect(result.basics.name).toBe("Jane Doe");
expect(result.basics.headline).toBe("Engineer");
expect(result.summary.content).toBe("<p>Builds things</p>");
expect(result.summary.hidden).toBe(false);
});
it("imports work history from Positions.csv with LinkedIn-style dates", () => {
const zip = makeZip({
"Positions.csv":
"Company Name,Title,Description,Location,Started On,Finished On\nAcme,Engineer,Built stuff,Remote,Jan 2020,Dec 2022\n",
});
const result = parseLinkedInExport(zip);
expect(result.sections.experience.items).toHaveLength(1);
const item = result.sections.experience.items[0]!;
expect(item.company).toBe("Acme");
expect(item.position).toBe("Engineer");
expect(item.period).toBe("January 2020 - December 2022");
expect(item.description).toBe("<p>Built stuff</p>");
});
it("treats an ongoing position (no Finished On) as present", () => {
const zip = makeZip({
"Positions.csv": "Company Name,Title,Started On,Finished On\nAcme,Engineer,Jan 2020,\n",
});
const result = parseLinkedInExport(zip);
expect(result.sections.experience.items).toHaveLength(1);
expect(result.sections.experience.items[0]!.period).toBe("January 2020 - Present");
});
it("skips positions without a company name", () => {
const zip = makeZip({
"Positions.csv": "Company Name,Title\n,Freelancer\n",
});
const result = parseLinkedInExport(zip);
expect(result.sections.experience.items).toHaveLength(0);
});
it("imports education from Education.csv", () => {
const zip = makeZip({
"Education.csv": "School Name,Degree Name,Start Date,End Date\nMIT,BSc Computer Science,2016,2020\n",
});
const result = parseLinkedInExport(zip);
expect(result.sections.education.items).toHaveLength(1);
const item = result.sections.education.items[0]!;
expect(item.school).toBe("MIT");
expect(item.degree).toBe("BSc Computer Science");
expect(item.period).toBe("2016 - 2020");
});
it("imports skills, languages, and certifications", () => {
const zip = makeZip({
"Education.csv": "School Name\nMIT\n",
"Skills.csv": "Name\nTypeScript\n",
"Languages.csv": "Name,Proficiency\nSpanish,Native or bilingual\n",
"Certifications.csv": "Name,Authority,Url,Started On\nAWS Certified,Amazon,https://aws.amazon.com,Jun 2021\n",
});
const result = parseLinkedInExport(zip);
expect(result.sections.skills.items[0]!.name).toBe("TypeScript");
expect(result.sections.languages.items[0]!.language).toBe("Spanish");
expect(result.sections.languages.items[0]!.level).toBe(5);
expect(result.sections.certifications.items[0]!.title).toBe("AWS Certified");
expect(result.sections.certifications.items[0]!.website.url).toBe("https://aws.amazon.com");
});
it("finds CSVs nested inside a folder in the ZIP", () => {
const zip = makeZip({
"Basic_LinkedInDataExport/Education.csv": "School Name\nMIT\n",
});
const result = parseLinkedInExport(zip);
expect(result.sections.education.items[0]!.school).toBe("MIT");
});
it("escapes CSV text and turns line breaks into HTML", () => {
const zip = makeZip({
"Profile.csv": 'First Name,Summary\nJane,"Line one\nLine <two> & more"\n',
"Positions.csv": 'Company Name,Description\nAcme,"Led a team\n• Built <script>x</script>\n• Shipped C++ & Go"\n',
});
const result = parseLinkedInExport(zip);
expect(result.summary.content).toBe("<p>Line one</p><p>Line &lt;two&gt; &amp; more</p>");
expect(result.sections.experience.items[0]!.description).toBe(
"<ul><li>Led a team</li><li>Built &lt;script&gt;x&lt;/script&gt;</li><li>Shipped C++ &amp; Go</li></ul>",
);
});
it("keeps an unrecognised end date verbatim instead of showing the role as ongoing", () => {
const zip = makeZip({
"Positions.csv": "Company Name,Started On,Finished On\nAcme,Jan 2020,2021-06-30\n",
});
expect(parseLinkedInExport(zip).sections.experience.items[0]!.period).toBe("January 2020 - 2021-06-30");
});
it("reads headers behind a UTF-8 byte order mark", () => {
const zip = makeZip({ "Positions.csv": "Company Name,Title\nAcme,Engineer\n" });
expect(parseLinkedInExport(zip).sections.experience.items[0]!.company).toBe("Acme");
});
it("matches CSVs by exact file name, not suffix", () => {
const zip = makeZip({
"Learning_Profile.csv": "First Name,Last Name\nWrong,Person\n",
"Education.csv": "School Name\nMIT\n",
});
expect(parseLinkedInExport(zip).basics.name).toBe("");
});
it("maps LinkedIn's language proficiency options onto levels", () => {
const zip = makeZip({
"Education.csv": "School Name\nMIT\n",
"Languages.csv": [
"Name,Proficiency",
"A,Native or bilingual proficiency",
"B,Full professional proficiency",
"C,Professional working proficiency",
"D,Limited working proficiency",
"E,Elementary proficiency",
].join("\n"),
});
expect(parseLinkedInExport(zip).sections.languages.items.map((item) => item.level)).toEqual([5, 4, 3, 2, 1]);
});
it("rejects an oversized CSV entry", () => {
const zip = makeZip({ "Positions.csv": `Company Name\n${"a".repeat(5 * 1024 * 1024)}\n` });
expect(() => parseLinkedInExport(zip)).toThrow(/larger than 5 MB/);
});
});
+284
View File
@@ -0,0 +1,284 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { unzipSync } from "fflate";
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { generateId } from "@reactive-resume/utils/string";
import { formatDate } from "./date";
import { rethrowAsImportError } from "./error";
import { toHtml } from "./html";
import { parseLevel } from "./level";
const LINKEDIN_CSVS = new Set([
"profile.csv",
"positions.csv",
"education.csv",
"skills.csv",
"languages.csv",
"certifications.csv",
]);
// Real LinkedIn CSVs are kilobytes; the cap keeps a crafted archive from exhausting the tab's memory.
const MAX_CSV_BYTES = 5 * 1024 * 1024;
// LinkedIn's five fixed proficiency options, mapped onto the 0-5 level scale.
const LANGUAGE_LEVELS: Record<string, number> = {
"native or bilingual": 5,
"full professional": 4,
"professional working": 3,
"limited working": 2,
elementary: 1,
};
const MONTHS: Record<string, string> = {
jan: "01",
feb: "02",
mar: "03",
apr: "04",
may: "05",
jun: "06",
jul: "07",
aug: "08",
sep: "09",
oct: "10",
nov: "11",
dec: "12",
};
// LinkedIn's "Started On" / "Finished On" cells are "Mon YYYY" (e.g. "Jan 2020") or a bare year.
// Anything else is kept verbatim rather than dropped.
function formatLinkedInDate(value = ""): string {
const trimmed = value.trim();
const monthYear = /^([A-Za-z]{3})[a-z]*\s+(\d{4})$/.exec(trimmed);
const month = MONTHS[monthYear?.[1]?.toLowerCase() ?? ""];
return monthYear && month ? formatDate(`${monthYear[2]}-${month}`) : trimmed;
}
// Only an empty end cell means the entry is ongoing; an unrecognised one must not read as "Present".
function formatLinkedInPeriod(start?: string, end?: string): string {
const from = formatLinkedInDate(start);
const to = formatLinkedInDate(end);
if (!from) return to;
return `${from} - ${to || "Present"}`;
}
const textToHtml = (text = "") => toHtml(text.split(/\r\n?|\n/));
const languageLevel = (proficiency = "") =>
LANGUAGE_LEVELS[proficiency.toLowerCase().replace(/\s*proficiency$/, "")] ?? parseLevel(proficiency);
const basename = (path: string) => path.slice(path.lastIndexOf("/") + 1).toLowerCase();
// Minimal RFC-4180-ish CSV parser: handles quoted fields, escaped quotes (""), and commas/newlines
// inside quotes. LinkedIn's export files are small, so a non-streaming parser is enough.
// No BOM handling needed: TextDecoder strips a leading UTF-8 BOM before the text gets here.
function parseCsv(text: string): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let field = "";
let inQuotes = false;
for (let i = 0; i < text.length; i++) {
const char = text[i];
if (inQuotes) {
if (char === '"') {
if (text[i + 1] === '"') {
field += '"';
i++;
} else {
inQuotes = false;
}
} else {
field += char;
}
continue;
}
if (char === '"') inQuotes = true;
else if (char === ",") {
row.push(field);
field = "";
} else if (char === "\n" || char === "\r") {
if (char === "\r" && text[i + 1] === "\n") i++;
row.push(field);
rows.push(row);
row = [];
field = "";
} else {
field += char;
}
}
if (field !== "" || row.length > 0) {
row.push(field);
rows.push(row);
}
return rows.filter((r) => r.some((cell) => cell.trim() !== ""));
}
function rowsToRecords(rows: string[][]): Record<string, string>[] {
if (rows.length === 0) return [];
const header = rows[0] ?? [];
const body = rows.slice(1);
return body.map((row) => Object.fromEntries(header.map((key, i) => [key.trim(), (row[i] ?? "").trim()])));
}
function findCsv(files: Record<string, Uint8Array>, fileName: string): Record<string, string>[] {
const decoder = new TextDecoder();
const key = Object.keys(files).find((path) => basename(path) === fileName);
if (!key) return [];
return rowsToRecords(parseCsv(decoder.decode(files[key])));
}
const emptyWebsite = { url: "", label: "", inlineLink: false };
const linkWebsite = (url?: string) => (url ? { url, label: url, inlineLink: false } : emptyWebsite);
/**
* Converts LinkedIn's "Download your data" export (a ZIP of per-topic CSVs) into ResumeData.
* Only the CSVs relevant to a resume are read; the rest of the export is ignored.
*/
export function parseLinkedInExport(zipBytes: Uint8Array): ResumeData {
let files: Record<string, Uint8Array>;
let oversized = "";
try {
// Only inflate the CSVs we read; the rest of the export (messages, media, ...) can be large.
files = unzipSync(zipBytes, {
filter: (file) => {
if (!LINKEDIN_CSVS.has(basename(file.name))) return false;
if (file.originalSize > MAX_CSV_BYTES) oversized ||= file.name;
return !oversized;
},
});
} catch {
throw new Error(
'This file could not be read as a ZIP archive. Export your data from LinkedIn\'s "Get a copy of your data" page and upload the ZIP as-is.',
);
}
if (oversized) {
throw new Error(`"${oversized}" in this ZIP is larger than 5 MB, which is too large for a LinkedIn data export.`);
}
const profile = findCsv(files, "profile.csv")[0];
const positions = findCsv(files, "positions.csv");
const education = findCsv(files, "education.csv");
const skills = findCsv(files, "skills.csv");
const languages = findCsv(files, "languages.csv");
const certifications = findCsv(files, "certifications.csv");
if (!profile && positions.length === 0 && education.length === 0) {
throw new Error(
"This ZIP doesn't look like a LinkedIn data export. Expected a Profile.csv, Positions.csv, or Education.csv inside it.",
);
}
// structuredClone, not a shallow spread: `result.sections.x = ...` below would otherwise
// mutate defaultResumeData's shared nested objects and leak into the next import call.
const result: ResumeData = structuredClone(defaultResumeData);
if (profile) {
result.basics = {
...defaultResumeData.basics,
name: [profile["First Name"], profile["Last Name"]].filter(Boolean).join(" "),
headline: profile.Headline || "",
location: profile["Geo Location"] || "",
website: { url: "", label: "" },
};
if (profile.Summary) {
result.summary = { ...defaultResumeData.summary, content: textToHtml(profile.Summary), hidden: false };
}
}
const companies = positions.filter((position) => position["Company Name"]);
if (companies.length > 0) {
result.sections.experience = {
...defaultResumeData.sections.experience,
items: companies.map((position) => ({
id: generateId(),
hidden: false,
company: position["Company Name"] ?? "",
position: position.Title || "",
location: position.Location || "",
period: formatLinkedInPeriod(position["Started On"], position["Finished On"]),
website: emptyWebsite,
roles: [],
description: textToHtml(position.Description),
})),
};
}
const schools = education.filter((edu) => edu["School Name"]);
if (schools.length > 0) {
result.sections.education = {
...defaultResumeData.sections.education,
items: schools.map((edu) => ({
id: generateId(),
hidden: false,
school: edu["School Name"] ?? "",
degree: edu["Degree Name"] || "",
area: "",
grade: "",
location: "",
period: formatLinkedInPeriod(edu["Start Date"], edu["End Date"]),
website: emptyWebsite,
description: textToHtml(edu.Notes),
})),
};
}
const namedSkills = skills.filter((skill) => skill.Name);
if (namedSkills.length > 0) {
result.sections.skills = {
...defaultResumeData.sections.skills,
items: namedSkills.map((skill) => ({
id: generateId(),
hidden: false,
icon: "star",
iconColor: "",
name: skill.Name ?? "",
proficiency: "",
level: 0,
keywords: [],
})),
};
}
const namedLanguages = languages.filter((lang) => lang.Name);
if (namedLanguages.length > 0) {
result.sections.languages = {
...defaultResumeData.sections.languages,
items: namedLanguages.map((lang) => ({
id: generateId(),
hidden: false,
language: lang.Name ?? "",
fluency: lang.Proficiency || "",
level: languageLevel(lang.Proficiency),
})),
};
}
const namedCertifications = certifications.filter((cert) => cert.Name);
if (namedCertifications.length > 0) {
result.sections.certifications = {
...defaultResumeData.sections.certifications,
items: namedCertifications.map((cert) => ({
id: generateId(),
hidden: false,
title: cert.Name ?? "",
issuer: cert.Authority || "",
date: formatLinkedInDate(cert["Started On"]),
website: linkWebsite(cert.Url),
description: "",
})),
};
}
try {
return resumeDataSchema.parse(result);
} catch (error) {
rethrowAsImportError(error);
}
}
+1 -22
View File
@@ -3,6 +3,7 @@ import { parsePeriod, parseSingleDate } from "@reactive-resume/resume/ats";
import { parseResumeData } from "@reactive-resume/schema/resume/data"; import { parseResumeData } from "@reactive-resume/schema/resume/data";
import { defaultResumeData } from "@reactive-resume/schema/resume/default"; import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { generateId } from "@reactive-resume/utils/string"; import { generateId } from "@reactive-resume/utils/string";
import { BULLET_PATTERN, toHtml } from "./html";
type SectionKey = SectionType | "summary"; type SectionKey = SectionType | "summary";
@@ -82,7 +83,6 @@ const SECTION_ALIASES: Readonly<Record<string, SectionKey>> = {
"social profiles": "profiles", "social profiles": "profiles",
}; };
const BULLET_PATTERN = /^\s*[-–—•*◦‣·]\s+/;
const EMAIL_PATTERN = /[\w.+-]+@[\w-]+\.[\w.-]*\w/; const EMAIL_PATTERN = /[\w.+-]+@[\w-]+\.[\w.-]*\w/;
const URL_PATTERN = /\b(?:https?:\/\/|www\.)[^\s,;|•·]+/gi; const URL_PATTERN = /\b(?:https?:\/\/|www\.)[^\s,;|•·]+/gi;
const PHONE_CANDIDATE = /[+(]?\d[\d\s().+-]{5,}\d/g; const PHONE_CANDIDATE = /[+(]?\d[\d\s().+-]{5,}\d/g;
@@ -95,14 +95,6 @@ const STRONG_SEPARATOR = /\s*[|•·]\s*|\s{2,}|\s+[–—]\s+/;
const SENTENCE_END = /[.!?]$/; const SENTENCE_END = /[.!?]$/;
const TRAILING_DATES = [/(?:\p{L}{3,}\.?\s+)?(?:\d{1,2}[/.])?(?:19|20)\d{2}$/u, /(?:\d{1,2}[/.])?(?:19|20)\d{2}$/]; const TRAILING_DATES = [/(?:\p{L}{3,}\.?\s+)?(?:\d{1,2}[/.])?(?:19|20)\d{2}$/u, /(?:\d{1,2}[/.])?(?:19|20)\d{2}$/];
const escapeHtml = (value: string) =>
value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
const normalizeHeading = (line: string) => const normalizeHeading = (line: string) =>
line line
.replace(/[::]\s*$/, "") .replace(/[::]\s*$/, "")
@@ -229,19 +221,6 @@ function splitHeaderParts(text: string): string[] {
.filter(Boolean); .filter(Boolean);
} }
function toHtml(lines: string[]): string {
const cleaned = lines.map((line) => line.trim()).filter(Boolean);
if (cleaned.length === 0) return "";
const bulleted = cleaned.filter((line) => BULLET_PATTERN.test(line));
if (bulleted.length >= 2 && bulleted.length * 2 >= cleaned.length) {
const items = cleaned.map((line) => `<li>${escapeHtml(line.replace(BULLET_PATTERN, ""))}</li>`).join(""); // nosemgrep
return `<ul>${items}</ul>`; // nosemgrep
}
return cleaned.map((line) => `<p>${escapeHtml(line.replace(BULLET_PATTERN, ""))}</p>`).join(""); // nosemgrep
}
function splitList(lines: string[]): string[] { function splitList(lines: string[]): string[] {
const values: string[] = []; const values: string[] = [];
+3
View File
@@ -1217,6 +1217,9 @@ importers:
'@reactive-resume/utils': '@reactive-resume/utils':
specifier: workspace:* specifier: workspace:*
version: link:../utils version: link:../utils
fflate:
specifier: ^0.8.3
version: 0.8.3
zod: zod:
specifier: ^4.6.5 specifier: ^4.6.5
version: 4.6.5 version: 4.6.5