feat(resume): add a deterministic ATS parseability check (#3314)

* feat(resume): add a deterministic ATS parseability check

Adds an offline ATS linter that reports whether a parser can read a
resume, surfaced as an always-on panel in the builder.

The existing Resume Analysis panel needs a configured AI provider, so
users who never set one up get no feedback at all. These 22 rules run
as a pure function over ResumeData with no provider, no network and no
rendered PDF, so they work for everyone on every edit.

Rules cover contact details, date parseability, sections that hold
content but never render, column and sidebar placement, and typography
thresholds. The catalog mirrors the Semantic CSS diagnostic catalog:
stable codes carrying a severity, meaning and action, with no i18n
dependency so the web layer translates by code. Each finding carries a
JSON Pointer, which is what makes jump-to-field work.

Deliberately no second score. Resume Analysis owns overallScore, so
this reports "N of M checks passed" and counts by severity instead.

* fix(resume): accept localized ongoing periods and reject bare ones

Two period-parsing bugs found in review.

The ongoing-token set was English-only, so a German resume reading
"2020 - heute" was reported as unparseable and the panel told the user
to rewrite a perfectly valid range. Rather than guess translations for
55 locales, a range ending that carries no digits and is not a month
name in the resume's locale is now read as ongoing. That keeps a
genuinely incomplete ending such as "Jan 2020 - Feb" reported, since
"Feb" resolves as a month.

A bare "Present" also parsed as a valid period, so an experience entry
with no start date passed the check. A standalone ongoing token is now
rejected; ongoing tokens remain valid as the end of a range.

* feat(web): scroll ATS findings to the item they belong to

Findings for different items in one section all landed on the section
header, so a date problem on the third role gave no more help than
naming the section.

getAtsFindingTarget now resolves the offending item from the finding's
JSON Pointer against the resume, and SectionItem carries a matching DOM
id. The panel scrolls to that item and falls back to the section header
when the item is not mounted, which is what happens while its section
is collapsed.

* fix(resume): recognize ongoing periods by token, not by shape

The previous heuristic read any short, digit-free range ending as an
ongoing marker, so "2020 - unknown", "2020 - later" and "2020 - tbd"
parsed cleanly and suppressed the finding they should have raised.

Replaced with an explicit table of ongoing words keyed by language,
covering the locales the app ships. Matching is exact, so unrecognized
endings are reported again. A locale missing from the table falls back
to the earlier behaviour of reporting its ongoing periods, which is a
visible gap someone can close by adding a word rather than a silent
hole in detection.

Tests assert every listed token parses and that the table stays
lowercase, since lookups normalize that way.

* fix(ats): parse punctuated ongoing tokens

* fix(ats): parse Unicode punctuated ongoing tokens

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
This commit is contained in:
Syed Ali Abbas Zaidi
2026-08-13 23:07:55 +02:00
committed by GitHub
co-authored by Amruth Pillai
parent f64d02df7f
commit 45303fb465
17 changed files with 2175 additions and 0 deletions
+152
View File
@@ -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();
});
});
+186
View File
@@ -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<string, { items?: readonly unknown[] }>)[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}`;
}
+5
View File
@@ -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", () => <NotepadIcon {...iconProps} />)
.with("sharing", () => <ShareFatIcon {...iconProps} />)
.with("statistics", () => <ChartLineIcon {...iconProps} />)
.with("ats", () => <SealCheckIcon {...iconProps} />)
.with("analysis", () => <BrainIcon {...iconProps} />)
.with("export", () => <DownloadIcon {...iconProps} />)
.with("information", () => <InfoIcon {...iconProps} />)
@@ -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<T extends CustomSectionItem | SectionItemType>({
return (
<Reorder.Item
key={item.id}
id={atsFindingItemElementId(item.id)}
value={item}
dragListener={false}
dragControls={controls}
@@ -9,6 +9,7 @@ import { Copyright } from "@/components/ui/copyright";
import { getSectionIcon, getSectionTitle, rightSidebarSections } from "@/libs/resume/section";
import { BuilderSidebarEdge } from "../../-components/edge";
import { useBuilderSidebar } from "../../-store/sidebar";
import { AtsCheckSectionBuilder } from "./sections/ats-check";
import { CustomStylesSectionBuilder } from "./sections/custom-styles";
import { DesignSectionBuilder } from "./sections/design";
import { ExportSectionBuilder } from "./sections/export";
@@ -33,6 +34,7 @@ function getSectionComponent(type: RightSidebarSection) {
.with("notes", () => <NotesSectionBuilder />)
.with("sharing", () => <SharingSectionBuilder />)
.with("statistics", () => <StatisticsSectionBuilder />)
.with("ats", () => <AtsCheckSectionBuilder />)
.with("analysis", () => <ResumeAnalysisSectionBuilder />)
.with("export", () => <ExportSectionBuilder />)
.with("information", () => <InformationSectionBuilder />)
@@ -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) => <div>{children}</div>,
}));
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> = {}): ExperienceItem => ({
id: "exp-1",
hidden: false,
company: "Analytical Engines",
position: "Engineer",
location: "London",
period: "Jan 2020 - Present",
website: { url: "", label: "", inlineLink: false },
description: "<p>Designed and shipped the difference engine.</p>",
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(
<I18nProvider i18n={i18n}>
<AtsCheckSectionBuilder />
</I18nProvider>,
);
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();
});
});
@@ -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 (
<span className="inline-flex items-center gap-1.5 text-muted-foreground text-xs">
<span className={cn("size-2 shrink-0 rounded-full", severityDotClass(severity))} aria-hidden />
{match(severity)
.with("error", () => <Plural value={count} one="# error" other="# errors" />)
.with("warning", () => <Plural value={count} one="# warning" other="# warnings" />)
.with("info", () => <Plural value={count} one="# note" other="# notes" />)
.exhaustive()}
</span>
);
}
type AtsFindingRowProps = {
finding: AtsFinding;
onJump: (pointer: string) => void;
};
function AtsFindingRow({ finding, onJump }: AtsFindingRowProps) {
const message = getAtsFindingMessage(finding.code);
const location = getAtsFindingLocation(finding.pointer);
return (
<li className="space-y-2 rounded-md border bg-card p-3">
<div className="flex items-start gap-2">
<span className={cn("mt-1.5 size-2 shrink-0 rounded-full", severityDotClass(finding.severity))} aria-hidden />
<div className="min-w-0 flex-1 space-y-1">
<p className="font-medium text-sm leading-snug">{message.title}</p>
<p className="text-muted-foreground text-xs leading-normal">{message.action}</p>
</div>
<Badge variant="secondary" className="shrink-0">
{severityLabel(finding.severity)}
</Badge>
</div>
{location && (
<Button size="sm" variant="ghost" className="h-7 gap-1.5 px-2 text-xs" onClick={() => onJump(finding.pointer)}>
{location}
<ArrowRightIcon />
</Button>
)}
</li>
);
}
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 (
<SectionBase type="ats" className="space-y-4">
<div className="space-y-3">
<div className="space-y-3 rounded-md border bg-card p-3">
<p className="text-muted-foreground text-xs leading-normal">
<Trans>
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.
</Trans>
</p>
<div className="space-y-2">
<p className="font-medium text-sm leading-none">
<Trans>
{passedRules} of {totalRules} checks passed
</Trans>
</p>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-[width] duration-300"
style={{ width: `${Math.round((passedRules / totalRules) * 100)}%` }}
/>
</div>
</div>
{findings.length > 0 && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
{SEVERITIES.filter((severity) => counts[severity] > 0).map((severity) => (
<SeverityCount key={severity} severity={severity} count={counts[severity]} />
))}
</div>
)}
</div>
{findings.length === 0 ? (
<div className="flex items-center gap-3 rounded-md border border-dashed p-3">
<CheckCircleIcon className="size-5 shrink-0 text-emerald-600" />
<p className="text-muted-foreground text-sm leading-normal">
<Trans>Every check passed. Nothing here should stop a parser from reading your resume.</Trans>
</p>
</div>
) : (
<ul className="space-y-2">
{findings.map((finding) => (
<AtsFindingRow key={`${finding.code}:${finding.pointer}`} finding={finding} onJump={onJump} />
))}
</ul>
)}
</div>
</SectionBase>
);
}
+1
View File
@@ -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",
+47
View File
@@ -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");
});
});
+130
View File
@@ -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<Record<string, AtsRuleReference>>;
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;
+55
View File
@@ -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<Record<AtsSeverity, number>> = { 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<AtsSeverity, number> = { error: 0, warning: 0, info: 0 };
const fired = new Set<AtsRuleCode>();
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";
+159
View File
@@ -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 }],
["20202022", { 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,
});
}
}
});
});
+236
View File
@@ -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<Record<string, readonly string[]>> = {
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<Record<string, number>> = {
spring: 3,
summer: 6,
fall: 9,
autumn: 9,
winter: 12,
};
const EXTRA_MONTH_ALIASES: Readonly<Record<string, number>> = { sept: 9 };
const DASH_CHARS = new Set(["-", "", "—", "~"]);
const SPACED_SEPARATOR = /\s(?:[-–—~]+|to|through|until)\s/;
const monthLookupCache = new Map<string, ReadonlyMap<string, number>>();
const normalizeToken = (value: string) => value.trim().toLowerCase().replace(/\.$/, "");
const normalizeWhitespace = (value: string) => value.replace(/\s+/g, " ").trim().toLowerCase();
function buildMonthLookup(locale: string): ReadonlyMap<string, number> {
const lookup = new Map<string, number>();
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<string, number> {
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<string, number>): 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<string, number>): 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);
}
+405
View File
@@ -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> = {}): ExperienceItem => ({
id: "exp-1",
hidden: false,
company: "Analytical Engines",
position: "Engineer",
location: "London",
period: "Jan 2020 - Present",
website: { url: "", label: "", inlineLink: false },
description: "<p>Designed and shipped the difference engine.</p>",
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: "<p>A machine.</p>",
},
];
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: "<p>Work.</p>" }],
}),
];
});
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: "<p></p>" })];
});
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: "<p>Shipped it.</p>" }],
}),
];
});
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: "<p>Hiring Manager</p>", content: "<p>Dear team,</p>" }],
},
];
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([]);
});
});
+311
View File
@@ -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<CustomSectionType>(["experience", "education"]);
const PROSE_SECTION_TYPES = new Set<CustomSectionType>(["summary", "experience", "education", "projects", "volunteer"]);
const SECTION_TITLE_ALIASES: Partial<Record<CustomSectionType, ReadonlySet<string>>> = {
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(/&nbsp;/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<string, unknown>).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<string, unknown>).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,
];
+19
View File
@@ -0,0 +1,19 @@
import type { AtsRuleCode } from "./catalog";
export type AtsSeverity = "error" | "warning" | "info";
export type AtsFindingParams = Readonly<Record<string, string | number>>;
export type AtsFinding = {
code: AtsRuleCode;
severity: AtsSeverity;
pointer: string;
params?: AtsFindingParams;
};
export type AtsReport = {
findings: readonly AtsFinding[];
counts: Readonly<Record<AtsSeverity, number>>;
totalRules: number;
passedRules: number;
};
+101
View File
@@ -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<Record<string, unknown>>;
};
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<string, SectionPlacement> {
const placement = new Map<string, SectionPlacement>();
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<string, unknown>;
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;
}