diff --git a/apps/web/src/libs/resume/ats.test.ts b/apps/web/src/libs/resume/ats.test.ts new file mode 100644 index 000000000..3bc803d66 --- /dev/null +++ b/apps/web/src/libs/resume/ats.test.ts @@ -0,0 +1,152 @@ +// @vitest-environment happy-dom + +import type { ResumeData } from "@reactive-resume/schema/resume/data"; +import { beforeAll, describe, expect, it } from "vitest"; +import { i18n } from "@lingui/core"; +import { ATS_RULE_CODES } from "@reactive-resume/resume/ats"; +import { defaultResumeData } from "@reactive-resume/schema/resume/default"; +import { atsFindingItemElementId, getAtsFindingLocation, getAtsFindingMessage, getAtsFindingTarget } from "./ats"; + +const experienceItem = (id: string) => ({ + id, + hidden: false, + company: "Analytical Engines", + position: "Engineer", + location: "London", + period: "", + website: { url: "", label: "", inlineLink: false }, + description: "", + roles: [], +}); + +function makeResume(): ResumeData { + const data = structuredClone(defaultResumeData); + data.sections.experience.items = [experienceItem("exp-a"), experienceItem("exp-b")] as never; + data.customSections = [ + { + id: "custom-1", + type: "experience", + title: "Consulting", + icon: "", + columns: 1, + hidden: false, + keepTogether: false, + startOnNewPage: false, + items: [experienceItem("custom-item-a")], + }, + ] as never; + return data; +} + +beforeAll(() => { + i18n.loadAndActivate({ locale: "en", messages: {} }); +}); + +describe("getAtsFindingMessage", () => { + it("covers every rule in the catalog", () => { + for (const code of ATS_RULE_CODES) { + const message = getAtsFindingMessage(code); + expect(message.title.length, code).toBeGreaterThan(0); + expect(message.action.length, code).toBeGreaterThan(0); + } + }); + + it("gives each rule a distinct title", () => { + const titles = ATS_RULE_CODES.map((code) => getAtsFindingMessage(code).title); + expect(new Set(titles).size).toBe(titles.length); + }); +}); + +describe("getAtsFindingTarget", () => { + it.each([ + ["/basics/email", { side: "left", section: "basics" }], + ["/basics/customFields/0/link", { side: "left", section: "basics" }], + ["/picture", { side: "left", section: "picture" }], + ["/summary/content", { side: "left", section: "summary" }], + ["/sections/experience/items/0/period", { side: "left", section: "experience" }], + ["/customSections/2/items/1/period", { side: "left", section: "custom" }], + ["/metadata/typography/body/fontSize", { side: "right", section: "typography" }], + ["/metadata/page/marginX", { side: "right", section: "page" }], + ["/metadata/layout/pages", { side: "right", section: "layout" }], + ])("resolves %s", (pointer, expected) => { + expect(getAtsFindingTarget(pointer)).toEqual(expected); + }); + + it("returns null for a pointer with no sidebar home", () => { + expect(getAtsFindingTarget("/metadata/styleRules/0")).toBeNull(); + }); + + it("decodes escaped pointer tokens", () => { + expect(getAtsFindingTarget("/sections/experience/items/0/period")).toEqual({ + side: "left", + section: "experience", + }); + }); +}); + +describe("getAtsFindingTarget item resolution", () => { + it("resolves the item a finding belongs to", () => { + expect(getAtsFindingTarget("/sections/experience/items/0/period", makeResume())).toEqual({ + side: "left", + section: "experience", + itemId: "exp-a", + }); + }); + + it("sends two findings in the same section to different items", () => { + const data = makeResume(); + const first = getAtsFindingTarget("/sections/experience/items/0/period", data); + const second = getAtsFindingTarget("/sections/experience/items/1/period", data); + + expect(first?.itemId).toBe("exp-a"); + expect(second?.itemId).toBe("exp-b"); + expect(first?.section).toBe(second?.section); + }); + + it("resolves items inside custom sections", () => { + expect(getAtsFindingTarget("/customSections/0/items/0/period", makeResume())).toEqual({ + side: "left", + section: "custom", + itemId: "custom-item-a", + }); + }); + + it("omits the item when no resume data is supplied", () => { + expect(getAtsFindingTarget("/sections/experience/items/0/period")).toEqual({ + side: "left", + section: "experience", + }); + }); + + it("omits the item for a section-level pointer", () => { + expect(getAtsFindingTarget("/sections/experience", makeResume())).toEqual({ + side: "left", + section: "experience", + }); + }); + + it("omits the item when the index is out of range", () => { + expect(getAtsFindingTarget("/sections/experience/items/9/period", makeResume())).toEqual({ + side: "left", + section: "experience", + }); + }); + + it("builds a stable element id", () => { + expect(atsFindingItemElementId("exp-a")).toBe("resume-item-exp-a"); + }); +}); + +describe("getAtsFindingLocation", () => { + it("names the section for a section-level pointer", () => { + expect(getAtsFindingLocation("/basics/email")).toBe("Basics"); + }); + + it("adds a one-based item position for an item-level pointer", () => { + expect(getAtsFindingLocation("/sections/experience/items/0/period")).toBe("Experience · Item 1"); + }); + + it("returns null when the pointer has no sidebar home", () => { + expect(getAtsFindingLocation("/metadata/styleRules/0")).toBeNull(); + }); +}); diff --git a/apps/web/src/libs/resume/ats.ts b/apps/web/src/libs/resume/ats.ts new file mode 100644 index 000000000..923ed1a61 --- /dev/null +++ b/apps/web/src/libs/resume/ats.ts @@ -0,0 +1,186 @@ +import type { AtsRuleCode } from "@reactive-resume/resume/ats"; +import type { ResumeData } from "@reactive-resume/schema/resume/data"; +import type { LeftSidebarSection, SidebarSection } from "./section"; +import { t } from "@lingui/core/macro"; +import { match } from "ts-pattern"; +import { getSectionTitle, leftSidebarSections } from "./section"; + +export type AtsFindingMessage = { + title: string; + action: string; +}; + +export type AtsFindingTarget = { + side: "left" | "right"; + section: SidebarSection; + itemId?: string; +}; + +export const atsFindingItemElementId = (itemId: string) => `resume-item-${itemId}`; + +export function getAtsFindingMessage(code: AtsRuleCode): AtsFindingMessage { + return match(code) + .with("MISSING_NAME", () => ({ + title: t`Your resume has no name.`, + action: t`Add your full name under Basics.`, + })) + .with("MISSING_EMAIL", () => ({ + title: t`Your resume has no email address.`, + action: t`Most systems file candidates by email. Add one under Basics.`, + })) + .with("MALFORMED_EMAIL", () => ({ + title: t`This email address will not be recognized.`, + action: t`Use a plain address such as name@example.com, with no surrounding text.`, + })) + .with("MISSING_PHONE", () => ({ + title: t`Your resume has no phone number.`, + action: t`Some application systems require one before you can submit.`, + })) + .with("MISSING_LOCATION", () => ({ + title: t`Your resume has no location.`, + action: t`Add at least a city and country so roles can be matched to your region.`, + })) + .with("MALFORMED_URL", () => ({ + title: t`This link is missing its protocol.`, + action: t`Write the full address, including https://.`, + })) + .with("PICTURE_PRESENT", () => ({ + title: t`Your resume includes a photo.`, + action: t`Some parsers mishandle images, and photos are discouraged in some regions.`, + })) + .with("EMPTY_PERIOD", () => ({ + title: t`This entry has no dates.`, + action: t`Add a period such as "Jan 2020 - Present" so it lands on your timeline.`, + })) + .with("UNPARSEABLE_PERIOD", () => ({ + title: t`These dates will not be read correctly.`, + action: t`Use a recognized form such as "Jan 2020 - Mar 2022" or "2020 - 2022".`, + })) + .with("UNPARSEABLE_DATE", () => ({ + title: t`This date will not be read correctly.`, + action: t`Use a recognized form such as "March 2022" or "2022".`, + })) + .with("REVERSED_PERIOD", () => ({ + title: t`This period ends before it starts.`, + action: t`Swap the start and end dates.`, + })) + .with("FUTURE_DATED_PERIOD", () => ({ + title: t`This period starts in the future.`, + action: t`Correct the year, or use "Present" for ongoing work.`, + })) + .with("SECTION_MISSING_FROM_LAYOUT", () => ({ + title: t`This section has content but never appears.`, + action: t`Place it on a page from the Layout panel, or hide it if you meant to park it.`, + })) + .with("EMPTY_RENDERED_SECTION", () => ({ + title: t`This section renders as an empty heading.`, + action: t`Add an item to it, or hide it.`, + })) + .with("NO_VISIBLE_EXPERIENCE", () => ({ + title: t`Your resume shows no work experience.`, + action: t`Add an entry, or use projects and volunteer work to show equivalent history.`, + })) + .with("MISSING_EXPERIENCE_DESCRIPTION", () => ({ + title: t`This role has no description.`, + action: t`Describe what you did so the entry contributes keywords for matching.`, + })) + .with("NON_STANDARD_SECTION_TITLE", () => ({ + title: t`This heading is not one parsers look for.`, + action: t`Prefer a conventional heading such as "Work Experience" or "Education".`, + })) + .with("MULTI_COLUMN_PROSE_SECTION", () => ({ + title: t`This section is split across columns.`, + action: t`Columns commonly scramble the order text is read in. Set it to a single column.`, + })) + .with("PROSE_SECTION_IN_SIDEBAR", () => ({ + title: t`This section sits in the sidebar.`, + action: t`Move it into the main column and keep the sidebar for short lists.`, + })) + .with("SMALL_BODY_FONT", () => ({ + title: t`Your body text is very small.`, + action: t`Use a size of at least 9pt so re-rendered copies stay accurate.`, + })) + .with("TIGHT_LINE_HEIGHT", () => ({ + title: t`Your lines are packed very tightly.`, + action: t`Use a line height of at least 1.15 so lines are not merged together.`, + })) + .with("TIGHT_PAGE_MARGINS", () => ({ + title: t`Your page margins are very narrow.`, + action: t`Increase them so content stays inside the reliably read area.`, + })) + .exhaustive(); +} + +function decodePointerToken(token: string): string { + return token.replace(/~1/g, "/").replace(/~0/g, "~"); +} + +function pointerTokens(pointer: string): string[] { + return pointer.split("/").slice(1).map(decodePointerToken); +} + +const isLeftSidebarSection = (value: string): value is LeftSidebarSection => + (leftSidebarSections as string[]).includes(value); + +function resolveItemId(tokens: readonly string[], data?: ResumeData): string | undefined { + if (!data) return undefined; + + const itemsIndex = tokens.indexOf("items"); + if (itemsIndex === -1) return undefined; + + const position = Number(tokens[itemsIndex + 1]); + if (!Number.isInteger(position)) return undefined; + + const [head, next] = tokens; + let items: readonly unknown[] | undefined; + + if (head === "sections" && next) { + items = (data.sections as Record)[next]?.items; + } else if (head === "customSections") { + items = data.customSections[Number(next)]?.items; + } + + const item = items?.[position] as { id?: unknown } | undefined; + return typeof item?.id === "string" ? item.id : undefined; +} + +export function getAtsFindingTarget(pointer: string, data?: ResumeData): AtsFindingTarget | null { + const tokens = pointerTokens(pointer); + const [head, next] = tokens; + const itemId = resolveItemId(tokens, data); + const withItem = (target: AtsFindingTarget): AtsFindingTarget => (itemId ? { ...target, itemId } : target); + + if (head === "basics") return { side: "left", section: "basics" }; + if (head === "picture") return { side: "left", section: "picture" }; + if (head === "summary") return { side: "left", section: "summary" }; + if (head === "customSections") return withItem({ side: "left", section: "custom" }); + + if (head === "sections" && next && isLeftSidebarSection(next)) { + return withItem({ side: "left", section: next }); + } + + if (head === "metadata") { + if (next === "typography") return { side: "right", section: "typography" }; + if (next === "page") return { side: "right", section: "page" }; + if (next === "layout") return { side: "right", section: "layout" }; + } + + return null; +} + +export function getAtsFindingLocation(pointer: string): string | null { + const target = getAtsFindingTarget(pointer); + if (!target) return null; + + const tokens = pointerTokens(pointer); + const itemsIndex = tokens.indexOf("items"); + const sectionTitle = getSectionTitle(target.section); + + if (itemsIndex === -1) return sectionTitle; + + const position = Number(tokens[itemsIndex + 1]); + if (!Number.isInteger(position)) return sectionTitle; + + const itemNumber = position + 1; + return t`${sectionTitle} · Item ${itemNumber}`; +} diff --git a/apps/web/src/libs/resume/section.tsx b/apps/web/src/libs/resume/section.tsx index 5e03da41f..f4a498735 100644 --- a/apps/web/src/libs/resume/section.tsx +++ b/apps/web/src/libs/resume/section.tsx @@ -25,6 +25,7 @@ import { PaletteIcon, PhoneIcon, ReadCvLogoIcon, + SealCheckIcon, ShareFatIcon, StarIcon, TextTIcon, @@ -52,6 +53,7 @@ export type RightSidebarSection = | "notes" | "sharing" | "statistics" + | "ats" | "analysis" | "export" | "information"; @@ -87,6 +89,7 @@ export const rightSidebarSections: RightSidebarSection[] = [ "styles", "page", "notes", + "ats", "analysis", "export", "information", @@ -126,6 +129,7 @@ export const getSectionTitle = (type: SidebarSection | CustomOnlyType): string = .with("notes", () => t`Notes`) .with("sharing", () => t`Sharing`) .with("statistics", () => t`Statistics`) + .with("ats", () => t`ATS Check`) .with("analysis", () => t`Resume Analysis`) .with("export", () => t`Export`) .with("information", () => t`Information`) @@ -170,6 +174,7 @@ export const getSectionIcon = (type: SidebarSection | CustomOnlyType, props?: Ic .with("notes", () => ) .with("sharing", () => ) .with("statistics", () => ) + .with("ats", () => ) .with("analysis", () => ) .with("export", () => ) .with("information", () => ) diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx index be9f359ea..d7f053ec8 100644 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx @@ -39,6 +39,7 @@ import { cn } from "@reactive-resume/utils/style"; import { useDialogStore } from "@/dialogs/store"; import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft"; import { useConfirm } from "@/hooks/use-confirm"; +import { atsFindingItemElementId } from "@/libs/resume/ats"; import { addItemToSection, createCustomSectionWithItem, @@ -244,6 +245,7 @@ export function SectionItem({ return ( ) .with("sharing", () => ) .with("statistics", () => ) + .with("ats", () => ) .with("analysis", () => ) .with("export", () => ) .with("information", () => ) diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/ats-check.test.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/ats-check.test.tsx new file mode 100644 index 000000000..39d1a8ed7 --- /dev/null +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/ats-check.test.tsx @@ -0,0 +1,194 @@ +// @vitest-environment happy-dom + +import type { ExperienceItem, ResumeData } from "@reactive-resume/schema/resume/data"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { i18n } from "@lingui/core"; +import { I18nProvider } from "@lingui/react"; +import { defaultResumeData } from "@reactive-resume/schema/resume/default"; + +const resumeState = vi.hoisted(() => ({ data: undefined as ResumeData | undefined })); +const sidebarState = vi.hoisted(() => ({ toggleSidebar: vi.fn() })); +const sectionState = vi.hoisted(() => ({ setCollapsed: vi.fn() })); + +type SectionBaseProps = { children: React.ReactNode }; +type SectionStoreSelector = (state: { setCollapsed: typeof sectionState.setCollapsed }) => unknown; + +vi.mock("@/features/resume/builder/draft", () => ({ + useResumeData: () => resumeState.data, +})); +vi.mock("../../../-store/sidebar", () => ({ + useBuilderSidebar: () => sidebarState, +})); +vi.mock("../../../-store/section", () => ({ + useSectionStore: (selector: SectionStoreSelector) => selector(sectionState), +})); +vi.mock("../shared/section-base", () => ({ + SectionBase: ({ children }: SectionBaseProps) =>
{children}
, +})); + +const { AtsCheckSectionBuilder } = await import("./ats-check"); + +beforeAll(() => { + i18n.loadAndActivate({ locale: "en", messages: {} }); + Element.prototype.scrollIntoView = vi.fn(); +}); + +beforeEach(() => { + vi.clearAllMocks(); + resumeState.data = undefined; + document.body.innerHTML = ""; +}); + +const experienceItem = (overrides: Partial = {}): ExperienceItem => ({ + id: "exp-1", + hidden: false, + company: "Analytical Engines", + position: "Engineer", + location: "London", + period: "Jan 2020 - Present", + website: { url: "", label: "", inlineLink: false }, + description: "

Designed and shipped the difference engine.

", + roles: [], + ...overrides, +}); + +function makeResume(mutate: (data: ResumeData) => void = () => undefined): ResumeData { + const data = structuredClone(defaultResumeData); + + data.basics.name = "Ada Lovelace"; + data.basics.email = "ada@example.com"; + data.basics.phone = "+44 20 7946 0100"; + data.basics.location = "London, UK"; + data.sections.experience.items = [experienceItem()]; + data.metadata.layout.pages = [{ fullWidth: false, main: ["experience"], sidebar: [] }]; + + mutate(data); + return data; +} + +const renderPanel = () => + render( + + + , + ); + +describe("AtsCheckSectionBuilder", () => { + it("renders nothing before the resume is ready", () => { + const { container } = renderPanel(); + expect(container).toBeEmptyDOMElement(); + }); + + it("reports a clean resume as fully passing", () => { + resumeState.data = makeResume(); + renderPanel(); + + expect(screen.getByText("22 of 22 checks passed")).toBeTruthy(); + expect(screen.getByText(/Every check passed/)).toBeTruthy(); + }); + + it("lists a finding with its title and remedy", () => { + resumeState.data = makeResume((data) => { + data.basics.email = "ada at example dot com"; + }); + renderPanel(); + + expect(screen.getByText("This email address will not be recognized.")).toBeTruthy(); + expect(screen.getByText(/Use a plain address/)).toBeTruthy(); + expect(screen.getByText("21 of 22 checks passed")).toBeTruthy(); + }); + + it("counts findings by severity", () => { + resumeState.data = makeResume((data) => { + data.basics.email = ""; + data.picture.url = "/uploads/ada.png"; + }); + renderPanel(); + + expect(screen.getByText("1 error")).toBeTruthy(); + expect(screen.getByText("1 note")).toBeTruthy(); + }); + + it("opens the owning sidebar section when a finding's location is clicked", () => { + resumeState.data = makeResume((data) => { + data.basics.email = ""; + }); + renderPanel(); + + fireEvent.click(screen.getByRole("button", { name: /Basics/ })); + + expect(sidebarState.toggleSidebar).toHaveBeenCalledWith("left", true); + expect(sectionState.setCollapsed).toHaveBeenCalledWith("basics", false); + }); + + it("points typography findings at the right sidebar", () => { + resumeState.data = makeResume((data) => { + data.metadata.typography.body.fontSize = 8; + }); + renderPanel(); + + fireEvent.click(screen.getByRole("button", { name: /Typography/ })); + + expect(sidebarState.toggleSidebar).toHaveBeenCalledWith("right", true); + expect(sectionState.setCollapsed).toHaveBeenCalledWith("typography", false); + }); +}); + +describe("navigating to a finding", () => { + const stubElement = (id: string) => { + const element = document.createElement("div"); + element.id = id; + element.scrollIntoView = vi.fn(); + document.body.append(element); + return element; + }; + + it("scrolls to the item a finding belongs to, not the section header", () => { + const section = stubElement("sidebar-experience"); + const item = stubElement("resume-item-exp-1"); + + resumeState.data = makeResume((data) => { + data.sections.experience.items = [experienceItem({ period: "nonsense" })]; + }); + renderPanel(); + + fireEvent.click(screen.getByRole("button", { name: /Experience/ })); + + expect(item.scrollIntoView).toHaveBeenCalled(); + expect(section.scrollIntoView).not.toHaveBeenCalled(); + }); + + it("sends two findings in the same section to different items", () => { + const first = stubElement("resume-item-exp-1"); + const second = stubElement("resume-item-exp-2"); + + resumeState.data = makeResume((data) => { + data.sections.experience.items = [ + experienceItem({ id: "exp-1", period: "nonsense" }), + experienceItem({ id: "exp-2", period: "gibberish" }), + ]; + }); + renderPanel(); + + const buttons = screen.getAllByRole("button", { name: /Experience · Item/ }); + fireEvent.click(buttons[0] as HTMLElement); + fireEvent.click(buttons[1] as HTMLElement); + + expect(first.scrollIntoView).toHaveBeenCalledOnce(); + expect(second.scrollIntoView).toHaveBeenCalledOnce(); + }); + + it("falls back to the section header when the item is not mounted", () => { + const section = stubElement("sidebar-experience"); + + resumeState.data = makeResume((data) => { + data.sections.experience.items = [experienceItem({ period: "nonsense" })]; + }); + renderPanel(); + + fireEvent.click(screen.getByRole("button", { name: /Experience/ })); + + expect(section.scrollIntoView).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/ats-check.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/ats-check.tsx new file mode 100644 index 000000000..32fd71a17 --- /dev/null +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/ats-check.tsx @@ -0,0 +1,170 @@ +import type { AtsFinding, AtsSeverity } from "@reactive-resume/resume/ats"; +import { t } from "@lingui/core/macro"; +import { Plural, Trans } from "@lingui/react/macro"; +import { ArrowRightIcon, CheckCircleIcon } from "@phosphor-icons/react"; +import { useCallback, useMemo } from "react"; +import { match } from "ts-pattern"; +import { lintResumeForAts } from "@reactive-resume/resume/ats"; +import { Badge } from "@reactive-resume/ui/components/badge"; +import { Button } from "@reactive-resume/ui/components/button"; +import { cn } from "@reactive-resume/utils/style"; +import { useResumeData } from "@/features/resume/builder/draft"; +import { + atsFindingItemElementId, + getAtsFindingLocation, + getAtsFindingMessage, + getAtsFindingTarget, +} from "@/libs/resume/ats"; +import { useSectionStore } from "../../../-store/section"; +import { useBuilderSidebar } from "../../../-store/sidebar"; +import { SectionBase } from "../shared/section-base"; + +const SEVERITIES = ["error", "warning", "info"] as const; + +function severityDotClass(severity: AtsSeverity) { + return match(severity) + .with("error", () => "bg-rose-600") + .with("warning", () => "bg-amber-600") + .with("info", () => "bg-sky-600") + .exhaustive(); +} + +function severityLabel(severity: AtsSeverity) { + return match(severity) + .with("error", () => t`Error`) + .with("warning", () => t`Warning`) + .with("info", () => t`Note`) + .exhaustive(); +} + +type SeverityCountProps = { + severity: AtsSeverity; + count: number; +}; + +function SeverityCount({ severity, count }: SeverityCountProps) { + return ( + + + {match(severity) + .with("error", () => ) + .with("warning", () => ) + .with("info", () => ) + .exhaustive()} + + ); +} + +type AtsFindingRowProps = { + finding: AtsFinding; + onJump: (pointer: string) => void; +}; + +function AtsFindingRow({ finding, onJump }: AtsFindingRowProps) { + const message = getAtsFindingMessage(finding.code); + const location = getAtsFindingLocation(finding.pointer); + + return ( +
  • +
    + + +
    +

    {message.title}

    +

    {message.action}

    +
    + + + {severityLabel(finding.severity)} + +
    + + {location && ( + + )} +
  • + ); +} + +export function AtsCheckSectionBuilder() { + const data = useResumeData(); + const { toggleSidebar } = useBuilderSidebar(); + const setCollapsed = useSectionStore((state) => state.setCollapsed); + + const report = useMemo(() => (data ? lintResumeForAts(data) : null), [data]); + + const onJump = useCallback( + (pointer: string) => { + const target = getAtsFindingTarget(pointer, data); + if (!target) return; + + toggleSidebar(target.side, true); + setCollapsed(target.section, false); + + const item = target.itemId ? document.getElementById(atsFindingItemElementId(target.itemId)) : null; + const destination = item ?? document.getElementById(`sidebar-${target.section}`); + destination?.scrollIntoView({ block: "start", inline: "nearest", behavior: "smooth" }); + }, + [data, setCollapsed, toggleSidebar], + ); + + if (!report) return null; + + const { counts, findings, passedRules, totalRules } = report; + + return ( + +
    +
    +

    + + These checks run as you type and never leave your browser. They ask whether a machine can read your + resume, not whether it reads well. + +

    + +
    +

    + + {passedRules} of {totalRules} checks passed + +

    + +
    +
    +
    +
    + + {findings.length > 0 && ( +
    + {SEVERITIES.filter((severity) => counts[severity] > 0).map((severity) => ( + + ))} +
    + )} +
    + + {findings.length === 0 ? ( +
    + +

    + Every check passed. Nothing here should stop a parser from reading your resume. +

    +
    + ) : ( +
      + {findings.map((finding) => ( + + ))} +
    + )} +
    + + ); +} diff --git a/packages/resume/package.json b/packages/resume/package.json index 56583bd0b..123b99c5e 100644 --- a/packages/resume/package.json +++ b/packages/resume/package.json @@ -4,6 +4,7 @@ "type": "module", "private": true, "exports": { + "./ats": "./src/ats/index.ts", "./export-sections": "./src/export-sections.ts", "./icons": "./src/icons.ts", "./markdown": "./src/markdown.ts", diff --git a/packages/resume/src/ats/catalog.test.ts b/packages/resume/src/ats/catalog.test.ts new file mode 100644 index 000000000..4b942db76 --- /dev/null +++ b/packages/resume/src/ats/catalog.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { ATS_RULE_CATALOG_V1, ATS_RULE_CODES, atsRuleSeverity } from "./catalog"; + +describe("ATS_RULE_CATALOG_V1", () => { + it("has a stable set of codes", () => { + expect(ATS_RULE_CODES).toMatchInlineSnapshot(` + [ + "MISSING_NAME", + "MISSING_EMAIL", + "MALFORMED_EMAIL", + "MISSING_PHONE", + "MISSING_LOCATION", + "MALFORMED_URL", + "PICTURE_PRESENT", + "EMPTY_PERIOD", + "UNPARSEABLE_PERIOD", + "UNPARSEABLE_DATE", + "REVERSED_PERIOD", + "FUTURE_DATED_PERIOD", + "SECTION_MISSING_FROM_LAYOUT", + "EMPTY_RENDERED_SECTION", + "NO_VISIBLE_EXPERIENCE", + "MISSING_EXPERIENCE_DESCRIPTION", + "NON_STANDARD_SECTION_TITLE", + "MULTI_COLUMN_PROSE_SECTION", + "PROSE_SECTION_IN_SIDEBAR", + "SMALL_BODY_FONT", + "TIGHT_LINE_HEIGHT", + "TIGHT_PAGE_MARGINS", + ] + `); + }); + + it("gives every rule a severity, a meaning and an action", () => { + for (const code of ATS_RULE_CODES) { + const rule = ATS_RULE_CATALOG_V1[code]; + expect(["error", "warning", "info"]).toContain(rule.severity); + expect(rule.meaning.length).toBeGreaterThan(0); + expect(rule.action.length).toBeGreaterThan(0); + } + }); + + it("resolves severity by code", () => { + expect(atsRuleSeverity("MISSING_EMAIL")).toBe("error"); + expect(atsRuleSeverity("PICTURE_PRESENT")).toBe("info"); + }); +}); diff --git a/packages/resume/src/ats/catalog.ts b/packages/resume/src/ats/catalog.ts new file mode 100644 index 000000000..1295886f9 --- /dev/null +++ b/packages/resume/src/ats/catalog.ts @@ -0,0 +1,130 @@ +import type { AtsSeverity } from "./types"; + +type AtsRuleReference = { + severity: AtsSeverity; + meaning: string; + action: string; +}; + +export const ATS_RULE_CATALOG_V1 = { + MISSING_NAME: { + severity: "error", + meaning: "The resume has no name, so a parser has nothing to file the application under.", + action: "Fill in your full name under Basics.", + }, + MISSING_EMAIL: { + severity: "error", + meaning: "The resume has no email address, which is the field most parsers key a candidate record on.", + action: "Add an email address under Basics.", + }, + MALFORMED_EMAIL: { + severity: "error", + meaning: "The email address is not in a shape a parser will recognize.", + action: "Use a plain address such as name@example.com, with no surrounding text.", + }, + MISSING_PHONE: { + severity: "warning", + meaning: "The resume has no phone number, which some applicant systems require before submission.", + action: "Add a phone number under Basics.", + }, + MISSING_LOCATION: { + severity: "info", + meaning: "The resume has no location, which many systems use to match against a role's region.", + action: "Add at least a city and country under Basics.", + }, + MALFORMED_URL: { + severity: "warning", + meaning: "A link is missing its protocol or is not a valid URL, so it may be dropped or mis-parsed.", + action: "Write the full address including https://.", + }, + PICTURE_PRESENT: { + severity: "info", + meaning: "The resume includes a photo. Some parsers mishandle images, and some regions advise against them.", + action: "Hide the picture if you are applying where photos are not customary.", + }, + + EMPTY_PERIOD: { + severity: "warning", + meaning: "A dated entry has no period, so a parser cannot place it on your timeline.", + action: "Add a period such as 'Jan 2020 - Present'.", + }, + UNPARSEABLE_PERIOD: { + severity: "error", + meaning: "A period is written in a format most parsers cannot read.", + action: "Use a recognized form such as 'Jan 2020 - Mar 2022', '2020 - 2022', or '03/2020 - Present'.", + }, + UNPARSEABLE_DATE: { + severity: "warning", + meaning: "A single date is written in a format most parsers cannot read.", + action: "Use a recognized form such as 'March 2022' or '2022'.", + }, + REVERSED_PERIOD: { + severity: "error", + meaning: "A period ends before it starts.", + action: "Swap the start and end dates.", + }, + FUTURE_DATED_PERIOD: { + severity: "warning", + meaning: "A period starts in the future, which reads as a typo to a reviewer and to a parser.", + action: "Correct the year, or use 'Present' for ongoing work.", + }, + + SECTION_MISSING_FROM_LAYOUT: { + severity: "error", + meaning: "A section has visible content but is not placed on any page, so it never renders or exports.", + action: "Place the section on a page from the Layout panel, or hide it if it is intentionally unused.", + }, + EMPTY_RENDERED_SECTION: { + severity: "warning", + meaning: "A section is placed on a page but has no visible items, so it renders as a bare heading.", + action: "Add an item to the section, or hide it.", + }, + NO_VISIBLE_EXPERIENCE: { + severity: "warning", + meaning: "The resume shows no work experience, which most screening systems rank on.", + action: "Add at least one experience entry, or use projects and volunteer work to show equivalent history.", + }, + MISSING_EXPERIENCE_DESCRIPTION: { + severity: "warning", + meaning: "An experience entry has no description, so it contributes no keywords for matching.", + action: "Describe what you did in that role.", + }, + NON_STANDARD_SECTION_TITLE: { + severity: "info", + meaning: "A section heading is not one of the conventional names parsers look for when segmenting a resume.", + action: "Prefer a conventional heading such as 'Work Experience' or 'Education'.", + }, + + MULTI_COLUMN_PROSE_SECTION: { + severity: "warning", + meaning: "A prose-heavy section is split across columns, which commonly scrambles extracted reading order.", + action: "Set the section to a single column.", + }, + PROSE_SECTION_IN_SIDEBAR: { + severity: "warning", + meaning: "A prose-heavy section sits in the narrow sidebar, where extraction interleaves it with the main column.", + action: "Move the section into the main column and keep the sidebar for short lists.", + }, + + SMALL_BODY_FONT: { + severity: "warning", + meaning: "The body font is small enough that scanned or re-rendered copies lose accuracy.", + action: "Use a body size of at least 9pt.", + }, + TIGHT_LINE_HEIGHT: { + severity: "warning", + meaning: "Lines are packed tightly enough that extraction can merge them into a single run of text.", + action: "Use a line height of at least 1.15.", + }, + TIGHT_PAGE_MARGINS: { + severity: "warning", + meaning: "Page margins are narrow enough that content can fall outside the reliably extracted area.", + action: "Increase the page margins.", + }, +} as const satisfies Readonly>; + +export type AtsRuleCode = keyof typeof ATS_RULE_CATALOG_V1; + +export const ATS_RULE_CODES = Object.keys(ATS_RULE_CATALOG_V1) as readonly AtsRuleCode[]; + +export const atsRuleSeverity = (code: AtsRuleCode): AtsSeverity => ATS_RULE_CATALOG_V1[code].severity; diff --git a/packages/resume/src/ats/index.ts b/packages/resume/src/ats/index.ts new file mode 100644 index 000000000..b69742b3f --- /dev/null +++ b/packages/resume/src/ats/index.ts @@ -0,0 +1,55 @@ +import type { ResumeData } from "@reactive-resume/schema/resume/data"; +import type { AtsRuleCode } from "./catalog"; +import type { RuleContext } from "./rules"; +import type { AtsFinding, AtsReport, AtsSeverity } from "./types"; +import { ATS_RULE_CODES } from "./catalog"; +import { ATS_RULES } from "./rules"; +import { walkSections } from "./walk"; + +export type AtsLintOptions = { + now?: Date; +}; + +const SEVERITY_ORDER: Readonly> = { error: 0, warning: 1, info: 2 }; + +function compareFindings(a: AtsFinding, b: AtsFinding): number { + const bySeverity = SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]; + if (bySeverity !== 0) return bySeverity; + if (a.pointer !== b.pointer) return a.pointer < b.pointer ? -1 : 1; + if (a.code === b.code) return 0; + return a.code < b.code ? -1 : 1; +} + +export function lintResumeForAts(data: ResumeData, options: AtsLintOptions = {}): AtsReport { + const context: RuleContext = { + data, + sections: walkSections(data), + locale: data.metadata.page.locale.trim() || "en-US", + now: options.now ?? new Date(), + }; + + const findings = ATS_RULES.flatMap((rule) => rule(context)).sort(compareFindings); + + const counts: Record = { error: 0, warning: 0, info: 0 }; + const fired = new Set(); + + for (const item of findings) { + counts[item.severity] += 1; + fired.add(item.code); + } + + return { + findings, + counts, + totalRules: ATS_RULE_CODES.length, + passedRules: ATS_RULE_CODES.length - fired.size, + }; +} + +export type { AtsRuleCode } from "./catalog"; +export type { ParsedPeriod, PeriodEndpoint } from "./period"; +export type { AtsFinding, AtsFindingParams, AtsReport, AtsSeverity } from "./types"; +export type { SectionPlacement, WalkedItem, WalkedSection } from "./walk"; +export { ATS_RULE_CATALOG_V1, ATS_RULE_CODES, atsRuleSeverity } from "./catalog"; +export { isFutureEndpoint, isReversedPeriod, parsePeriod, parseSingleDate } from "./period"; +export { escapePointerToken, isRenderedSection, walkSections } from "./walk"; diff --git a/packages/resume/src/ats/period.test.ts b/packages/resume/src/ats/period.test.ts new file mode 100644 index 000000000..c671bb18f --- /dev/null +++ b/packages/resume/src/ats/period.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "vitest"; +import { isFutureEndpoint, isReversedPeriod, ONGOING_TOKENS_BY_LANGUAGE, parsePeriod, parseSingleDate } from "./period"; + +const NOW = new Date("2024-06-15T00:00:00Z"); + +describe("parsePeriod", () => { + it.each([ + ["Jan 2020 - Present", { start: { year: 2020, month: 1 }, ongoing: true }], + ["January 2020 – March 2022", { start: { year: 2020, month: 1 }, end: { year: 2022, month: 3 }, ongoing: false }], + ["Sept 2019 - Dec 2019", { start: { year: 2019, month: 9 }, end: { year: 2019, month: 12 }, ongoing: false }], + ["2020 - 2022", { start: { year: 2020 }, end: { year: 2022 }, ongoing: false }], + ["2020-2022", { start: { year: 2020 }, end: { year: 2022 }, ongoing: false }], + ["2020–2022", { start: { year: 2020 }, end: { year: 2022 }, ongoing: false }], + ["03/2020 - 06/2021", { start: { year: 2020, month: 3 }, end: { year: 2021, month: 6 }, ongoing: false }], + ["03/2020-06/2021", { start: { year: 2020, month: 3 }, end: { year: 2021, month: 6 }, ongoing: false }], + ["2020-03 - 2021-06", { start: { year: 2020, month: 3 }, end: { year: 2021, month: 6 }, ongoing: false }], + ["Mar 2020 to Present", { start: { year: 2020, month: 3 }, ongoing: true }], + ["Jan 2020 until now", { start: { year: 2020, month: 1 }, ongoing: true }], + ["Fall 2019 - Spring 2021", { start: { year: 2019, month: 9 }, end: { year: 2021, month: 3 }, ongoing: false }], + ["15/03/2020", { start: { year: 2020, month: 3 }, ongoing: false }], + ])("reads %s", (input, expected) => { + expect(parsePeriod(input)).toEqual(expected); + }); + + it("treats an ISO year-month as one endpoint rather than a range", () => { + expect(parsePeriod("2020-03")).toEqual({ start: { year: 2020, month: 3 }, ongoing: false }); + }); + + it.each(["", " ", "Summer of love", "sometime in 2020", "20-22", "Jan 2020 - Feb", "Present - 2020", "2020 -"])( + "rejects %j", + (input) => { + expect(parsePeriod(input)).toBeNull(); + }, + ); + + it.each(["Present", "Current", "Now", "Ongoing"])("rejects %j on its own, with no start date", (input) => { + expect(parsePeriod(input)).toBeNull(); + }); + + it.each([ + ["2020 - heute", { year: 2020 }], + ["Jan 2020 - présent", { year: 2020, month: 1 }], + ["2020 - 現在", { year: 2020 }], + ["2020 - en cours", { year: 2020 }], + ["Mar 2021 - actualidad", { year: 2021, month: 3 }], + ])("reads %s as ongoing without knowing the word", (input, start) => { + expect(parsePeriod(input)).toEqual({ start, ongoing: true }); + }); + + it("reads localized ongoing tokens followed by ordinary punctuation", () => { + expect(parsePeriod("2020 - heute.", "de-DE")).toEqual({ start: { year: 2020 }, ongoing: true }); + expect(parsePeriod("2020 - 現在。", "ja-JP")).toEqual({ start: { year: 2020 }, ongoing: true }); + }); + + it("does not mistake an incomplete month ending for an ongoing marker", () => { + expect(parsePeriod("Jan 2020 - Feb")).toBeNull(); + }); + + it.each(["2020 - unknown", "2020 - later", "2020 - tbd", "2020 - ???", "2020 - see below"])( + "rejects %j rather than reading the ending as ongoing", + (input) => { + expect(parsePeriod(input)).toBeNull(); + }, + ); + + it("does not treat a long phrase as an ongoing marker", () => { + expect(parsePeriod("2020 - whenever we finally got around to shipping it")).toBeNull(); + }); + + it("reads month names in the resume's own locale", () => { + expect(parsePeriod("janvier 2020 - mars 2022", "fr-FR")).toEqual({ + start: { year: 2020, month: 1 }, + end: { year: 2022, month: 3 }, + ongoing: false, + }); + }); + + it("still reads English month names under a non-English locale", () => { + expect(parsePeriod("Jan 2020 - Mar 2022", "fr-FR")).toEqual({ + start: { year: 2020, month: 1 }, + end: { year: 2022, month: 3 }, + ongoing: false, + }); + }); + + it("falls back to English when the locale tag is unusable", () => { + expect(parsePeriod("Jan 2020", "not a locale")).toEqual({ start: { year: 2020, month: 1 }, ongoing: false }); + }); +}); + +describe("parseSingleDate", () => { + it.each([ + ["March 2022", { year: 2022, month: 3 }], + ["2022", { year: 2022 }], + ["2022-11", { year: 2022, month: 11 }], + ])("reads %s", (input, expected) => { + expect(parseSingleDate(input)).toEqual(expected); + }); + + it.each(["", "Present", "whenever", "13/2020"])("rejects %j", (input) => { + expect(parseSingleDate(input)).toBeNull(); + }); +}); + +describe("isReversedPeriod", () => { + it("flags a period that runs backwards", () => { + expect(isReversedPeriod({ year: 2022 }, { year: 2020 })).toBe(true); + }); + + it("reads a missing end month as the end of its year", () => { + expect(isReversedPeriod({ year: 2020, month: 12 }, { year: 2020 })).toBe(false); + }); + + it("accepts a period inside a single year", () => { + expect(isReversedPeriod({ year: 2020, month: 3 }, { year: 2020, month: 9 })).toBe(false); + }); +}); + +describe("isFutureEndpoint", () => { + it("flags a start after the current month", () => { + expect(isFutureEndpoint({ year: 2025, month: 1 }, NOW)).toBe(true); + }); + + it("accepts the current month", () => { + expect(isFutureEndpoint({ year: 2024, month: 6 }, NOW)).toBe(false); + }); + + it("reads a missing month as the start of its year", () => { + expect(isFutureEndpoint({ year: 2024 }, NOW)).toBe(false); + }); +}); + +describe("ONGOING_TOKENS_BY_LANGUAGE", () => { + it("lists at least one token for every language it covers", () => { + for (const [language, tokens] of Object.entries(ONGOING_TOKENS_BY_LANGUAGE)) { + expect(tokens.length, language).toBeGreaterThan(0); + } + }); + + it("stores every token lowercased, since lookups normalize that way", () => { + for (const [language, tokens] of Object.entries(ONGOING_TOKENS_BY_LANGUAGE)) { + for (const token of tokens) { + expect(token, `${language}: ${token}`).toBe(token.toLowerCase()); + expect(token.trim(), language).toBe(token); + } + } + }); + + it("reads every listed token as an open-ended period", () => { + for (const [language, tokens] of Object.entries(ONGOING_TOKENS_BY_LANGUAGE)) { + for (const token of tokens) { + expect(parsePeriod(`2020 - ${token}`, language), `${language}: ${token}`).toEqual({ + start: { year: 2020 }, + ongoing: true, + }); + } + } + }); +}); diff --git a/packages/resume/src/ats/period.ts b/packages/resume/src/ats/period.ts new file mode 100644 index 000000000..6cb1960a1 --- /dev/null +++ b/packages/resume/src/ats/period.ts @@ -0,0 +1,236 @@ +export type PeriodEndpoint = { year: number; month?: number }; + +export type ParsedPeriod = { + start?: PeriodEndpoint; + end?: PeriodEndpoint; + ongoing: boolean; +}; + +type EndpointResult = PeriodEndpoint | "ongoing"; + +const MIN_YEAR = 1900; +const MAX_YEAR = 2100; + +export const ONGOING_TOKENS_BY_LANGUAGE: Readonly> = { + af: ["hede", "tans", "huidig"], + ar: ["الآن", "حتى الآن", "الحاضر"], + bg: ["настояще", "сега", "днес"], + bn: ["বর্তমান"], + ca: ["present", "actual", "actualitat"], + cs: ["současnost", "současný", "dosud", "nyní"], + da: ["nuværende", "i dag"], + de: ["heute", "aktuell", "laufend", "gegenwärtig", "jetzt"], + el: ["παρόν", "σήμερα", "τώρα"], + en: ["present", "current", "currently", "now", "ongoing", "today", "date", "to date"], + es: ["presente", "actual", "actualidad", "actualmente", "hoy", "hasta la fecha"], + fa: ["اکنون", "تاکنون", "حال حاضر"], + fi: ["nykyinen", "nykyään", "tähän asti"], + fr: ["présent", "actuel", "actuellement", "aujourd'hui", "en cours", "à ce jour"], + he: ["כיום", "הווה", "היום"], + hi: ["वर्तमान", "अब तक"], + hu: ["jelen", "jelenleg", "napjainkig"], + id: ["sekarang", "saat ini", "kini"], + it: ["presente", "attuale", "attualmente", "oggi", "in corso"], + ja: ["現在", "現在に至る"], + kn: ["ಪ್ರಸ್ತುತ"], + ko: ["현재", "지금", "재직중"], + lt: ["dabar", "iki dabar", "šiuo metu"], + lv: ["pašlaik", "tagad", "līdz šim"], + ml: ["നിലവിൽ"], + mr: ["सध्या", "वर्तमान"], + ms: ["sekarang", "kini"], + ne: ["हाल", "वर्तमान"], + nl: ["heden", "huidig", "nu"], + no: ["nåværende", "i dag"], + pl: ["obecnie", "obecny", "teraz", "nadal"], + pt: ["presente", "atual", "atualmente", "hoje", "até o momento"], + ro: ["prezent", "în prezent", "azi"], + ru: ["настоящее", "настоящее время", "по настоящее время", "сейчас"], + sk: ["súčasnosť", "súčasný", "doteraz", "teraz"], + sl: ["sedanjost", "trenutno", "danes"], + sq: ["aktual", "aktualisht", "tani"], + sr: ["sadašnjost", "trenutno", "danas"], + sv: ["nuvarande", "pågående", "idag"], + ta: ["தற்போது"], + te: ["ప్రస్తుతం"], + th: ["ปัจจุบัน"], + tr: ["halen", "hâlen", "günümüz", "şu an", "devam ediyor"], + uk: ["теперішній час", "зараз", "дотепер", "нині"], + vi: ["hiện tại", "đến nay"], + zh: ["至今", "现在", "現在", "迄今"], + zu: ["manje", "okwamanje"], +}; + +const PRESENT_TOKENS = new Set(Object.values(ONGOING_TOKENS_BY_LANGUAGE).flat()); + +const SEASON_MONTHS: Readonly> = { + spring: 3, + summer: 6, + fall: 9, + autumn: 9, + winter: 12, +}; + +const EXTRA_MONTH_ALIASES: Readonly> = { sept: 9 }; + +const DASH_CHARS = new Set(["-", "–", "—", "~"]); + +const SPACED_SEPARATOR = /\s(?:[-–—~]+|to|through|until)\s/; + +const monthLookupCache = new Map>(); + +const normalizeToken = (value: string) => value.trim().toLowerCase().replace(/\.$/, ""); + +const normalizeWhitespace = (value: string) => value.replace(/\s+/g, " ").trim().toLowerCase(); + +function buildMonthLookup(locale: string): ReadonlyMap { + const lookup = new Map(); + + for (const tag of new Set(["en-US", locale])) { + for (const month of ["long", "short"] as const) { + let format: Intl.DateTimeFormat; + + try { + format = new Intl.DateTimeFormat(tag, { month, timeZone: "UTC" }); + } catch { + continue; + } + + for (let index = 1; index <= 12; index++) { + const name = normalizeToken(format.format(Date.UTC(2000, index - 1, 1))); + if (name) lookup.set(name, index); + } + } + } + + for (const [alias, month] of Object.entries(EXTRA_MONTH_ALIASES)) { + if (!lookup.has(alias)) lookup.set(alias, month); + } + + return lookup; +} + +function getMonthLookup(locale: string): ReadonlyMap { + const cached = monthLookupCache.get(locale); + if (cached) return cached; + + const lookup = buildMonthLookup(locale); + monthLookupCache.set(locale, lookup); + return lookup; +} + +function toEndpoint(year: number, month?: number): PeriodEndpoint | null { + if (!Number.isInteger(year) || year < MIN_YEAR || year > MAX_YEAR) return null; + if (month === undefined) return { year }; + if (!Number.isInteger(month) || month < 1 || month > 12) return null; + return { year, month }; +} + +function parseEndpoint(raw: string, months: ReadonlyMap): EndpointResult | null { + const value = normalizeWhitespace(raw); + if (!value) return null; + if (PRESENT_TOKENS.has(value.replace(/\p{P}+$/u, ""))) return "ongoing"; + + const yearOnly = /^(\d{4})$/.exec(value); + if (yearOnly?.[1]) return toEndpoint(Number(yearOnly[1])); + + const iso = /^(\d{4})-(\d{1,2})(?:-(\d{1,2}))?$/.exec(value); + if (iso?.[1] && iso[2]) return toEndpoint(Number(iso[1]), Number(iso[2])); + + const monthYear = /^(\d{1,2})[/.](\d{4})$/.exec(value); + if (monthYear?.[1] && monthYear[2]) return toEndpoint(Number(monthYear[2]), Number(monthYear[1])); + + const dayMonthYear = /^(\d{1,2})[/.](\d{1,2})[/.](\d{4})$/.exec(value); + if (dayMonthYear?.[1] && dayMonthYear[2] && dayMonthYear[3]) { + const year = Number(dayMonthYear[3]); + const first = Number(dayMonthYear[1]); + const second = Number(dayMonthYear[2]); + return toEndpoint(year, first) ?? toEndpoint(year, second); + } + + const named = /^(\p{L}+\.?)\s+(?:\d{1,2},?\s+)?(\d{4})$/u.exec(value); + if (named?.[1] && named[2]) { + const token = normalizeToken(named[1]); + const month = months.get(token) ?? SEASON_MONTHS[token]; + return month === undefined ? null : toEndpoint(Number(named[2]), month); + } + + return null; +} + +function toPeriod(start: EndpointResult | undefined, end: EndpointResult | undefined): ParsedPeriod { + return { + ...(start && start !== "ongoing" ? { start } : {}), + ...(end && end !== "ongoing" ? { end } : {}), + ongoing: start === "ongoing" || end === "ongoing", + }; +} + +function splitOnce(value: string, separator: RegExp): [string, string] | null { + const match = separator.exec(value); + if (!match) return null; + + const left = value.slice(0, match.index); + const right = value.slice(match.index + match[0].length); + if (!left.trim() || !right.trim()) return null; + + return [left, right]; +} + +function* dashSplits(value: string): Generator<[string, string]> { + for (let index = 0; index < value.length; index++) { + const char = value[index]; + if (!char || !DASH_CHARS.has(char)) continue; + + const left = value.slice(0, index); + const right = value.slice(index + 1); + if (left.trim() && right.trim()) yield [left, right]; + } +} + +function parseSplit(parts: [string, string], months: ReadonlyMap): ParsedPeriod | null { + const start = parseEndpoint(parts[0], months); + if (!start || start === "ongoing") return null; + + const end = parseEndpoint(parts[1], months); + return end ? toPeriod(start, end) : null; +} + +export function parsePeriod(value: string, locale = "en-US"): ParsedPeriod | null { + const normalized = normalizeWhitespace(value); + if (!normalized) return null; + + const months = getMonthLookup(locale); + + const single = parseEndpoint(normalized, months); + if (single) return single === "ongoing" ? null : toPeriod(single, undefined); + + const spaced = splitOnce(normalized, SPACED_SEPARATOR); + if (spaced) { + const period = parseSplit(spaced, months); + if (period) return period; + } + + for (const candidate of dashSplits(normalized)) { + const period = parseSplit(candidate, months); + if (period) return period; + } + + return null; +} + +export function parseSingleDate(value: string, locale = "en-US"): PeriodEndpoint | null { + const normalized = normalizeWhitespace(value); + if (!normalized) return null; + + const result = parseEndpoint(normalized, getMonthLookup(locale)); + return result && result !== "ongoing" ? result : null; +} + +export function isReversedPeriod(start: PeriodEndpoint, end: PeriodEndpoint): boolean { + return start.year * 12 + (start.month ?? 1) > end.year * 12 + (end.month ?? 12); +} + +export function isFutureEndpoint(endpoint: PeriodEndpoint, now: Date): boolean { + return endpoint.year * 12 + (endpoint.month ?? 1) > now.getUTCFullYear() * 12 + (now.getUTCMonth() + 1); +} diff --git a/packages/resume/src/ats/rules.test.ts b/packages/resume/src/ats/rules.test.ts new file mode 100644 index 000000000..d85e68089 --- /dev/null +++ b/packages/resume/src/ats/rules.test.ts @@ -0,0 +1,405 @@ +import type { ExperienceItem, ResumeData } from "@reactive-resume/schema/resume/data"; +import { describe, expect, it } from "vitest"; +import { defaultResumeData } from "@reactive-resume/schema/resume/default"; +import { sampleResumeData } from "@reactive-resume/schema/resume/sample"; +import { lintResumeForAts } from "./index"; + +const NOW = new Date("2024-06-15T00:00:00Z"); + +const experienceItem = (overrides: Partial = {}): ExperienceItem => ({ + id: "exp-1", + hidden: false, + company: "Analytical Engines", + position: "Engineer", + location: "London", + period: "Jan 2020 - Present", + website: { url: "", label: "", inlineLink: false }, + description: "

    Designed and shipped the difference engine.

    ", + roles: [], + ...overrides, +}); + +function makeResume(mutate: (data: ResumeData) => void = () => undefined): ResumeData { + const data = structuredClone(defaultResumeData); + + data.basics.name = "Ada Lovelace"; + data.basics.email = "ada@example.com"; + data.basics.phone = "+44 20 7946 0100"; + data.basics.location = "London, UK"; + data.sections.experience.items = [experienceItem()]; + data.metadata.layout.pages = [{ fullWidth: false, main: ["experience"], sidebar: [] }]; + + mutate(data); + return data; +} + +const lint = (data: ResumeData) => lintResumeForAts(data, { now: NOW }); +const codesOf = (data: ResumeData) => lint(data).findings.map((item) => item.code); + +describe("lintResumeForAts", () => { + it("reports nothing on a well-formed resume", () => { + expect(lint(makeResume()).findings).toEqual([]); + }); + + it("counts every rule as passed when nothing fires", () => { + const report = lint(makeResume()); + expect(report.passedRules).toBe(report.totalRules); + expect(report.counts).toEqual({ error: 0, warning: 0, info: 0 }); + }); + + it("flags the gaps in a blank resume", () => { + const codes = codesOf(defaultResumeData); + expect(codes).toContain("MISSING_NAME"); + expect(codes).toContain("MISSING_EMAIL"); + expect(codes).toContain("MISSING_PHONE"); + expect(codes).toContain("NO_VISIBLE_EXPERIENCE"); + expect(codes).toContain("EMPTY_RENDERED_SECTION"); + }); + + it("sorts findings by severity", () => { + const report = lint(defaultResumeData); + const severities = report.findings.map((item) => item.severity); + expect(severities).toEqual([...severities].sort((a, b) => (a === b ? 0 : a === "error" ? -1 : 1))); + }); +}); + +describe("contact rules", () => { + it("flags a malformed email instead of a missing one", () => { + const codes = codesOf(makeResume((data) => (data.basics.email = "ada at example dot com"))); + expect(codes).toContain("MALFORMED_EMAIL"); + expect(codes).not.toContain("MISSING_EMAIL"); + }); + + it("flags a link with no protocol", () => { + const report = lint(makeResume((data) => (data.basics.website.url = "example.com/ada"))); + expect(report.findings).toContainEqual({ + code: "MALFORMED_URL", + severity: "warning", + pointer: "/basics/website/url", + params: { value: "example.com/ada" }, + }); + }); + + it("accepts mailto and tel links in custom fields", () => { + const data = makeResume((resume) => { + resume.basics.customFields = [ + { id: "a", icon: "", text: "Mail", link: "mailto:ada@example.com" }, + { id: "b", icon: "", text: "Phone", link: "tel:+442079460100" }, + ]; + }); + expect(codesOf(data)).not.toContain("MALFORMED_URL"); + }); + + it("notes a visible picture", () => { + expect(codesOf(makeResume((data) => (data.picture.url = "/uploads/ada.png")))).toContain("PICTURE_PRESENT"); + }); + + it("ignores a hidden picture", () => { + const data = makeResume((resume) => { + resume.picture.url = "/uploads/ada.png"; + resume.picture.hidden = true; + }); + expect(codesOf(data)).not.toContain("PICTURE_PRESENT"); + }); +}); + +describe("date rules", () => { + it("flags an unreadable period at the offending item", () => { + const data = makeResume((resume) => { + resume.sections.experience.items = [experienceItem({ period: "a while back" })]; + }); + + expect(lint(data).findings).toContainEqual({ + code: "UNPARSEABLE_PERIOD", + severity: "error", + pointer: "/sections/experience/items/0/period", + params: { value: "a while back" }, + }); + }); + + it("requires a period on experience", () => { + const data = makeResume((resume) => { + resume.sections.experience.items = [experienceItem({ period: "" })]; + }); + expect(codesOf(data)).toContain("EMPTY_PERIOD"); + }); + + it("does not require a period on projects", () => { + const data = makeResume((resume) => { + resume.sections.projects.items = [ + { + id: "p1", + hidden: false, + name: "Difference Engine", + period: "", + website: { url: "", label: "", inlineLink: false }, + description: "

    A machine.

    ", + }, + ]; + resume.metadata.layout.pages = [{ fullWidth: false, main: ["experience", "projects"], sidebar: [] }]; + }); + + expect(codesOf(data)).not.toContain("EMPTY_PERIOD"); + }); + + it("flags an open-ended period with no start date", () => { + const data = makeResume((resume) => { + resume.sections.experience.items = [experienceItem({ period: "Present" })]; + }); + expect(codesOf(data)).toContain("UNPARSEABLE_PERIOD"); + }); + + it("accepts a localized open-ended period", () => { + const data = makeResume((resume) => { + resume.metadata.page.locale = "de-DE"; + resume.sections.experience.items = [experienceItem({ period: "Jan 2020 - heute" })]; + }); + expect(codesOf(data)).not.toContain("UNPARSEABLE_PERIOD"); + }); + + it("flags a period that runs backwards", () => { + const data = makeResume((resume) => { + resume.sections.experience.items = [experienceItem({ period: "Mar 2022 - Jan 2020" })]; + }); + expect(codesOf(data)).toContain("REVERSED_PERIOD"); + }); + + it("flags a period starting in the future", () => { + const data = makeResume((resume) => { + resume.sections.experience.items = [experienceItem({ period: "Jan 2030 - Present" })]; + }); + expect(codesOf(data)).toContain("FUTURE_DATED_PERIOD"); + }); + + it("checks periods on nested roles", () => { + const data = makeResume((resume) => { + resume.sections.experience.items = [ + experienceItem({ + roles: [{ id: "r1", position: "Junior Engineer", period: "whenever", description: "

    Work.

    " }], + }), + ]; + }); + + expect(lint(data).findings.map((item) => item.pointer)).toContain("/sections/experience/items/0/roles/0/period"); + }); + + it("flags an unreadable single date", () => { + const data = makeResume((resume) => { + resume.sections.awards.items = [ + { + id: "a1", + hidden: false, + title: "Turing Award", + awarder: "ACM", + date: "some time ago", + website: { url: "", label: "", inlineLink: false }, + description: "", + }, + ]; + resume.metadata.layout.pages = [{ fullWidth: false, main: ["experience", "awards"], sidebar: [] }]; + }); + + expect(codesOf(data)).toContain("UNPARSEABLE_DATE"); + }); + + it("ignores hidden items", () => { + const data = makeResume((resume) => { + resume.sections.experience.items = [ + experienceItem(), + experienceItem({ id: "exp-2", period: "???", hidden: true }), + ]; + }); + expect(codesOf(data)).not.toContain("UNPARSEABLE_PERIOD"); + }); +}); + +describe("structure rules", () => { + it("flags content that is never placed on a page", () => { + const data = makeResume((resume) => { + resume.sections.education.items = [ + { + id: "e1", + hidden: false, + school: "University of London", + degree: "BSc", + area: "Mathematics", + grade: "", + location: "London", + period: "2016 - 2019", + website: { url: "", label: "", inlineLink: false }, + description: "", + }, + ]; + }); + + expect(lint(data).findings).toContainEqual({ + code: "SECTION_MISSING_FROM_LAYOUT", + severity: "error", + pointer: "/sections/education", + params: { section: "education" }, + }); + }); + + it("flags a placed section with no items", () => { + const data = makeResume((resume) => { + resume.metadata.layout.pages = [{ fullWidth: false, main: ["experience", "skills"], sidebar: [] }]; + }); + expect(codesOf(data)).toContain("EMPTY_RENDERED_SECTION"); + }); + + it("flags an experience entry with no narrative", () => { + const data = makeResume((resume) => { + resume.sections.experience.items = [experienceItem({ description: "

    " })]; + }); + expect(codesOf(data)).toContain("MISSING_EXPERIENCE_DESCRIPTION"); + }); + + it("accepts an experience entry whose narrative lives on its roles", () => { + const data = makeResume((resume) => { + resume.sections.experience.items = [ + experienceItem({ + description: "", + roles: [{ id: "r1", position: "Engineer", period: "2020 - 2022", description: "

    Shipped it.

    " }], + }), + ]; + }); + expect(codesOf(data)).not.toContain("MISSING_EXPERIENCE_DESCRIPTION"); + }); + + it("counts a custom section of type experience as experience", () => { + const data = makeResume((resume) => { + resume.sections.experience.items = []; + resume.customSections = [ + { + id: "custom-exp", + type: "experience", + title: "Work Experience", + icon: "briefcase", + columns: 1, + hidden: false, + keepTogether: false, + startOnNewPage: false, + items: [experienceItem()], + }, + ]; + resume.metadata.layout.pages = [{ fullWidth: false, main: ["custom-exp"], sidebar: [] }]; + }); + + expect(codesOf(data)).not.toContain("NO_VISIBLE_EXPERIENCE"); + }); +}); + +describe("cover letter sections", () => { + it("exempts cover letters from resume rules", () => { + const data = makeResume((resume) => { + resume.customSections = [ + { + id: "cover", + type: "cover-letter", + title: "Cover Letter", + icon: "envelope-simple", + columns: 1, + hidden: false, + keepTogether: false, + startOnNewPage: false, + items: [{ id: "c1", hidden: false, recipient: "

    Hiring Manager

    ", content: "

    Dear team,

    " }], + }, + ]; + resume.metadata.layout.pages = [{ fullWidth: false, main: ["experience", "cover"], sidebar: [] }]; + }); + + expect(lint(data).findings.filter((item) => item.pointer.startsWith("/customSections"))).toEqual([]); + }); +}); + +describe("layout rules", () => { + it("flags a prose section split into columns", () => { + const data = makeResume((resume) => (resume.sections.experience.columns = 2)); + expect(codesOf(data)).toContain("MULTI_COLUMN_PROSE_SECTION"); + }); + + it("flags a prose section parked in the sidebar", () => { + const data = makeResume((resume) => { + resume.metadata.layout.pages = [{ fullWidth: false, main: [], sidebar: ["experience"] }]; + }); + expect(codesOf(data)).toContain("PROSE_SECTION_IN_SIDEBAR"); + }); + + it("treats a full-width page's sidebar as the main column", () => { + const data = makeResume((resume) => { + resume.metadata.layout.pages = [{ fullWidth: true, main: [], sidebar: ["experience"] }]; + }); + expect(codesOf(data)).not.toContain("PROSE_SECTION_IN_SIDEBAR"); + }); + + it("leaves short-list sections in the sidebar alone", () => { + const data = makeResume((resume) => { + resume.sections.skills.items = [ + { + id: "s1", + hidden: false, + icon: "", + iconColor: "", + name: "Mathematics", + proficiency: "", + level: 0, + keywords: [], + }, + ]; + resume.metadata.layout.pages = [{ fullWidth: false, main: ["experience"], sidebar: ["skills"] }]; + }); + + expect(codesOf(data)).not.toContain("PROSE_SECTION_IN_SIDEBAR"); + }); +}); + +describe("title rules", () => { + it("flags an unconventional heading", () => { + const data = makeResume((resume) => (resume.sections.experience.title = "Where I've Been")); + expect(codesOf(data)).toContain("NON_STANDARD_SECTION_TITLE"); + }); + + it("accepts a conventional heading regardless of case", () => { + const data = makeResume((resume) => (resume.sections.experience.title = "Work Experience")); + expect(codesOf(data)).not.toContain("NON_STANDARD_SECTION_TITLE"); + }); + + it("stays quiet on a localized resume", () => { + const data = makeResume((resume) => { + resume.sections.experience.title = "Berufserfahrung"; + resume.metadata.page.locale = "de-DE"; + }); + expect(codesOf(data)).not.toContain("NON_STANDARD_SECTION_TITLE"); + }); +}); + +describe("typography rules", () => { + it("flags a small body font", () => { + expect(codesOf(makeResume((data) => (data.metadata.typography.body.fontSize = 8)))).toContain("SMALL_BODY_FONT"); + }); + + it("flags a tight line height", () => { + expect(codesOf(makeResume((data) => (data.metadata.typography.body.lineHeight = 1)))).toContain( + "TIGHT_LINE_HEIGHT", + ); + }); + + it("flags each tight margin axis", () => { + const data = makeResume((resume) => { + resume.metadata.page.marginX = 4; + resume.metadata.page.marginY = 4; + }); + + expect( + lint(data) + .findings.filter((item) => item.code === "TIGHT_PAGE_MARGINS") + .map((item) => item.pointer), + ).toEqual(["/metadata/page/marginX", "/metadata/page/marginY"]); + }); +}); + +describe("sample resume", () => { + it("raises no errors on the resume the product ships as its example", () => { + expect(lint(sampleResumeData).findings.filter((item) => item.severity === "error")).toEqual([]); + }); +}); diff --git a/packages/resume/src/ats/rules.ts b/packages/resume/src/ats/rules.ts new file mode 100644 index 000000000..430fe2d34 --- /dev/null +++ b/packages/resume/src/ats/rules.ts @@ -0,0 +1,311 @@ +import type { CustomSectionType, ResumeData } from "@reactive-resume/schema/resume/data"; +import type { AtsRuleCode } from "./catalog"; +import type { AtsFinding, AtsFindingParams } from "./types"; +import type { WalkedSection } from "./walk"; +import { atsRuleSeverity } from "./catalog"; +import { isFutureEndpoint, isReversedPeriod, parsePeriod, parseSingleDate } from "./period"; +import { isRenderedSection } from "./walk"; + +export type RuleContext = { + data: ResumeData; + sections: readonly WalkedSection[]; + locale: string; + now: Date; +}; + +export type AtsRule = (context: RuleContext) => AtsFinding[]; + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; + +const ALLOWED_URL_PROTOCOLS = new Set(["http:", "https:", "mailto:", "tel:"]); + +const PERIOD_REQUIRED_TYPES = new Set(["experience", "education"]); + +const PROSE_SECTION_TYPES = new Set(["summary", "experience", "education", "projects", "volunteer"]); + +const SECTION_TITLE_ALIASES: Partial>> = { + summary: new Set([ + "summary", + "professional summary", + "profile", + "about", + "about me", + "objective", + "career objective", + ]), + experience: new Set([ + "experience", + "work experience", + "professional experience", + "employment", + "employment history", + "work history", + "career history", + ]), + education: new Set(["education", "academic background", "education & training", "educational background"]), + projects: new Set(["projects", "personal projects", "selected projects", "side projects"]), + skills: new Set(["skills", "technical skills", "core competencies", "competencies", "skills & expertise"]), + languages: new Set(["languages"]), + interests: new Set(["interests", "hobbies", "hobbies & interests"]), + awards: new Set(["awards", "honors", "awards & honors", "achievements"]), + certifications: new Set(["certifications", "certificates", "licenses", "licenses & certifications"]), + publications: new Set(["publications", "papers", "research"]), + volunteer: new Set(["volunteer", "volunteering", "volunteer experience", "community involvement"]), + references: new Set(["references"]), + profiles: new Set(["profiles", "links", "social profiles"]), +}; + +const MIN_BODY_FONT_SIZE = 9; +const MIN_LINE_HEIGHT = 1.15; +const MIN_PAGE_MARGIN = 8; + +function finding(code: AtsRuleCode, pointer: string, params?: AtsFindingParams): AtsFinding { + return { code, severity: atsRuleSeverity(code), pointer, ...(params ? { params } : {}) }; +} + +const isCoverLetter = (section: WalkedSection) => section.type === "cover-letter"; + +const hasText = (value: unknown) => + typeof value === "string" && + value + .replace(/<[^>]*>/g, "") + .replace(/ /g, " ") + .trim().length > 0; + +function isParseableUrl(value: string): boolean { + try { + return ALLOWED_URL_PROTOCOLS.has(new URL(value).protocol); + } catch { + return false; + } +} + +const contactRules: AtsRule = (context) => { + const { basics, picture } = context.data; + const findings: AtsFinding[] = []; + + if (!basics.name.trim()) findings.push(finding("MISSING_NAME", "/basics/name")); + + const email = basics.email.trim(); + if (email) { + if (!EMAIL_PATTERN.test(email)) findings.push(finding("MALFORMED_EMAIL", "/basics/email", { value: email })); + } else { + findings.push(finding("MISSING_EMAIL", "/basics/email")); + } + + if (!basics.phone.trim()) findings.push(finding("MISSING_PHONE", "/basics/phone")); + if (!basics.location.trim()) findings.push(finding("MISSING_LOCATION", "/basics/location")); + if (!picture.hidden && picture.url.trim()) findings.push(finding("PICTURE_PRESENT", "/picture")); + + return findings; +}; + +const urlRules: AtsRule = (context) => { + const findings: AtsFinding[] = []; + + const website = context.data.basics.website.url.trim(); + if (website && !isParseableUrl(website)) { + findings.push(finding("MALFORMED_URL", "/basics/website/url", { value: website })); + } + + context.data.basics.customFields.forEach((field, index) => { + const link = field.link.trim(); + if (link && !isParseableUrl(link)) { + findings.push(finding("MALFORMED_URL", `/basics/customFields/${index}/link`, { value: link })); + } + }); + + for (const section of context.sections) { + if (isCoverLetter(section) || !isRenderedSection(section)) continue; + + for (const item of section.items) { + const itemWebsite = item.value.website as { url?: unknown } | undefined; + const url = typeof itemWebsite?.url === "string" ? itemWebsite.url.trim() : ""; + if (url && !isParseableUrl(url)) { + findings.push(finding("MALFORMED_URL", `${item.pointer}/website/url`, { value: url })); + } + } + } + + return findings; +}; + +function periodFindings(raw: unknown, pointer: string, type: CustomSectionType, context: RuleContext): AtsFinding[] { + if (typeof raw !== "string") return []; + + const value = raw.trim(); + if (!value) return PERIOD_REQUIRED_TYPES.has(type) ? [finding("EMPTY_PERIOD", pointer)] : []; + + const parsed = parsePeriod(value, context.locale); + if (!parsed) return [finding("UNPARSEABLE_PERIOD", pointer, { value })]; + + const findings: AtsFinding[] = []; + if (parsed.start && parsed.end && isReversedPeriod(parsed.start, parsed.end)) { + findings.push(finding("REVERSED_PERIOD", pointer, { value })); + } + if (parsed.start && isFutureEndpoint(parsed.start, context.now)) { + findings.push(finding("FUTURE_DATED_PERIOD", pointer, { value })); + } + + return findings; +} + +function singleDateFindings(raw: unknown, pointer: string, context: RuleContext): AtsFinding[] { + if (typeof raw !== "string") return []; + + const value = raw.trim(); + if (!value) return []; + + return parseSingleDate(value, context.locale) ? [] : [finding("UNPARSEABLE_DATE", pointer, { value })]; +} + +const dateRules: AtsRule = (context) => { + const findings: AtsFinding[] = []; + + for (const section of context.sections) { + if (isCoverLetter(section) || !isRenderedSection(section)) continue; + + for (const item of section.items) { + findings.push(...periodFindings(item.value.period, `${item.pointer}/period`, section.type, context)); + findings.push(...singleDateFindings(item.value.date, `${item.pointer}/date`, context)); + + const roles = item.value.roles; + if (!Array.isArray(roles)) continue; + + roles.forEach((role, index) => { + const value = (role as Record).period; + findings.push(...periodFindings(value, `${item.pointer}/roles/${index}/period`, section.type, context)); + }); + } + } + + return findings; +}; + +const structureRules: AtsRule = (context) => { + const findings: AtsFinding[] = []; + + for (const section of context.sections) { + if (isCoverLetter(section) || section.hidden) continue; + + if (section.placement === "none") { + if (section.items.length > 0) { + findings.push(finding("SECTION_MISSING_FROM_LAYOUT", section.pointer, { section: section.id })); + } + continue; + } + + if (section.items.length === 0) { + findings.push(finding("EMPTY_RENDERED_SECTION", section.pointer, { section: section.id })); + } + } + + const experienceSections = context.sections.filter( + (section) => section.type === "experience" && isRenderedSection(section), + ); + + if (!experienceSections.some((section) => section.items.length > 0)) { + findings.push(finding("NO_VISIBLE_EXPERIENCE", "/sections/experience")); + } + + for (const section of experienceSections) { + for (const item of section.items) { + const roles = item.value.roles; + const roleHasText = + Array.isArray(roles) && roles.some((role) => hasText((role as Record).description)); + + if (!hasText(item.value.description) && !roleHasText) { + findings.push(finding("MISSING_EXPERIENCE_DESCRIPTION", `${item.pointer}/description`)); + } + } + } + + return findings; +}; + +const titleRules: AtsRule = (context) => { + if (!context.locale.toLowerCase().startsWith("en")) return []; + + const findings: AtsFinding[] = []; + + for (const section of context.sections) { + if (isCoverLetter(section) || !isRenderedSection(section)) continue; + + const title = section.title.trim(); + if (!title) continue; + + const aliases = SECTION_TITLE_ALIASES[section.type]; + if (!aliases || aliases.has(title.toLowerCase())) continue; + + findings.push(finding("NON_STANDARD_SECTION_TITLE", `${section.pointer}/title`, { section: section.id, title })); + } + + return findings; +}; + +const layoutRules: AtsRule = (context) => { + const findings: AtsFinding[] = []; + + for (const section of context.sections) { + if (isCoverLetter(section) || !isRenderedSection(section)) continue; + if (!PROSE_SECTION_TYPES.has(section.type) || section.items.length === 0) continue; + + if (section.columns > 1) { + findings.push( + finding("MULTI_COLUMN_PROSE_SECTION", `${section.pointer}/columns`, { + section: section.id, + columns: section.columns, + }), + ); + } + + if (section.placement === "sidebar") { + findings.push(finding("PROSE_SECTION_IN_SIDEBAR", section.pointer, { section: section.id })); + } + } + + return findings; +}; + +const typographyRules: AtsRule = (context) => { + const findings: AtsFinding[] = []; + const { page, typography } = context.data.metadata; + + if (typography.body.fontSize < MIN_BODY_FONT_SIZE) { + findings.push( + finding("SMALL_BODY_FONT", "/metadata/typography/body/fontSize", { + fontSize: typography.body.fontSize, + minimum: MIN_BODY_FONT_SIZE, + }), + ); + } + + if (typography.body.lineHeight < MIN_LINE_HEIGHT) { + findings.push( + finding("TIGHT_LINE_HEIGHT", "/metadata/typography/body/lineHeight", { + lineHeight: typography.body.lineHeight, + minimum: MIN_LINE_HEIGHT, + }), + ); + } + + for (const axis of ["marginX", "marginY"] as const) { + if (page[axis] < MIN_PAGE_MARGIN) { + findings.push( + finding("TIGHT_PAGE_MARGINS", `/metadata/page/${axis}`, { margin: page[axis], minimum: MIN_PAGE_MARGIN }), + ); + } + } + + return findings; +}; + +export const ATS_RULES: readonly AtsRule[] = [ + contactRules, + urlRules, + dateRules, + structureRules, + titleRules, + layoutRules, + typographyRules, +]; diff --git a/packages/resume/src/ats/types.ts b/packages/resume/src/ats/types.ts new file mode 100644 index 000000000..9f417d3c4 --- /dev/null +++ b/packages/resume/src/ats/types.ts @@ -0,0 +1,19 @@ +import type { AtsRuleCode } from "./catalog"; + +export type AtsSeverity = "error" | "warning" | "info"; + +export type AtsFindingParams = Readonly>; + +export type AtsFinding = { + code: AtsRuleCode; + severity: AtsSeverity; + pointer: string; + params?: AtsFindingParams; +}; + +export type AtsReport = { + findings: readonly AtsFinding[]; + counts: Readonly>; + totalRules: number; + passedRules: number; +}; diff --git a/packages/resume/src/ats/walk.ts b/packages/resume/src/ats/walk.ts new file mode 100644 index 000000000..4e85c313b --- /dev/null +++ b/packages/resume/src/ats/walk.ts @@ -0,0 +1,101 @@ +import type { CustomSectionType, ResumeData, SectionType } from "@reactive-resume/schema/resume/data"; + +export type SectionPlacement = "main" | "sidebar" | "none"; + +export type WalkedItem = { + pointer: string; + value: Readonly>; +}; + +export type WalkedSection = { + id: string; + type: CustomSectionType; + title: string; + columns: number; + hidden: boolean; + placement: SectionPlacement; + pointer: string; + items: readonly WalkedItem[]; +}; + +export const escapePointerToken = (token: string) => token.replace(/~/g, "~0").replace(/\//g, "~1"); + +export const isRenderedSection = (section: WalkedSection) => !section.hidden && section.placement !== "none"; + +function buildPlacement(data: ResumeData): ReadonlyMap { + const placement = new Map(); + + for (const page of data.metadata.layout.pages) { + for (const id of page.main) { + if (!placement.has(id)) placement.set(id, "main"); + } + + const sidebarPlacement: SectionPlacement = page.fullWidth ? "main" : "sidebar"; + for (const id of page.sidebar) { + if (!placement.has(id)) placement.set(id, sidebarPlacement); + } + } + + return placement; +} + +function visibleItems(items: readonly unknown[], sectionPointer: string): WalkedItem[] { + const walked: WalkedItem[] = []; + + items.forEach((item, index) => { + const value = item as Record; + if (value.hidden === true) return; + walked.push({ pointer: `${sectionPointer}/items/${index}`, value }); + }); + + return walked; +} + +export function walkSections(data: ResumeData): readonly WalkedSection[] { + const placement = buildPlacement(data); + const sections: WalkedSection[] = []; + + const summaryPointer = "/summary"; + sections.push({ + id: "summary", + type: "summary", + title: data.summary.title, + columns: data.summary.columns, + hidden: data.summary.hidden, + placement: placement.get("summary") ?? "none", + pointer: summaryPointer, + items: data.summary.content.trim() + ? [{ pointer: `${summaryPointer}/content`, value: { content: data.summary.content } }] + : [], + }); + + for (const [key, section] of Object.entries(data.sections) as [SectionType, ResumeData["sections"][SectionType]][]) { + const pointer = `/sections/${escapePointerToken(key)}`; + sections.push({ + id: key, + type: key, + title: section.title, + columns: section.columns, + hidden: section.hidden, + placement: placement.get(key) ?? "none", + pointer, + items: visibleItems(section.items, pointer), + }); + } + + data.customSections.forEach((section, index) => { + const pointer = `/customSections/${index}`; + sections.push({ + id: section.id, + type: section.type, + title: section.title, + columns: section.columns, + hidden: section.hidden, + placement: placement.get(section.id) ?? "none", + pointer, + items: visibleItems(section.items, pointer), + }); + }); + + return sections; +}