feat(resume): check categories, stable issue keys, ignores and a two-column rule

- Every live check has a category (contact details, dates, layout, section
  headings, writing). Reports score the applicable rules, per category too.
- Findings carry a key that uses entry ids instead of array indexes, so it
  survives reordering. Keys in the new metadata.check.ignored set findings
  aside without counting them against the score.
- TWO_COLUMN_LAYOUT flags a two-column template that prints a sidebar.
- Full-width pages print no sidebar, so sections placed only there are now
  reported as never printing instead of passing as main-column content.
- metadata.check also holds job-posting terms hidden as not true; public
  viewers don't receive it.
- ats-pdf exports the job-description matcher, spelling variants and the
  semantics reader for the editor's job match and parser view.
This commit is contained in:
Amruth Pillai
2026-09-28 21:33:11 +02:00
parent 0e36729b80
commit e26ce08fc5
11 changed files with 291 additions and 33 deletions
@@ -62,13 +62,21 @@ describe("redactResumeForViewer", () => {
expect(result.name).toBe("Resume");
});
it("strips metadata.notes for non-owner", () => {
it("strips metadata.notes and the author's Check choices for non-owner", () => {
const resume = {
name: "Title",
data: { ...defaultResumeData, metadata: { ...defaultResumeData.metadata, notes: "Private notes" } },
data: {
...defaultResumeData,
metadata: {
...defaultResumeData.metadata,
notes: "Private notes",
check: { ignored: ["MISSING_PHONE:/basics/phone"], hiddenTerms: ["HIPAA"] },
},
},
};
const result = redactResumeForViewer(resume, false);
expect(result.data.metadata.notes).toBe("");
expect(result.data.metadata.check).toBeUndefined();
});
it("preserves stylesheet source for an authorized non-owner", () => {
@@ -42,6 +42,8 @@ export function assertCanView(resume: Resume, viewer: Viewer): void {
* contains personal context like "Senior Eng @ Foo — final draft").
* - `resume.data.metadata.notes` — explicitly documented as "only visible
* to the author when editing" in the resume schema.
* - `resume.data.metadata.check` — the author's Check choices (ignored
* issues, job-posting terms hidden as "not true for me").
*
* Everything else (including `data.basics.name`, the person's name on the
* resume itself) is part of the public payload and is returned unchanged.
@@ -61,6 +63,7 @@ export function redactResumeForViewer<T extends { name: string; data: ResumeData
metadata: {
...resume.data.metadata,
notes: "",
check: undefined,
},
},
};
+6
View File
@@ -131,6 +131,8 @@ export function analyzePdfResume(raw: RawExtraction, options: AnalyzePdfOptions
export type { HarvestOptions, HarvestProgress, PdfDocumentLike, PdfPageLike } from "./harvest";
export type {
ContactEntities,
DetectedHeading,
ExtractedDocument,
JdMatchReport,
JdTermMatch,
@@ -147,9 +149,13 @@ export type {
RawPage,
RawTextItem,
ResumeSemantics,
TextLine,
} from "./types";
export type { PdfRuleCode };
export { buildResumeSemantics } from "./analyze/semantics";
export { PDF_ATS_RULE_CODES } from "./catalog";
export { buildExtractedDocument } from "./extract";
export { HARVEST_DEFAULTS, harvestPdfDocument } from "./harvest";
export { surfaceFormsOf } from "./jd/aliases";
export { matchJobDescription } from "./jd/match";
export { PDF_OPS } from "./pdf-ops";
+4 -2
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { ATS_RULE_CATALOG_V1, ATS_RULE_CODES, atsRuleSeverity } from "./catalog";
import { ATS_CATEGORIES, ATS_RULE_CATALOG_V1, ATS_RULE_CODES, atsRuleSeverity } from "./catalog";
describe("ATS_RULE_CATALOG_V1", () => {
it("has a stable set of codes", () => {
@@ -23,6 +23,7 @@ describe("ATS_RULE_CATALOG_V1", () => {
"NON_STANDARD_SECTION_TITLE",
"MULTI_COLUMN_PROSE_SECTION",
"PROSE_SECTION_IN_SIDEBAR",
"TWO_COLUMN_LAYOUT",
"SMALL_BODY_FONT",
"TIGHT_LINE_HEIGHT",
"TIGHT_PAGE_MARGINS",
@@ -30,10 +31,11 @@ describe("ATS_RULE_CATALOG_V1", () => {
`);
});
it("gives every rule a severity", () => {
it("gives every rule a severity and a category", () => {
for (const code of ATS_RULE_CODES) {
const rule = ATS_RULE_CATALOG_V1[code];
expect(["error", "warning", "info"]).toContain(rule.severity);
expect(ATS_CATEGORIES).toContain(rule.category);
}
});
+38 -1
View File
@@ -1,76 +1,102 @@
import type { AtsSeverity } from "./types";
import type { AtsCategory, AtsSeverity } from "./types";
type AtsRuleReference = {
severity: AtsSeverity;
category: AtsCategory;
};
export const ATS_RULE_CATALOG_V1 = {
MISSING_NAME: {
severity: "error",
category: "contact",
},
MISSING_EMAIL: {
severity: "error",
category: "contact",
},
MALFORMED_EMAIL: {
severity: "error",
category: "contact",
},
MISSING_PHONE: {
severity: "warning",
category: "contact",
},
MISSING_LOCATION: {
severity: "info",
category: "contact",
},
MALFORMED_URL: {
severity: "warning",
category: "contact",
},
PICTURE_PRESENT: {
severity: "info",
category: "contact",
},
EMPTY_PERIOD: {
severity: "warning",
category: "dates",
},
UNPARSEABLE_PERIOD: {
severity: "error",
category: "dates",
},
UNPARSEABLE_DATE: {
severity: "warning",
category: "dates",
},
REVERSED_PERIOD: {
severity: "error",
category: "dates",
},
FUTURE_DATED_PERIOD: {
severity: "warning",
category: "dates",
},
SECTION_MISSING_FROM_LAYOUT: {
severity: "error",
category: "layout",
},
NO_VISIBLE_EXPERIENCE: {
severity: "warning",
category: "headings",
},
MISSING_EXPERIENCE_DESCRIPTION: {
severity: "warning",
category: "writing",
},
NON_STANDARD_SECTION_TITLE: {
severity: "info",
category: "headings",
},
MULTI_COLUMN_PROSE_SECTION: {
severity: "warning",
category: "layout",
},
PROSE_SECTION_IN_SIDEBAR: {
severity: "warning",
category: "layout",
},
TWO_COLUMN_LAYOUT: {
severity: "warning",
category: "layout",
},
SMALL_BODY_FONT: {
severity: "warning",
category: "layout",
},
TIGHT_LINE_HEIGHT: {
severity: "warning",
category: "layout",
},
TIGHT_PAGE_MARGINS: {
severity: "warning",
category: "layout",
},
} as const satisfies Readonly<Record<string, AtsRuleReference>>;
@@ -78,4 +104,15 @@ export type AtsRuleCode = keyof typeof ATS_RULE_CATALOG_V1;
export const ATS_RULE_CODES = Object.keys(ATS_RULE_CATALOG_V1) as readonly AtsRuleCode[];
/** The order Check lists its category rows in. */
export const ATS_CATEGORIES = [
"contact",
"dates",
"layout",
"headings",
"writing",
] as const satisfies readonly AtsCategory[];
export const atsRuleSeverity = (code: AtsRuleCode): AtsSeverity => ATS_RULE_CATALOG_V1[code].severity;
export const atsRuleCategory = (code: AtsRuleCode): AtsCategory => ATS_RULE_CATALOG_V1[code].category;
+64 -11
View File
@@ -1,9 +1,9 @@
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 type { AtsRuleFinding, RuleContext } from "./rules";
import type { AtsCategory, AtsCategoryScore, AtsFinding, AtsReport, AtsSeverity } from "./types";
import { ATS_CATEGORIES, ATS_RULE_CODES, atsRuleCategory } from "./catalog";
import { ATS_RULES, checksSectionTitles } from "./rules";
import { walkSections } from "./walk";
export type AtsLintOptions = {
@@ -20,6 +20,30 @@ function compareFindings(a: AtsFinding, b: AtsFinding): number {
return a.code < b.code ? -1 : 1;
}
const decodePointerToken = (token: string) => token.replace(/~1/g, "/").replace(/~0/g, "~");
/** The finding's key: its code and pointer, with array indexes swapped for entry ids (see {@link AtsFinding.key}). */
export function atsFindingKey(finding: Pick<AtsFinding, "code" | "pointer">, data: ResumeData): string {
let node: unknown = data;
const tokens = finding.pointer
.split("/")
.slice(1)
.map((token) => {
if (Array.isArray(node)) {
const entry: unknown = node[Number(token)];
node = entry;
const id = (entry as { id?: unknown } | undefined)?.id;
return typeof id === "string" ? `#${id}` : token;
}
node = (node as Record<string, unknown> | undefined)?.[decodePointerToken(token)];
return token;
});
return `${finding.code}:/${tokens.join("/")}`;
}
export function lintResumeForAts(data: ResumeData, options: AtsLintOptions = {}): AtsReport {
const context: RuleContext = {
data,
@@ -28,25 +52,54 @@ export function lintResumeForAts(data: ResumeData, options: AtsLintOptions = {})
now: options.now ?? new Date(),
};
const findings = ATS_RULES.flatMap((rule) => rule(context)).sort(compareFindings);
const ignoredKeys = new Set(data.metadata.check?.ignored ?? []);
const all = ATS_RULES.flatMap((rule) => rule(context))
.map((item: AtsRuleFinding): AtsFinding => ({ ...item, key: atsFindingKey(item, data) }))
.sort(compareFindings);
const findings = all.filter((item) => !ignoredKeys.has(item.key));
const counts: Record<AtsSeverity, number> = { error: 0, warning: 0, info: 0 };
const fired = new Set<AtsRuleCode>();
const failed = new Set<AtsRuleCode>();
for (const item of findings) {
counts[item.severity] += 1;
fired.add(item.code);
failed.add(item.code);
}
const applicable = ATS_RULE_CODES.filter(
(code) => code !== "NON_STANDARD_SECTION_TITLE" || checksSectionTitles(context.locale),
);
const passed = applicable.filter((code) => !failed.has(code));
const categories = Object.fromEntries(
ATS_CATEGORIES.map((category): [AtsCategory, AtsCategoryScore] => [
category,
{
total: applicable.filter((code) => atsRuleCategory(code) === category).length,
passed: passed.filter((code) => atsRuleCategory(code) === category).length,
},
]),
) as Record<AtsCategory, AtsCategoryScore>;
return {
findings,
ignored: all.filter((item) => ignoredKeys.has(item.key)),
counts,
totalRules: ATS_RULE_CODES.length,
passedRules: ATS_RULE_CODES.length - fired.size,
totalRules: applicable.length,
passedRules: passed.length,
score: Math.round((passed.length / applicable.length) * 100),
categories,
};
}
export type { AtsRuleCode } from "./catalog";
export type { AtsFinding, AtsFindingParams, AtsReport, AtsSeverity } from "./types";
export type {
AtsCategory,
AtsCategoryScore,
AtsFinding,
AtsFindingParams,
AtsReport,
AtsSeverity,
} from "./types";
export type { SectionPlacement, WalkedItem, WalkedSection } from "./walk";
export { ATS_RULE_CODES } from "./catalog";
export { ATS_CATEGORIES, ATS_RULE_CODES, atsRuleCategory } from "./catalog";
+80 -1
View File
@@ -33,6 +33,17 @@ function makeResume(mutate: (data: ResumeData) => void = () => undefined): Resum
return data;
}
const skillItem = () => ({
id: "s1",
hidden: false,
icon: "",
iconColor: "",
name: "Mathematics",
proficiency: "",
level: 0,
keywords: [],
});
const lint = (data: ResumeData) => lintResumeForAts(data, { now: NOW });
const codesOf = (data: ResumeData) => lint(data).findings.map((item) => item.code);
@@ -44,7 +55,46 @@ describe("lintResumeForAts", () => {
it("counts every rule as passed when nothing fires", () => {
const report = lint(makeResume());
expect(report.passedRules).toBe(report.totalRules);
expect(report.score).toBe(100);
expect(report.counts).toEqual({ error: 0, warning: 0, info: 0 });
expect(report.categories.contact).toEqual({ total: 7, passed: 7 });
});
it("scores the share of rules with no open finding, and groups them by category", () => {
const report = lint(makeResume((data) => (data.basics.phone = "")));
expect(report.passedRules).toBe(report.totalRules - 1);
expect(report.score).toBe(Math.round(((report.totalRules - 1) / report.totalRules) * 100));
expect(report.categories.contact).toEqual({ total: 7, passed: 6 });
});
it("leaves the English heading rule out of the score for other languages", () => {
const english = lint(makeResume());
const german = lint(makeResume((data) => (data.metadata.page.locale = "de-DE")));
expect(german.totalRules).toBe(english.totalRules - 1);
expect(german.categories.headings.total).toBe(english.categories.headings.total - 1);
});
it("keys findings by entry id, so reordering entries keeps the key", () => {
const second = experienceItem({ id: "exp-2", period: "a while back" });
const before = lint(makeResume((data) => (data.sections.experience.items = [experienceItem(), second])));
const after = lint(makeResume((data) => (data.sections.experience.items = [second, experienceItem()])));
const keyOf = (report: typeof before) => report.findings.find((item) => item.code === "UNPARSEABLE_PERIOD")?.key;
expect(keyOf(before)).toBe("UNPARSEABLE_PERIOD:/sections/experience/items/#exp-2/period");
expect(keyOf(after)).toBe(keyOf(before));
});
it("sets ignored findings aside without counting them against the score", () => {
const data = makeResume((resume) => {
resume.basics.phone = "";
resume.metadata.check = { ignored: ["MISSING_PHONE:/basics/phone"], hiddenTerms: [] };
});
const report = lint(data);
expect(report.findings).toEqual([]);
expect(report.ignored.map((item) => item.code)).toEqual(["MISSING_PHONE"]);
expect(report.counts.warning).toBe(0);
expect(report.score).toBe(100);
});
it("flags the gaps in a blank resume", () => {
@@ -75,6 +125,7 @@ describe("contact rules", () => {
code: "MALFORMED_URL",
severity: "warning",
pointer: "/basics/website/url",
key: "MALFORMED_URL:/basics/website/url",
params: { value: "example.com/ada" },
});
});
@@ -112,6 +163,7 @@ describe("date rules", () => {
code: "UNPARSEABLE_PERIOD",
severity: "error",
pointer: "/sections/experience/items/0/period",
key: "UNPARSEABLE_PERIOD:/sections/experience/items/#exp-1/period",
params: { value: "a while back" },
});
});
@@ -245,6 +297,7 @@ describe("structure rules", () => {
code: "SECTION_MISSING_FROM_LAYOUT",
severity: "error",
pointer: "/sections/education",
key: "SECTION_MISSING_FROM_LAYOUT:/sections/education",
params: { section: "education" },
});
});
@@ -335,11 +388,37 @@ describe("layout rules", () => {
expect(codesOf(data)).toContain("PROSE_SECTION_IN_SIDEBAR");
});
it("treats a full-width page's sidebar as the main column", () => {
it("knows a full-width page prints no sidebar", () => {
const data = makeResume((resume) => {
resume.metadata.layout.pages = [{ fullWidth: true, main: [], sidebar: ["experience"] }];
});
expect(codesOf(data)).not.toContain("PROSE_SECTION_IN_SIDEBAR");
expect(codesOf(data)).toContain("SECTION_MISSING_FROM_LAYOUT");
});
it("flags a two-column template printing a sidebar, naming its sections", () => {
const data = makeResume((resume) => {
resume.sections.skills.items = [skillItem()];
resume.metadata.template = "azurill";
resume.metadata.layout.pages = [{ fullWidth: false, main: ["experience"], sidebar: ["skills"] }];
});
expect(lint(data).findings).toContainEqual(
expect.objectContaining({ code: "TWO_COLUMN_LAYOUT", params: { sections: "skills" } }),
);
});
it("leaves one-column templates, full-width pages and empty sidebars out of the two-column rule", () => {
const withSidebar = (template: ResumeData["metadata"]["template"], fullWidth: boolean, withSkills = true) =>
makeResume((resume) => {
resume.sections.skills.items = withSkills ? [skillItem()] : [];
resume.metadata.template = template;
resume.metadata.layout.pages = [{ fullWidth, main: ["experience"], sidebar: ["skills"] }];
});
expect(codesOf(withSidebar("onyx", false))).not.toContain("TWO_COLUMN_LAYOUT");
expect(codesOf(withSidebar("azurill", true))).not.toContain("TWO_COLUMN_LAYOUT");
expect(codesOf(withSidebar("azurill", false, false))).not.toContain("TWO_COLUMN_LAYOUT");
});
it("leaves short-list sections in the sidebar alone", () => {
+43 -14
View File
@@ -9,6 +9,7 @@ import {
parsePeriod,
parseSingleDate,
} from "@reactive-resume/schema/resume/period";
import { templateLayouts } from "@reactive-resume/schema/templates";
import { atsRuleSeverity } from "./catalog";
import { SECTION_TITLE_ALIASES } from "./section-aliases";
import { isRenderedSection } from "./walk";
@@ -20,7 +21,10 @@ export type RuleContext = {
now: Date;
};
export type AtsRule = (context: RuleContext) => AtsFinding[];
/** A finding as a rule reports it; the linter adds its key. */
export type AtsRuleFinding = Omit<AtsFinding, "key">;
export type AtsRule = (context: RuleContext) => AtsRuleFinding[];
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
@@ -34,7 +38,7 @@ 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 {
function finding(code: AtsRuleCode, pointer: string, params?: AtsFindingParams): AtsRuleFinding {
return { code, severity: atsRuleSeverity(code), pointer, ...(params ? { params } : {}) };
}
@@ -57,7 +61,7 @@ function isParseableUrl(value: string): boolean {
const contactRules: AtsRule = (context) => {
const { basics, picture } = context.data;
const findings: AtsFinding[] = [];
const findings: AtsRuleFinding[] = [];
if (!basics.name.trim()) findings.push(finding("MISSING_NAME", "/basics/name"));
@@ -76,7 +80,7 @@ const contactRules: AtsRule = (context) => {
};
const urlRules: AtsRule = (context) => {
const findings: AtsFinding[] = [];
const findings: AtsRuleFinding[] = [];
const website = context.data.basics.website.url.trim();
if (website && !isParseableUrl(website)) {
@@ -117,7 +121,7 @@ function periodFindings(
pointer: string,
type: CustomSectionType,
context: RuleContext,
): AtsFinding[] {
): AtsRuleFinding[] {
if (typeof raw !== "string") return [];
const value = raw.trim();
@@ -127,7 +131,7 @@ function periodFindings(
const parsed = structured ? resumeDatesToPeriod(structured) : parsePeriod(value, context.locale);
if (!parsed) return [finding("UNPARSEABLE_PERIOD", pointer, { value })];
const findings: AtsFinding[] = [];
const findings: AtsRuleFinding[] = [];
if (parsed.start && parsed.end && isReversedPeriod(parsed.start, parsed.end)) {
findings.push(finding("REVERSED_PERIOD", pointer, { value }));
}
@@ -138,7 +142,7 @@ function periodFindings(
return findings;
}
function singleDateFindings(raw: unknown, dates: unknown, pointer: string, context: RuleContext): AtsFinding[] {
function singleDateFindings(raw: unknown, dates: unknown, pointer: string, context: RuleContext): AtsRuleFinding[] {
if (typeof raw !== "string") return [];
const value = raw.trim();
@@ -150,7 +154,7 @@ function singleDateFindings(raw: unknown, dates: unknown, pointer: string, conte
}
const dateRules: AtsRule = (context) => {
const findings: AtsFinding[] = [];
const findings: AtsRuleFinding[] = [];
for (const section of context.sections) {
if (isCoverLetter(section) || !isRenderedSection(section)) continue;
@@ -175,7 +179,7 @@ const dateRules: AtsRule = (context) => {
};
const structureRules: AtsRule = (context) => {
const findings: AtsFinding[] = [];
const findings: AtsRuleFinding[] = [];
// A section with no items is not reported: every template's renderer returns null before it
// emits a heading, so an empty section produces nothing on the page rather than a bare title.
@@ -211,10 +215,13 @@ const structureRules: AtsRule = (context) => {
return findings;
};
const titleRules: AtsRule = (context) => {
if (!context.locale.toLowerCase().startsWith("en")) return [];
/** The heading rule knows English headings only, so it applies to English resumes alone. */
export const checksSectionTitles = (locale: string) => locale.toLowerCase().startsWith("en");
const findings: AtsFinding[] = [];
const titleRules: AtsRule = (context) => {
if (!checksSectionTitles(context.locale)) return [];
const findings: AtsRuleFinding[] = [];
for (const section of context.sections) {
if (isCoverLetter(section) || !isRenderedSection(section)) continue;
@@ -232,7 +239,7 @@ const titleRules: AtsRule = (context) => {
};
const layoutRules: AtsRule = (context) => {
const findings: AtsFinding[] = [];
const findings: AtsRuleFinding[] = [];
for (const section of context.sections) {
if (isCoverLetter(section) || !isRenderedSection(section)) continue;
@@ -255,8 +262,29 @@ const layoutRules: AtsRule = (context) => {
return findings;
};
/**
* A two-column template prints its sidebar as a column of its own, which most systems read after the main
* column (or interleave with it). Full-width pages print their sidebar sections in the main column instead.
*/
const twoColumnRules: AtsRule = (context) => {
const { layout, template } = context.data.metadata;
if (templateLayouts[template].columns !== 2) return [];
const printed = new Set(
context.sections
.filter((section) => !isCoverLetter(section) && isRenderedSection(section) && section.items.length > 0)
.map((section) => section.id),
);
const sidebar = new Set(
layout.pages.flatMap((page) => (page.fullWidth ? [] : page.sidebar.filter((id) => printed.has(id)))),
);
if (sidebar.size === 0) return [];
return [finding("TWO_COLUMN_LAYOUT", "/metadata/layout/pages", { sections: [...sidebar].join(",") })];
};
const typographyRules: AtsRule = (context) => {
const findings: AtsFinding[] = [];
const findings: AtsRuleFinding[] = [];
const { page, typography } = context.data.metadata;
if (typography.body.fontSize < MIN_BODY_FONT_SIZE) {
@@ -295,5 +323,6 @@ export const ATS_RULES: readonly AtsRule[] = [
structureRules,
titleRules,
layoutRules,
twoColumnRules,
typographyRules,
];
+26
View File
@@ -2,18 +2,44 @@ import type { AtsRuleCode } from "./catalog";
export type AtsSeverity = "error" | "warning" | "info";
/** The groups Check shows its rules in: contact details, dates, layout, section headings and writing. */
export type AtsCategory = "contact" | "dates" | "layout" | "headings" | "writing";
export type AtsFindingParams = Readonly<Record<string, string | number>>;
export type AtsFinding = {
code: AtsRuleCode;
severity: AtsSeverity;
/** JSON pointer to the value, with array indexes (`/sections/experience/items/0/period`). */
pointer: string;
/**
* Identifies the finding across edits: the code and the pointer with each array index replaced by the
* entry's id where it has one (`MALFORMED_URL:/sections/experience/items/#a1b2/website/url`), so moving
* entries doesn't change it. Ignored findings are stored by this key.
*/
key: string;
params?: AtsFindingParams;
};
export type AtsCategoryScore = {
/** Rules in the category that apply to this resume. */
total: number;
/** Those with no open finding. */
passed: number;
};
export type AtsReport = {
/** Open findings, most severe first. */
findings: readonly AtsFinding[];
/** Findings the author chose to ignore (`metadata.check.ignored`). They don't count against the score. */
ignored: readonly AtsFinding[];
/** Open findings by severity. */
counts: Readonly<Record<AtsSeverity, number>>;
/** Rules that apply to this resume: all of them, less the English heading rule for other languages. */
totalRules: number;
/** Applicable rules with no open finding. */
passedRules: number;
/** `passedRules / totalRules`, as a whole percentage. */
score: number;
categories: Readonly<Record<AtsCategory, AtsCategoryScore>>;
};
+3 -2
View File
@@ -30,9 +30,10 @@ function buildPlacement(data: ResumeData): ReadonlyMap<string, SectionPlacement>
if (!placement.has(id)) placement.set(id, "main");
}
const sidebarPlacement: SectionPlacement = page.fullWidth ? "main" : "sidebar";
// A full-width page prints no sidebar at all, so its sidebar sections only print if another page places them.
if (page.fullWidth) continue;
for (const id of page.sidebar) {
if (!placement.has(id)) placement.set(id, sidebarPlacement);
if (!placement.has(id)) placement.set(id, "sidebar");
}
}
+14
View File
@@ -738,6 +738,20 @@ export const metadataSchema = z.object({
"Structured style rules that target semantic resume sections and slots for React PDF rendering.",
),
stylesheet: semanticStylesheetSchema.optional(),
check: z
.object({
ignored: z
.array(z.string())
.catch([])
.describe("Check issues the author chose to ignore, by issue key (the rule code and where it applies)."),
hiddenTerms: z
.array(z.string())
.catch([])
.describe("Job-posting terms the author hid from Job match as not true for them."),
})
.optional()
.catch(undefined)
.describe("The author's Check choices for this resume. Not printed; missing until a choice is made."),
});
export const resumeDataSchema = z.looseObject({