@takuhon/api 0.15.1 → 0.17.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/dist/index.js CHANGED
@@ -46,13 +46,311 @@ import {
46
46
  NotFoundError,
47
47
  SCHEMA_VERSION,
48
48
  applyPublicPrivacyFilter,
49
- generateJsonLd,
49
+ generateJsonLd as generateJsonLd2,
50
50
  normalize,
51
51
  resolveLocale,
52
52
  schema
53
53
  } from "@takuhon/core";
54
54
  import { Hono } from "hono";
55
55
 
56
+ // src/html/build-html.ts
57
+ import { generateJsonLd, renderActivitySvg } from "@takuhon/core";
58
+
59
+ // src/html/html-helpers.ts
60
+ function escapeHtml(value) {
61
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
62
+ }
63
+ function safeUrl(url) {
64
+ const trimmed = url.trim();
65
+ const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(trimmed)?.[1]?.toLowerCase();
66
+ if (scheme === void 0) return trimmed;
67
+ return scheme === "http" || scheme === "https" || scheme === "mailto" ? trimmed : void 0;
68
+ }
69
+ function dateRange(start, end, isCurrent) {
70
+ const left = start ?? "";
71
+ const right = isCurrent === true || end === null ? "Present" : end ?? "";
72
+ if (left && right) return `${left} \u2013 ${right}`;
73
+ return left || right;
74
+ }
75
+ function nonEmpty(values, separator) {
76
+ const joined = values.filter((v) => typeof v === "string" && v.length > 0).join(separator);
77
+ return joined.length > 0 ? joined : void 0;
78
+ }
79
+
80
+ // src/html/build-html.ts
81
+ function escapeJsonLd(json) {
82
+ return json.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
83
+ }
84
+ var CSS = `:root{--fg:#1a1a1a;--muted:#666;--accent:#0b5fff;--line:#e5e5e5}
85
+ *{box-sizing:border-box}
86
+ body{margin:0;color:var(--fg);font:16px/1.6 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;background:#fff}
87
+ main{max-width:42rem;margin:0 auto;padding:2rem 1.25rem}
88
+ a{color:var(--accent)}
89
+ h1{font-size:1.9rem;margin:.2rem 0}
90
+ h2{font-size:1.15rem;margin:2rem 0 .75rem;padding-bottom:.3rem;border-bottom:1px solid var(--line)}
91
+ h3{font-size:1rem;margin:0}
92
+ header .avatar{width:96px;height:96px;border-radius:50%;object-fit:cover}
93
+ .tagline{font-size:1.1rem;color:var(--muted);margin:.2rem 0}
94
+ .location{color:var(--muted);margin:.2rem 0}
95
+ .bio{margin:.75rem 0}
96
+ ul{padding:0;margin:0;list-style:none}
97
+ .entries>li{margin:0 0 1.1rem}
98
+ .sub{margin:.1rem 0;font-weight:600}
99
+ .meta{margin:.1rem 0;color:var(--muted);font-size:.9rem}
100
+ .links{display:flex;flex-wrap:wrap;gap:.5rem 1rem;margin:.75rem 0}
101
+ .skills,.tags{display:flex;flex-wrap:wrap;gap:.4rem}
102
+ .skills>li,.tags>li{background:#f2f2f2;border-radius:1rem;padding:.15rem .6rem;font-size:.85rem}
103
+ .rec{margin:0 0 1.1rem}
104
+ .rec blockquote{margin:0;padding-left:.9rem;border-left:3px solid var(--line)}
105
+ .rec figcaption{color:var(--muted);font-size:.9rem;margin-top:.3rem}
106
+ nav.locales{display:flex;gap:.75rem;margin-bottom:1rem;font-size:.9rem}
107
+ .activity svg{max-width:100%;height:auto}
108
+ footer.powered{max-width:42rem;margin:0 auto;padding:1.5rem 1.25rem;color:var(--muted);font-size:.85rem}`;
109
+ function renderEntry(entry) {
110
+ const href = entry.url ? safeUrl(entry.url) : void 0;
111
+ const heading = href ? `<a href="${escapeHtml(href)}">${escapeHtml(entry.heading)}</a>` : escapeHtml(entry.heading);
112
+ const parts = [`<h3>${heading}</h3>`];
113
+ if (entry.sub) parts.push(`<p class="sub">${escapeHtml(entry.sub)}</p>`);
114
+ if (entry.dates) parts.push(`<p class="meta">${escapeHtml(entry.dates)}</p>`);
115
+ if (entry.body) parts.push(`<p>${escapeHtml(entry.body)}</p>`);
116
+ if (entry.tags && entry.tags.length > 0) {
117
+ parts.push(
118
+ `<ul class="tags">${entry.tags.map((t) => `<li>${escapeHtml(t)}</li>`).join("")}</ul>`
119
+ );
120
+ }
121
+ return `<li>${parts.join("")}</li>`;
122
+ }
123
+ function entryList(title, entries) {
124
+ if (entries.length === 0) return "";
125
+ return `<section><h2>${escapeHtml(title)}</h2><ul class="entries">${entries.map(renderEntry).join("")}</ul></section>`;
126
+ }
127
+ function renderHeader(p) {
128
+ const parts = [];
129
+ const avatarSrc = p.avatar?.url ? safeUrl(p.avatar.url) : void 0;
130
+ if (avatarSrc) {
131
+ parts.push(
132
+ `<img class="avatar" src="${escapeHtml(avatarSrc)}" alt="${escapeHtml(p.avatar?.alt ?? "")}">`
133
+ );
134
+ }
135
+ parts.push(`<h1>${escapeHtml(p.displayName)}</h1>`);
136
+ if (p.tagline) parts.push(`<p class="tagline">${escapeHtml(p.tagline)}</p>`);
137
+ if (p.location?.display) parts.push(`<p class="location">${escapeHtml(p.location.display)}</p>`);
138
+ if (p.bio) parts.push(`<p class="bio">${escapeHtml(p.bio)}</p>`);
139
+ return `<header>${parts.join("")}</header>`;
140
+ }
141
+ function renderLinks(links) {
142
+ if (links.length === 0) return "";
143
+ const items = links.map((l) => {
144
+ const text = escapeHtml(l.label ?? l.url);
145
+ const href = safeUrl(l.url);
146
+ return href ? `<li><a href="${escapeHtml(href)}">${text}</a></li>` : `<li>${text}</li>`;
147
+ }).join("");
148
+ return `<nav aria-label="Links"><ul class="links">${items}</ul></nav>`;
149
+ }
150
+ function renderSkills(skills) {
151
+ if (skills.length === 0) return "";
152
+ const items = skills.map((s) => `<li>${escapeHtml(s.label)}</li>`).join("");
153
+ return `<section><h2>Skills</h2><ul class="skills">${items}</ul></section>`;
154
+ }
155
+ function renderLanguages(languages) {
156
+ if (languages.length === 0) return "";
157
+ const items = languages.map((l) => `<li>${escapeHtml(`${l.displayName ?? l.language} \u2014 ${l.proficiency}`)}</li>`).join("");
158
+ return `<section><h2>Languages</h2><ul class="entries">${items}</ul></section>`;
159
+ }
160
+ function renderRecommendations(recs) {
161
+ if (recs.length === 0) return "";
162
+ const items = recs.map((r) => {
163
+ const authorHref = r.author.url ? safeUrl(r.author.url) : void 0;
164
+ const name = authorHref ? `<a href="${escapeHtml(authorHref)}">${escapeHtml(r.author.name)}</a>` : escapeHtml(r.author.name);
165
+ const caption = [name, r.author.headline ? escapeHtml(r.author.headline) : ""].filter(Boolean).join(", ");
166
+ const rel = r.relationship ? ` (${escapeHtml(r.relationship)})` : "";
167
+ return `<figure class="rec"><blockquote>${escapeHtml(r.body)}</blockquote><figcaption>\u2014 ${caption}${rel}</figcaption></figure>`;
168
+ }).join("");
169
+ return `<section><h2>Recommendations</h2>${items}</section>`;
170
+ }
171
+ function renderContact(contact) {
172
+ const items = [];
173
+ if (contact.email) {
174
+ items.push(
175
+ `<li><a href="mailto:${escapeHtml(contact.email)}">${escapeHtml(contact.email)}</a></li>`
176
+ );
177
+ }
178
+ const formHref = contact.formUrl ? safeUrl(contact.formUrl) : void 0;
179
+ if (formHref) {
180
+ items.push(`<li><a href="${escapeHtml(formHref)}">Contact form</a></li>`);
181
+ }
182
+ if (items.length === 0) return "";
183
+ return `<section><h2>Contact</h2><ul class="entries">${items.join("")}</ul></section>`;
184
+ }
185
+ function renderActivity(snapshot) {
186
+ if (!snapshot) return "";
187
+ const svg = renderActivitySvg(snapshot);
188
+ if (svg === "") return "";
189
+ return `<section class="activity"><h2>Activity</h2>${svg}</section>`;
190
+ }
191
+ function renderJsonLdScript(data) {
192
+ const payload = JSON.stringify(generateJsonLd(data));
193
+ return `<script type="application/ld+json">${escapeJsonLd(payload)}</script>`;
194
+ }
195
+ function renderLocaleNav(localeNav) {
196
+ const items = localeNav.map(
197
+ (l) => l.current ? `<span aria-current="true">${escapeHtml(l.locale)}</span>` : `<a href="${escapeHtml(l.href)}">${escapeHtml(l.locale)}</a>`
198
+ ).join("");
199
+ return `<nav class="locales" aria-label="Language">${items}</nav>`;
200
+ }
201
+ function renderProfileHtml(input) {
202
+ const d = input.localized;
203
+ const p = d.profile;
204
+ const description = p.tagline ?? p.bio ?? "";
205
+ const head = [
206
+ '<meta charset="utf-8">',
207
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
208
+ `<title>${escapeHtml(p.displayName)}</title>`,
209
+ description ? `<meta name="description" content="${escapeHtml(description.slice(0, 300))}">` : "",
210
+ input.canonicalUrl ? `<link rel="canonical" href="${escapeHtml(input.canonicalUrl)}">` : "",
211
+ ...input.alternates.map(
212
+ (a) => `<link rel="alternate" hreflang="${escapeHtml(a.hreflang)}" href="${escapeHtml(a.href)}">`
213
+ ),
214
+ input.jsonLd ? renderJsonLdScript(d) : "",
215
+ `<style>${CSS}</style>`
216
+ ].filter(Boolean).join("\n ");
217
+ const body = [
218
+ input.localeNav.length > 1 ? renderLocaleNav(input.localeNav) : "",
219
+ renderHeader(p),
220
+ renderLinks(d.links),
221
+ entryList(
222
+ "Experience",
223
+ d.careers.map((c) => ({
224
+ heading: c.role,
225
+ sub: c.organization,
226
+ dates: dateRange(c.startDate, c.endDate, c.isCurrent),
227
+ body: c.description,
228
+ url: c.url
229
+ }))
230
+ ),
231
+ entryList(
232
+ "Projects",
233
+ d.projects.map((x) => ({
234
+ heading: x.title,
235
+ dates: dateRange(x.startDate, x.endDate),
236
+ body: x.description,
237
+ url: x.url,
238
+ tags: x.tags
239
+ }))
240
+ ),
241
+ renderSkills(d.skills),
242
+ renderActivity(input.activitySnapshot),
243
+ entryList(
244
+ "Education",
245
+ d.education.map((e) => {
246
+ const degree = nonEmpty([e.degree, e.fieldOfStudy], ", ");
247
+ return {
248
+ heading: degree ?? e.institution,
249
+ sub: degree ? e.institution : void 0,
250
+ dates: dateRange(e.startDate, e.endDate, e.isCurrent),
251
+ body: e.description,
252
+ url: e.url
253
+ };
254
+ })
255
+ ),
256
+ entryList(
257
+ "Certifications",
258
+ d.certifications.map((c) => ({
259
+ heading: c.title,
260
+ sub: c.issuingOrganization,
261
+ dates: dateRange(c.issueDate, c.expirationDate),
262
+ url: c.url
263
+ }))
264
+ ),
265
+ entryList(
266
+ "Publications",
267
+ d.publications.map((x) => ({
268
+ heading: x.title,
269
+ sub: nonEmpty([x.publisher, x.coAuthors?.join(", ")], " \xB7 "),
270
+ dates: dateRange(x.date),
271
+ body: x.description,
272
+ url: x.url ?? (x.doi ? `https://doi.org/${x.doi}` : void 0)
273
+ }))
274
+ ),
275
+ entryList(
276
+ "Honors & awards",
277
+ d.honors.map((x) => ({
278
+ heading: x.title,
279
+ sub: x.issuer,
280
+ dates: dateRange(x.date),
281
+ body: x.description,
282
+ url: x.url
283
+ }))
284
+ ),
285
+ entryList(
286
+ "Memberships",
287
+ d.memberships.map((x) => ({
288
+ heading: x.role ?? x.organization,
289
+ sub: x.role ? x.organization : void 0,
290
+ dates: dateRange(x.startDate, x.endDate, x.isCurrent),
291
+ body: x.description,
292
+ url: x.url
293
+ }))
294
+ ),
295
+ entryList(
296
+ "Volunteering",
297
+ d.volunteering.map((x) => ({
298
+ heading: x.role,
299
+ sub: nonEmpty([x.organization, x.cause], " \xB7 "),
300
+ dates: dateRange(x.startDate, x.endDate, x.isCurrent),
301
+ body: x.description,
302
+ url: x.url
303
+ }))
304
+ ),
305
+ entryList(
306
+ "Courses",
307
+ d.courses.map((x) => ({
308
+ heading: x.title,
309
+ sub: x.provider,
310
+ dates: dateRange(x.completionDate),
311
+ body: x.description,
312
+ url: x.certificateUrl
313
+ }))
314
+ ),
315
+ entryList(
316
+ "Patents",
317
+ d.patents.map((x) => ({
318
+ heading: x.title,
319
+ sub: nonEmpty([x.patentNumber, x.office, x.status, x.coInventors?.join(", ")], " \xB7 "),
320
+ dates: dateRange(x.filingDate ?? x.grantDate),
321
+ body: x.description,
322
+ url: x.url
323
+ }))
324
+ ),
325
+ entryList(
326
+ "Test scores",
327
+ d.testScores.map((x) => ({
328
+ heading: `${x.title}: ${x.score}`,
329
+ dates: dateRange(x.date),
330
+ body: x.description,
331
+ url: x.url
332
+ }))
333
+ ),
334
+ renderLanguages(d.languages),
335
+ renderRecommendations(d.recommendations),
336
+ renderContact(d.contact)
337
+ ].filter(Boolean).join("\n");
338
+ const footer = d.settings.showPoweredBy === true ? '<footer class="powered">Powered by takuhon</footer>' : "";
339
+ return `<!DOCTYPE html>
340
+ <html lang="${escapeHtml(d.resolvedLocale)}">
341
+ <head>
342
+ ${head}
343
+ </head>
344
+ <body>
345
+ <main>
346
+ ${body}
347
+ </main>
348
+ ${footer ? `${footer}
349
+ ` : ""}</body>
350
+ </html>
351
+ `;
352
+ }
353
+
56
354
  // src/locale-resolution.ts
