feat(schema): structured resume dates with a read-time upgrade and dual write

Dated entries and roles carry dates (start, end, present, and raw when the
text couldn't be read exactly). The parser moves to the schema package and
reports how each date was written; parseResumeData fills dates, infers the
date format from how dates were typed and rewrites the legacy period/date
text from them, so older clients and API readers keep working. API writes
sync against the stored data, so an edit to the text alone is read back
into dates.

getById and getBySlug return upgraded data, ATS date rules and sorting read
dates, JSON Resume and LinkedIn imports map their dates directly, entry
titles may be empty (drafts aren't printed), and the MCP schema resource is
generated live in place of the stale schema.json.
This commit is contained in:
Amruth Pillai
2026-09-28 18:20:18 +02:00
parent fa19b891db
commit bda01febd5
32 changed files with 2082 additions and 4615 deletions
File diff suppressed because it is too large Load Diff
@@ -2,6 +2,7 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { ORPCError } from "@orpc/client";
import { SEMANTIC_CSS_LIMITS_V1 } from "@reactive-resume/resume/stylesheet";
import { parseResumeData } from "@reactive-resume/schema/resume/data";
import { syncResumeDates, upgradeResumeDates } from "@reactive-resume/schema/resume/dates";
import { parseResumeDataForWrite } from "@reactive-resume/schema/resume/write";
function parseApiResumeData(data: unknown, code: "BAD_REQUEST" | "INTERNAL_SERVER_ERROR", message: string): ResumeData {
@@ -21,8 +22,17 @@ function parseApiResumeData(data: unknown, code: "BAD_REQUEST" | "INTERNAL_SERVE
}
}
export const parseWritableResumeData = (data: unknown) =>
parseApiResumeData(data, "BAD_REQUEST", "Resume data does not match the canonical schema.");
/**
* Validates data before it's saved, then keeps structured dates and their legacy text in step. `previous` is
* the stored data being replaced, which tells an edit to the text alone (from a client that doesn't know
* structured dates) from an edit to the dates.
*/
export const parseWritableResumeData = (data: unknown, previous?: ResumeData) => {
const parsed = parseApiResumeData(data, "BAD_REQUEST", "Resume data does not match the canonical schema.");
upgradeResumeDates(parsed);
syncResumeDates(parsed, previous);
return parsed;
};
export const parseStoredResumeData = (data: unknown) =>
parseApiResumeData(data, "INTERNAL_SERVER_ERROR", "Stored resume data does not match the canonical schema.");
+10 -5
View File
@@ -140,9 +140,10 @@ async function applyResumePatchTx(
input.operations.forEach(assertValidPatchPointers);
let patchedData: ResumeData;
const storedData = parseStoredResumeData(existing.data);
try {
patchedData = applyResumePatches(parseStoredResumeData(existing.data), input.operations);
patchedData = applyResumePatches(storedData, input.operations);
} catch (error) {
if (error instanceof ResumePatchError) {
throw new ORPCError("INVALID_PATCH_OPERATIONS", {
@@ -158,7 +159,7 @@ async function applyResumePatchTx(
});
}
patchedData = parseWritableResumeData(patchedData);
patchedData = parseWritableResumeData(patchedData, storedData);
// The version guard is the ms-precision JS check above, under the SELECT ... FOR UPDATE lock.
// Never compare expectedUpdatedAt in SQL: rows stamped by Postgres now() (defaultNow() on
// insert) carry microseconds, while JS Dates are ms-truncated — SQL equality then matches
@@ -509,7 +510,8 @@ export const resumeService = {
if (!resume) throw new ORPCError("NOT_FOUND");
return resume;
// Clients get the current data shape (structured dates and their text in step), not the stored one.
return { ...resume, data: parseStoredResumeData(resume.data) };
},
getBySlug: async (input: {
@@ -562,7 +564,8 @@ export const resumeService = {
}
}
return toSharedResumeResponse(redactResumeForViewer(resume, isOwner(resume, viewer)), resume.hasPassword);
const current = { ...resume, data: parseStoredResumeData(resume.data) };
return toSharedResumeResponse(redactResumeForViewer(current, isOwner(current, viewer)), resume.hasPassword);
},
create: async (input: {
@@ -633,7 +636,9 @@ export const resumeService = {
if (!existing) throw new ORPCError("NOT_FOUND");
if (existing.isLocked) throw new ORPCError("RESUME_LOCKED");
const normalizedData = input.data ? parseWritableResumeData(input.data) : undefined;
const normalizedData = input.data
? parseWritableResumeData(input.data, parseStoredResumeData(existing.data))
: undefined;
const updateData: Partial<typeof schema.resume.$inferSelect> = {
...(input.name !== undefined ? { name: input.name } : {}),
...(input.slug !== undefined ? { slug: input.slug } : {}),
+15 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { formatDate, formatPeriod, formatSingleDate } from "./date";
import { formatDate, formatPeriod, formatSingleDate, toRangeDates, toSingleDates, toYearMonth } from "./date";
describe("formatDate", () => {
it("formats YYYY-MM as 'Month Year'", () => {
@@ -87,3 +87,17 @@ describe("formatSingleDate", () => {
expect(formatSingleDate("2024")).toBe("2024");
});
});
describe("structured dates", () => {
it("keeps the year and month of ISO dates", () => {
expect(toYearMonth("2024-01-15")).toBe("2024-01");
expect(toYearMonth("2024")).toBe("2024");
expect(toYearMonth("soon")).toBeNull();
});
it("marks a start without an end as ongoing", () => {
expect(toRangeDates("2020-03", undefined)).toEqual({ start: "2020-03", end: null, present: true });
expect(toRangeDates("2020-03", "2021")).toEqual({ start: "2020-03", end: "2021", present: false });
expect(toSingleDates("2019-11-02")).toEqual({ start: "2019-11", end: null, present: false });
});
});
+19
View File
@@ -1,3 +1,5 @@
import type { ResumeDates, YearMonth } from "@reactive-resume/schema/resume/dates";
// ponytail: Intl replaces the 12-line MONTH_NAMES array; pinned to en-US so output is stable
const fmt = new Intl.DateTimeFormat("en-US", { month: "long" });
@@ -49,3 +51,20 @@ export function formatSingleDate(date?: string): string {
if (!date) return "";
return formatDate(date, true);
}
/** The year and month of an ISO 8601 date ("2024-01-15" → "2024-01"); null when it isn't one. */
export function toYearMonth(date?: string): YearMonth | null {
const match = /^(\d{4})(?:-(0[1-9]|1[0-2]))?/.exec(date?.trim() ?? "");
if (!match?.[1]) return null;
return match[2] ? `${match[1]}-${match[2]}` : match[1];
}
/** Structured dates for a range of ISO dates; a start with no end is ongoing, as `formatPeriod` prints it. */
export function toRangeDates(startDate?: string, endDate?: string): ResumeDates {
const start = toYearMonth(startDate);
const end = toYearMonth(endDate);
return { start, end, present: Boolean(start) && !endDate };
}
/** Structured dates for a single ISO date. The day, if any, doesn't print. */
export const toSingleDates = (date?: string): ResumeDates => ({ start: toYearMonth(date), end: null, present: false });
+8 -1
View File
@@ -4,7 +4,7 @@ import { getNetworkIcon } from "@reactive-resume/resume/icons";
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { generateId } from "@reactive-resume/utils/string";
import { formatPeriod, formatSingleDate } from "./date";
import { formatPeriod, formatSingleDate, toRangeDates, toSingleDates } from "./date";
import { rethrowAsImportError } from "./error";
import { arrayToHtmlList, toHtmlDescription } from "./html";
import { parseLevel } from "./level";
@@ -221,6 +221,7 @@ function convertJSONResume(jsonResume: JSONResume): ResumeData {
position: work.position || "",
location: work.location || "",
period: formatPeriod(work.startDate, work.endDate),
dates: toRangeDates(work.startDate, work.endDate),
website: createItemWebsite(work.url),
roles: [],
description: toHtmlDescription(work.summary, work.highlights),
@@ -243,6 +244,7 @@ function convertJSONResume(jsonResume: JSONResume): ResumeData {
grade: edu.score || "",
location: "",
period: formatPeriod(edu.startDate, edu.endDate),
dates: toRangeDates(edu.startDate, edu.endDate),
website: createItemWebsite(edu.url),
description: edu.courses && edu.courses.length > 0 ? arrayToHtmlList(edu.courses) : "",
})),
@@ -260,6 +262,7 @@ function convertJSONResume(jsonResume: JSONResume): ResumeData {
hidden: false,
name: project.name || "",
period: formatPeriod(project.startDate, project.endDate),
dates: toRangeDates(project.startDate, project.endDate),
website: createItemWebsite(project.url),
description: toHtmlDescription(project.description, project.highlights),
})),
@@ -330,6 +333,7 @@ function convertJSONResume(jsonResume: JSONResume): ResumeData {
title: award.title || "",
awarder: award.awarder || "",
date: formatSingleDate(award.date),
dates: toSingleDates(award.date),
website: createItemWebsite(),
description: award.summary ? `<p>${award.summary}</p>` : "",
})),
@@ -348,6 +352,7 @@ function convertJSONResume(jsonResume: JSONResume): ResumeData {
title: cert.name || "",
issuer: cert.issuer || "",
date: formatSingleDate(cert.date),
dates: toSingleDates(cert.date),
website: createItemWebsite(cert.url),
description: "",
})),
@@ -366,6 +371,7 @@ function convertJSONResume(jsonResume: JSONResume): ResumeData {
title: pub.name || "",
publisher: pub.publisher || "",
date: formatSingleDate(pub.releaseDate),
dates: toSingleDates(pub.releaseDate),
website: createItemWebsite(pub.url),
description: pub.summary ? `<p>${pub.summary}</p>` : "",
})),
@@ -384,6 +390,7 @@ function convertJSONResume(jsonResume: JSONResume): ResumeData {
organization: vol.organization || "",
location: "",
period: formatPeriod(vol.startDate, vol.endDate),
dates: toRangeDates(vol.startDate, vol.endDate),
website: createItemWebsite(vol.url),
description: toHtmlDescription(vol.summary, vol.highlights),
})),
+6 -1
View File
@@ -45,6 +45,7 @@ describe("parseLinkedInExport", () => {
expect(item.company).toBe("Acme");
expect(item.position).toBe("Engineer");
expect(item.period).toBe("January 2020 - December 2022");
expect(item.dates).toEqual({ start: "2020-01", end: "2022-12", present: false });
expect(item.description).toBe("<p>Built stuff</p>");
});
@@ -56,6 +57,7 @@ describe("parseLinkedInExport", () => {
const result = parseLinkedInExport(zip);
expect(result.sections.experience.items).toHaveLength(1);
expect(result.sections.experience.items[0]!.period).toBe("January 2020 - Present");
expect(result.sections.experience.items[0]!.dates).toEqual({ start: "2020-01", end: null, present: true });
});
it("skips positions without a company name", () => {
@@ -123,7 +125,10 @@ describe("parseLinkedInExport", () => {
"Positions.csv": "Company Name,Started On,Finished On\nAcme,Jan 2020,2021-06-30\n",
});
expect(parseLinkedInExport(zip).sections.experience.items[0]!.period).toBe("January 2020 - 2021-06-30");
const [item] = parseLinkedInExport(zip).sections.experience.items;
expect(item!.period).toBe("January 2020 - 2021-06-30");
// An end cell it couldn't read leaves the text to be read, and flagged, when saved.
expect(item!.dates).toBeUndefined();
});
it("reads headers behind a UTF-8 byte order mark", () => {
+27 -3
View File
@@ -1,9 +1,10 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { ResumeDates } from "@reactive-resume/schema/resume/dates";
import { unzipSync } from "fflate";
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { generateId } from "@reactive-resume/utils/string";
import { formatDate } from "./date";
import { formatDate, toRangeDates, toSingleDates } from "./date";
import { rethrowAsImportError } from "./error";
import { toHtml } from "./html";
import { parseLevel } from "./level";
@@ -46,11 +47,18 @@ const MONTHS: Record<string, string> = {
// LinkedIn's "Started On" / "Finished On" cells are "Mon YYYY" (e.g. "Jan 2020") or a bare year.
// Anything else is kept verbatim rather than dropped.
function formatLinkedInDate(value = ""): string {
// LinkedIn writes "Jan 2020" (or a bare year); anything else stays as written and asks for a review.
function toLinkedInIsoDate(value = ""): string | null {
const trimmed = value.trim();
if (/^\d{4}$/.test(trimmed)) return trimmed;
const monthYear = /^([A-Za-z]{3})[a-z]*\s+(\d{4})$/.exec(trimmed);
const month = MONTHS[monthYear?.[1]?.toLowerCase() ?? ""];
return monthYear && month ? formatDate(`${monthYear[2]}-${month}`) : trimmed;
return monthYear && month ? `${monthYear[2]}-${month}` : null;
}
function formatLinkedInDate(value = ""): string {
const iso = toLinkedInIsoDate(value);
return iso ? formatDate(iso) : value.trim();
}
// Only an empty end cell means the entry is ongoing; an unrecognised one must not read as "Present".
@@ -61,6 +69,19 @@ function formatLinkedInPeriod(start?: string, end?: string): string {
return `${from} - ${to || "Present"}`;
}
// Dates only when every cell was read; otherwise the text is read (and flagged if need be) when saved.
function linkedInPeriodDates(start?: string, end?: string): ResumeDates | undefined {
const from = toLinkedInIsoDate(start);
const to = toLinkedInIsoDate(end);
if (!from || (end?.trim() && !to)) return undefined;
return toRangeDates(from, to ?? undefined);
}
function linkedInSingleDates(value?: string): ResumeDates | undefined {
const iso = toLinkedInIsoDate(value);
return iso ? toSingleDates(iso) : undefined;
}
const textToHtml = (text = "") => toHtml(text.split(/\r\n?|\n/));
const languageLevel = (proficiency = "") =>
@@ -203,6 +224,7 @@ export function parseLinkedInExport(zipBytes: Uint8Array): ResumeData {
position: position.Title || "",
location: position.Location || "",
period: formatLinkedInPeriod(position["Started On"], position["Finished On"]),
dates: linkedInPeriodDates(position["Started On"], position["Finished On"]),
website: emptyWebsite,
roles: [],
description: textToHtml(position.Description),
@@ -223,6 +245,7 @@ export function parseLinkedInExport(zipBytes: Uint8Array): ResumeData {
grade: "",
location: "",
period: formatLinkedInPeriod(edu["Start Date"], edu["End Date"]),
dates: linkedInPeriodDates(edu["Start Date"], edu["End Date"]),
website: emptyWebsite,
description: textToHtml(edu.Notes),
})),
@@ -270,6 +293,7 @@ export function parseLinkedInExport(zipBytes: Uint8Array): ResumeData {
title: cert.Name ?? "",
issuer: cert.Authority || "",
date: formatLinkedInDate(cert["Started On"]),
dates: linkedInSingleDates(cert["Started On"]),
website: linkWebsite(cert.Url),
description: "",
})),
+9 -9
View File
@@ -61,12 +61,12 @@ describe("parseResumeText", () => {
company: "Analytical Engines",
position: "Senior Engineer",
location: "Berlin",
period: "Jan 2020 - Present",
period: "Jan 2020 – Present",
});
expect(data.sections.experience.items[1]).toMatchObject({
company: "Babbage Ltd",
position: "Engineer",
period: "Mar 2016 - Dec 2019",
period: "Mar 2016 – Dec 2019",
});
});
@@ -80,7 +80,7 @@ describe("parseResumeText", () => {
expect(data.sections.education.items[0]).toMatchObject({
school: "University of London",
degree: "BSc Mathematics",
period: "2012 - 2016",
period: "2012 – 2016",
});
});
@@ -173,7 +173,7 @@ describe("parseResumeText review findings", () => {
expect(data.sections.experience.items[0]).toMatchObject({
company: "Analytical Engines",
position: "Senior Engineer",
period: "Jan 2020 - Present",
period: "Jan 2020 – Present",
});
});
@@ -201,7 +201,7 @@ describe("parseResumeText multi-line entry preambles", () => {
expect(data.sections.experience.items[0]).toMatchObject({
company: "ACME CORPORATION",
position: "Senior Engineer",
period: "Jan 2020 - Present",
period: "Jan 2020 – Present",
});
});
@@ -213,7 +213,7 @@ describe("parseResumeText multi-line entry preambles", () => {
expect(data.sections.education.items[0]).toMatchObject({
school: "UNIVERSITY OF LONDON",
degree: "BSc Mathematics",
period: "2012 - 2016",
period: "2012 – 2016",
});
});
@@ -237,7 +237,7 @@ describe("parseResumeText four-line entry preambles", () => {
company: "ACME CORPORATION",
position: "Senior Engineer",
location: "Berlin, Germany",
period: "Jan 2020 - Present",
period: "Jan 2020 – Present",
});
});
@@ -250,7 +250,7 @@ describe("parseResumeText four-line entry preambles", () => {
school: "UNIVERSITY OF LONDON",
degree: "BSc Mathematics",
location: "London, UK",
period: "2012 - 2016",
period: "2012 – 2016",
});
});
});
@@ -314,7 +314,7 @@ describe("parseResumeText keeps content the layout hides", () => {
);
expect(() => resumeDataSchema.parse(data)).not.toThrow();
expect(data.sections.experience.items[0]).toMatchObject({ company: "Acme Corp", period: "Jan 2020 - Present" });
expect(data.sections.experience.items[0]).toMatchObject({ company: "Acme Corp", period: "Jan 2020 – Present" });
expect(data.sections.education.items[0]).toMatchObject({ school: "University of London" });
expect(data.sections.certifications.items).toHaveLength(0);
});
+1 -1
View File
@@ -1,7 +1,7 @@
import type { ResumeData, SectionType } from "@reactive-resume/schema/resume/data";
import { parsePeriod, parseSingleDate } from "@reactive-resume/resume/ats";
import { parseResumeData } from "@reactive-resume/schema/resume/data";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { parsePeriod, parseSingleDate } from "@reactive-resume/schema/resume/period";
import { generateId } from "@reactive-resume/utils/string";
import { BULLET_PATTERN, toHtml } from "./html";
+3 -2
View File
@@ -2,7 +2,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { RouterClient } from "@orpc/server";
import type router from "@reactive-resume/api/routers";
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import schemaJSON from "@reactive-resume/schema/schema.json";
import { createResumeDataJsonSchema } from "@reactive-resume/schema/resume/json-schema";
import { MCP_TOOL_NAME as T } from "./mcp-tool-names";
export function registerResources(server: McpServer, client: RouterClient<typeof router>) {
@@ -63,7 +63,8 @@ export function registerResources(server: McpServer, client: RouterClient<typeof
{
uri: uri.href,
mimeType: "application/json" as const,
text: JSON.stringify(schemaJSON, null, 2),
// Generated from the Zod schema, like /schema.json, so it never drifts from what writes accept.
text: JSON.stringify(createResumeDataJsonSchema(), null, 2),
},
],
}),
+4
View File
@@ -280,7 +280,11 @@ export const TOOL_META = {
" /metadata/template — Change the template (e.g. 'azurill', 'bronzor', 'onyx')",
" /metadata/design/colors/primary — Change the primary color (rgba string)",
" /sections/interests/hidden — Hide/show a section",
" /sections/experience/items/0/dates — Set dates: { start, end, present } with years or",
" year-months ('2022' or '2022-03')",
"",
"Dates: write `dates`; the text in `period` (or `date` for awards, certifications and",
"publications) is rewritten from it in the resume's locale. Editing only the text still works.",
"Important: HTML content fields (description, summary.content) must use valid HTML.",
"New items must include a valid UUID as `id` and `hidden: false`.",
`Locked resumes cannot be patched; use \`${T.unlockResume}\` first.`,
+6 -1
View File
@@ -1,5 +1,10 @@
import type { DateToken, ExtractedDocument } from "../types";
import { isFutureEndpoint, isReversedPeriod, parsePeriod, parseSingleDate } from "../../ats/period";
import {
isFutureEndpoint,
isReversedPeriod,
parsePeriod,
parseSingleDate,
} from "@reactive-resume/schema/resume/period";
const MONTH_WORD = String.raw`[A-Za-z]{3,9}\.?`;
const ENDPOINT = String.raw`(?:${MONTH_WORD}\s*'?\d{2,4}|\d{1,2}[/.\-]\d{2,4}|\d{4})`;
-2
View File
@@ -47,8 +47,6 @@ export function lintResumeForAts(data: ResumeData, options: AtsLintOptions = {})
}
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_CODES } from "./catalog";
export { isFutureEndpoint, isReversedPeriod, parsePeriod, parseSingleDate } from "./period";
+10
View File
@@ -156,6 +156,16 @@ describe("date rules", () => {
expect(codesOf(data)).not.toContain("UNPARSEABLE_PERIOD");
});
it("reads structured dates rather than their printed text", () => {
const data = makeResume((resume) => {
resume.metadata.page.locale = "ja-JP";
resume.sections.experience.items = [
experienceItem({ period: "2022年3月 – 現在", dates: { start: "2022-03", end: null, present: true } }),
];
});
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" })];
+32 -9
View File
@@ -2,8 +2,14 @@ import type { CustomSectionType, ResumeData } from "@reactive-resume/schema/resu
import type { AtsRuleCode } from "./catalog";
import type { AtsFinding, AtsFindingParams } from "./types";
import type { WalkedSection } from "./walk";
import { resumeDatesSchema, resumeDatesToPeriod } from "@reactive-resume/schema/resume/dates";
import {
isFutureEndpoint,
isReversedPeriod,
parsePeriod,
parseSingleDate,
} from "@reactive-resume/schema/resume/period";
import { atsRuleSeverity } from "./catalog";
import { isFutureEndpoint, isReversedPeriod, parsePeriod, parseSingleDate } from "./period";
import { SECTION_TITLE_ALIASES } from "./section-aliases";
import { isRenderedSection } from "./walk";
@@ -99,13 +105,26 @@ const urlRules: AtsRule = (context) => {
return findings;
};
function periodFindings(raw: unknown, pointer: string, type: CustomSectionType, context: RuleContext): AtsFinding[] {
// Structured dates are read already; text that never parsed keeps its original in `raw`.
const readItemDates = (dates: unknown) => {
const parsed = resumeDatesSchema.safeParse(dates);
return parsed.success ? parsed.data : undefined;
};
function periodFindings(
raw: unknown,
dates: 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);
const structured = readItemDates(dates);
const parsed = structured ? resumeDatesToPeriod(structured) : parsePeriod(value, context.locale);
if (!parsed) return [finding("UNPARSEABLE_PERIOD", pointer, { value })];
const findings: AtsFinding[] = [];
@@ -119,13 +138,15 @@ function periodFindings(raw: unknown, pointer: string, type: CustomSectionType,
return findings;
}
function singleDateFindings(raw: unknown, pointer: string, context: RuleContext): AtsFinding[] {
function singleDateFindings(raw: unknown, dates: 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 structured = readItemDates(dates);
const readable = structured ? Boolean(structured.start) : Boolean(parseSingleDate(value, context.locale));
return readable ? [] : [finding("UNPARSEABLE_DATE", pointer, { value })];
}
const dateRules: AtsRule = (context) => {
@@ -135,15 +156,17 @@ const dateRules: AtsRule = (context) => {
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));
findings.push(
...periodFindings(item.value.period, item.value.dates, `${item.pointer}/period`, section.type, context),
);
findings.push(...singleDateFindings(item.value.date, item.value.dates, `${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));
const { period, dates } = role as Record<string, unknown>;
findings.push(...periodFindings(period, dates, `${item.pointer}/roles/${index}/period`, section.type, context));
});
}
}
+6 -3
View File
@@ -1,9 +1,12 @@
import type { PeriodEndpoint } from "./ats/period";
import { isReversedPeriod, parsePeriod } from "./ats/period";
import type { ResumeDates } from "@reactive-resume/schema/resume/dates";
import type { PeriodEndpoint } from "@reactive-resume/schema/resume/period";
import { resumeDatesToPeriod } from "@reactive-resume/schema/resume/dates";
import { isReversedPeriod, parsePeriod } from "@reactive-resume/schema/resume/period";
type SectionItemWithPeriod = {
id: string;
period: string;
dates?: ResumeDates | undefined;
};
export type SortSectionItemsByPeriodResult<T> = {
@@ -30,7 +33,7 @@ function compareEndpointDescending(left: PeriodEndpoint | undefined, right: Peri
}
function rankItem<T extends SectionItemWithPeriod>(item: T, index: number, locale: string): RankedItem<T> {
const period = parsePeriod(item.period, locale);
const period = item.dates ? resumeDatesToPeriod(item.dates) : parsePeriod(item.period, locale);
if (!period?.start || (period.end && isReversedPeriod(period.start, period.end))) {
return { item, index, group: "unresolved" };
}
-1
View File
@@ -9,7 +9,6 @@
"./icons": "./src/icons.ts",
"./resume/stylesheet": "./src/resume/stylesheet.ts",
"./resume/*": "./src/resume/*.ts",
"./schema.json": "./schema.json",
"./templates": "./src/templates.ts"
},
"scripts": {
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -357,7 +357,7 @@ describe("customFieldSchema", () => {
});
describe("experienceItemSchema", () => {
it("requires company name (min 1)", () => {
it("accepts a draft without a company (drafts aren't printed)", () => {
const result = experienceItemSchema.safeParse({
id: "abcdef0123456789",
hidden: false,
@@ -369,7 +369,7 @@ describe("experienceItemSchema", () => {
description: "",
roles: [],
});
expect(result.success).toBe(false);
expect(result.success).toBe(true);
});
it("validates a complete experience item", () => {
@@ -404,8 +404,8 @@ describe("experienceItemSchema", () => {
});
describe("skillItemSchema", () => {
it("requires name (min 1)", () => {
const invalid = {
it("accepts a draft without a name (drafts aren't printed)", () => {
const draft = {
id: "x",
hidden: false,
icon: "",
@@ -415,7 +415,7 @@ describe("skillItemSchema", () => {
level: 4,
keywords: [],
};
expect(skillItemSchema.safeParse(invalid).success).toBe(false);
expect(skillItemSchema.safeParse(draft).success).toBe(true);
});
it("clamps invalid level via .catch(0)", () => {
+75 -20
View File
@@ -1,5 +1,6 @@
import z from "zod";
import { templateSchema } from "../templates";
import { dateFormatSchema, resumeDatesSchema, syncResumeDates, upgradeResumeDates } from "./dates";
import { semanticStylesheetSchema } from "./stylesheet";
const iconSchema = z
@@ -132,28 +133,43 @@ export const summaryItemSchema = baseItemSchema.extend({
export type SummaryItem = z.infer<typeof summaryItemSchema>;
export const awardItemSchema = baseItemSchema.extend({
title: z.string().min(1).describe("The title of the award."),
title: z.string().describe("The title of the award. Empty while the entry is a draft, which isn't printed."),
awarder: z.string().describe("The awarder of the award."),
date: z.string().describe("The date when the award was received."),
date: z
.string()
.describe("The date when the award was received, as text. Kept in sync from `dates`; prefer writing `dates`."),
dates: resumeDatesSchema.optional(),
website: itemWebsiteSchema.describe("The website of the award, if any."),
description: z.string().describe("The description of the award. This should be a HTML-formatted string."),
});
export const certificationItemSchema = baseItemSchema.extend({
title: z.string().min(1).describe("The title of the certification."),
title: z.string().describe("The title of the certification. Empty while the entry is a draft, which isn't printed."),
issuer: z.string().describe("The issuer of the certification."),
date: z.string().describe("The date when the certification was received."),
date: z
.string()
.describe(
"The date when the certification was received, as text. Kept in sync from `dates`; prefer writing `dates`.",
),
dates: resumeDatesSchema.optional(),
website: itemWebsiteSchema.describe("The website of the certification, if any."),
description: z.string().describe("The description of the certification. This should be a HTML-formatted string."),
});
export const educationItemSchema = baseItemSchema.extend({
school: z.string().min(1).describe("The name of the school or institution."),
school: z
.string()
.describe("The name of the school or institution. Empty while the entry is a draft, which isn't printed."),
degree: z.string().describe("The degree or qualification obtained."),
area: z.string().describe("The area of study or specialization."),
grade: z.string().describe("The grade or score achieved."),
location: z.string().describe("The location of the school or institution."),
period: z.string().describe("The period of time the education was obtained over."),
period: z
.string()
.describe(
"The period of time the education was obtained over, as text. Kept in sync from `dates`; prefer writing `dates`.",
),
dates: resumeDatesSchema.optional(),
website: itemWebsiteSchema.describe("The website of the school or institution, if any."),
description: z.string().describe("The description of the education. This should be a HTML-formatted string."),
});
@@ -161,14 +177,19 @@ export const educationItemSchema = baseItemSchema.extend({
const roleItemSchema = z.object({
id: z.string().describe("The unique identifier for the role. Usually generated as a UUID."),
position: z.string().describe("The position or job title for this role."),
period: z.string().describe("The period of time this role was held."),
period: z
.string()
.describe("The period of time this role was held, as text. Kept in sync from `dates`; prefer writing `dates`."),
dates: resumeDatesSchema.optional(),
description: z.string().describe("The description of this specific role. This should be a HTML-formatted string."),
});
export type RoleItem = z.infer<typeof roleItemSchema>;
export const experienceItemSchema = baseItemSchema.extend({
company: z.string().min(1).describe("The name of the company or organization."),
company: z
.string()
.describe("The name of the company or organization. Empty while the entry is a draft, which isn't printed."),
position: z
.string()
.describe(
@@ -178,8 +199,9 @@ export const experienceItemSchema = baseItemSchema.extend({
period: z
.string()
.describe(
"The overall period of time at the company. When multiple roles are used, this should reflect the total tenure.",
"The overall period of time at the company, as text. When multiple roles are used, this should reflect the total tenure. Kept in sync from `dates`; prefer writing `dates`.",
),
dates: resumeDatesSchema.optional(),
website: itemWebsiteSchema.describe("The website of the company or organization, if any."),
description: z.string().describe("The description of the experience. This should be a HTML-formatted string."),
roles: z
@@ -191,7 +213,7 @@ export const experienceItemSchema = baseItemSchema.extend({
export const interestItemSchema = baseItemSchema.extend({
icon: iconSchema,
iconColor: iconColorSchema,
name: z.string().min(1).describe("The name of the interest/hobby."),
name: z.string().describe("The name of the interest/hobby. Empty while the entry is a draft, which isn't printed."),
keywords: z
.array(z.string())
.catch([])
@@ -199,7 +221,9 @@ export const interestItemSchema = baseItemSchema.extend({
});
export const languageItemSchema = baseItemSchema.extend({
language: z.string().min(1).describe("The name of the language the author knows."),
language: z
.string()
.describe("The name of the language the author knows. Empty while the entry is a draft, which isn't printed."),
fluency: z
.string()
.describe(
@@ -218,28 +242,44 @@ export const languageItemSchema = baseItemSchema.extend({
export const profileItemSchema = baseItemSchema.extend({
icon: iconSchema,
iconColor: iconColorSchema,
network: z.string().min(1).describe("The name of the network or platform."),
network: z
.string()
.describe("The name of the network or platform. Empty while the entry is a draft, which isn't printed."),
username: z.string().describe("The username of the author on the network or platform."),
website: itemWebsiteSchema.describe("The link to the profile of the author on the network or platform, if any."),
});
export const projectItemSchema = baseItemSchema.extend({
name: z.string().min(1).describe("The name of the project."),
period: z.string().describe("The period of time the project was worked on."),
name: z.string().describe("The name of the project. Empty while the entry is a draft, which isn't printed."),
period: z
.string()
.describe(
"The period of time the project was worked on, as text. Kept in sync from `dates`; prefer writing `dates`.",
),
dates: resumeDatesSchema.optional(),
website: itemWebsiteSchema.describe("The link to the project, if any."),
description: z.string().describe("The description of the project. This should be a HTML-formatted string."),
});
export const publicationItemSchema = baseItemSchema.extend({
title: z.string().min(1).describe("The title of the publication."),
title: z.string().describe("The title of the publication. Empty while the entry is a draft, which isn't printed."),
publisher: z.string().describe("The publisher of the publication."),
date: z.string().describe("The date when the publication was published."),
date: z
.string()
.describe(
"The date when the publication was published, as text. Kept in sync from `dates`; prefer writing `dates`.",
),
dates: resumeDatesSchema.optional(),
website: itemWebsiteSchema.describe("The link to the publication, if any."),
description: z.string().describe("The description of the publication. This should be a HTML-formatted string."),
});
export const referenceItemSchema = baseItemSchema.extend({
name: z.string().min(1).describe("The name of the reference, or a note such as 'Available upon request'."),
name: z
.string()
.describe(
"The name of the reference, or a note such as 'Available upon request'. Empty while the entry is a draft, which isn't printed.",
),
position: z.string().describe("The position or job title of the reference."),
website: itemWebsiteSchema.describe("The website or LinkedIn profile of the reference, if any."),
phone: z.string().describe("The phone number of the reference."),
@@ -253,7 +293,7 @@ export const referenceItemSchema = baseItemSchema.extend({
export const skillItemSchema = baseItemSchema.extend({
icon: iconSchema,
iconColor: iconColorSchema,
name: z.string().min(1).describe("The name of the skill."),
name: z.string().describe("The name of the skill. Empty while the entry is a draft, which isn't printed."),
proficiency: z
.string()
.describe(
@@ -274,9 +314,16 @@ export const skillItemSchema = baseItemSchema.extend({
});
export const volunteerItemSchema = baseItemSchema.extend({
organization: z.string().min(1).describe("The name of the organization or company."),
organization: z
.string()
.describe("The name of the organization or company. Empty while the entry is a draft, which isn't printed."),
location: z.string().describe("The location of the organization or company."),
period: z.string().describe("The period of time the author was volunteered at the organization or company."),
period: z
.string()
.describe(
"The period of time the author was volunteered at the organization or company, as text. Kept in sync from `dates`; prefer writing `dates`.",
),
dates: resumeDatesSchema.optional(),
website: itemWebsiteSchema.describe("The link to the organization or company, if any."),
description: z
.string()
@@ -494,6 +541,12 @@ export const pageSchema = z.object({
.string()
.describe("The locale of the page. Used for displaying pre-translated section headings, if not overridden.")
.catch("en-US"),
dateFormat: dateFormatSchema
.optional()
.catch(undefined)
.describe(
"How dates print: 'short' (Mar 2022), 'long' (March 2022), 'numeric' (03/2022) or 'iso' (2022-03). When missing, it's read from how the dates were typed.",
),
hideLinkUnderline: z.boolean().describe("Whether to hide the underlines of the links.").catch(false),
hideIcons: z.boolean().describe("Whether to hide the item-level icons (skills, profiles, interests).").catch(false),
hideSectionIcons: z
@@ -709,6 +762,8 @@ export const parseResumeData = (data: unknown): ResumeData => {
parsed.summary.showHeading ??= true;
for (const section of Object.values(parsed.sections)) section.showHeading ??= true;
for (const section of parsed.customSections) section.showHeading ??= true;
upgradeResumeDates(parsed);
syncResumeDates(parsed);
return parsed;
};
+296
View File
@@ -0,0 +1,296 @@
import type { ResumeData } from "./data";
import { describe, expect, it } from "vitest";
import { parseResumeData } from "./data";
import {
formatEntryDates,
formatResumeDates,
formatYearMonth,
getPresentLabel,
inferDateFormat,
readLegacyDates,
resumeDatesSchema,
syncResumeDates,
} from "./dates";
import { defaultResumeData } from "./default";
const range = (text: string, locale = "en-US") => readLegacyDates(text, { locale, single: false });
const single = (text: string, locale = "en-US") => readLegacyDates(text, { locale, single: true });
describe("readLegacyDates", () => {
it.each([
["March 2022 - Present", { start: "2022-03", end: null, present: true }],
["Mar 2020 – Dec 2021", { start: "2020-03", end: "2021-12", present: false }],
["2014 - 2018", { start: "2014", end: "2018", present: false }],
["03/2020 - 06/2021", { start: "2020-03", end: "2021-06", present: false }],
["2020-03 to 2021-06", { start: "2020-03", end: "2021-06", present: false }],
["2024 - 2020", { start: "2024", end: "2020", present: false }],
["2019", { start: "2019", end: null, present: false }],
])("reads %j exactly, with no review flag", (text, expected) => {
expect(range(text)).toEqual(expected);
});
it("reads month names and 'Present' in the resume's own locale", () => {
expect(range("janvier 2020 - aujourd'hui", "fr-FR")).toEqual({ start: "2020-01", end: null, present: true });
expect(range("März 2021 – heute", "de-DE")).toEqual({ start: "2021-03", end: null, present: true });
});
it.each([
["Summer 2016 - 2018", { start: "2016-06", end: "2018", present: false }],
["15/03/2020 - 2021", { start: "2020-03", end: "2021", present: false }],
["March 3, 2020 - Present", { start: "2020-03", end: null, present: true }],
])("keeps approximate text %j for a review", (text, expected) => {
expect(range(text)).toEqual({ ...expected, raw: text });
});
it("keeps unreadable text as written, with no dates", () => {
expect(range("Sometime in college")).toEqual({
start: null,
end: null,
present: false,
raw: "Sometime in college",
});
});
it("reads nothing from empty text and doesn't flag it", () => {
expect(range(" ")).toEqual({ start: null, end: null, present: false });
});
it("reads single dates for awards, certifications and publications", () => {
expect(single("June 2021")).toEqual({ start: "2021-06", end: null, present: false });
expect(single("2021-06-14")).toEqual({ start: "2021-06", end: null, present: false, raw: "2021-06-14" });
expect(single("Spring term")).toEqual({ start: null, end: null, present: false, raw: "Spring term" });
});
});
describe("resumeDatesSchema", () => {
it("accepts years and year-months only", () => {
expect(resumeDatesSchema.parse({ start: "2022-03", end: "2023", present: false })).toEqual({
start: "2022-03",
end: "2023",
present: false,
});
expect(resumeDatesSchema.parse({ start: "2022-13", end: "22", present: "no" })).toEqual({
start: null,
end: null,
present: false,
});
});
});
describe("inferDateFormat", () => {
it("follows how most dates were typed", () => {
expect(inferDateFormat(["March 2020 - Present", "January 2018 - February 2020"], "en-US")).toBe("long");
expect(inferDateFormat(["Mar 2020 - Present", "Sept 2019 - Dec 2019"], "en-US")).toBe("short");
expect(inferDateFormat(["03/2020 - 06/2021"], "en-US")).toBe("numeric");
expect(inferDateFormat(["2020-03 - 2021-06"], "en-US")).toBe("iso");
});
it("falls back to short month names when nothing tells", () => {
expect(inferDateFormat(["2014 - 2018", "May 2020", ""], "en-US")).toBe("short");
});
});
describe("getPresentLabel", () => {
it("uses the translation when there is one, else the language's usual word", () => {
expect(getPresentLabel("en-US")).toBe("Present");
expect(getPresentLabel("de-DE")).toBe("Heute");
expect(getPresentLabel("xx-XX")).toBe("Present");
});
});
describe("upgradeResumeDates", () => {
const withExperience = (period: string) => {
const data = structuredClone(defaultResumeData);
// Saved before structured dates: no date format yet.
delete data.metadata.page.dateFormat;
data.sections.experience.items = [
{
id: "job",
hidden: false,
company: "Lumen",
position: "Designer",
location: "",
period,
website: { url: "", label: "", inlineLink: false },
description: "",
roles: [{ id: "role", position: "Lead", period: "Jan 2023 - Present", description: "" }],
},
];
return data;
};
it("fills dates on entries and their roles, and the format from how they were typed", () => {
const data = parseResumeData(withExperience("January 2020 - March 2022"));
const [job] = data.sections.experience.items;
expect(job?.dates).toEqual({ start: "2020-01", end: "2022-03", present: false });
expect(job?.roles[0]?.dates).toEqual({ start: "2023-01", end: null, present: true });
expect(data.metadata.page.dateFormat).toBe("long");
});
it("is idempotent and never overwrites existing dates", () => {
const once = parseResumeData(withExperience("2020 - 2022"));
const [job] = once.sections.experience.items;
if (!job) throw new Error("Missing entry.");
job.dates = { start: "2019-05", end: null, present: true };
const twice = parseResumeData(JSON.parse(JSON.stringify(once)));
const thrice = parseResumeData(JSON.parse(JSON.stringify(twice)));
expect(twice.sections.experience.items[0]).toMatchObject({
dates: { start: "2019-05", end: null, present: true },
period: "May 2019 – Present",
});
expect(thrice).toEqual(twice);
});
});
describe("formatYearMonth", () => {
it.each([
["short", "en-US", "Mar 2022"],
["long", "en-US", "March 2022"],
["numeric", "en-US", "03/2022"],
["iso", "en-US", "2022-03"],
["long", "de-DE", "März 2022"],
["short", "fr-FR", "mars 2022"],
] as const)("prints 2022-03 as %s in %s", (format, locale, expected) => {
expect(formatYearMonth("2022-03", { locale, format })).toBe(expected);
});
it("prints year-only dates as the year in every format", () => {
expect(formatYearMonth("2014", { locale: "en-US", format: "long" })).toBe("2014");
expect(formatYearMonth("2014", { locale: "en-US", format: "numeric" })).toBe("2014");
});
});
describe("formatResumeDates", () => {
const options = { locale: "en-US", format: "short" } as const;
it("prints ranges, ongoing entries and single dates", () => {
expect(formatResumeDates({ start: "2022-03", end: null, present: true }, options)).toBe("Mar 2022 – Present");
expect(formatResumeDates({ start: "2014", end: "2018", present: false }, options)).toBe("2014 – 2018");
expect(formatResumeDates({ start: "2021-06", end: null, present: false }, options)).toBe("Jun 2021");
expect(formatResumeDates({ start: null, end: null, present: false }, options)).toBe("");
});
it("prints 'Present' in the resume's locale", () => {
expect(formatResumeDates({ start: "2021", end: null, present: true }, { locale: "de-DE" })).toBe("2021 – Heute");
});
it("prints text it couldn't read exactly as written, until it's reviewed", () => {
const dates = { start: "2016-06", end: "2018", present: false, raw: "Summer 2016 - 2018" };
expect(formatResumeDates(dates, options)).toBe("Summer 2016 - 2018");
});
});
describe("formatEntryDates", () => {
it("falls back to the legacy text for data without dates", () => {
expect(formatEntryDates(undefined, "whenever", { locale: "en-US" })).toBe("whenever");
});
});
describe("syncResumeDates", () => {
const resume = (period: string, extra: Partial<ResumeData["sections"]["experience"]["items"][number]> = {}) => {
const data = structuredClone(defaultResumeData);
data.metadata.page.dateFormat = "short";
data.sections.experience.items = [
{
id: "job",
hidden: false,
company: "Lumen",
position: "Designer",
location: "",
period,
website: { url: "", label: "", inlineLink: false },
description: "",
roles: [],
...extra,
},
];
data.sections.awards.items = [
{
id: "award",
hidden: false,
title: "Best in show",
awarder: "",
date: "June 2021",
website: { url: "", label: "", inlineLink: false },
description: "",
},
];
return data;
};
const job = (data: ResumeData) => data.sections.experience.items[0];
it("gives entries without dates their dates, and rewrites the text from them", () => {
const data = resume("March 2022 - Present");
syncResumeDates(data);
expect(job(data)?.dates).toEqual({ start: "2022-03", end: null, present: true });
expect(job(data)?.period).toBe("Mar 2022 – Present");
expect(data.sections.awards.items[0]).toMatchObject({ date: "Jun 2021", dates: { start: "2021-06" } });
});
it("rewrites the text when the dates change (dates are the source of truth)", () => {
const previous = parseResumeData(resume("2020 - 2022"));
syncResumeDates(previous);
const next = structuredClone(previous);
const entry = job(next);
if (!entry) throw new Error("Missing entry.");
entry.dates = { start: "2019-04", end: null, present: true };
syncResumeDates(next, previous);
expect(job(next)?.period).toBe("Apr 2019 – Present");
});
it("reads the text again when only the text changed, as clients unaware of dates do", () => {
const previous = parseResumeData(resume("2020 - 2022"));
syncResumeDates(previous);
const next = structuredClone(previous);
const entry = job(next);
if (!entry) throw new Error("Missing entry.");
entry.period = "Jan 2018 - Present";
syncResumeDates(next, previous);
expect(job(next)?.dates).toEqual({ start: "2018-01", end: null, present: true });
expect(job(next)?.period).toBe("Jan 2018 – Present");
});
it("reformats every entry when the locale or format changes, without reading the text again", () => {
const previous = parseResumeData(resume("March 2022 - Present"));
syncResumeDates(previous);
const next = structuredClone(previous);
next.metadata.page.locale = "ja-JP";
next.metadata.page.dateFormat = "long";
syncResumeDates(next, previous);
expect(job(next)?.dates).toEqual({ start: "2022-03", end: null, present: true });
expect(job(next)?.period).toBe(
`2022年3月 – ${formatResumeDates({ start: null, end: null, present: true }, { locale: "ja-JP" })}`,
);
});
it("leaves text it couldn't read untouched, and is idempotent", () => {
const data = resume("Summer 2016 - 2018");
syncResumeDates(data);
const once = structuredClone(data);
syncResumeDates(data, once);
expect(job(data)?.period).toBe("Summer 2016 - 2018");
expect(job(data)?.dates?.raw).toBe("Summer 2016 - 2018");
expect(data).toEqual(once);
});
it("keeps roles in step too", () => {
const data = resume("2020 - 2023", {
roles: [{ id: "role", position: "Lead", period: "2022 - Present", description: "" }],
});
syncResumeDates(data);
expect(job(data)?.roles[0]).toMatchObject({ period: "2022 – Present", dates: { start: "2022", present: true } });
});
});
+288
View File
@@ -0,0 +1,288 @@
import type { ResumeData } from "./data";
import type { EndpointReading, ParsedPeriod, PeriodEndpoint } from "./period";
import z from "zod";
import { ONGOING_TOKENS_BY_LANGUAGE, readPeriod, readSingleDate } from "./period";
import presentLabels from "./present-labels.json";
/** A year ("2022") or a year and month ("2022-03"). A month is optional, so year-only dates are exact. */
export const yearMonthSchema = z
.string()
.regex(/^\d{4}(?:-(?:0[1-9]|1[0-2]))?$/)
.describe('A year ("2022") or a year and month ("2022-03").');
export type YearMonth = z.infer<typeof yearMonthSchema>;
export const resumeDatesSchema = z
.object({
start: yearMonthSchema
.nullable()
.catch(null)
.describe("When it started, or the date itself for single-date entries (awards, certifications, publications)."),
end: yearMonthSchema.nullable().catch(null).describe("When it ended. Null while ongoing or for single dates."),
present: z.boolean().catch(false).describe("Whether it's ongoing, printed as 'Present'."),
raw: z
.string()
.optional()
.describe(
"The original text when it couldn't be read exactly, such as 'Summer 2016'. It's printed as written until the dates are edited, which removes it.",
),
})
.describe(
"Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them.",
);
export type ResumeDates = z.infer<typeof resumeDatesSchema>;
/** How dates print: Mar 2022, March 2022, 03/2022 or 2022-03. */
export const dateFormatSchema = z.enum(["short", "long", "numeric", "iso"]);
export type DateFormat = z.infer<typeof dateFormatSchema>;
export const DEFAULT_DATE_FORMAT: DateFormat = "short";
export const EMPTY_RESUME_DATES: ResumeDates = { start: null, end: null, present: false };
/** Dates of the same entry are the same when their fields are; `raw` counts, since it changes what prints. */
export const areResumeDatesEqual = (a: ResumeDates | undefined, b: ResumeDates | undefined) =>
a?.start === b?.start && a?.end === b?.end && a?.present === b?.present && a?.raw === b?.raw;
const labels: Record<string, string> = presentLabels;
/**
* "Present" in a resume's locale, as dates print it. Locales without a translation yet fall back to the
* language's usual word from the parser's list ("Heute", "Presente"), then to English.
*/
export function getPresentLabel(locale: string): string {
const language = locale.split("-")[0]?.toLowerCase() ?? "";
const translated = labels[locale];
if (translated && (translated !== "Present" || language === "en")) return translated;
const word = ONGOING_TOKENS_BY_LANGUAGE[language]?.[0];
if (!word) return "Present";
return word.charAt(0).toLocaleUpperCase(locale) + word.slice(1);
}
const toEndpoint = (value: YearMonth): PeriodEndpoint => {
const [year, month] = value.split("-").map(Number);
return month ? { year: year ?? 0, month } : { year: year ?? 0 };
};
/** Dates as the period shape sorting and checks use; null when nothing was read (no start). */
export const resumeDatesToPeriod = (dates: ResumeDates): ParsedPeriod | null =>
dates.start
? {
start: toEndpoint(dates.start),
...(dates.end ? { end: toEndpoint(dates.end) } : {}),
ongoing: dates.present,
}
: null;
export const toYearMonth = ({ year, month }: PeriodEndpoint): YearMonth =>
month === undefined ? String(year) : `${year}-${String(month).padStart(2, "0")}`;
// Seasons and days don't fit a year-month exactly, so readings that use them keep the original text.
const isExact = (reading: EndpointReading | undefined) =>
!reading || (reading.style !== "season" && reading.style !== "day");
/**
* Reads a legacy date text into structured dates. Exact readings (years, months by name or number, and
* "Present") drop the text; approximate or unreadable ones keep it in `raw`, which asks for a review and
* prints as written until then.
*/
export function readLegacyDates(text: string, options: { locale: string; single: boolean }): ResumeDates {
const value = text.trim();
if (!value) return { ...EMPTY_RESUME_DATES };
if (options.single) {
const reading = readSingleDate(value, options.locale);
if (!reading) return { ...EMPTY_RESUME_DATES, raw: value };
const dates: ResumeDates = { start: toYearMonth(reading.endpoint), end: null, present: false };
return isExact(reading) ? dates : { ...dates, raw: value };
}
const reading = readPeriod(value, options.locale, [getPresentLabel(options.locale)]);
if (!reading?.start) return { ...EMPTY_RESUME_DATES, raw: value };
const dates: ResumeDates = {
start: toYearMonth(reading.start.endpoint),
end: reading.end ? toYearMonth(reading.end.endpoint) : null,
present: reading.ongoing,
};
return isExact(reading.start) && isExact(reading.end) ? dates : { ...dates, raw: value };
}
/**
* Guesses the date format a resume was written in from its legacy texts, so upgraded resumes keep printing
* the way they were typed: month names long or short, numeric months, or ISO. Year-only dates don't vote.
*/
export function inferDateFormat(texts: readonly string[], locale: string): DateFormat {
const votes: Record<DateFormat, number> = { short: 0, long: 0, numeric: 0, iso: 0 };
for (const text of texts) {
const reading = readPeriod(text, locale) ?? { start: readSingleDate(text, locale) ?? undefined };
for (const endpoint of [reading.start, "end" in reading ? reading.end : undefined]) {
const style = endpoint?.style;
if (style === "short" || style === "long" || style === "numeric" || style === "iso") votes[style] += 1;
}
}
const [winner, count] = Object.entries(votes).sort((a, b) => b[1] - a[1])[0] as [DateFormat, number];
return count > 0 ? winner : DEFAULT_DATE_FORMAT;
}
/** An entry that carries dates, with the legacy text field that mirrors them. */
export type DatedEntry = { dates?: ResumeDates | undefined } & ({ period: string } | { date: string });
type DatedEntryVisitor = (entry: DatedEntry, field: "period" | "date") => void;
const RANGE_TYPES = new Set(["experience", "education", "projects", "volunteer"]);
const SINGLE_TYPES = new Set(["awards", "certifications", "publications"]);
function visitItems(type: string, items: readonly unknown[], visit: DatedEntryVisitor) {
const field = RANGE_TYPES.has(type) ? "period" : SINGLE_TYPES.has(type) ? "date" : null;
if (!field) return;
for (const item of items as DatedEntry[]) {
visit(item, field);
if (type !== "experience") continue;
for (const role of (item as { roles?: DatedEntry[] }).roles ?? []) visit(role, "period");
}
}
/**
* Visits every dated entry: experience (and its roles), education, projects and volunteer by period; awards,
* certifications and publications by date; custom sections of those types too. Mutating visitors are fine,
* including inside an immer draft.
*/
export function forEachDatedEntry(data: ResumeData, visit: DatedEntryVisitor) {
for (const [type, section] of Object.entries(data.sections)) visitItems(type, section.items, visit);
for (const section of data.customSections) visitItems(section.type, section.items, visit);
}
export const getLegacyDateText = (entry: DatedEntry, field: "period" | "date") =>
field === "period" ? (entry as { period: string }).period : (entry as { date: string }).date;
/**
* Read-time upgrade for resumes saved before structured dates: fills `dates` from each entry's legacy text,
* and the date format from how those texts were typed. Entries that already have dates keep them.
*/
export function upgradeResumeDates(data: ResumeData) {
const { page } = data.metadata;
if (!page.dateFormat) {
const texts: string[] = [];
forEachDatedEntry(data, (entry, field) => {
const text = getLegacyDateText(entry, field);
if (!entry.dates && text) texts.push(text);
});
page.dateFormat = inferDateFormat(texts, page.locale);
}
forEachDatedEntry(data, (entry, field) => {
entry.dates ??= readLegacyDates(getLegacyDateText(entry, field), { locale: page.locale, single: field === "date" });
});
}
export type DateFormatOptions = {
locale: string;
format?: DateFormat | undefined;
/** Defaults to "Present" in the locale. */
presentLabel?: string | undefined;
};
const monthFormatters = new Map<string, Intl.DateTimeFormat>();
function formatMonth(year: number, month: number, locale: string, style: "short" | "long") {
const key = `${locale}:${style}`;
let formatter = monthFormatters.get(key);
if (!formatter) {
try {
formatter = new Intl.DateTimeFormat(locale, { year: "numeric", month: style, timeZone: "UTC" });
} catch {
formatter = new Intl.DateTimeFormat("en-US", { year: "numeric", month: style, timeZone: "UTC" });
}
monthFormatters.set(key, formatter);
}
return formatter.format(Date.UTC(year, month - 1, 1));
}
/** One date in the chosen format: Mar 2022, March 2022, 03/2022 or 2022-03. Year-only dates print the year. */
export function formatYearMonth(value: YearMonth, { locale, format = DEFAULT_DATE_FORMAT }: DateFormatOptions) {
const [yearText, monthText] = value.split("-");
if (!monthText) return yearText ?? value;
const year = Number(yearText);
const month = Number(monthText);
if (format === "iso") return value;
if (format === "numeric") return `${monthText}/${yearText}`;
return formatMonth(year, month, locale, format);
}
/**
* Dates as they print: "Mar 2022 – Present", "2014 – 2018" or one date. Dates that still carry `raw` (text that
* couldn't be read exactly) print that text as written until someone reviews them.
*/
export function formatResumeDates(dates: ResumeDates, options: DateFormatOptions): string {
if (dates.raw !== undefined) return dates.raw;
const start = dates.start ? formatYearMonth(dates.start, options) : "";
const end = dates.present
? (options.presentLabel ?? getPresentLabel(options.locale))
: dates.end
? formatYearMonth(dates.end, options)
: "";
return start && end ? `${start} – ${end}` : start || end;
}
type PageDateOptions = { locale: string; dateFormat?: DateFormat | undefined };
/** Prints an entry's dates, or its legacy text when it has none (data that skipped the read-time upgrade). */
export const formatEntryDates = (dates: ResumeDates | undefined, text: string, page: PageDateOptions) =>
dates ? formatResumeDates(dates, { locale: page.locale, format: page.dateFormat }) : text;
type Snapshot = { text: string; dates: ResumeDates | undefined };
const entryKey = (entry: DatedEntry) => (entry as { id?: string }).id;
/**
* Keeps structured dates and the legacy text (`period` or `date`) in step before resume data is saved, so
* older app versions and API clients that read the text keep working (the dual write).
*
* Dates are the source of truth: the text is rewritten from them in the resume's locale and date format.
* The one exception is an edit that changed only the text since `previous` (a client that doesn't know
* structured dates); then the text is read into new dates. Entries without dates get them from their text.
*
* Mutates `data` in place and writes only what changed, so it's safe inside an immer draft.
*/
export function syncResumeDates(data: ResumeData, previous?: ResumeData) {
const { locale, dateFormat } = data.metadata.page;
const options: DateFormatOptions = { locale, format: dateFormat, presentLabel: getPresentLabel(locale) };
const before = new Map<string, Snapshot>();
if (previous) {
forEachDatedEntry(previous, (entry, field) => {
const key = entryKey(entry);
if (key) before.set(key, { text: getLegacyDateText(entry, field), dates: entry.dates });
});
}
forEachDatedEntry(data, (entry, field) => {
const text = getLegacyDateText(entry, field);
const read = () => readLegacyDates(text, { locale, single: field === "date" });
if (!entry.dates) {
entry.dates = read();
} else if (text !== formatResumeDates(entry.dates, options)) {
const key = entryKey(entry);
const snapshot = key ? before.get(key) : undefined;
const onlyTextChanged = snapshot && snapshot.text !== text && areResumeDatesEqual(snapshot.dates, entry.dates);
if (onlyTextChanged) entry.dates = read();
}
const formatted = formatResumeDates(entry.dates, options);
if (formatted === text) return;
if (field === "period") (entry as { period: string }).period = formatted;
else (entry as { date: string }).date = formatted;
});
}
+1
View File
@@ -79,6 +79,7 @@ export const defaultResumeData: ResumeData = {
marginY: 12,
format: "a4",
locale: "en-US",
dateFormat: "short",
hideLinkUnderline: false,
hideIcons: false,
hideSectionIcons: true,
@@ -6,7 +6,24 @@ export type ParsedPeriod = {
ongoing: boolean;
};
type EndpointResult = PeriodEndpoint | "ongoing";
/**
* How an endpoint was written. "long" and "short" are month names ("March", "Mar"); "name" is a month whose
* long and short names are the same ("May"). "season" and "day" lose or add precision, so they aren't exact.
*/
export type EndpointStyle = "year" | "iso" | "numeric" | "long" | "short" | "name" | "season" | "day";
export type EndpointReading = { endpoint: PeriodEndpoint; style: EndpointStyle };
/** A period with how each endpoint was written, so callers can tell exact readings from approximate ones. */
export type PeriodReading = {
start?: EndpointReading;
end?: EndpointReading;
ongoing: boolean;
};
type EndpointResult = EndpointReading | "ongoing";
type MonthName = { month: number; style: "long" | "short" | "name" };
const MIN_YEAR = 1900;
const MAX_YEAR = 2100;
@@ -77,40 +94,43 @@ const DASH_CHARS = new Set(["-", "–", "—", "~"]);
const SPACED_SEPARATOR = /\s(?:[-–—~]+|to|through|until)\s/;
const monthLookupCache = new Map<string, ReadonlyMap<string, number>>();
const monthLookupCache = new Map<string, ReadonlyMap<string, MonthName>>();
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>();
function buildMonthLookup(locale: string): ReadonlyMap<string, MonthName> {
const lookup = new Map<string, MonthName>();
for (const tag of new Set(["en-US", locale])) {
for (const month of ["long", "short"] as const) {
for (const style of ["long", "short"] as const) {
let format: Intl.DateTimeFormat;
try {
format = new Intl.DateTimeFormat(tag, { month, timeZone: "UTC" });
format = new Intl.DateTimeFormat(tag, { month: style, 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 (let month = 1; month <= 12; month++) {
const name = normalizeToken(format.format(Date.UTC(2000, month - 1, 1)));
if (!name) continue;
const existing = lookup.get(name);
// "May" is both the long and the short name.
lookup.set(name, existing && existing.style !== style ? { month, style: "name" } : { month, style });
}
}
}
for (const [alias, month] of Object.entries(EXTRA_MONTH_ALIASES)) {
if (!lookup.has(alias)) lookup.set(alias, month);
if (!lookup.has(alias)) lookup.set(alias, { month, style: "short" });
}
return lookup;
}
function getMonthLookup(locale: string): ReadonlyMap<string, number> {
function getMonthLookup(locale: string): ReadonlyMap<string, MonthName> {
const cached = monthLookupCache.get(locale);
if (cached) return cached;
@@ -126,39 +146,48 @@ function toEndpoint(year: number, month?: number): PeriodEndpoint | null {
return { year, month };
}
function parseEndpoint(raw: string, months: ReadonlyMap<string, number>): EndpointResult | null {
function read(endpoint: PeriodEndpoint | null, style: EndpointStyle): EndpointReading | null {
return endpoint ? { endpoint, style } : null;
}
type Tokens = { months: ReadonlyMap<string, MonthName>; present: ReadonlySet<string> };
function parseEndpoint(raw: string, { months, present }: Tokens): EndpointResult | null {
const value = normalizeWhitespace(raw);
if (!value) return null;
if (PRESENT_TOKENS.has(value.replace(/\p{P}+$/u, ""))) return "ongoing";
if (present.has(value.replace(/\p{P}+$/u, ""))) return "ongoing";
const yearOnly = /^(\d{4})$/.exec(value);
if (yearOnly?.[1]) return toEndpoint(Number(yearOnly[1]));
if (yearOnly?.[1]) return read(toEndpoint(Number(yearOnly[1])), "year");
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]));
if (iso?.[1] && iso[2]) return read(toEndpoint(Number(iso[1]), Number(iso[2])), iso[3] ? "day" : "iso");
const monthYear = /^(\d{1,2})[/.](\d{4})$/.exec(value);
if (monthYear?.[1] && monthYear[2]) return toEndpoint(Number(monthYear[2]), Number(monthYear[1]));
if (monthYear?.[1] && monthYear[2]) return read(toEndpoint(Number(monthYear[2]), Number(monthYear[1])), "numeric");
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);
return read(toEndpoint(year, first) ?? toEndpoint(year, second), "day");
}
const named = /^(\p{L}+\.?)\s+(?:\d{1,2},?\s+)?(\d{4})$/u.exec(value);
if (named?.[1] && named[2]) {
const named = /^(\p{L}+\.?)\s+(\d{1,2},?\s+)?(\d{4})$/u.exec(value);
if (named?.[1] && named[3]) {
const token = normalizeToken(named[1]);
const month = months.get(token) ?? SEASON_MONTHS[token];
return month === undefined ? null : toEndpoint(Number(named[2]), month);
const year = Number(named[3]);
const month = months.get(token);
if (month) return read(toEndpoint(year, month.month), named[2] ? "day" : month.style);
const season = SEASON_MONTHS[token];
return season === undefined ? null : read(toEndpoint(year, season), "season");
}
return null;
}
function toPeriod(start: EndpointResult | undefined, end: EndpointResult | undefined): ParsedPeriod {
function toReading(start: EndpointResult | undefined, end: EndpointResult | undefined): PeriodReading {
return {
...(start && start !== "ongoing" ? { start } : {}),
...(end && end !== "ongoing" ? { end } : {}),
@@ -166,6 +195,18 @@ function toPeriod(start: EndpointResult | undefined, end: EndpointResult | undef
};
}
const toPeriod = (reading: PeriodReading): ParsedPeriod => ({
...(reading.start ? { start: reading.start.endpoint } : {}),
...(reading.end ? { end: reading.end.endpoint } : {}),
ongoing: reading.ongoing,
});
function getTokens(locale: string, presentWords: readonly string[] = []): Tokens {
const months = getMonthLookup(locale);
if (presentWords.length === 0) return { months, present: PRESENT_TOKENS };
return { months, present: new Set([...PRESENT_TOKENS, ...presentWords.map(normalizeWhitespace)]) };
}
function splitOnce(value: string, separator: RegExp): [string, string] | null {
const match = separator.exec(value);
if (!match) return null;
@@ -188,45 +229,59 @@ function* dashSplits(value: string): Generator<[string, string]> {
}
}
function parseSplit(parts: [string, string], months: ReadonlyMap<string, number>): ParsedPeriod | null {
const start = parseEndpoint(parts[0], months);
function parseSplit(parts: [string, string], tokens: Tokens): PeriodReading | null {
const start = parseEndpoint(parts[0], tokens);
if (!start || start === "ongoing") return null;
const end = parseEndpoint(parts[1], months);
return end ? toPeriod(start, end) : null;
const end = parseEndpoint(parts[1], tokens);
return end ? toReading(start, end) : null;
}
export function parsePeriod(value: string, locale = "en-US"): ParsedPeriod | null {
/**
* Reads a period such as "Mar 2020 – Present" and reports how each endpoint was written. `presentWords` adds
* words for "Present" beyond the built-in list, such as the label a resume's own locale prints.
*/
export function readPeriod(value: string, locale = "en-US", presentWords?: readonly string[]): PeriodReading | null {
const normalized = normalizeWhitespace(value);
if (!normalized) return null;
const months = getMonthLookup(locale);
const tokens = getTokens(locale, presentWords);
const single = parseEndpoint(normalized, months);
if (single) return single === "ongoing" ? null : toPeriod(single, undefined);
const single = parseEndpoint(normalized, tokens);
if (single) return single === "ongoing" ? null : toReading(single, undefined);
const spaced = splitOnce(normalized, SPACED_SEPARATOR);
if (spaced) {
const period = parseSplit(spaced, months);
const period = parseSplit(spaced, tokens);
if (period) return period;
}
for (const candidate of dashSplits(normalized)) {
const period = parseSplit(candidate, months);
const period = parseSplit(candidate, tokens);
if (period) return period;
}
return null;
}
export function parseSingleDate(value: string, locale = "en-US"): PeriodEndpoint | null {
/** Reads a single date such as "Mar 2020" and reports how it was written. */
export function readSingleDate(value: string, locale = "en-US"): EndpointReading | null {
const normalized = normalizeWhitespace(value);
if (!normalized) return null;
const result = parseEndpoint(normalized, getMonthLookup(locale));
const result = parseEndpoint(normalized, getTokens(locale));
return result && result !== "ongoing" ? result : null;
}
export function parsePeriod(value: string, locale = "en-US"): ParsedPeriod | null {
const reading = readPeriod(value, locale);
return reading ? toPeriod(reading) : null;
}
export function parseSingleDate(value: string, locale = "en-US"): PeriodEndpoint | null {
return readSingleDate(value, locale)?.endpoint ?? null;
}
export function isReversedPeriod(start: PeriodEndpoint, end: PeriodEndpoint): boolean {
return start.year * 12 + (start.month ?? 1) > end.year * 12 + (end.month ?? 12);
}
@@ -0,0 +1,57 @@
{
"af-ZA": "Present",
"am-ET": "Present",
"ar-SA": "Present",
"az-AZ": "Present",
"bg-BG": "Present",
"bn-BD": "Present",
"ca-ES": "Present",
"cs-CZ": "Present",
"da-DK": "Present",
"de-DE": "Present",
"el-GR": "Present",
"en-GB": "Present",
"en-US": "Present",
"es-ES": "Present",
"fa-IR": "Present",
"fi-FI": "Present",
"fr-FR": "Present",
"he-IL": "Present",
"hi-IN": "Present",
"hu-HU": "Present",
"id-ID": "Present",
"it-IT": "Present",
"ja-JP": "Present",
"km-KH": "Present",
"kn-IN": "Present",
"ko-KR": "Present",
"lt-LT": "Present",
"lv-LV": "Present",
"ml-IN": "Present",
"mr-IN": "Present",
"ms-MY": "Present",
"ne-NP": "Present",
"nl-NL": "Present",
"no-NO": "Present",
"or-IN": "Present",
"pl-PL": "Present",
"pt-BR": "Present",
"pt-PT": "Present",
"ro-RO": "Present",
"ru-RU": "Present",
"sk-SK": "Present",
"sl-SI": "Present",
"sq-AL": "Present",
"sr-SP": "Present",
"sv-SE": "Present",
"ta-IN": "Present",
"te-IN": "Present",
"th-TH": "Present",
"tr-TR": "Present",
"uk-UA": "Present",
"uz-UZ": "Present",
"vi-VN": "Present",
"zh-CN": "Present",
"zh-TW": "Present",
"zu-ZA": "Present"
}
+249 -40
View File
@@ -14,18 +14,82 @@ Choose one coherent shape for each union value. Required fields are local to tha
| Path | Type/variant | Item schema | Representative required shape |
| --- | --- | --- | --- |
| `sections.experience.items[].dates.start` | variant 1 | — | `string` |
| `sections.experience.items[].dates.start` | variant 2 | — | `null` |
| `sections.experience.items[].dates.end` | variant 1 | — | `string` |
| `sections.experience.items[].dates.end` | variant 2 | — | `null` |
| `sections.experience.items[].roles[].dates.start` | variant 1 | — | `string` |
| `sections.experience.items[].roles[].dates.start` | variant 2 | — | `null` |
| `sections.experience.items[].roles[].dates.end` | variant 1 | — | `string` |
| `sections.experience.items[].roles[].dates.end` | variant 2 | — | `null` |
| `sections.education.items[].dates.start` | variant 1 | — | `string` |
| `sections.education.items[].dates.start` | variant 2 | — | `null` |
| `sections.education.items[].dates.end` | variant 1 | — | `string` |
| `sections.education.items[].dates.end` | variant 2 | — | `null` |
| `sections.projects.items[].dates.start` | variant 1 | — | `string` |
| `sections.projects.items[].dates.start` | variant 2 | — | `null` |
| `sections.projects.items[].dates.end` | variant 1 | — | `string` |
| `sections.projects.items[].dates.end` | variant 2 | — | `null` |
| `sections.awards.items[].dates.start` | variant 1 | — | `string` |
| `sections.awards.items[].dates.start` | variant 2 | — | `null` |
| `sections.awards.items[].dates.end` | variant 1 | — | `string` |
| `sections.awards.items[].dates.end` | variant 2 | — | `null` |
| `sections.certifications.items[].dates.start` | variant 1 | — | `string` |
| `sections.certifications.items[].dates.start` | variant 2 | — | `null` |
| `sections.certifications.items[].dates.end` | variant 1 | — | `string` |
| `sections.certifications.items[].dates.end` | variant 2 | — | `null` |
| `sections.publications.items[].dates.start` | variant 1 | — | `string` |
| `sections.publications.items[].dates.start` | variant 2 | — | `null` |
| `sections.publications.items[].dates.end` | variant 1 | — | `string` |
| `sections.publications.items[].dates.end` | variant 2 | — | `null` |
| `sections.volunteer.items[].dates.start` | variant 1 | — | `string` |
| `sections.volunteer.items[].dates.start` | variant 2 | — | `null` |
| `sections.volunteer.items[].dates.end` | variant 1 | — | `string` |
| `sections.volunteer.items[].dates.end` | variant 2 | — | `null` |
| `customSections[]` | `summary` | `summaryItemSchema` | `{ id, hidden, content }` |
| `customSections[]` | `profiles` | `profileItemSchema` | `{ id, hidden, icon, iconColor, network, username, website }` |
| `customSections[]` | `experience` | `experienceItemSchema` | `{ id, hidden, company, position, location, period, website, description, roles }` |
| `customSections[].items[].dates.start` | variant 1 | — | `string` |
| `customSections[].items[].dates.start` | variant 2 | — | `null` |
| `customSections[].items[].dates.end` | variant 1 | — | `string` |
| `customSections[].items[].dates.end` | variant 2 | — | `null` |
| `customSections[].items[].roles[].dates.start` | variant 1 | — | `string` |
| `customSections[].items[].roles[].dates.start` | variant 2 | — | `null` |
| `customSections[].items[].roles[].dates.end` | variant 1 | — | `string` |
| `customSections[].items[].roles[].dates.end` | variant 2 | — | `null` |
| `customSections[]` | `education` | `educationItemSchema` | `{ id, hidden, school, degree, area, grade, location, period, website, description }` |
| `customSections[].items[].dates.start` | variant 1 | — | `string` |
| `customSections[].items[].dates.start` | variant 2 | — | `null` |
| `customSections[].items[].dates.end` | variant 1 | — | `string` |
| `customSections[].items[].dates.end` | variant 2 | — | `null` |
| `customSections[]` | `projects` | `projectItemSchema` | `{ id, hidden, name, period, website, description }` |
| `customSections[].items[].dates.start` | variant 1 | — | `string` |
| `customSections[].items[].dates.start` | variant 2 | — | `null` |
| `customSections[].items[].dates.end` | variant 1 | — | `string` |
| `customSections[].items[].dates.end` | variant 2 | — | `null` |
| `customSections[]` | `skills` | `skillItemSchema` | `{ id, hidden, icon, iconColor, name, proficiency, level, keywords }` |
| `customSections[]` | `languages` | `languageItemSchema` | `{ id, hidden, language, fluency, level }` |
| `customSections[]` | `interests` | `interestItemSchema` | `{ id, hidden, icon, iconColor, name, keywords }` |
| `customSections[]` | `awards` | `awardItemSchema` | `{ id, hidden, title, awarder, date, website, description }` |
| `customSections[].items[].dates.start` | variant 1 | — | `string` |
| `customSections[].items[].dates.start` | variant 2 | — | `null` |
| `customSections[].items[].dates.end` | variant 1 | — | `string` |
| `customSections[].items[].dates.end` | variant 2 | — | `null` |
| `customSections[]` | `certifications` | `certificationItemSchema` | `{ id, hidden, title, issuer, date, website, description }` |
| `customSections[].items[].dates.start` | variant 1 | — | `string` |
| `customSections[].items[].dates.start` | variant 2 | — | `null` |
| `customSections[].items[].dates.end` | variant 1 | — | `string` |
| `customSections[].items[].dates.end` | variant 2 | — | `null` |
| `customSections[]` | `publications` | `publicationItemSchema` | `{ id, hidden, title, publisher, date, website, description }` |
| `customSections[].items[].dates.start` | variant 1 | — | `string` |
| `customSections[].items[].dates.start` | variant 2 | — | `null` |
| `customSections[].items[].dates.end` | variant 1 | — | `string` |
| `customSections[].items[].dates.end` | variant 2 | — | `null` |
| `customSections[]` | `volunteer` | `volunteerItemSchema` | `{ id, hidden, organization, location, period, website, description }` |
| `customSections[].items[].dates.start` | variant 1 | — | `string` |
| `customSections[].items[].dates.start` | variant 2 | — | `null` |
| `customSections[].items[].dates.end` | variant 1 | — | `string` |
| `customSections[].items[].dates.end` | variant 2 | — | `null` |
| `customSections[]` | `references` | `referenceItemSchema` | `{ id, hidden, name, position, website, phone, description }` |
| `customSections[]` | `cover-letter` | `coverLetterItemSchema` | `{ id, hidden, recipient, content }` |
@@ -84,7 +148,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `sections.profiles.items[].hidden` | `boolean` | yes | — | Whether to hide the item from the resume. |
| `sections.profiles.items[].icon` | `string` | yes | — | The icon to display for the custom field. Must be a valid icon name from @phosphor-icons/web icon set, or an empty string to hide. Default to '' (empty string) when unsure which icons are available. |
| `sections.profiles.items[].iconColor` | `string` | yes | default: "" | Custom color for the icon, defined as rgba(r, g, b, a). Leave blank to use the template default icon color. |
| `sections.profiles.items[].network` | `string` | yes | minLength: 1 | The name of the network or platform. |
| `sections.profiles.items[].network` | `string` | yes | — | The name of the network or platform. Empty while the entry is a draft, which isn't printed. |
| `sections.profiles.items[].username` | `string` | yes | — | The username of the author on the network or platform. |
| `sections.profiles.items[].website` | `object` | yes | default: {"url":"","label":"","inlineLink":false} | The link to the profile of the author on the network or platform, if any. |
| `sections.profiles.items[].website.url` | `string` | yes | — | The URL to show as a link. Must be a valid URL with a protocol (http:// or https://). |
@@ -102,10 +166,19 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `sections.experience.items[]` | `object` | — | — | — |
| `sections.experience.items[].id` | `string` | yes | — | The unique identifier for the item. Usually generated as a UUID. |
| `sections.experience.items[].hidden` | `boolean` | yes | — | Whether to hide the item from the resume. |
| `sections.experience.items[].company` | `string` | yes | minLength: 1 | The name of the company or organization. |
| `sections.experience.items[].company` | `string` | yes | — | The name of the company or organization. Empty while the entry is a draft, which isn't printed. |
| `sections.experience.items[].position` | `string` | yes | — | The position held at the company or organization. Used when there is only a single role. If multiple roles are provided in the 'roles' field, this serves as a summary title or can be left blank. |
| `sections.experience.items[].location` | `string` | yes | — | The location of the company or organization. |
| `sections.experience.items[].period` | `string` | yes | — | The overall period of time at the company. When multiple roles are used, this should reflect the total tenure. |
| `sections.experience.items[].period` | `string` | yes | — | The overall period of time at the company, as text. When multiple roles are used, this should reflect the total tenure. Kept in sync from `dates`; prefer writing `dates`. |
| `sections.experience.items[].dates` | `object` | no | — | Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them. |
| `sections.experience.items[].dates.start` | `string or null` | yes | default: null | When it started, or the date itself for single-date entries (awards, certifications, publications). |
| `sections.experience.items[].dates.start` | `string` | yes (variant 1 at sections.experience.items[].dates.start) | — | A year ("2022") or a year and month ("2022-03"). |
| `sections.experience.items[].dates.start` | `null` | yes (variant 2 at sections.experience.items[].dates.start) | — | — |
| `sections.experience.items[].dates.end` | `string or null` | yes | default: null | When it ended. Null while ongoing or for single dates. |
| `sections.experience.items[].dates.end` | `string` | yes (variant 1 at sections.experience.items[].dates.end) | — | A year ("2022") or a year and month ("2022-03"). |
| `sections.experience.items[].dates.end` | `null` | yes (variant 2 at sections.experience.items[].dates.end) | — | — |
| `sections.experience.items[].dates.present` | `boolean` | yes | default: false | Whether it's ongoing, printed as 'Present'. |
| `sections.experience.items[].dates.raw` | `string` | no | — | The original text when it couldn't be read exactly, such as 'Summer 2016'. It's printed as written until the dates are edited, which removes it. |
| `sections.experience.items[].website` | `object` | yes | default: {"url":"","label":"","inlineLink":false} | The website of the company or organization, if any. |
| `sections.experience.items[].website.url` | `string` | yes | — | The URL to show as a link. Must be a valid URL with a protocol (http:// or https://). |
| `sections.experience.items[].website.label` | `string` | yes | — | The label to display for the URL. Leave blank to display the URL as-is. |
@@ -115,7 +188,16 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `sections.experience.items[].roles[]` | `object` | — | — | — |
| `sections.experience.items[].roles[].id` | `string` | yes | — | The unique identifier for the role. Usually generated as a UUID. |
| `sections.experience.items[].roles[].position` | `string` | yes | — | The position or job title for this role. |
| `sections.experience.items[].roles[].period` | `string` | yes | — | The period of time this role was held. |
| `sections.experience.items[].roles[].period` | `string` | yes | — | The period of time this role was held, as text. Kept in sync from `dates`; prefer writing `dates`. |
| `sections.experience.items[].roles[].dates` | `object` | no | — | Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them. |
| `sections.experience.items[].roles[].dates.start` | `string or null` | yes | default: null | When it started, or the date itself for single-date entries (awards, certifications, publications). |
| `sections.experience.items[].roles[].dates.start` | `string` | yes (variant 1 at sections.experience.items[].roles[].dates.start) | — | A year ("2022") or a year and month ("2022-03"). |
| `sections.experience.items[].roles[].dates.start` | `null` | yes (variant 2 at sections.experience.items[].roles[].dates.start) | — | — |
| `sections.experience.items[].roles[].dates.end` | `string or null` | yes | default: null | When it ended. Null while ongoing or for single dates. |
| `sections.experience.items[].roles[].dates.end` | `string` | yes (variant 1 at sections.experience.items[].roles[].dates.end) | — | A year ("2022") or a year and month ("2022-03"). |
| `sections.experience.items[].roles[].dates.end` | `null` | yes (variant 2 at sections.experience.items[].roles[].dates.end) | — | — |
| `sections.experience.items[].roles[].dates.present` | `boolean` | yes | default: false | Whether it's ongoing, printed as 'Present'. |
| `sections.experience.items[].roles[].dates.raw` | `string` | no | — | The original text when it couldn't be read exactly, such as 'Summer 2016'. It's printed as written until the dates are edited, which removes it. |
| `sections.experience.items[].roles[].description` | `string` | yes | — | The description of this specific role. This should be a HTML-formatted string. |
| `sections.education` | `object` | yes | — | The section to display the education of the author. |
| `sections.education.title` | `string` | yes | — | The title of the section. |
@@ -129,12 +211,21 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `sections.education.items[]` | `object` | — | — | — |
| `sections.education.items[].id` | `string` | yes | — | The unique identifier for the item. Usually generated as a UUID. |
| `sections.education.items[].hidden` | `boolean` | yes | — | Whether to hide the item from the resume. |
| `sections.education.items[].school` | `string` | yes | minLength: 1 | The name of the school or institution. |
| `sections.education.items[].school` | `string` | yes | — | The name of the school or institution. Empty while the entry is a draft, which isn't printed. |
| `sections.education.items[].degree` | `string` | yes | — | The degree or qualification obtained. |
| `sections.education.items[].area` | `string` | yes | — | The area of study or specialization. |
| `sections.education.items[].grade` | `string` | yes | — | The grade or score achieved. |
| `sections.education.items[].location` | `string` | yes | — | The location of the school or institution. |
| `sections.education.items[].period` | `string` | yes | — | The period of time the education was obtained over. |
| `sections.education.items[].period` | `string` | yes | — | The period of time the education was obtained over, as text. Kept in sync from `dates`; prefer writing `dates`. |
| `sections.education.items[].dates` | `object` | no | — | Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them. |
| `sections.education.items[].dates.start` | `string or null` | yes | default: null | When it started, or the date itself for single-date entries (awards, certifications, publications). |
| `sections.education.items[].dates.start` | `string` | yes (variant 1 at sections.education.items[].dates.start) | — | A year ("2022") or a year and month ("2022-03"). |
| `sections.education.items[].dates.start` | `null` | yes (variant 2 at sections.education.items[].dates.start) | — | — |
| `sections.education.items[].dates.end` | `string or null` | yes | default: null | When it ended. Null while ongoing or for single dates. |
| `sections.education.items[].dates.end` | `string` | yes (variant 1 at sections.education.items[].dates.end) | — | A year ("2022") or a year and month ("2022-03"). |
| `sections.education.items[].dates.end` | `null` | yes (variant 2 at sections.education.items[].dates.end) | — | — |
| `sections.education.items[].dates.present` | `boolean` | yes | default: false | Whether it's ongoing, printed as 'Present'. |
| `sections.education.items[].dates.raw` | `string` | no | — | The original text when it couldn't be read exactly, such as 'Summer 2016'. It's printed as written until the dates are edited, which removes it. |
| `sections.education.items[].website` | `object` | yes | default: {"url":"","label":"","inlineLink":false} | The website of the school or institution, if any. |
| `sections.education.items[].website.url` | `string` | yes | — | The URL to show as a link. Must be a valid URL with a protocol (http:// or https://). |
| `sections.education.items[].website.label` | `string` | yes | — | The label to display for the URL. Leave blank to display the URL as-is. |
@@ -152,8 +243,17 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `sections.projects.items[]` | `object` | — | — | — |
| `sections.projects.items[].id` | `string` | yes | — | The unique identifier for the item. Usually generated as a UUID. |
| `sections.projects.items[].hidden` | `boolean` | yes | — | Whether to hide the item from the resume. |
| `sections.projects.items[].name` | `string` | yes | minLength: 1 | The name of the project. |
| `sections.projects.items[].period` | `string` | yes | — | The period of time the project was worked on. |
| `sections.projects.items[].name` | `string` | yes | — | The name of the project. Empty while the entry is a draft, which isn't printed. |
| `sections.projects.items[].period` | `string` | yes | — | The period of time the project was worked on, as text. Kept in sync from `dates`; prefer writing `dates`. |
| `sections.projects.items[].dates` | `object` | no | — | Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them. |
| `sections.projects.items[].dates.start` | `string or null` | yes | default: null | When it started, or the date itself for single-date entries (awards, certifications, publications). |
| `sections.projects.items[].dates.start` | `string` | yes (variant 1 at sections.projects.items[].dates.start) | — | A year ("2022") or a year and month ("2022-03"). |
| `sections.projects.items[].dates.start` | `null` | yes (variant 2 at sections.projects.items[].dates.start) | — | — |
| `sections.projects.items[].dates.end` | `string or null` | yes | default: null | When it ended. Null while ongoing or for single dates. |
| `sections.projects.items[].dates.end` | `string` | yes (variant 1 at sections.projects.items[].dates.end) | — | A year ("2022") or a year and month ("2022-03"). |
| `sections.projects.items[].dates.end` | `null` | yes (variant 2 at sections.projects.items[].dates.end) | — | — |
| `sections.projects.items[].dates.present` | `boolean` | yes | default: false | Whether it's ongoing, printed as 'Present'. |
| `sections.projects.items[].dates.raw` | `string` | no | — | The original text when it couldn't be read exactly, such as 'Summer 2016'. It's printed as written until the dates are edited, which removes it. |
| `sections.projects.items[].website` | `object` | yes | default: {"url":"","label":"","inlineLink":false} | The link to the project, if any. |
| `sections.projects.items[].website.url` | `string` | yes | — | The URL to show as a link. Must be a valid URL with a protocol (http:// or https://). |
| `sections.projects.items[].website.label` | `string` | yes | — | The label to display for the URL. Leave blank to display the URL as-is. |
@@ -173,7 +273,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `sections.skills.items[].hidden` | `boolean` | yes | — | Whether to hide the item from the resume. |
| `sections.skills.items[].icon` | `string` | yes | — | The icon to display for the custom field. Must be a valid icon name from @phosphor-icons/web icon set, or an empty string to hide. Default to '' (empty string) when unsure which icons are available. |
| `sections.skills.items[].iconColor` | `string` | yes | default: "" | Custom color for the icon, defined as rgba(r, g, b, a). Leave blank to use the template default icon color. |
| `sections.skills.items[].name` | `string` | yes | minLength: 1 | The name of the skill. |
| `sections.skills.items[].name` | `string` | yes | — | The name of the skill. Empty while the entry is a draft, which isn't printed. |
| `sections.skills.items[].proficiency` | `string` | yes | — | The proficiency level of the skill. Can be any text, such as 'Beginner', 'Intermediate', 'Advanced', etc. |
| `sections.skills.items[].level` | `number` | yes | minimum: 0; maximum: 5; default: 0 | The proficiency level of the skill, defined as a number between 0 and 5. If set to 0, the icons displaying the level will be hidden. |
| `sections.skills.items[].keywords` | `array` | yes | default: [] | The keywords associated with the skill, if any. These are displayed as tags below the name. |
@@ -192,7 +292,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `sections.languages.items[]` | `object` | — | — | — |
| `sections.languages.items[].id` | `string` | yes | — | The unique identifier for the item. Usually generated as a UUID. |
| `sections.languages.items[].hidden` | `boolean` | yes | — | Whether to hide the item from the resume. |
| `sections.languages.items[].language` | `string` | yes | minLength: 1 | The name of the language the author knows. |
| `sections.languages.items[].language` | `string` | yes | — | The name of the language the author knows. Empty while the entry is a draft, which isn't printed. |
| `sections.languages.items[].fluency` | `string` | yes | — | The fluency level of the language. Can be any text, such as 'Native', 'Fluent', 'Conversational', etc. or can also be a CEFR level (A1, A2, B1, B2, C1, C2). |
| `sections.languages.items[].level` | `number` | yes | minimum: 0; maximum: 5; default: 0 | The proficiency level of the language, defined as a number between 0 and 5. If set to 0, the icons displaying the level will be hidden. |
| `sections.interests` | `object` | yes | — | The section to display the interests of the author. |
@@ -209,7 +309,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `sections.interests.items[].hidden` | `boolean` | yes | — | Whether to hide the item from the resume. |
| `sections.interests.items[].icon` | `string` | yes | — | The icon to display for the custom field. Must be a valid icon name from @phosphor-icons/web icon set, or an empty string to hide. Default to '' (empty string) when unsure which icons are available. |
| `sections.interests.items[].iconColor` | `string` | yes | default: "" | Custom color for the icon, defined as rgba(r, g, b, a). Leave blank to use the template default icon color. |
| `sections.interests.items[].name` | `string` | yes | minLength: 1 | The name of the interest/hobby. |
| `sections.interests.items[].name` | `string` | yes | — | The name of the interest/hobby. Empty while the entry is a draft, which isn't printed. |
| `sections.interests.items[].keywords` | `array` | yes | default: [] | The keywords associated with the interest/hobby, if any. These are displayed as tags below the name. |
| `sections.interests.items[].keywords[]` | `string` | — | — | — |
| `sections.awards` | `object` | yes | — | The section to display the awards of the author. |
@@ -224,9 +324,18 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `sections.awards.items[]` | `object` | — | — | — |
| `sections.awards.items[].id` | `string` | yes | — | The unique identifier for the item. Usually generated as a UUID. |
| `sections.awards.items[].hidden` | `boolean` | yes | — | Whether to hide the item from the resume. |
| `sections.awards.items[].title` | `string` | yes | minLength: 1 | The title of the award. |
| `sections.awards.items[].title` | `string` | yes | — | The title of the award. Empty while the entry is a draft, which isn't printed. |
| `sections.awards.items[].awarder` | `string` | yes | — | The awarder of the award. |
| `sections.awards.items[].date` | `string` | yes | — | The date when the award was received. |
| `sections.awards.items[].date` | `string` | yes | — | The date when the award was received, as text. Kept in sync from `dates`; prefer writing `dates`. |
| `sections.awards.items[].dates` | `object` | no | — | Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them. |
| `sections.awards.items[].dates.start` | `string or null` | yes | default: null | When it started, or the date itself for single-date entries (awards, certifications, publications). |
| `sections.awards.items[].dates.start` | `string` | yes (variant 1 at sections.awards.items[].dates.start) | — | A year ("2022") or a year and month ("2022-03"). |
| `sections.awards.items[].dates.start` | `null` | yes (variant 2 at sections.awards.items[].dates.start) | — | — |
| `sections.awards.items[].dates.end` | `string or null` | yes | default: null | When it ended. Null while ongoing or for single dates. |
| `sections.awards.items[].dates.end` | `string` | yes (variant 1 at sections.awards.items[].dates.end) | — | A year ("2022") or a year and month ("2022-03"). |
| `sections.awards.items[].dates.end` | `null` | yes (variant 2 at sections.awards.items[].dates.end) | — | — |
| `sections.awards.items[].dates.present` | `boolean` | yes | default: false | Whether it's ongoing, printed as 'Present'. |
| `sections.awards.items[].dates.raw` | `string` | no | — | The original text when it couldn't be read exactly, such as 'Summer 2016'. It's printed as written until the dates are edited, which removes it. |
| `sections.awards.items[].website` | `object` | yes | default: {"url":"","label":"","inlineLink":false} | The website of the award, if any. |
| `sections.awards.items[].website.url` | `string` | yes | — | The URL to show as a link. Must be a valid URL with a protocol (http:// or https://). |
| `sections.awards.items[].website.label` | `string` | yes | — | The label to display for the URL. Leave blank to display the URL as-is. |
@@ -244,9 +353,18 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `sections.certifications.items[]` | `object` | — | — | — |
| `sections.certifications.items[].id` | `string` | yes | — | The unique identifier for the item. Usually generated as a UUID. |
| `sections.certifications.items[].hidden` | `boolean` | yes | — | Whether to hide the item from the resume. |
| `sections.certifications.items[].title` | `string` | yes | minLength: 1 | The title of the certification. |
| `sections.certifications.items[].title` | `string` | yes | — | The title of the certification. Empty while the entry is a draft, which isn't printed. |
| `sections.certifications.items[].issuer` | `string` | yes | — | The issuer of the certification. |
| `sections.certifications.items[].date` | `string` | yes | — | The date when the certification was received. |
| `sections.certifications.items[].date` | `string` | yes | — | The date when the certification was received, as text. Kept in sync from `dates`; prefer writing `dates`. |
| `sections.certifications.items[].dates` | `object` | no | — | Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them. |
| `sections.certifications.items[].dates.start` | `string or null` | yes | default: null | When it started, or the date itself for single-date entries (awards, certifications, publications). |
| `sections.certifications.items[].dates.start` | `string` | yes (variant 1 at sections.certifications.items[].dates.start) | — | A year ("2022") or a year and month ("2022-03"). |
| `sections.certifications.items[].dates.start` | `null` | yes (variant 2 at sections.certifications.items[].dates.start) | — | — |
| `sections.certifications.items[].dates.end` | `string or null` | yes | default: null | When it ended. Null while ongoing or for single dates. |
| `sections.certifications.items[].dates.end` | `string` | yes (variant 1 at sections.certifications.items[].dates.end) | — | A year ("2022") or a year and month ("2022-03"). |
| `sections.certifications.items[].dates.end` | `null` | yes (variant 2 at sections.certifications.items[].dates.end) | — | — |
| `sections.certifications.items[].dates.present` | `boolean` | yes | default: false | Whether it's ongoing, printed as 'Present'. |
| `sections.certifications.items[].dates.raw` | `string` | no | — | The original text when it couldn't be read exactly, such as 'Summer 2016'. It's printed as written until the dates are edited, which removes it. |
| `sections.certifications.items[].website` | `object` | yes | default: {"url":"","label":"","inlineLink":false} | The website of the certification, if any. |
| `sections.certifications.items[].website.url` | `string` | yes | — | The URL to show as a link. Must be a valid URL with a protocol (http:// or https://). |
| `sections.certifications.items[].website.label` | `string` | yes | — | The label to display for the URL. Leave blank to display the URL as-is. |
@@ -264,9 +382,18 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `sections.publications.items[]` | `object` | — | — | — |
| `sections.publications.items[].id` | `string` | yes | — | The unique identifier for the item. Usually generated as a UUID. |
| `sections.publications.items[].hidden` | `boolean` | yes | — | Whether to hide the item from the resume. |
| `sections.publications.items[].title` | `string` | yes | minLength: 1 | The title of the publication. |
| `sections.publications.items[].title` | `string` | yes | — | The title of the publication. Empty while the entry is a draft, which isn't printed. |
| `sections.publications.items[].publisher` | `string` | yes | — | The publisher of the publication. |
| `sections.publications.items[].date` | `string` | yes | — | The date when the publication was published. |
| `sections.publications.items[].date` | `string` | yes | — | The date when the publication was published, as text. Kept in sync from `dates`; prefer writing `dates`. |
| `sections.publications.items[].dates` | `object` | no | — | Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them. |
| `sections.publications.items[].dates.start` | `string or null` | yes | default: null | When it started, or the date itself for single-date entries (awards, certifications, publications). |
| `sections.publications.items[].dates.start` | `string` | yes (variant 1 at sections.publications.items[].dates.start) | — | A year ("2022") or a year and month ("2022-03"). |
| `sections.publications.items[].dates.start` | `null` | yes (variant 2 at sections.publications.items[].dates.start) | — | — |
| `sections.publications.items[].dates.end` | `string or null` | yes | default: null | When it ended. Null while ongoing or for single dates. |
| `sections.publications.items[].dates.end` | `string` | yes (variant 1 at sections.publications.items[].dates.end) | — | A year ("2022") or a year and month ("2022-03"). |
| `sections.publications.items[].dates.end` | `null` | yes (variant 2 at sections.publications.items[].dates.end) | — | — |
| `sections.publications.items[].dates.present` | `boolean` | yes | default: false | Whether it's ongoing, printed as 'Present'. |
| `sections.publications.items[].dates.raw` | `string` | no | — | The original text when it couldn't be read exactly, such as 'Summer 2016'. It's printed as written until the dates are edited, which removes it. |
| `sections.publications.items[].website` | `object` | yes | default: {"url":"","label":"","inlineLink":false} | The link to the publication, if any. |
| `sections.publications.items[].website.url` | `string` | yes | — | The URL to show as a link. Must be a valid URL with a protocol (http:// or https://). |
| `sections.publications.items[].website.label` | `string` | yes | — | The label to display for the URL. Leave blank to display the URL as-is. |
@@ -284,9 +411,18 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `sections.volunteer.items[]` | `object` | — | — | — |
| `sections.volunteer.items[].id` | `string` | yes | — | The unique identifier for the item. Usually generated as a UUID. |
| `sections.volunteer.items[].hidden` | `boolean` | yes | — | Whether to hide the item from the resume. |
| `sections.volunteer.items[].organization` | `string` | yes | minLength: 1 | The name of the organization or company. |
| `sections.volunteer.items[].organization` | `string` | yes | — | The name of the organization or company. Empty while the entry is a draft, which isn't printed. |
| `sections.volunteer.items[].location` | `string` | yes | — | The location of the organization or company. |
| `sections.volunteer.items[].period` | `string` | yes | — | The period of time the author was volunteered at the organization or company. |
| `sections.volunteer.items[].period` | `string` | yes | — | The period of time the author was volunteered at the organization or company, as text. Kept in sync from `dates`; prefer writing `dates`. |
| `sections.volunteer.items[].dates` | `object` | no | — | Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them. |
| `sections.volunteer.items[].dates.start` | `string or null` | yes | default: null | When it started, or the date itself for single-date entries (awards, certifications, publications). |
| `sections.volunteer.items[].dates.start` | `string` | yes (variant 1 at sections.volunteer.items[].dates.start) | — | A year ("2022") or a year and month ("2022-03"). |
| `sections.volunteer.items[].dates.start` | `null` | yes (variant 2 at sections.volunteer.items[].dates.start) | — | — |
| `sections.volunteer.items[].dates.end` | `string or null` | yes | default: null | When it ended. Null while ongoing or for single dates. |
| `sections.volunteer.items[].dates.end` | `string` | yes (variant 1 at sections.volunteer.items[].dates.end) | — | A year ("2022") or a year and month ("2022-03"). |
| `sections.volunteer.items[].dates.end` | `null` | yes (variant 2 at sections.volunteer.items[].dates.end) | — | — |
| `sections.volunteer.items[].dates.present` | `boolean` | yes | default: false | Whether it's ongoing, printed as 'Present'. |
| `sections.volunteer.items[].dates.raw` | `string` | no | — | The original text when it couldn't be read exactly, such as 'Summer 2016'. It's printed as written until the dates are edited, which removes it. |
| `sections.volunteer.items[].website` | `object` | yes | default: {"url":"","label":"","inlineLink":false} | The link to the organization or company, if any. |
| `sections.volunteer.items[].website.url` | `string` | yes | — | The URL to show as a link. Must be a valid URL with a protocol (http:// or https://). |
| `sections.volunteer.items[].website.label` | `string` | yes | — | The label to display for the URL. Leave blank to display the URL as-is. |
@@ -304,7 +440,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `sections.references.items[]` | `object` | — | — | — |
| `sections.references.items[].id` | `string` | yes | — | The unique identifier for the item. Usually generated as a UUID. |
| `sections.references.items[].hidden` | `boolean` | yes | — | Whether to hide the item from the resume. |
| `sections.references.items[].name` | `string` | yes | minLength: 1 | The name of the reference, or a note such as 'Available upon request'. |
| `sections.references.items[].name` | `string` | yes | — | The name of the reference, or a note such as 'Available upon request'. Empty while the entry is a draft, which isn't printed. |
| `sections.references.items[].position` | `string` | yes | — | The position or job title of the reference. |
| `sections.references.items[].website` | `object` | yes | default: {"url":"","label":"","inlineLink":false} | The website or LinkedIn profile of the reference, if any. |
| `sections.references.items[].website.url` | `string` | yes | — | The URL to show as a link. Must be a valid URL with a protocol (http:// or https://). |
@@ -347,7 +483,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `customSections[].items[].hidden` | `boolean` | yes (type profiles, schema profileItemSchema at customSections[]) | — | Whether to hide the item from the resume. |
| `customSections[].items[].icon` | `string` | yes (type profiles, schema profileItemSchema at customSections[]) | — | The icon to display for the custom field. Must be a valid icon name from @phosphor-icons/web icon set, or an empty string to hide. Default to '' (empty string) when unsure which icons are available. |
| `customSections[].items[].iconColor` | `string` | yes (type profiles, schema profileItemSchema at customSections[]) | default: "" | Custom color for the icon, defined as rgba(r, g, b, a). Leave blank to use the template default icon color. |
| `customSections[].items[].network` | `string` | yes (type profiles, schema profileItemSchema at customSections[]) | minLength: 1 | The name of the network or platform. |
| `customSections[].items[].network` | `string` | yes (type profiles, schema profileItemSchema at customSections[]) | — | The name of the network or platform. Empty while the entry is a draft, which isn't printed. |
| `customSections[].items[].username` | `string` | yes (type profiles, schema profileItemSchema at customSections[]) | — | The username of the author on the network or platform. |
| `customSections[].items[].website` | `object` | yes (type profiles, schema profileItemSchema at customSections[]) | default: {"url":"","label":"","inlineLink":false} | The link to the profile of the author on the network or platform, if any. |
| `customSections[].items[].website.url` | `string` | yes (type profiles, schema profileItemSchema at customSections[]) | — | The URL to show as a link. Must be a valid URL with a protocol (http:// or https://). |
@@ -368,10 +504,19 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `customSections[].items[]` | `object` | — (type experience, schema experienceItemSchema at customSections[]) | — | — |
| `customSections[].items[].id` | `string` | yes (type experience, schema experienceItemSchema at customSections[]) | — | The unique identifier for the item. Usually generated as a UUID. |
| `customSections[].items[].hidden` | `boolean` | yes (type experience, schema experienceItemSchema at customSections[]) | — | Whether to hide the item from the resume. |
| `customSections[].items[].company` | `string` | yes (type experience, schema experienceItemSchema at customSections[]) | minLength: 1 | The name of the company or organization. |
| `customSections[].items[].company` | `string` | yes (type experience, schema experienceItemSchema at customSections[]) | — | The name of the company or organization. Empty while the entry is a draft, which isn't printed. |
| `customSections[].items[].position` | `string` | yes (type experience, schema experienceItemSchema at customSections[]) | — | The position held at the company or organization. Used when there is only a single role. If multiple roles are provided in the 'roles' field, this serves as a summary title or can be left blank. |
| `customSections[].items[].location` | `string` | yes (type experience, schema experienceItemSchema at customSections[]) | — | The location of the company or organization. |
| `customSections[].items[].period` | `string` | yes (type experience, schema experienceItemSchema at customSections[]) | — | The overall period of time at the company. When multiple roles are used, this should reflect the total tenure. |
| `customSections[].items[].period` | `string` | yes (type experience, schema experienceItemSchema at customSections[]) | — | The overall period of time at the company, as text. When multiple roles are used, this should reflect the total tenure. Kept in sync from `dates`; prefer writing `dates`. |
| `customSections[].items[].dates` | `object` | no (type experience, schema experienceItemSchema at customSections[]) | — | Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them. |
| `customSections[].items[].dates.start` | `string or null` | yes (type experience, schema experienceItemSchema at customSections[]) | default: null | When it started, or the date itself for single-date entries (awards, certifications, publications). |
| `customSections[].items[].dates.start` | `string` | yes (type experience, schema experienceItemSchema at customSections[]; variant 1 at customSections[].items[].dates.start) | — | A year ("2022") or a year and month ("2022-03"). |
| `customSections[].items[].dates.start` | `null` | yes (type experience, schema experienceItemSchema at customSections[]; variant 2 at customSections[].items[].dates.start) | — | — |
| `customSections[].items[].dates.end` | `string or null` | yes (type experience, schema experienceItemSchema at customSections[]) | default: null | When it ended. Null while ongoing or for single dates. |
| `customSections[].items[].dates.end` | `string` | yes (type experience, schema experienceItemSchema at customSections[]; variant 1 at customSections[].items[].dates.end) | — | A year ("2022") or a year and month ("2022-03"). |
| `customSections[].items[].dates.end` | `null` | yes (type experience, schema experienceItemSchema at customSections[]; variant 2 at customSections[].items[].dates.end) | — | — |
| `customSections[].items[].dates.present` | `boolean` | yes (type experience, schema experienceItemSchema at customSections[]) | default: false | Whether it's ongoing, printed as 'Present'. |
| `customSections[].items[].dates.raw` | `string` | no (type experience, schema experienceItemSchema at customSections[]) | — | The original text when it couldn't be read exactly, such as 'Summer 2016'. It's printed as written until the dates are edited, which removes it. |
| `customSections[].items[].website` | `object` | yes (type experience, schema experienceItemSchema at customSections[]) | default: {"url":"","label":"","inlineLink":false} | The website of the company or organization, if any. |
| `customSections[].items[].website.url` | `string` | yes (type experience, schema experienceItemSchema at customSections[]) | — | The URL to show as a link. Must be a valid URL with a protocol (http:// or https://). |
| `customSections[].items[].website.label` | `string` | yes (type experience, schema experienceItemSchema at customSections[]) | — | The label to display for the URL. Leave blank to display the URL as-is. |
@@ -381,7 +526,16 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `customSections[].items[].roles[]` | `object` | — (type experience, schema experienceItemSchema at customSections[]) | — | — |
| `customSections[].items[].roles[].id` | `string` | yes (type experience, schema experienceItemSchema at customSections[]) | — | The unique identifier for the role. Usually generated as a UUID. |
| `customSections[].items[].roles[].position` | `string` | yes (type experience, schema experienceItemSchema at customSections[]) | — | The position or job title for this role. |
| `customSections[].items[].roles[].period` | `string` | yes (type experience, schema experienceItemSchema at customSections[]) | — | The period of time this role was held. |
| `customSections[].items[].roles[].period` | `string` | yes (type experience, schema experienceItemSchema at customSections[]) | — | The period of time this role was held, as text. Kept in sync from `dates`; prefer writing `dates`. |
| `customSections[].items[].roles[].dates` | `object` | no (type experience, schema experienceItemSchema at customSections[]) | — | Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them. |
| `customSections[].items[].roles[].dates.start` | `string or null` | yes (type experience, schema experienceItemSchema at customSections[]) | default: null | When it started, or the date itself for single-date entries (awards, certifications, publications). |
| `customSections[].items[].roles[].dates.start` | `string` | yes (type experience, schema experienceItemSchema at customSections[]; variant 1 at customSections[].items[].roles[].dates.start) | — | A year ("2022") or a year and month ("2022-03"). |
| `customSections[].items[].roles[].dates.start` | `null` | yes (type experience, schema experienceItemSchema at customSections[]; variant 2 at customSections[].items[].roles[].dates.start) | — | — |
| `customSections[].items[].roles[].dates.end` | `string or null` | yes (type experience, schema experienceItemSchema at customSections[]) | default: null | When it ended. Null while ongoing or for single dates. |
| `customSections[].items[].roles[].dates.end` | `string` | yes (type experience, schema experienceItemSchema at customSections[]; variant 1 at customSections[].items[].roles[].dates.end) | — | A year ("2022") or a year and month ("2022-03"). |
| `customSections[].items[].roles[].dates.end` | `null` | yes (type experience, schema experienceItemSchema at customSections[]; variant 2 at customSections[].items[].roles[].dates.end) | — | — |
| `customSections[].items[].roles[].dates.present` | `boolean` | yes (type experience, schema experienceItemSchema at customSections[]) | default: false | Whether it's ongoing, printed as 'Present'. |
| `customSections[].items[].roles[].dates.raw` | `string` | no (type experience, schema experienceItemSchema at customSections[]) | — | The original text when it couldn't be read exactly, such as 'Summer 2016'. It's printed as written until the dates are edited, which removes it. |
| `customSections[].items[].roles[].description` | `string` | yes (type experience, schema experienceItemSchema at customSections[]) | — | The description of this specific role. This should be a HTML-formatted string. |
| `customSections[]` | `object` | — (type education, schema educationItemSchema at customSections[]) | — | — |
| `customSections[].title` | `string` | yes (type education, schema educationItemSchema at customSections[]) | — | The title of the section. |
@@ -398,12 +552,21 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `customSections[].items[]` | `object` | — (type education, schema educationItemSchema at customSections[]) | — | — |
| `customSections[].items[].id` | `string` | yes (type education, schema educationItemSchema at customSections[]) | — | The unique identifier for the item. Usually generated as a UUID. |
| `customSections[].items[].hidden` | `boolean` | yes (type education, schema educationItemSchema at customSections[]) | — | Whether to hide the item from the resume. |
| `customSections[].items[].school` | `string` | yes (type education, schema educationItemSchema at customSections[]) | minLength: 1 | The name of the school or institution. |
| `customSections[].items[].school` | `string` | yes (type education, schema educationItemSchema at customSections[]) | — | The name of the school or institution. Empty while the entry is a draft, which isn't printed. |
| `customSections[].items[].degree` | `string` | yes (type education, schema educationItemSchema at customSections[]) | — | The degree or qualification obtained. |
| `customSections[].items[].area` | `string` | yes (type education, schema educationItemSchema at customSections[]) | — | The area of study or specialization. |
| `customSections[].items[].grade` | `string` | yes (type education, schema educationItemSchema at customSections[]) | — | The grade or score achieved. |
| `customSections[].items[].location` | `string` | yes (type education, schema educationItemSchema at customSections[]) | — | The location of the school or institution. |
| `customSections[].items[].period` | `string` | yes (type education, schema educationItemSchema at customSections[]) | — | The period of time the education was obtained over. |
| `customSections[].items[].period` | `string` | yes (type education, schema educationItemSchema at customSections[]) | — | The period of time the education was obtained over, as text. Kept in sync from `dates`; prefer writing `dates`. |
| `customSections[].items[].dates` | `object` | no (type education, schema educationItemSchema at customSections[]) | — | Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them. |
| `customSections[].items[].dates.start` | `string or null` | yes (type education, schema educationItemSchema at customSections[]) | default: null | When it started, or the date itself for single-date entries (awards, certifications, publications). |
| `customSections[].items[].dates.start` | `string` | yes (type education, schema educationItemSchema at customSections[]; variant 1 at customSections[].items[].dates.start) | — | A year ("2022") or a year and month ("2022-03"). |
| `customSections[].items[].dates.start` | `null` | yes (type education, schema educationItemSchema at customSections[]; variant 2 at customSections[].items[].dates.start) | — | — |
| `customSections[].items[].dates.end` | `string or null` | yes (type education, schema educationItemSchema at customSections[]) | default: null | When it ended. Null while ongoing or for single dates. |
| `customSections[].items[].dates.end` | `string` | yes (type education, schema educationItemSchema at customSections[]; variant 1 at customSections[].items[].dates.end) | — | A year ("2022") or a year and month ("2022-03"). |
| `customSections[].items[].dates.end` | `null` | yes (type education, schema educationItemSchema at customSections[]; variant 2 at customSections[].items[].dates.end) | — | — |
| `customSections[].items[].dates.present` | `boolean` | yes (type education, schema educationItemSchema at customSections[]) | default: false | Whether it's ongoing, printed as 'Present'. |
| `customSections[].items[].dates.raw` | `string` | no (type education, schema educationItemSchema at customSections[]) | — | The original text when it couldn't be read exactly, such as 'Summer 2016'. It's printed as written until the dates are edited, which removes it. |
| `customSections[].items[].website` | `object` | yes (type education, schema educationItemSchema at customSections[]) | default: {"url":"","label":"","inlineLink":false} | The website of the school or institution, if any. |
| `customSections[].items[].website.url` | `string` | yes (type education, schema educationItemSchema at customSections[]) | — | The URL to show as a link. Must be a valid URL with a protocol (http:// or https://). |
| `customSections[].items[].website.label` | `string` | yes (type education, schema educationItemSchema at customSections[]) | — | The label to display for the URL. Leave blank to display the URL as-is. |
@@ -424,8 +587,17 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `customSections[].items[]` | `object` | — (type projects, schema projectItemSchema at customSections[]) | — | — |
| `customSections[].items[].id` | `string` | yes (type projects, schema projectItemSchema at customSections[]) | — | The unique identifier for the item. Usually generated as a UUID. |
| `customSections[].items[].hidden` | `boolean` | yes (type projects, schema projectItemSchema at customSections[]) | — | Whether to hide the item from the resume. |
| `customSections[].items[].name` | `string` | yes (type projects, schema projectItemSchema at customSections[]) | minLength: 1 | The name of the project. |
| `customSections[].items[].period` | `string` | yes (type projects, schema projectItemSchema at customSections[]) | — | The period of time the project was worked on. |
| `customSections[].items[].name` | `string` | yes (type projects, schema projectItemSchema at customSections[]) | — | The name of the project. Empty while the entry is a draft, which isn't printed. |
| `customSections[].items[].period` | `string` | yes (type projects, schema projectItemSchema at customSections[]) | — | The period of time the project was worked on, as text. Kept in sync from `dates`; prefer writing `dates`. |
| `customSections[].items[].dates` | `object` | no (type projects, schema projectItemSchema at customSections[]) | — | Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them. |
| `customSections[].items[].dates.start` | `string or null` | yes (type projects, schema projectItemSchema at customSections[]) | default: null | When it started, or the date itself for single-date entries (awards, certifications, publications). |
| `customSections[].items[].dates.start` | `string` | yes (type projects, schema projectItemSchema at customSections[]; variant 1 at customSections[].items[].dates.start) | — | A year ("2022") or a year and month ("2022-03"). |
| `customSections[].items[].dates.start` | `null` | yes (type projects, schema projectItemSchema at customSections[]; variant 2 at customSections[].items[].dates.start) | — | — |
| `customSections[].items[].dates.end` | `string or null` | yes (type projects, schema projectItemSchema at customSections[]) | default: null | When it ended. Null while ongoing or for single dates. |
| `customSections[].items[].dates.end` | `string` | yes (type projects, schema projectItemSchema at customSections[]; variant 1 at customSections[].items[].dates.end) | — | A year ("2022") or a year and month ("2022-03"). |
| `customSections[].items[].dates.end` | `null` | yes (type projects, schema projectItemSchema at customSections[]; variant 2 at customSections[].items[].dates.end) | — | — |
| `customSections[].items[].dates.present` | `boolean` | yes (type projects, schema projectItemSchema at customSections[]) | default: false | Whether it's ongoing, printed as 'Present'. |
| `customSections[].items[].dates.raw` | `string` | no (type projects, schema projectItemSchema at customSections[]) | — | The original text when it couldn't be read exactly, such as 'Summer 2016'. It's printed as written until the dates are edited, which removes it. |
| `customSections[].items[].website` | `object` | yes (type projects, schema projectItemSchema at customSections[]) | default: {"url":"","label":"","inlineLink":false} | The link to the project, if any. |
| `customSections[].items[].website.url` | `string` | yes (type projects, schema projectItemSchema at customSections[]) | — | The URL to show as a link. Must be a valid URL with a protocol (http:// or https://). |
| `customSections[].items[].website.label` | `string` | yes (type projects, schema projectItemSchema at customSections[]) | — | The label to display for the URL. Leave blank to display the URL as-is. |
@@ -448,7 +620,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `customSections[].items[].hidden` | `boolean` | yes (type skills, schema skillItemSchema at customSections[]) | — | Whether to hide the item from the resume. |
| `customSections[].items[].icon` | `string` | yes (type skills, schema skillItemSchema at customSections[]) | — | The icon to display for the custom field. Must be a valid icon name from @phosphor-icons/web icon set, or an empty string to hide. Default to '' (empty string) when unsure which icons are available. |
| `customSections[].items[].iconColor` | `string` | yes (type skills, schema skillItemSchema at customSections[]) | default: "" | Custom color for the icon, defined as rgba(r, g, b, a). Leave blank to use the template default icon color. |
| `customSections[].items[].name` | `string` | yes (type skills, schema skillItemSchema at customSections[]) | minLength: 1 | The name of the skill. |
| `customSections[].items[].name` | `string` | yes (type skills, schema skillItemSchema at customSections[]) | — | The name of the skill. Empty while the entry is a draft, which isn't printed. |
| `customSections[].items[].proficiency` | `string` | yes (type skills, schema skillItemSchema at customSections[]) | — | The proficiency level of the skill. Can be any text, such as 'Beginner', 'Intermediate', 'Advanced', etc. |
| `customSections[].items[].level` | `number` | yes (type skills, schema skillItemSchema at customSections[]) | minimum: 0; maximum: 5; default: 0 | The proficiency level of the skill, defined as a number between 0 and 5. If set to 0, the icons displaying the level will be hidden. |
| `customSections[].items[].keywords` | `array` | yes (type skills, schema skillItemSchema at customSections[]) | default: [] | The keywords associated with the skill, if any. These are displayed as tags below the name. |
@@ -468,7 +640,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `customSections[].items[]` | `object` | — (type languages, schema languageItemSchema at customSections[]) | — | — |
| `customSections[].items[].id` | `string` | yes (type languages, schema languageItemSchema at customSections[]) | — | The unique identifier for the item. Usually generated as a UUID. |
| `customSections[].items[].hidden` | `boolean` | yes (type languages, schema languageItemSchema at customSections[]) | — | Whether to hide the item from the resume. |
| `customSections[].items[].language` | `string` | yes (type languages, schema languageItemSchema at customSections[]) | minLength: 1 | The name of the language the author knows. |
| `customSections[].items[].language` | `string` | yes (type languages, schema languageItemSchema at customSections[]) | — | The name of the language the author knows. Empty while the entry is a draft, which isn't printed. |
| `customSections[].items[].fluency` | `string` | yes (type languages, schema languageItemSchema at customSections[]) | — | The fluency level of the language. Can be any text, such as 'Native', 'Fluent', 'Conversational', etc. or can also be a CEFR level (A1, A2, B1, B2, C1, C2). |
| `customSections[].items[].level` | `number` | yes (type languages, schema languageItemSchema at customSections[]) | minimum: 0; maximum: 5; default: 0 | The proficiency level of the language, defined as a number between 0 and 5. If set to 0, the icons displaying the level will be hidden. |
| `customSections[]` | `object` | — (type interests, schema interestItemSchema at customSections[]) | — | — |
@@ -488,7 +660,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `customSections[].items[].hidden` | `boolean` | yes (type interests, schema interestItemSchema at customSections[]) | — | Whether to hide the item from the resume. |
| `customSections[].items[].icon` | `string` | yes (type interests, schema interestItemSchema at customSections[]) | — | The icon to display for the custom field. Must be a valid icon name from @phosphor-icons/web icon set, or an empty string to hide. Default to '' (empty string) when unsure which icons are available. |
| `customSections[].items[].iconColor` | `string` | yes (type interests, schema interestItemSchema at customSections[]) | default: "" | Custom color for the icon, defined as rgba(r, g, b, a). Leave blank to use the template default icon color. |
| `customSections[].items[].name` | `string` | yes (type interests, schema interestItemSchema at customSections[]) | minLength: 1 | The name of the interest/hobby. |
| `customSections[].items[].name` | `string` | yes (type interests, schema interestItemSchema at customSections[]) | — | The name of the interest/hobby. Empty while the entry is a draft, which isn't printed. |
| `customSections[].items[].keywords` | `array` | yes (type interests, schema interestItemSchema at customSections[]) | default: [] | The keywords associated with the interest/hobby, if any. These are displayed as tags below the name. |
| `customSections[].items[].keywords[]` | `string` | — (type interests, schema interestItemSchema at customSections[]) | — | — |
| `customSections[]` | `object` | — (type awards, schema awardItemSchema at customSections[]) | — | — |
@@ -506,9 +678,18 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `customSections[].items[]` | `object` | — (type awards, schema awardItemSchema at customSections[]) | — | — |
| `customSections[].items[].id` | `string` | yes (type awards, schema awardItemSchema at customSections[]) | — | The unique identifier for the item. Usually generated as a UUID. |
| `customSections[].items[].hidden` | `boolean` | yes (type awards, schema awardItemSchema at customSections[]) | — | Whether to hide the item from the resume. |
| `customSections[].items[].title` | `string` | yes (type awards, schema awardItemSchema at customSections[]) | minLength: 1 | The title of the award. |
| `customSections[].items[].title` | `string` | yes (type awards, schema awardItemSchema at customSections[]) | — | The title of the award. Empty while the entry is a draft, which isn't printed. |
| `customSections[].items[].awarder` | `string` | yes (type awards, schema awardItemSchema at customSections[]) | — | The awarder of the award. |
| `customSections[].items[].date` | `string` | yes (type awards, schema awardItemSchema at customSections[]) | — | The date when the award was received. |
| `customSections[].items[].date` | `string` | yes (type awards, schema awardItemSchema at customSections[]) | — | The date when the award was received, as text. Kept in sync from `dates`; prefer writing `dates`. |
| `customSections[].items[].dates` | `object` | no (type awards, schema awardItemSchema at customSections[]) | — | Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them. |
| `customSections[].items[].dates.start` | `string or null` | yes (type awards, schema awardItemSchema at customSections[]) | default: null | When it started, or the date itself for single-date entries (awards, certifications, publications). |
| `customSections[].items[].dates.start` | `string` | yes (type awards, schema awardItemSchema at customSections[]; variant 1 at customSections[].items[].dates.start) | — | A year ("2022") or a year and month ("2022-03"). |
| `customSections[].items[].dates.start` | `null` | yes (type awards, schema awardItemSchema at customSections[]; variant 2 at customSections[].items[].dates.start) | — | — |
| `customSections[].items[].dates.end` | `string or null` | yes (type awards, schema awardItemSchema at customSections[]) | default: null | When it ended. Null while ongoing or for single dates. |
| `customSections[].items[].dates.end` | `string` | yes (type awards, schema awardItemSchema at customSections[]; variant 1 at customSections[].items[].dates.end) | — | A year ("2022") or a year and month ("2022-03"). |
| `customSections[].items[].dates.end` | `null` | yes (type awards, schema awardItemSchema at customSections[]; variant 2 at customSections[].items[].dates.end) | — | — |
| `customSections[].items[].dates.present` | `boolean` | yes (type awards, schema awardItemSchema at customSections[]) | default: false | Whether it's ongoing, printed as 'Present'. |
| `customSections[].items[].dates.raw` | `string` | no (type awards, schema awardItemSchema at customSections[]) | — | The original text when it couldn't be read exactly, such as 'Summer 2016'. It's printed as written until the dates are edited, which removes it. |
| `customSections[].items[].website` | `object` | yes (type awards, schema awardItemSchema at customSections[]) | default: {"url":"","label":"","inlineLink":false} | The website of the award, if any. |
| `customSections[].items[].website.url` | `string` | yes (type awards, schema awardItemSchema at customSections[]) | — | The URL to show as a link. Must be a valid URL with a protocol (http:// or https://). |
| `customSections[].items[].website.label` | `string` | yes (type awards, schema awardItemSchema at customSections[]) | — | The label to display for the URL. Leave blank to display the URL as-is. |
@@ -529,9 +710,18 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `customSections[].items[]` | `object` | — (type certifications, schema certificationItemSchema at customSections[]) | — | — |
| `customSections[].items[].id` | `string` | yes (type certifications, schema certificationItemSchema at customSections[]) | — | The unique identifier for the item. Usually generated as a UUID. |
| `customSections[].items[].hidden` | `boolean` | yes (type certifications, schema certificationItemSchema at customSections[]) | — | Whether to hide the item from the resume. |
| `customSections[].items[].title` | `string` | yes (type certifications, schema certificationItemSchema at customSections[]) | minLength: 1 | The title of the certification. |
| `customSections[].items[].title` | `string` | yes (type certifications, schema certificationItemSchema at customSections[]) | — | The title of the certification. Empty while the entry is a draft, which isn't printed. |
| `customSections[].items[].issuer` | `string` | yes (type certifications, schema certificationItemSchema at customSections[]) | — | The issuer of the certification. |
| `customSections[].items[].date` | `string` | yes (type certifications, schema certificationItemSchema at customSections[]) | — | The date when the certification was received. |
| `customSections[].items[].date` | `string` | yes (type certifications, schema certificationItemSchema at customSections[]) | — | The date when the certification was received, as text. Kept in sync from `dates`; prefer writing `dates`. |
| `customSections[].items[].dates` | `object` | no (type certifications, schema certificationItemSchema at customSections[]) | — | Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them. |
| `customSections[].items[].dates.start` | `string or null` | yes (type certifications, schema certificationItemSchema at customSections[]) | default: null | When it started, or the date itself for single-date entries (awards, certifications, publications). |
| `customSections[].items[].dates.start` | `string` | yes (type certifications, schema certificationItemSchema at customSections[]; variant 1 at customSections[].items[].dates.start) | — | A year ("2022") or a year and month ("2022-03"). |
| `customSections[].items[].dates.start` | `null` | yes (type certifications, schema certificationItemSchema at customSections[]; variant 2 at customSections[].items[].dates.start) | — | — |
| `customSections[].items[].dates.end` | `string or null` | yes (type certifications, schema certificationItemSchema at customSections[]) | default: null | When it ended. Null while ongoing or for single dates. |
| `customSections[].items[].dates.end` | `string` | yes (type certifications, schema certificationItemSchema at customSections[]; variant 1 at customSections[].items[].dates.end) | — | A year ("2022") or a year and month ("2022-03"). |
| `customSections[].items[].dates.end` | `null` | yes (type certifications, schema certificationItemSchema at customSections[]; variant 2 at customSections[].items[].dates.end) | — | — |
| `customSections[].items[].dates.present` | `boolean` | yes (type certifications, schema certificationItemSchema at customSections[]) | default: false | Whether it's ongoing, printed as 'Present'. |
| `customSections[].items[].dates.raw` | `string` | no (type certifications, schema certificationItemSchema at customSections[]) | — | The original text when it couldn't be read exactly, such as 'Summer 2016'. It's printed as written until the dates are edited, which removes it. |
| `customSections[].items[].website` | `object` | yes (type certifications, schema certificationItemSchema at customSections[]) | default: {"url":"","label":"","inlineLink":false} | The website of the certification, if any. |
| `customSections[].items[].website.url` | `string` | yes (type certifications, schema certificationItemSchema at customSections[]) | — | The URL to show as a link. Must be a valid URL with a protocol (http:// or https://). |
| `customSections[].items[].website.label` | `string` | yes (type certifications, schema certificationItemSchema at customSections[]) | — | The label to display for the URL. Leave blank to display the URL as-is. |
@@ -552,9 +742,18 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `customSections[].items[]` | `object` | — (type publications, schema publicationItemSchema at customSections[]) | — | — |
| `customSections[].items[].id` | `string` | yes (type publications, schema publicationItemSchema at customSections[]) | — | The unique identifier for the item. Usually generated as a UUID. |
| `customSections[].items[].hidden` | `boolean` | yes (type publications, schema publicationItemSchema at customSections[]) | — | Whether to hide the item from the resume. |
| `customSections[].items[].title` | `string` | yes (type publications, schema publicationItemSchema at customSections[]) | minLength: 1 | The title of the publication. |
| `customSections[].items[].title` | `string` | yes (type publications, schema publicationItemSchema at customSections[]) | — | The title of the publication. Empty while the entry is a draft, which isn't printed. |
| `customSections[].items[].publisher` | `string` | yes (type publications, schema publicationItemSchema at customSections[]) | — | The publisher of the publication. |
| `customSections[].items[].date` | `string` | yes (type publications, schema publicationItemSchema at customSections[]) | — | The date when the publication was published. |
| `customSections[].items[].date` | `string` | yes (type publications, schema publicationItemSchema at customSections[]) | — | The date when the publication was published, as text. Kept in sync from `dates`; prefer writing `dates`. |
| `customSections[].items[].dates` | `object` | no (type publications, schema publicationItemSchema at customSections[]) | — | Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them. |
| `customSections[].items[].dates.start` | `string or null` | yes (type publications, schema publicationItemSchema at customSections[]) | default: null | When it started, or the date itself for single-date entries (awards, certifications, publications). |
| `customSections[].items[].dates.start` | `string` | yes (type publications, schema publicationItemSchema at customSections[]; variant 1 at customSections[].items[].dates.start) | — | A year ("2022") or a year and month ("2022-03"). |
| `customSections[].items[].dates.start` | `null` | yes (type publications, schema publicationItemSchema at customSections[]; variant 2 at customSections[].items[].dates.start) | — | — |
| `customSections[].items[].dates.end` | `string or null` | yes (type publications, schema publicationItemSchema at customSections[]) | default: null | When it ended. Null while ongoing or for single dates. |
| `customSections[].items[].dates.end` | `string` | yes (type publications, schema publicationItemSchema at customSections[]; variant 1 at customSections[].items[].dates.end) | — | A year ("2022") or a year and month ("2022-03"). |
| `customSections[].items[].dates.end` | `null` | yes (type publications, schema publicationItemSchema at customSections[]; variant 2 at customSections[].items[].dates.end) | — | — |
| `customSections[].items[].dates.present` | `boolean` | yes (type publications, schema publicationItemSchema at customSections[]) | default: false | Whether it's ongoing, printed as 'Present'. |
| `customSections[].items[].dates.raw` | `string` | no (type publications, schema publicationItemSchema at customSections[]) | — | The original text when it couldn't be read exactly, such as 'Summer 2016'. It's printed as written until the dates are edited, which removes it. |
| `customSections[].items[].website` | `object` | yes (type publications, schema publicationItemSchema at customSections[]) | default: {"url":"","label":"","inlineLink":false} | The link to the publication, if any. |
| `customSections[].items[].website.url` | `string` | yes (type publications, schema publicationItemSchema at customSections[]) | — | The URL to show as a link. Must be a valid URL with a protocol (http:// or https://). |
| `customSections[].items[].website.label` | `string` | yes (type publications, schema publicationItemSchema at customSections[]) | — | The label to display for the URL. Leave blank to display the URL as-is. |
@@ -575,9 +774,18 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `customSections[].items[]` | `object` | — (type volunteer, schema volunteerItemSchema at customSections[]) | — | — |
| `customSections[].items[].id` | `string` | yes (type volunteer, schema volunteerItemSchema at customSections[]) | — | The unique identifier for the item. Usually generated as a UUID. |
| `customSections[].items[].hidden` | `boolean` | yes (type volunteer, schema volunteerItemSchema at customSections[]) | — | Whether to hide the item from the resume. |
| `customSections[].items[].organization` | `string` | yes (type volunteer, schema volunteerItemSchema at customSections[]) | minLength: 1 | The name of the organization or company. |
| `customSections[].items[].organization` | `string` | yes (type volunteer, schema volunteerItemSchema at customSections[]) | — | The name of the organization or company. Empty while the entry is a draft, which isn't printed. |
| `customSections[].items[].location` | `string` | yes (type volunteer, schema volunteerItemSchema at customSections[]) | — | The location of the organization or company. |
| `customSections[].items[].period` | `string` | yes (type volunteer, schema volunteerItemSchema at customSections[]) | — | The period of time the author was volunteered at the organization or company. |
| `customSections[].items[].period` | `string` | yes (type volunteer, schema volunteerItemSchema at customSections[]) | — | The period of time the author was volunteered at the organization or company, as text. Kept in sync from `dates`; prefer writing `dates`. |
| `customSections[].items[].dates` | `object` | no (type volunteer, schema volunteerItemSchema at customSections[]) | — | Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them. |
| `customSections[].items[].dates.start` | `string or null` | yes (type volunteer, schema volunteerItemSchema at customSections[]) | default: null | When it started, or the date itself for single-date entries (awards, certifications, publications). |
| `customSections[].items[].dates.start` | `string` | yes (type volunteer, schema volunteerItemSchema at customSections[]; variant 1 at customSections[].items[].dates.start) | — | A year ("2022") or a year and month ("2022-03"). |
| `customSections[].items[].dates.start` | `null` | yes (type volunteer, schema volunteerItemSchema at customSections[]; variant 2 at customSections[].items[].dates.start) | — | — |
| `customSections[].items[].dates.end` | `string or null` | yes (type volunteer, schema volunteerItemSchema at customSections[]) | default: null | When it ended. Null while ongoing or for single dates. |
| `customSections[].items[].dates.end` | `string` | yes (type volunteer, schema volunteerItemSchema at customSections[]; variant 1 at customSections[].items[].dates.end) | — | A year ("2022") or a year and month ("2022-03"). |
| `customSections[].items[].dates.end` | `null` | yes (type volunteer, schema volunteerItemSchema at customSections[]; variant 2 at customSections[].items[].dates.end) | — | — |
| `customSections[].items[].dates.present` | `boolean` | yes (type volunteer, schema volunteerItemSchema at customSections[]) | default: false | Whether it's ongoing, printed as 'Present'. |
| `customSections[].items[].dates.raw` | `string` | no (type volunteer, schema volunteerItemSchema at customSections[]) | — | The original text when it couldn't be read exactly, such as 'Summer 2016'. It's printed as written until the dates are edited, which removes it. |
| `customSections[].items[].website` | `object` | yes (type volunteer, schema volunteerItemSchema at customSections[]) | default: {"url":"","label":"","inlineLink":false} | The link to the organization or company, if any. |
| `customSections[].items[].website.url` | `string` | yes (type volunteer, schema volunteerItemSchema at customSections[]) | — | The URL to show as a link. Must be a valid URL with a protocol (http:// or https://). |
| `customSections[].items[].website.label` | `string` | yes (type volunteer, schema volunteerItemSchema at customSections[]) | — | The label to display for the URL. Leave blank to display the URL as-is. |
@@ -598,7 +806,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `customSections[].items[]` | `object` | — (type references, schema referenceItemSchema at customSections[]) | — | — |
| `customSections[].items[].id` | `string` | yes (type references, schema referenceItemSchema at customSections[]) | — | The unique identifier for the item. Usually generated as a UUID. |
| `customSections[].items[].hidden` | `boolean` | yes (type references, schema referenceItemSchema at customSections[]) | — | Whether to hide the item from the resume. |
| `customSections[].items[].name` | `string` | yes (type references, schema referenceItemSchema at customSections[]) | minLength: 1 | The name of the reference, or a note such as 'Available upon request'. |
| `customSections[].items[].name` | `string` | yes (type references, schema referenceItemSchema at customSections[]) | — | The name of the reference, or a note such as 'Available upon request'. Empty while the entry is a draft, which isn't printed. |
| `customSections[].items[].position` | `string` | yes (type references, schema referenceItemSchema at customSections[]) | — | The position or job title of the reference. |
| `customSections[].items[].website` | `object` | yes (type references, schema referenceItemSchema at customSections[]) | default: {"url":"","label":"","inlineLink":false} | The website or LinkedIn profile of the reference, if any. |
| `customSections[].items[].website.url` | `string` | yes (type references, schema referenceItemSchema at customSections[]) | — | The URL to show as a link. Must be a valid URL with a protocol (http:// or https://). |
@@ -641,6 +849,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `metadata.page.marginY` | `number` | yes | minimum: 0; maximum: 100; default: 12 | The vertical margin of the page, defined in points (pt). |
| `metadata.page.format` | `string` | yes | enum: ["a4","letter","free-form"]; default: "a4" | The format of the page. Can be 'a4', 'letter', or 'free-form'. |
| `metadata.page.locale` | `string` | yes | default: "en-US" | The locale of the page. Used for displaying pre-translated section headings, if not overridden. |
| `metadata.page.dateFormat` | `string` | no | enum: ["short","long","numeric","iso"] | How dates print: 'short' (Mar 2022), 'long' (March 2022), 'numeric' (03/2022) or 'iso' (2022-03). When missing, it's read from how the dates were typed. |
| `metadata.page.hideLinkUnderline` | `boolean` | yes | default: false | Whether to hide the underlines of the links. |
| `metadata.page.hideIcons` | `boolean` | yes | default: false | Whether to hide the item-level icons (skills, profiles, interests). |
| `metadata.page.hideSectionIcons` | `boolean` | yes | default: true | Whether to hide the section heading icons displayed before section titles. |
+11 -5
View File
@@ -1,9 +1,15 @@
import { writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { generateSectionTitleCatalog } from "./section-titles";
import { generatePresentLabels, generateSectionTitleCatalog } from "./section-titles";
const catalogs = fileURLToPath(new URL("../../apps/web/locales/", import.meta.url));
const output = new URL("../../packages/pdf/src/section-title-catalog.json", import.meta.url);
const translations = await generateSectionTitleCatalog(catalogs);
await writeFile(output, `${JSON.stringify(translations, null, "\t")}\n`);
console.log(`Updated PDF section titles for ${Object.keys(translations).length} locales.`);
const titles = await generateSectionTitleCatalog(catalogs);
const titlesOutput = new URL("../../packages/pdf/src/section-title-catalog.json", import.meta.url);
await writeFile(titlesOutput, `${JSON.stringify(titles, null, "\t")}\n`);
console.log(`Updated PDF section titles for ${Object.keys(titles).length} locales.`);
const presentLabels = await generatePresentLabels(catalogs);
const presentOutput = new URL("../../packages/schema/src/resume/present-labels.json", import.meta.url);
await writeFile(presentOutput, `${JSON.stringify(presentLabels, null, "\t")}\n`);
console.log(`Updated "Present" for ${Object.keys(presentLabels).length} locales.`);
+11 -1
View File
@@ -1,7 +1,7 @@
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { expect, it } from "vitest";
import { generateSectionTitleCatalog } from "./section-titles";
import { generatePresentLabels, generateSectionTitleCatalog } from "./section-titles";
it("keeps PDF headings synchronized with the source Lingui catalogs", async () => {
const catalogs = fileURLToPath(new URL("../../apps/web/locales/", import.meta.url));
@@ -12,3 +12,13 @@ it("keeps PDF headings synchronized with the source Lingui catalogs", async () =
expect(committed, "Run pnpm pdf:translations after updating translations.").toEqual(generated);
expect(generated["es-ES"]?.Experience).toBe("Experiencia");
});
it("keeps the printed 'Present' synchronized with the source Lingui catalogs", async () => {
const catalogs = fileURLToPath(new URL("../../apps/web/locales/", import.meta.url));
const generated = await generatePresentLabels(catalogs);
const committed = JSON.parse(
await readFile(new URL("../../packages/schema/src/resume/present-labels.json", import.meta.url), "utf8"),
);
expect(committed, "Run pnpm pdf:translations after updating translations.").toEqual(generated);
expect(generated["en-US"]).toBe("Present");
});
+10 -2
View File
@@ -20,7 +20,15 @@ const titles = [
];
/** Only default resume headings are needed by the PDF renderer, not the entire application catalog. */
export async function generateSectionTitleCatalog(catalogDirectory: string) {
export const generateSectionTitleCatalog = (catalogDirectory: string) => generateCatalog(catalogDirectory, titles);
/** "Present" as printed in resume dates, per locale; untranslated locales carry the English source. */
export async function generatePresentLabels(catalogDirectory: string) {
const catalog = await generateCatalog(catalogDirectory, ["Present"]);
return Object.fromEntries(Object.entries(catalog).map(([locale, messages]) => [locale, messages.Present]));
}
async function generateCatalog(catalogDirectory: string, titles: readonly string[]) {
const result: Record<string, Record<string, string>> = {};
for (const filename of (await readdir(catalogDirectory)).filter((file) => file.endsWith(".po")).sort()) {
const locale = filename.slice(0, -3);
@@ -33,7 +41,7 @@ export async function generateSectionTitleCatalog(catalogDirectory: string) {
result[locale] = Object.fromEntries(
titles.map((title) => {
const message = messages.find((message) => message.message === title);
if (locale === "en-US" && !message) throw new Error(`Missing source section title: ${title}`);
if (locale === "en-US" && !message) throw new Error(`Missing source message: ${title}`);
return [title, message?.translation?.trim() ? message.translation : title];
}),
);
+4 -4
View File
@@ -2,10 +2,10 @@ import { describe, expect, it } from "vitest";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { compareResumeRecovery } from "./compare-resume";
const SYNTHETIC_SOURCE_HASH = "33bd2901474d077a37ed73f0646ac2812a0fbb6ca8ca7bd5825c56ec4c598b8c";
const RECOVERED_COPY_HASH = "1c0e1eefac8375d15b5d0d5fc040a970d1f10242c31a224f3c2a0ea5750c719e";
const CURRENT_COPY_HASH = "0e8a2e90ccb44068f500a92bc0d312b4290269f1b68ba925fdfe6109a15e739e";
const DEFAULT_RESUME_HASH = "1870f56666738b8748ac2769f5c79fe7b19863efecc07142a34c425254d871d8";
const SYNTHETIC_SOURCE_HASH = "eb59024ff3f5612a6446a576196429225add6fd36740474fd4a9b9a68a092e7d";
const RECOVERED_COPY_HASH = "018f754b86693bf25b45d38c8b2f76cccc8f28d61295e03829cf7292457d1e48";
const CURRENT_COPY_HASH = "9691213f937189cd5799620f4fbd46412f2ec701093cbedafe12bf9bf6f51a30";
const DEFAULT_RESUME_HASH = "9bfe1368cc030cd6ef1223a02f481d1bd7197fad3b5ba280a5bb987011ba71fb";
const FORMAT_CHARACTERS = [
["zero-width space (U+200B)", "\u200B"],