Sifa professional network frontend (Next.js, React, TailwindCSS)
sifa.id/
1import type { LocationValue } from '@/lib/types';
2import { formatLocation } from '@/lib/location-utils';
3
4interface ProfilePosition {
5 company?: string;
6 title?: string;
7 startedAt?: string;
8 endedAt?: string;
9 description?: string;
10}
11
12interface ProfileEducation {
13 institution?: string;
14 degree?: string;
15 fieldOfStudy?: string;
16}
17
18interface ProfileSkill {
19 name?: string;
20}
21
22interface ProfileCertification {
23 name?: string;
24 issuingOrg?: string;
25 credentialUrl?: string;
26}
27
28interface ProfileVolunteering {
29 organization?: string;
30 role?: string;
31 startDate?: string;
32 endDate?: string;
33}
34
35interface ProfileHonor {
36 title?: string;
37}
38
39interface ProfileLanguage {
40 language?: string;
41}
42
43interface VerifiedAccount {
44 platform: string;
45 identifier: string;
46 url?: string;
47}
48
49interface ProfileData {
50 handle: string;
51 displayName?: string;
52 headline?: string;
53 about?: string;
54 avatar?: string;
55 location?: LocationValue | null;
56 website?: string;
57 positions?: ProfilePosition[];
58 education?: ProfileEducation[];
59 skills?: ProfileSkill[];
60 certifications?: ProfileCertification[];
61 volunteering?: ProfileVolunteering[];
62 honors?: ProfileHonor[];
63 languages?: ProfileLanguage[];
64 verifiedAccounts?: VerifiedAccount[];
65}
66
67type Sanitizer = (input: string) => string;
68
69const identity: Sanitizer = (input: string) => input;
70
71export function buildPersonJsonLd(profile: ProfileData, sanitizer: Sanitizer = identity) {
72 const s = sanitizer;
73 const currentPosition = profile.positions?.find((p) => !p.endedAt);
74
75 // Collect sameAs URLs from verified accounts and website
76 const sameAs: string[] = [];
77 if (profile.website) {
78 const url = profile.website.startsWith('http') ? profile.website : `https://${profile.website}`;
79 sameAs.push(url);
80 }
81 if (profile.verifiedAccounts) {
82 for (const account of profile.verifiedAccounts) {
83 if (account.url) sameAs.push(account.url);
84 }
85 }
86
87 const hasCredential = [
88 ...(profile.education ?? [])
89 .filter((e) => e.degree)
90 .map((e) => ({
91 '@type': 'EducationalOccupationalCredential' as const,
92 credentialCategory: 'degree' as const,
93 name: [e.degree, e.fieldOfStudy].filter(Boolean).join(' '),
94 ...(e.institution && {
95 recognizedBy: {
96 '@type': 'EducationalOrganization' as const,
97 name: s(e.institution),
98 },
99 }),
100 })),
101 ...(profile.certifications ?? [])
102 .filter((c) => c.name)
103 .map((c) => ({
104 '@type': 'EducationalOccupationalCredential' as const,
105 name: s(c.name!),
106 ...(c.issuingOrg && {
107 recognizedBy: {
108 '@type': 'Organization' as const,
109 name: s(c.issuingOrg),
110 },
111 }),
112 ...(c.credentialUrl && { url: c.credentialUrl }),
113 })),
114 ];
115
116 return {
117 '@context': 'https://schema.org',
118 '@type': 'Person',
119 name: s(profile.displayName ?? profile.handle),
120 jobTitle: profile.headline
121 ? s(profile.headline)
122 : currentPosition?.title
123 ? s(currentPosition.title)
124 : undefined,
125 description: profile.about ? s(profile.about) : undefined,
126 url: `https://sifa.id/p/${profile.handle}`,
127 image: profile.avatar ?? undefined,
128 ...(profile.location && {
129 homeLocation: {
130 '@type': 'Place',
131 name: s(formatLocation(profile.location)),
132 ...(profile.location.countryCode && {
133 address: {
134 '@type': 'PostalAddress',
135 addressCountry: profile.location.countryCode,
136 },
137 }),
138 },
139 }),
140 ...(profile.positions?.length && {
141 worksFor: profile.positions
142 .filter((p) => p.company)
143 .map((p) => ({
144 '@type': 'Organization' as const,
145 name: s(p.company!),
146 ...(p.title && {
147 member: {
148 '@type': 'OrganizationRole' as const,
149 roleName: s(p.title),
150 ...(p.startedAt && { startDate: p.startedAt }),
151 ...(p.endedAt && { endDate: p.endedAt }),
152 },
153 }),
154 })),
155 }),
156 ...(profile.education?.length && {
157 alumniOf: profile.education
158 .filter((e) => e.institution)
159 .map((e) => ({
160 '@type': 'EducationalOrganization' as const,
161 name: s(e.institution!),
162 })),
163 }),
164 ...(hasCredential.length > 0 && { hasCredential }),
165 ...(profile.volunteering?.length && {
166 memberOf: profile.volunteering
167 .filter((v) => v.organization)
168 .map((v) => ({
169 '@type': 'OrganizationRole' as const,
170 memberOf: {
171 '@type': 'Organization' as const,
172 name: s(v.organization!),
173 },
174 ...(v.role && { roleName: s(v.role) }),
175 ...(v.startDate && { startDate: v.startDate }),
176 ...(v.endDate && { endDate: v.endDate }),
177 })),
178 }),
179 ...(() => {
180 const awards = (profile.honors ?? []).filter((h) => h.title).map((h) => s(h.title!));
181 return awards.length > 0 ? { award: awards } : {};
182 })(),
183 ...(profile.languages?.length && {
184 knowsLanguage: profile.languages.filter((l) => l.language).map((l) => l.language!),
185 }),
186 ...(profile.skills?.length && {
187 knowsAbout: profile.skills.map((sk) => (sk.name ? s(sk.name) : undefined)),
188 }),
189 ...(sameAs.length > 0 && { sameAs }),
190 };
191}
192
193export function buildProfilePageJsonLd(profile: ProfileData, sanitizer: Sanitizer = identity) {
194 const person = buildPersonJsonLd(profile, sanitizer);
195 const { '@context': _, ...personWithoutContext } = person;
196
197 return {
198 '@context': 'https://schema.org',
199 '@type': 'ProfilePage' as const,
200 url: `https://sifa.id/p/${profile.handle}`,
201 mainEntity: personWithoutContext,
202 };
203}
204
205/**
206 * Generate a meta description from profile data.
207 * Falls back gracefully when data is incomplete.
208 */
209export function buildMetaDescription(profile: ProfileData): string {
210 const parts: string[] = [];
211
212 if (profile.headline) {
213 parts.push(profile.headline);
214 }
215
216 const currentPosition = profile.positions?.find((p) => !p.endedAt);
217 if (currentPosition) {
218 const positionParts: string[] = [];
219 if (currentPosition.title) positionParts.push(currentPosition.title);
220 if (currentPosition.company) positionParts.push(`at ${currentPosition.company}`);
221 if (positionParts.length > 0) parts.push(positionParts.join(' '));
222 }
223
224 if (profile.location) {
225 parts.push(formatLocation(profile.location));
226 }
227
228 if (parts.length === 0) {
229 return `${profile.displayName ?? profile.handle} on Sifa`;
230 }
231
232 return parts.join(' · ');
233}