@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.
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Resume Data Calculation Helpers
3
+ *
4
+ * Pure functions for computing metrics and insights from JSON Resume data.
5
+ * Use these helpers in themes to display calculated metrics, statistics, and insights.
6
+ *
7
+ * This module is a thin re-export barrel. The implementations live in focused
8
+ * sibling modules grouped by concern:
9
+ * - ./experience.js work tenure & career progression
10
+ * - ./counts.js simple section tallies
11
+ * - ./education.js education years & highest degree
12
+ * - ./workHistory.js employment status, industries, volunteer tenure
13
+ * - ./keyMetrics.js dashboard metrics aggregation
14
+ *
15
+ * @module @jsonresume/utils/metrics
16
+ */
17
+
18
+ export {
19
+ calculateTotalExperience,
20
+ calculateCurrentRoleExperience,
21
+ countCareerPositions,
22
+ getCareerProgressionRate,
23
+ countTotalHighlights,
24
+ } from './experience.js';
25
+
26
+ export {
27
+ countCompanies,
28
+ countProjects,
29
+ countPublications,
30
+ countAwards,
31
+ countTotalSkills,
32
+ countSkillCategories,
33
+ countLanguages,
34
+ } from './counts.js';
35
+
36
+ export { calculateEducationYears, getHighestDegree } from './education.js';
37
+
38
+ export {
39
+ calculateVolunteerYears,
40
+ getUniqueIndustries,
41
+ getCurrentEmployer,
42
+ isCurrentlyEmployed,
43
+ } from './workHistory.js';
44
+
45
+ export { calculateKeyMetrics } from './keyMetrics.js';
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Resume Key Metrics Aggregation Helper
3
+ *
4
+ * Composes the focused calculation helpers into a dashboard-ready summary
5
+ * of key metrics derived from a complete JSON Resume object.
6
+ *
7
+ * @module @jsonresume/utils/metrics/keyMetrics
8
+ */
9
+
10
+ import { calculateTotalExperience } from './experience.js';
11
+ import {
12
+ countCompanies,
13
+ countProjects,
14
+ countTotalSkills,
15
+ countPublications,
16
+ countAwards,
17
+ countLanguages,
18
+ } from './counts.js';
19
+ import { getHighestDegree } from './education.js';
20
+
21
+ /**
22
+ * Calculate comprehensive Key Metrics object for dashboard displays
23
+ * @param {Object} resume - Complete JSON Resume object
24
+ * @returns {Array<Object>} Array of metric objects with label and value
25
+ *
26
+ * @example
27
+ * const metrics = calculateKeyMetrics(resume);
28
+ * // => [
29
+ * // { label: 'Years Experience', value: 8 },
30
+ * // { label: 'Companies', value: 5 },
31
+ * // { label: 'Projects', value: 12 },
32
+ * // { label: 'Core Skills', value: 42 }
33
+ * // ]
34
+ */
35
+ export function calculateKeyMetrics(resume) {
36
+ const metrics = [];
37
+
38
+ const {
39
+ work = [],
40
+ projects = [],
41
+ skills = [],
42
+ publications = [],
43
+ awards = [],
44
+ education = [],
45
+ languages = [],
46
+ } = resume;
47
+
48
+ // Experience
49
+ const experience = calculateTotalExperience(work);
50
+ if (experience > 0) {
51
+ metrics.push({ label: 'Years Experience', value: experience });
52
+ }
53
+
54
+ // Companies
55
+ const companies = countCompanies(work);
56
+ if (companies > 0) {
57
+ metrics.push({ label: 'Companies', value: companies });
58
+ }
59
+
60
+ // Projects
61
+ const projectCount = countProjects(projects);
62
+ if (projectCount > 0) {
63
+ metrics.push({ label: 'Projects', value: projectCount });
64
+ }
65
+
66
+ // Skills
67
+ const skillCount = countTotalSkills(skills);
68
+ if (skillCount > 0) {
69
+ metrics.push({ label: 'Core Skills', value: skillCount });
70
+ }
71
+
72
+ // Publications
73
+ const pubCount = countPublications(publications);
74
+ if (pubCount > 0) {
75
+ metrics.push({ label: 'Publications', value: pubCount });
76
+ }
77
+
78
+ // Awards
79
+ const awardCount = countAwards(awards);
80
+ if (awardCount > 0) {
81
+ metrics.push({ label: 'Awards', value: awardCount });
82
+ }
83
+
84
+ // Education
85
+ const degree = getHighestDegree(education);
86
+ if (degree) {
87
+ metrics.push({ label: 'Education', value: degree });
88
+ }
89
+
90
+ // Languages
91
+ const langCount = countLanguages(languages);
92
+ if (langCount > 1) {
93
+ // Only show if multilingual
94
+ metrics.push({ label: 'Languages', value: langCount });
95
+ }
96
+
97
+ return metrics;
98
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Resume Work History Insight Helpers
3
+ *
4
+ * Pure functions for deriving employment status, industries, and volunteer
5
+ * tenure from JSON Resume data.
6
+ *
7
+ * @module @jsonresume/utils/metrics/workHistory
8
+ */
9
+
10
+ /**
11
+ * Calculate total volunteer hours (if duration data is available)
12
+ * @param {Array} volunteer - Array of volunteer objects with startDate and endDate
13
+ * @returns {number} Total volunteer years
14
+ *
15
+ * @example
16
+ * const volunteerYears = calculateVolunteerYears(resume.volunteer);
17
+ * // => 4
18
+ */
19
+ export function calculateVolunteerYears(volunteer = []) {
20
+ if (!Array.isArray(volunteer) || volunteer.length === 0) return 0;
21
+
22
+ const totalYears = volunteer.reduce((acc, vol) => {
23
+ if (!vol.startDate) return acc;
24
+
25
+ const start = new Date(vol.startDate);
26
+ const end = vol.endDate ? new Date(vol.endDate) : new Date();
27
+ const years = (end - start) / (1000 * 60 * 60 * 24 * 365.25);
28
+
29
+ return acc + years;
30
+ }, 0);
31
+
32
+ return Math.round(totalYears);
33
+ }
34
+
35
+ /**
36
+ * Get all unique industries from work experience
37
+ * @param {Array} work - Array of work experience objects with industry field
38
+ * @returns {Array<string>} Array of unique industries
39
+ *
40
+ * @example
41
+ * const industries = getUniqueIndustries(resume.work);
42
+ * // => ["Technology", "Finance", "Healthcare"]
43
+ */
44
+ export function getUniqueIndustries(work = []) {
45
+ if (!Array.isArray(work) || work.length === 0) return [];
46
+
47
+ const industries = new Set(work.map((job) => job.industry).filter(Boolean));
48
+
49
+ return Array.from(industries);
50
+ }
51
+
52
+ /**
53
+ * Get most recent/current employer
54
+ * @param {Array} work - Array of work experience objects
55
+ * @returns {Object|null} Most recent work object
56
+ *
57
+ * @example
58
+ * const current = getCurrentEmployer(resume.work);
59
+ * // => { name: "Google", position: "Senior Engineer", ... }
60
+ */
61
+ export function getCurrentEmployer(work = []) {
62
+ if (!Array.isArray(work) || work.length === 0) return null;
63
+
64
+ // Find first job without endDate (current), or first in array
65
+ return work.find((job) => !job.endDate) || work[0];
66
+ }
67
+
68
+ /**
69
+ * Check if currently employed
70
+ * @param {Array} work - Array of work experience objects
71
+ * @returns {boolean} True if currently employed
72
+ *
73
+ * @example
74
+ * const employed = isCurrentlyEmployed(resume.work);
75
+ * // => true
76
+ */
77
+ export function isCurrentlyEmployed(work = []) {
78
+ if (!Array.isArray(work) || work.length === 0) return false;
79
+ return work.some((job) => !job.endDate);
80
+ }
package/src/resume.js ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Pure resume-shape utilities for JSON Resume.
3
+ *
4
+ * Framework-free helpers for formatting and normalizing resume data.
5
+ *
6
+ * @module @jsonresume/utils/resume
7
+ */
8
+
9
+ /**
10
+ * The eleven standard array sections of a JSON Resume. Together with `basics`
11
+ * these make up the twelve standard sections normalizeResume guarantees.
12
+ * @type {ReadonlyArray<string>}
13
+ */
14
+ const STANDARD_ARRAY_SECTIONS = [
15
+ 'work',
16
+ 'volunteer',
17
+ 'education',
18
+ 'awards',
19
+ 'certificates',
20
+ 'publications',
21
+ 'skills',
22
+ 'languages',
23
+ 'interests',
24
+ 'references',
25
+ 'projects',
26
+ ];
27
+
28
+ /**
29
+ * Format a JSON Resume location into a single display line.
30
+ * Drops empty parts and joins the rest with ', '.
31
+ *
32
+ * @param {Object} [location] - JSON Resume location object
33
+ * @returns {string} e.g. 'City, Region, CC' (empty string when nothing is set)
34
+ *
35
+ * @example
36
+ * formatLocation({ city: 'Berlin', region: 'BE', countryCode: 'DE' })
37
+ * // 'Berlin, BE, DE'
38
+ */
39
+ export function formatLocation(location) {
40
+ if (!location || typeof location !== 'object') {
41
+ return '';
42
+ }
43
+
44
+ return [location.city, location.region, location.countryCode]
45
+ .filter(Boolean)
46
+ .join(', ');
47
+ }
48
+
49
+ /**
50
+ * Normalize a resume into an object with all twelve standard array sections
51
+ * defaulted to [] and `basics` defaulted to an object. Never mutates the input.
52
+ * Unknown extra fields are preserved (JSON Resume allows additional properties).
53
+ *
54
+ * @param {Object} [resume] - JSON Resume object (possibly partial)
55
+ * @returns {Object} A new object with standard sections guaranteed present
56
+ *
57
+ * @example
58
+ * normalizeResume({ basics: { name: 'A' } }).work // []
59
+ */
60
+ export function normalizeResume(resume) {
61
+ const source = resume && typeof resume === 'object' ? resume : {};
62
+
63
+ const normalized = { ...source };
64
+ normalized.basics =
65
+ source.basics && typeof source.basics === 'object' ? source.basics : {};
66
+
67
+ for (const section of STANDARD_ARRAY_SECTIONS) {
68
+ normalized[section] = Array.isArray(source[section]) ? source[section] : [];
69
+ }
70
+
71
+ return normalized;
72
+ }
package/src/url.js ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * URL safety utilities for JSON Resume.
3
+ *
4
+ * Framework-free. Prevents XSS attacks and ensures safe URL handling.
5
+ *
6
+ * @module @jsonresume/utils/url
7
+ */
8
+
9
+ /**
10
+ * Sanitizes URLs to prevent XSS attacks
11
+ * - Blocks javascript:, data:, vbscript: schemes
12
+ * - Allows http:, https:, mailto:, tel: schemes
13
+ * - Returns null for invalid/dangerous URLs
14
+ *
15
+ * @param {string} url - The URL to sanitize
16
+ * @returns {string|null} - Safe URL or null if dangerous
17
+ *
18
+ * @example
19
+ * safeUrl('https://example.com') // 'https://example.com'
20
+ * safeUrl('javascript:alert(1)') // null
21
+ * safeUrl('mailto:user@example.com') // 'mailto:user@example.com'
22
+ */
23
+ export function safeUrl(url) {
24
+ if (!url || typeof url !== 'string') {
25
+ return null;
26
+ }
27
+
28
+ // Trim whitespace
29
+ const trimmed = url.trim();
30
+
31
+ // Check for dangerous protocols
32
+ const dangerousProtocols = /^(javascript|data|vbscript|file|about):/i;
33
+ if (dangerousProtocols.test(trimmed)) {
34
+ // Dangerous URL blocked. (Intentionally silent: no console output so this
35
+ // stays usable in SSR/library contexts that forbid console writes.)
36
+ return null;
37
+ }
38
+
39
+ // Allow safe protocols
40
+ const safeProtocols = /^(https?|mailto|tel|sms|ftp):/i;
41
+ if (safeProtocols.test(trimmed)) {
42
+ return trimmed;
43
+ }
44
+
45
+ // Allow relative URLs (starting with / or .)
46
+ if (trimmed.startsWith('/') || trimmed.startsWith('.')) {
47
+ return trimmed;
48
+ }
49
+
50
+ // Allow URLs without protocol (assume https)
51
+ if (/^www\./i.test(trimmed)) {
52
+ return `https://${trimmed}`;
53
+ }
54
+
55
+ // For other cases, check if it looks like a valid domain
56
+ // Allow alphanumeric, hyphens, dots, and common TLDs
57
+ if (/^[a-z0-9][a-z0-9.-]+\.[a-z]{2,}$/i.test(trimmed)) {
58
+ return `https://${trimmed}`;
59
+ }
60
+
61
+ // If we can't determine safety, return the original (let the browser handle
62
+ // it). Intentionally silent — no console output in this library context.
63
+ return trimmed;
64
+ }
65
+
66
+ /**
67
+ * Returns proper rel attribute for external links
68
+ * Adds security attributes for links opening in new windows
69
+ *
70
+ * @param {string} url - The URL to check
71
+ * @param {boolean} [openInNewTab=false] - Whether link opens in new tab
72
+ * @returns {string} - rel attribute value
73
+ *
74
+ * @example
75
+ * getLinkRel('https://example.com', true) // 'noopener noreferrer'
76
+ * getLinkRel('mailto:user@example.com', false) // ''
77
+ */
78
+ export function getLinkRel(url, openInNewTab = false) {
79
+ if (!url || typeof url !== 'string') {
80
+ return '';
81
+ }
82
+
83
+ // Only add security attributes for http(s) links opening in new tabs
84
+ if (openInNewTab && /^https?:/i.test(url)) {
85
+ return 'noopener noreferrer';
86
+ }
87
+
88
+ return '';
89
+ }
90
+
91
+ /**
92
+ * Sanitizes HTML to prevent XSS
93
+ * Simple implementation that escapes dangerous characters
94
+ * For more complex needs, use DOMPurify
95
+ *
96
+ * @param {string} html - HTML string to sanitize
97
+ * @returns {string} - Sanitized HTML
98
+ *
99
+ * @example
100
+ * sanitizeHtml('<script>alert(1)</script>') // '&lt;script&gt;alert(1)&lt;/script&gt;'
101
+ */
102
+ export function sanitizeHtml(html) {
103
+ if (!html || typeof html !== 'string') {
104
+ return '';
105
+ }
106
+
107
+ return html
108
+ .replace(/&/g, '&amp;')
109
+ .replace(/</g, '&lt;')
110
+ .replace(/>/g, '&gt;')
111
+ .replace(/"/g, '&quot;')
112
+ .replace(/'/g, '&#039;');
113
+ }
114
+
115
+ /**
116
+ * Checks if a URL is external (different origin)
117
+ *
118
+ * @param {string} url - URL to check
119
+ * @param {string} [currentOrigin=null] - Current site origin (default: window.location.origin if available)
120
+ * @returns {boolean} - True if URL is external
121
+ *
122
+ * @example
123
+ * isExternalUrl('https://example.com') // true (if on different domain)
124
+ * isExternalUrl('/about') // false
125
+ */
126
+ export function isExternalUrl(url, currentOrigin = null) {
127
+ if (!url || typeof url !== 'string') {
128
+ return false;
129
+ }
130
+
131
+ // Relative URLs are not external
132
+ if (url.startsWith('/') || url.startsWith('.') || url.startsWith('#')) {
133
+ return false;
134
+ }
135
+
136
+ // mailto:, tel:, etc. are not external in the HTTP sense
137
+ if (/^(mailto|tel|sms):/i.test(url)) {
138
+ return false;
139
+ }
140
+
141
+ // If no origin provided and we're in a browser, use window.location
142
+ if (!currentOrigin && typeof window !== 'undefined') {
143
+ currentOrigin = window.location.origin;
144
+ }
145
+
146
+ // If still no origin, we can't determine (assume external for safety)
147
+ if (!currentOrigin) {
148
+ return true;
149
+ }
150
+
151
+ try {
152
+ const urlObj = new URL(url, currentOrigin);
153
+ return urlObj.origin !== currentOrigin;
154
+ } catch (e) {
155
+ // Invalid URL, treat as external for safety
156
+ return true;
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Format a URL for display: strip the protocol and any trailing slash.
162
+ *
163
+ * @param {string} url - URL to format
164
+ * @returns {string} Display-friendly URL (empty string for falsy/non-string)
165
+ *
166
+ * @example
167
+ * formatUrlForDisplay('https://example.com/') // 'example.com'
168
+ * formatUrlForDisplay('http://example.com/blog') // 'example.com/blog'
169
+ */
170
+ export function formatUrlForDisplay(url) {
171
+ if (!url || typeof url !== 'string') {
172
+ return '';
173
+ }
174
+
175
+ return url.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').replace(/\/+$/, '');
176
+ }