57
355
  import { getCookie } from "hono/cookie";
58
356
  var BCP47 = /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]+)*$/;
@@ -174,7 +472,10 @@ function pathLocaleFromUrl(url) {
174
472
  var FALLBACK_VERSION = "bundled-fixture";
175
473
  var PUBLIC_CSP = [
176
474
  "default-src 'self'",
177
- "img-src 'self' data:",
475
+ // `https:` lets the server-rendered profile page load remote avatar images
476
+ // (the schema permits any https avatar URL, and `safeUrl` in the renderer
477
+ // already blocks non-http(s) schemes); `data:` covers inline placeholders.
478
+ "img-src 'self' https: data:",
178
479
  "style-src 'self' 'unsafe-inline'",
179
480
  "script-src 'self'",
180
481
  "font-src 'self'",
@@ -222,7 +523,37 @@ function createPublicApp(deps) {
222
523
  detail: `No route matches ${new URL(c.req.url).pathname}.`
223
524
  })
224
525
  );
225
- app.get("/", (c) => c.text("takuhon \u2014 visit /api/profile or /api/schema\n"));
526
+ app.get("/", async (c) => {
527
+ const { data, version } = await loadProfile(deps);
528
+ const profile = normalize(data);
529
+ const { locale, fallbackLocale } = resolveRequestLocales(
530
+ c,
531
+ profile.settings.availableLocales,
532
+ pathLocaleFromUrl(c.req.url)
533
+ );
534
+ const localized = applyPublicPrivacyFilter(resolveLocale(profile, locale, fallbackLocale));
535
+ const defaultLocale = profile.settings.defaultLocale;
536
+ const locales = [.../* @__PURE__ */ new Set([defaultLocale, ...profile.settings.availableLocales])];
537
+ const current = localized.resolvedLocale;
538
+ const origin = new URL(c.req.url).origin;
539
+ const localePath = (l) => l === defaultLocale ? "/" : `/${l}/`;
540
+ const snapshot = profile.settings.activity?.enabled === true && deps.activityStorage ? await deps.activityStorage.getActivitySnapshot() : null;
541
+ const html = renderProfileHtml({
542
+ localized,
543
+ canonicalUrl: `${origin}${localePath(current)}`,
544
+ alternates: [
545
+ ...locales.map((l) => ({ hreflang: l, href: `${origin}${localePath(l)}` })),
546
+ { hreflang: "x-default", href: `${origin}${localePath(defaultLocale)}` }
547
+ ],
548
+ localeNav: locales.map((l) => ({ locale: l, href: localePath(l), current: l === current })),
549
+ jsonLd: profile.settings.enableJsonLd !== false,
550
+ activitySnapshot: snapshot ?? void 0
551
+ });
552
+ c.header("etag", `"${version}"`);
553
+ c.header("cache-control", "public, max-age=300");
554
+ c.header("vary", "Accept-Language, Cookie");
555
+ return c.html(html);
556
+ });
226
557
  app.get("/health", (c) => {
227
558
  c.header("cache-control", "no-store");
228
559
  return c.json({ status: "ok", schemaVersion: SCHEMA_VERSION });
@@ -276,7 +607,7 @@ function createPublicApp(deps) {
276
607
  const localized = applyPublicPrivacyFilter(
277
608
  resolveLocale(normalize(data), locale, fallbackLocale)
278
609
  );
279
- const ld = generateJsonLd(localized);
610
+ const ld = generateJsonLd2(localized);
280
611
  c.header("etag", `"${version}"`);
281
612
  c.header("cache-control", "private, max-age=300");
282
613
  c.header("vary", "Accept-Language, Cookie");
@@ -907,6 +1238,280 @@ var noopCachePurger = {
907
1238
  profileUpdated: () => Promise.resolve(),
908
1239
  profileDeleted: () => Promise.resolve()
909
1240
  };
1241
+
1242
+ // src/html/site.ts
1243
+ import { deriveCv, resolveLocale as resolveLocale2 } from "@takuhon/core";
1244
+
1245
+ // src/html/cv-html.ts
1246
+ var SECTION_TITLES = {
1247
+ experience: "Experience",
1248
+ education: "Education",
1249
+ skills: "Skills",
1250
+ certifications: "Certifications",
1251
+ publications: "Publications",
1252
+ honors: "Honors & Awards",
1253
+ courses: "Courses",
1254
+ patents: "Patents",
1255
+ languages: "Languages",
1256
+ volunteering: "Volunteering",
1257
+ memberships: "Memberships"
1258
+ };
1259
+ var CSS2 = `:root{--fg:#1a1a1a;--muted:#555;--accent:#0b5fff;--line:#d9d9d9}
1260
+ *{box-sizing:border-box}
1261
+ body{margin:0;background:#f3f4f6;color:var(--fg);font:13px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
1262
+ main{background:#fff;width:210mm;min-height:297mm;margin:1.5rem auto;padding:18mm 16mm;box-shadow:0 1px 6px rgba(0,0,0,.15)}
1263
+ h1{font-size:1.7rem;margin:0}
1264
+ .tagline{font-size:1.05rem;color:var(--muted);margin:.15rem 0 0}
1265
+ .contact{color:var(--muted);font-size:.85rem;margin:.4rem 0 0;display:flex;flex-wrap:wrap;gap:.25rem 1rem}
1266
+ .contact a{color:var(--accent)}
1267
+ .bio{margin:.75rem 0 0}
1268
+ header{border-bottom:2px solid var(--fg);padding-bottom:.6rem;margin-bottom:.4rem}
1269
+ section{margin-top:1.1rem}
1270
+ h2{font-size:.95rem;text-transform:uppercase;letter-spacing:.05em;color:var(--accent);border-bottom:1px solid var(--line);margin:0 0 .5rem;padding-bottom:.15rem}
1271
+ ul{padding:0;margin:0;list-style:none}
1272
+ .entry{margin:0 0 .7rem;break-inside:avoid}
1273
+ .entry .row{display:flex;justify-content:space-between;gap:1rem;align-items:baseline}
1274
+ .entry h3{font-size:.95rem;margin:0}
1275
+ .entry .dates{color:var(--muted);font-size:.8rem;white-space:nowrap}
1276
+ .entry .sub{color:var(--muted);margin:.05rem 0}
1277
+ .entry p{margin:.2rem 0 0}
1278
+ .entry a{color:var(--accent)}
1279
+ .chips{display:flex;flex-wrap:wrap;gap:.3rem}
1280
+ .chips li{border:1px solid var(--line);border-radius:1rem;padding:.05rem .55rem;font-size:.8rem}
1281
+ @media print{
1282
+ body{background:#fff}
1283
+ main{width:auto;min-height:0;margin:0;padding:0;box-shadow:none}
1284
+ a{color:var(--fg)}
1285
+ }
1286
+ @page{size:A4;margin:14mm}`;
1287
+ function renderEntry2(entry) {
1288
+ const href = entry.url ? safeUrl(entry.url) : void 0;
1289
+ const title = href ? `<a href="${escapeHtml(href)}">${escapeHtml(entry.heading)}</a>` : escapeHtml(entry.heading);
1290
+ const dates = entry.dates ? `<span class="dates">${escapeHtml(entry.dates)}</span>` : "";
1291
+ const parts = [`<div class="row"><h3>${title}</h3>${dates}</div>`];
1292
+ if (entry.sub) parts.push(`<p class="sub">${escapeHtml(entry.sub)}</p>`);
1293
+ if (entry.body) parts.push(`<p>${escapeHtml(entry.body)}</p>`);
1294
+ return `<li class="entry">${parts.join("")}</li>`;
1295
+ }
1296
+ function entryListSection(title, entries) {
1297
+ return `<section><h2>${escapeHtml(title)}</h2><ul>${entries.map(renderEntry2).join("")}</ul></section>`;
1298
+ }
1299
+ function chipSection(title, labels) {
1300
+ const chips = labels.map((l) => `<li>${escapeHtml(l)}</li>`).join("");
1301
+ return `<section><h2>${escapeHtml(title)}</h2><ul class="chips">${chips}</ul></section>`;
1302
+ }
1303
+ function languageLabel(l) {
1304
+ return `${l.displayName ?? l.language} \u2014 ${l.proficiency}`;
1305
+ }
1306
+ function renderSection(section) {
1307
+ const title = SECTION_TITLES[section.kind];
1308
+ switch (section.kind) {
1309
+ case "experience":
1310
+ return entryListSection(
1311
+ title,
1312
+ section.entries.map((c) => ({
1313
+ heading: c.role,
1314
+ sub: c.organization,
1315
+ dates: dateRange(c.startDate, c.endDate, c.isCurrent),
1316
+ body: c.description,
1317
+ url: c.url
1318
+ }))
1319
+ );
1320
+ case "education":
1321
+ return entryListSection(
1322
+ title,
1323
+ section.entries.map((e) => {
1324
+ const degree = nonEmpty([e.degree, e.fieldOfStudy], ", ");
1325
+ return {
1326
+ heading: degree ?? e.institution,
1327
+ sub: degree ? e.institution : void 0,
1328
+ dates: dateRange(e.startDate, e.endDate, e.isCurrent),
1329
+ body: e.description,
1330
+ url: e.url
1331
+ };
1332
+ })
1333
+ );
1334
+ case "skills":
1335
+ return chipSection(
1336
+ title,
1337
+ section.entries.map((s) => s.label)
1338
+ );
1339
+ case "languages":
1340
+ return chipSection(title, section.entries.map(languageLabel));
1341
+ case "certifications":
1342
+ return entryListSection(
1343
+ title,
1344
+ section.entries.map((c) => ({
1345
+ heading: c.title,
1346
+ sub: c.issuingOrganization,
1347
+ dates: dateRange(c.issueDate, c.expirationDate),
1348
+ url: c.url
1349
+ }))
1350
+ );
1351
+ case "publications":
1352
+ return entryListSection(
1353
+ title,
1354
+ section.entries.map((x) => ({
1355
+ heading: x.title,
1356
+ sub: nonEmpty([x.publisher, x.coAuthors?.join(", ")], " \xB7 "),
1357
+ dates: dateRange(x.date),
1358
+ body: x.description,
1359
+ url: x.url ?? (x.doi ? `https://doi.org/${x.doi}` : void 0)
1360
+ }))
1361
+ );
1362
+ case "honors":
1363
+ return entryListSection(
1364
+ title,
1365
+ section.entries.map((x) => ({
1366
+ heading: x.title,
1367
+ sub: x.issuer,
1368
+ dates: dateRange(x.date),
1369
+ body: x.description,
1370
+ url: x.url
1371
+ }))
1372
+ );
1373
+ case "courses":
1374
+ return entryListSection(
1375
+ title,
1376
+ section.entries.map((x) => ({
1377
+ heading: x.title,
1378
+ sub: x.provider,
1379
+ dates: dateRange(x.completionDate),
1380
+ body: x.description,
1381
+ url: x.certificateUrl
1382
+ }))
1383
+ );
1384
+ case "patents":
1385
+ return entryListSection(
1386
+ title,
1387
+ section.entries.map((x) => ({
1388
+ heading: x.title,
1389
+ sub: nonEmpty([x.patentNumber, x.office, x.status], " \xB7 "),
1390
+ dates: dateRange(x.filingDate ?? x.grantDate),
1391
+ body: x.description,
1392
+ url: x.url
1393
+ }))
1394
+ );
1395
+ case "volunteering":
1396
+ return entryListSection(
1397
+ title,
1398
+ section.entries.map((x) => ({
1399
+ heading: x.role,
1400
+ sub: nonEmpty([x.organization, x.cause], " \xB7 "),
1401
+ dates: dateRange(x.startDate, x.endDate, x.isCurrent),
1402
+ body: x.description,
1403
+ url: x.url
1404
+ }))
1405
+ );
1406
+ case "memberships":
1407
+ return entryListSection(
1408
+ title,
1409
+ section.entries.map((x) => ({
1410
+ heading: x.role ?? x.organization,
1411
+ sub: x.role ? x.organization : void 0,
1412
+ dates: dateRange(x.startDate, x.endDate, x.isCurrent),
1413
+ body: x.description,
1414
+ url: x.url
1415
+ }))
1416
+ );
1417
+ }
1418
+ }
1419
+ function renderHeader2(cv) {
1420
+ const h = cv.header;
1421
+ const parts = [`<h1>${escapeHtml(h.displayName)}</h1>`];
1422
+ if (h.tagline) parts.push(`<p class="tagline">${escapeHtml(h.tagline)}</p>`);
1423
+ const contact = [];
1424
+ if (h.location) contact.push(`<span>${escapeHtml(h.location)}</span>`);
1425
+ if (h.email) {
1426
+ contact.push(`<a href="mailto:${escapeHtml(h.email)}">${escapeHtml(h.email)}</a>`);
1427
+ }
1428
+ const formHref = h.formUrl ? safeUrl(h.formUrl) : void 0;
1429
+ if (formHref) contact.push(`<a href="${escapeHtml(formHref)}">Contact</a>`);
1430
+ if (contact.length > 0) parts.push(`<div class="contact">${contact.join("")}</div>`);
1431
+ if (h.bio) parts.push(`<p class="bio">${escapeHtml(h.bio)}</p>`);
1432
+ return `<header>${parts.join("")}</header>`;
1433
+ }
1434
+ function renderCvHtml(cv) {
1435
+ const title = `${cv.header.displayName} \u2014 CV`;
1436
+ const body = [renderHeader2(cv), ...cv.sections.map(renderSection)].join("\n");
1437
+ return `<!DOCTYPE html>
1438
+ <html lang="${escapeHtml(cv.resolvedLocale)}">
1439
+ <head>
1440
+ <meta charset="utf-8">
1441
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1442
+ <title>${escapeHtml(title)}</title>
1443
+ <style>${CSS2}</style>
1444
+ </head>
1445
+ <body>
1446
+ <main>
1447
+ ${body}
1448
+ </main>
1449
+ </body>
1450
+ </html>
1451
+ `;
1452
+ }
1453
+
1454
+ // src/html/site.ts
1455
+ function generateSite(profile, options = {}) {
1456
+ const { baseUrl } = options;
1457
+ const defaultLocale = profile.settings.defaultLocale;
1458
+ const locales = [.../* @__PURE__ */ new Set([defaultLocale, ...profile.settings.availableLocales])];
1459
+ const jsonLd = profile.settings.enableJsonLd !== false;
1460
+ const activitySnapshot = profile.settings.activity?.enabled === true ? options.activitySnapshot ?? void 0 : void 0;
1461
+ const pages = [];
1462
+ for (const locale of locales) {
1463
+ const localized = resolveLocale2(profile, locale);
1464
+ const isDefault = locale === defaultLocale;
1465
+ const localeNav = locales.map((to) => ({
1466
+ locale: to,
1467
+ href: localeHref(locale, to, defaultLocale),
1468
+ current: to === locale
1469
+ }));
1470
+ const canonicalUrl = baseUrl ? absoluteUrl(baseUrl, locale, defaultLocale) : void 0;
1471
+ const alternates = baseUrl ? buildAlternates(baseUrl, locales, defaultLocale) : [];
1472
+ const html = renderProfileHtml({
1473
+ localized,
1474
+ canonicalUrl,
1475
+ alternates,
1476
+ localeNav,
1477
+ jsonLd,
1478
+ activitySnapshot
1479
+ });
1480
+ pages.push({
1481
+ route: isDefault ? "/" : `/${locale}/`,
1482
+ file: isDefault ? "index.html" : `${locale}/index.html`,
1483
+ html
1484
+ });
1485
+ if (options.cv === true) {
1486
+ pages.push({
1487
+ route: isDefault ? "/cv/" : `/${locale}/cv/`,
1488
+ file: isDefault ? "cv.html" : `${locale}/cv.html`,
1489
+ html: renderCvHtml(deriveCv(localized))
1490
+ });
1491
+ }
1492
+ }
1493
+ return pages;
1494
+ }
1495
+ function absoluteUrl(baseUrl, locale, defaultLocale) {
1496
+ return locale === defaultLocale ? `${baseUrl}/` : `${baseUrl}/${locale}/`;
1497
+ }
1498
+ function buildAlternates(baseUrl, locales, defaultLocale) {
1499
+ const alternates = locales.map((locale) => ({
1500
+ hreflang: locale,
1501
+ href: absoluteUrl(baseUrl, locale, defaultLocale)
1502
+ }));
1503
+ alternates.push({
1504
+ hreflang: "x-default",
1505
+ href: absoluteUrl(baseUrl, defaultLocale, defaultLocale)
1506
+ });
1507
+ return alternates;
1508
+ }
1509
+ function localeHref(from, to, defaultLocale) {
1510
+ const fromRoot = from === defaultLocale;
1511
+ const toRoot = to === defaultLocale;
1512
+ if (fromRoot) return toRoot ? "./" : `${to}/`;
1513
+ return toRoot ? "../" : `../${to}/`;
1514
+ }
910
1515
  export {
911
1516
  ERROR_SLUGS,
912
1517
  LOCALE_AWARE_REMAINDERS,
@@ -916,11 +1521,15 @@ export {
916
1521
  createAdminApiApp,
917
1522
  createAdminUiApp,
918
1523
  createPublicApp,
1524
+ escapeHtml,
1525
+ generateSite,
919
1526
  localePrefixGetPath,
920
1527
  noopAuditLogger,
921
1528
  noopCachePurger,
922
1529
  pathLocaleFromUrl,
923
1530
  problemResponse,
1531
+ renderCvHtml,
1532
+ renderProfileHtml,
924
1533
  stripLocalePrefix
925
1534
  };
926
1535
  //# sourceMappingURL=index.js.map