@jsonresume/utils 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/dates.js ADDED
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Pure date utilities for JSON Resume.
3
+ *
4
+ * Framework-free: no React, no styled-components. The styled DateRange /
5
+ * RelativeDate components in @jsonresume/core import these functions back.
6
+ *
7
+ * @module @jsonresume/utils/dates
8
+ */
9
+
10
+ /**
11
+ * Date formatting utility with Intl.DateTimeFormat
12
+ * Formats date ranges for work/education experience with full locale support
13
+ *
14
+ * @param {Object} options - DateRange options
15
+ * @param {string|Date} options.startDate - Start date (ISO string or Date object)
16
+ * @param {string|Date} [options.endDate] - End date (ISO string, Date object, or null/undefined for "Present")
17
+ * @param {string} [options.format='short'] - Format style: 'short' (default), 'long', 'numeric'
18
+ * @param {string} [options.locale='en-US'] - BCP 47 locale (e.g., 'en-US', 'fr-FR', 'ar-SA')
19
+ * @param {string} [options.numberingSystem] - Numbering system (e.g., 'arab', 'latn', 'hanidec')
20
+ * @param {string} [options.presentLabel] - Custom label for present/ongoing (defaults to localized)
21
+ * @returns {string} Formatted date range
22
+ *
23
+ * @example
24
+ * formatDateRange({ startDate: '2020-01-15', endDate: null })
25
+ * // 'Jan 2020 - Present'
26
+ *
27
+ * @example
28
+ * formatDateRange({
29
+ * startDate: '2020-01-15',
30
+ * locale: 'fr-FR',
31
+ * format: 'long'
32
+ * })
33
+ * // 'janvier 2020 - Présent'
34
+ *
35
+ * @example
36
+ * formatDateRange({
37
+ * startDate: '2020-01-15',
38
+ * locale: 'ar-SA',
39
+ * numberingSystem: 'arab'
40
+ * })
41
+ * // With Arabic numerals
42
+ */
43
+ export function formatDateRange({
44
+ startDate,
45
+ endDate,
46
+ format = 'short',
47
+ locale = 'en-US',
48
+ numberingSystem,
49
+ presentLabel,
50
+ }) {
51
+ if (!startDate) return '';
52
+
53
+ // Determine "Present" label based on locale
54
+ const getPresentLabel = () => {
55
+ if (presentLabel) return presentLabel;
56
+
57
+ const labels = {
58
+ en: 'Present',
59
+ 'en-US': 'Present',
60
+ 'en-GB': 'Present',
61
+ fr: 'Présent',
62
+ 'fr-FR': 'Présent',
63
+ es: 'Presente',
64
+ 'es-ES': 'Presente',
65
+ de: 'Heute',
66
+ 'de-DE': 'Heute',
67
+ it: 'Presente',
68
+ 'it-IT': 'Presente',
69
+ pt: 'Presente',
70
+ 'pt-BR': 'Presente',
71
+ ja: '現在',
72
+ 'ja-JP': '現在',
73
+ zh: '至今',
74
+ 'zh-CN': '至今',
75
+ 'zh-TW': '至今',
76
+ ko: '현재',
77
+ 'ko-KR': '현재',
78
+ ar: 'حاضر',
79
+ 'ar-SA': 'حاضر',
80
+ };
81
+
82
+ return labels[locale] || labels[locale.split('-')[0]] || 'Present';
83
+ };
84
+
85
+ const formatDate = (dateStr) => {
86
+ if (!dateStr) return getPresentLabel();
87
+
88
+ const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr;
89
+
90
+ if (isNaN(date.getTime())) return dateStr; // Invalid date, return as-is
91
+
92
+ // Configure Intl.DateTimeFormat based on format style
93
+ const monthFormats = {
94
+ short: { month: 'short' },
95
+ long: { month: 'long' },
96
+ numeric: { month: '2-digit' },
97
+ };
98
+
99
+ const options = {
100
+ ...monthFormats[format],
101
+ year: 'numeric',
102
+ };
103
+
104
+ // Add numbering system if provided
105
+ if (numberingSystem) {
106
+ options.numberingSystem = numberingSystem;
107
+ }
108
+
109
+ // Use Intl.DateTimeFormat for proper locale support
110
+ const formatter = new Intl.DateTimeFormat(locale, options);
111
+ return formatter.format(date);
112
+ };
113
+
114
+ const start = formatDate(startDate);
115
+
116
+ // A genuinely absent endDate (key omitted -> undefined) is a single point in
117
+ // time (e.g. an award/certificate/publication date) and renders just the
118
+ // start. An explicit `null` endDate is the "ongoing" sentinel and renders
119
+ // start + separator + "Present" (formatDate returns the localized present
120
+ // label for a falsy-but-not-undefined value). This matches long-standing
121
+ // behavior; do not collapse the two — single-date sections depend on it.
122
+ if (endDate === undefined) {
123
+ return start;
124
+ }
125
+
126
+ const end = formatDate(endDate);
127
+
128
+ return `${start} - ${end}`;
129
+ }
130
+
131
+ /**
132
+ * Calculate relative time from a date
133
+ * @param {string|Date} date - Date to calculate from
134
+ * @param {boolean} [ago=true] - Include "ago" suffix
135
+ * @returns {string} Relative time string
136
+ *
137
+ * @example
138
+ * getRelativeTime('2020-01-01') // '6 years ago'
139
+ */
140
+ export function getRelativeTime(date, ago = true) {
141
+ const now = new Date();
142
+ const past = new Date(date);
143
+ const diffMs = now - past;
144
+ const diffSec = Math.floor(diffMs / 1000);
145
+ const diffMin = Math.floor(diffSec / 60);
146
+ const diffHour = Math.floor(diffMin / 60);
147
+ const diffDay = Math.floor(diffHour / 24);
148
+ const diffWeek = Math.floor(diffDay / 7);
149
+ const diffMonth = Math.floor(diffDay / 30);
150
+ const diffYear = Math.floor(diffDay / 365);
151
+
152
+ const suffix = ago ? ' ago' : '';
153
+
154
+ if (diffYear > 0) {
155
+ return `${diffYear} year${diffYear !== 1 ? 's' : ''}${suffix}`;
156
+ }
157
+ if (diffMonth > 0) {
158
+ return `${diffMonth} month${diffMonth !== 1 ? 's' : ''}${suffix}`;
159
+ }
160
+ if (diffWeek > 0) {
161
+ return `${diffWeek} week${diffWeek !== 1 ? 's' : ''}${suffix}`;
162
+ }
163
+ if (diffDay > 0) {
164
+ return `${diffDay} day${diffDay !== 1 ? 's' : ''}${suffix}`;
165
+ }
166
+ if (diffHour > 0) {
167
+ return `${diffHour} hour${diffHour !== 1 ? 's' : ''}${suffix}`;
168
+ }
169
+ if (diffMin > 0) {
170
+ return `${diffMin} minute${diffMin !== 1 ? 's' : ''}${suffix}`;
171
+ }
172
+ return 'just now';
173
+ }
174
+
175
+ /**
176
+ * Calculate duration between two dates
177
+ * @param {string|Date} startDate - Start date
178
+ * @param {string|Date} [endDate=new Date()] - End date (defaults to now)
179
+ * @returns {string} Duration string
180
+ *
181
+ * @example
182
+ * getDuration('2020-01-01', '2022-07-01') // '2 years, 6 months'
183
+ */
184
+ export function getDuration(startDate, endDate = new Date()) {
185
+ const start = new Date(startDate);
186
+ const end = new Date(endDate);
187
+ const diffMs = end - start;
188
+ const diffDay = Math.floor(diffMs / (1000 * 60 * 60 * 24));
189
+ const diffMonth = Math.floor(diffDay / 30);
190
+ const diffYear = Math.floor(diffDay / 365);
191
+
192
+ const years = diffYear;
193
+ const months = diffMonth % 12;
194
+
195
+ if (years > 0 && months > 0) {
196
+ return `${years} year${years !== 1 ? 's' : ''}, ${months} month${
197
+ months !== 1 ? 's' : ''
198
+ }`;
199
+ }
200
+ if (years > 0) {
201
+ return `${years} year${years !== 1 ? 's' : ''}`;
202
+ }
203
+ if (months > 0) {
204
+ return `${months} month${months !== 1 ? 's' : ''}`;
205
+ }
206
+ return `${diffDay} day${diffDay !== 1 ? 's' : ''}`;
207
+ }
208
+
209
+ /**
210
+ * Ensures all date fields in the resume are plain strings.
211
+ * Some themes (e.g. macchiato) use moment.js or Handlebars helpers that
212
+ * break when dates are Date objects instead of strings.
213
+ *
214
+ * @param {Object} resume - JSON Resume object
215
+ * @returns {Object} A shallow copy with Date-valued date fields stringified
216
+ */
217
+ export function normalizeDates(resume) {
218
+ const dateFields = ['startDate', 'endDate', 'date', 'releaseDate'];
219
+ const sections = [
220
+ 'work',
221
+ 'education',
222
+ 'volunteer',
223
+ 'projects',
224
+ 'awards',
225
+ 'publications',
226
+ ];
227
+
228
+ const normalized = { ...resume };
229
+ for (const section of sections) {
230
+ if (Array.isArray(normalized[section])) {
231
+ normalized[section] = normalized[section].map((item) => {
232
+ const copy = { ...item };
233
+ for (const field of dateFields) {
234
+ if (copy[field] instanceof Date) {
235
+ copy[field] = copy[field].toISOString().split('T')[0];
236
+ }
237
+ // Non-Date, non-string values (plain objects, arrays, etc.) are left
238
+ // untouched. Previously these were String()-coerced, which turned a
239
+ // malformed object date into the literal "[object Object]" and joined
240
+ // arrays into comma strings — mangling the value handed to the theme.
241
+ }
242
+ return copy;
243
+ });
244
+ }
245
+ }
246
+ return normalized;
247
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Public type surface for @jsonresume/utils.
3
+ *
4
+ * The runtime is plain JS (src/*.js); these declarations type the public API
5
+ * against @jsonresume/types so consumers get full inference.
6
+ */
7
+ import type {
8
+ Resume,
9
+ ResumeLocation,
10
+ WorkItem,
11
+ VolunteerItem,
12
+ EducationItem,
13
+ SkillItem,
14
+ } from '@jsonresume/types';
15
+
16
+ // --- dates ---
17
+
18
+ export interface DateRangeOptions {
19
+ startDate?: string | Date;
20
+ endDate?: string | Date | null;
21
+ format?: 'short' | 'long' | 'numeric';
22
+ locale?: string;
23
+ numberingSystem?: string;
24
+ presentLabel?: string;
25
+ }
26
+
27
+ export function formatDateRange(options: DateRangeOptions): string;
28
+ export function getRelativeTime(date: string | Date, ago?: boolean): string;
29
+ export function getDuration(
30
+ startDate: string | Date,
31
+ endDate?: string | Date
32
+ ): string;
33
+ export function normalizeDates(resume: Resume): Resume;
34
+
35
+ // --- metrics ---
36
+
37
+ export interface KeyMetric {
38
+ label: string;
39
+ value: string | number;
40
+ }
41
+
42
+ export function calculateTotalExperience(work?: WorkItem[]): number;
43
+ export function calculateCurrentRoleExperience(work?: WorkItem[]): number;
44
+ export function countCareerPositions(work?: WorkItem[]): number;
45
+ export function getCareerProgressionRate(work?: WorkItem[]): number;
46
+ export function countTotalHighlights(work?: WorkItem[]): number;
47
+ export function countCompanies(work?: WorkItem[]): number;
48
+ export function countProjects(projects?: unknown[]): number;
49
+ export function countPublications(publications?: unknown[]): number;
50
+ export function countAwards(awards?: unknown[]): number;
51
+ export function countTotalSkills(skills?: SkillItem[]): number;
52
+ export function countSkillCategories(skills?: SkillItem[]): number;
53
+ export function countLanguages(languages?: unknown[]): number;
54
+ export function calculateEducationYears(education?: EducationItem[]): number;
55
+ export function getHighestDegree(education?: EducationItem[]): string;
56
+ export function calculateVolunteerYears(volunteer?: VolunteerItem[]): number;
57
+ export function getUniqueIndustries(work?: WorkItem[]): string[];
58
+ export function getCurrentEmployer(work?: WorkItem[]): WorkItem | null;
59
+ export function isCurrentlyEmployed(work?: WorkItem[]): boolean;
60
+ export function calculateKeyMetrics(resume: Resume): KeyMetric[];
61
+
62
+ // --- url ---
63
+
64
+ export function safeUrl(url: string): string | null;
65
+ export function getLinkRel(url: string, openInNewTab?: boolean): string;
66
+ export function sanitizeHtml(html: string): string;
67
+ export function isExternalUrl(
68
+ url: string,
69
+ currentOrigin?: string | null
70
+ ): boolean;
71
+ export function formatUrlForDisplay(url: string): string;
72
+
73
+ // --- resume shape ---
74
+
75
+ export function formatLocation(location?: ResumeLocation): string;
76
+ export function normalizeResume(resume?: Partial<Resume>): Resume;
package/src/index.js ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @jsonresume/utils
3
+ *
4
+ * Framework-free pure utilities for JSON Resume. No React, no
5
+ * styled-components — safe to use anywhere (server, CLI, browser, themes).
6
+ *
7
+ * Subpath exports: './dates', './metrics', './url'. The root barrel below
8
+ * re-exports everything for convenience.
9
+ *
10
+ * @module @jsonresume/utils
11
+ */
12
+
13
+ // Date utilities
14
+ export {
15
+ formatDateRange,
16
+ getRelativeTime,
17
+ getDuration,
18
+ normalizeDates,
19
+ } from './dates.js';
20
+
21
+ // Metrics / calculation helpers
22
+ export {
23
+ calculateTotalExperience,
24
+ calculateCurrentRoleExperience,
25
+ countCareerPositions,
26
+ getCareerProgressionRate,
27
+ countTotalHighlights,
28
+ countCompanies,
29
+ countProjects,
30
+ countPublications,
31
+ countAwards,
32
+ countTotalSkills,
33
+ countSkillCategories,
34
+ countLanguages,
35
+ calculateEducationYears,
36
+ getHighestDegree,
37
+ calculateVolunteerYears,
38
+ getUniqueIndustries,
39
+ getCurrentEmployer,
40
+ isCurrentlyEmployed,
41
+ calculateKeyMetrics,
42
+ } from './metrics/index.js';
43
+
44
+ // URL / security utilities
45
+ export {
46
+ safeUrl,
47
+ getLinkRel,
48
+ sanitizeHtml,
49
+ isExternalUrl,
50
+ formatUrlForDisplay,
51
+ } from './url.js';
52
+
53
+ // Resume-shape utilities
54
+ export { formatLocation, normalizeResume } from './resume.js';
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Resume Count Calculation Helpers
3
+ *
4
+ * Pure functions for tallying simple counts across JSON Resume sections.
5
+ *
6
+ * @module @jsonresume/utils/metrics/counts
7
+ */
8
+
9
+ /**
10
+ * Count total number of companies worked at
11
+ * @param {Array} work - Array of work experience objects
12
+ * @returns {number} Number of unique companies
13
+ *
14
+ * @example
15
+ * const companies = countCompanies(resume.work);
16
+ * // => 5
17
+ */
18
+ export function countCompanies(work = []) {
19
+ if (!Array.isArray(work) || work.length === 0) return 0;
20
+
21
+ const uniqueCompanies = new Set(work.map((job) => job.name).filter(Boolean));
22
+
23
+ return uniqueCompanies.size;
24
+ }
25
+
26
+ /**
27
+ * Count total number of projects
28
+ * @param {Array} projects - Array of project objects
29
+ * @returns {number} Number of projects
30
+ *
31
+ * @example
32
+ * const projectCount = countProjects(resume.projects);
33
+ * // => 12
34
+ */
35
+ export function countProjects(projects = []) {
36
+ return Array.isArray(projects) ? projects.length : 0;
37
+ }
38
+
39
+ /**
40
+ * Count total number of publications
41
+ * @param {Array} publications - Array of publication objects
42
+ * @returns {number} Number of publications
43
+ *
44
+ * @example
45
+ * const pubCount = countPublications(resume.publications);
46
+ * // => 8
47
+ */
48
+ export function countPublications(publications = []) {
49
+ return Array.isArray(publications) ? publications.length : 0;
50
+ }
51
+
52
+ /**
53
+ * Count total number of awards received
54
+ * @param {Array} awards - Array of award objects
55
+ * @returns {number} Number of awards
56
+ *
57
+ * @example
58
+ * const awardCount = countAwards(resume.awards);
59
+ * // => 3
60
+ */
61
+ export function countAwards(awards = []) {
62
+ return Array.isArray(awards) ? awards.length : 0;
63
+ }
64
+
65
+ /**
66
+ * Count total skills across all skill categories
67
+ * @param {Array} skills - Array of skill objects with keywords
68
+ * @returns {number} Total number of skill keywords
69
+ *
70
+ * @example
71
+ * const skillCount = countTotalSkills(resume.skills);
72
+ * // => 42
73
+ */
74
+ export function countTotalSkills(skills = []) {
75
+ if (!Array.isArray(skills) || skills.length === 0) return 0;
76
+
77
+ return skills.reduce((total, skill) => {
78
+ const keywords = skill.keywords || [];
79
+ return total + keywords.length;
80
+ }, 0);
81
+ }
82
+
83
+ /**
84
+ * Count skill categories
85
+ * @param {Array} skills - Array of skill objects
86
+ * @returns {number} Number of skill categories
87
+ *
88
+ * @example
89
+ * const categories = countSkillCategories(resume.skills);
90
+ * // => 6
91
+ */
92
+ export function countSkillCategories(skills = []) {
93
+ return Array.isArray(skills) ? skills.length : 0;
94
+ }
95
+
96
+ /**
97
+ * Count languages spoken
98
+ * @param {Array} languages - Array of language objects
99
+ * @returns {number} Number of languages
100
+ *
101
+ * @example
102
+ * const langCount = countLanguages(resume.languages);
103
+ * // => 3
104
+ */
105
+ export function countLanguages(languages = []) {
106
+ return Array.isArray(languages) ? languages.length : 0;
107
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Resume Education Calculation Helpers
3
+ *
4
+ * Pure functions for computing education metrics from JSON Resume data.
5
+ *
6
+ * @module @jsonresume/utils/metrics/education
7
+ */
8
+
9
+ /**
10
+ * Calculate total education years
11
+ * @param {Array} education - Array of education objects with startDate and endDate
12
+ * @returns {number} Total years of education
13
+ *
14
+ * @example
15
+ * const eduYears = calculateEducationYears(resume.education);
16
+ * // => 6
17
+ */
18
+ export function calculateEducationYears(education = []) {
19
+ if (!Array.isArray(education) || education.length === 0) return 0;
20
+
21
+ const totalYears = education.reduce((acc, edu) => {
22
+ if (!edu.startDate) return acc;
23
+
24
+ const start = new Date(edu.startDate);
25
+ const end = edu.endDate ? new Date(edu.endDate) : new Date();
26
+ // Use 365.25 for leap years (matches calculateTotalExperience). Previously
27
+ // this divided by (ms-per-day / 365.25), inflating the result ~133M-fold.
28
+ const years = (end - start) / (1000 * 60 * 60 * 24 * 365.25);
29
+
30
+ return acc + years;
31
+ }, 0);
32
+
33
+ return Math.round(totalYears);
34
+ }
35
+
36
+ /**
37
+ * Get highest education level
38
+ * @param {Array} education - Array of education objects with studyType
39
+ * @returns {string} Highest degree (PhD, Master's, Bachelor's, etc.)
40
+ *
41
+ * @example
42
+ * const degree = getHighestDegree(resume.education);
43
+ * // => "PhD"
44
+ */
45
+ export function getHighestDegree(education = []) {
46
+ if (!Array.isArray(education) || education.length === 0) return '';
47
+
48
+ const degreeRanking = {
49
+ phd: 5,
50
+ doctorate: 5,
51
+ doctoral: 5,
52
+ master: 4,
53
+ mba: 4,
54
+ bachelor: 3,
55
+ associate: 2,
56
+ diploma: 1,
57
+ certificate: 1,
58
+ };
59
+
60
+ let highest = { level: 0, studyType: '' };
61
+
62
+ education.forEach((edu) => {
63
+ if (!edu.studyType) return;
64
+
65
+ const studyTypeLower = edu.studyType.toLowerCase();
66
+ for (const [key, level] of Object.entries(degreeRanking)) {
67
+ if (studyTypeLower.includes(key) && level > highest.level) {
68
+ highest = { level, studyType: edu.studyType };
69
+ }
70
+ }
71
+ });
72
+
73
+ return highest.studyType;
74
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Resume Experience Calculation Helpers
3
+ *
4
+ * Pure functions for computing work tenure and career progression metrics
5
+ * from JSON Resume work history.
6
+ *
7
+ * @module @jsonresume/utils/metrics/experience
8
+ */
9
+
10
+ /**
11
+ * Calculate total years of professional experience from work history
12
+ * @param {Array} work - Array of work experience objects with startDate and endDate
13
+ * @returns {number} Total years of experience (rounded to nearest year)
14
+ *
15
+ * @example
16
+ * const years = calculateTotalExperience(resume.work);
17
+ * // => 8 (years)
18
+ */
19
+ export function calculateTotalExperience(work = []) {
20
+ if (!Array.isArray(work) || work.length === 0) return 0;
21
+
22
+ const totalYears = work.reduce((acc, job) => {
23
+ if (!job.startDate) return acc;
24
+
25
+ const start = new Date(job.startDate);
26
+ const end = job.endDate ? new Date(job.endDate) : new Date();
27
+ const years = (end - start) / (1000 * 60 * 60 * 24 * 365.25); // Use 365.25 for leap years
28
+
29
+ return acc + years;
30
+ }, 0);
31
+
32
+ return Math.round(totalYears);
33
+ }
34
+
35
+ /**
36
+ * Calculate years of experience for the current/most recent role
37
+ * @param {Array} work - Array of work experience objects
38
+ * @returns {number} Years in current role (rounded to 1 decimal)
39
+ *
40
+ * @example
41
+ * const currentYears = calculateCurrentRoleExperience(resume.work);
42
+ * // => 2.5 (years)
43
+ */
44
+ export function calculateCurrentRoleExperience(work = []) {
45
+ if (!Array.isArray(work) || work.length === 0) return 0;
46
+
47
+ // Assume first item is most recent (no endDate or latest endDate)
48
+ const currentRole = work.find((job) => !job.endDate) || work[0];
49
+ if (!currentRole || !currentRole.startDate) return 0;
50
+
51
+ const start = new Date(currentRole.startDate);
52
+ const end = currentRole.endDate ? new Date(currentRole.endDate) : new Date();
53
+ const years = (end - start) / (1000 * 60 * 60 * 24 * 365.25);
54
+
55
+ return Math.round(years * 10) / 10; // Round to 1 decimal
56
+ }
57
+
58
+ /**
59
+ * Calculate career trajectory (promotions/role changes)
60
+ * @param {Array} work - Array of work experience objects with position
61
+ * @returns {number} Number of distinct positions held
62
+ *
63
+ * @example
64
+ * const positions = countCareerPositions(resume.work);
65
+ * // => 7
66
+ */
67
+ export function countCareerPositions(work = []) {
68
+ if (!Array.isArray(work) || work.length === 0) return 0;
69
+
70
+ const uniquePositions = new Set(
71
+ work.map((job) => job.position).filter(Boolean)
72
+ );
73
+
74
+ return uniquePositions.size;
75
+ }
76
+
77
+ /**
78
+ * Get career progression rate (positions per year)
79
+ * @param {Array} work - Array of work experience objects
80
+ * @returns {number} Average years per position
81
+ *
82
+ * @example
83
+ * const rate = getCareerProgressionRate(resume.work);
84
+ * // => 2.3 (years per position)
85
+ */
86
+ export function getCareerProgressionRate(work = []) {
87
+ const totalYears = calculateTotalExperience(work);
88
+ const positions = countCareerPositions(work);
89
+
90
+ if (totalYears === 0 || positions === 0) return 0;
91
+
92
+ return Math.round((totalYears / positions) * 10) / 10;
93
+ }
94
+
95
+ /**
96
+ * Calculate total highlights/achievements across work experience
97
+ * @param {Array} work - Array of work experience objects with highlights
98
+ * @returns {number} Total number of highlights
99
+ *
100
+ * @example
101
+ * const achievements = countTotalHighlights(resume.work);
102
+ * // => 28
103
+ */
104
+ export function countTotalHighlights(work = []) {
105
+ if (!Array.isArray(work) || work.length === 0) return 0;
106
+
107
+ return work.reduce((total, job) => {
108
+ const highlights = job.highlights || [];
109
+ return total + highlights.length;
110
+ }, 0);
111
+ }