@ansiversa/components 0.0.122 → 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.122",
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 ?? [];
@@ -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 ?? "")
@@ -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 ?? [];
@@ -64,7 +48,7 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
64
48
  </div>
65
49
 
66
50
  {basics.summary && (
67
- <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>
68
52
  )}
69
53
  </header>
70
54
 
@@ -86,13 +70,13 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
86
70
  <p class="text-sm text-slate-500">{formatDateRange(item)}</p>
87
71
  </div>
88
72
  {item.summary && (
89
- <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>
90
74
  )}
91
75
  {item.bullets && item.bullets.length > 0 && (
92
76
  <ul class="mt-3 space-y-2 text-sm leading-7 text-slate-700">
93
77
  {item.bullets.map((bullet) => (
94
78
  <li>
95
- <span class="font-medium">•</span> {bullet}
79
+ <span class="font-medium">•</span> {truncateText(bullet, 140)}
96
80
  </li>
97
81
  ))}
98
82
  </ul>
@@ -128,7 +112,7 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
128
112
  )}
129
113
  </div>
130
114
  {project.summary && (
131
- <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>
132
116
  )}
133
117
  {project.tags && project.tags.length > 0 && (
134
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 ?? [];
@@ -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
+ };