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 +142 -0
- package/package.json +40 -0
- package/src/fixtures.ts +464 -0
- package/src/index.ts +8 -0
- package/src/schema.ts +366 -0
- package/src/validate.ts +70 -0
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
|
+
}
|
package/src/fixtures.ts
ADDED
|
@@ -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
|
+
}
|
package/src/validate.ts
ADDED
|
@@ -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
|
+
}
|