ajustacv-resume-schema 0.1.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 ADDED
@@ -0,0 +1,142 @@
1
+ # ajustacv-resume-schema
2
+
3
+ The canonical `ResumeDocument` model (issue #69), shared by the AjustaCV
4
+ frontend and API so neither repo keeps its own copy.
5
+
6
+ Zod schemas are the source of truth. Every exported TypeScript type is inferred
7
+ from them with `z.infer`, so the runtime validator and the compile-time types
8
+ cannot drift apart.
9
+
10
+ ## Install
11
+
12
+ `ajustacv.com` consumes it through the Bun workspace and always builds from
13
+ source in this directory, so schema edits need no release:
14
+
15
+ ```json
16
+ { "dependencies": { "ajustacv-resume-schema": "workspace:*" } }
17
+ ```
18
+
19
+ `api.ajustacv.com` is a separate repo whose Dockerfile copies only its own
20
+ files, so it cannot reach this directory at build time and depends on the
21
+ published version instead:
22
+
23
+ ```json
24
+ { "dependencies": { "ajustacv-resume-schema": "^0.1.0" } }
25
+ ```
26
+
27
+ The published tarball is source-only and carries no rights (`UNLICENSED`) —
28
+ publishing keeps the API's Docker build credential-free, it does not make the
29
+ repo open source.
30
+
31
+ ## Usage
32
+
33
+ ```ts
34
+ import { validateResumeDocument, type ResumeDocument } from "ajustacv-resume-schema";
35
+
36
+ const result = validateResumeDocument(await request.json());
37
+ if (!result.ok) return { status: 400, issues: result.issues };
38
+
39
+ const doc: ResumeDocument = result.document;
40
+ ```
41
+
42
+ Sample documents live behind a separate entry point so they never reach the
43
+ frontend bundle:
44
+
45
+ ```ts
46
+ import { FULL_RESUME, RESUME_FIXTURES } from "ajustacv-resume-schema/fixtures";
47
+ ```
48
+
49
+ ## The model
50
+
51
+ ```
52
+ ResumeDocument
53
+ ├── header ResumeHeader name, headline, contacts, links, optional photo
54
+ ├── sections ResumeSection[] ordered; discriminated union over 8 types
55
+ ├── design DesignSettings templateId, free-hex accent, curated fontId, A4
56
+ └── meta DocumentMeta schemaVersion, targetRole, nullable ownerId
57
+ ```
58
+
59
+ The eight section types are `summary`, `experience`, `education`, `skills`,
60
+ `languages`, `certifications`, `projects` and `custom`. `custom` is the
61
+ catch-all that keeps parsing lossless — content an extractor cannot classify
62
+ lands there rather than being dropped. Duplicates are allowed: a resume may
63
+ carry two `custom` sections, or two `experience` ones.
64
+
65
+ `summary` is the single-entry section (it has `entry`, not `entries`), because
66
+ a resume has one summary paragraph and a list would allow states no UI can
67
+ produce. Every other section holds an array.
68
+
69
+ ### Conventions worth knowing
70
+
71
+ - **`RichLine`** is one line of an inline markdown subset — `**bold**`,
72
+ `*italic*`, `[label](url)`. Block structure is modelled in the schema itself
73
+ (bullets are arrays, not `- ` prefixes), so a line break inside a `RichLine`
74
+ means the producer flattened structure away and is rejected.
75
+ - **Dates** are structured `YearMonth` (`month` optional) inside a `DateRange`
76
+ with a `current` flag. An ongoing range must have a null `end`, and `end` may
77
+ not precede `start`. Use `compareYearMonth` to order them; a missing month
78
+ sorts as January.
79
+ - **Hiding is non-destructive.** A section carries `visible`, and every entry
80
+ carries `hiddenFields`. The values stay in the document and templates skip
81
+ rendering them, so nothing is lost by tucking a detail away.
82
+ - **Page breaks** are `pageBreakBefore` flags on sections and on entries.
83
+ - **URLs are restricted to http/https**, and markdown links inside a `RichLine`
84
+ to http/https/mailto. Resume content comes from parsed uploads and AI output
85
+ and is rendered as `href`s in the editor and the exported PDF, and a bare
86
+ `z.url()` accepts `javascript:` targets.
87
+ - **Unknown keys are stripped, not rejected**, so a newer producer degrades to
88
+ a valid document. Real incompatibility is caught by `meta.schemaVersion`.
89
+ - **Ids** must be unique among sections, and among entries within a section.
90
+ They anchor editor selection, undo/redo and version diffs, so a duplicate
91
+ would make those features address the wrong node.
92
+
93
+ ## Validation
94
+
95
+ `validateResumeDocument(input)` returns a discriminated result rather than
96
+ throwing:
97
+
98
+ ```ts
99
+ { ok: true, document: ResumeDocument } | { ok: false, issues: SchemaIssue[] }
100
+ ```
101
+
102
+ Each issue carries a dotted `path` — `sections.1.entries.0.dateRange.end` — so
103
+ the editor can anchor the error to the field that caused it. All problems are
104
+ reported at once, not just the first.
105
+
106
+ Also exported: `parseResumeDocumentJson` for text arriving from the Draft Slot
107
+ or a request body, `isResumeDocument` as a type guard, `assertResumeDocument`
108
+ for call sites where invalid input is a bug rather than user input, and
109
+ `formatIssues` for logging.
110
+
111
+ ## Fixtures
112
+
113
+ | Fixture | Covers |
114
+ | --- | --- |
115
+ | `FULL_RESUME` | realistic pt-BR resume, all 8 section types, a hidden field, a page break |
116
+ | `MINIMAL_RESUME` | the smallest valid document — an untouched anonymous draft |
117
+ | `ALL_SECTION_TYPES_RESUME` | one entry per section type, plus a hidden section |
118
+ | `HIDDEN_FIELDS_RESUME` | every hideable field, hidden, values retained |
119
+ | `PAGE_BREAKS_RESUME` | manual breaks at both section and entry level |
120
+
121
+ Each is authored as schema *input* and parsed at module load, so a fixture that
122
+ falls behind a schema change fails on import instead of quietly teaching tests
123
+ the wrong shape.
124
+
125
+ ## Releasing
126
+
127
+ The frontend builds from source, so a release is only needed when the API needs
128
+ a schema change:
129
+
130
+ ```bash
131
+ cd packages/resume-schema
132
+ bun test && bunx tsc --noEmit
133
+ npm version patch # minor instead for a breaking change while on 0.x
134
+ npm publish
135
+ ```
136
+
137
+ Then bump the range in `api.ajustacv.com/package.json` and run `bun install`
138
+ there. While the version is `0.x`, a caret range allows patches but not minors,
139
+ so a breaking change cannot reach the API silently.
140
+
141
+ Only `src` ships, minus the tests — `bun test` and `bunx tsc --noEmit` must both
142
+ pass before publishing, since consumers compile this source directly.
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "ajustacv-resume-schema",
3
+ "version": "0.1.0",
4
+ "description": "Canonical ResumeDocument schema, validators and fixtures shared by ajustacv.com and api.ajustacv.com",
5
+ "license": "UNLICENSED",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/juninhopo/ajustacv.com.git",
9
+ "directory": "packages/resume-schema"
10
+ },
11
+ "keywords": [
12
+ "ajustacv",
13
+ "resume",
14
+ "curriculo",
15
+ "schema"
16
+ ],
17
+ "type": "module",
18
+ "sideEffects": false,
19
+ "main": "./src/index.ts",
20
+ "types": "./src/index.ts",
21
+ "exports": {
22
+ ".": "./src/index.ts",
23
+ "./fixtures": "./src/fixtures.ts"
24
+ },
25
+ "files": [
26
+ "src",
27
+ "!src/**/*.test.ts"
28
+ ],
29
+ "scripts": {
30
+ "test": "bun test",
31
+ "typecheck": "tsc --noEmit"
32
+ },
33
+ "dependencies": {
34
+ "zod": "^4.3.6"
35
+ },
36
+ "devDependencies": {
37
+ "@types/bun": "latest",
38
+ "typescript": "^5"
39
+ }
40
+ }
@@ -0,0 +1,464 @@
1
+ // Sample documents for tests, prototypes and template development.
2
+ //
3
+ // Each fixture is authored as schema *input* and run through the validator at
4
+ // module load, so a fixture that falls behind a schema change fails loudly on
5
+ // import instead of quietly teaching every test the wrong shape.
6
+
7
+ import { SCHEMA_VERSION, type ResumeDocument, type ResumeDocumentInput } from "./schema";
8
+ import { assertResumeDocument } from "./validate";
9
+
10
+ const DEFAULT_DESIGN = {
11
+ templateId: "classic",
12
+ accentColor: "#2062F5",
13
+ fontId: "dm",
14
+ pageSize: "a4",
15
+ } as const;
16
+
17
+ function fixture(input: ResumeDocumentInput): ResumeDocument {
18
+ return assertResumeDocument(input);
19
+ }
20
+
21
+ // A realistic pt-BR resume exercising all eight section types, inline
22
+ // markdown, dates with and without a month, an ongoing role, a hidden field
23
+ // and a manual page break — the document template work is developed against.
24
+ export const FULL_RESUME: ResumeDocument = fixture({
25
+ header: {
26
+ fullName: "Mariana Ferreira",
27
+ headline: "Desenvolvedora Full Stack Sênior",
28
+ email: "mariana.ferreira@email.com",
29
+ phone: "(11) 98765-4321",
30
+ location: "São Paulo, SP",
31
+ links: [
32
+ {
33
+ id: "l1",
34
+ label: "linkedin.com/in/marianaferreira",
35
+ url: "https://linkedin.com/in/marianaferreira",
36
+ },
37
+ { id: "l2", label: "github.com/marifer", url: "https://github.com/marifer" },
38
+ ],
39
+ photoUrl: null,
40
+ showPhoto: false,
41
+ },
42
+ design: DEFAULT_DESIGN,
43
+ meta: { schemaVersion: SCHEMA_VERSION, targetRole: "Tech Lead", ownerId: null },
44
+ sections: [
45
+ {
46
+ id: "s-summary",
47
+ type: "summary",
48
+ title: "Resumo",
49
+ visible: true,
50
+ entry: {
51
+ text: "Desenvolvedora full stack com **8 anos de experiência** construindo produtos web de alto tráfego. Especialista em React, Node.js e arquitetura de microsserviços, com histórico de liderança técnica em times de até 12 pessoas.",
52
+ },
53
+ },
54
+ {
55
+ id: "s-exp",
56
+ type: "experience",
57
+ title: "Experiência Profissional",
58
+ visible: true,
59
+ entries: [
60
+ {
61
+ id: "e1",
62
+ role: "Desenvolvedora Full Stack Sênior",
63
+ company: "Nubank",
64
+ location: "São Paulo, SP",
65
+ dateRange: { start: { year: 2022, month: 3 }, end: null, current: true },
66
+ bullets: [
67
+ "Liderei a migração do fluxo de onboarding para arquitetura de micro-frontends, reduzindo o tempo de carregamento em **43%**.",
68
+ "Implementei pipeline de CI/CD que cortou o tempo de deploy de 40 para **8 minutos**.",
69
+ "Mentoria de 4 pessoas desenvolvedoras júnior, com 2 promoções no período.",
70
+ ],
71
+ },
72
+ {
73
+ id: "e2",
74
+ role: "Desenvolvedora Full Stack Pleno",
75
+ company: "iFood",
76
+ location: "Osasco, SP",
77
+ dateRange: {
78
+ start: { year: 2019, month: 6 },
79
+ end: { year: 2022, month: 2 },
80
+ current: false,
81
+ },
82
+ bullets: [
83
+ "Desenvolvi o módulo de cupons do app, usado por **2M+ usuários/mês**.",
84
+ "Reduzi em 60% os erros de pagamento com *retry* idempotente na integração com adquirentes.",
85
+ ],
86
+ },
87
+ {
88
+ id: "e3",
89
+ role: "Desenvolvedora Front-end Júnior",
90
+ company: "Agência Wide",
91
+ // The oldest role keeps its city in the data but hides it, so the
92
+ // entry stays compact without losing information.
93
+ location: "Campinas, SP",
94
+ hiddenFields: ["location"],
95
+ dateRange: {
96
+ start: { year: 2017, month: 1 },
97
+ end: { year: 2019, month: 5 },
98
+ current: false,
99
+ },
100
+ bullets: [
101
+ "Entreguei mais de 20 sites institucionais responsivos para clientes de varejo e educação.",
102
+ ],
103
+ },
104
+ ],
105
+ },
106
+ {
107
+ id: "s-edu",
108
+ type: "education",
109
+ title: "Formação Acadêmica",
110
+ visible: true,
111
+ entries: [
112
+ {
113
+ id: "ed1",
114
+ institution: "Universidade de São Paulo (USP)",
115
+ course: "Bacharelado em Ciência da Computação",
116
+ dateRange: { start: { year: 2013 }, end: { year: 2016 }, current: false },
117
+ bullets: [],
118
+ },
119
+ ],
120
+ },
121
+ {
122
+ id: "s-skills",
123
+ type: "skills",
124
+ title: "Habilidades",
125
+ visible: true,
126
+ entries: [
127
+ {
128
+ id: "sg1",
129
+ name: "Técnicas",
130
+ skills: ["React", "TypeScript", "Node.js", "PostgreSQL", "AWS", "Docker", "GraphQL"],
131
+ },
132
+ {
133
+ id: "sg2",
134
+ name: "Comportamentais",
135
+ skills: ["Liderança técnica", "Comunicação", "Mentoria"],
136
+ },
137
+ ],
138
+ },
139
+ {
140
+ id: "s-lang",
141
+ type: "languages",
142
+ title: "Idiomas",
143
+ visible: true,
144
+ entries: [
145
+ { id: "lg1", language: "Português", level: "nativo" },
146
+ { id: "lg2", language: "Inglês", level: "avancado" },
147
+ { id: "lg3", language: "Espanhol", level: "intermediario" },
148
+ ],
149
+ },
150
+ {
151
+ id: "s-cert",
152
+ type: "certifications",
153
+ title: "Certificações",
154
+ visible: true,
155
+ entries: [
156
+ {
157
+ id: "c1",
158
+ name: "AWS Certified Solutions Architect",
159
+ issuer: "Amazon Web Services",
160
+ date: { year: 2023, month: 8 },
161
+ url: "https://aws.amazon.com/verification/ABC123",
162
+ },
163
+ { id: "c2", name: "Scrum Master Certified", issuer: "Scrum Alliance", date: { year: 2021 } },
164
+ ],
165
+ },
166
+ {
167
+ id: "s-proj",
168
+ type: "projects",
169
+ // Everything below here belongs on page two of the printed resume.
170
+ pageBreakBefore: true,
171
+ title: "Projetos",
172
+ visible: true,
173
+ entries: [
174
+ {
175
+ id: "p1",
176
+ name: "Radar de Vagas",
177
+ description:
178
+ "Agregador open source de vagas remotas em tecnologia, com **1.2k estrelas** no GitHub.",
179
+ url: "https://github.com/marifer/radar-de-vagas",
180
+ dateRange: { start: { year: 2021, month: 2 }, end: null, current: true },
181
+ },
182
+ ],
183
+ },
184
+ {
185
+ id: "s-custom",
186
+ type: "custom",
187
+ title: "Voluntariado",
188
+ visible: true,
189
+ entries: [
190
+ {
191
+ id: "cu1",
192
+ title: "Instrutora de programação",
193
+ subtitle: "[Reprograma](https://reprograma.com.br)",
194
+ dateRange: { start: { year: 2021 }, end: null, current: true },
195
+ bullets: ["Aulas de JavaScript para mulheres em transição de carreira."],
196
+ },
197
+ ],
198
+ },
199
+ ],
200
+ });
201
+
202
+ // The smallest document the schema accepts: what an anonymous visitor who has
203
+ // typed nothing still has in their Draft Slot.
204
+ export const MINIMAL_RESUME: ResumeDocument = fixture({
205
+ header: {
206
+ fullName: "",
207
+ headline: "",
208
+ email: "",
209
+ phone: "",
210
+ location: "",
211
+ links: [],
212
+ photoUrl: null,
213
+ showPhoto: false,
214
+ },
215
+ sections: [],
216
+ design: DEFAULT_DESIGN,
217
+ meta: { schemaVersion: SCHEMA_VERSION, targetRole: null, ownerId: null },
218
+ });
219
+
220
+ // One entry per section type, content trimmed to the minimum. Template tests
221
+ // render this to prove every section type has a renderer.
222
+ export const ALL_SECTION_TYPES_RESUME: ResumeDocument = fixture({
223
+ header: {
224
+ fullName: "Joana Silva",
225
+ headline: "Analista de Dados",
226
+ email: "joana@email.com",
227
+ phone: "(21) 91234-5678",
228
+ location: "Rio de Janeiro, RJ",
229
+ links: [],
230
+ photoUrl: "https://cdn.ajustacv.com/foto/joana.jpg",
231
+ showPhoto: true,
232
+ },
233
+ design: { ...DEFAULT_DESIGN, templateId: "sidebar", fontId: "serif" },
234
+ meta: { schemaVersion: SCHEMA_VERSION, targetRole: "Analista de Dados Sênior", ownerId: "user_1" },
235
+ sections: [
236
+ { id: "t-summary", type: "summary", title: "Resumo", visible: true, entry: { text: "Analista de dados." } },
237
+ {
238
+ id: "t-exp",
239
+ type: "experience",
240
+ title: "Experiência",
241
+ visible: true,
242
+ entries: [
243
+ {
244
+ id: "t-exp-1",
245
+ role: "Analista de Dados",
246
+ company: "Petrobras",
247
+ dateRange: { start: { year: 2020 }, end: null, current: true },
248
+ bullets: ["Construí painéis de acompanhamento operacional."],
249
+ },
250
+ ],
251
+ },
252
+ {
253
+ id: "t-edu",
254
+ type: "education",
255
+ title: "Formação",
256
+ visible: true,
257
+ entries: [
258
+ {
259
+ id: "t-edu-1",
260
+ institution: "UFRJ",
261
+ course: "Estatística",
262
+ dateRange: { start: { year: 2015 }, end: { year: 2019 }, current: false },
263
+ bullets: [],
264
+ },
265
+ ],
266
+ },
267
+ {
268
+ id: "t-skills",
269
+ type: "skills",
270
+ title: "Habilidades",
271
+ visible: true,
272
+ entries: [{ id: "t-skills-1", name: "Ferramentas", skills: ["SQL", "Python", "Power BI"] }],
273
+ },
274
+ {
275
+ id: "t-lang",
276
+ type: "languages",
277
+ title: "Idiomas",
278
+ visible: true,
279
+ entries: [{ id: "t-lang-1", language: "Inglês", level: "fluente" }],
280
+ },
281
+ {
282
+ id: "t-cert",
283
+ type: "certifications",
284
+ title: "Certificações",
285
+ visible: true,
286
+ entries: [{ id: "t-cert-1", name: "DP-203", issuer: "Microsoft", date: { year: 2022 } }],
287
+ },
288
+ {
289
+ id: "t-proj",
290
+ type: "projects",
291
+ title: "Projetos",
292
+ visible: true,
293
+ entries: [{ id: "t-proj-1", name: "Painel COVID-RJ", description: "Painel público de indicadores." }],
294
+ },
295
+ {
296
+ id: "t-custom",
297
+ type: "custom",
298
+ title: "Prêmios",
299
+ visible: true,
300
+ entries: [{ id: "t-custom-1", title: "Menção honrosa", bullets: [] }],
301
+ },
302
+ // A renamed, hidden section: the content survives, templates skip it.
303
+ {
304
+ id: "t-hidden",
305
+ type: "custom",
306
+ title: "Rascunho",
307
+ visible: false,
308
+ entries: [{ id: "t-hidden-1", title: "Conteúdo guardado para depois", bullets: [] }],
309
+ },
310
+ ],
311
+ });
312
+
313
+ // Every hideable field, hidden. Templates must render this document without
314
+ // showing any of the hidden values — and without losing them on round-trip.
315
+ export const HIDDEN_FIELDS_RESUME: ResumeDocument = fixture({
316
+ header: {
317
+ fullName: "Paulo Mendes",
318
+ headline: "Designer de Produto",
319
+ email: "paulo@email.com",
320
+ phone: "(31) 99999-0000",
321
+ location: "Belo Horizonte, MG",
322
+ links: [],
323
+ photoUrl: "https://cdn.ajustacv.com/foto/paulo.jpg",
324
+ // The photo is stored but not rendered — the header's own hide switch.
325
+ showPhoto: false,
326
+ },
327
+ design: DEFAULT_DESIGN,
328
+ meta: { schemaVersion: SCHEMA_VERSION, targetRole: null, ownerId: null },
329
+ sections: [
330
+ {
331
+ id: "h-exp",
332
+ type: "experience",
333
+ title: "Experiência",
334
+ visible: true,
335
+ entries: [
336
+ {
337
+ id: "h-exp-1",
338
+ role: "Designer de Produto",
339
+ company: "Hotmart",
340
+ location: "Belo Horizonte, MG",
341
+ hiddenFields: ["location", "dates", "bullets"],
342
+ dateRange: { start: { year: 2021, month: 4 }, end: null, current: true },
343
+ bullets: ["Redesenhei o fluxo de checkout."],
344
+ },
345
+ ],
346
+ },
347
+ {
348
+ id: "h-cert",
349
+ type: "certifications",
350
+ title: "Certificações",
351
+ visible: true,
352
+ entries: [
353
+ {
354
+ id: "h-cert-1",
355
+ name: "Nielsen Norman UX Certification",
356
+ issuer: "Nielsen Norman Group",
357
+ hiddenFields: ["issuer", "url"],
358
+ date: { year: 2023, month: 5 },
359
+ url: "https://nngroup.com/certificate/123",
360
+ },
361
+ ],
362
+ },
363
+ {
364
+ id: "h-proj",
365
+ type: "projects",
366
+ title: "Projetos",
367
+ visible: true,
368
+ entries: [
369
+ {
370
+ id: "h-proj-1",
371
+ name: "Design System Aurora",
372
+ description: "Biblioteca de componentes usada por 6 times.",
373
+ hiddenFields: ["description", "url"],
374
+ url: "https://aurora.design",
375
+ },
376
+ ],
377
+ },
378
+ {
379
+ id: "h-custom",
380
+ type: "custom",
381
+ title: "Palestras",
382
+ visible: true,
383
+ entries: [
384
+ {
385
+ id: "h-custom-1",
386
+ title: "Design de produto em escala",
387
+ subtitle: "TDC Floripa",
388
+ hiddenFields: ["subtitle"],
389
+ bullets: [],
390
+ },
391
+ ],
392
+ },
393
+ ],
394
+ });
395
+
396
+ // Manual page breaks at both levels the schema allows: before a section and
397
+ // before an entry inside one. Pagination tests assert the breaks land here.
398
+ export const PAGE_BREAKS_RESUME: ResumeDocument = fixture({
399
+ header: {
400
+ fullName: "Renata Alves",
401
+ headline: "Gerente de Projetos",
402
+ email: "renata@email.com",
403
+ phone: "(41) 98888-1111",
404
+ location: "Curitiba, PR",
405
+ links: [],
406
+ photoUrl: null,
407
+ showPhoto: false,
408
+ },
409
+ design: { ...DEFAULT_DESIGN, templateId: "executive" },
410
+ meta: { schemaVersion: SCHEMA_VERSION, targetRole: "Gerente de Projetos Sênior", ownerId: "user_2" },
411
+ sections: [
412
+ {
413
+ id: "b-exp",
414
+ type: "experience",
415
+ title: "Experiência",
416
+ visible: true,
417
+ entries: [
418
+ {
419
+ id: "b-exp-1",
420
+ role: "Gerente de Projetos",
421
+ company: "Positivo",
422
+ dateRange: { start: { year: 2020 }, end: null, current: true },
423
+ bullets: ["Coordenei 8 projetos simultâneos de hardware educacional."],
424
+ },
425
+ {
426
+ id: "b-exp-2",
427
+ // This role must start a fresh page rather than be split across two.
428
+ pageBreakBefore: true,
429
+ role: "Analista de Projetos",
430
+ company: "Boticário",
431
+ dateRange: { start: { year: 2016 }, end: { year: 2020 }, current: false },
432
+ bullets: ["Implantei o processo de gestão de portfólio da área digital."],
433
+ },
434
+ ],
435
+ },
436
+ {
437
+ id: "b-edu",
438
+ type: "education",
439
+ // The whole education section opens page two.
440
+ pageBreakBefore: true,
441
+ title: "Formação",
442
+ visible: true,
443
+ entries: [
444
+ {
445
+ id: "b-edu-1",
446
+ institution: "UFPR",
447
+ course: "Administração",
448
+ dateRange: { start: { year: 2011 }, end: { year: 2015 }, current: false },
449
+ bullets: [],
450
+ },
451
+ ],
452
+ },
453
+ ],
454
+ });
455
+
456
+ export const RESUME_FIXTURES = {
457
+ full: FULL_RESUME,
458
+ minimal: MINIMAL_RESUME,
459
+ allSectionTypes: ALL_SECTION_TYPES_RESUME,
460
+ hiddenFields: HIDDEN_FIELDS_RESUME,
461
+ pageBreaks: PAGE_BREAKS_RESUME,
462
+ } as const;
463
+
464
+ export type ResumeFixtureName = keyof typeof RESUME_FIXTURES;
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ // Public surface of ajustacv-resume-schema.
2
+ //
3
+ // Fixtures are deliberately NOT re-exported here: they are sample data for
4
+ // tests and prototypes, and importing them from "ajustacv-resume-schema"
5
+ // would drag them into the frontend bundle. Import "ajustacv-resume-schema/fixtures".
6
+
7
+ export * from "./schema";
8
+ export * from "./validate";
package/src/schema.ts ADDED
@@ -0,0 +1,366 @@
1
+ // Canonical ResumeDocument schema (issue #69, packaged by #108).
2
+ //
3
+ // Zod schemas are the source of truth; every exported TypeScript type is
4
+ // inferred from them, so the runtime validator and the compile-time types
5
+ // cannot drift apart.
6
+
7
+ import { z } from "zod";
8
+
9
+ // Bounds exist so the API can accept untrusted JSON without unbounded work.
10
+ // They are deliberately generous — no realistic resume comes close.
11
+ export const LIMITS = {
12
+ shortText: 200,
13
+ richLine: 5000,
14
+ url: 2000,
15
+ sections: 60,
16
+ entriesPerSection: 100,
17
+ bulletsPerEntry: 60,
18
+ skillsPerGroup: 100,
19
+ links: 20,
20
+ } as const;
21
+
22
+ const shortText = z.string().max(LIMITS.shortText);
23
+ const nonEmptyId = z.string().min(1).max(LIMITS.shortText);
24
+ // Restricted to http(s) on purpose: every URL here is rendered as an href in
25
+ // the editor and the exported PDF, and a bare z.url() happily accepts
26
+ // "javascript:alert(1)" — parsed resumes are untrusted input.
27
+ const url = z.url({ protocol: /^https?$/ }).max(LIMITS.url);
28
+
29
+ // ── Primitives ─────────────────────────────────────────────────────
30
+
31
+ // Inline markdown subset only: **bold**, *italic*, [label](url). Block
32
+ // structure lives in the schema itself (bullets are arrays, not "- " lines),
33
+ // so a line break here always means the producer flattened structure away.
34
+ export const RichLineSchema = z
35
+ .string()
36
+ .max(LIMITS.richLine)
37
+ .refine((value) => !/[\r\n]/.test(value), {
38
+ message:
39
+ "RichLine is a single line of inline markdown — use structured bullets instead of line breaks",
40
+ })
41
+ .refine((value) => linkTargets(value).every(isSafeLinkTarget), {
42
+ message: "markdown links must point at http, https or mailto",
43
+ });
44
+
45
+ // Anchored on "](" rather than on the label, because the label is not reliably
46
+ // parseable: "[a\]b](…)" and "[a[b]c](…)" are both links CommonMark renders,
47
+ // and any pattern that reads the label first stops at the wrong bracket and
48
+ // misses the target entirely. The optional "<" covers the pointy-bracket
49
+ // destination form, "[label](<url>)".
50
+ const LINK_TARGET = /\]\(\s*<?([^)>\s]*)/g;
51
+
52
+ // CommonMark autolinks carry the scheme directly: <javascript:alert(1)>.
53
+ const AUTOLINK_TARGET = /<([a-zA-Z][a-zA-Z0-9+.-]*:[^>\s]*)>/g;
54
+
55
+ function linkTargets(value: string): string[] {
56
+ const inline = [...value.matchAll(LINK_TARGET)];
57
+ const autolinks = [...value.matchAll(AUTOLINK_TARGET)];
58
+ return [...inline, ...autolinks].map((match) => match[1] ?? "");
59
+ }
60
+
61
+ // An allowlist, so no case, whitespace or percent-encoding trick reaches a
62
+ // renderer: a target is safe only if it plainly begins with a web or mail
63
+ // scheme. Defence in depth — the renderer must still sanitise its own hrefs.
64
+ function isSafeLinkTarget(target: string): boolean {
65
+ return /^(https?:\/\/|mailto:)/i.test(target);
66
+ }
67
+
68
+ export const YearMonthSchema = z.object({
69
+ year: z.int().min(1900).max(2100),
70
+ month: z.int().min(1).max(12).optional(),
71
+ });
72
+
73
+ export const DateRangeSchema = z
74
+ .object({
75
+ start: YearMonthSchema.nullable(),
76
+ end: YearMonthSchema.nullable(),
77
+ current: z.boolean(),
78
+ })
79
+ .check((ctx) => {
80
+ const { start, end, current } = ctx.value;
81
+
82
+ if (current && end !== null) {
83
+ ctx.issues.push({
84
+ code: "custom",
85
+ input: ctx.value,
86
+ path: ["end"],
87
+ message: "an ongoing date range (current: true) must not have an end",
88
+ });
89
+ }
90
+
91
+ if (start && end && compareYearMonth(start, end) > 0) {
92
+ ctx.issues.push({
93
+ code: "custom",
94
+ input: ctx.value,
95
+ path: ["end"],
96
+ message: "end date must not be earlier than start date",
97
+ });
98
+ }
99
+ });
100
+
101
+ export const LANGUAGE_LEVELS = [
102
+ "basico",
103
+ "intermediario",
104
+ "avancado",
105
+ "fluente",
106
+ "nativo",
107
+ ] as const;
108
+
109
+ export const LanguageLevelSchema = z.enum(LANGUAGE_LEVELS);
110
+
111
+ // Fields an entry can hide non-destructively: the value stays in the
112
+ // document, templates skip rendering it. Deleting is a separate action.
113
+ export const HIDEABLE_FIELDS = [
114
+ "location",
115
+ "dates",
116
+ "bullets",
117
+ "subtitle",
118
+ "issuer",
119
+ "url",
120
+ "description",
121
+ ] as const;
122
+
123
+ export const HideableFieldSchema = z.enum(HIDEABLE_FIELDS);
124
+
125
+ // ── Header ─────────────────────────────────────────────────────────
126
+
127
+ export const HeaderLinkSchema = z.object({
128
+ id: nonEmptyId,
129
+ label: shortText,
130
+ url,
131
+ });
132
+
133
+ export const ResumeHeaderSchema = z.object({
134
+ fullName: shortText,
135
+ headline: shortText,
136
+ email: z.union([z.literal(""), z.email().max(LIMITS.shortText)]),
137
+ phone: shortText,
138
+ location: shortText,
139
+ links: z.array(HeaderLinkSchema).max(LIMITS.links),
140
+ photoUrl: url.nullable(),
141
+ showPhoto: z.boolean(),
142
+ });
143
+
144
+ // ── Entries ────────────────────────────────────────────────────────
145
+
146
+ const entryBase = {
147
+ id: nonEmptyId,
148
+ pageBreakBefore: z.boolean().default(false),
149
+ hiddenFields: z.array(HideableFieldSchema).default([]),
150
+ };
151
+
152
+ export const SummaryEntrySchema = z.object({ text: RichLineSchema });
153
+
154
+ export const ExperienceEntrySchema = z.object({
155
+ ...entryBase,
156
+ role: shortText,
157
+ company: shortText,
158
+ location: shortText.default(""),
159
+ dateRange: DateRangeSchema,
160
+ bullets: z.array(RichLineSchema).max(LIMITS.bulletsPerEntry),
161
+ });
162
+
163
+ export const EducationEntrySchema = z.object({
164
+ ...entryBase,
165
+ institution: shortText,
166
+ course: shortText,
167
+ location: shortText.default(""),
168
+ dateRange: DateRangeSchema,
169
+ bullets: z.array(RichLineSchema).max(LIMITS.bulletsPerEntry),
170
+ });
171
+
172
+ export const SkillGroupSchema = z.object({
173
+ ...entryBase,
174
+ name: shortText,
175
+ skills: z.array(shortText).max(LIMITS.skillsPerGroup),
176
+ });
177
+
178
+ export const LanguageEntrySchema = z.object({
179
+ ...entryBase,
180
+ language: shortText,
181
+ level: LanguageLevelSchema.nullable(),
182
+ });
183
+
184
+ export const CertificationEntrySchema = z.object({
185
+ ...entryBase,
186
+ name: shortText,
187
+ issuer: shortText,
188
+ date: YearMonthSchema.nullable(),
189
+ url: url.nullable().default(null),
190
+ });
191
+
192
+ export const ProjectEntrySchema = z.object({
193
+ ...entryBase,
194
+ name: shortText,
195
+ description: RichLineSchema,
196
+ url: url.nullable().default(null),
197
+ dateRange: DateRangeSchema.nullable().default(null),
198
+ });
199
+
200
+ // The catch-all that keeps parsing lossless: anything the extractor cannot
201
+ // map to a typed section still lands here rather than being dropped.
202
+ export const CustomEntrySchema = z.object({
203
+ ...entryBase,
204
+ title: shortText.default(""),
205
+ subtitle: shortText.default(""),
206
+ dateRange: DateRangeSchema.nullable().default(null),
207
+ bullets: z.array(RichLineSchema).max(LIMITS.bulletsPerEntry),
208
+ });
209
+
210
+ // ── Sections ───────────────────────────────────────────────────────
211
+
212
+ export const SECTION_TYPES = [
213
+ "summary",
214
+ "experience",
215
+ "education",
216
+ "skills",
217
+ "languages",
218
+ "certifications",
219
+ "projects",
220
+ "custom",
221
+ ] as const;
222
+
223
+ export const SectionTypeSchema = z.enum(SECTION_TYPES);
224
+
225
+ const sectionBase = {
226
+ id: nonEmptyId,
227
+ title: shortText,
228
+ visible: z.boolean(),
229
+ pageBreakBefore: z.boolean().default(false),
230
+ };
231
+
232
+ const entryList = <T extends z.ZodType>(entry: T) =>
233
+ z.array(entry).max(LIMITS.entriesPerSection);
234
+
235
+ // Summary is the one single-entry section: a resume has one summary
236
+ // paragraph, so modelling it as a list would allow states no UI can produce.
237
+ export const ResumeSectionSchema = z.discriminatedUnion("type", [
238
+ z.object({ ...sectionBase, type: z.literal("summary"), entry: SummaryEntrySchema }),
239
+ z.object({ ...sectionBase, type: z.literal("experience"), entries: entryList(ExperienceEntrySchema) }),
240
+ z.object({ ...sectionBase, type: z.literal("education"), entries: entryList(EducationEntrySchema) }),
241
+ z.object({ ...sectionBase, type: z.literal("skills"), entries: entryList(SkillGroupSchema) }),
242
+ z.object({ ...sectionBase, type: z.literal("languages"), entries: entryList(LanguageEntrySchema) }),
243
+ z.object({ ...sectionBase, type: z.literal("certifications"), entries: entryList(CertificationEntrySchema) }),
244
+ z.object({ ...sectionBase, type: z.literal("projects"), entries: entryList(ProjectEntrySchema) }),
245
+ z.object({ ...sectionBase, type: z.literal("custom"), entries: entryList(CustomEntrySchema) }),
246
+ ]);
247
+
248
+ // ── Design ─────────────────────────────────────────────────────────
249
+
250
+ export const TEMPLATE_IDS = ["classic", "centered", "sidebar", "executive"] as const;
251
+
252
+ export const TemplateIdSchema = z.enum(TEMPLATE_IDS);
253
+
254
+ export const FONT_IDS = ["dm", "sans", "serif"] as const;
255
+
256
+ export const FontIdSchema = z.enum(FONT_IDS);
257
+
258
+ export const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
259
+
260
+ export const DesignSettingsSchema = z.object({
261
+ templateId: TemplateIdSchema,
262
+ // Free choice, not a palette — but normalised to #RRGGBB so templates can
263
+ // derive tints from it without re-parsing shorthand or named colours.
264
+ accentColor: z.string().regex(HEX_COLOR, "accentColor must be a #RRGGBB hex colour"),
265
+ fontId: FontIdSchema,
266
+ pageSize: z.literal("a4"),
267
+ });
268
+
269
+ // ── Meta ───────────────────────────────────────────────────────────
270
+
271
+ export const SCHEMA_VERSION = 1;
272
+
273
+ export const DocumentMetaSchema = z.object({
274
+ schemaVersion: z.literal(SCHEMA_VERSION),
275
+ targetRole: shortText.nullable(),
276
+ // null while the document lives in an anonymous Draft Slot; set at Claim.
277
+ ownerId: nonEmptyId.nullable(),
278
+ });
279
+
280
+ // ── Document ───────────────────────────────────────────────────────
281
+
282
+ export const ResumeDocumentSchema = z
283
+ .object({
284
+ header: ResumeHeaderSchema,
285
+ sections: z.array(ResumeSectionSchema).max(LIMITS.sections),
286
+ design: DesignSettingsSchema,
287
+ meta: DocumentMetaSchema,
288
+ })
289
+ .check((ctx) => {
290
+ collectDuplicateIdIssues(ctx);
291
+ });
292
+
293
+ // ── Inferred types ─────────────────────────────────────────────────
294
+
295
+ export type RichLine = z.infer<typeof RichLineSchema>;
296
+ export type YearMonth = z.infer<typeof YearMonthSchema>;
297
+ export type DateRange = z.infer<typeof DateRangeSchema>;
298
+ export type LanguageLevel = z.infer<typeof LanguageLevelSchema>;
299
+ export type HideableField = z.infer<typeof HideableFieldSchema>;
300
+ export type HeaderLink = z.infer<typeof HeaderLinkSchema>;
301
+ export type ResumeHeader = z.infer<typeof ResumeHeaderSchema>;
302
+ export type SummaryEntry = z.infer<typeof SummaryEntrySchema>;
303
+ export type ExperienceEntry = z.infer<typeof ExperienceEntrySchema>;
304
+ export type EducationEntry = z.infer<typeof EducationEntrySchema>;
305
+ export type SkillGroup = z.infer<typeof SkillGroupSchema>;
306
+ export type LanguageEntry = z.infer<typeof LanguageEntrySchema>;
307
+ export type CertificationEntry = z.infer<typeof CertificationEntrySchema>;
308
+ export type ProjectEntry = z.infer<typeof ProjectEntrySchema>;
309
+ export type CustomEntry = z.infer<typeof CustomEntrySchema>;
310
+ export type SectionType = z.infer<typeof SectionTypeSchema>;
311
+ export type ResumeSection = z.infer<typeof ResumeSectionSchema>;
312
+ export type TemplateId = z.infer<typeof TemplateIdSchema>;
313
+ export type FontId = z.infer<typeof FontIdSchema>;
314
+ export type DesignSettings = z.infer<typeof DesignSettingsSchema>;
315
+ export type DocumentMeta = z.infer<typeof DocumentMetaSchema>;
316
+ export type ResumeDocument = z.infer<typeof ResumeDocumentSchema>;
317
+
318
+ // The shape callers construct: fields carrying a default may be omitted.
319
+ export type ResumeDocumentInput = z.input<typeof ResumeDocumentSchema>;
320
+
321
+ // ── Helpers ────────────────────────────────────────────────────────
322
+
323
+ // Negative when a precedes b, positive when a follows b, 0 when equal.
324
+ // A missing month sorts as January so partial dates stay orderable.
325
+ export function compareYearMonth(a: YearMonth, b: YearMonth): number {
326
+ const monthsA = a.year * 12 + ((a.month ?? 1) - 1);
327
+ const monthsB = b.year * 12 + ((b.month ?? 1) - 1);
328
+ return monthsA - monthsB;
329
+ }
330
+
331
+ // Ids anchor editor selection, undo/redo and version diffs, so a duplicate
332
+ // makes those features address the wrong node. Reported per offender rather
333
+ // than as one document-level failure so the caller can point at the entry.
334
+ function collectDuplicateIdIssues(ctx: {
335
+ value: { sections: ResumeSection[] };
336
+ issues: z.core.$ZodRawIssue[];
337
+ }): void {
338
+ const seenSectionIds = new Set<string>();
339
+
340
+ ctx.value.sections.forEach((section, sectionIndex) => {
341
+ if (seenSectionIds.has(section.id)) {
342
+ ctx.issues.push({
343
+ code: "custom",
344
+ input: section.id,
345
+ path: ["sections", sectionIndex, "id"],
346
+ message: `duplicate section id "${section.id}"`,
347
+ });
348
+ }
349
+ seenSectionIds.add(section.id);
350
+
351
+ if (section.type === "summary") return;
352
+
353
+ const seenEntryIds = new Set<string>();
354
+ section.entries.forEach((entry, entryIndex) => {
355
+ if (seenEntryIds.has(entry.id)) {
356
+ ctx.issues.push({
357
+ code: "custom",
358
+ input: entry.id,
359
+ path: ["sections", sectionIndex, "entries", entryIndex, "id"],
360
+ message: `duplicate entry id "${entry.id}" in section "${section.id}"`,
361
+ });
362
+ }
363
+ seenEntryIds.add(entry.id);
364
+ });
365
+ });
366
+ }
@@ -0,0 +1,70 @@
1
+ // Runtime validation entry points. Everything here is a thin, allocation-free
2
+ // wrapper over ResumeDocumentSchema — the value it adds is a stable error
3
+ // shape both repos can render, log and anchor to a field in the editor.
4
+
5
+ import type { z } from "zod";
6
+
7
+ import { ResumeDocumentSchema, type ResumeDocument } from "./schema";
8
+
9
+ export interface SchemaIssue {
10
+ // Dotted path to the offending value, array indices included:
11
+ // "sections.1.entries.0.dateRange.end". Empty for whole-document failures.
12
+ path: string;
13
+ message: string;
14
+ }
15
+
16
+ export type ValidationResult =
17
+ | { ok: true; document: ResumeDocument }
18
+ | { ok: false; issues: SchemaIssue[] };
19
+
20
+ // Unknown keys are stripped rather than rejected: a newer producer (a future
21
+ // schemaVersion, an over-eager extractor) should degrade to a valid document,
22
+ // not fail outright. Genuine incompatibility is caught by meta.schemaVersion.
23
+ export function validateResumeDocument(input: unknown): ValidationResult {
24
+ const result = ResumeDocumentSchema.safeParse(input);
25
+
26
+ if (result.success) return { ok: true, document: result.data };
27
+
28
+ return { ok: false, issues: toSchemaIssues(result.error) };
29
+ }
30
+
31
+ // The Draft Slot and API request bodies both arrive as text, and a truncated
32
+ // or hand-edited payload must read as an invalid document rather than a crash.
33
+ export function parseResumeDocumentJson(raw: string | null | undefined): ValidationResult {
34
+ if (raw === null || raw === undefined) {
35
+ return { ok: false, issues: [{ path: "", message: "no document JSON was provided" }] };
36
+ }
37
+
38
+ try {
39
+ return validateResumeDocument(JSON.parse(raw));
40
+ } catch (error) {
41
+ const detail = error instanceof Error ? error.message : String(error);
42
+ return { ok: false, issues: [{ path: "", message: `invalid JSON: ${detail}` }] };
43
+ }
44
+ }
45
+
46
+ export function isResumeDocument(input: unknown): input is ResumeDocument {
47
+ return ResumeDocumentSchema.safeParse(input).success;
48
+ }
49
+
50
+ // For call sites where an invalid document is a bug rather than user input —
51
+ // fixtures, migrations, tests. Prefer validateResumeDocument at any boundary.
52
+ export function assertResumeDocument(input: unknown): ResumeDocument {
53
+ const result = validateResumeDocument(input);
54
+ if (result.ok) return result.document;
55
+
56
+ throw new Error(`invalid ResumeDocument:\n${formatIssues(result.issues)}`);
57
+ }
58
+
59
+ export function formatIssues(issues: SchemaIssue[]): string {
60
+ return issues
61
+ .map((issue) => (issue.path === "" ? issue.message : `${issue.path}: ${issue.message}`))
62
+ .join("\n");
63
+ }
64
+
65
+ function toSchemaIssues(error: z.ZodError): SchemaIssue[] {
66
+ return error.issues.map((issue) => ({
67
+ path: issue.path.map(String).join("."),
68
+ message: issue.message,
69
+ }));
70
+ }