@ansiversa/components 0.0.122 → 0.0.124
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/package.json +1 -1
- package/src/resume-templates/ResumeTemplateClassic.astro +11 -31
- package/src/resume-templates/ResumeTemplateExecutiveTimeline.astro +7 -17
- package/src/resume-templates/ResumeTemplateMinimal.astro +19 -31
- package/src/resume-templates/ResumeTemplateModernTwoTone.astro +35 -43
- package/src/resume-templates/typescript-schema.ts +147 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
import type { ResumeData } from "./typescript-schema";
|
|
3
|
-
import { formatDateRange } from "./typescript-schema";
|
|
3
|
+
import { buildContactEntries, formatDateRange } from "./typescript-schema";
|
|
4
4
|
|
|
5
5
|
interface Props {
|
|
6
6
|
data: ResumeData;
|
|
@@ -13,27 +13,7 @@ const locationLabel =
|
|
|
13
13
|
basics.location?.label ||
|
|
14
14
|
[basics.location?.city, basics.location?.country].filter(Boolean).join(", ");
|
|
15
15
|
|
|
16
|
-
const
|
|
17
|
-
value.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
|
18
|
-
|
|
19
|
-
const contactItems: Array<{ label: string; href?: string }> = [];
|
|
20
|
-
if (locationLabel) contactItems.push({ label: locationLabel });
|
|
21
|
-
if (basics.contact.email) {
|
|
22
|
-
contactItems.push({ label: basics.contact.email, href: `mailto:${basics.contact.email}` });
|
|
23
|
-
}
|
|
24
|
-
if (basics.contact.phone) {
|
|
25
|
-
const phoneHref = basics.contact.phone.replace(/\s+/g, "");
|
|
26
|
-
contactItems.push({ label: basics.contact.phone, href: `tel:${phoneHref}` });
|
|
27
|
-
}
|
|
28
|
-
if (basics.contact.website) {
|
|
29
|
-
contactItems.push({ label: formatUrlLabel(basics.contact.website), href: basics.contact.website });
|
|
30
|
-
}
|
|
31
|
-
for (const link of basics.links ?? []) {
|
|
32
|
-
const urlLabel = link.url ? formatUrlLabel(link.url) : link.label;
|
|
33
|
-
if (urlLabel) {
|
|
34
|
-
contactItems.push({ label: urlLabel, href: link.url });
|
|
35
|
-
}
|
|
36
|
-
}
|
|
16
|
+
const contactItems = buildContactEntries(basics, locationLabel);
|
|
37
17
|
|
|
38
18
|
const experienceItems = data.experience ?? [];
|
|
39
19
|
const projectItems = data.projects ?? [];
|
|
@@ -50,17 +30,17 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
|
|
|
50
30
|
<div class="resume-template av-print-white bg-slate-100 text-slate-900 print:bg-white">
|
|
51
31
|
<main class="mx-auto max-w-4xl p-4 sm:p-6">
|
|
52
32
|
<section class="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 sm:p-10 print:shadow-none print:ring-0">
|
|
53
|
-
<header class="av-print-avoid-break flex flex-col gap-
|
|
54
|
-
<div>
|
|
55
|
-
<h1 class="text-
|
|
56
|
-
<p class="mt-1 text-
|
|
33
|
+
<header class="av-print-avoid-break flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
|
|
34
|
+
<div class="sm:max-w-[70%]">
|
|
35
|
+
<h1 class="text-4xl font-extrabold tracking-tight text-slate-950">{basics.fullName}</h1>
|
|
36
|
+
<p class="mt-1 text-sm font-semibold uppercase tracking-[0.14em] text-slate-700">{basics.headline}</p>
|
|
57
37
|
{basics.summary && (
|
|
58
38
|
<p class="mt-3 text-sm leading-6 text-slate-600">{basics.summary}</p>
|
|
59
39
|
)}
|
|
60
40
|
</div>
|
|
61
41
|
|
|
62
42
|
{contactItems.length > 0 && (
|
|
63
|
-
<div class="text-sm text-slate-700 sm:text-right">
|
|
43
|
+
<div class="text-sm text-slate-700 sm:max-w-[18rem] sm:border-l sm:border-slate-200 sm:pl-4 sm:text-right">
|
|
64
44
|
<div class="flex flex-col gap-1 sm:items-end">
|
|
65
45
|
{contactItems.map((item) =>
|
|
66
46
|
item.href ? (
|
|
@@ -272,21 +252,21 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
|
|
|
272
252
|
{hasDeclaration && (
|
|
273
253
|
<>
|
|
274
254
|
<div class="my-8 h-0 border-t border-slate-200"></div>
|
|
275
|
-
<section class="av-print-avoid-break space-y-
|
|
255
|
+
<section class="av-print-avoid-break space-y-3">
|
|
276
256
|
<h2 class="text-sm font-bold tracking-widest text-slate-900">DECLARATION</h2>
|
|
277
257
|
{declaration.text && (
|
|
278
258
|
<p class="text-sm leading-6 text-slate-700">{declaration.text}</p>
|
|
279
259
|
)}
|
|
280
260
|
{(declaration.place || declaration.name) && (
|
|
281
|
-
<div class="flex flex-wrap items-center justify-between gap-
|
|
261
|
+
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-slate-200 pt-3 text-sm text-slate-700">
|
|
282
262
|
{declaration.place && (
|
|
283
|
-
<div>
|
|
263
|
+
<div class="text-xs uppercase tracking-[0.12em] text-slate-600">
|
|
284
264
|
<span class="font-semibold text-slate-900">Place:</span>{" "}
|
|
285
265
|
{declaration.place}
|
|
286
266
|
</div>
|
|
287
267
|
)}
|
|
288
268
|
{declaration.name && (
|
|
289
|
-
<div class="font-
|
|
269
|
+
<div class="text-base font-bold tracking-wide text-slate-950">{declaration.name}</div>
|
|
290
270
|
)}
|
|
291
271
|
</div>
|
|
292
272
|
)}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
import type { ResumeData } from "./typescript-schema";
|
|
3
|
-
import { formatDateRange } from "./typescript-schema";
|
|
3
|
+
import { buildContactEntries, formatDateRange, truncateText } from "./typescript-schema";
|
|
4
4
|
|
|
5
5
|
interface Props {
|
|
6
6
|
data: ResumeData;
|
|
@@ -13,17 +13,7 @@ const locationLabel =
|
|
|
13
13
|
basics.location?.label ||
|
|
14
14
|
[basics.location?.city, basics.location?.country].filter(Boolean).join(", ");
|
|
15
15
|
|
|
16
|
-
const
|
|
17
|
-
value.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
|
18
|
-
|
|
19
|
-
const contactChips: string[] = [];
|
|
20
|
-
if (locationLabel) contactChips.push(locationLabel);
|
|
21
|
-
if (basics.contact.email) contactChips.push(basics.contact.email);
|
|
22
|
-
if (basics.contact.phone) contactChips.push(basics.contact.phone);
|
|
23
|
-
if (basics.contact.website) contactChips.push(formatUrlLabel(basics.contact.website));
|
|
24
|
-
for (const link of basics.links ?? []) {
|
|
25
|
-
contactChips.push(link.label ?? formatUrlLabel(link.url));
|
|
26
|
-
}
|
|
16
|
+
const contactChips = buildContactEntries(basics, locationLabel).map((item) => item.label);
|
|
27
17
|
|
|
28
18
|
const normalizeSkillLabel = (value?: string) =>
|
|
29
19
|
(value ?? "")
|
|
@@ -92,7 +82,7 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
|
|
|
92
82
|
{item.bullets && item.bullets.length > 0 && (
|
|
93
83
|
<ul class="mt-3 list-disc space-y-2 pl-5 text-sm leading-6 text-slate-700">
|
|
94
84
|
{item.bullets.map((bullet) => (
|
|
95
|
-
<li>{bullet}</li>
|
|
85
|
+
<li class="break-words print:line-clamp-2">{truncateText(bullet, 140)}</li>
|
|
96
86
|
))}
|
|
97
87
|
</ul>
|
|
98
88
|
)}
|
|
@@ -184,21 +174,21 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
|
|
|
184
174
|
{hasDeclaration && (
|
|
185
175
|
<>
|
|
186
176
|
<div class="my-8 h-px bg-slate-200"></div>
|
|
187
|
-
<section class="av-print-avoid-break space-y-
|
|
177
|
+
<section class="av-print-avoid-break space-y-3">
|
|
188
178
|
<h2 class="text-sm font-bold tracking-widest text-slate-900">DECLARATION</h2>
|
|
189
179
|
{declaration.text && (
|
|
190
180
|
<p class="text-sm leading-6 text-slate-700">{declaration.text}</p>
|
|
191
181
|
)}
|
|
192
182
|
{(declaration.place || declaration.name) && (
|
|
193
|
-
<div class="flex flex-wrap items-center justify-between gap-
|
|
183
|
+
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-slate-200 pt-3 text-sm text-slate-700">
|
|
194
184
|
{declaration.place && (
|
|
195
|
-
<div>
|
|
185
|
+
<div class="text-xs uppercase tracking-[0.12em] text-slate-600">
|
|
196
186
|
<span class="font-semibold text-slate-900">Place:</span>{" "}
|
|
197
187
|
{declaration.place}
|
|
198
188
|
</div>
|
|
199
189
|
)}
|
|
200
190
|
{declaration.name && (
|
|
201
|
-
<div class="font-
|
|
191
|
+
<div class="text-base font-bold tracking-wide text-slate-950">{declaration.name}</div>
|
|
202
192
|
)}
|
|
203
193
|
</div>
|
|
204
194
|
)}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
import type { ResumeData } from "./typescript-schema";
|
|
3
|
-
import { formatDateRange } from "./typescript-schema";
|
|
3
|
+
import { buildContactEntries, formatDateRange, truncateText } from "./typescript-schema";
|
|
4
4
|
|
|
5
5
|
interface Props {
|
|
6
6
|
data: ResumeData;
|
|
@@ -13,23 +13,7 @@ const locationLabel =
|
|
|
13
13
|
basics.location?.label ||
|
|
14
14
|
[basics.location?.city, basics.location?.country].filter(Boolean).join(", ");
|
|
15
15
|
|
|
16
|
-
const
|
|
17
|
-
value.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
|
18
|
-
|
|
19
|
-
const contactLinks: Array<{ label: string; href?: string }> = [];
|
|
20
|
-
if (basics.contact.email) {
|
|
21
|
-
contactLinks.push({ label: basics.contact.email, href: `mailto:${basics.contact.email}` });
|
|
22
|
-
}
|
|
23
|
-
if (basics.contact.phone) {
|
|
24
|
-
const phoneHref = basics.contact.phone.replace(/\s+/g, "");
|
|
25
|
-
contactLinks.push({ label: basics.contact.phone, href: `tel:${phoneHref}` });
|
|
26
|
-
}
|
|
27
|
-
if (basics.contact.website) {
|
|
28
|
-
contactLinks.push({ label: formatUrlLabel(basics.contact.website), href: basics.contact.website });
|
|
29
|
-
}
|
|
30
|
-
for (const link of basics.links ?? []) {
|
|
31
|
-
contactLinks.push({ label: link.label ?? formatUrlLabel(link.url), href: link.url });
|
|
32
|
-
}
|
|
16
|
+
const contactLinks = buildContactEntries(basics, locationLabel);
|
|
33
17
|
|
|
34
18
|
const headlineLine = [basics.headline, locationLabel].filter(Boolean).join(" · ");
|
|
35
19
|
const experienceItems = data.experience ?? [];
|
|
@@ -45,18 +29,22 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
|
|
|
45
29
|
<main class="mx-auto max-w-4xl p-4 sm:p-6">
|
|
46
30
|
<section class="rounded-2xl bg-white p-8 shadow-sm ring-1 ring-slate-200 sm:p-12 print:shadow-none print:ring-0">
|
|
47
31
|
<header class="space-y-4">
|
|
48
|
-
<div class="flex flex-col gap-4 sm:flex-row sm:items-
|
|
32
|
+
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
|
49
33
|
<div>
|
|
50
34
|
<h1 class="text-4xl font-semibold tracking-tight">{basics.fullName}</h1>
|
|
51
35
|
{headlineLine && <p class="mt-2 text-sm text-slate-600">{headlineLine}</p>}
|
|
52
36
|
</div>
|
|
53
37
|
{contactLinks.length > 0 && (
|
|
54
|
-
<div class="text-sm text-slate-700 sm:text-right">
|
|
38
|
+
<div class="text-sm text-slate-700 sm:max-w-[18rem] sm:border-l sm:border-slate-200 sm:pl-4 sm:text-right">
|
|
55
39
|
<div class="flex flex-col gap-1">
|
|
56
40
|
{contactLinks.map((item) => (
|
|
41
|
+
item.href ? (
|
|
57
42
|
<a class="underline underline-offset-4 hover:text-slate-900 print:no-underline" href={item.href}>
|
|
58
|
-
|
|
59
|
-
|
|
43
|
+
{item.label}
|
|
44
|
+
</a>
|
|
45
|
+
) : (
|
|
46
|
+
<div>{item.label}</div>
|
|
47
|
+
)
|
|
60
48
|
))}
|
|
61
49
|
</div>
|
|
62
50
|
</div>
|
|
@@ -64,7 +52,7 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
|
|
|
64
52
|
</div>
|
|
65
53
|
|
|
66
54
|
{basics.summary && (
|
|
67
|
-
<p class="max-w-3xl text-sm leading-7 text-slate-700">{basics.summary}</p>
|
|
55
|
+
<p class="max-w-3xl text-sm leading-7 text-slate-700">{truncateText(basics.summary, 220)}</p>
|
|
68
56
|
)}
|
|
69
57
|
</header>
|
|
70
58
|
|
|
@@ -86,13 +74,13 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
|
|
|
86
74
|
<p class="text-sm text-slate-500">{formatDateRange(item)}</p>
|
|
87
75
|
</div>
|
|
88
76
|
{item.summary && (
|
|
89
|
-
<p class="mt-3 text-sm leading-7 text-slate-700">{item.summary}</p>
|
|
77
|
+
<p class="mt-3 text-sm leading-7 text-slate-700">{truncateText(item.summary, 220)}</p>
|
|
90
78
|
)}
|
|
91
79
|
{item.bullets && item.bullets.length > 0 && (
|
|
92
80
|
<ul class="mt-3 space-y-2 text-sm leading-7 text-slate-700">
|
|
93
81
|
{item.bullets.map((bullet) => (
|
|
94
82
|
<li>
|
|
95
|
-
<span class="font-medium">•</span> {bullet}
|
|
83
|
+
<span class="font-medium">•</span> {truncateText(bullet, 140)}
|
|
96
84
|
</li>
|
|
97
85
|
))}
|
|
98
86
|
</ul>
|
|
@@ -128,7 +116,7 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
|
|
|
128
116
|
)}
|
|
129
117
|
</div>
|
|
130
118
|
{project.summary && (
|
|
131
|
-
<p class="mt-2 text-sm leading-7 text-slate-700">{project.summary}</p>
|
|
119
|
+
<p class="mt-2 text-sm leading-7 text-slate-700">{truncateText(project.summary, 220)}</p>
|
|
132
120
|
)}
|
|
133
121
|
{project.tags && project.tags.length > 0 && (
|
|
134
122
|
<div class="mt-3 flex flex-wrap gap-2">
|
|
@@ -206,22 +194,22 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
|
|
|
206
194
|
|
|
207
195
|
{hasDeclaration && (
|
|
208
196
|
<>
|
|
209
|
-
<div class="my-
|
|
210
|
-
<section class="space-y-
|
|
197
|
+
<div class="my-6 h-px bg-slate-200"></div>
|
|
198
|
+
<section class="av-print-avoid-break rounded-xl border border-slate-200 p-4 space-y-3">
|
|
211
199
|
<h2 class="text-xs font-semibold tracking-[0.25em] text-slate-500">DECLARATION</h2>
|
|
212
200
|
{declaration.text && (
|
|
213
201
|
<p class="text-sm leading-7 text-slate-700">{declaration.text}</p>
|
|
214
202
|
)}
|
|
215
203
|
{(declaration.place || declaration.name) && (
|
|
216
|
-
<div class="flex flex-wrap items-center justify-between gap-
|
|
204
|
+
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-slate-200 pt-3 text-sm text-slate-700">
|
|
217
205
|
{declaration.place && (
|
|
218
|
-
<div>
|
|
206
|
+
<div class="text-xs uppercase tracking-[0.12em] text-slate-600">
|
|
219
207
|
<span class="font-semibold text-slate-900">Place:</span>{" "}
|
|
220
208
|
{declaration.place}
|
|
221
209
|
</div>
|
|
222
210
|
)}
|
|
223
211
|
{declaration.name && (
|
|
224
|
-
<div class="font-
|
|
212
|
+
<div class="text-base font-bold tracking-wide text-slate-950">{declaration.name}</div>
|
|
225
213
|
)}
|
|
226
214
|
</div>
|
|
227
215
|
)}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
import type { ResumeData } from "./typescript-schema";
|
|
3
|
-
import { formatDateRange } from "./typescript-schema";
|
|
3
|
+
import { buildContactEntries, formatDateRange } from "./typescript-schema";
|
|
4
4
|
|
|
5
5
|
interface Props {
|
|
6
6
|
data: ResumeData;
|
|
@@ -17,26 +17,14 @@ const locationLabel =
|
|
|
17
17
|
basics.location?.label ||
|
|
18
18
|
[basics.location?.city, basics.location?.country].filter(Boolean).join(", ");
|
|
19
19
|
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
if (basics.contact.email) {
|
|
26
|
-
contactItems.push({ label: basics.contact.email, href: `mailto:${basics.contact.email}` });
|
|
27
|
-
}
|
|
28
|
-
if (basics.contact.phone) {
|
|
29
|
-
const phoneHref = basics.contact.phone.replace(/\s+/g, "");
|
|
30
|
-
contactItems.push({ label: basics.contact.phone, href: `tel:${phoneHref}` });
|
|
31
|
-
}
|
|
32
|
-
if (basics.contact.website) {
|
|
33
|
-
contactItems.push({ label: formatUrlLabel(basics.contact.website), href: basics.contact.website });
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const directContactLine = [basics.contact.email, basics.contact.phone].filter(Boolean).join(" • ");
|
|
20
|
+
const contactItems = buildContactEntries(basics, locationLabel);
|
|
21
|
+
const directContactLine = contactItems
|
|
22
|
+
.filter((item) => item.kind === "email" || item.kind === "phone")
|
|
23
|
+
.map((item) => item.label)
|
|
24
|
+
.join(" • ");
|
|
37
25
|
|
|
38
26
|
const skills = data.skills ?? [];
|
|
39
|
-
const links =
|
|
27
|
+
const links = contactItems.filter((item) => item.kind === "link");
|
|
40
28
|
const languages = data.languages ?? [];
|
|
41
29
|
const experienceItems = data.experience ?? [];
|
|
42
30
|
const projects = data.projects ?? [];
|
|
@@ -50,12 +38,12 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
|
|
|
50
38
|
<main class="mx-auto max-w-4xl p-4 sm:p-6">
|
|
51
39
|
<section class="overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-slate-200 print:shadow-none print:ring-0">
|
|
52
40
|
<div class="grid lg:grid-cols-12 print:grid-cols-1">
|
|
53
|
-
<aside class="bg-slate-
|
|
41
|
+
<aside class="bg-slate-800 p-7 text-slate-100 sm:p-10 lg:col-span-4 print:order-2 print:bg-white print:text-slate-900">
|
|
54
42
|
<div class="space-y-6">
|
|
55
43
|
<div>
|
|
56
44
|
<h1 class="text-3xl font-bold tracking-tight">{firstName}</h1>
|
|
57
45
|
{lastName && <h1 class="-mt-1 text-3xl font-bold tracking-tight">{lastName}</h1>}
|
|
58
|
-
<p class="mt-2 text-sm text-slate-
|
|
46
|
+
<p class="mt-2 text-sm text-slate-100/90 print:text-slate-700">{basics.headline}</p>
|
|
59
47
|
</div>
|
|
60
48
|
|
|
61
49
|
<div class="h-px bg-slate-700 print:bg-slate-200"></div>
|
|
@@ -66,7 +54,7 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
|
|
|
66
54
|
<div class="space-y-1 text-slate-200 print:text-slate-700">
|
|
67
55
|
{directContactLine && <div class="font-medium">{directContactLine}</div>}
|
|
68
56
|
{contactItems
|
|
69
|
-
.filter((item) => item.
|
|
57
|
+
.filter((item) => item.kind !== "email" && item.kind !== "phone")
|
|
70
58
|
.map((item) =>
|
|
71
59
|
item.href ? (
|
|
72
60
|
<a class="underline underline-offset-2 print:no-underline" href={item.href}>
|
|
@@ -96,9 +84,13 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
|
|
|
96
84
|
<div class="text-sm font-semibold text-slate-100 print:text-slate-900">LINKS</div>
|
|
97
85
|
<div class="space-y-1 text-sm text-slate-200 print:text-slate-700">
|
|
98
86
|
{links.map((link) => (
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
87
|
+
link.href ? (
|
|
88
|
+
<a class="underline underline-offset-2 print:no-underline" href={link.href}>
|
|
89
|
+
{link.label}
|
|
90
|
+
</a>
|
|
91
|
+
) : (
|
|
92
|
+
<div>{link.label}</div>
|
|
93
|
+
)
|
|
102
94
|
))}
|
|
103
95
|
</div>
|
|
104
96
|
</div>
|
|
@@ -235,26 +227,26 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
|
|
|
235
227
|
</div>
|
|
236
228
|
</section>
|
|
237
229
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
</div>
|
|
252
|
-
)}
|
|
253
|
-
{declaration.name && (
|
|
254
|
-
<div class="font-semibold text-slate-900">{declaration.name}</div>
|
|
255
|
-
)}
|
|
230
|
+
{hasDeclaration && (
|
|
231
|
+
<section class="av-print-avoid-break">
|
|
232
|
+
<div class="my-8 h-px bg-slate-200"></div>
|
|
233
|
+
<h2 class="text-xs font-bold tracking-widest text-slate-500">DECLARATION</h2>
|
|
234
|
+
{declaration.text && (
|
|
235
|
+
<p class="mt-3 text-sm leading-6 text-slate-700">{declaration.text}</p>
|
|
236
|
+
)}
|
|
237
|
+
{(declaration.place || declaration.name) && (
|
|
238
|
+
<div class="mt-3 flex flex-wrap items-center justify-between gap-3 border-t border-slate-200 pt-3 text-sm text-slate-700">
|
|
239
|
+
{declaration.place && (
|
|
240
|
+
<div class="text-xs uppercase tracking-[0.12em] text-slate-600">
|
|
241
|
+
<span class="font-semibold text-slate-900">Place:</span>{" "}
|
|
242
|
+
{declaration.place}
|
|
256
243
|
</div>
|
|
257
244
|
)}
|
|
245
|
+
{declaration.name && (
|
|
246
|
+
<div class="text-base font-bold tracking-wide text-slate-950">{declaration.name}</div>
|
|
247
|
+
)}
|
|
248
|
+
</div>
|
|
249
|
+
)}
|
|
258
250
|
</section>
|
|
259
251
|
)}
|
|
260
252
|
</div>
|
|
@@ -117,3 +117,150 @@ export function formatDateRange(item: {
|
|
|
117
117
|
const end = item.end.month ? `${m(item.end.month)}/${item.end.year}` : `${item.end.year}`;
|
|
118
118
|
return `${start} — ${end}`;
|
|
119
119
|
}
|
|
120
|
+
|
|
121
|
+
export type ResumeContactEntryKind = "location" | "email" | "phone" | "website" | "link";
|
|
122
|
+
|
|
123
|
+
export type ResumeContactEntry = {
|
|
124
|
+
kind: ResumeContactEntryKind;
|
|
125
|
+
label: string;
|
|
126
|
+
href?: string;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const clean = (value?: string | null) => (value ?? "").replace(/\s+/g, " ").trim();
|
|
130
|
+
|
|
131
|
+
const normalizeEmail = (value?: string | null) => clean(value).toLowerCase();
|
|
132
|
+
|
|
133
|
+
const normalizePhone = (value?: string | null) => clean(value).replace(/[^\d+]/g, "");
|
|
134
|
+
|
|
135
|
+
const normalizeUrlHref = (value?: string | null) => {
|
|
136
|
+
const raw = clean(value);
|
|
137
|
+
if (!raw) return "";
|
|
138
|
+
if (/^mailto:/i.test(raw) || /^tel:/i.test(raw)) return raw;
|
|
139
|
+
const withoutScheme = raw.replace(/^https?:\/\//i, "").replace(/^\/+/, "");
|
|
140
|
+
if (!withoutScheme) return "";
|
|
141
|
+
return `https://${withoutScheme}`;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const canonicalUrlKey = (value?: string | null) => {
|
|
145
|
+
const href = normalizeUrlHref(value);
|
|
146
|
+
if (!href) return "";
|
|
147
|
+
if (/^mailto:/i.test(href) || /^tel:/i.test(href)) return href.toLowerCase();
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const parsed = new URL(href);
|
|
151
|
+
const pathname = parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/$/, "");
|
|
152
|
+
return `${parsed.protocol}//${parsed.hostname.toLowerCase()}${pathname}${parsed.search}`.toLowerCase();
|
|
153
|
+
} catch {
|
|
154
|
+
return href.toLowerCase();
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export const formatUrlLabel = (value?: string | null) => {
|
|
159
|
+
const href = normalizeUrlHref(value);
|
|
160
|
+
if (!href) return "";
|
|
161
|
+
if (/^mailto:/i.test(href) || /^tel:/i.test(href)) return href;
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const parsed = new URL(href);
|
|
165
|
+
const pathname = parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/$/, "");
|
|
166
|
+
return `${parsed.hostname.toLowerCase()}${pathname}${parsed.search}`;
|
|
167
|
+
} catch {
|
|
168
|
+
return href.replace(/^https?:\/\//i, "").replace(/\/$/, "");
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export const truncateText = (value?: string | null, max = 160) => {
|
|
173
|
+
const normalized = clean(value);
|
|
174
|
+
if (!normalized) return "";
|
|
175
|
+
if (normalized.length <= max) return normalized;
|
|
176
|
+
return `${normalized.slice(0, Math.max(0, max - 1)).trimEnd()}…`;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
export const buildContactEntries = (
|
|
180
|
+
basics: ResumeData["basics"],
|
|
181
|
+
locationLabel?: string,
|
|
182
|
+
): ResumeContactEntry[] => {
|
|
183
|
+
const seen = new Set<string>();
|
|
184
|
+
const entries: ResumeContactEntry[] = [];
|
|
185
|
+
|
|
186
|
+
const pushEntry = (entry: ResumeContactEntry, key: string) => {
|
|
187
|
+
if (!entry.label || !key || seen.has(key)) return;
|
|
188
|
+
seen.add(key);
|
|
189
|
+
entries.push(entry);
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const normalizedLocation = clean(locationLabel);
|
|
193
|
+
if (normalizedLocation) {
|
|
194
|
+
pushEntry(
|
|
195
|
+
{ kind: "location", label: normalizedLocation },
|
|
196
|
+
`location:${normalizedLocation.toLowerCase()}`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const email = normalizeEmail(basics.contact.email);
|
|
201
|
+
if (email) {
|
|
202
|
+
pushEntry({ kind: "email", label: email, href: `mailto:${email}` }, `email:${email}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const phoneLabel = clean(basics.contact.phone);
|
|
206
|
+
const phone = normalizePhone(basics.contact.phone);
|
|
207
|
+
if (phoneLabel && phone) {
|
|
208
|
+
pushEntry({ kind: "phone", label: phoneLabel, href: `tel:${phone}` }, `phone:${phone}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const websiteHref = normalizeUrlHref(basics.contact.website);
|
|
212
|
+
if (websiteHref) {
|
|
213
|
+
const websiteKey = canonicalUrlKey(websiteHref);
|
|
214
|
+
pushEntry(
|
|
215
|
+
{
|
|
216
|
+
kind: "website",
|
|
217
|
+
label: formatUrlLabel(websiteHref),
|
|
218
|
+
href: websiteHref,
|
|
219
|
+
},
|
|
220
|
+
`url:${websiteKey}`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
for (const link of basics.links ?? []) {
|
|
225
|
+
const rawLabel = clean(link.label);
|
|
226
|
+
const href = normalizeUrlHref(link.url);
|
|
227
|
+
|
|
228
|
+
if (/^mailto:/i.test(href)) {
|
|
229
|
+
const linkedEmail = normalizeEmail(href.replace(/^mailto:/i, ""));
|
|
230
|
+
if (linkedEmail) {
|
|
231
|
+
pushEntry({ kind: "email", label: linkedEmail, href: `mailto:${linkedEmail}` }, `email:${linkedEmail}`);
|
|
232
|
+
}
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (/^tel:/i.test(href)) {
|
|
237
|
+
const linkedPhone = normalizePhone(href.replace(/^tel:/i, ""));
|
|
238
|
+
if (linkedPhone) {
|
|
239
|
+
pushEntry(
|
|
240
|
+
{ kind: "phone", label: linkedPhone, href: `tel:${linkedPhone}` },
|
|
241
|
+
`phone:${linkedPhone}`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (href) {
|
|
248
|
+
const key = canonicalUrlKey(href);
|
|
249
|
+
pushEntry(
|
|
250
|
+
{
|
|
251
|
+
kind: "link",
|
|
252
|
+
label: rawLabel || formatUrlLabel(href),
|
|
253
|
+
href,
|
|
254
|
+
},
|
|
255
|
+
`url:${key}`,
|
|
256
|
+
);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (rawLabel) {
|
|
261
|
+
pushEntry({ kind: "link", label: rawLabel }, `text:${rawLabel.toLowerCase()}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return entries;
|
|
266
|
+
};
|