@ansiversa/components 0.0.121 → 0.0.123

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ansiversa/components",
3
- "version": "0.0.121",
3
+ "version": "0.0.123",
4
4
  "description": "Shared UI components and layouts for the Ansiversa ecosystem",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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 formatUrlLabel = (value: string) =>
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 ?? [];
@@ -47,7 +27,7 @@ const declaration = data.declaration ?? {};
47
27
  const hasDeclaration = Boolean(declaration.text || declaration.place || declaration.name);
48
28
  ---
49
29
 
50
- <div class="resume-template bg-slate-100 text-slate-900 print:bg-white">
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
33
  <header class="av-print-avoid-break flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
@@ -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 formatUrlLabel = (value: string) =>
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 ?? "")
@@ -41,7 +31,7 @@ const declaration = data.declaration ?? {};
41
31
  const hasDeclaration = Boolean(declaration.text || declaration.place || declaration.name);
42
32
  ---
43
33
 
44
- <div class="resume-template bg-slate-100 text-slate-900 print:bg-white">
34
+ <div class="resume-template av-print-white bg-slate-100 text-slate-900 print:bg-white">
45
35
  <main class="mx-auto max-w-4xl p-4 sm:p-6">
46
36
  <section class="rounded-2xl bg-white p-7 shadow-sm ring-1 ring-slate-200 sm:p-10 print:shadow-none print:ring-0">
47
37
  <header class="av-print-avoid-break flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
@@ -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
  )}
@@ -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 formatUrlLabel = (value: string) =>
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 ?? [];
@@ -41,20 +25,7 @@ const showSkillsAs = data.settings?.showSkillsAs ?? "levels";
41
25
  const declaration = data.declaration ?? {};
42
26
  const hasDeclaration = Boolean(declaration.text || declaration.place || declaration.name);
43
27
  ---
44
-
45
- <style>
46
- @media print {
47
- .resume-template a {
48
- text-decoration: none;
49
- color: inherit;
50
- }
51
- .resume-template {
52
- background: white;
53
- }
54
- }
55
- </style>
56
-
57
- <div class="resume-template bg-slate-100 text-slate-900 print:bg-white">
28
+ <div class="resume-template av-print-white bg-slate-100 text-slate-900 print:bg-white">
58
29
  <main class="mx-auto max-w-4xl p-4 sm:p-6">
59
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">
60
31
  <header class="space-y-4">
@@ -67,7 +38,7 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
67
38
  <div class="text-sm text-slate-700 sm:text-right">
68
39
  <div class="flex flex-col gap-1">
69
40
  {contactLinks.map((item) => (
70
- <a class="underline underline-offset-4 hover:text-slate-900" href={item.href}>
41
+ <a class="underline underline-offset-4 hover:text-slate-900 print:no-underline" href={item.href}>
71
42
  {item.label}
72
43
  </a>
73
44
  ))}
@@ -77,7 +48,7 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
77
48
  </div>
78
49
 
79
50
  {basics.summary && (
80
- <p class="max-w-3xl text-sm leading-7 text-slate-700">{basics.summary}</p>
51
+ <p class="max-w-3xl text-sm leading-7 text-slate-700">{truncateText(basics.summary, 220)}</p>
81
52
  )}
82
53
  </header>
83
54
 
@@ -99,13 +70,13 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
99
70
  <p class="text-sm text-slate-500">{formatDateRange(item)}</p>
100
71
  </div>
101
72
  {item.summary && (
102
- <p class="mt-3 text-sm leading-7 text-slate-700">{item.summary}</p>
73
+ <p class="mt-3 text-sm leading-7 text-slate-700">{truncateText(item.summary, 220)}</p>
103
74
  )}
104
75
  {item.bullets && item.bullets.length > 0 && (
105
76
  <ul class="mt-3 space-y-2 text-sm leading-7 text-slate-700">
106
77
  {item.bullets.map((bullet) => (
107
78
  <li>
108
- <span class="font-medium">•</span> {bullet}
79
+ <span class="font-medium">•</span> {truncateText(bullet, 140)}
109
80
  </li>
110
81
  ))}
111
82
  </ul>
@@ -141,7 +112,7 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
141
112
  )}
142
113
  </div>
143
114
  {project.summary && (
144
- <p class="mt-2 text-sm leading-7 text-slate-700">{project.summary}</p>
115
+ <p class="mt-2 text-sm leading-7 text-slate-700">{truncateText(project.summary, 220)}</p>
145
116
  )}
146
117
  {project.tags && project.tags.length > 0 && (
147
118
  <div class="mt-3 flex flex-wrap gap-2">
@@ -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 formatUrlLabel = (value: string) =>
21
- value.replace(/^https?:\/\//, "").replace(/\/$/, "");
22
-
23
- const contactItems: Array<{ label: string; href?: string }> = [];
24
- if (locationLabel) contactItems.push({ label: locationLabel });
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 = basics.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 ?? [];
@@ -46,7 +34,7 @@ const declaration = data.declaration ?? {};
46
34
  const hasDeclaration = Boolean(declaration.text || declaration.place || declaration.name);
47
35
  ---
48
36
 
49
- <div class="resume-template bg-slate-100 text-slate-900 print:bg-white">
37
+ <div class="resume-template av-print-white bg-slate-100 text-slate-900 print:bg-white">
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">
@@ -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.label !== basics.contact.email && item.label !== basics.contact.phone)
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
- <a class="underline underline-offset-2 print:no-underline" href={link.url}>
100
- {link.label ?? formatUrlLabel(link.url)}
101
- </a>
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>
@@ -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
+ };
@@ -106,6 +106,15 @@
106
106
  scrollbar-color: rgba(148, 163, 184, 0.6) transparent;
107
107
  }
108
108
 
109
+ @media print {
110
+ html,
111
+ body {
112
+ background: #fff !important;
113
+ background-image: none !important;
114
+ color: #000 !important;
115
+ }
116
+ }
117
+
109
118
  body::-webkit-scrollbar {
110
119
  width: 8px;
111
120
  }
@@ -1980,6 +1989,19 @@
1980
1989
  page-break-inside: avoid;
1981
1990
  }
1982
1991
 
1992
+ @media print {
1993
+ .av-print-hide {
1994
+ display: none !important;
1995
+ }
1996
+
1997
+ .av-print-white {
1998
+ background: #fff !important;
1999
+ color: #000 !important;
2000
+ box-shadow: none !important;
2001
+ background-image: none !important;
2002
+ }
2003
+ }
2004
+
1983
2005
  /* Loading bar utility */
1984
2006
  .av-loading-bar {
1985
2007
  height: 4px;