@juicedresume/mcp 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/README.md +55 -0
- package/dist/index.js +3472 -0
- package/package.json +56 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3472 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
7
|
+
import { z as z2 } from "zod";
|
|
8
|
+
|
|
9
|
+
// ../../packages/schema/src/index.ts
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
var Link = z.object({
|
|
12
|
+
label: z.string().default(""),
|
|
13
|
+
url: z.string().default("")
|
|
14
|
+
});
|
|
15
|
+
var PersonalInfo = z.object({
|
|
16
|
+
fullName: z.string().default(""),
|
|
17
|
+
title: z.string().default(""),
|
|
18
|
+
email: z.string().default(""),
|
|
19
|
+
phone: z.string().default(""),
|
|
20
|
+
location: z.string().default(""),
|
|
21
|
+
photoUrl: z.string().default(""),
|
|
22
|
+
// FlowCV uses freeform links; we keep both known social slots and a generic list
|
|
23
|
+
website: z.string().default(""),
|
|
24
|
+
linkedin: z.string().default(""),
|
|
25
|
+
github: z.string().default(""),
|
|
26
|
+
twitter: z.string().default(""),
|
|
27
|
+
links: z.array(Link).default([])
|
|
28
|
+
});
|
|
29
|
+
var baseEntry = z.object({
|
|
30
|
+
id: z.string(),
|
|
31
|
+
visible: z.boolean().default(true)
|
|
32
|
+
});
|
|
33
|
+
var SummaryItem = baseEntry.extend({
|
|
34
|
+
body: z.string().default("")
|
|
35
|
+
// rich text HTML
|
|
36
|
+
});
|
|
37
|
+
var ExperienceItem = baseEntry.extend({
|
|
38
|
+
jobTitle: z.string().default(""),
|
|
39
|
+
employer: z.string().default(""),
|
|
40
|
+
employerLink: z.string().default(""),
|
|
41
|
+
startDate: z.string().default(""),
|
|
42
|
+
// "MM/YYYY" or ""
|
|
43
|
+
endDate: z.string().default(""),
|
|
44
|
+
// "MM/YYYY" or ""
|
|
45
|
+
current: z.boolean().default(false),
|
|
46
|
+
location: z.string().default(""),
|
|
47
|
+
description: z.string().default("")
|
|
48
|
+
// rich text HTML
|
|
49
|
+
});
|
|
50
|
+
var EducationItem = baseEntry.extend({
|
|
51
|
+
degree: z.string().default(""),
|
|
52
|
+
field: z.string().default(""),
|
|
53
|
+
school: z.string().default(""),
|
|
54
|
+
schoolLink: z.string().default(""),
|
|
55
|
+
startDate: z.string().default(""),
|
|
56
|
+
endDate: z.string().default(""),
|
|
57
|
+
location: z.string().default(""),
|
|
58
|
+
description: z.string().default("")
|
|
59
|
+
});
|
|
60
|
+
var ProjectItem = baseEntry.extend({
|
|
61
|
+
name: z.string().default(""),
|
|
62
|
+
role: z.string().default(""),
|
|
63
|
+
link: z.string().default(""),
|
|
64
|
+
startDate: z.string().default(""),
|
|
65
|
+
endDate: z.string().default(""),
|
|
66
|
+
description: z.string().default("")
|
|
67
|
+
});
|
|
68
|
+
var SkillItem = baseEntry.extend({
|
|
69
|
+
category: z.string().default(""),
|
|
70
|
+
// FlowCV stores skills as comma-list per group OR with levels
|
|
71
|
+
skills: z.string().default(""),
|
|
72
|
+
// comma-separated for simple mode
|
|
73
|
+
level: z.enum(["", "beginner", "intermediate", "advanced", "expert"]).default("")
|
|
74
|
+
});
|
|
75
|
+
var LanguageItem = baseEntry.extend({
|
|
76
|
+
name: z.string().default(""),
|
|
77
|
+
level: z.string().default("")
|
|
78
|
+
// "Native" | "Fluent" | "Conversational" | "Basic" | free text
|
|
79
|
+
});
|
|
80
|
+
var CertificateItem = baseEntry.extend({
|
|
81
|
+
name: z.string().default(""),
|
|
82
|
+
issuer: z.string().default(""),
|
|
83
|
+
date: z.string().default(""),
|
|
84
|
+
link: z.string().default("")
|
|
85
|
+
});
|
|
86
|
+
var InterestItem = baseEntry.extend({
|
|
87
|
+
name: z.string().default(""),
|
|
88
|
+
description: z.string().default("")
|
|
89
|
+
});
|
|
90
|
+
var CourseItem = baseEntry.extend({
|
|
91
|
+
name: z.string().default(""),
|
|
92
|
+
institution: z.string().default(""),
|
|
93
|
+
date: z.string().default(""),
|
|
94
|
+
description: z.string().default("")
|
|
95
|
+
});
|
|
96
|
+
var AwardItem = baseEntry.extend({
|
|
97
|
+
name: z.string().default(""),
|
|
98
|
+
issuer: z.string().default(""),
|
|
99
|
+
date: z.string().default(""),
|
|
100
|
+
description: z.string().default("")
|
|
101
|
+
});
|
|
102
|
+
var OrganisationItem = baseEntry.extend({
|
|
103
|
+
name: z.string().default(""),
|
|
104
|
+
role: z.string().default(""),
|
|
105
|
+
startDate: z.string().default(""),
|
|
106
|
+
endDate: z.string().default(""),
|
|
107
|
+
description: z.string().default("")
|
|
108
|
+
});
|
|
109
|
+
var PublicationItem = baseEntry.extend({
|
|
110
|
+
title: z.string().default(""),
|
|
111
|
+
publisher: z.string().default(""),
|
|
112
|
+
date: z.string().default(""),
|
|
113
|
+
link: z.string().default(""),
|
|
114
|
+
description: z.string().default("")
|
|
115
|
+
});
|
|
116
|
+
var ReferenceItem = baseEntry.extend({
|
|
117
|
+
name: z.string().default(""),
|
|
118
|
+
relationship: z.string().default(""),
|
|
119
|
+
company: z.string().default(""),
|
|
120
|
+
email: z.string().default(""),
|
|
121
|
+
phone: z.string().default("")
|
|
122
|
+
});
|
|
123
|
+
var DeclarationItem = baseEntry.extend({
|
|
124
|
+
body: z.string().default(""),
|
|
125
|
+
signatureUrl: z.string().default(""),
|
|
126
|
+
signedAt: z.string().default("")
|
|
127
|
+
});
|
|
128
|
+
var CustomItem = baseEntry.extend({
|
|
129
|
+
title: z.string().default(""),
|
|
130
|
+
subtitle: z.string().default(""),
|
|
131
|
+
date: z.string().default(""),
|
|
132
|
+
description: z.string().default("")
|
|
133
|
+
});
|
|
134
|
+
var SectionBase = z.object({
|
|
135
|
+
id: z.string(),
|
|
136
|
+
title: z.string(),
|
|
137
|
+
visible: z.boolean().default(true),
|
|
138
|
+
columns: z.union([z.literal(1), z.literal(2)]).default(1)
|
|
139
|
+
});
|
|
140
|
+
var Section = z.discriminatedUnion("type", [
|
|
141
|
+
SectionBase.extend({ type: z.literal("summary"), items: z.array(SummaryItem).default([]) }),
|
|
142
|
+
SectionBase.extend({ type: z.literal("experience"), items: z.array(ExperienceItem).default([]) }),
|
|
143
|
+
SectionBase.extend({ type: z.literal("education"), items: z.array(EducationItem).default([]) }),
|
|
144
|
+
SectionBase.extend({ type: z.literal("skills"), items: z.array(SkillItem).default([]) }),
|
|
145
|
+
SectionBase.extend({ type: z.literal("languages"), items: z.array(LanguageItem).default([]) }),
|
|
146
|
+
SectionBase.extend({ type: z.literal("certificates"), items: z.array(CertificateItem).default([]) }),
|
|
147
|
+
SectionBase.extend({ type: z.literal("interests"), items: z.array(InterestItem).default([]) }),
|
|
148
|
+
SectionBase.extend({ type: z.literal("projects"), items: z.array(ProjectItem).default([]) }),
|
|
149
|
+
SectionBase.extend({ type: z.literal("courses"), items: z.array(CourseItem).default([]) }),
|
|
150
|
+
SectionBase.extend({ type: z.literal("awards"), items: z.array(AwardItem).default([]) }),
|
|
151
|
+
SectionBase.extend({ type: z.literal("organisations"), items: z.array(OrganisationItem).default([]) }),
|
|
152
|
+
SectionBase.extend({ type: z.literal("publications"), items: z.array(PublicationItem).default([]) }),
|
|
153
|
+
SectionBase.extend({ type: z.literal("references"), items: z.array(ReferenceItem).default([]) }),
|
|
154
|
+
SectionBase.extend({ type: z.literal("declaration"), items: z.array(DeclarationItem).default([]) }),
|
|
155
|
+
SectionBase.extend({ type: z.literal("custom"), items: z.array(CustomItem).default([]) })
|
|
156
|
+
]);
|
|
157
|
+
var DEFAULT_SECTION_TITLES = {
|
|
158
|
+
summary: "Professional Summary",
|
|
159
|
+
experience: "Professional Experience",
|
|
160
|
+
education: "Education",
|
|
161
|
+
skills: "Skills",
|
|
162
|
+
languages: "Languages",
|
|
163
|
+
certificates: "Certificates",
|
|
164
|
+
interests: "Interests",
|
|
165
|
+
projects: "Projects",
|
|
166
|
+
courses: "Courses",
|
|
167
|
+
awards: "Awards",
|
|
168
|
+
organisations: "Organisations",
|
|
169
|
+
publications: "Publications",
|
|
170
|
+
references: "References",
|
|
171
|
+
declaration: "Declaration",
|
|
172
|
+
custom: "Custom Section"
|
|
173
|
+
};
|
|
174
|
+
var TEMPLATE_IDS = [
|
|
175
|
+
// 5 design-forward (originals)
|
|
176
|
+
"aurora",
|
|
177
|
+
"obsidian",
|
|
178
|
+
"prism",
|
|
179
|
+
"meridian",
|
|
180
|
+
"coral",
|
|
181
|
+
// Simple
|
|
182
|
+
"atlas",
|
|
183
|
+
"linen",
|
|
184
|
+
"monolith",
|
|
185
|
+
// Modern
|
|
186
|
+
"vector",
|
|
187
|
+
"nova",
|
|
188
|
+
"axis",
|
|
189
|
+
// Creative
|
|
190
|
+
"kintsugi",
|
|
191
|
+
"spectra",
|
|
192
|
+
// Photo
|
|
193
|
+
"portrait",
|
|
194
|
+
"cameo",
|
|
195
|
+
// Compact
|
|
196
|
+
"compact",
|
|
197
|
+
// First Job
|
|
198
|
+
"graduate",
|
|
199
|
+
// Popular extras
|
|
200
|
+
"harvard",
|
|
201
|
+
"executive",
|
|
202
|
+
"bauhaus",
|
|
203
|
+
// FAANG-tested (community-validated formats)
|
|
204
|
+
"jake",
|
|
205
|
+
"deedy",
|
|
206
|
+
"sb2nov",
|
|
207
|
+
"engresumes",
|
|
208
|
+
"awesome",
|
|
209
|
+
"faangpath",
|
|
210
|
+
// International / Industry standards
|
|
211
|
+
"europass",
|
|
212
|
+
"mckinsey",
|
|
213
|
+
"stanford",
|
|
214
|
+
"mit",
|
|
215
|
+
"oneline",
|
|
216
|
+
"rolodex"
|
|
217
|
+
];
|
|
218
|
+
var Styling = z.object({
|
|
219
|
+
template: z.enum(TEMPLATE_IDS).default("aurora"),
|
|
220
|
+
colorMode: z.enum(["light", "dark"]).default("light"),
|
|
221
|
+
accent: z.string().default("#111111"),
|
|
222
|
+
fontHeading: z.string().default("Playfair Display"),
|
|
223
|
+
fontBody: z.string().default("Inter"),
|
|
224
|
+
fontSize: z.number().default(10.5),
|
|
225
|
+
// body pt
|
|
226
|
+
lineHeight: z.number().default(1.5),
|
|
227
|
+
letterSpacing: z.number().default(0),
|
|
228
|
+
// em x 1000 (we treat as px-ish)
|
|
229
|
+
headingsLine: z.enum(["none", "underline", "left-bar", "boxed"]).default("none"),
|
|
230
|
+
spacing: z.enum(["compact", "regular", "spacious"]).default("regular"),
|
|
231
|
+
bulletGlyph: z.enum(["disc", "dash", "arrow", "square", "check", "none"]).default("disc"),
|
|
232
|
+
layout: z.enum(["single", "sidebar-left", "sidebar-right", "two-column"]).default("single"),
|
|
233
|
+
subtitlePlacement: z.enum(["below", "inline", "right"]).default("below"),
|
|
234
|
+
subtitleStyle: z.enum(["italic", "regular", "bold", "small-caps"]).default("italic"),
|
|
235
|
+
photoEnabled: z.boolean().default(false),
|
|
236
|
+
photoShape: z.enum(["circle", "rounded", "square"]).default("circle"),
|
|
237
|
+
photoSize: z.number().default(72),
|
|
238
|
+
// Page margins in px @96dpi (used by the preview slicer + PDF export).
|
|
239
|
+
// Defaults match the previous hardcoded values: ~0.5in top/bottom, ~0.6in sides.
|
|
240
|
+
pageMarginX: z.number().default(56),
|
|
241
|
+
pageMarginTop: z.number().default(48),
|
|
242
|
+
pageMarginBottom: z.number().default(48)
|
|
243
|
+
});
|
|
244
|
+
var Locale = z.object({
|
|
245
|
+
language: z.string().default("en-GB"),
|
|
246
|
+
dateFormat: z.enum(["MM/YYYY", "MMM YYYY", "YYYY", "DD/MM/YYYY", "MM/DD/YYYY"]).default("MMM YYYY"),
|
|
247
|
+
pageFormat: z.enum(["A4", "Letter"]).default("A4")
|
|
248
|
+
});
|
|
249
|
+
var Resume = z.object({
|
|
250
|
+
id: z.string(),
|
|
251
|
+
name: z.string().default("Untitled"),
|
|
252
|
+
schemaVersion: z.literal(2).default(2),
|
|
253
|
+
createdAt: z.string().default(() => (/* @__PURE__ */ new Date()).toISOString()),
|
|
254
|
+
updatedAt: z.string().default(() => (/* @__PURE__ */ new Date()).toISOString()),
|
|
255
|
+
personal: PersonalInfo,
|
|
256
|
+
sections: z.array(Section).default([]),
|
|
257
|
+
styling: Styling,
|
|
258
|
+
locale: Locale
|
|
259
|
+
});
|
|
260
|
+
var CoverLetter = z.object({
|
|
261
|
+
id: z.string(),
|
|
262
|
+
name: z.string().default("Untitled"),
|
|
263
|
+
schemaVersion: z.literal(2).default(2),
|
|
264
|
+
createdAt: z.string().default(() => (/* @__PURE__ */ new Date()).toISOString()),
|
|
265
|
+
updatedAt: z.string().default(() => (/* @__PURE__ */ new Date()).toISOString()),
|
|
266
|
+
personal: PersonalInfo,
|
|
267
|
+
recipient: z.object({
|
|
268
|
+
name: z.string().default(""),
|
|
269
|
+
role: z.string().default(""),
|
|
270
|
+
company: z.string().default(""),
|
|
271
|
+
address: z.string().default("")
|
|
272
|
+
}).default({ name: "", role: "", company: "", address: "" }),
|
|
273
|
+
date: z.string().default(""),
|
|
274
|
+
subject: z.string().default(""),
|
|
275
|
+
greeting: z.string().default("Dear Hiring Manager,"),
|
|
276
|
+
body: z.string().default(""),
|
|
277
|
+
closing: z.string().default("Sincerely,"),
|
|
278
|
+
styling: Styling,
|
|
279
|
+
locale: Locale
|
|
280
|
+
});
|
|
281
|
+
var newId = () => Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
282
|
+
|
|
283
|
+
// ../../packages/libraries/src/action-verbs.ts
|
|
284
|
+
var VERB_CATEGORY_LABEL = {
|
|
285
|
+
accomplishment: "Accomplishment-driven",
|
|
286
|
+
communication: "Communication",
|
|
287
|
+
entrepreneurial: "Entrepreneurial",
|
|
288
|
+
executive: "Executive / Management",
|
|
289
|
+
leadership: "Leadership & Mentorship",
|
|
290
|
+
research: "Research & Analysis",
|
|
291
|
+
"problem-solving": "Problem Solving",
|
|
292
|
+
"process-improvement": "Process Improvement",
|
|
293
|
+
financial: "Financial / Numeric",
|
|
294
|
+
design: "Design & Creative",
|
|
295
|
+
administrative: "Administrative",
|
|
296
|
+
engineering: "Engineering / Technical"
|
|
297
|
+
};
|
|
298
|
+
var ACTION_VERBS = {
|
|
299
|
+
accomplishment: [
|
|
300
|
+
"Accelerated",
|
|
301
|
+
"Accomplished",
|
|
302
|
+
"Achieved",
|
|
303
|
+
"Advanced",
|
|
304
|
+
"Amplified",
|
|
305
|
+
"Attained",
|
|
306
|
+
"Boosted",
|
|
307
|
+
"Capitalized",
|
|
308
|
+
"Captured",
|
|
309
|
+
"Catapulted",
|
|
310
|
+
"Delivered",
|
|
311
|
+
"Doubled",
|
|
312
|
+
"Drove",
|
|
313
|
+
"Earned",
|
|
314
|
+
"Eclipsed",
|
|
315
|
+
"Elevated",
|
|
316
|
+
"Energized",
|
|
317
|
+
"Enhanced",
|
|
318
|
+
"Exceeded",
|
|
319
|
+
"Expanded",
|
|
320
|
+
"Expedited",
|
|
321
|
+
"Furthered",
|
|
322
|
+
"Gained",
|
|
323
|
+
"Generated",
|
|
324
|
+
"Grew",
|
|
325
|
+
"Hit",
|
|
326
|
+
"Improved",
|
|
327
|
+
"Increased",
|
|
328
|
+
"Launched",
|
|
329
|
+
"Maximized",
|
|
330
|
+
"Multiplied",
|
|
331
|
+
"Outperformed",
|
|
332
|
+
"Outpaced",
|
|
333
|
+
"Outsold",
|
|
334
|
+
"Overdelivered",
|
|
335
|
+
"Pioneered",
|
|
336
|
+
"Produced",
|
|
337
|
+
"Propelled",
|
|
338
|
+
"Quadrupled",
|
|
339
|
+
"Realized",
|
|
340
|
+
"Secured",
|
|
341
|
+
"Spearheaded",
|
|
342
|
+
"Stimulated",
|
|
343
|
+
"Surged",
|
|
344
|
+
"Surpassed",
|
|
345
|
+
"Tripled",
|
|
346
|
+
"Won"
|
|
347
|
+
],
|
|
348
|
+
communication: [
|
|
349
|
+
"Addressed",
|
|
350
|
+
"Advised",
|
|
351
|
+
"Advocated",
|
|
352
|
+
"Articulated",
|
|
353
|
+
"Authored",
|
|
354
|
+
"Briefed",
|
|
355
|
+
"Communicated",
|
|
356
|
+
"Composed",
|
|
357
|
+
"Conferred",
|
|
358
|
+
"Consulted",
|
|
359
|
+
"Conveyed",
|
|
360
|
+
"Corresponded",
|
|
361
|
+
"Debated",
|
|
362
|
+
"Delivered",
|
|
363
|
+
"Demonstrated",
|
|
364
|
+
"Drafted",
|
|
365
|
+
"Edited",
|
|
366
|
+
"Educated",
|
|
367
|
+
"Explained",
|
|
368
|
+
"Facilitated",
|
|
369
|
+
"Influenced",
|
|
370
|
+
"Informed",
|
|
371
|
+
"Interpreted",
|
|
372
|
+
"Interviewed",
|
|
373
|
+
"Lectured",
|
|
374
|
+
"Marketed",
|
|
375
|
+
"Mediated",
|
|
376
|
+
"Moderated",
|
|
377
|
+
"Negotiated",
|
|
378
|
+
"Persuaded",
|
|
379
|
+
"Pitched",
|
|
380
|
+
"Presented",
|
|
381
|
+
"Promoted",
|
|
382
|
+
"Publicized",
|
|
383
|
+
"Reported",
|
|
384
|
+
"Spoke",
|
|
385
|
+
"Summarized",
|
|
386
|
+
"Trained",
|
|
387
|
+
"Translated",
|
|
388
|
+
"Wrote"
|
|
389
|
+
],
|
|
390
|
+
entrepreneurial: [
|
|
391
|
+
"Bootstrapped",
|
|
392
|
+
"Built",
|
|
393
|
+
"Catalyzed",
|
|
394
|
+
"Co-founded",
|
|
395
|
+
"Conceived",
|
|
396
|
+
"Conceptualized",
|
|
397
|
+
"Created",
|
|
398
|
+
"Established",
|
|
399
|
+
"Founded",
|
|
400
|
+
"Identified",
|
|
401
|
+
"Incubated",
|
|
402
|
+
"Initiated",
|
|
403
|
+
"Invented",
|
|
404
|
+
"Launched",
|
|
405
|
+
"Originated",
|
|
406
|
+
"Pioneered",
|
|
407
|
+
"Productized",
|
|
408
|
+
"Prototyped",
|
|
409
|
+
"Scaled",
|
|
410
|
+
"Spun-up",
|
|
411
|
+
"Started",
|
|
412
|
+
"Validated"
|
|
413
|
+
],
|
|
414
|
+
executive: [
|
|
415
|
+
"Administered",
|
|
416
|
+
"Allocated",
|
|
417
|
+
"Appointed",
|
|
418
|
+
"Approved",
|
|
419
|
+
"Assigned",
|
|
420
|
+
"Authorized",
|
|
421
|
+
"Budgeted",
|
|
422
|
+
"Chaired",
|
|
423
|
+
"Commanded",
|
|
424
|
+
"Commissioned",
|
|
425
|
+
"Controlled",
|
|
426
|
+
"Coordinated",
|
|
427
|
+
"Delegated",
|
|
428
|
+
"Directed",
|
|
429
|
+
"Drove",
|
|
430
|
+
"Empowered",
|
|
431
|
+
"Enacted",
|
|
432
|
+
"Endorsed",
|
|
433
|
+
"Enforced",
|
|
434
|
+
"Established",
|
|
435
|
+
"Executed",
|
|
436
|
+
"Facilitated",
|
|
437
|
+
"Governed",
|
|
438
|
+
"Guided",
|
|
439
|
+
"Headed",
|
|
440
|
+
"Hired",
|
|
441
|
+
"Implemented",
|
|
442
|
+
"Instituted",
|
|
443
|
+
"Led",
|
|
444
|
+
"Managed",
|
|
445
|
+
"Mobilized",
|
|
446
|
+
"Operated",
|
|
447
|
+
"Organized",
|
|
448
|
+
"Orchestrated",
|
|
449
|
+
"Oversaw",
|
|
450
|
+
"Owned",
|
|
451
|
+
"Piloted",
|
|
452
|
+
"Presided",
|
|
453
|
+
"Steered",
|
|
454
|
+
"Supervised"
|
|
455
|
+
],
|
|
456
|
+
leadership: [
|
|
457
|
+
"Aligned",
|
|
458
|
+
"Championed",
|
|
459
|
+
"Coached",
|
|
460
|
+
"Cultivated",
|
|
461
|
+
"Developed",
|
|
462
|
+
"Empowered",
|
|
463
|
+
"Energized",
|
|
464
|
+
"Engaged",
|
|
465
|
+
"Enlisted",
|
|
466
|
+
"Fostered",
|
|
467
|
+
"Galvanized",
|
|
468
|
+
"Guided",
|
|
469
|
+
"Inspired",
|
|
470
|
+
"Influenced",
|
|
471
|
+
"Led",
|
|
472
|
+
"Mentored",
|
|
473
|
+
"Modeled",
|
|
474
|
+
"Motivated",
|
|
475
|
+
"Nurtured",
|
|
476
|
+
"Onboarded",
|
|
477
|
+
"Recruited",
|
|
478
|
+
"Sponsored",
|
|
479
|
+
"Supported",
|
|
480
|
+
"Taught",
|
|
481
|
+
"Trained",
|
|
482
|
+
"Unified",
|
|
483
|
+
"United"
|
|
484
|
+
],
|
|
485
|
+
research: [
|
|
486
|
+
"Analyzed",
|
|
487
|
+
"Appraised",
|
|
488
|
+
"Assayed",
|
|
489
|
+
"Assessed",
|
|
490
|
+
"Audited",
|
|
491
|
+
"Benchmarked",
|
|
492
|
+
"Calculated",
|
|
493
|
+
"Categorized",
|
|
494
|
+
"Charted",
|
|
495
|
+
"Clarified",
|
|
496
|
+
"Compared",
|
|
497
|
+
"Computed",
|
|
498
|
+
"Concluded",
|
|
499
|
+
"Correlated",
|
|
500
|
+
"Decoded",
|
|
501
|
+
"Detected",
|
|
502
|
+
"Determined",
|
|
503
|
+
"Diagnosed",
|
|
504
|
+
"Discovered",
|
|
505
|
+
"Documented",
|
|
506
|
+
"Evaluated",
|
|
507
|
+
"Examined",
|
|
508
|
+
"Experimented",
|
|
509
|
+
"Explored",
|
|
510
|
+
"Extrapolated",
|
|
511
|
+
"Forecasted",
|
|
512
|
+
"Forecast",
|
|
513
|
+
"Gathered",
|
|
514
|
+
"Identified",
|
|
515
|
+
"Indexed",
|
|
516
|
+
"Inspected",
|
|
517
|
+
"Interpreted",
|
|
518
|
+
"Investigated",
|
|
519
|
+
"Mapped",
|
|
520
|
+
"Measured",
|
|
521
|
+
"Modeled",
|
|
522
|
+
"Monitored",
|
|
523
|
+
"Observed",
|
|
524
|
+
"Profiled",
|
|
525
|
+
"Quantified",
|
|
526
|
+
"Researched",
|
|
527
|
+
"Reviewed",
|
|
528
|
+
"Scrutinized",
|
|
529
|
+
"Studied",
|
|
530
|
+
"Surveyed",
|
|
531
|
+
"Synthesized",
|
|
532
|
+
"Tested",
|
|
533
|
+
"Tracked",
|
|
534
|
+
"Verified"
|
|
535
|
+
],
|
|
536
|
+
"problem-solving": [
|
|
537
|
+
"Alleviated",
|
|
538
|
+
"Arranged",
|
|
539
|
+
"Combatted",
|
|
540
|
+
"Conquered",
|
|
541
|
+
"Corrected",
|
|
542
|
+
"Counteracted",
|
|
543
|
+
"Cured",
|
|
544
|
+
"Debugged",
|
|
545
|
+
"Defeated",
|
|
546
|
+
"Diagnosed",
|
|
547
|
+
"Eased",
|
|
548
|
+
"Eliminated",
|
|
549
|
+
"Eradicated",
|
|
550
|
+
"Fixed",
|
|
551
|
+
"Mitigated",
|
|
552
|
+
"Overcame",
|
|
553
|
+
"Prevented",
|
|
554
|
+
"Reconciled",
|
|
555
|
+
"Recovered",
|
|
556
|
+
"Rectified",
|
|
557
|
+
"Reduced",
|
|
558
|
+
"Refined",
|
|
559
|
+
"Remedied",
|
|
560
|
+
"Resolved",
|
|
561
|
+
"Restored",
|
|
562
|
+
"Salvaged",
|
|
563
|
+
"Solved",
|
|
564
|
+
"Stabilized",
|
|
565
|
+
"Stopped",
|
|
566
|
+
"Triaged",
|
|
567
|
+
"Untangled",
|
|
568
|
+
"Unblocked"
|
|
569
|
+
],
|
|
570
|
+
"process-improvement": [
|
|
571
|
+
"Automated",
|
|
572
|
+
"Centralized",
|
|
573
|
+
"Codified",
|
|
574
|
+
"Consolidated",
|
|
575
|
+
"Converted",
|
|
576
|
+
"Decoupled",
|
|
577
|
+
"Digitized",
|
|
578
|
+
"Eliminated",
|
|
579
|
+
"Engineered",
|
|
580
|
+
"Established",
|
|
581
|
+
"Improved",
|
|
582
|
+
"Industrialized",
|
|
583
|
+
"Lean-ified",
|
|
584
|
+
"Migrated",
|
|
585
|
+
"Modernized",
|
|
586
|
+
"Optimized",
|
|
587
|
+
"Productized",
|
|
588
|
+
"Redesigned",
|
|
589
|
+
"Reduced",
|
|
590
|
+
"Refined",
|
|
591
|
+
"Reformed",
|
|
592
|
+
"Reorganized",
|
|
593
|
+
"Replatformed",
|
|
594
|
+
"Restructured",
|
|
595
|
+
"Revamped",
|
|
596
|
+
"Revitalized",
|
|
597
|
+
"Revolutionized",
|
|
598
|
+
"Simplified",
|
|
599
|
+
"Standardized",
|
|
600
|
+
"Streamlined",
|
|
601
|
+
"Systematized",
|
|
602
|
+
"Transformed",
|
|
603
|
+
"Unified",
|
|
604
|
+
"Upgraded"
|
|
605
|
+
],
|
|
606
|
+
financial: [
|
|
607
|
+
"Allocated",
|
|
608
|
+
"Appraised",
|
|
609
|
+
"Audited",
|
|
610
|
+
"Balanced",
|
|
611
|
+
"Banked",
|
|
612
|
+
"Billed",
|
|
613
|
+
"Budgeted",
|
|
614
|
+
"Calculated",
|
|
615
|
+
"Capitalized",
|
|
616
|
+
"Charged",
|
|
617
|
+
"Collected",
|
|
618
|
+
"Compounded",
|
|
619
|
+
"Cut",
|
|
620
|
+
"Decreased",
|
|
621
|
+
"Disbursed",
|
|
622
|
+
"Discounted",
|
|
623
|
+
"Estimated",
|
|
624
|
+
"Forecast",
|
|
625
|
+
"Funded",
|
|
626
|
+
"Hedged",
|
|
627
|
+
"Invested",
|
|
628
|
+
"Invoiced",
|
|
629
|
+
"Issued",
|
|
630
|
+
"Liquidated",
|
|
631
|
+
"Maximized",
|
|
632
|
+
"Minimized",
|
|
633
|
+
"Negotiated",
|
|
634
|
+
"Profited",
|
|
635
|
+
"Projected",
|
|
636
|
+
"Quantified",
|
|
637
|
+
"Reconciled",
|
|
638
|
+
"Reduced",
|
|
639
|
+
"Saved",
|
|
640
|
+
"Sold",
|
|
641
|
+
"Tracked",
|
|
642
|
+
"Yielded"
|
|
643
|
+
],
|
|
644
|
+
design: [
|
|
645
|
+
"Animated",
|
|
646
|
+
"Branded",
|
|
647
|
+
"Composed",
|
|
648
|
+
"Conceived",
|
|
649
|
+
"Conceptualized",
|
|
650
|
+
"Created",
|
|
651
|
+
"Crafted",
|
|
652
|
+
"Curated",
|
|
653
|
+
"Customized",
|
|
654
|
+
"Designed",
|
|
655
|
+
"Devised",
|
|
656
|
+
"Drafted",
|
|
657
|
+
"Drew",
|
|
658
|
+
"Engineered",
|
|
659
|
+
"Envisioned",
|
|
660
|
+
"Fashioned",
|
|
661
|
+
"Formed",
|
|
662
|
+
"Formulated",
|
|
663
|
+
"Illustrated",
|
|
664
|
+
"Imagined",
|
|
665
|
+
"Innovated",
|
|
666
|
+
"Invented",
|
|
667
|
+
"Modelled",
|
|
668
|
+
"Originated",
|
|
669
|
+
"Painted",
|
|
670
|
+
"Pioneered",
|
|
671
|
+
"Prototyped",
|
|
672
|
+
"Reimagined",
|
|
673
|
+
"Rendered",
|
|
674
|
+
"Sculpted",
|
|
675
|
+
"Shaped",
|
|
676
|
+
"Sketched",
|
|
677
|
+
"Styled",
|
|
678
|
+
"Visualized"
|
|
679
|
+
],
|
|
680
|
+
administrative: [
|
|
681
|
+
"Approved",
|
|
682
|
+
"Arranged",
|
|
683
|
+
"Authored",
|
|
684
|
+
"Booked",
|
|
685
|
+
"Calendared",
|
|
686
|
+
"Catalogued",
|
|
687
|
+
"Charted",
|
|
688
|
+
"Classified",
|
|
689
|
+
"Collated",
|
|
690
|
+
"Compiled",
|
|
691
|
+
"Coordinated",
|
|
692
|
+
"Disseminated",
|
|
693
|
+
"Distributed",
|
|
694
|
+
"Documented",
|
|
695
|
+
"Drafted",
|
|
696
|
+
"Executed",
|
|
697
|
+
"Expedited",
|
|
698
|
+
"Filed",
|
|
699
|
+
"Generated",
|
|
700
|
+
"Implemented",
|
|
701
|
+
"Logged",
|
|
702
|
+
"Maintained",
|
|
703
|
+
"Monitored",
|
|
704
|
+
"Organized",
|
|
705
|
+
"Prepared",
|
|
706
|
+
"Prioritized",
|
|
707
|
+
"Processed",
|
|
708
|
+
"Provided",
|
|
709
|
+
"Recorded",
|
|
710
|
+
"Registered",
|
|
711
|
+
"Reported",
|
|
712
|
+
"Routed",
|
|
713
|
+
"Scheduled",
|
|
714
|
+
"Screened",
|
|
715
|
+
"Submitted",
|
|
716
|
+
"Tracked",
|
|
717
|
+
"Updated",
|
|
718
|
+
"Validated"
|
|
719
|
+
],
|
|
720
|
+
engineering: [
|
|
721
|
+
"Architected",
|
|
722
|
+
"Automated",
|
|
723
|
+
"Benchmarked",
|
|
724
|
+
"Built",
|
|
725
|
+
"Coded",
|
|
726
|
+
"Compiled",
|
|
727
|
+
"Configured",
|
|
728
|
+
"Containerized",
|
|
729
|
+
"Debugged",
|
|
730
|
+
"Decoupled",
|
|
731
|
+
"Deployed",
|
|
732
|
+
"Designed",
|
|
733
|
+
"Developed",
|
|
734
|
+
"Engineered",
|
|
735
|
+
"Hardened",
|
|
736
|
+
"Implemented",
|
|
737
|
+
"Instrumented",
|
|
738
|
+
"Integrated",
|
|
739
|
+
"Migrated",
|
|
740
|
+
"Optimized",
|
|
741
|
+
"Orchestrated",
|
|
742
|
+
"Programmed",
|
|
743
|
+
"Provisioned",
|
|
744
|
+
"Refactored",
|
|
745
|
+
"Released",
|
|
746
|
+
"Scaled",
|
|
747
|
+
"Shipped",
|
|
748
|
+
"Tested",
|
|
749
|
+
"Tuned",
|
|
750
|
+
"Upgraded",
|
|
751
|
+
"Virtualized",
|
|
752
|
+
"Wrote"
|
|
753
|
+
]
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
// ../../packages/libraries/src/skills.ts
|
|
757
|
+
var SKILLS = [
|
|
758
|
+
// Engineering / Software
|
|
759
|
+
...[
|
|
760
|
+
"JavaScript",
|
|
761
|
+
"TypeScript",
|
|
762
|
+
"Python",
|
|
763
|
+
"Java",
|
|
764
|
+
"C",
|
|
765
|
+
"C++",
|
|
766
|
+
"C#",
|
|
767
|
+
"Go",
|
|
768
|
+
"Rust",
|
|
769
|
+
"Ruby",
|
|
770
|
+
"PHP",
|
|
771
|
+
"Swift",
|
|
772
|
+
"Kotlin",
|
|
773
|
+
"Objective-C",
|
|
774
|
+
"Scala",
|
|
775
|
+
"R",
|
|
776
|
+
"MATLAB",
|
|
777
|
+
"Bash",
|
|
778
|
+
"Shell scripting",
|
|
779
|
+
"SQL",
|
|
780
|
+
"NoSQL",
|
|
781
|
+
"GraphQL",
|
|
782
|
+
"REST APIs",
|
|
783
|
+
"gRPC",
|
|
784
|
+
"Protobuf",
|
|
785
|
+
"WebSockets",
|
|
786
|
+
"React",
|
|
787
|
+
"React Native",
|
|
788
|
+
"Next.js",
|
|
789
|
+
"Vue",
|
|
790
|
+
"Nuxt",
|
|
791
|
+
"Angular",
|
|
792
|
+
"Svelte",
|
|
793
|
+
"SvelteKit",
|
|
794
|
+
"Solid",
|
|
795
|
+
"Remix",
|
|
796
|
+
"Astro",
|
|
797
|
+
"Redux",
|
|
798
|
+
"Zustand",
|
|
799
|
+
"Tailwind CSS",
|
|
800
|
+
"CSS",
|
|
801
|
+
"SASS",
|
|
802
|
+
"HTML",
|
|
803
|
+
"Webpack",
|
|
804
|
+
"Vite",
|
|
805
|
+
"ESBuild",
|
|
806
|
+
"Turbopack",
|
|
807
|
+
"Node.js",
|
|
808
|
+
"Express",
|
|
809
|
+
"Fastify",
|
|
810
|
+
"NestJS",
|
|
811
|
+
"Deno",
|
|
812
|
+
"Bun",
|
|
813
|
+
"Django",
|
|
814
|
+
"Flask",
|
|
815
|
+
"FastAPI",
|
|
816
|
+
"Spring",
|
|
817
|
+
"Spring Boot",
|
|
818
|
+
"Ruby on Rails",
|
|
819
|
+
"Laravel",
|
|
820
|
+
"ASP.NET",
|
|
821
|
+
"Phoenix",
|
|
822
|
+
"Elixir",
|
|
823
|
+
"PostgreSQL",
|
|
824
|
+
"MySQL",
|
|
825
|
+
"SQLite",
|
|
826
|
+
"MongoDB",
|
|
827
|
+
"Redis",
|
|
828
|
+
"Cassandra",
|
|
829
|
+
"DynamoDB",
|
|
830
|
+
"Elasticsearch",
|
|
831
|
+
"Snowflake",
|
|
832
|
+
"BigQuery",
|
|
833
|
+
"Databricks",
|
|
834
|
+
"Kafka",
|
|
835
|
+
"RabbitMQ",
|
|
836
|
+
"ClickHouse",
|
|
837
|
+
"DuckDB",
|
|
838
|
+
"AWS",
|
|
839
|
+
"GCP",
|
|
840
|
+
"Azure",
|
|
841
|
+
"Vercel",
|
|
842
|
+
"Netlify",
|
|
843
|
+
"Fly.io",
|
|
844
|
+
"Render",
|
|
845
|
+
"Heroku",
|
|
846
|
+
"Cloudflare",
|
|
847
|
+
"Cloudflare Workers",
|
|
848
|
+
"Lambda",
|
|
849
|
+
"S3",
|
|
850
|
+
"EC2",
|
|
851
|
+
"ECS",
|
|
852
|
+
"EKS",
|
|
853
|
+
"Kubernetes",
|
|
854
|
+
"Docker",
|
|
855
|
+
"Terraform",
|
|
856
|
+
"Pulumi",
|
|
857
|
+
"Ansible",
|
|
858
|
+
"Helm",
|
|
859
|
+
"Istio",
|
|
860
|
+
"CI/CD",
|
|
861
|
+
"GitHub Actions",
|
|
862
|
+
"CircleCI",
|
|
863
|
+
"Jenkins",
|
|
864
|
+
"GitLab CI",
|
|
865
|
+
"ArgoCD",
|
|
866
|
+
"Spinnaker",
|
|
867
|
+
"Linux",
|
|
868
|
+
"macOS",
|
|
869
|
+
"Windows",
|
|
870
|
+
"Git",
|
|
871
|
+
"Mercurial",
|
|
872
|
+
"SVN",
|
|
873
|
+
"Playwright",
|
|
874
|
+
"Cypress",
|
|
875
|
+
"Selenium",
|
|
876
|
+
"Jest",
|
|
877
|
+
"Vitest",
|
|
878
|
+
"Mocha",
|
|
879
|
+
"Chai",
|
|
880
|
+
"PyTest",
|
|
881
|
+
"JUnit",
|
|
882
|
+
"TestNG",
|
|
883
|
+
"Postman",
|
|
884
|
+
"Insomnia",
|
|
885
|
+
"Pandas",
|
|
886
|
+
"NumPy",
|
|
887
|
+
"SciPy",
|
|
888
|
+
"scikit-learn",
|
|
889
|
+
"TensorFlow",
|
|
890
|
+
"PyTorch",
|
|
891
|
+
"Keras",
|
|
892
|
+
"XGBoost",
|
|
893
|
+
"LightGBM",
|
|
894
|
+
"CatBoost",
|
|
895
|
+
"Hugging Face",
|
|
896
|
+
"LangChain",
|
|
897
|
+
"LlamaIndex",
|
|
898
|
+
"Pinecone",
|
|
899
|
+
"Weaviate",
|
|
900
|
+
"Milvus",
|
|
901
|
+
"Chroma",
|
|
902
|
+
"Qdrant",
|
|
903
|
+
"OpenAI API",
|
|
904
|
+
"Anthropic API",
|
|
905
|
+
"Claude",
|
|
906
|
+
"GPT",
|
|
907
|
+
"RAG",
|
|
908
|
+
"Vector search",
|
|
909
|
+
"Prompt engineering",
|
|
910
|
+
"Tool use",
|
|
911
|
+
"MCP",
|
|
912
|
+
"Fine-tuning",
|
|
913
|
+
"Embeddings",
|
|
914
|
+
"LLM ops",
|
|
915
|
+
"Vertex AI",
|
|
916
|
+
"SageMaker",
|
|
917
|
+
"Solidity",
|
|
918
|
+
"Web3",
|
|
919
|
+
"Ethereum",
|
|
920
|
+
"Smart contracts",
|
|
921
|
+
"Hardhat",
|
|
922
|
+
"Foundry",
|
|
923
|
+
"DevSecOps",
|
|
924
|
+
"OWASP",
|
|
925
|
+
"SOC2",
|
|
926
|
+
"SAST",
|
|
927
|
+
"DAST",
|
|
928
|
+
"Pen testing",
|
|
929
|
+
"Zero trust",
|
|
930
|
+
"SSO",
|
|
931
|
+
"SAML",
|
|
932
|
+
"OAuth",
|
|
933
|
+
"OIDC",
|
|
934
|
+
"JWT"
|
|
935
|
+
].map((n) => ({ name: n, kind: "hard", family: "engineering" })),
|
|
936
|
+
// Data / Analytics
|
|
937
|
+
...["Excel", "Google Sheets", "Power BI", "Tableau", "Looker", "Mode", "Metabase", "Superset", "Mixpanel", "Amplitude", "Heap", "Hotjar", "Segment", "RudderStack", "Snowplow", "dbt", "Airflow", "Prefect", "Dagster", "Spark", "Hadoop", "Hive", "Presto", "Trino", "ETL", "ELT", "Data modeling", "Star schema", "Snowflake schema", "Dimensional modeling", "KPI design", "Cohort analysis", "Funnel analysis", "A/B testing", "Causal inference", "SQL optimization", "Time-series analysis", "Forecasting", "Regression", "Classification", "Clustering", "Statistical analysis", "Hypothesis testing", "Bayesian methods"].map((n) => ({ name: n, kind: "hard", family: "data" })),
|
|
938
|
+
// Product / PM
|
|
939
|
+
...["Product strategy", "Roadmapping", "Discovery", "Product analytics", "User research", "UX research", "Customer interviews", "Jobs-to-be-Done", "Lean", "Agile", "Scrum", "Kanban", "SAFe", "OKRs", "Goal setting", "Prioritization", "RICE", "ICE", "Kano", "MoSCoW", "Story mapping", "Release management", "Go-to-market", "GTM", "Pricing", "Packaging", "Positioning", "Messaging", "Competitive analysis", "Win/loss analysis", "Stakeholder management", "Cross-functional leadership", "Specification writing", "PRDs", "Discovery sprints"].map((n) => ({ name: n, kind: "hard", family: "product" })),
|
|
940
|
+
// Design
|
|
941
|
+
...["Figma", "Sketch", "Adobe XD", "Illustrator", "Photoshop", "InDesign", "After Effects", "Premiere", "Framer", "Webflow", "Principle", "ProtoPie", "Lottie", "Design systems", "Typography", "Brand identity", "Visual design", "Interaction design", "Information architecture", "Wireframing", "Prototyping", "User flows", "Accessibility", "WCAG", "Usability testing", "Heuristic evaluation"].map((n) => ({ name: n, kind: "hard", family: "design" })),
|
|
942
|
+
// Marketing / Growth
|
|
943
|
+
...["SEO", "SEM", "Google Ads", "Facebook Ads", "LinkedIn Ads", "TikTok Ads", "Twitter Ads", "Email marketing", "Mailchimp", "Klaviyo", "Customer.io", "HubSpot", "Marketo", "Salesforce", "Pardot", "CRM", "Content marketing", "Copywriting", "Brand strategy", "Affiliate marketing", "Influencer marketing", "Growth hacking", "Landing page optimization", "Conversion rate optimization", "Lifecycle marketing", "Marketing automation", "Social media management"].map((n) => ({ name: n, kind: "hard", family: "marketing" })),
|
|
944
|
+
// Sales / CS
|
|
945
|
+
...["Sales prospecting", "Cold outreach", "Discovery calls", "Demo delivery", "Solution selling", "SPIN selling", "MEDDIC", "Challenger", "Account management", "Account-based selling", "Negotiation", "Contract negotiation", "Pipeline management", "Forecasting", "Salesforce CRM", "HubSpot CRM", "Outreach", "Salesloft", "Gong", "Customer success", "Onboarding", "Renewals", "Expansion", "Churn reduction", "NPS", "CSAT", "QBRs"].map((n) => ({ name: n, kind: "hard", family: "sales" })),
|
|
946
|
+
// Finance / Ops
|
|
947
|
+
...["Financial modeling", "FP&A", "Budgeting", "Forecasting", "Variance analysis", "Three-statement models", "Discounted cash flow", "M&A", "Due diligence", "Audit", "SOX", "GAAP", "IFRS", "Tax", "Bookkeeping", "QuickBooks", "NetSuite", "SAP", "Oracle ERP", "Supply chain", "Procurement", "Vendor management", "Logistics", "Inventory management", "Six Sigma", "Lean Six Sigma", "Operations", "Project management", "PMP", "PRINCE2"].map((n) => ({ name: n, kind: "hard", family: "finance" })),
|
|
948
|
+
// Languages (hard skill)
|
|
949
|
+
...["English", "Hindi", "Arabic", "French", "German", "Spanish", "Portuguese", "Italian", "Mandarin", "Cantonese", "Japanese", "Korean", "Russian", "Turkish", "Polish", "Dutch", "Swedish", "Danish", "Norwegian", "Finnish", "Greek", "Hebrew", "Urdu", "Bengali", "Tamil", "Telugu", "Marathi", "Gujarati", "Punjabi", "Vietnamese", "Thai", "Indonesian", "Malay"].map((n) => ({ name: n, kind: "hard", family: "language" })),
|
|
950
|
+
// Soft skills
|
|
951
|
+
...["Leadership", "Communication", "Collaboration", "Critical thinking", "Problem solving", "Creativity", "Adaptability", "Time management", "Organization", "Attention to detail", "Decision making", "Conflict resolution", "Empathy", "Active listening", "Mentoring", "Public speaking", "Negotiation", "Storytelling", "Stakeholder management", "Cross-cultural communication", "Emotional intelligence", "Coaching", "Facilitation", "Strategic thinking", "Resilience", "Curiosity", "Ownership", "Bias for action", "First-principles thinking"].map((n) => ({ name: n, kind: "soft", family: "soft" }))
|
|
952
|
+
];
|
|
953
|
+
var SKILL_INDEX = /* @__PURE__ */ new Map();
|
|
954
|
+
for (const s of SKILLS) SKILL_INDEX.set(s.name.toLowerCase(), s);
|
|
955
|
+
function lookupSkill(s) {
|
|
956
|
+
return SKILL_INDEX.get(s.toLowerCase().trim());
|
|
957
|
+
}
|
|
958
|
+
function searchSkills(query, limit = 12) {
|
|
959
|
+
const q = query.toLowerCase().trim();
|
|
960
|
+
if (!q) return [];
|
|
961
|
+
const out = [];
|
|
962
|
+
for (const s of SKILLS) {
|
|
963
|
+
if (s.name.toLowerCase().includes(q)) {
|
|
964
|
+
out.push(s);
|
|
965
|
+
if (out.length >= limit) break;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
return out;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// ../../packages/libraries/src/companies.ts
|
|
972
|
+
var COMPANIES = [
|
|
973
|
+
"Google",
|
|
974
|
+
"Alphabet",
|
|
975
|
+
"Meta",
|
|
976
|
+
"Facebook",
|
|
977
|
+
"Amazon",
|
|
978
|
+
"AWS",
|
|
979
|
+
"Apple",
|
|
980
|
+
"Microsoft",
|
|
981
|
+
"Netflix",
|
|
982
|
+
"Tesla",
|
|
983
|
+
"SpaceX",
|
|
984
|
+
"Stripe",
|
|
985
|
+
"Shopify",
|
|
986
|
+
"Spotify",
|
|
987
|
+
"Uber",
|
|
988
|
+
"Lyft",
|
|
989
|
+
"Airbnb",
|
|
990
|
+
"DoorDash",
|
|
991
|
+
"Instacart",
|
|
992
|
+
"Pinterest",
|
|
993
|
+
"Snap",
|
|
994
|
+
"TikTok",
|
|
995
|
+
"ByteDance",
|
|
996
|
+
"Slack",
|
|
997
|
+
"Salesforce",
|
|
998
|
+
"Oracle",
|
|
999
|
+
"SAP",
|
|
1000
|
+
"IBM",
|
|
1001
|
+
"Intel",
|
|
1002
|
+
"NVIDIA",
|
|
1003
|
+
"AMD",
|
|
1004
|
+
"Qualcomm",
|
|
1005
|
+
"Cisco",
|
|
1006
|
+
"Adobe",
|
|
1007
|
+
"Atlassian",
|
|
1008
|
+
"Datadog",
|
|
1009
|
+
"Snowflake",
|
|
1010
|
+
"Databricks",
|
|
1011
|
+
"MongoDB",
|
|
1012
|
+
"Twilio",
|
|
1013
|
+
"HubSpot",
|
|
1014
|
+
"Asana",
|
|
1015
|
+
"Notion",
|
|
1016
|
+
"Figma",
|
|
1017
|
+
"Canva",
|
|
1018
|
+
"Anthropic",
|
|
1019
|
+
"OpenAI",
|
|
1020
|
+
"DeepMind",
|
|
1021
|
+
"Hugging Face",
|
|
1022
|
+
"GitHub",
|
|
1023
|
+
"GitLab",
|
|
1024
|
+
"Vercel",
|
|
1025
|
+
"Cloudflare",
|
|
1026
|
+
"DigitalOcean",
|
|
1027
|
+
"Heroku",
|
|
1028
|
+
"Linear",
|
|
1029
|
+
"Plaid",
|
|
1030
|
+
"Coinbase",
|
|
1031
|
+
"Square",
|
|
1032
|
+
"Block",
|
|
1033
|
+
"Robinhood",
|
|
1034
|
+
"Revolut",
|
|
1035
|
+
"Wise",
|
|
1036
|
+
"Brex",
|
|
1037
|
+
"Ramp",
|
|
1038
|
+
"Mercury",
|
|
1039
|
+
"Chime",
|
|
1040
|
+
"Affirm",
|
|
1041
|
+
"Klarna",
|
|
1042
|
+
"Plaid",
|
|
1043
|
+
"Booking.com",
|
|
1044
|
+
"Expedia",
|
|
1045
|
+
"Trip.com",
|
|
1046
|
+
"Flipkart",
|
|
1047
|
+
"Swiggy",
|
|
1048
|
+
"Zomato",
|
|
1049
|
+
"Paytm",
|
|
1050
|
+
"Razorpay",
|
|
1051
|
+
"PhonePe",
|
|
1052
|
+
"BYJU'S",
|
|
1053
|
+
"Ola",
|
|
1054
|
+
"Practo",
|
|
1055
|
+
"Cred",
|
|
1056
|
+
"Meesho",
|
|
1057
|
+
"Zoho",
|
|
1058
|
+
"Freshworks",
|
|
1059
|
+
"Postman",
|
|
1060
|
+
"BrowserStack",
|
|
1061
|
+
"Razorpay",
|
|
1062
|
+
"Groww",
|
|
1063
|
+
"Zerodha",
|
|
1064
|
+
"Upstox",
|
|
1065
|
+
"Acko",
|
|
1066
|
+
"Pristyn",
|
|
1067
|
+
"Cure.fit",
|
|
1068
|
+
"ADP",
|
|
1069
|
+
"Accenture",
|
|
1070
|
+
"Deloitte",
|
|
1071
|
+
"McKinsey",
|
|
1072
|
+
"Bain",
|
|
1073
|
+
"BCG",
|
|
1074
|
+
"PwC",
|
|
1075
|
+
"KPMG",
|
|
1076
|
+
"EY",
|
|
1077
|
+
"Capgemini",
|
|
1078
|
+
"TCS",
|
|
1079
|
+
"Infosys",
|
|
1080
|
+
"Wipro",
|
|
1081
|
+
"HCL",
|
|
1082
|
+
"Cognizant",
|
|
1083
|
+
"JPMorgan",
|
|
1084
|
+
"Goldman Sachs",
|
|
1085
|
+
"Morgan Stanley",
|
|
1086
|
+
"Citi",
|
|
1087
|
+
"HSBC",
|
|
1088
|
+
"Standard Chartered",
|
|
1089
|
+
"Barclays",
|
|
1090
|
+
"UBS",
|
|
1091
|
+
"Deutsche Bank",
|
|
1092
|
+
"Credit Suisse",
|
|
1093
|
+
"Carly",
|
|
1094
|
+
"JuicedTrade",
|
|
1095
|
+
"Rintel"
|
|
1096
|
+
];
|
|
1097
|
+
var JOB_TITLES = [
|
|
1098
|
+
// Product
|
|
1099
|
+
"Product Manager",
|
|
1100
|
+
"Senior Product Manager",
|
|
1101
|
+
"Group Product Manager",
|
|
1102
|
+
"Director of Product",
|
|
1103
|
+
"VP Product",
|
|
1104
|
+
"Chief Product Officer",
|
|
1105
|
+
"Associate Product Manager",
|
|
1106
|
+
"Product Lead",
|
|
1107
|
+
"Head of Product",
|
|
1108
|
+
"Principal Product Manager",
|
|
1109
|
+
"Technical Product Manager",
|
|
1110
|
+
"Growth Product Manager",
|
|
1111
|
+
"Platform Product Manager",
|
|
1112
|
+
"Product Operations Manager",
|
|
1113
|
+
// Engineering
|
|
1114
|
+
"Software Engineer",
|
|
1115
|
+
"Senior Software Engineer",
|
|
1116
|
+
"Staff Software Engineer",
|
|
1117
|
+
"Principal Software Engineer",
|
|
1118
|
+
"Engineering Manager",
|
|
1119
|
+
"Director of Engineering",
|
|
1120
|
+
"VP Engineering",
|
|
1121
|
+
"CTO",
|
|
1122
|
+
"Frontend Engineer",
|
|
1123
|
+
"Backend Engineer",
|
|
1124
|
+
"Full Stack Engineer",
|
|
1125
|
+
"Mobile Engineer",
|
|
1126
|
+
"iOS Engineer",
|
|
1127
|
+
"Android Engineer",
|
|
1128
|
+
"DevOps Engineer",
|
|
1129
|
+
"Site Reliability Engineer",
|
|
1130
|
+
"Platform Engineer",
|
|
1131
|
+
"Data Engineer",
|
|
1132
|
+
"Machine Learning Engineer",
|
|
1133
|
+
"AI Engineer",
|
|
1134
|
+
"Security Engineer",
|
|
1135
|
+
"QA Engineer",
|
|
1136
|
+
"Test Engineer",
|
|
1137
|
+
"Embedded Engineer",
|
|
1138
|
+
// Design
|
|
1139
|
+
"Product Designer",
|
|
1140
|
+
"Senior Product Designer",
|
|
1141
|
+
"Staff Product Designer",
|
|
1142
|
+
"Design Lead",
|
|
1143
|
+
"Design Manager",
|
|
1144
|
+
"UX Designer",
|
|
1145
|
+
"UI Designer",
|
|
1146
|
+
"Visual Designer",
|
|
1147
|
+
"Brand Designer",
|
|
1148
|
+
"Motion Designer",
|
|
1149
|
+
"Graphic Designer",
|
|
1150
|
+
"UX Researcher",
|
|
1151
|
+
"Design Systems Designer",
|
|
1152
|
+
"Head of Design",
|
|
1153
|
+
// Data
|
|
1154
|
+
"Data Analyst",
|
|
1155
|
+
"Senior Data Analyst",
|
|
1156
|
+
"Data Scientist",
|
|
1157
|
+
"Senior Data Scientist",
|
|
1158
|
+
"Principal Data Scientist",
|
|
1159
|
+
"Analytics Engineer",
|
|
1160
|
+
"Business Intelligence Analyst",
|
|
1161
|
+
"Data Engineer",
|
|
1162
|
+
"Senior Data Engineer",
|
|
1163
|
+
"Head of Data",
|
|
1164
|
+
"Director of Analytics",
|
|
1165
|
+
// Marketing
|
|
1166
|
+
"Marketing Manager",
|
|
1167
|
+
"Senior Marketing Manager",
|
|
1168
|
+
"Director of Marketing",
|
|
1169
|
+
"CMO",
|
|
1170
|
+
"VP Marketing",
|
|
1171
|
+
"Content Marketing Manager",
|
|
1172
|
+
"Performance Marketing Manager",
|
|
1173
|
+
"Growth Marketing Manager",
|
|
1174
|
+
"Brand Manager",
|
|
1175
|
+
"Product Marketing Manager",
|
|
1176
|
+
"SEO Manager",
|
|
1177
|
+
"Social Media Manager",
|
|
1178
|
+
"Lifecycle Marketing Manager",
|
|
1179
|
+
// Sales / CS
|
|
1180
|
+
"Account Executive",
|
|
1181
|
+
"Senior Account Executive",
|
|
1182
|
+
"Sales Development Representative",
|
|
1183
|
+
"Business Development Representative",
|
|
1184
|
+
"Sales Manager",
|
|
1185
|
+
"Director of Sales",
|
|
1186
|
+
"VP Sales",
|
|
1187
|
+
"Customer Success Manager",
|
|
1188
|
+
"Senior Customer Success Manager",
|
|
1189
|
+
"Director of Customer Success",
|
|
1190
|
+
"Solutions Engineer",
|
|
1191
|
+
"Sales Engineer",
|
|
1192
|
+
// Operations / Finance
|
|
1193
|
+
"Operations Manager",
|
|
1194
|
+
"Director of Operations",
|
|
1195
|
+
"COO",
|
|
1196
|
+
"Chief of Staff",
|
|
1197
|
+
"Program Manager",
|
|
1198
|
+
"Project Manager",
|
|
1199
|
+
"Business Operations Manager",
|
|
1200
|
+
"Strategy & Operations",
|
|
1201
|
+
"Financial Analyst",
|
|
1202
|
+
"Senior Financial Analyst",
|
|
1203
|
+
"FP&A Manager",
|
|
1204
|
+
"Controller",
|
|
1205
|
+
"CFO",
|
|
1206
|
+
"Accountant",
|
|
1207
|
+
"Treasury Analyst",
|
|
1208
|
+
"Procurement Manager",
|
|
1209
|
+
"Supply Chain Manager",
|
|
1210
|
+
// Generic
|
|
1211
|
+
"Intern",
|
|
1212
|
+
"Working Student",
|
|
1213
|
+
"Founder",
|
|
1214
|
+
"Co-founder",
|
|
1215
|
+
"CEO",
|
|
1216
|
+
"President",
|
|
1217
|
+
"Advisor",
|
|
1218
|
+
"Consultant"
|
|
1219
|
+
];
|
|
1220
|
+
function searchCompanies(q, limit = 8) {
|
|
1221
|
+
const s = q.toLowerCase();
|
|
1222
|
+
if (!s) return [];
|
|
1223
|
+
return COMPANIES.filter((c) => c.toLowerCase().includes(s)).slice(0, limit);
|
|
1224
|
+
}
|
|
1225
|
+
function searchTitles(q, limit = 8) {
|
|
1226
|
+
const s = q.toLowerCase();
|
|
1227
|
+
if (!s) return [];
|
|
1228
|
+
return JOB_TITLES.filter((t) => t.toLowerCase().includes(s)).slice(0, limit);
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// ../../packages/scoring/src/tailor.ts
|
|
1232
|
+
var STOPWORDS = /* @__PURE__ */ new Set([
|
|
1233
|
+
"a",
|
|
1234
|
+
"an",
|
|
1235
|
+
"and",
|
|
1236
|
+
"or",
|
|
1237
|
+
"the",
|
|
1238
|
+
"of",
|
|
1239
|
+
"for",
|
|
1240
|
+
"to",
|
|
1241
|
+
"in",
|
|
1242
|
+
"on",
|
|
1243
|
+
"at",
|
|
1244
|
+
"with",
|
|
1245
|
+
"by",
|
|
1246
|
+
"from",
|
|
1247
|
+
"is",
|
|
1248
|
+
"are",
|
|
1249
|
+
"be",
|
|
1250
|
+
"was",
|
|
1251
|
+
"were",
|
|
1252
|
+
"as",
|
|
1253
|
+
"it",
|
|
1254
|
+
"this",
|
|
1255
|
+
"that",
|
|
1256
|
+
"these",
|
|
1257
|
+
"those",
|
|
1258
|
+
"you",
|
|
1259
|
+
"your",
|
|
1260
|
+
"our",
|
|
1261
|
+
"we",
|
|
1262
|
+
"they",
|
|
1263
|
+
"their",
|
|
1264
|
+
"i",
|
|
1265
|
+
"my",
|
|
1266
|
+
"me",
|
|
1267
|
+
"not",
|
|
1268
|
+
"but",
|
|
1269
|
+
"if",
|
|
1270
|
+
"then",
|
|
1271
|
+
"do",
|
|
1272
|
+
"does",
|
|
1273
|
+
"did",
|
|
1274
|
+
"have",
|
|
1275
|
+
"has",
|
|
1276
|
+
"had",
|
|
1277
|
+
"will",
|
|
1278
|
+
"would",
|
|
1279
|
+
"can",
|
|
1280
|
+
"could",
|
|
1281
|
+
"should",
|
|
1282
|
+
"may",
|
|
1283
|
+
"might",
|
|
1284
|
+
"also",
|
|
1285
|
+
"than",
|
|
1286
|
+
"into",
|
|
1287
|
+
"over",
|
|
1288
|
+
"under",
|
|
1289
|
+
"between",
|
|
1290
|
+
"across",
|
|
1291
|
+
"per",
|
|
1292
|
+
"via",
|
|
1293
|
+
"using",
|
|
1294
|
+
"use",
|
|
1295
|
+
"including",
|
|
1296
|
+
"etc",
|
|
1297
|
+
"ability",
|
|
1298
|
+
"experience",
|
|
1299
|
+
"experienced",
|
|
1300
|
+
"skills",
|
|
1301
|
+
"skill",
|
|
1302
|
+
"strong",
|
|
1303
|
+
"excellent",
|
|
1304
|
+
"good",
|
|
1305
|
+
"ideal",
|
|
1306
|
+
"candidate",
|
|
1307
|
+
"role",
|
|
1308
|
+
"responsibilities",
|
|
1309
|
+
"duties",
|
|
1310
|
+
"requirements",
|
|
1311
|
+
"required",
|
|
1312
|
+
"preferred",
|
|
1313
|
+
"plus",
|
|
1314
|
+
"bonus",
|
|
1315
|
+
"minimum",
|
|
1316
|
+
"year",
|
|
1317
|
+
"years",
|
|
1318
|
+
"month",
|
|
1319
|
+
"months",
|
|
1320
|
+
"week",
|
|
1321
|
+
"weeks",
|
|
1322
|
+
"day",
|
|
1323
|
+
"days"
|
|
1324
|
+
]);
|
|
1325
|
+
function tokens(s) {
|
|
1326
|
+
return s.toLowerCase().replace(/[^a-z0-9+./# -]/g, " ").split(/\s+/).filter((w) => w && w.length > 1 && !STOPWORDS.has(w));
|
|
1327
|
+
}
|
|
1328
|
+
function bigrams(toks) {
|
|
1329
|
+
const out = [];
|
|
1330
|
+
for (let i = 0; i < toks.length - 1; i++) out.push(`${toks[i]} ${toks[i + 1]}`);
|
|
1331
|
+
return out;
|
|
1332
|
+
}
|
|
1333
|
+
function resumeText(resume) {
|
|
1334
|
+
const parts = [];
|
|
1335
|
+
parts.push(resume.personal.title);
|
|
1336
|
+
for (const sec of resume.sections) {
|
|
1337
|
+
for (const it of sec.items) {
|
|
1338
|
+
parts.push(it.body || "");
|
|
1339
|
+
parts.push(it.description || "");
|
|
1340
|
+
parts.push(it.skills || "");
|
|
1341
|
+
parts.push(it.jobTitle || it.degree || it.name || it.title || "");
|
|
1342
|
+
parts.push(it.employer || it.school || it.publisher || it.institution || "");
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
return parts.join(" ").replace(/<[^>]+>/g, " ");
|
|
1346
|
+
}
|
|
1347
|
+
function tailorToJob(resume, jdText) {
|
|
1348
|
+
const jdToks = tokens(jdText);
|
|
1349
|
+
const resumeToks = new Set(tokens(resumeText(resume)));
|
|
1350
|
+
const jdGrams = /* @__PURE__ */ new Set([...jdToks, ...bigrams(jdToks)]);
|
|
1351
|
+
const skillNames = new Map(SKILLS.map((s) => [s.name.toLowerCase(), s]));
|
|
1352
|
+
const freq = /* @__PURE__ */ new Map();
|
|
1353
|
+
for (const t of jdToks) freq.set(t, (freq.get(t) || 0) + 1);
|
|
1354
|
+
for (const g of bigrams(jdToks)) freq.set(g, (freq.get(g) || 0) + 1);
|
|
1355
|
+
const jdSkills = /* @__PURE__ */ new Map();
|
|
1356
|
+
for (const [lower, s] of skillNames) {
|
|
1357
|
+
if (jdGrams.has(lower)) {
|
|
1358
|
+
jdSkills.set(s.name, { kind: s.kind, importance: (freq.get(lower) || 1) + 2 });
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
const sortedNonSkill = [...freq.entries()].filter(([k]) => !jdSkills.has(k) && !skillNames.has(k)).filter(([k]) => k.length > 2).sort((a, b) => b[1] - a[1]).slice(0, 25);
|
|
1362
|
+
for (const [k, c] of sortedNonSkill) jdSkills.set(k, { importance: c });
|
|
1363
|
+
const matched = [];
|
|
1364
|
+
const missing = [];
|
|
1365
|
+
for (const [k, meta] of jdSkills) {
|
|
1366
|
+
const lower = k.toLowerCase();
|
|
1367
|
+
if (resumeToks.has(lower) || [...resumeToks].some((t) => t.includes(lower))) {
|
|
1368
|
+
matched.push({ keyword: k, kind: meta.kind, count: meta.importance });
|
|
1369
|
+
} else {
|
|
1370
|
+
missing.push({ keyword: k, kind: meta.kind, importance: meta.importance });
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
const matchedWeight = matched.reduce((a, m) => a + m.count, 0);
|
|
1374
|
+
const totalWeight = matchedWeight + missing.reduce((a, m) => a + m.importance, 0);
|
|
1375
|
+
const score = totalWeight === 0 ? 0 : Math.round(matchedWeight / totalWeight * 100);
|
|
1376
|
+
missing.sort((a, b) => b.importance - a.importance);
|
|
1377
|
+
matched.sort((a, b) => b.count - a.count);
|
|
1378
|
+
return {
|
|
1379
|
+
score,
|
|
1380
|
+
matched,
|
|
1381
|
+
missing,
|
|
1382
|
+
jdSkills: jdSkills.size,
|
|
1383
|
+
resumeSkills: resumeToks.size
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
// ../../packages/scoring/src/v2-additions.ts
|
|
1388
|
+
function stripHtml(s) {
|
|
1389
|
+
return (s || "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
|
1390
|
+
}
|
|
1391
|
+
function passSoft(id, label, score, message, suggestion) {
|
|
1392
|
+
const s = Math.max(0, Math.min(1, score));
|
|
1393
|
+
return {
|
|
1394
|
+
id,
|
|
1395
|
+
label,
|
|
1396
|
+
score: s,
|
|
1397
|
+
status: s >= 0.85 ? "pass" : s >= 0.55 ? "warn" : "fail",
|
|
1398
|
+
message,
|
|
1399
|
+
suggestion: suggestion ?? null,
|
|
1400
|
+
severity: "soft"
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1403
|
+
function hardCheck(id, label, score, message, suggestion) {
|
|
1404
|
+
return { ...passSoft(id, label, score, message, suggestion), severity: "hard" };
|
|
1405
|
+
}
|
|
1406
|
+
function firstBulletStrongest(resume) {
|
|
1407
|
+
const exp = resume.sections.find((s) => s.type === "experience");
|
|
1408
|
+
if (!exp?.items?.length) return null;
|
|
1409
|
+
const first = exp.items[0];
|
|
1410
|
+
const bullets = (first.description?.match(/<li[^>]*>([\s\S]*?)<\/li>/gi) || []).map((m) => stripHtml(m));
|
|
1411
|
+
if (bullets.length < 2) return null;
|
|
1412
|
+
const QUANT = /(\$\s?\d|\d+\s?%|\d+\s?x|\d+[KMB]\b|\d+\s+(users|customers|engineers|hours|days|weeks|months))/i;
|
|
1413
|
+
const top = bullets[0];
|
|
1414
|
+
const others = bullets.slice(1);
|
|
1415
|
+
const topQuantified = QUANT.test(top);
|
|
1416
|
+
const topLength = top.split(/\s+/).length;
|
|
1417
|
+
const avgOtherLength = others.reduce((a, b) => a + b.split(/\s+/).length, 0) / others.length;
|
|
1418
|
+
let score = 0.4;
|
|
1419
|
+
if (topQuantified) score += 0.35;
|
|
1420
|
+
if (topLength >= avgOtherLength) score += 0.25;
|
|
1421
|
+
return {
|
|
1422
|
+
dim: "impact",
|
|
1423
|
+
check: passSoft(
|
|
1424
|
+
"A12.lead-bullet-strongest",
|
|
1425
|
+
"Lead bullet of recent role is strongest",
|
|
1426
|
+
score,
|
|
1427
|
+
topQuantified && topLength >= avgOtherLength ? "Top bullet of your most-recent role is quantified and substantive \u2014 recruiter eye lands here first." : "Your strongest bullet should be FIRST in your most-recent role (recruiters skim top-down).",
|
|
1428
|
+
"Reorder bullets in your most-recent experience so the most quantified / highest-impact bullet is first."
|
|
1429
|
+
)
|
|
1430
|
+
};
|
|
1431
|
+
}
|
|
1432
|
+
function dateFormatConsistency(resume) {
|
|
1433
|
+
const exp = resume.sections.find((s) => s.type === "experience");
|
|
1434
|
+
const edu = resume.sections.find((s) => s.type === "education");
|
|
1435
|
+
const dates = [];
|
|
1436
|
+
[...exp?.items || [], ...edu?.items || []].forEach((it) => {
|
|
1437
|
+
if (it.startDate) dates.push(it.startDate);
|
|
1438
|
+
if (it.endDate && !it.current) dates.push(it.endDate);
|
|
1439
|
+
});
|
|
1440
|
+
if (dates.length < 2) return null;
|
|
1441
|
+
const MM_YYYY = /^\d{1,2}\/\d{4}$/;
|
|
1442
|
+
const MMM_YYYY = /^[A-Za-z]+\s+\d{4}$/;
|
|
1443
|
+
const YYYY = /^\d{4}$/;
|
|
1444
|
+
const ABBREV = /['']\d{2}|\d{1,2}\/\d{2}|\b[A-Z][a-z]{2}'\d{2}\b/;
|
|
1445
|
+
const formats = /* @__PURE__ */ new Set();
|
|
1446
|
+
for (const d of dates) {
|
|
1447
|
+
if (ABBREV.test(d)) formats.add("abbrev");
|
|
1448
|
+
else if (MM_YYYY.test(d)) formats.add("mm/yyyy");
|
|
1449
|
+
else if (MMM_YYYY.test(d)) formats.add("month-yyyy");
|
|
1450
|
+
else if (YYYY.test(d)) formats.add("yyyy");
|
|
1451
|
+
else formats.add("other");
|
|
1452
|
+
}
|
|
1453
|
+
const hasAbbrev = formats.has("abbrev");
|
|
1454
|
+
const inconsistent = formats.size > 1;
|
|
1455
|
+
const score = hasAbbrev ? 0.2 : inconsistent ? 0.55 : 1;
|
|
1456
|
+
return {
|
|
1457
|
+
dim: "style",
|
|
1458
|
+
check: passSoft(
|
|
1459
|
+
"C9.date-format",
|
|
1460
|
+
"Date format is consistent",
|
|
1461
|
+
score,
|
|
1462
|
+
hasAbbrev ? "Date abbreviations like '21 or Jan'24 break ATS years-of-experience calculation." : inconsistent ? `You're mixing date formats (${[...formats].join(", ")}). Pick one and apply consistently.` : "Dates are formatted consistently \u2014 ATS parses them cleanly.",
|
|
1463
|
+
"Use MM/YYYY (06/2024) or Month YYYY (June 2024) \u2014 consistently across every entry."
|
|
1464
|
+
)
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
function targetTitle(resume) {
|
|
1468
|
+
const title = resume.personal?.title?.trim() || "";
|
|
1469
|
+
const score = title.length >= 3 ? 1 : 0;
|
|
1470
|
+
return {
|
|
1471
|
+
dim: "structure",
|
|
1472
|
+
check: passSoft(
|
|
1473
|
+
"AT11.target-title",
|
|
1474
|
+
"Target job title under your name",
|
|
1475
|
+
score,
|
|
1476
|
+
title ? `Target title "${title}" set \u2014 recruiters and LLM screeners see role fit at a glance.` : "No target title under your name. The single biggest cheap win: add it.",
|
|
1477
|
+
"Add a target title (e.g. 'Senior Software Engineer') under your name in Personal Info. Recruiters scan for role match in the first 2 seconds."
|
|
1478
|
+
)
|
|
1479
|
+
};
|
|
1480
|
+
}
|
|
1481
|
+
function dobAgePenalty(resume) {
|
|
1482
|
+
const haystack = JSON.stringify(resume).toLowerCase();
|
|
1483
|
+
const DOB_RE = /(date of birth|d\.o\.b|dob[\s:]|born on|age\s*:|years old|\b19\d{2}\b\s*\(age)/i;
|
|
1484
|
+
const has = DOB_RE.test(haystack);
|
|
1485
|
+
return {
|
|
1486
|
+
dim: "polish",
|
|
1487
|
+
check: passSoft(
|
|
1488
|
+
"P4.dob-age",
|
|
1489
|
+
"No DOB / age on resume",
|
|
1490
|
+
has ? 0 : 1,
|
|
1491
|
+
has ? "Date of birth or age detected \u2014 US/UK recruiters cite this as a top instant-rejection trigger." : "No DOB / age on resume \u2014 protects against age-discrimination filters.",
|
|
1492
|
+
"Remove date of birth, age, and full address. Required in some non-US markets; default is to omit."
|
|
1493
|
+
)
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1496
|
+
function singleColumnHard(resume) {
|
|
1497
|
+
const layout = resume.styling?.layout;
|
|
1498
|
+
const twoCol = resume.sections.some((s) => s.columns === 2) || layout === "two-column" || layout === "sidebar-left" || layout === "sidebar-right";
|
|
1499
|
+
return {
|
|
1500
|
+
dim: "ats",
|
|
1501
|
+
check: hardCheck(
|
|
1502
|
+
"AT12.single-column-hard",
|
|
1503
|
+
"Single-column layout (ATS-critical)",
|
|
1504
|
+
twoCol ? 0 : 1,
|
|
1505
|
+
twoCol ? "Two-column / sidebar layout detected \u2014 #1 cause of ATS parse failure. Workday concatenates columns into gibberish." : "Single-column layout \u2014 every ATS parses this cleanly.",
|
|
1506
|
+
"Switch to a single-column template (Jake, Harvard, Classic). Multi-column is the top score-destroyer in ATS systems."
|
|
1507
|
+
)
|
|
1508
|
+
};
|
|
1509
|
+
}
|
|
1510
|
+
function threeLocationsRule(resume) {
|
|
1511
|
+
const summary = resume.sections.find((s) => s.type === "summary");
|
|
1512
|
+
const skills = resume.sections.find((s) => s.type === "skills");
|
|
1513
|
+
const exp = resume.sections.find((s) => s.type === "experience");
|
|
1514
|
+
if (!summary || !skills || !exp) return null;
|
|
1515
|
+
const summaryText = stripHtml(summary.items?.[0]?.body || "");
|
|
1516
|
+
const expText = (exp.items || []).map((it) => stripHtml(it.description || "")).join(" ").toLowerCase();
|
|
1517
|
+
const skillsText = (skills.items || []).flatMap((it) => (it.skills || "").split(/[,;]/)).map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
1518
|
+
if (skillsText.length === 0) return null;
|
|
1519
|
+
const top5 = skillsText.slice(0, 5);
|
|
1520
|
+
let inAll3 = 0;
|
|
1521
|
+
for (const sk of top5) {
|
|
1522
|
+
const inSummary = summaryText.toLowerCase().includes(sk);
|
|
1523
|
+
const inExp = expText.includes(sk);
|
|
1524
|
+
if (inSummary && inExp) inAll3++;
|
|
1525
|
+
}
|
|
1526
|
+
const score = inAll3 / top5.length;
|
|
1527
|
+
return {
|
|
1528
|
+
dim: "skills",
|
|
1529
|
+
check: passSoft(
|
|
1530
|
+
"S5.three-locations-rule",
|
|
1531
|
+
"Top skills appear in summary + experience + skills",
|
|
1532
|
+
score,
|
|
1533
|
+
score >= 0.8 ? "Your top skills appear across all three sections \u2014 strong ATS keyword signal." : `${inAll3}/${top5.length} of your top skills are reinforced in both summary and experience sections.`,
|
|
1534
|
+
"Top 5 hard skills should appear in your summary, in skills section, AND inside at least one experience bullet. ATS scoring weighs density across sections."
|
|
1535
|
+
)
|
|
1536
|
+
};
|
|
1537
|
+
}
|
|
1538
|
+
function bulletLengthSweetSpot(resume) {
|
|
1539
|
+
const exp = resume.sections.find((s) => s.type === "experience");
|
|
1540
|
+
if (!exp?.items?.length) return null;
|
|
1541
|
+
const bullets = [];
|
|
1542
|
+
for (const it of exp.items) {
|
|
1543
|
+
const html = it.description || "";
|
|
1544
|
+
const lis = html.match(/<li[^>]*>([\s\S]*?)<\/li>/gi) || [];
|
|
1545
|
+
for (const li of lis) bullets.push(stripHtml(li));
|
|
1546
|
+
}
|
|
1547
|
+
if (bullets.length === 0) return null;
|
|
1548
|
+
let inRange = 0;
|
|
1549
|
+
for (const b of bullets) {
|
|
1550
|
+
const w = b.split(/\s+/).filter(Boolean).length;
|
|
1551
|
+
if (w >= 12 && w <= 25) inRange++;
|
|
1552
|
+
}
|
|
1553
|
+
const ratio = inRange / bullets.length;
|
|
1554
|
+
return {
|
|
1555
|
+
dim: "brevity",
|
|
1556
|
+
check: passSoft(
|
|
1557
|
+
"B5.sweet-spot",
|
|
1558
|
+
"Bullets in 12\u201325 word range",
|
|
1559
|
+
ratio,
|
|
1560
|
+
`${Math.round(ratio * 100)}% of your bullets fall in the 12\u201325 word sweet spot.`,
|
|
1561
|
+
"Aim for bullets between 12 and 25 words \u2014 short enough for the 6-second recruiter scan, long enough to convey impact + metric."
|
|
1562
|
+
)
|
|
1563
|
+
};
|
|
1564
|
+
}
|
|
1565
|
+
function additionalChecks(resume) {
|
|
1566
|
+
return [
|
|
1567
|
+
firstBulletStrongest(resume),
|
|
1568
|
+
dateFormatConsistency(resume),
|
|
1569
|
+
targetTitle(resume),
|
|
1570
|
+
dobAgePenalty(resume),
|
|
1571
|
+
singleColumnHard(resume),
|
|
1572
|
+
threeLocationsRule(resume),
|
|
1573
|
+
bulletLengthSweetSpot(resume)
|
|
1574
|
+
].filter((x) => x !== null);
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
// ../../packages/scoring/src/wordlists.ts
|
|
1578
|
+
var STRONG_VERBS = /* @__PURE__ */ new Set([
|
|
1579
|
+
// Leadership
|
|
1580
|
+
"led",
|
|
1581
|
+
"directed",
|
|
1582
|
+
"managed",
|
|
1583
|
+
"oversaw",
|
|
1584
|
+
"supervised",
|
|
1585
|
+
"headed",
|
|
1586
|
+
"spearheaded",
|
|
1587
|
+
"orchestrated",
|
|
1588
|
+
"coordinated",
|
|
1589
|
+
"drove",
|
|
1590
|
+
"commanded",
|
|
1591
|
+
"chaired",
|
|
1592
|
+
"governed",
|
|
1593
|
+
"steered",
|
|
1594
|
+
"championed",
|
|
1595
|
+
"mobilized",
|
|
1596
|
+
"united",
|
|
1597
|
+
// Build / Ship
|
|
1598
|
+
"built",
|
|
1599
|
+
"designed",
|
|
1600
|
+
"architected",
|
|
1601
|
+
"engineered",
|
|
1602
|
+
"developed",
|
|
1603
|
+
"created",
|
|
1604
|
+
"crafted",
|
|
1605
|
+
"constructed",
|
|
1606
|
+
"prototyped",
|
|
1607
|
+
"launched",
|
|
1608
|
+
"shipped",
|
|
1609
|
+
"deployed",
|
|
1610
|
+
"released",
|
|
1611
|
+
"rolled",
|
|
1612
|
+
"instituted",
|
|
1613
|
+
"implemented",
|
|
1614
|
+
"executed",
|
|
1615
|
+
"produced",
|
|
1616
|
+
"delivered",
|
|
1617
|
+
"rebuilt",
|
|
1618
|
+
"redesigned",
|
|
1619
|
+
"rewrote",
|
|
1620
|
+
// Ownership
|
|
1621
|
+
"owned",
|
|
1622
|
+
"drove",
|
|
1623
|
+
"operated",
|
|
1624
|
+
"ran",
|
|
1625
|
+
"piloted",
|
|
1626
|
+
"steered",
|
|
1627
|
+
"championed",
|
|
1628
|
+
"commanded",
|
|
1629
|
+
// Improve / Optimize
|
|
1630
|
+
"improved",
|
|
1631
|
+
"optimized",
|
|
1632
|
+
"reduced",
|
|
1633
|
+
"increased",
|
|
1634
|
+
"grew",
|
|
1635
|
+
"boosted",
|
|
1636
|
+
"accelerated",
|
|
1637
|
+
"streamlined",
|
|
1638
|
+
"modernized",
|
|
1639
|
+
"refactored",
|
|
1640
|
+
"redesigned",
|
|
1641
|
+
"upgraded",
|
|
1642
|
+
"transformed",
|
|
1643
|
+
"revamped",
|
|
1644
|
+
"restructured",
|
|
1645
|
+
"consolidated",
|
|
1646
|
+
"simplified",
|
|
1647
|
+
"automated",
|
|
1648
|
+
"standardized",
|
|
1649
|
+
"unified",
|
|
1650
|
+
// Achievement
|
|
1651
|
+
"achieved",
|
|
1652
|
+
"exceeded",
|
|
1653
|
+
"surpassed",
|
|
1654
|
+
"won",
|
|
1655
|
+
"earned",
|
|
1656
|
+
"secured",
|
|
1657
|
+
"captured",
|
|
1658
|
+
"gained",
|
|
1659
|
+
"attained",
|
|
1660
|
+
"outperformed",
|
|
1661
|
+
"quadrupled",
|
|
1662
|
+
"tripled",
|
|
1663
|
+
"doubled",
|
|
1664
|
+
"generated",
|
|
1665
|
+
"produced",
|
|
1666
|
+
"delivered",
|
|
1667
|
+
// Analytical
|
|
1668
|
+
"analyzed",
|
|
1669
|
+
"assessed",
|
|
1670
|
+
"audited",
|
|
1671
|
+
"benchmarked",
|
|
1672
|
+
"calculated",
|
|
1673
|
+
"computed",
|
|
1674
|
+
"correlated",
|
|
1675
|
+
"decoded",
|
|
1676
|
+
"detected",
|
|
1677
|
+
"determined",
|
|
1678
|
+
"diagnosed",
|
|
1679
|
+
"discovered",
|
|
1680
|
+
"evaluated",
|
|
1681
|
+
"examined",
|
|
1682
|
+
"experimented",
|
|
1683
|
+
"forecasted",
|
|
1684
|
+
"investigated",
|
|
1685
|
+
"measured",
|
|
1686
|
+
"modeled",
|
|
1687
|
+
"monitored",
|
|
1688
|
+
"quantified",
|
|
1689
|
+
"researched",
|
|
1690
|
+
"tracked",
|
|
1691
|
+
"verified",
|
|
1692
|
+
// Communication
|
|
1693
|
+
"addressed",
|
|
1694
|
+
"advised",
|
|
1695
|
+
"advocated",
|
|
1696
|
+
"articulated",
|
|
1697
|
+
"authored",
|
|
1698
|
+
"briefed",
|
|
1699
|
+
"communicated",
|
|
1700
|
+
"conveyed",
|
|
1701
|
+
"drafted",
|
|
1702
|
+
"edited",
|
|
1703
|
+
"facilitated",
|
|
1704
|
+
"informed",
|
|
1705
|
+
"interpreted",
|
|
1706
|
+
"interviewed",
|
|
1707
|
+
"lectured",
|
|
1708
|
+
"mediated",
|
|
1709
|
+
"moderated",
|
|
1710
|
+
"negotiated",
|
|
1711
|
+
"persuaded",
|
|
1712
|
+
"pitched",
|
|
1713
|
+
"presented",
|
|
1714
|
+
"promoted",
|
|
1715
|
+
"published",
|
|
1716
|
+
"spoke",
|
|
1717
|
+
"summarized",
|
|
1718
|
+
"translated",
|
|
1719
|
+
"wrote",
|
|
1720
|
+
// Entrepreneurial
|
|
1721
|
+
"bootstrapped",
|
|
1722
|
+
"co-founded",
|
|
1723
|
+
"conceived",
|
|
1724
|
+
"conceptualized",
|
|
1725
|
+
"established",
|
|
1726
|
+
"founded",
|
|
1727
|
+
"incubated",
|
|
1728
|
+
"initiated",
|
|
1729
|
+
"invented",
|
|
1730
|
+
"originated",
|
|
1731
|
+
"pioneered",
|
|
1732
|
+
"productized",
|
|
1733
|
+
"scaled",
|
|
1734
|
+
"started",
|
|
1735
|
+
"validated",
|
|
1736
|
+
// Mentorship
|
|
1737
|
+
"coached",
|
|
1738
|
+
"cultivated",
|
|
1739
|
+
"empowered",
|
|
1740
|
+
"fostered",
|
|
1741
|
+
"guided",
|
|
1742
|
+
"inspired",
|
|
1743
|
+
"mentored",
|
|
1744
|
+
"nurtured",
|
|
1745
|
+
"onboarded",
|
|
1746
|
+
"recruited",
|
|
1747
|
+
"supported",
|
|
1748
|
+
"taught",
|
|
1749
|
+
"trained",
|
|
1750
|
+
"unified",
|
|
1751
|
+
// Problem solving
|
|
1752
|
+
"combatted",
|
|
1753
|
+
"corrected",
|
|
1754
|
+
"cured",
|
|
1755
|
+
"debugged",
|
|
1756
|
+
"eliminated",
|
|
1757
|
+
"eradicated",
|
|
1758
|
+
"fixed",
|
|
1759
|
+
"mitigated",
|
|
1760
|
+
"overcame",
|
|
1761
|
+
"prevented",
|
|
1762
|
+
"reconciled",
|
|
1763
|
+
"recovered",
|
|
1764
|
+
"rectified",
|
|
1765
|
+
"remedied",
|
|
1766
|
+
"resolved",
|
|
1767
|
+
"restored",
|
|
1768
|
+
"salvaged",
|
|
1769
|
+
"solved",
|
|
1770
|
+
"stabilized",
|
|
1771
|
+
"triaged",
|
|
1772
|
+
"untangled",
|
|
1773
|
+
"unblocked",
|
|
1774
|
+
// Engineering
|
|
1775
|
+
"automated",
|
|
1776
|
+
"benchmarked",
|
|
1777
|
+
"compiled",
|
|
1778
|
+
"configured",
|
|
1779
|
+
"containerized",
|
|
1780
|
+
"decoupled",
|
|
1781
|
+
"deployed",
|
|
1782
|
+
"hardened",
|
|
1783
|
+
"instrumented",
|
|
1784
|
+
"integrated",
|
|
1785
|
+
"migrated",
|
|
1786
|
+
"programmed",
|
|
1787
|
+
"provisioned",
|
|
1788
|
+
"refactored",
|
|
1789
|
+
"tuned",
|
|
1790
|
+
"virtualized",
|
|
1791
|
+
// Sales/Finance
|
|
1792
|
+
"negotiated",
|
|
1793
|
+
"closed",
|
|
1794
|
+
"sold",
|
|
1795
|
+
"upsold",
|
|
1796
|
+
"cross-sold",
|
|
1797
|
+
"prospected",
|
|
1798
|
+
"forecasted",
|
|
1799
|
+
"budgeted",
|
|
1800
|
+
"reconciled",
|
|
1801
|
+
"liquidated",
|
|
1802
|
+
"invoiced"
|
|
1803
|
+
]);
|
|
1804
|
+
var RESPONSIBILITY_OPENERS = [
|
|
1805
|
+
"responsible for",
|
|
1806
|
+
"responsibilities included",
|
|
1807
|
+
"duties included",
|
|
1808
|
+
"duties:",
|
|
1809
|
+
"tasked with",
|
|
1810
|
+
"in charge of",
|
|
1811
|
+
"worked on",
|
|
1812
|
+
"worked with",
|
|
1813
|
+
"involved in",
|
|
1814
|
+
"participated in",
|
|
1815
|
+
"assisted with",
|
|
1816
|
+
"helped with",
|
|
1817
|
+
"helped to",
|
|
1818
|
+
"took part in"
|
|
1819
|
+
];
|
|
1820
|
+
var WEAK_VERBS = /* @__PURE__ */ new Set([
|
|
1821
|
+
"helped",
|
|
1822
|
+
"assisted",
|
|
1823
|
+
"supported",
|
|
1824
|
+
"participated",
|
|
1825
|
+
"contributed",
|
|
1826
|
+
"worked",
|
|
1827
|
+
"used",
|
|
1828
|
+
"utilized",
|
|
1829
|
+
"tried",
|
|
1830
|
+
"ran",
|
|
1831
|
+
"handled",
|
|
1832
|
+
"dealt",
|
|
1833
|
+
"interacted",
|
|
1834
|
+
"communicated",
|
|
1835
|
+
"attended",
|
|
1836
|
+
"prepared",
|
|
1837
|
+
"reviewed",
|
|
1838
|
+
"checked",
|
|
1839
|
+
"provided",
|
|
1840
|
+
"gave",
|
|
1841
|
+
"made",
|
|
1842
|
+
"did",
|
|
1843
|
+
"got",
|
|
1844
|
+
"had",
|
|
1845
|
+
"took",
|
|
1846
|
+
"kept",
|
|
1847
|
+
"showed",
|
|
1848
|
+
"saw",
|
|
1849
|
+
"brought",
|
|
1850
|
+
"put",
|
|
1851
|
+
"let",
|
|
1852
|
+
"came",
|
|
1853
|
+
"went",
|
|
1854
|
+
"seemed",
|
|
1855
|
+
"appeared",
|
|
1856
|
+
"seemed",
|
|
1857
|
+
"felt",
|
|
1858
|
+
"needed",
|
|
1859
|
+
"wanted"
|
|
1860
|
+
]);
|
|
1861
|
+
var FILLER_WORDS = /* @__PURE__ */ new Set([
|
|
1862
|
+
"various",
|
|
1863
|
+
"multiple",
|
|
1864
|
+
"several",
|
|
1865
|
+
"numerous",
|
|
1866
|
+
"many",
|
|
1867
|
+
"successfully",
|
|
1868
|
+
"effectively",
|
|
1869
|
+
"really",
|
|
1870
|
+
"very",
|
|
1871
|
+
"just",
|
|
1872
|
+
"quite",
|
|
1873
|
+
"actually",
|
|
1874
|
+
"basically",
|
|
1875
|
+
"essentially",
|
|
1876
|
+
"literally",
|
|
1877
|
+
"kind",
|
|
1878
|
+
"sort",
|
|
1879
|
+
"stuff",
|
|
1880
|
+
"things",
|
|
1881
|
+
"etc"
|
|
1882
|
+
]);
|
|
1883
|
+
var BUZZWORDS = [
|
|
1884
|
+
"team player",
|
|
1885
|
+
"go-getter",
|
|
1886
|
+
"go getter",
|
|
1887
|
+
"synergy",
|
|
1888
|
+
"results-driven",
|
|
1889
|
+
"results oriented",
|
|
1890
|
+
"results-oriented",
|
|
1891
|
+
"detail-oriented",
|
|
1892
|
+
"detail oriented",
|
|
1893
|
+
"hard-working",
|
|
1894
|
+
"hard working",
|
|
1895
|
+
"self-motivated",
|
|
1896
|
+
"self motivated",
|
|
1897
|
+
"self-starter",
|
|
1898
|
+
"self starter",
|
|
1899
|
+
"passionate",
|
|
1900
|
+
"dynamic",
|
|
1901
|
+
"think outside the box",
|
|
1902
|
+
"outside-the-box",
|
|
1903
|
+
"outside the box",
|
|
1904
|
+
"strategic thinker",
|
|
1905
|
+
"fast-paced",
|
|
1906
|
+
"fast paced",
|
|
1907
|
+
"big picture",
|
|
1908
|
+
"big-picture",
|
|
1909
|
+
"track record",
|
|
1910
|
+
"proven track record",
|
|
1911
|
+
"track-record",
|
|
1912
|
+
"go to market",
|
|
1913
|
+
"go-to-market"
|
|
1914
|
+
];
|
|
1915
|
+
var PRONOUNS = /* @__PURE__ */ new Set([
|
|
1916
|
+
"i",
|
|
1917
|
+
"i'm",
|
|
1918
|
+
"i've",
|
|
1919
|
+
"i'd",
|
|
1920
|
+
"i'll",
|
|
1921
|
+
"me",
|
|
1922
|
+
"my",
|
|
1923
|
+
"mine",
|
|
1924
|
+
"myself",
|
|
1925
|
+
"we",
|
|
1926
|
+
"we're",
|
|
1927
|
+
"we've",
|
|
1928
|
+
"us",
|
|
1929
|
+
"our",
|
|
1930
|
+
"ours",
|
|
1931
|
+
"ourselves",
|
|
1932
|
+
"they",
|
|
1933
|
+
"they're",
|
|
1934
|
+
"their",
|
|
1935
|
+
"theirs",
|
|
1936
|
+
"them"
|
|
1937
|
+
]);
|
|
1938
|
+
var SOFT_SKILLS = /* @__PURE__ */ new Set([
|
|
1939
|
+
"communication",
|
|
1940
|
+
"leadership",
|
|
1941
|
+
"teamwork",
|
|
1942
|
+
"collaboration",
|
|
1943
|
+
"problem solving",
|
|
1944
|
+
"problem-solving",
|
|
1945
|
+
"time management",
|
|
1946
|
+
"organization",
|
|
1947
|
+
"adaptability",
|
|
1948
|
+
"critical thinking",
|
|
1949
|
+
"work ethic",
|
|
1950
|
+
"interpersonal",
|
|
1951
|
+
"decision making",
|
|
1952
|
+
"decision-making",
|
|
1953
|
+
"creativity",
|
|
1954
|
+
"creative thinking",
|
|
1955
|
+
"empathy",
|
|
1956
|
+
"negotiation",
|
|
1957
|
+
"active listening",
|
|
1958
|
+
"conflict resolution",
|
|
1959
|
+
"emotional intelligence",
|
|
1960
|
+
"public speaking",
|
|
1961
|
+
"presentation",
|
|
1962
|
+
"storytelling",
|
|
1963
|
+
"ownership",
|
|
1964
|
+
"accountability",
|
|
1965
|
+
"resilience",
|
|
1966
|
+
"curiosity",
|
|
1967
|
+
"facilitation",
|
|
1968
|
+
"mentoring",
|
|
1969
|
+
"coaching",
|
|
1970
|
+
"strategic thinking",
|
|
1971
|
+
"first principles",
|
|
1972
|
+
"first-principles",
|
|
1973
|
+
"bias for action"
|
|
1974
|
+
]);
|
|
1975
|
+
var METHOD_INTROS = ["by", "through", "using", "via", "leveraging", "with", "driving", "building", "designing", "launching", "shipping", "implementing", "applying", "introducing", "leading", "running", "scaling", "migrating", "optimizing"];
|
|
1976
|
+
var ATS_SECTION_LABELS = {
|
|
1977
|
+
experience: ["experience", "work experience", "professional experience", "employment", "employment history", "career history", "work history"],
|
|
1978
|
+
education: ["education", "academic background", "educational background"],
|
|
1979
|
+
skills: ["skills", "technical skills", "core skills", "core competencies", "technologies"],
|
|
1980
|
+
projects: ["projects", "selected projects", "personal projects", "side projects", "key projects"],
|
|
1981
|
+
certificates: ["certifications", "certificates", "licenses", "credentials"],
|
|
1982
|
+
summary: ["summary", "professional summary", "profile", "about", "objective", "executive summary", "career summary"],
|
|
1983
|
+
languages: ["languages"],
|
|
1984
|
+
awards: ["awards", "honors", "honors and awards"],
|
|
1985
|
+
publications: ["publications"],
|
|
1986
|
+
interests: ["interests", "hobbies"],
|
|
1987
|
+
organisations: ["organisations", "organizations", "volunteer", "volunteering", "memberships"],
|
|
1988
|
+
references: ["references"],
|
|
1989
|
+
courses: ["courses", "coursework"]
|
|
1990
|
+
};
|
|
1991
|
+
var SAFE_BULLET_GLYPHS = /* @__PURE__ */ new Set(["\u2022", "-", "*"]);
|
|
1992
|
+
var SAFE_FONTS = /* @__PURE__ */ new Set(["Arial", "Calibri", "Helvetica", "Helvetica Neue", "Garamond", "Georgia", "Times New Roman", "Times", "Verdana", "Tahoma", "Cambria", "Roboto", "Open Sans", "Inter", "Manrope", "Lato", "Source Serif Pro", "Cormorant Garamond", "DM Serif Display", "Playfair Display"]);
|
|
1993
|
+
|
|
1994
|
+
// ../../packages/scoring/src/index.ts
|
|
1995
|
+
function stripHtml2(html) {
|
|
1996
|
+
return (html || "").replace(/<[^>]+>/g, " ").replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/\s+/g, " ").trim();
|
|
1997
|
+
}
|
|
1998
|
+
function bulletTextsFromHtml(html) {
|
|
1999
|
+
if (!html) return [];
|
|
2000
|
+
const lis = Array.from(html.matchAll(/<li[^>]*>([\s\S]*?)<\/li>/gi)).map((m) => stripHtml2(m[1]));
|
|
2001
|
+
if (lis.length) return lis.filter(Boolean);
|
|
2002
|
+
const t = stripHtml2(html);
|
|
2003
|
+
return t ? [t] : [];
|
|
2004
|
+
}
|
|
2005
|
+
function gather(resume) {
|
|
2006
|
+
const bullets = [];
|
|
2007
|
+
let summaryText = "";
|
|
2008
|
+
for (const sec of resume.sections) {
|
|
2009
|
+
if (sec.visible === false) continue;
|
|
2010
|
+
const items = sec.items;
|
|
2011
|
+
items.forEach((it, ei) => {
|
|
2012
|
+
if (it.visible === false) return;
|
|
2013
|
+
const bodies = [];
|
|
2014
|
+
if (typeof it.description === "string") bodies.push(it.description);
|
|
2015
|
+
if (typeof it.body === "string") bodies.push(it.body);
|
|
2016
|
+
bodies.forEach((b) => {
|
|
2017
|
+
const lines = bulletTextsFromHtml(b);
|
|
2018
|
+
if (sec.type === "summary") summaryText += " " + stripHtml2(b);
|
|
2019
|
+
lines.forEach((text, bi) => {
|
|
2020
|
+
bullets.push({
|
|
2021
|
+
id: `${sec.id}:${it.id}:${bi}`,
|
|
2022
|
+
text,
|
|
2023
|
+
sectionId: sec.id,
|
|
2024
|
+
sectionType: sec.type,
|
|
2025
|
+
itemId: it.id,
|
|
2026
|
+
entryIndex: ei,
|
|
2027
|
+
bulletIndex: bi
|
|
2028
|
+
});
|
|
2029
|
+
});
|
|
2030
|
+
});
|
|
2031
|
+
});
|
|
2032
|
+
}
|
|
2033
|
+
return { bullets, expBullets: bullets.filter((b) => b.sectionType === "experience"), summaryText: summaryText.trim() };
|
|
2034
|
+
}
|
|
2035
|
+
function wordCount(s) {
|
|
2036
|
+
return s.split(/\s+/).filter(Boolean).length;
|
|
2037
|
+
}
|
|
2038
|
+
function firstWord(s) {
|
|
2039
|
+
return s.trim().split(/\s+/)[0] || "";
|
|
2040
|
+
}
|
|
2041
|
+
function norm(s) {
|
|
2042
|
+
return s.toLowerCase().replace(/[^a-z'-]/g, "");
|
|
2043
|
+
}
|
|
2044
|
+
function tokens2(s) {
|
|
2045
|
+
return s.toLowerCase().split(/[\s.,!?;:()\[\]"'`/\\]+/).filter(Boolean);
|
|
2046
|
+
}
|
|
2047
|
+
function statusFromScore(s, passAt = 0.85, warnAt = 0.55) {
|
|
2048
|
+
if (s >= passAt) return "pass";
|
|
2049
|
+
if (s >= warnAt) return "warn";
|
|
2050
|
+
return "fail";
|
|
2051
|
+
}
|
|
2052
|
+
function band(score) {
|
|
2053
|
+
if (score >= 85) return "excellent";
|
|
2054
|
+
if (score >= 65) return "good";
|
|
2055
|
+
if (score >= 45) return "average";
|
|
2056
|
+
return "weak";
|
|
2057
|
+
}
|
|
2058
|
+
var QUANT_RE = /(\$\s?\d|\d+\s?(?:%|x|×|k|m|b|bn|mn|hrs?|hours?|mins?|min|sec|days?|weeks?|months?|years?|yrs?|pts?|bps|users|customers|engineers|teams?|reports?|clients?|accounts?|deals?|leads?|tickets?|stores?|countries|markets?)|\d[\d,.]*\s*(?:million|billion|thousand|trillion|crore|lakh))/i;
|
|
2059
|
+
var RATIO_RE = /\b\d+\s*[:/]\s*\d+\b|\btop\s*\d+%?\b|#\s?\d+\s+of\s+\d+/i;
|
|
2060
|
+
var PERCENT_RE = /\d+\s?%/;
|
|
2061
|
+
var MONEY_RE = /[\$₹€£¥]\s?\d|\bUSD|\bINR|\bEUR/i;
|
|
2062
|
+
var TIME_RE = /\b(\d+\s?(?:hours?|hrs?|min|mins?|seconds?|sec|days?|weeks?|months?|years?|yrs?|qtrs?|quarters?))\b/i;
|
|
2063
|
+
var COUNT_RE = /\b\d+\+?\s+(?:users|customers|members|engineers|teams|clients|requests|reports|tickets|countries|stores|deals|accounts|signups|partners|brands|leads|features|prs|prs\/quarter|projects)\b/i;
|
|
2064
|
+
function impactChecks(resume, b) {
|
|
2065
|
+
const E = b.expBullets;
|
|
2066
|
+
const total = Math.max(1, E.length);
|
|
2067
|
+
const quantified = E.filter((x) => QUANT_RE.test(x.text));
|
|
2068
|
+
const nonQ = E.filter((x) => !QUANT_RE.test(x.text));
|
|
2069
|
+
const qRatio = quantified.length / total;
|
|
2070
|
+
const metricTypes = /* @__PURE__ */ new Set();
|
|
2071
|
+
for (const x of E) {
|
|
2072
|
+
if (PERCENT_RE.test(x.text)) metricTypes.add("%");
|
|
2073
|
+
if (MONEY_RE.test(x.text)) metricTypes.add("$");
|
|
2074
|
+
if (TIME_RE.test(x.text)) metricTypes.add("time");
|
|
2075
|
+
if (COUNT_RE.test(x.text)) metricTypes.add("count");
|
|
2076
|
+
if (RATIO_RE.test(x.text)) metricTypes.add("ratio");
|
|
2077
|
+
}
|
|
2078
|
+
const xyz = E.filter((x) => {
|
|
2079
|
+
const fw = norm(firstWord(x.text));
|
|
2080
|
+
const hasVerb = STRONG_VERBS.has(fw);
|
|
2081
|
+
const hasQuant = QUANT_RE.test(x.text);
|
|
2082
|
+
const lower = " " + x.text.toLowerCase() + " ";
|
|
2083
|
+
const hasMethod = METHOD_INTROS.some((m) => lower.includes(` ${m} `));
|
|
2084
|
+
return hasVerb && hasQuant && hasMethod;
|
|
2085
|
+
});
|
|
2086
|
+
const responsibilityFlags = E.filter((x) => RESPONSIBILITY_OPENERS.some((p) => x.text.toLowerCase().startsWith(p)));
|
|
2087
|
+
const strongStart = E.filter((x) => STRONG_VERBS.has(norm(firstWord(x.text))));
|
|
2088
|
+
const weakStart = E.filter((x) => WEAK_VERBS.has(norm(firstWord(x.text))));
|
|
2089
|
+
const openingVerbCount = /* @__PURE__ */ new Map();
|
|
2090
|
+
for (const x of E) {
|
|
2091
|
+
const fw = norm(firstWord(x.text));
|
|
2092
|
+
if (!fw) continue;
|
|
2093
|
+
if (!openingVerbCount.has(fw)) openingVerbCount.set(fw, []);
|
|
2094
|
+
openingVerbCount.get(fw).push(x.id);
|
|
2095
|
+
}
|
|
2096
|
+
const overusedVerbs = [...openingVerbCount.entries()].filter(([_, ids]) => ids.length > 2);
|
|
2097
|
+
const SCALE_RE = /\b(team of \d+|\d+\+?\s+(engineers|reports|members)|\$\d|\d+\s?(?:M|B|MM|BN)|managed \d+|led \d+)\b/i;
|
|
2098
|
+
const scaleSignals = E.filter((x) => SCALE_RE.test(x.text));
|
|
2099
|
+
const timeBoundRe = /\b(within|in|over)\s+\d+\s*(weeks?|months?|quarters?|years?|days?)\b|\bq[1-4]\s?'?\d{2,4}\b/i;
|
|
2100
|
+
const timeBound = E.filter((x) => timeBoundRe.test(x.text));
|
|
2101
|
+
return [
|
|
2102
|
+
mk(
|
|
2103
|
+
"A1.quantification",
|
|
2104
|
+
"Bullets with measurable outcomes",
|
|
2105
|
+
qRatio >= 0.6 ? 1 : qRatio >= 0.4 ? 0.7 : qRatio / 0.4,
|
|
2106
|
+
`${quantified.length} of ${total} experience bullets include numbers, %, $, or counts (${Math.round(qRatio * 100)}%).`,
|
|
2107
|
+
nonQ.slice(0, 6).map((x) => x.id),
|
|
2108
|
+
qRatio < 0.6 ? "Add concrete metrics: % change, $ amount, users, time saved." : void 0
|
|
2109
|
+
),
|
|
2110
|
+
mk(
|
|
2111
|
+
"A2.metric-diversity",
|
|
2112
|
+
"Variety of metric types",
|
|
2113
|
+
Math.min(1, metricTypes.size / 3),
|
|
2114
|
+
metricTypes.size === 0 ? "No metric types detected (%, $, time, counts, ratios)." : `Uses ${metricTypes.size} metric type(s): ${[...metricTypes].join(", ")}.`,
|
|
2115
|
+
[],
|
|
2116
|
+
metricTypes.size < 3 ? "Mix metric flavors: a % gain, a $ saving, and a scale number tells a richer story than three %s." : void 0
|
|
2117
|
+
),
|
|
2118
|
+
mk(
|
|
2119
|
+
"A3.xyz-bullets",
|
|
2120
|
+
"Bullets that follow the X-Y-Z format",
|
|
2121
|
+
Math.min(1, xyz.length / Math.max(1, total * 0.4)),
|
|
2122
|
+
`${xyz.length} of ${total} bullets read as "Accomplished X (measured by Y) by doing Z."`,
|
|
2123
|
+
[],
|
|
2124
|
+
xyz.length / total < 0.4 ? "Reframe bullets as Action + Measurable Outcome + Method ('Cut LLM cost 35% by redesigning the prompt+cache layer')." : void 0
|
|
2125
|
+
),
|
|
2126
|
+
mk(
|
|
2127
|
+
"A4.responsibility",
|
|
2128
|
+
"Avoids responsibility phrasing",
|
|
2129
|
+
responsibilityFlags.length === 0 ? 1 : 1 - Math.min(1, responsibilityFlags.length / 2),
|
|
2130
|
+
responsibilityFlags.length === 0 ? "No bullets start with 'Responsible for' / 'Duties' / 'Worked on'." : `${responsibilityFlags.length} bullets read as duties, not achievements.`,
|
|
2131
|
+
responsibilityFlags.slice(0, 5).map((x) => x.id),
|
|
2132
|
+
responsibilityFlags.length ? "Rewrite duty-phrasing as outcome-phrasing: 'Responsible for onboarding' \u2192 'Onboarded 32 engineers in 6 months, cutting time-to-first-PR by 40%.'" : void 0
|
|
2133
|
+
),
|
|
2134
|
+
mk(
|
|
2135
|
+
"A5.strong-verbs",
|
|
2136
|
+
"Bullets open with a strong action verb",
|
|
2137
|
+
strongStart.length / total,
|
|
2138
|
+
`${strongStart.length} of ${total} bullets open with a strong verb (Led, Built, Drove, \u2026).`,
|
|
2139
|
+
E.filter((x) => !STRONG_VERBS.has(norm(firstWord(x.text)))).slice(0, 5).map((x) => x.id),
|
|
2140
|
+
strongStart.length / total < 0.85 ? "Replace soft openers with strong past-tense verbs." : void 0
|
|
2141
|
+
),
|
|
2142
|
+
mk(
|
|
2143
|
+
"A6.weak-verbs",
|
|
2144
|
+
"Avoids weak verbs (helped, assisted, \u2026)",
|
|
2145
|
+
weakStart.length === 0 ? 1 : 1 - Math.min(1, weakStart.length / 3),
|
|
2146
|
+
weakStart.length === 0 ? "No bullets open with weak verbs." : `${weakStart.length} bullets open with weak verbs (helped, assisted, supported, worked, used).`,
|
|
2147
|
+
weakStart.slice(0, 5).map((x) => x.id),
|
|
2148
|
+
weakStart.length ? "Swap 'Helped X' for 'Led X' or 'Drove X' if you owned the result." : void 0
|
|
2149
|
+
),
|
|
2150
|
+
mk(
|
|
2151
|
+
"A7.verb-variety",
|
|
2152
|
+
"Variety of opening verbs",
|
|
2153
|
+
overusedVerbs.length === 0 ? 1 : 1 - Math.min(1, overusedVerbs.length / 3),
|
|
2154
|
+
overusedVerbs.length === 0 ? "Opening verbs are varied." : `Verb(s) overused: ${overusedVerbs.map(([v, ids]) => `${v} (\xD7${ids.length})`).join(", ")}.`,
|
|
2155
|
+
overusedVerbs.flatMap(([_, ids]) => ids).slice(0, 5),
|
|
2156
|
+
overusedVerbs.length ? "Vary openers \u2014 most recruiters mark repetition after the third occurrence." : void 0
|
|
2157
|
+
),
|
|
2158
|
+
mk(
|
|
2159
|
+
"A8.scale-signals",
|
|
2160
|
+
"Mentions team size, budget, or org scale",
|
|
2161
|
+
scaleSignals.length === 0 ? 0 : Math.min(1, scaleSignals.length / 2),
|
|
2162
|
+
scaleSignals.length === 0 ? "No team/budget/scale signals detected." : `${scaleSignals.length} bullet(s) reference team size, budget, or organisation scale.`,
|
|
2163
|
+
[],
|
|
2164
|
+
scaleSignals.length === 0 ? "Add scale: 'Led a team of 6', 'Owned a $2M budget', '60+ engineers depend on this service.'" : void 0
|
|
2165
|
+
),
|
|
2166
|
+
mk(
|
|
2167
|
+
"A9.time-bound",
|
|
2168
|
+
"Time-bound impact statements",
|
|
2169
|
+
timeBound.length === 0 ? 0 : Math.min(1, timeBound.length / 2),
|
|
2170
|
+
timeBound.length === 0 ? "No time-bound results ('within 6 months', 'in 2 quarters')." : `${timeBound.length} bullet(s) include a timeframe.`,
|
|
2171
|
+
[],
|
|
2172
|
+
timeBound.length === 0 ? "Anchor results in time: 'within 6 months', 'in 12 weeks', 'over Q3 2025'." : void 0
|
|
2173
|
+
)
|
|
2174
|
+
];
|
|
2175
|
+
}
|
|
2176
|
+
function brevityChecks(resume, b) {
|
|
2177
|
+
const all = b.bullets;
|
|
2178
|
+
const E = b.expBullets;
|
|
2179
|
+
const total = Math.max(1, E.length);
|
|
2180
|
+
const longs = E.filter((x) => wordCount(x.text) > 30);
|
|
2181
|
+
const idealBullets = E.filter((x) => {
|
|
2182
|
+
const w = wordCount(x.text);
|
|
2183
|
+
return w >= 12 && w <= 22;
|
|
2184
|
+
});
|
|
2185
|
+
const shorts = E.filter((x) => wordCount(x.text) < 8);
|
|
2186
|
+
const totalWords = all.reduce((a, x) => a + wordCount(x.text), 0);
|
|
2187
|
+
const expWords = E.reduce((a, x) => a + wordCount(x.text), 0);
|
|
2188
|
+
const summaryWords = wordCount(b.summaryText);
|
|
2189
|
+
const expSection = resume.sections.find((s) => s.type === "experience");
|
|
2190
|
+
const bulletsPerRole = [];
|
|
2191
|
+
if (expSection) {
|
|
2192
|
+
(expSection.items || []).forEach((it, idx) => {
|
|
2193
|
+
const desc = typeof it.description === "string" ? it.description : "";
|
|
2194
|
+
bulletsPerRole.push({ jobTitle: it.jobTitle || `Role ${idx + 1}`, bullets: bulletTextsFromHtml(desc).length, entryIndex: idx });
|
|
2195
|
+
});
|
|
2196
|
+
}
|
|
2197
|
+
const bprWarnings = bulletsPerRole.filter((r, i) => {
|
|
2198
|
+
if (r.bullets === 0) return true;
|
|
2199
|
+
if (i === 0) return r.bullets < 3 || r.bullets > 7;
|
|
2200
|
+
if (i === 1) return r.bullets < 2 || r.bullets > 6;
|
|
2201
|
+
return r.bullets > 5;
|
|
2202
|
+
});
|
|
2203
|
+
const pages = Math.max(1, Math.ceil(totalWords / 450));
|
|
2204
|
+
const targetPages = experienceYears(resume) >= 10 ? 2 : 1;
|
|
2205
|
+
const lengthOK = pages <= targetPages;
|
|
2206
|
+
return [
|
|
2207
|
+
mk(
|
|
2208
|
+
"B1.bullet-length",
|
|
2209
|
+
"Bullet length 12\u201322 words",
|
|
2210
|
+
idealBullets.length / total,
|
|
2211
|
+
`${idealBullets.length} of ${total} bullets sit in the 12\u201322-word sweet spot.`,
|
|
2212
|
+
E.filter((x) => {
|
|
2213
|
+
const w = wordCount(x.text);
|
|
2214
|
+
return w < 12 || w > 22;
|
|
2215
|
+
}).slice(0, 5).map((x) => x.id),
|
|
2216
|
+
idealBullets.length / total < 0.7 ? "Aim for 12\u201322 words per bullet \u2014 short enough to scan, long enough to carry an outcome." : void 0
|
|
2217
|
+
),
|
|
2218
|
+
mk(
|
|
2219
|
+
"B2.long-bullets",
|
|
2220
|
+
"No paragraph-style bullets (>30 words)",
|
|
2221
|
+
longs.length === 0 ? 1 : 1 - Math.min(1, longs.length / 3),
|
|
2222
|
+
longs.length === 0 ? "All bullets stay under 30 words." : `${longs.length} bullet(s) exceed 30 words \u2014 recruiters skim.`,
|
|
2223
|
+
longs.slice(0, 5).map((x) => x.id),
|
|
2224
|
+
longs.length ? "Trim or split: each line should fit 1\u20132 visual rows on the page." : void 0
|
|
2225
|
+
),
|
|
2226
|
+
mk(
|
|
2227
|
+
"B3.short-bullets",
|
|
2228
|
+
"No telegraphic stubs (<8 words)",
|
|
2229
|
+
shorts.length === 0 ? 1 : 1 - Math.min(1, shorts.length / 3),
|
|
2230
|
+
shorts.length === 0 ? "No bullets under 8 words." : `${shorts.length} bullet(s) are under 8 words and likely lack context.`,
|
|
2231
|
+
shorts.slice(0, 5).map((x) => x.id),
|
|
2232
|
+
shorts.length ? "Add the so-what: what did it move, by how much?" : void 0
|
|
2233
|
+
),
|
|
2234
|
+
mk(
|
|
2235
|
+
"B4.page-length",
|
|
2236
|
+
`Resume length fits ${targetPages} page${targetPages > 1 ? "s" : ""}`,
|
|
2237
|
+
lengthOK ? 1 : Math.max(0, 1 - (pages - targetPages) * 0.5),
|
|
2238
|
+
`Estimated ${pages} page${pages > 1 ? "s" : ""} (${totalWords} words total).`,
|
|
2239
|
+
[],
|
|
2240
|
+
!lengthOK ? `Cut ~${(pages - targetPages) * 450} words to fit ${targetPages} page${targetPages > 1 ? "s" : ""}.` : void 0
|
|
2241
|
+
),
|
|
2242
|
+
mk(
|
|
2243
|
+
"B5.bullets-per-role",
|
|
2244
|
+
"Right number of bullets per role",
|
|
2245
|
+
bulletsPerRole.length === 0 ? 0 : 1 - Math.min(1, bprWarnings.length / Math.max(1, bulletsPerRole.length)),
|
|
2246
|
+
bulletsPerRole.length === 0 ? "No experience entries detected." : `${bulletsPerRole.length - bprWarnings.length} of ${bulletsPerRole.length} roles have a balanced bullet count.`,
|
|
2247
|
+
[],
|
|
2248
|
+
bprWarnings.length ? "Front-load: ~4\u20136 bullets on the most recent role, ~3\u20135 on the previous, ~2\u20133 older." : void 0
|
|
2249
|
+
),
|
|
2250
|
+
mk(
|
|
2251
|
+
"B6.summary-length",
|
|
2252
|
+
"Summary 30\u201375 words",
|
|
2253
|
+
summaryWords === 0 ? 0.4 : summaryWords >= 30 && summaryWords <= 75 ? 1 : Math.max(0, 1 - Math.abs(summaryWords - 50) / 50),
|
|
2254
|
+
summaryWords === 0 ? "No professional summary yet." : `Summary is ${summaryWords} word${summaryWords === 1 ? "" : "s"}.`,
|
|
2255
|
+
[],
|
|
2256
|
+
summaryWords === 0 ? "Write a 30\u201375-word summary: who you are, what you ship, where you want to go." : void 0
|
|
2257
|
+
)
|
|
2258
|
+
];
|
|
2259
|
+
}
|
|
2260
|
+
var PASSIVE_RE = /\b(was|were|been|being|is|are)\s+(?:[a-z]+ly\s+)?(\w+ed|written|driven|built|made|done|seen|taken|sent|given|known|shown|spoken|chosen|stolen|broken|frozen)\b/i;
|
|
2261
|
+
function styleChecks(resume, b) {
|
|
2262
|
+
const E = b.expBullets;
|
|
2263
|
+
const total = Math.max(1, E.length);
|
|
2264
|
+
const withPronouns = E.filter((x) => tokens2(x.text).some((t) => PRONOUNS.has(t)));
|
|
2265
|
+
const passive = E.filter((x) => PASSIVE_RE.test(x.text));
|
|
2266
|
+
const withFiller = E.filter((x) => {
|
|
2267
|
+
const t = ` ${x.text.toLowerCase()} `;
|
|
2268
|
+
for (const f of FILLER_WORDS) if (t.includes(` ${f} `)) return true;
|
|
2269
|
+
return false;
|
|
2270
|
+
});
|
|
2271
|
+
const withBuzz = E.filter((x) => {
|
|
2272
|
+
const t = x.text.toLowerCase();
|
|
2273
|
+
return BUZZWORDS.some((bw) => t.includes(bw));
|
|
2274
|
+
});
|
|
2275
|
+
let parallelOk = 0, parallelTotal = 0;
|
|
2276
|
+
const expSection = resume.sections.find((s) => s.type === "experience");
|
|
2277
|
+
if (expSection) for (const it of expSection.items || []) {
|
|
2278
|
+
const lines = bulletTextsFromHtml(it.description || "");
|
|
2279
|
+
if (lines.length < 2) continue;
|
|
2280
|
+
parallelTotal++;
|
|
2281
|
+
const allVerbInitial = lines.every((l) => /^[A-Z][a-z]+ed\b|^[A-Z][a-z]+\b/.test(l));
|
|
2282
|
+
if (allVerbInitial) parallelOk++;
|
|
2283
|
+
}
|
|
2284
|
+
let tenseOk = 0, tenseTotal = 0;
|
|
2285
|
+
if (expSection) for (const it of expSection.items || []) {
|
|
2286
|
+
const lines = bulletTextsFromHtml(it.description || "");
|
|
2287
|
+
if (lines.length === 0) continue;
|
|
2288
|
+
tenseTotal++;
|
|
2289
|
+
const isCurrent = !!it.current || !it.endDate && it.current !== false;
|
|
2290
|
+
const isPast = (s) => /^[A-Z][a-z]*(ed|wrote|led|built|drove|spoke|sold|chose|saw|sent|shipped|made)\b/.test(s);
|
|
2291
|
+
const allPast = lines.every(isPast);
|
|
2292
|
+
const allPresent = lines.every((l) => !isPast(l));
|
|
2293
|
+
if (isCurrent ? allPresent || allPast : allPast) tenseOk++;
|
|
2294
|
+
}
|
|
2295
|
+
const endsWithPeriod = E.filter((x) => /[.!?]\s*$/.test(x.text)).length;
|
|
2296
|
+
const endsWithout = total - endsWithPeriod;
|
|
2297
|
+
const periodConsistent = endsWithPeriod === 0 || endsWithout === 0 || total === 0;
|
|
2298
|
+
const allText = E.map((x) => x.text).join(" ");
|
|
2299
|
+
const hasSmart = /[“”‘’—–]/.test(allText);
|
|
2300
|
+
const hasAscii = /["'\-]/.test(allText);
|
|
2301
|
+
const punctConsistent = !(hasSmart && hasAscii) || allText.length === 0;
|
|
2302
|
+
const startsCap = E.filter((x) => /^[A-Z]/.test(x.text)).length;
|
|
2303
|
+
const capConsistent = startsCap === total || startsCap === 0 || total === 0;
|
|
2304
|
+
const allWords = E.flatMap((x) => tokens2(x.text)).filter((w) => w.length > 1);
|
|
2305
|
+
const allcapsWords = allWords.filter((w) => /^[A-Z]{2,}$/.test(w));
|
|
2306
|
+
const acronymRatio = allWords.length ? allcapsWords.length / allWords.length : 0;
|
|
2307
|
+
function syllableCount(word) {
|
|
2308
|
+
word = word.toLowerCase().replace(/[^a-z]/g, "");
|
|
2309
|
+
if (word.length <= 3) return 1;
|
|
2310
|
+
word = word.replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, "").replace(/^y/, "");
|
|
2311
|
+
const matches = word.match(/[aeiouy]{1,2}/g);
|
|
2312
|
+
return matches ? matches.length : 1;
|
|
2313
|
+
}
|
|
2314
|
+
const expText = E.map((x) => x.text).join(". ");
|
|
2315
|
+
const sentences = Math.max(1, expText.split(/[.!?]+/).filter(Boolean).length);
|
|
2316
|
+
const words = expText.split(/\s+/).filter(Boolean);
|
|
2317
|
+
const syllables = words.reduce((a, w) => a + syllableCount(w), 0);
|
|
2318
|
+
const fk = words.length === 0 ? 10 : 0.39 * (words.length / sentences) + 11.8 * (syllables / Math.max(1, words.length)) - 15.59;
|
|
2319
|
+
const fkOk = fk >= 8 && fk <= 13;
|
|
2320
|
+
return [
|
|
2321
|
+
mk(
|
|
2322
|
+
"C1.no-pronouns",
|
|
2323
|
+
"No personal pronouns in bullets",
|
|
2324
|
+
withPronouns.length === 0 ? 1 : 1 - Math.min(1, withPronouns.length / 3),
|
|
2325
|
+
withPronouns.length === 0 ? "Clean \u2014 no 'I / we / my' in bullets." : `${withPronouns.length} bullet(s) contain personal pronouns.`,
|
|
2326
|
+
withPronouns.slice(0, 5).map((x) => x.id),
|
|
2327
|
+
withPronouns.length ? "Drop the subject: 'I led X' \u2192 'Led X.'" : void 0
|
|
2328
|
+
),
|
|
2329
|
+
mk(
|
|
2330
|
+
"C2.passive-voice",
|
|
2331
|
+
"Active voice (\u226415% passive)",
|
|
2332
|
+
passive.length / total <= 0.15 ? 1 : 1 - (passive.length / total - 0.15) * 2,
|
|
2333
|
+
`${passive.length} of ${total} bullets read as passive voice (${Math.round(passive.length / total * 100)}%).`,
|
|
2334
|
+
passive.slice(0, 5).map((x) => x.id),
|
|
2335
|
+
passive.length / total > 0.15 ? "Flip subject/object: 'The project was led by me' \u2192 'Led the project.'" : void 0
|
|
2336
|
+
),
|
|
2337
|
+
mk(
|
|
2338
|
+
"C3.tense-consistency",
|
|
2339
|
+
"Tense is consistent within each role",
|
|
2340
|
+
tenseTotal === 0 ? 1 : tenseOk / tenseTotal,
|
|
2341
|
+
`${tenseOk} of ${tenseTotal} role${tenseTotal === 1 ? "" : "s"} have consistent tense across bullets.`,
|
|
2342
|
+
[],
|
|
2343
|
+
tenseTotal && tenseOk / tenseTotal < 1 ? "Use past tense for prior roles; current role can be present-tense but pick one style and stick to it." : void 0
|
|
2344
|
+
),
|
|
2345
|
+
mk(
|
|
2346
|
+
"C4.parallelism",
|
|
2347
|
+
"Parallel structure within a role",
|
|
2348
|
+
parallelTotal === 0 ? 1 : parallelOk / parallelTotal,
|
|
2349
|
+
`${parallelOk} of ${parallelTotal} multi-bullet role${parallelTotal === 1 ? "" : "s"} have parallel openers.`,
|
|
2350
|
+
[],
|
|
2351
|
+
parallelTotal && parallelOk / parallelTotal < 1 ? "Every bullet inside a role should start with the same grammatical form (a verb)." : void 0
|
|
2352
|
+
),
|
|
2353
|
+
mk(
|
|
2354
|
+
"C5.filler-words",
|
|
2355
|
+
"No filler words",
|
|
2356
|
+
withFiller.length === 0 ? 1 : 1 - Math.min(1, withFiller.length / 3),
|
|
2357
|
+
withFiller.length === 0 ? "No filler detected." : `${withFiller.length} bullet(s) contain filler (very, really, various, multiple, \u2026).`,
|
|
2358
|
+
withFiller.slice(0, 5).map((x) => x.id),
|
|
2359
|
+
withFiller.length ? "Strip fluff \u2014 adverbs like 'very' and 'really' dilute the metric." : void 0
|
|
2360
|
+
),
|
|
2361
|
+
mk(
|
|
2362
|
+
"C6.buzzwords",
|
|
2363
|
+
"No buzzwords / clich\xE9s",
|
|
2364
|
+
withBuzz.length === 0 ? 1 : 1 - Math.min(1, withBuzz.length / 3),
|
|
2365
|
+
withBuzz.length === 0 ? "No buzzword clich\xE9s." : `${withBuzz.length} bullet(s) contain a clich\xE9 (team player, results-driven, dynamic, \u2026).`,
|
|
2366
|
+
withBuzz.slice(0, 5).map((x) => x.id),
|
|
2367
|
+
withBuzz.length ? "Show, don't claim: 'team player' is invisible; 'led a 6-person migration' is undeniable." : void 0
|
|
2368
|
+
),
|
|
2369
|
+
mk(
|
|
2370
|
+
"C7.punctuation",
|
|
2371
|
+
"End-punctuation is consistent",
|
|
2372
|
+
periodConsistent ? 1 : 0.5,
|
|
2373
|
+
periodConsistent ? "All bullets agree on whether they end with a period." : `Mixed: ${endsWithPeriod} end with a period, ${endsWithout} don't.`,
|
|
2374
|
+
[],
|
|
2375
|
+
!periodConsistent ? "Pick one rule for the whole resume." : void 0
|
|
2376
|
+
),
|
|
2377
|
+
mk(
|
|
2378
|
+
"C8.punctuation-chars",
|
|
2379
|
+
"Consistent quote / dash style",
|
|
2380
|
+
punctConsistent ? 1 : 0.5,
|
|
2381
|
+
punctConsistent ? "Punctuation glyphs are consistent." : "Mixes smart quotes/em-dashes with ASCII equivalents.",
|
|
2382
|
+
[],
|
|
2383
|
+
!punctConsistent ? 'Pick either smart quotes (\u201C\u201D) or straight quotes (") consistently.' : void 0
|
|
2384
|
+
),
|
|
2385
|
+
mk(
|
|
2386
|
+
"C9.capitalization",
|
|
2387
|
+
"Consistent first-letter capitalization",
|
|
2388
|
+
capConsistent ? 1 : 0.5,
|
|
2389
|
+
capConsistent ? "Every bullet starts with the same case." : `${startsCap} of ${total} bullets start with a capital letter.`,
|
|
2390
|
+
[],
|
|
2391
|
+
!capConsistent ? "Capitalize the first letter of every bullet." : void 0
|
|
2392
|
+
),
|
|
2393
|
+
mk(
|
|
2394
|
+
"C10.acronym-density",
|
|
2395
|
+
"Acronyms used in moderation",
|
|
2396
|
+
acronymRatio <= 0.12 ? 1 : Math.max(0, 1 - (acronymRatio - 0.12) * 4),
|
|
2397
|
+
`${Math.round(acronymRatio * 100)}% of words are all-caps acronyms.`,
|
|
2398
|
+
[],
|
|
2399
|
+
acronymRatio > 0.12 ? "Spell out at least the first acronym in each section \u2014 recruiters outside the team won't decode all of them." : void 0
|
|
2400
|
+
),
|
|
2401
|
+
mk(
|
|
2402
|
+
"C11.readability",
|
|
2403
|
+
"Readability (Flesch-Kincaid 8\u201313)",
|
|
2404
|
+
fkOk ? 1 : Math.max(0, 1 - Math.abs(fk - 10.5) / 6),
|
|
2405
|
+
`Flesch-Kincaid grade \u2248 ${fk.toFixed(1)}.`,
|
|
2406
|
+
[],
|
|
2407
|
+
!fkOk ? fk < 8 ? "Bullets read younger than 8th grade \u2014 likely too vague." : "Bullets read denser than grad-school \u2014 simplify or shorten." : void 0
|
|
2408
|
+
)
|
|
2409
|
+
];
|
|
2410
|
+
}
|
|
2411
|
+
function structureChecks(resume, b) {
|
|
2412
|
+
const have = new Set(resume.sections.filter((s) => s.visible !== false).map((s) => s.type));
|
|
2413
|
+
const needed = ["experience", "education", "skills"];
|
|
2414
|
+
const missing = needed.filter((n) => !have.has(n));
|
|
2415
|
+
const expSec = resume.sections.find((s) => s.type === "experience");
|
|
2416
|
+
const eduSec = resume.sections.find((s) => s.type === "education");
|
|
2417
|
+
const skillsSec = resume.sections.find((s) => s.type === "skills");
|
|
2418
|
+
let expGood = 0, expTotal = 0;
|
|
2419
|
+
if (expSec) for (const it of expSec.items || []) {
|
|
2420
|
+
expTotal++;
|
|
2421
|
+
const lines = bulletTextsFromHtml(it.description || "");
|
|
2422
|
+
if (it.jobTitle && it.employer && it.startDate && (it.current || it.endDate) && lines.length >= 2) expGood++;
|
|
2423
|
+
}
|
|
2424
|
+
let eduGood = 0, eduTotal = 0;
|
|
2425
|
+
if (eduSec) for (const it of eduSec.items || []) {
|
|
2426
|
+
eduTotal++;
|
|
2427
|
+
if (it.school && it.degree && (it.startDate || it.endDate)) eduGood++;
|
|
2428
|
+
}
|
|
2429
|
+
const skillItems = skillsSec?.items || [];
|
|
2430
|
+
const skillProfQualifiers = skillItems.some((s) => /\b(expert|proficient|advanced|intermediate|basic|beginner)\b/i.test(s.skills || ""));
|
|
2431
|
+
let softSkillPollution = 0, hardSkillCount = 0;
|
|
2432
|
+
for (const grp of skillItems) {
|
|
2433
|
+
const items = String(grp.skills || "").split(/[,•|/]/).map((x) => x.trim()).filter(Boolean);
|
|
2434
|
+
for (const sk of items) {
|
|
2435
|
+
if (SOFT_SKILLS.has(sk.toLowerCase())) softSkillPollution++;
|
|
2436
|
+
else hardSkillCount++;
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
function rev(items) {
|
|
2440
|
+
const dates = items.map((x) => parseMMYYYY(x.startDate || "")).filter(Boolean);
|
|
2441
|
+
for (let i = 1; i < dates.length; i++) if (dates[i] > dates[i - 1]) return false;
|
|
2442
|
+
return true;
|
|
2443
|
+
}
|
|
2444
|
+
const expRev = expSec ? rev(expSec.items || []) : true;
|
|
2445
|
+
const eduRev = eduSec ? rev(eduSec.items || []) : true;
|
|
2446
|
+
function isCanonical(type, title) {
|
|
2447
|
+
const t = title.toLowerCase().trim();
|
|
2448
|
+
const list = ATS_SECTION_LABELS[type] || [];
|
|
2449
|
+
return list.some((l) => t === l);
|
|
2450
|
+
}
|
|
2451
|
+
const sectionNameOK = resume.sections.filter((s) => s.visible !== false).every((s) => isCanonical(s.type, s.title));
|
|
2452
|
+
const nonCanonical = resume.sections.filter((s) => s.visible !== false && !isCanonical(s.type, s.title));
|
|
2453
|
+
const yearsExp = experienceYears(resume);
|
|
2454
|
+
const isStudent = yearsExp < 1;
|
|
2455
|
+
const order = resume.sections.map((s) => s.type);
|
|
2456
|
+
const idxExp = order.indexOf("experience"), idxEdu = order.indexOf("education");
|
|
2457
|
+
let orderOK = true;
|
|
2458
|
+
if (idxExp >= 0 && idxEdu >= 0) {
|
|
2459
|
+
orderOK = isStudent ? idxEdu < idxExp : idxExp < idxEdu;
|
|
2460
|
+
}
|
|
2461
|
+
return [
|
|
2462
|
+
mk(
|
|
2463
|
+
"D1.essential-sections",
|
|
2464
|
+
"Essential sections present (Experience, Education, Skills)",
|
|
2465
|
+
missing.length === 0 ? 1 : 1 - missing.length / needed.length,
|
|
2466
|
+
missing.length === 0 ? "All essential sections present." : `Missing: ${missing.join(", ")}.`,
|
|
2467
|
+
[],
|
|
2468
|
+
missing.length ? `Add a section for: ${missing.join(", ")}.` : void 0
|
|
2469
|
+
),
|
|
2470
|
+
mk(
|
|
2471
|
+
"D2.section-order",
|
|
2472
|
+
isStudent ? "Education first (student)" : "Experience first (mid-career)",
|
|
2473
|
+
orderOK ? 1 : 0,
|
|
2474
|
+
orderOK ? "Section order matches your experience level." : `Reorder: ${isStudent ? "Education should come before Experience" : "Experience should come before Education"}.`,
|
|
2475
|
+
[]
|
|
2476
|
+
),
|
|
2477
|
+
mk(
|
|
2478
|
+
"D3.canonical-section-names",
|
|
2479
|
+
"ATS-canonical section titles",
|
|
2480
|
+
sectionNameOK ? 1 : 1 - Math.min(1, nonCanonical.length / 3),
|
|
2481
|
+
sectionNameOK ? "All section titles match ATS-canonical labels." : `${nonCanonical.length} section title(s) are non-standard.`,
|
|
2482
|
+
[],
|
|
2483
|
+
nonCanonical.length ? `Rename: ${nonCanonical.map((s) => `"${s.title}"`).join(", ")} \u2192 use "${(ATS_SECTION_LABELS[nonCanonical[0]?.type] || ["a standard label"])[0]}" etc.` : void 0
|
|
2484
|
+
),
|
|
2485
|
+
mk(
|
|
2486
|
+
"D4.experience-integrity",
|
|
2487
|
+
"Every role has title/employer/dates and 2+ bullets",
|
|
2488
|
+
expTotal === 0 ? 0 : expGood / expTotal,
|
|
2489
|
+
expTotal === 0 ? "No experience entries." : `${expGood} of ${expTotal} role${expTotal === 1 ? "" : "s"} are complete.`,
|
|
2490
|
+
[],
|
|
2491
|
+
expGood < expTotal ? "Fill in any missing title, employer, dates, or add \u22652 bullets." : void 0
|
|
2492
|
+
),
|
|
2493
|
+
mk(
|
|
2494
|
+
"D5.education-integrity",
|
|
2495
|
+
"Every education entry has school/degree/dates",
|
|
2496
|
+
eduTotal === 0 ? 0.5 : eduGood / eduTotal,
|
|
2497
|
+
eduTotal === 0 ? "No education entries." : `${eduGood} of ${eduTotal} entries are complete.`,
|
|
2498
|
+
[]
|
|
2499
|
+
),
|
|
2500
|
+
mk(
|
|
2501
|
+
"D6.skills-hygiene",
|
|
2502
|
+
"Skills are hard-skill, comma-separated, no proficiency qualifiers",
|
|
2503
|
+
skillItems.length === 0 ? 0 : (!skillProfQualifiers ? 0.5 : 0) + (hardSkillCount >= 8 ? 0.3 : hardSkillCount / 30) + (softSkillPollution === 0 ? 0.2 : 0),
|
|
2504
|
+
skillItems.length === 0 ? "No skills section yet." : `${hardSkillCount} hard skill${hardSkillCount === 1 ? "" : "s"}, ${softSkillPollution} soft-skill pollutants${skillProfQualifiers ? ", contains proficiency qualifiers" : ""}.`,
|
|
2505
|
+
[],
|
|
2506
|
+
skillItems.length === 0 ? "Add a Skills section." : skillProfQualifiers ? "Drop 'Expert in / Proficient in' \u2014 keep skills as plain comma-separated tokens." : softSkillPollution > 0 ? "Move soft skills (communication, leadership) out of the technical Skills section." : void 0
|
|
2507
|
+
),
|
|
2508
|
+
mk(
|
|
2509
|
+
"D7.reverse-chronological",
|
|
2510
|
+
"Experience & Education are reverse-chronological",
|
|
2511
|
+
expRev && eduRev ? 1 : 0.5,
|
|
2512
|
+
expRev && eduRev ? "Both sections are reverse-chronological." : `${!expRev ? "Experience" : ""}${!expRev && !eduRev ? " & " : ""}${!eduRev ? "Education" : ""} out of order.`,
|
|
2513
|
+
[],
|
|
2514
|
+
!expRev || !eduRev ? "List most recent first." : void 0
|
|
2515
|
+
)
|
|
2516
|
+
];
|
|
2517
|
+
}
|
|
2518
|
+
function atsChecks(resume, b) {
|
|
2519
|
+
const s = resume.styling;
|
|
2520
|
+
const p = resume.personal;
|
|
2521
|
+
const allText = b.bullets.map((x) => x.text).join(" ");
|
|
2522
|
+
const isSingleCol = s.layout === "single";
|
|
2523
|
+
const isSafeFont = SAFE_FONTS.has(s.fontHeading) && SAFE_FONTS.has(s.fontBody);
|
|
2524
|
+
const fontSizeOK = s.fontSize >= 9.5 && s.fontSize <= 12;
|
|
2525
|
+
const safeBullet = SAFE_BULLET_GLYPHS.has(s.bulletGlyph === "disc" ? "\u2022" : s.bulletGlyph === "dash" ? "-" : "");
|
|
2526
|
+
const emailOK = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(p.email);
|
|
2527
|
+
const phoneOK = !p.phone || /(\+?\d[\d\s().-]{6,})/.test(p.phone);
|
|
2528
|
+
const hasLinkedIn = !!(p.linkedin && /linkedin\.com\/in\//i.test(p.linkedin));
|
|
2529
|
+
const locationOK = !!p.location && /,/.test(p.location);
|
|
2530
|
+
const allDates = [];
|
|
2531
|
+
for (const sec of resume.sections) for (const it of sec.items || []) {
|
|
2532
|
+
if (it.startDate) allDates.push(it.startDate);
|
|
2533
|
+
if (it.endDate) allDates.push(it.endDate);
|
|
2534
|
+
if (it.date) allDates.push(it.date);
|
|
2535
|
+
}
|
|
2536
|
+
const dateFormats = /* @__PURE__ */ new Set();
|
|
2537
|
+
for (const d of allDates) {
|
|
2538
|
+
if (!d) continue;
|
|
2539
|
+
if (/^\d{2}\/\d{4}$/.test(d)) dateFormats.add("MM/YYYY");
|
|
2540
|
+
else if (/^[A-Za-z]{3,}\s+\d{4}$/.test(d)) dateFormats.add("MMM YYYY");
|
|
2541
|
+
else if (/^\d{4}$/.test(d)) dateFormats.add("YYYY");
|
|
2542
|
+
else if (/^[A-Za-z]+\.\s+\d{4}$/.test(d)) dateFormats.add("Mar. YYYY");
|
|
2543
|
+
else if (/^\d{2}\/\d{2}\/\d{4}$/.test(d)) dateFormats.add("DD/MM/YYYY");
|
|
2544
|
+
else if (d) dateFormats.add("other");
|
|
2545
|
+
}
|
|
2546
|
+
const badDateFormats = ["other", "Mar. YYYY"];
|
|
2547
|
+
const hasBadDateFormat = [...dateFormats].some((f) => badDateFormats.includes(f));
|
|
2548
|
+
let presentLiteralOK = true;
|
|
2549
|
+
for (const sec of resume.sections) for (const it of sec.items || []) {
|
|
2550
|
+
if (it.current && it.endDate && !/^present$/i.test(it.endDate)) presentLiteralOK = false;
|
|
2551
|
+
}
|
|
2552
|
+
const atsKillerCharRe = /[▪◆★▶♦►→⇒]/;
|
|
2553
|
+
const hasKillerChars = atsKillerCharRe.test(allText);
|
|
2554
|
+
return [
|
|
2555
|
+
mk(
|
|
2556
|
+
"E1.single-column",
|
|
2557
|
+
"Single-column layout (ATS-critical)",
|
|
2558
|
+
isSingleCol ? 1 : 0,
|
|
2559
|
+
isSingleCol ? "Layout is single-column." : "Multi-column layout \u2014 many ATS parsers scramble reading order.",
|
|
2560
|
+
[],
|
|
2561
|
+
!isSingleCol ? "Switch to a single-column template (Customize \u2192 Layout \u2192 single)." : void 0,
|
|
2562
|
+
!isSingleCol ? "hard" : "soft"
|
|
2563
|
+
),
|
|
2564
|
+
mk(
|
|
2565
|
+
"E2.contact-in-body",
|
|
2566
|
+
"Contact info rendered in body, not header/footer",
|
|
2567
|
+
1,
|
|
2568
|
+
"Contact info is rendered in the document body in this app.",
|
|
2569
|
+
[]
|
|
2570
|
+
),
|
|
2571
|
+
mk(
|
|
2572
|
+
"E3.email-valid",
|
|
2573
|
+
"Email is present and valid",
|
|
2574
|
+
emailOK ? 1 : 0,
|
|
2575
|
+
emailOK ? "Email is well-formed." : "Email missing or malformed.",
|
|
2576
|
+
[],
|
|
2577
|
+
!emailOK ? "Add a valid email (firstname.lastname@\u2026)." : void 0,
|
|
2578
|
+
!emailOK ? "hard" : "soft"
|
|
2579
|
+
),
|
|
2580
|
+
mk(
|
|
2581
|
+
"E4.phone-valid",
|
|
2582
|
+
"Phone is present and parseable",
|
|
2583
|
+
phoneOK && p.phone ? 1 : 0,
|
|
2584
|
+
p.phone ? "Phone present." : "Phone missing.",
|
|
2585
|
+
[],
|
|
2586
|
+
!p.phone ? "Add a phone number (E.164 or local format)." : void 0
|
|
2587
|
+
),
|
|
2588
|
+
mk(
|
|
2589
|
+
"E5.location",
|
|
2590
|
+
"Location includes city + region/country",
|
|
2591
|
+
locationOK ? 1 : p.location ? 0.5 : 0,
|
|
2592
|
+
locationOK ? "Location includes a comma." : p.location ? "Location is single-token \u2014 add region/country." : "Location missing.",
|
|
2593
|
+
[],
|
|
2594
|
+
!locationOK ? "Use 'City, Country' or 'City, State'." : void 0
|
|
2595
|
+
),
|
|
2596
|
+
mk(
|
|
2597
|
+
"E6.linkedin",
|
|
2598
|
+
"LinkedIn URL present",
|
|
2599
|
+
hasLinkedIn ? 1 : 0,
|
|
2600
|
+
hasLinkedIn ? "LinkedIn URL detected." : "LinkedIn URL missing or non-canonical.",
|
|
2601
|
+
[],
|
|
2602
|
+
!hasLinkedIn ? "Add a linkedin.com/in/your-handle URL." : void 0
|
|
2603
|
+
),
|
|
2604
|
+
mk(
|
|
2605
|
+
"E7.date-format",
|
|
2606
|
+
"Consistent, ATS-safe date format (MMM YYYY or MM/YYYY)",
|
|
2607
|
+
dateFormats.size <= 1 && !hasBadDateFormat ? 1 : 0.5,
|
|
2608
|
+
dateFormats.size <= 1 && !hasBadDateFormat ? "Dates use a single ATS-safe format." : `Mixed or non-standard date formats: ${[...dateFormats].join(", ")}.`,
|
|
2609
|
+
[],
|
|
2610
|
+
dateFormats.size > 1 || hasBadDateFormat ? "Pick one of: 'Mar 2024' or '03/2024'. Avoid 'Mar.' with a period, 'Spring 2024', or '\u201924'." : void 0
|
|
2611
|
+
),
|
|
2612
|
+
mk(
|
|
2613
|
+
"E8.present-literal",
|
|
2614
|
+
"'Present' literal for current roles",
|
|
2615
|
+
presentLiteralOK ? 1 : 0,
|
|
2616
|
+
presentLiteralOK ? "Current roles use 'Present' or leave end date blank." : "Some current roles use 'Now / Ongoing / Current' instead of 'Present'.",
|
|
2617
|
+
[],
|
|
2618
|
+
!presentLiteralOK ? "Some ATS parsers only recognise 'Present'." : void 0
|
|
2619
|
+
),
|
|
2620
|
+
mk(
|
|
2621
|
+
"E9.bullet-glyph",
|
|
2622
|
+
"ATS-safe bullet glyph (\u2022, -, *)",
|
|
2623
|
+
safeBullet ? 1 : 0.6,
|
|
2624
|
+
safeBullet ? "Bullet glyph is ATS-safe." : `Bullet glyph '${s.bulletGlyph}' may not survive every ATS parser.`,
|
|
2625
|
+
[],
|
|
2626
|
+
!safeBullet ? "Switch bullet glyph to disc, dash, or square (Customize \u2192 Layout \u2192 bullet glyph)." : void 0
|
|
2627
|
+
),
|
|
2628
|
+
mk(
|
|
2629
|
+
"E10.font-safe",
|
|
2630
|
+
"Body and heading fonts are ATS-safe",
|
|
2631
|
+
isSafeFont ? 1 : 0.5,
|
|
2632
|
+
isSafeFont ? "Fonts are ATS-safe." : `Heading '${s.fontHeading}' or body '${s.fontBody}' may not be embedded by some PDF renderers.`,
|
|
2633
|
+
[],
|
|
2634
|
+
!isSafeFont ? "Use Arial, Calibri, Helvetica, Garamond, or one of the safe defaults." : void 0
|
|
2635
|
+
),
|
|
2636
|
+
mk(
|
|
2637
|
+
"E11.font-size",
|
|
2638
|
+
"Body font size 10\u201312pt",
|
|
2639
|
+
fontSizeOK ? 1 : 0.5,
|
|
2640
|
+
fontSizeOK ? `Font size ${s.fontSize}pt is in range.` : `Font size ${s.fontSize}pt is outside the 10\u201312pt range.`,
|
|
2641
|
+
[],
|
|
2642
|
+
!fontSizeOK ? "Set body size to 10\u201312pt." : void 0
|
|
2643
|
+
),
|
|
2644
|
+
mk(
|
|
2645
|
+
"E12.no-killer-chars",
|
|
2646
|
+
"No ATS-killer glyphs in body",
|
|
2647
|
+
hasKillerChars ? 0 : 1,
|
|
2648
|
+
hasKillerChars ? "Body contains decorative glyphs (\u25AA \u25C6 \u2605 \u2192 \u21D2) that can scramble parsers." : "No risky glyphs detected.",
|
|
2649
|
+
[],
|
|
2650
|
+
hasKillerChars ? "Replace decorative glyphs with words or plain bullets." : void 0
|
|
2651
|
+
)
|
|
2652
|
+
];
|
|
2653
|
+
}
|
|
2654
|
+
function skillsChecks(resume, b) {
|
|
2655
|
+
const skillsSec = resume.sections.find((s) => s.type === "skills");
|
|
2656
|
+
const items = skillsSec?.items || [];
|
|
2657
|
+
const all = items.flatMap((it) => String(it.skills || "").split(/[,•|/]/).map((x) => x.trim()).filter(Boolean));
|
|
2658
|
+
const hardCount = all.filter((s) => lookupSkill(s)?.kind === "hard").length;
|
|
2659
|
+
const softCount = all.filter((s) => lookupSkill(s)?.kind === "soft").length;
|
|
2660
|
+
const allBulletText = b.bullets.map((x) => x.text).join(" ").toLowerCase();
|
|
2661
|
+
const evidenced = all.filter((sk) => allBulletText.includes(sk.toLowerCase()));
|
|
2662
|
+
const counts = /* @__PURE__ */ new Map();
|
|
2663
|
+
for (const x of b.bullets) for (const t of tokens2(x.text)) {
|
|
2664
|
+
if (t.length < 3) continue;
|
|
2665
|
+
counts.set(t, (counts.get(t) || 0) + 1);
|
|
2666
|
+
}
|
|
2667
|
+
const stuffed = [...counts.entries()].filter(([k, v]) => v > 6 && /^[a-z]+$/.test(k) && !["with", "from", "into", "over", "under", "across", "by", "to", "for", "the", "and", "of", "on", "in"].includes(k));
|
|
2668
|
+
const allText = b.bullets.map((x) => x.text).join(" ");
|
|
2669
|
+
const acronyms = [...new Set(Array.from(allText.matchAll(/\b([A-Z]{2,5})\b/g)).map((m) => m[1]))].slice(0, 5);
|
|
2670
|
+
const expanded = acronyms.filter((a) => new RegExp(`\\b${a}\\s*\\(`).test(allText));
|
|
2671
|
+
return [
|
|
2672
|
+
mk(
|
|
2673
|
+
"F1.skills-count",
|
|
2674
|
+
"Adequate number of hard skills (\u226510)",
|
|
2675
|
+
Math.min(1, hardCount / 10),
|
|
2676
|
+
`${hardCount} hard skill${hardCount === 1 ? "" : "s"} listed.`,
|
|
2677
|
+
[],
|
|
2678
|
+
hardCount < 10 ? "Add more concrete, name-able skills (tools, languages, frameworks)." : void 0
|
|
2679
|
+
),
|
|
2680
|
+
mk(
|
|
2681
|
+
"F2.skills-mix",
|
|
2682
|
+
"Mix of hard and soft skills",
|
|
2683
|
+
hardCount >= 6 && softCount >= 1 ? 1 : hardCount >= 4 ? 0.7 : 0.3,
|
|
2684
|
+
`${hardCount} hard, ${softCount} soft skill${softCount === 1 ? "" : "s"}.`,
|
|
2685
|
+
[],
|
|
2686
|
+
hardCount < 6 ? "Add at least 6 hard skills." : softCount === 0 ? "Add 2\u20133 soft skills (Leadership, Communication, Mentoring)." : void 0
|
|
2687
|
+
),
|
|
2688
|
+
mk(
|
|
2689
|
+
"F3.skills-evidenced",
|
|
2690
|
+
"Skills appear in your experience bullets",
|
|
2691
|
+
Math.min(1, evidenced.length / Math.max(1, Math.min(8, all.length))),
|
|
2692
|
+
`${evidenced.length} of ${all.length} listed skills are also mentioned in bullets.`,
|
|
2693
|
+
[],
|
|
2694
|
+
evidenced.length / Math.max(1, all.length) < 0.5 ? "ATS systems weight skills that appear in BOTH the skills section AND the bullets. Mention each top skill in at least one bullet." : void 0
|
|
2695
|
+
),
|
|
2696
|
+
mk(
|
|
2697
|
+
"F4.no-keyword-stuffing",
|
|
2698
|
+
"No keyword stuffing",
|
|
2699
|
+
stuffed.length === 0 ? 1 : 1 - Math.min(1, stuffed.length / 3),
|
|
2700
|
+
stuffed.length === 0 ? "No token over-repeated." : `Over-repeated: ${stuffed.slice(0, 4).map(([k, v]) => `${k}\xD7${v}`).join(", ")}.`,
|
|
2701
|
+
[],
|
|
2702
|
+
stuffed.length ? "Diversify language \u2014 repeating a keyword more than 5\xD7 looks like stuffing." : void 0
|
|
2703
|
+
),
|
|
2704
|
+
mk(
|
|
2705
|
+
"F5.acronym-expansion",
|
|
2706
|
+
"Acronyms have their expansion at least once",
|
|
2707
|
+
acronyms.length === 0 ? 1 : expanded.length / acronyms.length,
|
|
2708
|
+
acronyms.length === 0 ? "No acronyms detected." : `${expanded.length} of ${acronyms.length} top acronyms have an inline expansion.`,
|
|
2709
|
+
[],
|
|
2710
|
+
acronyms.length > 0 && expanded.length < acronyms.length ? `Expand at least once: e.g. '${acronyms[0]} (\u2026)'.` : void 0
|
|
2711
|
+
)
|
|
2712
|
+
];
|
|
2713
|
+
}
|
|
2714
|
+
function polishChecks(resume, b) {
|
|
2715
|
+
const p = resume.personal;
|
|
2716
|
+
const allText = b.bullets.map((x) => x.text).join(" ");
|
|
2717
|
+
const PLACEHOLDER_RE = /\b(lorem ipsum|todo|tbd|xxx|placeholder)\b|\[.*?\]/i;
|
|
2718
|
+
const placeholders = b.bullets.filter((x) => PLACEHOLDER_RE.test(x.text));
|
|
2719
|
+
const urls = Array.from(allText.matchAll(/https?:\/\/\S+/g)).map((m) => m[0]);
|
|
2720
|
+
const malformedUrls = urls.filter((u) => !/^https?:\/\/[^\s)]+\.[a-z]{2,}/i.test(u));
|
|
2721
|
+
const emailUnprofessional = p.email && /(cool|sexy|hot|cute|cutie|420|69|hunny|baby|princess|smile|love)/i.test(p.email.split("@")[0]);
|
|
2722
|
+
const linkedinGeneric = p.linkedin && /\/in\/[a-z0-9]{8,}-[a-z0-9]{4,}/i.test(p.linkedin) && !/[a-zA-Z]/.test(p.linkedin.split("/in/")[1]?.split(/[-?#]/)[0] || "");
|
|
2723
|
+
const hasCustomWebsite = !!p.website && !/(linkedin\.com|github\.com|facebook\.com|twitter\.com|x\.com)/i.test(p.website);
|
|
2724
|
+
const hasHeadline = !!p.title;
|
|
2725
|
+
return [
|
|
2726
|
+
mk(
|
|
2727
|
+
"G1.no-placeholders",
|
|
2728
|
+
"No placeholder text",
|
|
2729
|
+
placeholders.length === 0 ? 1 : 0,
|
|
2730
|
+
placeholders.length === 0 ? "No placeholders detected." : `${placeholders.length} bullet(s) contain placeholder text (TODO / Lorem / [Insert]).`,
|
|
2731
|
+
placeholders.slice(0, 5).map((x) => x.id),
|
|
2732
|
+
placeholders.length ? "Replace placeholders with real content before exporting." : void 0
|
|
2733
|
+
),
|
|
2734
|
+
mk(
|
|
2735
|
+
"G2.urls-valid",
|
|
2736
|
+
"All URLs are well-formed",
|
|
2737
|
+
malformedUrls.length === 0 ? 1 : 1 - Math.min(1, malformedUrls.length / 3),
|
|
2738
|
+
malformedUrls.length === 0 ? "URLs look well-formed." : `${malformedUrls.length} URL(s) look broken.`,
|
|
2739
|
+
[],
|
|
2740
|
+
malformedUrls.length ? "Check each URL \u2014 typos break recruiter clicks." : void 0
|
|
2741
|
+
),
|
|
2742
|
+
mk(
|
|
2743
|
+
"G3.email-professional",
|
|
2744
|
+
"Email looks professional",
|
|
2745
|
+
emailUnprofessional ? 0 : 1,
|
|
2746
|
+
emailUnprofessional ? "Email contains casual / informal tokens." : "Email looks professional.",
|
|
2747
|
+
[],
|
|
2748
|
+
emailUnprofessional ? "Use a firstname.lastname@gmail.com style address for applications." : void 0
|
|
2749
|
+
),
|
|
2750
|
+
mk(
|
|
2751
|
+
"G4.linkedin-custom",
|
|
2752
|
+
"LinkedIn URL uses a custom slug",
|
|
2753
|
+
linkedinGeneric ? 0.5 : 1,
|
|
2754
|
+
linkedinGeneric ? "LinkedIn slug looks auto-generated (digits + hash)." : "LinkedIn URL is customised.",
|
|
2755
|
+
[],
|
|
2756
|
+
linkedinGeneric ? "Customise your LinkedIn URL (linkedin.com/in/yourname)." : void 0
|
|
2757
|
+
),
|
|
2758
|
+
mk(
|
|
2759
|
+
"G5.headline",
|
|
2760
|
+
"Has a headline / target title",
|
|
2761
|
+
hasHeadline ? 1 : 0.5,
|
|
2762
|
+
hasHeadline ? "Headline present." : "No headline \u2014 add a one-line title under your name.",
|
|
2763
|
+
[],
|
|
2764
|
+
!hasHeadline ? "Add a title (e.g. 'Senior Product Manager')." : void 0
|
|
2765
|
+
),
|
|
2766
|
+
mk(
|
|
2767
|
+
"G6.custom-portfolio",
|
|
2768
|
+
"Custom portfolio or personal site (nice-to-have)",
|
|
2769
|
+
hasCustomWebsite ? 1 : 0.6,
|
|
2770
|
+
hasCustomWebsite ? "Custom site present." : "No personal site listed.",
|
|
2771
|
+
[],
|
|
2772
|
+
hasCustomWebsite ? void 0 : "A custom domain (yourname.dev) lifts a senior resume \u2014 optional."
|
|
2773
|
+
)
|
|
2774
|
+
];
|
|
2775
|
+
}
|
|
2776
|
+
function mk(id, label, score, message, evidence, suggestion, severity) {
|
|
2777
|
+
const s = Math.max(0, Math.min(1, score));
|
|
2778
|
+
return {
|
|
2779
|
+
id,
|
|
2780
|
+
label,
|
|
2781
|
+
score: s,
|
|
2782
|
+
status: statusFromScore(s),
|
|
2783
|
+
message,
|
|
2784
|
+
evidence: evidence ?? [],
|
|
2785
|
+
suggestion: suggestion ?? null,
|
|
2786
|
+
severity: severity || "soft"
|
|
2787
|
+
};
|
|
2788
|
+
}
|
|
2789
|
+
function parseMMYYYY(d) {
|
|
2790
|
+
if (!d) return null;
|
|
2791
|
+
const m = d.match(/^(\d{1,2})\/(\d{4})$/);
|
|
2792
|
+
if (m) return parseInt(m[2], 10) * 12 + parseInt(m[1], 10);
|
|
2793
|
+
const y = d.match(/^(\d{4})$/);
|
|
2794
|
+
if (y) return parseInt(y[1], 10) * 12;
|
|
2795
|
+
const mn = d.match(/^([A-Za-z]+)\s+(\d{4})$/);
|
|
2796
|
+
if (mn) {
|
|
2797
|
+
const months = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
|
|
2798
|
+
const i = months.findIndex((x) => mn[1].toLowerCase().startsWith(x));
|
|
2799
|
+
if (i >= 0) return parseInt(mn[2], 10) * 12 + (i + 1);
|
|
2800
|
+
}
|
|
2801
|
+
return null;
|
|
2802
|
+
}
|
|
2803
|
+
function experienceYears(resume) {
|
|
2804
|
+
const expSec = resume.sections.find((s) => s.type === "experience");
|
|
2805
|
+
if (!expSec) return 0;
|
|
2806
|
+
let months = 0;
|
|
2807
|
+
for (const it of expSec.items || []) {
|
|
2808
|
+
const s = parseMMYYYY(it.startDate || "");
|
|
2809
|
+
const e = it.current ? (/* @__PURE__ */ new Date()).getFullYear() * 12 + ((/* @__PURE__ */ new Date()).getMonth() + 1) : parseMMYYYY(it.endDate || "");
|
|
2810
|
+
if (s && e && e >= s) months += e - s;
|
|
2811
|
+
}
|
|
2812
|
+
return Math.floor(months / 12);
|
|
2813
|
+
}
|
|
2814
|
+
function aggregate(checks, weight) {
|
|
2815
|
+
if (!checks.length) return { score: 0, weighted: 0 };
|
|
2816
|
+
const sum = checks.reduce((a, c) => a + c.score, 0);
|
|
2817
|
+
const score = Math.round(sum / checks.length * 100);
|
|
2818
|
+
return { score, weighted: sum / checks.length * weight };
|
|
2819
|
+
}
|
|
2820
|
+
var WEIGHTS = {
|
|
2821
|
+
impact: 22,
|
|
2822
|
+
brevity: 12,
|
|
2823
|
+
style: 14,
|
|
2824
|
+
structure: 12,
|
|
2825
|
+
ats: 18,
|
|
2826
|
+
skills: 15,
|
|
2827
|
+
polish: 7
|
|
2828
|
+
};
|
|
2829
|
+
var LABELS = {
|
|
2830
|
+
impact: "Impact & Quantification",
|
|
2831
|
+
brevity: "Brevity & Density",
|
|
2832
|
+
style: "Style & Linguistics",
|
|
2833
|
+
structure: "Structure & Sections",
|
|
2834
|
+
ats: "ATS Parsability",
|
|
2835
|
+
skills: "Skills & Keywords",
|
|
2836
|
+
polish: "Completeness & Polish"
|
|
2837
|
+
};
|
|
2838
|
+
function scoreResume(resume) {
|
|
2839
|
+
const b = gather(resume);
|
|
2840
|
+
const dims = [
|
|
2841
|
+
{ dim: "impact", checks: impactChecks(resume, b) },
|
|
2842
|
+
{ dim: "brevity", checks: brevityChecks(resume, b) },
|
|
2843
|
+
{ dim: "style", checks: styleChecks(resume, b) },
|
|
2844
|
+
{ dim: "structure", checks: structureChecks(resume, b) },
|
|
2845
|
+
{ dim: "ats", checks: atsChecks(resume, b) },
|
|
2846
|
+
{ dim: "skills", checks: skillsChecks(resume, b) },
|
|
2847
|
+
{ dim: "polish", checks: polishChecks(resume, b) }
|
|
2848
|
+
];
|
|
2849
|
+
for (const { dim, check } of additionalChecks(resume)) {
|
|
2850
|
+
const row = dims.find((d) => d.dim === dim);
|
|
2851
|
+
if (row) row.checks.push(check);
|
|
2852
|
+
}
|
|
2853
|
+
const totalWeight = Object.values(WEIGHTS).reduce((a, b2) => a + b2, 0);
|
|
2854
|
+
let weightedSum = 0;
|
|
2855
|
+
const dimensions = dims.map(({ dim, checks }) => {
|
|
2856
|
+
const agg = aggregate(checks, WEIGHTS[dim]);
|
|
2857
|
+
weightedSum += agg.weighted;
|
|
2858
|
+
return {
|
|
2859
|
+
dimension: dim,
|
|
2860
|
+
label: LABELS[dim],
|
|
2861
|
+
weight: WEIGHTS[dim],
|
|
2862
|
+
score: agg.score,
|
|
2863
|
+
band: band(agg.score),
|
|
2864
|
+
checks
|
|
2865
|
+
};
|
|
2866
|
+
});
|
|
2867
|
+
let overall = Math.round(weightedSum / totalWeight * 100);
|
|
2868
|
+
const hardFail = dimensions.some((d) => d.checks.some((c) => c.severity === "hard" && c.status === "fail"));
|
|
2869
|
+
if (hardFail) overall = Math.min(overall, 60);
|
|
2870
|
+
const sortedDims = [...dimensions].sort((a, b2) => a.score - b2.score);
|
|
2871
|
+
const worst = sortedDims[0];
|
|
2872
|
+
const best = sortedDims[sortedDims.length - 1];
|
|
2873
|
+
const allFails = dimensions.flatMap((d) => d.checks.map((c) => ({ ...c, dim: d.dimension, dimLabel: d.label, weight: d.weight }))).filter((c) => c.suggestion && c.status !== "pass").sort((a, b2) => a.score - b2.score);
|
|
2874
|
+
const priorities = allFails.slice(0, 5);
|
|
2875
|
+
return {
|
|
2876
|
+
overall,
|
|
2877
|
+
band: band(overall),
|
|
2878
|
+
dimensions,
|
|
2879
|
+
summary: `Strongest: ${best.label} (${best.score}). Focus area: ${worst.label} (${worst.score}).`,
|
|
2880
|
+
priorities,
|
|
2881
|
+
hardFails: dimensions.flatMap((d) => d.checks.filter((c) => c.severity === "hard" && c.status === "fail").map((c) => ({ ...c, dim: d.dimension }))),
|
|
2882
|
+
stats: {
|
|
2883
|
+
totalBullets: b.bullets.length,
|
|
2884
|
+
experienceBullets: b.expBullets.length,
|
|
2885
|
+
experienceYears: experienceYears(resume),
|
|
2886
|
+
totalWords: b.bullets.reduce((a, x) => a + wordCount(x.text), 0)
|
|
2887
|
+
}
|
|
2888
|
+
};
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
// src/storage.ts
|
|
2892
|
+
import { promises as fs } from "fs";
|
|
2893
|
+
import path from "path";
|
|
2894
|
+
var ROOT = path.resolve(process.env.JUICED_DATA_DIR || path.join(process.cwd(), "../..", "data"));
|
|
2895
|
+
var RESUMES = path.join(ROOT, "resumes");
|
|
2896
|
+
var VERSIONS = path.join(ROOT, "versions");
|
|
2897
|
+
async function ensure(dir) {
|
|
2898
|
+
await fs.mkdir(dir, { recursive: true });
|
|
2899
|
+
}
|
|
2900
|
+
async function listResumes() {
|
|
2901
|
+
await ensure(RESUMES);
|
|
2902
|
+
const files = await fs.readdir(RESUMES);
|
|
2903
|
+
const out = [];
|
|
2904
|
+
for (const f of files) {
|
|
2905
|
+
if (!f.endsWith(".json")) continue;
|
|
2906
|
+
try {
|
|
2907
|
+
const raw = await fs.readFile(path.join(RESUMES, f), "utf8");
|
|
2908
|
+
out.push(Resume.parse(JSON.parse(raw)));
|
|
2909
|
+
} catch {
|
|
2910
|
+
}
|
|
2911
|
+
}
|
|
2912
|
+
return out.sort((a, b) => a.updatedAt < b.updatedAt ? 1 : -1);
|
|
2913
|
+
}
|
|
2914
|
+
async function getResume(id) {
|
|
2915
|
+
try {
|
|
2916
|
+
const raw = await fs.readFile(path.join(RESUMES, `${id}.json`), "utf8");
|
|
2917
|
+
return Resume.parse(JSON.parse(raw));
|
|
2918
|
+
} catch {
|
|
2919
|
+
return null;
|
|
2920
|
+
}
|
|
2921
|
+
}
|
|
2922
|
+
async function saveResume(r) {
|
|
2923
|
+
await ensure(RESUMES);
|
|
2924
|
+
await ensure(path.join(VERSIONS, r.id));
|
|
2925
|
+
const next = { ...r, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
2926
|
+
const file = path.join(RESUMES, `${r.id}.json`);
|
|
2927
|
+
try {
|
|
2928
|
+
const prev = await fs.readFile(file, "utf8");
|
|
2929
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
2930
|
+
await fs.writeFile(path.join(VERSIONS, r.id, `${ts}.json`), prev, "utf8");
|
|
2931
|
+
} catch {
|
|
2932
|
+
}
|
|
2933
|
+
await fs.writeFile(file, JSON.stringify(next, null, 2), "utf8");
|
|
2934
|
+
return next;
|
|
2935
|
+
}
|
|
2936
|
+
async function createBlankResume(name = "Untitled") {
|
|
2937
|
+
const blank = Resume.parse({
|
|
2938
|
+
id: newId(),
|
|
2939
|
+
name,
|
|
2940
|
+
personal: {},
|
|
2941
|
+
sections: [],
|
|
2942
|
+
styling: {},
|
|
2943
|
+
locale: {}
|
|
2944
|
+
});
|
|
2945
|
+
return await saveResume(blank);
|
|
2946
|
+
}
|
|
2947
|
+
async function deleteResume(id) {
|
|
2948
|
+
try {
|
|
2949
|
+
await fs.unlink(path.join(RESUMES, `${id}.json`));
|
|
2950
|
+
} catch {
|
|
2951
|
+
}
|
|
2952
|
+
}
|
|
2953
|
+
|
|
2954
|
+
// src/latex.ts
|
|
2955
|
+
function resumeToLatex(r) {
|
|
2956
|
+
const accent = sanitizeColor(r.styling.accent || "#1a1a1a");
|
|
2957
|
+
const p = r.personal;
|
|
2958
|
+
const head = `\\documentclass[${r.locale.pageFormat === "Letter" ? "letterpaper" : "a4paper"},11pt]{article}
|
|
2959
|
+
\\usepackage[T1]{fontenc}
|
|
2960
|
+
\\usepackage[utf8]{inputenc}
|
|
2961
|
+
\\usepackage{textcomp}
|
|
2962
|
+
\\usepackage[margin=0.6in]{geometry}
|
|
2963
|
+
\\usepackage{enumitem}
|
|
2964
|
+
\\usepackage{titlesec}
|
|
2965
|
+
\\usepackage{hyperref}
|
|
2966
|
+
\\usepackage{xcolor}
|
|
2967
|
+
\\usepackage{parskip}
|
|
2968
|
+
\\providecommand{\\rupee}{Rs.}
|
|
2969
|
+
\\definecolor{accent}{HTML}{${accent}}
|
|
2970
|
+
\\hypersetup{colorlinks=true, urlcolor=accent, linkcolor=accent}
|
|
2971
|
+
\\titleformat{\\section}{\\Large\\bfseries\\color{accent}}{}{0pt}{}[\\titlerule]
|
|
2972
|
+
\\titlespacing*{\\section}{0pt}{8pt}{4pt}
|
|
2973
|
+
\\setlist[itemize]{leftmargin=*, topsep=2pt, itemsep=1pt, parsep=0pt}
|
|
2974
|
+
\\pagenumbering{gobble}
|
|
2975
|
+
\\begin{document}
|
|
2976
|
+
`;
|
|
2977
|
+
const header = `\\begin{center}
|
|
2978
|
+
{\\LARGE \\textbf{${tex(p.fullName || "Your Name")}}}\\\\[2pt]
|
|
2979
|
+
${p.title ? `{\\itshape ${tex(p.title)}}\\\\[2pt]` : ""}
|
|
2980
|
+
${[
|
|
2981
|
+
p.email && `\\href{mailto:${p.email}}{${tex(p.email)}}`,
|
|
2982
|
+
p.phone && tex(p.phone),
|
|
2983
|
+
p.location && tex(p.location),
|
|
2984
|
+
p.linkedin && hrefHandle(p.linkedin),
|
|
2985
|
+
p.github && hrefHandle(p.github),
|
|
2986
|
+
p.website && hrefHandle(p.website)
|
|
2987
|
+
].filter(Boolean).join(" \\textbullet{} ")}
|
|
2988
|
+
\\end{center}
|
|
2989
|
+
\\vspace{4pt}
|
|
2990
|
+
`;
|
|
2991
|
+
const body = r.sections.filter((s) => s.visible !== false).map((s) => renderSection(s, r)).join("\n");
|
|
2992
|
+
const tail = `
|
|
2993
|
+
\\end{document}
|
|
2994
|
+
`;
|
|
2995
|
+
return head + header + body + tail;
|
|
2996
|
+
}
|
|
2997
|
+
function renderSection(s, r) {
|
|
2998
|
+
const items = s.items.filter((i) => i.visible !== false);
|
|
2999
|
+
if (!items.length) return "";
|
|
3000
|
+
const header = `\\section*{${tex(s.title)}}`;
|
|
3001
|
+
switch (s.type) {
|
|
3002
|
+
case "summary":
|
|
3003
|
+
return `${header}
|
|
3004
|
+
${items.map((i) => htmlToTex(i.body)).join("\n\n")}
|
|
3005
|
+
`;
|
|
3006
|
+
case "experience":
|
|
3007
|
+
return `${header}
|
|
3008
|
+
${items.map((i) => `
|
|
3009
|
+
\\noindent\\textbf{${tex(i.jobTitle)}}\\hfill ${tex(dateRange(i.startDate, i.endDate, i.current, r))}\\\\
|
|
3010
|
+
{\\itshape\\color{accent}${tex(i.employer)}}${i.location ? ` \\textbullet{} ${tex(i.location)}` : ""}\\\\
|
|
3011
|
+
${htmlToTex(i.description)}
|
|
3012
|
+
`).join("\n\\vspace{4pt}\n")}
|
|
3013
|
+
`;
|
|
3014
|
+
case "education":
|
|
3015
|
+
return `${header}
|
|
3016
|
+
${items.map((i) => `
|
|
3017
|
+
\\noindent\\textbf{${tex(i.degree)}${i.field ? `, ${tex(i.field)}` : ""}}\\hfill ${tex(dateRange(i.startDate, i.endDate, false, r))}\\\\
|
|
3018
|
+
{\\itshape\\color{accent}${tex(i.school)}}${i.location ? ` \\textbullet{} ${tex(i.location)}` : ""}\\\\
|
|
3019
|
+
${i.description ? htmlToTex(i.description) : ""}
|
|
3020
|
+
`).join("\n")}
|
|
3021
|
+
`;
|
|
3022
|
+
case "projects":
|
|
3023
|
+
return `${header}
|
|
3024
|
+
${items.map((i) => `
|
|
3025
|
+
\\noindent\\textbf{${tex(i.name)}}${i.role ? ` --- ${tex(i.role)}` : ""}\\hfill ${tex(dateRange(i.startDate, i.endDate, false, r))}\\\\
|
|
3026
|
+
${i.link ? `\\href{${ensureProto(i.link)}}{${tex(i.link)}}\\\\` : ""}
|
|
3027
|
+
${htmlToTex(i.description)}
|
|
3028
|
+
`).join("\n")}
|
|
3029
|
+
`;
|
|
3030
|
+
case "skills":
|
|
3031
|
+
return `${header}
|
|
3032
|
+
${items.map((i) => `
|
|
3033
|
+
\\noindent\\textbf{${tex(i.category || "Skills")}:} ${tex(i.skills || "")}\\\\
|
|
3034
|
+
`).join("\n")}
|
|
3035
|
+
`;
|
|
3036
|
+
case "languages":
|
|
3037
|
+
return `${header}
|
|
3038
|
+
\\noindent ${items.map(
|
|
3039
|
+
(i) => `\\textbf{${tex(i.name)}}${i.level ? ` (${tex(i.level)})` : ""}`
|
|
3040
|
+
).join(" \\textbullet{} ")}\\\\
|
|
3041
|
+
`;
|
|
3042
|
+
case "certificates":
|
|
3043
|
+
return `${header}
|
|
3044
|
+
\\begin{itemize}
|
|
3045
|
+
${items.map(
|
|
3046
|
+
(i) => ` \\item \\textbf{${tex(i.name)}}${i.issuer ? ` --- ${tex(i.issuer)}` : ""}${i.date ? ` (${tex(i.date)})` : ""}${i.link ? ` \\href{${ensureProto(i.link)}}{link}` : ""}`
|
|
3047
|
+
).join("\n")}
|
|
3048
|
+
\\end{itemize}
|
|
3049
|
+
`;
|
|
3050
|
+
case "interests":
|
|
3051
|
+
return `${header}
|
|
3052
|
+
\\noindent ${items.map((i) => tex(i.name)).join(" \\textbullet{} ")}\\\\
|
|
3053
|
+
`;
|
|
3054
|
+
case "courses":
|
|
3055
|
+
return `${header}
|
|
3056
|
+
\\begin{itemize}
|
|
3057
|
+
${items.map(
|
|
3058
|
+
(i) => ` \\item \\textbf{${tex(i.name)}}${i.institution ? ` --- ${tex(i.institution)}` : ""}${i.date ? ` (${tex(i.date)})` : ""}`
|
|
3059
|
+
).join("\n")}
|
|
3060
|
+
\\end{itemize}
|
|
3061
|
+
`;
|
|
3062
|
+
case "awards":
|
|
3063
|
+
return `${header}
|
|
3064
|
+
${items.map((i) => `
|
|
3065
|
+
\\noindent\\textbf{${tex(i.name)}}${i.issuer ? ` --- ${tex(i.issuer)}` : ""}${i.date ? ` \\hfill ${tex(i.date)}` : ""}\\\\
|
|
3066
|
+
${i.description ? htmlToTex(i.description) : ""}
|
|
3067
|
+
`).join("\n")}
|
|
3068
|
+
`;
|
|
3069
|
+
case "organisations":
|
|
3070
|
+
return `${header}
|
|
3071
|
+
${items.map((i) => `
|
|
3072
|
+
\\noindent\\textbf{${tex(i.name)}}${i.role ? ` --- ${tex(i.role)}` : ""}\\hfill ${tex(dateRange(i.startDate, i.endDate, false, r))}\\\\
|
|
3073
|
+
${i.description ? htmlToTex(i.description) : ""}
|
|
3074
|
+
`).join("\n")}
|
|
3075
|
+
`;
|
|
3076
|
+
case "publications":
|
|
3077
|
+
return `${header}
|
|
3078
|
+
${items.map((i) => `
|
|
3079
|
+
\\noindent\\textbf{${tex(i.title)}}${i.publisher ? ` --- ${tex(i.publisher)}` : ""}${i.date ? `\\hfill ${tex(i.date)}` : ""}\\\\
|
|
3080
|
+
${i.link ? `\\href{${ensureProto(i.link)}}{${tex(i.link)}}\\\\` : ""}
|
|
3081
|
+
${i.description ? htmlToTex(i.description) : ""}
|
|
3082
|
+
`).join("\n")}
|
|
3083
|
+
`;
|
|
3084
|
+
case "references":
|
|
3085
|
+
return `${header}
|
|
3086
|
+
${items.map((i) => `
|
|
3087
|
+
\\noindent\\textbf{${tex(i.name)}}${i.relationship ? `, ${tex(i.relationship)}` : ""}${i.company ? ` (${tex(i.company)})` : ""}\\\\
|
|
3088
|
+
${[i.email, i.phone].filter(Boolean).map(tex).join(" \\textbullet{} ")}
|
|
3089
|
+
`).join("\n")}
|
|
3090
|
+
`;
|
|
3091
|
+
case "declaration":
|
|
3092
|
+
return `${header}
|
|
3093
|
+
${items.map((i) => htmlToTex(i.body)).join("\n")}${items[0]?.signedAt ? `
|
|
3094
|
+
\\vspace{6pt}\\noindent ${tex(items[0].signedAt)}` : ""}
|
|
3095
|
+
`;
|
|
3096
|
+
case "custom":
|
|
3097
|
+
return `${header}
|
|
3098
|
+
${items.map((i) => `
|
|
3099
|
+
\\noindent\\textbf{${tex(i.title)}}${i.subtitle ? ` --- ${tex(i.subtitle)}` : ""}${i.date ? `\\hfill ${tex(i.date)}` : ""}\\\\
|
|
3100
|
+
${i.description ? htmlToTex(i.description) : ""}
|
|
3101
|
+
`).join("\n")}
|
|
3102
|
+
`;
|
|
3103
|
+
}
|
|
3104
|
+
}
|
|
3105
|
+
function tex(s) {
|
|
3106
|
+
if (!s) return "";
|
|
3107
|
+
return s.replace(/\\/g, "\\textbackslash{}").replace(/[$&%#_{}]/g, (c) => "\\" + c).replace(/~/g, "\\textasciitilde{}").replace(/\^/g, "\\textasciicircum{}").replace(/</g, "\\textless{}").replace(/>/g, "\\textgreater{}").replace(/—/g, "---").replace(/–/g, "--").replace(/[“”]/g, '"').replace(/[‘’]/g, "'").replace(/…/g, "\\ldots{}").replace(/•/g, "\\textbullet{}").replace(/●|■|▪|▶|►|·/g, "\\textbullet{}").replace(/→|➔|➜|⇒/g, "$\\rightarrow$").replace(/←|⇐/g, "$\\leftarrow$").replace(/↑|⇑/g, "$\\uparrow$").replace(/↓|⇓/g, "$\\downarrow$").replace(/₹/g, "\\rupee{}").replace(/€/g, "\\texteuro{}").replace(/£/g, "\\pounds{}").replace(/¥/g, "\\textyen{}").replace(/×/g, "$\\times$").replace(/÷/g, "$\\div$").replace(/±/g, "$\\pm$").replace(/©/g, "\\textcopyright{}").replace(/®/g, "\\textregistered{}").replace(/™/g, "\\texttrademark{}").replace(/§/g, "\\S{}").replace(/°/g, "$^{\\circ}$");
|
|
3108
|
+
}
|
|
3109
|
+
function inlineHtmlToTex(html) {
|
|
3110
|
+
let out = "";
|
|
3111
|
+
let i = 0;
|
|
3112
|
+
const src = html;
|
|
3113
|
+
while (i < src.length) {
|
|
3114
|
+
if (src[i] !== "<") {
|
|
3115
|
+
const next = src.indexOf("<", i);
|
|
3116
|
+
const chunk = next === -1 ? src.slice(i) : src.slice(i, next);
|
|
3117
|
+
out += tex(decodeEntities(chunk));
|
|
3118
|
+
if (next === -1) break;
|
|
3119
|
+
i = next;
|
|
3120
|
+
continue;
|
|
3121
|
+
}
|
|
3122
|
+
const close = src.indexOf(">", i);
|
|
3123
|
+
if (close === -1) break;
|
|
3124
|
+
const tagSrc = src.slice(i, close + 1);
|
|
3125
|
+
let m;
|
|
3126
|
+
if (m = tagSrc.match(/^<(strong|b)\b/i)) {
|
|
3127
|
+
const end = findCloseTag(src, close + 1, m[1]);
|
|
3128
|
+
const inner = src.slice(close + 1, end.start);
|
|
3129
|
+
out += `\\textbf{${inlineHtmlToTex(inner)}}`;
|
|
3130
|
+
i = end.after;
|
|
3131
|
+
continue;
|
|
3132
|
+
}
|
|
3133
|
+
if (m = tagSrc.match(/^<(em|i)\b/i)) {
|
|
3134
|
+
const end = findCloseTag(src, close + 1, m[1]);
|
|
3135
|
+
const inner = src.slice(close + 1, end.start);
|
|
3136
|
+
out += `\\emph{${inlineHtmlToTex(inner)}}`;
|
|
3137
|
+
i = end.after;
|
|
3138
|
+
continue;
|
|
3139
|
+
}
|
|
3140
|
+
if (tagSrc.match(/^<u\b/i)) {
|
|
3141
|
+
const end = findCloseTag(src, close + 1, "u");
|
|
3142
|
+
const inner = src.slice(close + 1, end.start);
|
|
3143
|
+
out += `\\underline{${inlineHtmlToTex(inner)}}`;
|
|
3144
|
+
i = end.after;
|
|
3145
|
+
continue;
|
|
3146
|
+
}
|
|
3147
|
+
if (m = tagSrc.match(/^<a\b[^>]*href="([^"]+)"/i)) {
|
|
3148
|
+
const href = m[1];
|
|
3149
|
+
const end = findCloseTag(src, close + 1, "a");
|
|
3150
|
+
const inner = src.slice(close + 1, end.start);
|
|
3151
|
+
out += `\\href{${href}}{${inlineHtmlToTex(inner)}}`;
|
|
3152
|
+
i = end.after;
|
|
3153
|
+
continue;
|
|
3154
|
+
}
|
|
3155
|
+
i = close + 1;
|
|
3156
|
+
}
|
|
3157
|
+
return out;
|
|
3158
|
+
}
|
|
3159
|
+
function findCloseTag(src, from, tag) {
|
|
3160
|
+
const re = new RegExp(`</${tag}\\s*>`, "i");
|
|
3161
|
+
const m = src.slice(from).match(re);
|
|
3162
|
+
if (!m) return { start: src.length, after: src.length };
|
|
3163
|
+
const start = from + (m.index || 0);
|
|
3164
|
+
return { start, after: start + m[0].length };
|
|
3165
|
+
}
|
|
3166
|
+
function decodeEntities(s) {
|
|
3167
|
+
return s.replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'|'/g, "'");
|
|
3168
|
+
}
|
|
3169
|
+
function htmlToTex(html) {
|
|
3170
|
+
if (!html) return "";
|
|
3171
|
+
const liMatches = Array.from(html.matchAll(/<li[^>]*>([\s\S]*?)<\/li>/gi));
|
|
3172
|
+
if (liMatches.length) {
|
|
3173
|
+
const items = liMatches.map((m) => ` \\item ${inlineHtmlToTex(m[1])}`);
|
|
3174
|
+
const withoutLi = html.replace(/<ul[^>]*>[\s\S]*?<\/ul>/gi, "");
|
|
3175
|
+
const segments = withoutLi.split("");
|
|
3176
|
+
const renderPara = (chunk) => chunk.split(/<\/?p[^>]*>/gi).map((c) => inlineHtmlToTex(c).trim()).filter(Boolean).join("\\\\\n");
|
|
3177
|
+
const before = renderPara(segments[0] || "");
|
|
3178
|
+
const after = renderPara(segments.slice(1).join("\n"));
|
|
3179
|
+
return `${before ? before + "\n" : ""}\\begin{itemize}
|
|
3180
|
+
${items.join("\n")}
|
|
3181
|
+
\\end{itemize}
|
|
3182
|
+
${after}`;
|
|
3183
|
+
}
|
|
3184
|
+
return html.split(/<\/?p[^>]*>/gi).map((c) => inlineHtmlToTex(c).trim()).filter(Boolean).join("\\\\\n");
|
|
3185
|
+
}
|
|
3186
|
+
function sanitizeColor(hex) {
|
|
3187
|
+
return hex.replace(/^#/, "").slice(0, 6).padEnd(6, "0");
|
|
3188
|
+
}
|
|
3189
|
+
function hrefHandle(s) {
|
|
3190
|
+
return `\\href{${ensureProto(s)}}{${tex(s)}}`;
|
|
3191
|
+
}
|
|
3192
|
+
function ensureProto(s) {
|
|
3193
|
+
if (/^https?:\/\//i.test(s)) return s;
|
|
3194
|
+
return `https://${s.replace(/^\/+/, "")}`;
|
|
3195
|
+
}
|
|
3196
|
+
function dateRange(start, end, current, r) {
|
|
3197
|
+
function f(d) {
|
|
3198
|
+
if (!d) return "";
|
|
3199
|
+
const m = d.match(/^(\d{1,2})\/(\d{4})$/);
|
|
3200
|
+
if (m) {
|
|
3201
|
+
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
3202
|
+
const idx = Math.max(0, Math.min(11, parseInt(m[1], 10) - 1));
|
|
3203
|
+
if (r.locale.dateFormat === "MM/YYYY") return `${m[1].padStart(2, "0")}/${m[2]}`;
|
|
3204
|
+
if (r.locale.dateFormat === "YYYY") return m[2];
|
|
3205
|
+
return `${months[idx]} ${m[2]}`;
|
|
3206
|
+
}
|
|
3207
|
+
return d;
|
|
3208
|
+
}
|
|
3209
|
+
const a = f(start);
|
|
3210
|
+
const b = current ? "Present" : f(end);
|
|
3211
|
+
if (a && b) return `${a} -- ${b}`;
|
|
3212
|
+
return a || b || "";
|
|
3213
|
+
}
|
|
3214
|
+
|
|
3215
|
+
// src/latexCompile.ts
|
|
3216
|
+
import { spawn } from "child_process";
|
|
3217
|
+
import { promises as fs2 } from "fs";
|
|
3218
|
+
import os from "os";
|
|
3219
|
+
import path2 from "path";
|
|
3220
|
+
var ENGINES = ["tectonic", "xelatex", "pdflatex", "lualatex"];
|
|
3221
|
+
async function whichEngine() {
|
|
3222
|
+
for (const e of ENGINES) {
|
|
3223
|
+
if (await commandExists(e)) return e;
|
|
3224
|
+
}
|
|
3225
|
+
return null;
|
|
3226
|
+
}
|
|
3227
|
+
function commandExists(cmd) {
|
|
3228
|
+
return new Promise((resolve) => {
|
|
3229
|
+
const p = spawn("which", [cmd]);
|
|
3230
|
+
p.on("close", (code) => resolve(code === 0));
|
|
3231
|
+
p.on("error", () => resolve(false));
|
|
3232
|
+
});
|
|
3233
|
+
}
|
|
3234
|
+
async function compileLatex(tex2) {
|
|
3235
|
+
const engine = await whichEngine();
|
|
3236
|
+
if (!engine) {
|
|
3237
|
+
return {
|
|
3238
|
+
log: "",
|
|
3239
|
+
engine: "",
|
|
3240
|
+
error: "no_engine"
|
|
3241
|
+
};
|
|
3242
|
+
}
|
|
3243
|
+
const dir = await fs2.mkdtemp(path2.join(os.tmpdir(), "juiced-tex-"));
|
|
3244
|
+
const src = path2.join(dir, "resume.tex");
|
|
3245
|
+
await fs2.writeFile(src, tex2, "utf8");
|
|
3246
|
+
let cmd;
|
|
3247
|
+
let args;
|
|
3248
|
+
if (engine === "tectonic") {
|
|
3249
|
+
cmd = "tectonic";
|
|
3250
|
+
args = ["--keep-logs", "--outdir", dir, src];
|
|
3251
|
+
} else {
|
|
3252
|
+
cmd = engine;
|
|
3253
|
+
args = ["-interaction=nonstopmode", "-halt-on-error", "-output-directory", dir, src];
|
|
3254
|
+
}
|
|
3255
|
+
const log = await runOnce(cmd, args, dir);
|
|
3256
|
+
if (engine !== "tectonic") await runOnce(cmd, args, dir);
|
|
3257
|
+
const pdfPath = path2.join(dir, "resume.pdf");
|
|
3258
|
+
try {
|
|
3259
|
+
const pdf = await fs2.readFile(pdfPath);
|
|
3260
|
+
return { pdf, log, engine };
|
|
3261
|
+
} catch {
|
|
3262
|
+
return { log, engine, error: "no_pdf_produced" };
|
|
3263
|
+
} finally {
|
|
3264
|
+
fs2.rm(dir, { recursive: true, force: true }).catch(() => {
|
|
3265
|
+
});
|
|
3266
|
+
}
|
|
3267
|
+
}
|
|
3268
|
+
function runOnce(cmd, args, cwd) {
|
|
3269
|
+
return new Promise((resolve) => {
|
|
3270
|
+
let out = "";
|
|
3271
|
+
const p = spawn(cmd, args, { cwd });
|
|
3272
|
+
p.stdout.on("data", (d) => out += d.toString());
|
|
3273
|
+
p.stderr.on("data", (d) => out += d.toString());
|
|
3274
|
+
p.on("close", () => resolve(out));
|
|
3275
|
+
p.on("error", (e) => resolve(out + "\n" + String(e)));
|
|
3276
|
+
});
|
|
3277
|
+
}
|
|
3278
|
+
|
|
3279
|
+
// src/index.ts
|
|
3280
|
+
import { promises as fs3 } from "fs";
|
|
3281
|
+
import path3 from "path";
|
|
3282
|
+
var server = new Server(
|
|
3283
|
+
{ name: "juiced-resume", version: "0.2.0" },
|
|
3284
|
+
{ capabilities: { tools: {} } }
|
|
3285
|
+
);
|
|
3286
|
+
var idArg = z2.object({ id: z2.string() });
|
|
3287
|
+
var ok = (data) => ({ content: [{ type: "text", text: typeof data === "string" ? data : JSON.stringify(data, null, 2) }] });
|
|
3288
|
+
async function mutate(id, fn) {
|
|
3289
|
+
const r = await getResume(id);
|
|
3290
|
+
if (!r) throw new Error(`Resume not found: ${id}`);
|
|
3291
|
+
const next = fn(r);
|
|
3292
|
+
return await saveResume(next);
|
|
3293
|
+
}
|
|
3294
|
+
var TOOLS = [
|
|
3295
|
+
// discovery
|
|
3296
|
+
{ name: "list_resumes", description: "List all resumes.", inputSchema: { type: "object", properties: {}, additionalProperties: false } },
|
|
3297
|
+
{ name: "get_resume", description: "Get a resume by id.", inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] } },
|
|
3298
|
+
{ name: "create_resume", description: "Create a blank resume.", inputSchema: { type: "object", properties: { name: { type: "string" } }, additionalProperties: false } },
|
|
3299
|
+
{ name: "delete_resume", description: "Delete a resume.", inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] } },
|
|
3300
|
+
{ name: "duplicate_resume", description: "Duplicate a resume.", inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] } },
|
|
3301
|
+
// personal info / summary
|
|
3302
|
+
{ name: "set_personal_info", description: "Patch personal info (only included keys are updated).", inputSchema: { type: "object", properties: {
|
|
3303
|
+
id: { type: "string" },
|
|
3304
|
+
patch: { type: "object" }
|
|
3305
|
+
}, required: ["id", "patch"] } },
|
|
3306
|
+
{ name: "set_summary", description: "Replace the Summary section's body HTML.", inputSchema: { type: "object", properties: { id: { type: "string" }, html: { type: "string" } }, required: ["id", "html"] } },
|
|
3307
|
+
// sections
|
|
3308
|
+
{ name: "add_section", description: "Add a section by type. Type must be one of: summary, experience, education, skills, languages, certificates, interests, projects, courses, awards, organisations, publications, references, declaration, custom.", inputSchema: { type: "object", properties: { id: { type: "string" }, type: { type: "string" }, title: { type: "string" } }, required: ["id", "type"] } },
|
|
3309
|
+
{ name: "remove_section", description: "Remove a section by id.", inputSchema: { type: "object", properties: { id: { type: "string" }, sectionId: { type: "string" } }, required: ["id", "sectionId"] } },
|
|
3310
|
+
{ name: "reorder_sections", description: "Reorder sections by id list.", inputSchema: { type: "object", properties: { id: { type: "string" }, order: { type: "array", items: { type: "string" } } }, required: ["id", "order"] } },
|
|
3311
|
+
{ name: "rename_section", description: "Rename a section.", inputSchema: { type: "object", properties: { id: { type: "string" }, sectionId: { type: "string" }, title: { type: "string" } }, required: ["id", "sectionId", "title"] } },
|
|
3312
|
+
{ name: "toggle_section_visibility", description: "Toggle a section's visibility.", inputSchema: { type: "object", properties: { id: { type: "string" }, sectionId: { type: "string" }, visible: { type: "boolean" } }, required: ["id", "sectionId"] } },
|
|
3313
|
+
// entries
|
|
3314
|
+
{ name: "add_entry", description: "Add an entry to a section. `entry` is the partial item shape.", inputSchema: { type: "object", properties: { id: { type: "string" }, sectionId: { type: "string" }, entry: { type: "object" } }, required: ["id", "sectionId", "entry"] } },
|
|
3315
|
+
{ name: "update_entry", description: "Patch an entry.", inputSchema: { type: "object", properties: { id: { type: "string" }, sectionId: { type: "string" }, entryId: { type: "string" }, patch: { type: "object" } }, required: ["id", "sectionId", "entryId", "patch"] } },
|
|
3316
|
+
{ name: "remove_entry", description: "Remove an entry.", inputSchema: { type: "object", properties: { id: { type: "string" }, sectionId: { type: "string" }, entryId: { type: "string" } }, required: ["id", "sectionId", "entryId"] } },
|
|
3317
|
+
// styling
|
|
3318
|
+
{ name: "set_template", description: "Switch template.", inputSchema: { type: "object", properties: { id: { type: "string" }, template: { type: "string" } }, required: ["id", "template"] } },
|
|
3319
|
+
{ name: "set_styling", description: "Patch styling (accent, fonts, spacing, layout, photo, \u2026).", inputSchema: { type: "object", properties: { id: { type: "string" }, patch: { type: "object" } }, required: ["id", "patch"] } },
|
|
3320
|
+
{ name: "set_locale", description: "Patch locale (language, dateFormat, pageFormat).", inputSchema: { type: "object", properties: { id: { type: "string" }, patch: { type: "object" } }, required: ["id", "patch"] } },
|
|
3321
|
+
// scoring + tailoring
|
|
3322
|
+
{ name: "score_resume", description: "Run Resume Worded-style scoring.", inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] } },
|
|
3323
|
+
{ name: "tailor_to_jd", description: "Match resume against a JD; returns relevancy score and missing keywords.", inputSchema: { type: "object", properties: { id: { type: "string" }, jd: { type: "string" } }, required: ["id", "jd"] } },
|
|
3324
|
+
// libraries
|
|
3325
|
+
{ name: "verb_lookup", description: "Return action verbs for a category. Categories: accomplishment, communication, entrepreneurial, executive, leadership, research, problem-solving, process-improvement, financial, design, administrative, engineering.", inputSchema: { type: "object", properties: { category: { type: "string" } }, required: ["category"] } },
|
|
3326
|
+
{ name: "skill_search", description: "Fuzzy-search the skills dictionary.", inputSchema: { type: "object", properties: { query: { type: "string" }, limit: { type: "number" } }, required: ["query"] } },
|
|
3327
|
+
{ name: "title_search", description: "Fuzzy-search job titles.", inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] } },
|
|
3328
|
+
{ name: "company_search", description: "Fuzzy-search company names.", inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] } },
|
|
3329
|
+
// meta
|
|
3330
|
+
{ name: "templates_list", description: "List installed templates with categories.", inputSchema: { type: "object", properties: {}, additionalProperties: false } },
|
|
3331
|
+
// export
|
|
3332
|
+
{ name: "export_latex", description: "Render a resume to LaTeX source (string). Optionally save to outPath.", inputSchema: { type: "object", properties: { id: { type: "string" }, outPath: { type: "string" } }, required: ["id"] } },
|
|
3333
|
+
{ name: "export_ats_pdf", description: "Compile a resume to an ATS-friendly PDF via LaTeX. Saves to outPath if given, otherwise returns a base64 PDF.", inputSchema: { type: "object", properties: { id: { type: "string" }, outPath: { type: "string" } }, required: ["id"] } }
|
|
3334
|
+
];
|
|
3335
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
3336
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
3337
|
+
const name = req.params.name;
|
|
3338
|
+
const args = req.params.arguments ?? {};
|
|
3339
|
+
try {
|
|
3340
|
+
switch (name) {
|
|
3341
|
+
case "list_resumes":
|
|
3342
|
+
return ok(await listResumes());
|
|
3343
|
+
case "get_resume":
|
|
3344
|
+
return ok(await getResume(args.id));
|
|
3345
|
+
case "create_resume":
|
|
3346
|
+
return ok(await createBlankResume(args.name || "Untitled"));
|
|
3347
|
+
case "delete_resume":
|
|
3348
|
+
await deleteResume(args.id);
|
|
3349
|
+
return ok({ ok: true });
|
|
3350
|
+
case "duplicate_resume": {
|
|
3351
|
+
const r = await getResume(args.id);
|
|
3352
|
+
if (!r) throw new Error("not found");
|
|
3353
|
+
return ok(await saveResume({ ...r, id: newId(), name: `${r.name} (Copy)`, createdAt: (/* @__PURE__ */ new Date()).toISOString() }));
|
|
3354
|
+
}
|
|
3355
|
+
case "set_personal_info":
|
|
3356
|
+
return ok(await mutate(args.id, (r) => ({ ...r, personal: { ...r.personal, ...args.patch || {} } })));
|
|
3357
|
+
case "set_summary":
|
|
3358
|
+
return ok(await mutate(args.id, (r) => {
|
|
3359
|
+
let sections = r.sections.slice();
|
|
3360
|
+
let sec = sections.find((s) => s.type === "summary");
|
|
3361
|
+
if (!sec) {
|
|
3362
|
+
sec = { id: newId(), type: "summary", title: DEFAULT_SECTION_TITLES.summary, visible: true, columns: 1, items: [] };
|
|
3363
|
+
sections.push(sec);
|
|
3364
|
+
}
|
|
3365
|
+
sec.items = [{ id: sec.items[0]?.id || newId(), visible: true, body: args.html }];
|
|
3366
|
+
return { ...r, sections };
|
|
3367
|
+
}));
|
|
3368
|
+
case "add_section":
|
|
3369
|
+
return ok(await mutate(args.id, (r) => {
|
|
3370
|
+
const type = args.type;
|
|
3371
|
+
if (!DEFAULT_SECTION_TITLES[type]) throw new Error(`unknown section type: ${type}`);
|
|
3372
|
+
const section = { id: newId(), type, title: args.title || DEFAULT_SECTION_TITLES[type], visible: true, columns: 1, items: [] };
|
|
3373
|
+
return { ...r, sections: [...r.sections, section] };
|
|
3374
|
+
}));
|
|
3375
|
+
case "remove_section":
|
|
3376
|
+
return ok(await mutate(args.id, (r) => ({ ...r, sections: r.sections.filter((s) => s.id !== args.sectionId) })));
|
|
3377
|
+
case "reorder_sections":
|
|
3378
|
+
return ok(await mutate(args.id, (r) => {
|
|
3379
|
+
const map = new Map(r.sections.map((s) => [s.id, s]));
|
|
3380
|
+
const ordered = args.order.map((id) => map.get(id)).filter(Boolean);
|
|
3381
|
+
const missing = r.sections.filter((s) => !args.order.includes(s.id));
|
|
3382
|
+
return { ...r, sections: [...ordered, ...missing] };
|
|
3383
|
+
}));
|
|
3384
|
+
case "rename_section":
|
|
3385
|
+
return ok(await mutate(args.id, (r) => ({ ...r, sections: r.sections.map((s) => s.id === args.sectionId ? { ...s, title: args.title } : s) })));
|
|
3386
|
+
case "toggle_section_visibility":
|
|
3387
|
+
return ok(await mutate(args.id, (r) => ({ ...r, sections: r.sections.map((s) => s.id === args.sectionId ? { ...s, visible: typeof args.visible === "boolean" ? args.visible : !s.visible } : s) })));
|
|
3388
|
+
case "add_entry":
|
|
3389
|
+
return ok(await mutate(args.id, (r) => ({
|
|
3390
|
+
...r,
|
|
3391
|
+
sections: r.sections.map((s) => s.id === args.sectionId ? { ...s, items: [...s.items, { id: newId(), visible: true, ...args.entry || {} }] } : s)
|
|
3392
|
+
})));
|
|
3393
|
+
case "update_entry":
|
|
3394
|
+
return ok(await mutate(args.id, (r) => ({
|
|
3395
|
+
...r,
|
|
3396
|
+
sections: r.sections.map((s) => s.id === args.sectionId ? { ...s, items: s.items.map((it) => it.id === args.entryId ? { ...it, ...args.patch || {} } : it) } : s)
|
|
3397
|
+
})));
|
|
3398
|
+
case "remove_entry":
|
|
3399
|
+
return ok(await mutate(args.id, (r) => ({
|
|
3400
|
+
...r,
|
|
3401
|
+
sections: r.sections.map((s) => s.id === args.sectionId ? { ...s, items: s.items.filter((it) => it.id !== args.entryId) } : s)
|
|
3402
|
+
})));
|
|
3403
|
+
case "set_template": {
|
|
3404
|
+
if (!TEMPLATE_IDS.includes(args.template)) throw new Error(`unknown template: ${args.template}. Available: ${TEMPLATE_IDS.join(", ")}`);
|
|
3405
|
+
return ok(await mutate(args.id, (r) => ({ ...r, styling: { ...r.styling, template: args.template } })));
|
|
3406
|
+
}
|
|
3407
|
+
case "set_styling":
|
|
3408
|
+
return ok(await mutate(args.id, (r) => ({ ...r, styling: { ...r.styling, ...args.patch || {} } })));
|
|
3409
|
+
case "set_locale":
|
|
3410
|
+
return ok(await mutate(args.id, (r) => ({ ...r, locale: { ...r.locale, ...args.patch || {} } })));
|
|
3411
|
+
case "score_resume": {
|
|
3412
|
+
const r = await getResume(args.id);
|
|
3413
|
+
if (!r) throw new Error("not found");
|
|
3414
|
+
return ok(scoreResume(r));
|
|
3415
|
+
}
|
|
3416
|
+
case "tailor_to_jd": {
|
|
3417
|
+
const r = await getResume(args.id);
|
|
3418
|
+
if (!r) throw new Error("not found");
|
|
3419
|
+
return ok(tailorToJob(r, String(args.jd || "")));
|
|
3420
|
+
}
|
|
3421
|
+
case "verb_lookup": {
|
|
3422
|
+
const c = args.category;
|
|
3423
|
+
const list = ACTION_VERBS[c];
|
|
3424
|
+
if (!list) throw new Error(`unknown verb category: ${args.category}. Try: ${Object.keys(ACTION_VERBS).join(", ")}`);
|
|
3425
|
+
return ok({ category: c, label: VERB_CATEGORY_LABEL[c], verbs: list });
|
|
3426
|
+
}
|
|
3427
|
+
case "skill_search":
|
|
3428
|
+
return ok(searchSkills(String(args.query || ""), args.limit ?? 12));
|
|
3429
|
+
case "title_search":
|
|
3430
|
+
return ok(searchTitles(String(args.query || "")));
|
|
3431
|
+
case "company_search":
|
|
3432
|
+
return ok(searchCompanies(String(args.query || "")));
|
|
3433
|
+
case "templates_list":
|
|
3434
|
+
return ok(TEMPLATE_IDS);
|
|
3435
|
+
case "export_latex": {
|
|
3436
|
+
const r = await getResume(args.id);
|
|
3437
|
+
if (!r) throw new Error("not found");
|
|
3438
|
+
const tex2 = resumeToLatex(r);
|
|
3439
|
+
if (args.outPath) {
|
|
3440
|
+
await fs3.mkdir(path3.dirname(args.outPath), { recursive: true });
|
|
3441
|
+
await fs3.writeFile(args.outPath, tex2, "utf8");
|
|
3442
|
+
return ok({ outPath: args.outPath, bytes: Buffer.byteLength(tex2, "utf8") });
|
|
3443
|
+
}
|
|
3444
|
+
return ok({ latex: tex2 });
|
|
3445
|
+
}
|
|
3446
|
+
case "export_ats_pdf": {
|
|
3447
|
+
const r = await getResume(args.id);
|
|
3448
|
+
if (!r) throw new Error("not found");
|
|
3449
|
+
const tex2 = resumeToLatex(r);
|
|
3450
|
+
const out = await compileLatex(tex2);
|
|
3451
|
+
if (!out.pdf) {
|
|
3452
|
+
if (out.error === "no_engine") throw new Error("No LaTeX engine found. Install one: `brew install tectonic` (recommended), or BasicTeX for pdflatex.");
|
|
3453
|
+
throw new Error(`Compile failed (${out.error || "unknown"}). Last log:
|
|
3454
|
+
${out.log.slice(-1500)}`);
|
|
3455
|
+
}
|
|
3456
|
+
if (args.outPath) {
|
|
3457
|
+
await fs3.mkdir(path3.dirname(args.outPath), { recursive: true });
|
|
3458
|
+
await fs3.writeFile(args.outPath, out.pdf);
|
|
3459
|
+
return ok({ outPath: args.outPath, engine: out.engine, bytes: out.pdf.length });
|
|
3460
|
+
}
|
|
3461
|
+
return ok({ engine: out.engine, bytes: out.pdf.length, pdfBase64: out.pdf.toString("base64") });
|
|
3462
|
+
}
|
|
3463
|
+
default:
|
|
3464
|
+
throw new Error(`unknown tool: ${name}`);
|
|
3465
|
+
}
|
|
3466
|
+
} catch (err) {
|
|
3467
|
+
return { isError: true, content: [{ type: "text", text: `Error: ${err.message || String(err)}` }] };
|
|
3468
|
+
}
|
|
3469
|
+
});
|
|
3470
|
+
var transport = new StdioServerTransport();
|
|
3471
|
+
await server.connect(transport);
|
|
3472
|
+
console.error("[juiced-mcp] ready");
|