@takuhon/api 1.3.0 → 1.4.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
@@ -54,7 +54,7 @@ import {
54
54
  import { Hono } from "hono";
55
55
 
56
56
  // src/html/build-html.ts
57
- import { generateJsonLd, renderActivitySvg } from "@takuhon/core";
57
+ import { generateJsonLd, renderActivitySvg, SECTION_KEYS } from "@takuhon/core";
58
58
 
59
59
  // src/html/html-helpers.ts
60
60
  import { formatDate, getPresentLabel } from "@takuhon/core";
@@ -74,10 +74,10 @@ function presentLabel(locale) {
74
74
  return escapeHtml(getPresentLabel(locale));
75
75
  }
76
76
  function dateRange(start, opts) {
77
- const { end, isCurrent, locale } = opts;
77
+ const { end, isCurrent, locale, separator = " \u2013 " } = opts;
78
78
  const left = start ? timeTag(start, locale) : "";
79
79
  const right = isCurrent === true || end === null ? presentLabel(locale) : end ? timeTag(end, locale) : "";
80
- if (left && right) return `${left} \u2013 ${right}`;
80
+ if (left && right) return `${left}${separator}${right}`;
81
81
  return left || right;
82
82
  }
83
83
  function nonEmpty(values, separator) {
@@ -86,7 +86,7 @@ function nonEmpty(values, separator) {
86
86
  }
87
87
 
88
88
  // src/html/brand-icons.ts
89
- var BRAND_ICONS = {
89
+ var GLYPHS = {
90
90
  github: {
91
91
  viewBox: "0 0 24 24",
92
92
  path: "M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
@@ -130,18 +130,143 @@ var BRAND_ICONS = {
130
130
  rss: {
131
131
  viewBox: "0 0 24 24",
132
132
  path: "M19.199 24C19.199 13.467 10.533 4.8 0 4.8V0c13.165 0 24 10.835 24 24h-4.801zM3.291 17.415c1.814 0 3.293 1.479 3.293 3.295 0 1.813-1.485 3.29-3.301 3.29C1.47 24 0 22.526 0 20.71s1.475-3.294 3.291-3.295zM15.909 24h-4.665c0-6.169-5.075-11.245-11.244-11.245V8.09c8.727 0 15.909 7.184 15.909 15.91z"
133
+ },
134
+ npm: {
135
+ viewBox: "0 0 24 24",
136
+ path: "M1.763 0C.786 0 0 .786 0 1.763v20.474C0 23.214.786 24 1.763 24h20.474c.977 0 1.763-.786 1.763-1.763V1.763C24 .786 23.214 0 22.237 0zM5.13 5.323l13.837.019-.009 13.836h-3.464l.01-10.382h-3.456L12.04 19.17H5.113z"
137
+ },
138
+ linktree: {
139
+ viewBox: "0 0 24 24",
140
+ path: "m13.73635 5.85251 4.00467-4.11665 2.3248 2.3808-4.20064 4.00466h5.9085v3.30473h-5.9365l4.22865 4.10766-2.3248 2.3338L12.0005 12.099l-5.74052 5.76852-2.3248-2.3248 4.22864-4.10766h-5.9375V8.12132h5.9085L3.93417 4.11666l2.3248-2.3808 4.00468 4.11665V0h3.4727zm-3.4727 10.30614h3.4727V24h-3.4727z"
141
+ },
142
+ wakatime: {
143
+ viewBox: "0 0 24 24",
144
+ path: "M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm0 2.824a9.176 9.176 0 1 1 0 18.352 9.176 9.176 0 0 1 0-18.352zm5.097 5.058c-.327 0-.61.19-.764.45-1.025 1.463-2.21 3.162-3.288 4.706l-.387-.636a.897.897 0 0 0-.759-.439.901.901 0 0 0-.788.492l-.357.581-1.992-2.943a.897.897 0 0 0-.761-.446c-.514 0-.903.452-.903.96a1 1 0 0 0 .207.61l2.719 3.96c.152.272.44.47.776.47a.91.91 0 0 0 .787-.483c.046-.071.23-.368.314-.504l.324.52c-.035-.047.076.113.087.13.024.031.054.059.078.085.019.019.04.036.058.052.036.033.08.056.115.08.025.016.052.028.076.04.029.015.06.024.088.035.058.025.122.027.18.04.031.004.064.003.092.005.29 0 .546-.149.707-.36 1.4-2 2.842-4.055 4.099-5.849A.995.995 0 0 0 18 8.842c0-.508-.389-.96-.903-.96"
145
+ },
146
+ wordpress: {
147
+ viewBox: "0 0 24 24",
148
+ path: "M21.469 6.825c.84 1.537 1.318 3.3 1.318 5.175 0 3.979-2.156 7.456-5.363 9.325l3.295-9.527c.615-1.54.82-2.771.82-3.864 0-.405-.026-.78-.07-1.11m-7.981.105c.647-.03 1.232-.105 1.232-.105.582-.075.514-.93-.067-.899 0 0-1.755.135-2.88.135-1.064 0-2.85-.15-2.85-.15-.585-.03-.661.855-.075.885 0 0 .54.061 1.125.09l1.68 4.605-2.37 7.08L5.354 6.9c.649-.03 1.234-.1 1.234-.1.585-.075.516-.93-.065-.896 0 0-1.746.138-2.874.138-.2 0-.438-.008-.69-.015C4.911 3.15 8.235 1.215 12 1.215c2.809 0 5.365 1.072 7.286 2.833-.046-.003-.091-.009-.141-.009-1.06 0-1.812.923-1.812 1.914 0 .89.513 1.643 1.06 2.531.411.72.89 1.643.89 2.977 0 .915-.354 1.994-.821 3.479l-1.075 3.585-3.9-11.61.001.014zM12 22.784c-1.059 0-2.081-.153-3.048-.437l3.237-9.406 3.315 9.087c.024.053.05.101.078.149-1.12.393-2.325.609-3.582.609M1.211 12c0-1.564.336-3.05.935-4.39L7.29 21.709C3.694 19.96 1.212 16.271 1.211 12M12 0C5.385 0 0 5.385 0 12s5.385 12 12 12 12-5.385 12-12S18.615 0 12 0"
133
149
  }
134
150
  };
135
- var BRAND_ICON_ALIASES = {
151
+ var TYPE_GLYPHS = {
152
+ github: "github",
153
+ gitlab: "gitlab",
154
+ linkedin: "linkedin",
155
+ x: "x",
156
+ mastodon: "mastodon",
157
+ bluesky: "bluesky",
158
+ instagram: "instagram",
159
+ youtube: "youtube",
160
+ threads: "threads",
161
+ facebook: "facebook",
162
+ rss: "rss",
136
163
  blog: "rss"
137
164
  };
138
- function brandIconSvg(type) {
139
- const icon = BRAND_ICONS[BRAND_ICON_ALIASES[type] ?? type];
165
+ var HOST_GLYPHS = {
166
+ "github.com": "github",
167
+ "gitlab.com": "gitlab",
168
+ "linkedin.com": "linkedin",
169
+ "x.com": "x",
170
+ "twitter.com": "x",
171
+ "instagram.com": "instagram",
172
+ "youtube.com": "youtube",
173
+ "threads.net": "threads",
174
+ "facebook.com": "facebook",
175
+ "bsky.app": "bluesky",
176
+ "npmjs.com": "npm",
177
+ "linktr.ee": "linktree",
178
+ "wakatime.com": "wakatime",
179
+ "wordpress.org": "wordpress"
180
+ };
181
+ function hostGlyph(url) {
182
+ let host;
183
+ try {
184
+ host = new URL(url, "https://takuhon.invalid").hostname.toLowerCase();
185
+ } catch {
186
+ return null;
187
+ }
188
+ for (const [domain, glyph] of Object.entries(HOST_GLYPHS)) {
189
+ if (host === domain || host.endsWith(`.${domain}`)) return glyph;
190
+ }
191
+ return null;
192
+ }
193
+ function renderGlyph(name) {
194
+ const icon = GLYPHS[name];
140
195
  if (!icon) return "";
141
196
  return `<svg class="brand-icon" viewBox="${icon.viewBox}" width="18" height="18" fill="currentColor" aria-hidden="true" focusable="false"><path d="${escapeHtml(icon.path)}"/></svg>`;
142
197
  }
198
+ function brandIconForLink(link) {
199
+ const glyph = link.type === "custom" ? hostGlyph(link.url) : TYPE_GLYPHS[link.type];
200
+ return glyph ? renderGlyph(glyph) : "";
201
+ }
202
+ function brandIconForPlatform(platform, url) {
203
+ const key = platform.trim().toLowerCase();
204
+ const named = key === "blog" ? "rss" : Object.hasOwn(GLYPHS, key) ? key : null;
205
+ const glyph = named ?? hostGlyph(url);
206
+ return glyph ? renderGlyph(glyph) : "";
207
+ }
208
+ function brandIconForHost(url) {
209
+ const glyph = hostGlyph(url);
210
+ return glyph ? renderGlyph(glyph) : "";
211
+ }
143
212
 
144
213
  // src/html/build-html.ts
214
+ var LABELS_EN = {
215
+ about: "About",
216
+ careers: "Experience",
217
+ projects: "Projects",
218
+ volunteering: "Volunteering",
219
+ skills: "Skills",
220
+ activity: "Developer activity",
221
+ education: "Education",
222
+ certifications: "Certifications",
223
+ publications: "Publications",
224
+ honors: "Honors & awards",
225
+ memberships: "Memberships",
226
+ courses: "Courses",
227
+ patents: "Patents",
228
+ testScores: "Test scores",
229
+ languages: "Languages",
230
+ recommendations: "Recommendations",
231
+ highlights: "Selected posts",
232
+ contact: "Contact",
233
+ skipLink: "Skip to main content",
234
+ localeNav: "Language",
235
+ featuredLinks: "Featured links",
236
+ otherLinks: "Links",
237
+ poweredBy: "Powered by"
238
+ };
239
+ var LABELS_JA = {
240
+ about: "\u81EA\u5DF1\u7D39\u4ECB",
241
+ careers: "\u7D4C\u6B74",
242
+ projects: "\u30D7\u30ED\u30B8\u30A7\u30AF\u30C8",
243
+ volunteering: "\u30DC\u30E9\u30F3\u30C6\u30A3\u30A2",
244
+ skills: "\u30B9\u30AD\u30EB",
245
+ activity: "\u958B\u767A\u30A2\u30AF\u30C6\u30A3\u30D3\u30C6\u30A3",
246
+ education: "\u5B66\u6B74",
247
+ certifications: "\u8CC7\u683C\u30FB\u8A8D\u5B9A",
248
+ publications: "\u51FA\u7248\u7269",
249
+ honors: "\u53D7\u8CDE\u30FB\u6804\u8A89",
250
+ memberships: "\u6240\u5C5E",
251
+ courses: "\u8B1B\u5EA7",
252
+ patents: "\u7279\u8A31",
253
+ testScores: "\u30C6\u30B9\u30C8\u30B9\u30B3\u30A2",
254
+ languages: "\u8A00\u8A9E",
255
+ recommendations: "\u63A8\u85A6",
256
+ highlights: "\u30D4\u30C3\u30AF\u30A2\u30C3\u30D7\u6295\u7A3F",
257
+ contact: "\u9023\u7D61\u5148",
258
+ skipLink: "\u30E1\u30A4\u30F3\u30B3\u30F3\u30C6\u30F3\u30C4\u3078\u30B9\u30AD\u30C3\u30D7",
259
+ localeNav: "\u8A00\u8A9E",
260
+ featuredLinks: "\u4E3B\u8981\u30EA\u30F3\u30AF",
261
+ otherLinks: "\u305D\u306E\u4ED6\u306E\u30EA\u30F3\u30AF",
262
+ poweredBy: "Powered by"
263
+ };
264
+ function isJapaneseLocale(resolvedLocale) {
265
+ return resolvedLocale.toLowerCase().split(/[-_]/)[0] === "ja";
266
+ }
267
+ function pickLabelPack(resolvedLocale) {
268
+ return isJapaneseLocale(resolvedLocale) ? LABELS_JA : LABELS_EN;
269
+ }
145
270
  function escapeJsonLd(json) {
146
271
  return json.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
147
272
  }
@@ -154,6 +279,7 @@ var DEFAULT_TOKENS = {
154
279
  "--takuhon-color-primary": "#2563eb",
155
280
  "--takuhon-color-primary-contrast": "#ffffff",
156
281
  "--takuhon-color-accent": "#4f46e5",
282
+ "--takuhon-color-heading": "#1e1888",
157
283
  "--takuhon-font-family": "system-ui,-apple-system,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif"
158
284
  };
159
285
  var DEFAULT_TOKENS_DARK = {
@@ -164,7 +290,8 @@ var DEFAULT_TOKENS_DARK = {
164
290
  "--takuhon-color-border": "#334155",
165
291
  "--takuhon-color-primary": "#60a5fa",
166
292
  "--takuhon-color-primary-contrast": "#0f172a",
167
- "--takuhon-color-accent": "#818cf8"
293
+ "--takuhon-color-accent": "#818cf8",
294
+ "--takuhon-color-heading": "#c7d2fe"
168
295
  };
169
296
  var INTERNAL_TOKENS = {
170
297
  "--takuhon-space-1": "4px",
@@ -193,7 +320,8 @@ var COLOR_TOKEN_VARS = {
193
320
  border: "--takuhon-color-border",
194
321
  accent: "--takuhon-color-accent",
195
322
  primary: "--takuhon-color-primary",
196
- primaryContrast: "--takuhon-color-primary-contrast"
323
+ primaryContrast: "--takuhon-color-primary-contrast",
324
+ heading: "--takuhon-color-heading"
197
325
  };
198
326
  var SAFE_COLOR = /^(?:#[0-9A-Fa-f]{3,8}|[A-Za-z]+|(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color)\([A-Za-z0-9.,%/\s-]*\))$/;
199
327
  var SAFE_FONT = /^[A-Za-z0-9\s,'"._-]+$/;
@@ -242,6 +370,8 @@ body{margin:0;color:var(--takuhon-color-text);background:var(--takuhon-color-bg)
242
370
  main{max-width:var(--takuhon-max-content-width);margin:0 auto;padding:var(--takuhon-space-6) var(--takuhon-space-4)}
243
371
  a{color:var(--takuhon-color-primary)}
244
372
  a:focus-visible{outline:2px solid var(--takuhon-color-accent);outline-offset:2px;border-radius:var(--takuhon-radius-sm)}
373
+ .skip-link{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}
374
+ .skip-link:focus{position:fixed;left:var(--takuhon-space-2);top:var(--takuhon-space-2);width:auto;height:auto;padding:var(--takuhon-space-2) var(--takuhon-space-4);background:var(--takuhon-color-primary);color:var(--takuhon-color-primary-contrast);border-radius:var(--takuhon-radius-sm);z-index:100}
245
375
  h1{font-size:var(--takuhon-font-size-2xl);font-weight:700;line-height:1.2;margin:0 0 var(--takuhon-space-2)}
246
376
  h2{font-size:var(--takuhon-font-size-xl);margin:0 0 var(--takuhon-space-3);padding-bottom:var(--takuhon-space-2);border-bottom:1px solid var(--takuhon-color-border)}
247
377
  h3{font-size:var(--takuhon-font-size-lg);font-weight:600;margin:0}
@@ -249,44 +379,111 @@ header{margin-bottom:var(--takuhon-space-6);display:flow-root}
249
379
  header .avatar{width:96px;height:96px;border-radius:var(--takuhon-radius-full);object-fit:cover;float:left;margin:0 var(--takuhon-space-3) var(--takuhon-space-3) 0;shape-outside:circle();border:1px solid var(--takuhon-color-border)}
250
380
  .tagline{font-size:var(--takuhon-font-size-lg);color:var(--takuhon-color-text-muted);margin:0 0 var(--takuhon-space-2)}
251
381
  .location{font-size:var(--takuhon-font-size-sm);color:var(--takuhon-color-text-muted);margin:0}
252
- .bio{margin:var(--takuhon-space-3) 0 0}
253
382
  section{margin:0 0 var(--takuhon-space-6)}
383
+ .bio-body h3{font-size:var(--takuhon-font-size-lg);font-weight:600;color:var(--takuhon-color-heading);margin:var(--takuhon-space-5) 0 var(--takuhon-space-2)}
384
+ .bio-body h4{font-size:var(--takuhon-font-size-base);font-weight:600;color:var(--takuhon-color-heading);margin:var(--takuhon-space-4) 0 var(--takuhon-space-2)}
385
+ .bio-body p{margin:0 0 var(--takuhon-space-3)}
386
+ .bio-body ul{list-style:disc;margin:0 0 var(--takuhon-space-3);padding-left:var(--takuhon-space-5)}
387
+ .bio-body li{margin:0 0 var(--takuhon-space-1)}
388
+ .bio-body hr{border:0;border-top:1px solid var(--takuhon-color-border);margin:var(--takuhon-space-5) 0}
254
389
  ul{padding:0;margin:0;list-style:none}
255
390
  .entries>li{margin:0 0 var(--takuhon-space-4)}
256
391
  .entries--timeline>li{position:relative;display:flex;flex-direction:column;margin:0 0 0 var(--takuhon-space-2);padding:0 0 var(--takuhon-space-5) var(--takuhon-space-4);border-left:2px solid var(--takuhon-color-border)}
257
392
  .entries--timeline>li:last-child{padding-bottom:0}
258
- .entries--timeline>li::before{content:"";position:absolute;left:-7px;top:6px;width:12px;height:12px;border-radius:var(--takuhon-radius-full);background:var(--takuhon-color-primary)}
393
+ .entries--timeline>li::before{content:"";position:absolute;left:-7px;top:6px;width:12px;height:12px;border-radius:var(--takuhon-radius-full);background:var(--takuhon-color-text-muted)}
259
394
  .entries--timeline>li.is-current::before{background:var(--takuhon-color-accent)}
260
395
  .entries--timeline .meta{order:-1;margin:0 0 var(--takuhon-space-1)}
396
+ .entries--timeline>li>h3{font-size:var(--takuhon-font-size-base);font-weight:600;margin:0}
261
397
  .entries--cards{display:grid;gap:var(--takuhon-space-3)}
262
398
  .entries--cards>li{margin:0;padding:var(--takuhon-space-4);border:1px solid var(--takuhon-color-border);border-radius:var(--takuhon-radius-md);background:var(--takuhon-color-surface)}
263
399
  .entries--cards>li.is-highlighted{border-color:var(--takuhon-color-accent)}
264
- .sub{margin:var(--takuhon-space-1) 0 0;font-weight:600}
400
+ .entries h3 a{color:inherit;text-decoration:none}
401
+ .entries h3 a:hover{color:var(--takuhon-color-primary);text-decoration:underline}
402
+ .sub{margin:var(--takuhon-space-1) 0 0;color:var(--takuhon-color-text-muted)}
403
+ .sub a{color:inherit;text-decoration:none}
404
+ .sub a:hover{color:var(--takuhon-color-primary);text-decoration:underline}
405
+ .role-badge{margin:var(--takuhon-space-1) 0 0;font-size:var(--takuhon-font-size-sm);font-weight:600;letter-spacing:.02em;color:var(--takuhon-color-accent)}
406
+ .desc{margin:var(--takuhon-space-2) 0 0}
407
+ .entries--timeline .desc{white-space:pre-wrap}
408
+ .external-icon{width:.72em;height:.72em;margin-left:.35em;vertical-align:-.05em;opacity:.55}
265
409
  .meta{margin:var(--takuhon-space-1) 0 0;color:var(--takuhon-color-text-muted);font-size:var(--takuhon-font-size-sm)}
266
410
  .featured-links,.other-links{margin:0 0 var(--takuhon-space-6)}
267
411
  .featured-links>ul,.other-links>ul{list-style:none;padding:0;margin:0;display:grid;gap:var(--takuhon-space-2)}
268
412
  .featured-links>ul{grid-template-columns:repeat(auto-fill,minmax(220px,1fr))}
269
- .featured-links a,.other-links a{display:flex;align-items:center;gap:var(--takuhon-space-2);min-height:var(--takuhon-tap-target);padding:var(--takuhon-space-2) var(--takuhon-space-3);background:var(--takuhon-color-surface);color:var(--takuhon-color-text);text-decoration:none;border:1px solid var(--takuhon-color-border);border-radius:var(--takuhon-radius-md)}
413
+ .featured-links a,.other-links a{display:flex;align-items:center;justify-content:space-between;gap:var(--takuhon-space-2);min-height:var(--takuhon-tap-target);padding:var(--takuhon-space-2) var(--takuhon-space-3);background:var(--takuhon-color-surface);color:var(--takuhon-color-text);text-decoration:none;border:1px solid var(--takuhon-color-border);border-radius:var(--takuhon-radius-md)}
270
414
  .featured-links a:hover,.other-links a:hover{border-color:var(--takuhon-color-accent)}
271
415
  .link-main{display:inline-flex;align-items:center;gap:var(--takuhon-space-2);min-width:0}
416
+ .link-type{font-size:var(--takuhon-font-size-sm);color:var(--takuhon-color-text-muted)}
272
417
  .brand-icon{width:1.15em;height:1.15em;flex:none;opacity:.85}
273
418
  .skills,.tags{display:flex;flex-wrap:wrap;gap:var(--takuhon-space-2)}
274
419
  .skills>li,.tags>li{background:var(--takuhon-color-surface);border:1px solid var(--takuhon-color-border);border-radius:var(--takuhon-radius-full);padding:var(--takuhon-space-1) var(--takuhon-space-3);font-size:var(--takuhon-font-size-sm)}
420
+ .entries--cards .tags{margin-top:var(--takuhon-space-2)}
421
+ .tags>li{background:var(--takuhon-color-bg);color:var(--takuhon-color-text-muted);padding:2px var(--takuhon-space-2)}
275
422
  .skills-groups{display:grid;gap:var(--takuhon-space-4)}
276
423
  .skill-group h3{font-size:var(--takuhon-font-size-base);margin:0 0 var(--takuhon-space-2);color:var(--takuhon-color-text-muted);text-transform:uppercase;letter-spacing:.04em}
424
+ .vol-list{display:grid;gap:var(--takuhon-space-2)}
425
+ .vol{padding:var(--takuhon-space-3) 0;border-bottom:1px solid var(--takuhon-color-border)}
426
+ .vol:last-child{border-bottom:0}
427
+ .vol-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:var(--takuhon-space-1) var(--takuhon-space-3)}
428
+ .vol-org{font-size:var(--takuhon-font-size-lg)}
429
+ .vol-org a{color:inherit;text-decoration:none}
430
+ .vol-org a:hover{color:var(--takuhon-color-primary);text-decoration:underline}
431
+ .vol-role{color:var(--takuhon-color-text-muted);font-size:var(--takuhon-font-size-sm)}
432
+ .vol-desc{margin:var(--takuhon-space-1) 0 0;font-size:var(--takuhon-font-size-sm)}
433
+ .vol-secondary{display:inline-flex;align-items:center;gap:var(--takuhon-space-1);margin-top:var(--takuhon-space-2);padding:2px var(--takuhon-space-2);font-size:var(--takuhon-font-size-sm);background:var(--takuhon-color-surface);border:1px solid var(--takuhon-color-border);border-radius:var(--takuhon-radius-full);color:var(--takuhon-color-text-muted);text-decoration:none}
434
+ .vol-secondary:hover{color:var(--takuhon-color-text);border-color:var(--takuhon-color-accent)}
435
+ .vol-secondary .brand-icon{width:1em;height:1em;opacity:.85}
277
436
  .rec{margin:0 0 var(--takuhon-space-4)}
278
437
  .rec blockquote{margin:0;padding-left:var(--takuhon-space-3);border-left:3px solid var(--takuhon-color-border)}
279
438
  .rec figcaption{color:var(--takuhon-color-text-muted);font-size:var(--takuhon-font-size-sm);margin-top:var(--takuhon-space-2)}
280
- nav.locales{display:flex;gap:var(--takuhon-space-3);margin-bottom:var(--takuhon-space-4);font-size:var(--takuhon-font-size-sm)}
281
- .activity svg{max-width:100%;height:auto}
282
- footer.powered{max-width:var(--takuhon-max-content-width);margin:0 auto;padding:var(--takuhon-space-5) var(--takuhon-space-4);color:var(--takuhon-color-text-muted);font-size:var(--takuhon-font-size-sm)}`;
439
+ nav.locales{display:flex;justify-content:flex-end;gap:var(--takuhon-space-3);margin-bottom:var(--takuhon-space-4);font-size:var(--takuhon-font-size-sm)}
440
+ nav.locales a,nav.locales [aria-current]{padding:var(--takuhon-space-1) var(--takuhon-space-2);border-radius:var(--takuhon-radius-sm);text-transform:uppercase}
441
+ nav.locales a{color:var(--takuhon-color-primary);text-decoration:none}
442
+ nav.locales a:hover{text-decoration:underline}
443
+ nav.locales [aria-current]{color:var(--takuhon-color-text);font-weight:700;background:var(--takuhon-color-surface)}
444
+ .activity svg{width:100%;height:auto}
445
+ footer.powered{max-width:var(--takuhon-max-content-width);margin:var(--takuhon-space-6) auto 0;padding:var(--takuhon-space-4) var(--takuhon-space-4) var(--takuhon-space-6);border-top:1px solid var(--takuhon-color-border);text-align:center;color:var(--takuhon-color-text-muted);font-size:var(--takuhon-font-size-sm)}
446
+ footer.powered p{margin:0 0 var(--takuhon-space-2)}
447
+ footer.powered p:last-child{margin-bottom:0}
448
+ footer.powered .powered-by a{color:var(--takuhon-color-primary);text-decoration:none}
449
+ footer.powered .powered-by a:hover{text-decoration:underline}
450
+ .highlights .highlights-intro{color:var(--takuhon-color-text-muted);margin:0 0 var(--takuhon-space-3)}
451
+ .highlights-track{display:flex;gap:var(--takuhon-space-3);list-style:none;margin:0;padding:0 0 var(--takuhon-space-2);overflow-x:auto;scroll-snap-type:x mandatory;scroll-padding-left:var(--takuhon-space-1);-webkit-overflow-scrolling:touch;overscroll-behavior-x:contain}
452
+ .highlight-card{position:relative;flex:0 0 85%;scroll-snap-align:start;display:flex;flex-direction:column;background:var(--takuhon-color-surface);border:1px solid var(--takuhon-color-border);border-radius:var(--takuhon-radius-md);overflow:hidden}
453
+ @media (min-width:640px){.highlight-card{flex-basis:calc((100% - var(--takuhon-space-3)) / 2)}}
454
+ @media (min-width:960px){.highlight-card{flex-basis:calc((100% - 2 * var(--takuhon-space-3)) / 3)}}
455
+ .highlight-card:hover{border-color:var(--takuhon-color-accent)}
456
+ .highlight-card:focus-within{outline:2px solid var(--takuhon-color-accent);outline-offset:2px}
457
+ .highlight-thumb{aspect-ratio:1 / 1;background:var(--takuhon-color-bg)}
458
+ .highlight-thumb img{display:block;width:100%;height:100%;object-fit:cover}
459
+ .highlight-body{padding:var(--takuhon-space-3);display:flex;flex-direction:column;gap:var(--takuhon-space-2)}
460
+ .highlight-body>*{margin:0}
461
+ .highlight-badge{display:inline-flex;align-items:center;gap:var(--takuhon-space-1);font-size:var(--takuhon-font-size-sm);color:var(--takuhon-color-text-muted)}
462
+ .highlight-badge .brand-icon{width:1em;height:1em;opacity:.85}
463
+ .highlight-title{font-size:var(--takuhon-font-size-base)}
464
+ .highlight-title a{color:var(--takuhon-color-text);text-decoration:none}
465
+ .highlight-title a:hover{color:var(--takuhon-color-primary)}
466
+ .highlight-title a::after{content:"";position:absolute;inset:0}
467
+ .highlight-title a:focus-visible{outline:none}
468
+ .highlight-date{font-size:var(--takuhon-font-size-sm);color:var(--takuhon-color-text-muted)}
469
+ .highlight-desc{font-size:var(--takuhon-font-size-sm)}
470
+ .highlight-cta{font-size:var(--takuhon-font-size-sm);font-weight:600;color:var(--takuhon-color-primary)}`;
471
+ var EXTERNAL_ICON = '<svg class="external-icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
472
+ function externalLink(url, innerEscaped) {
473
+ return `<a href="${escapeHtml(url)}" rel="noopener">${innerEscaped}${EXTERNAL_ICON}</a>`;
474
+ }
283
475
  function renderEntry(entry) {
284
- const href = entry.url ? safeUrl(entry.url) : void 0;
285
- const heading = href ? `<a href="${escapeHtml(href)}">${escapeHtml(entry.heading)}</a>` : escapeHtml(entry.heading);
476
+ const headHref = entry.url ? safeUrl(entry.url) : void 0;
477
+ const heading = headHref ? externalLink(headHref, escapeHtml(entry.heading)) : escapeHtml(entry.heading);
286
478
  const parts = [`<h3>${heading}</h3>`];
287
- if (entry.sub) parts.push(`<p class="sub">${escapeHtml(entry.sub)}</p>`);
479
+ if (entry.role) parts.push(`<p class="role-badge">${escapeHtml(entry.role)}</p>`);
480
+ if (entry.sub) {
481
+ const subHref = entry.subUrl ? safeUrl(entry.subUrl) : void 0;
482
+ const sub = subHref ? externalLink(subHref, escapeHtml(entry.sub)) : escapeHtml(entry.sub);
483
+ parts.push(`<p class="sub">${sub}</p>`);
484
+ }
288
485
  if (entry.dates) parts.push(`<p class="meta">${entry.dates}</p>`);
289
- if (entry.body) parts.push(`<p>${escapeHtml(entry.body)}</p>`);
486
+ if (entry.body) parts.push(`<p class="desc">${escapeHtml(entry.body)}</p>`);
290
487
  if (entry.tags && entry.tags.length > 0) {
291
488
  parts.push(
292
489
  `<ul class="tags">${entry.tags.map((t) => `<li>${escapeHtml(t)}</li>`).join("")}</ul>`
@@ -300,8 +497,65 @@ function entryList(title, entries, variant) {
300
497
  const cls = variant ? `entries entries--${variant}` : "entries";
301
498
  return `<section><h2>${escapeHtml(title)}</h2><ul class="${cls}">${entries.map(renderEntry).join("")}</ul></section>`;
302
499
  }
303
- function renderHeader(p) {
500
+ function renderInline(text) {
501
+ return escapeHtml(text).replace(/\*\*([^*]+?)\*\*/g, "<strong>$1</strong>");
502
+ }
503
+ function renderMarkdown(input) {
504
+ const lines = input.split("\n");
505
+ const out = [];
506
+ let i = 0;
507
+ while (i < lines.length) {
508
+ const trimmed = (lines[i] ?? "").trim();
509
+ if (trimmed === "") {
510
+ i++;
511
+ continue;
512
+ }
513
+ if (trimmed === "---") {
514
+ out.push("<hr>");
515
+ i++;
516
+ continue;
517
+ }
518
+ if (trimmed.startsWith("### ")) {
519
+ out.push(`<h4>${renderInline(trimmed.slice(4))}</h4>`);
520
+ i++;
521
+ continue;
522
+ }
523
+ if (trimmed.startsWith("## ")) {
524
+ out.push(`<h3>${renderInline(trimmed.slice(3))}</h3>`);
525
+ i++;
526
+ continue;
527
+ }
528
+ if (trimmed.startsWith("- ")) {
529
+ const items = [];
530
+ while (i < lines.length) {
531
+ const cur = (lines[i] ?? "").trim();
532
+ if (!cur.startsWith("- ")) break;
533
+ items.push(`<li>${renderInline(cur.slice(2))}</li>`);
534
+ i++;
535
+ }
536
+ out.push(`<ul>${items.join("")}</ul>`);
537
+ continue;
538
+ }
539
+ const para = [];
540
+ while (i < lines.length) {
541
+ const cur = (lines[i] ?? "").trim();
542
+ if (cur === "" || cur === "---" || cur.startsWith("## ") || cur.startsWith("### ") || cur.startsWith("- ")) {
543
+ break;
544
+ }
545
+ para.push(cur);
546
+ i++;
547
+ }
548
+ if (para.length > 0) out.push(`<p>${renderInline(para.join(" "))}</p>`);
549
+ }
550
+ return out.join("\n");
551
+ }
552
+ function renderBio(bio, heading) {
553
+ if (!bio) return "";
554
+ return `<section class="bio"><h2>${escapeHtml(heading)}</h2><div class="bio-body">${renderMarkdown(bio)}</div></section>`;
555
+ }
556
+ function renderHeader(p, localeNavHtml) {
304
557
  const parts = [];
558
+ if (localeNavHtml) parts.push(localeNavHtml);
305
559
  const avatarSrc = p.avatar?.url ? safeUrl(p.avatar.url) : void 0;
306
560
  if (avatarSrc) {
307
561
  parts.push(
@@ -311,33 +565,55 @@ function renderHeader(p) {
311
565
  parts.push(`<h1>${escapeHtml(p.displayName)}</h1>`);
312
566
  if (p.tagline) parts.push(`<p class="tagline">${escapeHtml(p.tagline)}</p>`);
313
567
  if (p.location?.display) parts.push(`<p class="location">${escapeHtml(p.location.display)}</p>`);
314
- if (p.bio) parts.push(`<p class="bio">${escapeHtml(p.bio)}</p>`);
315
568
  return `<header>${parts.join("")}</header>`;
316
569
  }
570
+ var LINK_TYPE_DISPLAY = {
571
+ website: "Website",
572
+ blog: "Blog",
573
+ github: "GitHub",
574
+ gitlab: "GitLab",
575
+ linkedin: "LinkedIn",
576
+ x: "X (Twitter)",
577
+ mastodon: "Mastodon",
578
+ bluesky: "Bluesky",
579
+ instagram: "Instagram",
580
+ youtube: "YouTube",
581
+ threads: "Threads",
582
+ facebook: "Facebook",
583
+ email: "Email",
584
+ rss: "RSS"
585
+ };
586
+ function linkTypePill(link, label) {
587
+ const display = LINK_TYPE_DISPLAY[link.type];
588
+ if (!display || display === label) return "";
589
+ return `<span class="link-type">${escapeHtml(display)}</span>`;
590
+ }
317
591
  function renderLinkItem(link) {
318
- const label = escapeHtml(link.label ?? link.url);
319
- const main = `<span class="link-main">${brandIconSvg(link.type)}<span>${label}</span></span>`;
592
+ const label = link.label ?? link.url;
593
+ const main = `<span class="link-main">${brandIconForLink(link)}<span>${escapeHtml(label)}</span></span>`;
594
+ const pill = link.label == null ? linkTypePill(link, label) : "";
320
595
  const href = safeUrl(link.url);
321
- return href ? `<li><a href="${escapeHtml(href)}" rel="me noopener">${main}</a></li>` : `<li>${main}</li>`;
596
+ return href ? `<li><a href="${escapeHtml(href)}" rel="me noopener">${main}${pill}</a></li>` : `<li>${main}${pill}</li>`;
322
597
  }
323
598
  function byOrder(a, b) {
324
599
  return (a.order ?? 0) - (b.order ?? 0);
325
600
  }
326
- function renderLinks(links) {
327
- if (links.length === 0) return "";
601
+ function renderFeaturedLinks(links, ariaLabel) {
328
602
  const featured = links.filter((l) => l.featured === true).sort(byOrder);
603
+ if (featured.length === 0) return "";
604
+ return `<nav class="featured-links" aria-label="${escapeHtml(ariaLabel)}"><ul>${featured.map(renderLinkItem).join("")}</ul></nav>`;
605
+ }
606
+ function renderOtherLinks(links, heading) {
329
607
  const others = links.filter((l) => l.featured !== true).sort(byOrder);
330
- const group = (cls, ariaLabel, items) => items.length === 0 ? "" : `<nav class="${cls}" aria-label="${ariaLabel}"><ul>${items.map(renderLinkItem).join("")}</ul></nav>`;
331
- return [
332
- group("featured-links", "Featured links", featured),
333
- group("other-links", "Links", others)
334
- ].filter(Boolean).join("\n");
608
+ if (others.length === 0) return "";
609
+ return `<section class="other-links"><h2>${escapeHtml(heading)}</h2><ul>${others.map(renderLinkItem).join("")}</ul></section>`;
335
610
  }
336
- function renderSkills(skills, categories) {
611
+ function renderSkills(skills, categories, heading) {
337
612
  if (skills.length === 0) return "";
613
+ const h = escapeHtml(heading);
338
614
  const chips = (list) => `<ul class="skills">${list.map((s) => `<li>${escapeHtml(s.label)}</li>`).join("")}</ul>`;
339
615
  if (!categories || categories.length === 0) {
340
- return `<section><h2>Skills</h2>${chips(skills)}</section>`;
616
+ return `<section><h2>${h}</h2>${chips(skills)}</section>`;
341
617
  }
342
618
  const UNCAT = "";
343
619
  const buckets = /* @__PURE__ */ new Map();
@@ -347,7 +623,7 @@ function renderSkills(skills, categories) {
347
623
  arr.push(s);
348
624
  buckets.set(key, arr);
349
625
  }
350
- const group = (heading, list) => `<div class="skill-group">${heading !== void 0 ? `<h3>${escapeHtml(heading)}</h3>` : ""}${chips(list)}</div>`;
626
+ const group = (heading2, list) => `<div class="skill-group">${heading2 !== void 0 ? `<h3>${escapeHtml(heading2)}</h3>` : ""}${chips(list)}</div>`;
351
627
  const seen = /* @__PURE__ */ new Set();
352
628
  const groups = [];
353
629
  for (const cat of categories) {
@@ -362,14 +638,41 @@ function renderSkills(skills, categories) {
362
638
  }
363
639
  const uncategorized = buckets.get(UNCAT);
364
640
  if (uncategorized && uncategorized.length > 0) groups.push(group(void 0, uncategorized));
365
- return `<section><h2>Skills</h2><div class="skills-groups">${groups.join("")}</div></section>`;
641
+ return `<section><h2>${h}</h2><div class="skills-groups">${groups.join("")}</div></section>`;
366
642
  }
367
- function renderLanguages(languages) {
643
+ function hostLabel(url) {
644
+ try {
645
+ const host = new URL(url).hostname.replace(/^www\./, "");
646
+ return host || url;
647
+ } catch {
648
+ return url;
649
+ }
650
+ }
651
+ function secondaryLinkPill(link) {
652
+ const label = escapeHtml(link.label ?? hostLabel(link.url));
653
+ const href = safeUrl(link.url);
654
+ const glyph = href ? brandIconForHost(link.url) : "";
655
+ const inner = `${glyph}<span>${label}</span>`;
656
+ return href ? `<a class="vol-secondary" href="${escapeHtml(href)}" rel="noopener">${inner}</a>` : `<span class="vol-secondary">${inner}</span>`;
657
+ }
658
+ function renderVolunteering(items, heading) {
659
+ if (items.length === 0) return "";
660
+ const li = (v) => {
661
+ const orgHref = v.url ? safeUrl(v.url) : void 0;
662
+ const org = orgHref ? externalLink(orgHref, escapeHtml(v.organization)) : escapeHtml(v.organization);
663
+ const role = v.role ? `<span class="vol-role">${escapeHtml(v.role)}</span>` : "";
664
+ const desc = v.description ? `<p class="vol-desc">${escapeHtml(v.description)}</p>` : "";
665
+ const secondary = v.secondaryLink ? secondaryLinkPill(v.secondaryLink) : "";
666
+ return `<li class="vol"><div class="vol-head"><span class="vol-org">${org}</span>${role}</div>${desc}${secondary}</li>`;
667
+ };
668
+ return `<section><h2>${escapeHtml(heading)}</h2><ul class="vol-list">${items.map(li).join("")}</ul></section>`;
669
+ }
670
+ function renderLanguages(languages, heading) {
368
671
  if (languages.length === 0) return "";
369
672
  const items = languages.map((l) => `<li>${escapeHtml(`${l.displayName ?? l.language} \u2014 ${l.proficiency}`)}</li>`).join("");
370
- return `<section><h2>Languages</h2><ul class="entries">${items}</ul></section>`;
673
+ return `<section><h2>${escapeHtml(heading)}</h2><ul class="entries">${items}</ul></section>`;
371
674
  }
372
- function renderRecommendations(recs) {
675
+ function renderRecommendations(recs, heading) {
373
676
  if (recs.length === 0) return "";
374
677
  const items = recs.map((r) => {
375
678
  const authorHref = r.author.url ? safeUrl(r.author.url) : void 0;
@@ -378,9 +681,9 @@ function renderRecommendations(recs) {
378
681
  const rel = r.relationship ? ` (${escapeHtml(r.relationship)})` : "";
379
682
  return `<figure class="rec"><blockquote>${escapeHtml(r.body)}</blockquote><figcaption>\u2014 ${caption}${rel}</figcaption></figure>`;
380
683
  }).join("");
381
- return `<section><h2>Recommendations</h2>${items}</section>`;
684
+ return `<section><h2>${escapeHtml(heading)}</h2>${items}</section>`;
382
685
  }
383
- function renderContact(contact) {
686
+ function renderContact(contact, heading) {
384
687
  const items = [];
385
688
  if (contact.email) {
386
689
  items.push(
@@ -392,33 +695,97 @@ function renderContact(contact) {
392
695
  items.push(`<li><a href="${escapeHtml(formHref)}">Contact form</a></li>`);
393
696
  }
394
697
  if (items.length === 0) return "";
395
- return `<section><h2>Contact</h2><ul class="entries">${items.join("")}</ul></section>`;
698
+ return `<section><h2>${escapeHtml(heading)}</h2><ul class="entries">${items.join("")}</ul></section>`;
699
+ }
700
+ var PLATFORM_DISPLAY = {
701
+ instagram: "Instagram",
702
+ x: "X",
703
+ linkedin: "LinkedIn",
704
+ github: "GitHub",
705
+ gitlab: "GitLab",
706
+ youtube: "YouTube",
707
+ threads: "Threads",
708
+ facebook: "Facebook",
709
+ mastodon: "Mastodon",
710
+ bluesky: "Bluesky",
711
+ zenn: "Zenn",
712
+ qiita: "Qiita",
713
+ note: "note",
714
+ blog: "Blog",
715
+ event: "Event",
716
+ artwork: "Artwork",
717
+ project: "Project"
718
+ };
719
+ function platformDisplay(platform) {
720
+ const label = PLATFORM_DISPLAY[platform.trim().toLowerCase()];
721
+ return typeof label === "string" ? label : platform;
722
+ }
723
+ function highlightBadge(h) {
724
+ return `<span class="highlight-badge">${brandIconForPlatform(h.platform, h.url)}<span>${escapeHtml(platformDisplay(h.platform))}</span></span>`;
725
+ }
726
+ function renderHighlightCard(h, locale) {
727
+ const imgSrc = h.image ? safeUrl(h.image) : void 0;
728
+ const img = imgSrc ? `<div class="highlight-thumb"><img src="${escapeHtml(imgSrc)}" alt="${escapeHtml(h.alt)}" loading="lazy" decoding="async"></div>` : "";
729
+ const href = h.url ? safeUrl(h.url) : void 0;
730
+ const name = platformDisplay(h.platform);
731
+ const cta = isJapaneseLocale(locale) ? `${name}\u3067\u898B\u308B` : `View on ${name}`;
732
+ const title = escapeHtml(h.title);
733
+ const titleEl = href ? `<h3 class="highlight-title"><a href="${escapeHtml(href)}" rel="noopener" aria-label="${escapeHtml(`${h.title} \u2014 ${cta}`)}">${title}</a></h3>` : `<h3 class="highlight-title">${title}</h3>`;
734
+ const date = h.postedAt ? `<p class="highlight-date">${timeTag(h.postedAt, locale)}</p>` : "";
735
+ const desc = h.description ? `<p class="highlight-desc">${escapeHtml(h.description)}</p>` : "";
736
+ const tags = h.tags && h.tags.length > 0 ? `<ul class="tags">${h.tags.map((t) => `<li>${escapeHtml(t)}</li>`).join("")}</ul>` : "";
737
+ const ctaEl = href ? `<span class="highlight-cta" aria-hidden="true">${escapeHtml(cta)} \u2192</span>` : "";
738
+ return `<li class="highlight-card">${img}<div class="highlight-body">${highlightBadge(h)}${titleEl}${date}${desc}${tags}${ctaEl}</div></li>`;
739
+ }
740
+ function renderHighlights(items, heading, intro, locale) {
741
+ if (items.length === 0) return "";
742
+ const introEl = intro ? `<p class="highlights-intro">${escapeHtml(intro)}</p>` : "";
743
+ const cards = items.map((h) => renderHighlightCard(h, locale)).join("");
744
+ return `<section class="highlights"><h2>${escapeHtml(heading)}</h2>${introEl}<ul class="highlights-track" role="list">${cards}</ul></section>`;
396
745
  }
397
- function renderActivity(snapshot) {
746
+ function renderActivity(snapshot, heading) {
398
747
  if (!snapshot) return "";
399
748
  const svg = renderActivitySvg(snapshot);
400
749
  if (svg === "") return "";
401
- return `<section class="activity"><h2>Activity</h2>${svg}</section>`;
750
+ return `<section class="activity"><h2>${escapeHtml(heading)}</h2>${svg}</section>`;
402
751
  }
403
752
  function renderJsonLdScript(data) {
404
753
  const payload = JSON.stringify(generateJsonLd(data));
405
754
  return `<script type="application/ld+json">${escapeJsonLd(payload)}</script>`;
406
755
  }
407
- function renderLocaleNav(localeNav) {
756
+ function renderLocaleNav(localeNav, ariaLabel) {
408
757
  const items = localeNav.map(
409
758
  (l) => l.current ? `<span aria-current="true">${escapeHtml(l.locale)}</span>` : `<a href="${escapeHtml(l.href)}">${escapeHtml(l.locale)}</a>`
410
759
  ).join("");
411
- return `<nav class="locales" aria-label="Language">${items}</nav>`;
760
+ return `<nav class="locales" aria-label="${escapeHtml(ariaLabel)}">${items}</nav>`;
412
761
  }
762
+ var DATE_SEPARATOR_JA = " \u301C ";
763
+ var DATE_SEPARATOR_DEFAULT = " \u2013 ";
413
764
  function renderProfileHtml(input) {
414
765
  const d = input.localized;
415
766
  const p = d.profile;
767
+ const locale = d.resolvedLocale;
416
768
  const description = p.tagline ?? p.bio ?? "";
769
+ const L = {
770
+ ...pickLabelPack(locale),
771
+ ...d.settings.sectionLabels ?? {},
772
+ ...input.labels
773
+ };
774
+ const omit = new Set(input.omitSections ?? []);
775
+ const keep = (key, html) => omit.has(key) ? "" : html;
776
+ const dateSeparator = isJapaneseLocale(locale) ? DATE_SEPARATOR_JA : DATE_SEPARATOR_DEFAULT;
777
+ const dates = (start, opts) => dateRange(start, { ...opts, locale, separator: dateSeparator });
417
778
  const head = [
418
779
  '<meta charset="utf-8">',
419
780
  '<meta name="viewport" content="width=device-width, initial-scale=1">',
420
781
  `<title>${escapeHtml(p.displayName)}</title>`,
421
782
  description ? `<meta name="description" content="${escapeHtml(description.slice(0, 300))}">` : "",
783
+ // Data-derived Open Graph tags (the renderer owns these; asset tags like
784
+ // og:image and PWA/theme-color are the host's, via the `head` slot).
785
+ '<meta property="og:type" content="profile">',
786
+ `<meta property="og:title" content="${escapeHtml(p.displayName)}">`,
787
+ description ? `<meta property="og:description" content="${escapeHtml(description.slice(0, 300))}">` : "",
788
+ input.canonicalUrl ? `<meta property="og:url" content="${escapeHtml(input.canonicalUrl)}">` : "",
422
789
  input.canonicalUrl ? `<link rel="canonical" href="${escapeHtml(input.canonicalUrl)}">` : "",
423
790
  ...input.alternates.map(
424
791
  (a) => `<link rel="alternate" hreflang="${escapeHtml(a.hreflang)}" href="${escapeHtml(a.href)}">`
@@ -426,162 +793,168 @@ function renderProfileHtml(input) {
426
793
  input.jsonLd ? renderJsonLdScript(d) : "",
427
794
  input.contact ? '<link rel="stylesheet" href="/contact-widget.css">' : "",
428
795
  `<style>${buildTokenCss(d.settings.appearance)}
429
- ${CSS}</style>`
796
+ ${CSS}</style>`,
797
+ // First-party <head> slot (raw, unescaped): OG image tags, manifest link, etc.
798
+ input.slots?.head ?? ""
430
799
  ].filter(Boolean).join("\n ");
431
- const body = [
432
- input.localeNav.length > 1 ? renderLocaleNav(input.localeNav) : "",
433
- renderHeader(p),
434
- renderLinks(d.links),
435
- entryList(
436
- "Experience",
800
+ const sections = {
801
+ about: renderBio(p.bio, L.about),
802
+ careers: entryList(
803
+ L.careers,
437
804
  d.careers.map((c) => ({
438
805
  heading: c.role,
439
806
  sub: c.organization,
440
- dates: dateRange(c.startDate, {
441
- end: c.endDate,
442
- isCurrent: c.isCurrent,
443
- locale: d.resolvedLocale
444
- }),
807
+ subUrl: c.url,
808
+ dates: dates(c.startDate, { end: c.endDate, isCurrent: c.isCurrent }),
445
809
  body: c.description,
446
- url: c.url,
447
810
  current: c.isCurrent
448
811
  })),
449
812
  "timeline"
450
813
  ),
451
- entryList(
452
- "Projects",
814
+ projects: entryList(
815
+ L.projects,
453
816
  d.projects.map((x) => ({
454
817
  heading: x.title,
455
- dates: dateRange(x.startDate, { end: x.endDate, locale: d.resolvedLocale }),
456
- body: x.description,
457
818
  url: x.url,
819
+ role: x.role,
820
+ dates: dates(x.startDate, { end: x.endDate }),
821
+ body: x.description,
458
822
  tags: x.tags,
459
823
  highlighted: x.highlighted
460
824
  })),
461
825
  "cards"
462
826
  ),
463
- renderSkills(d.skills, d.settings.skillCategories),
464
- renderActivity(input.activitySnapshot),
465
- entryList(
466
- "Education",
827
+ volunteering: renderVolunteering(d.volunteering, L.volunteering),
828
+ skills: renderSkills(d.skills, d.settings.skillCategories, L.skills),
829
+ activity: renderActivity(input.activitySnapshot, L.activity),
830
+ education: entryList(
831
+ L.education,
467
832
  d.education.map((e) => {
468
833
  const degree = nonEmpty([e.degree, e.fieldOfStudy], ", ");
469
834
  return {
470
835
  heading: degree ?? e.institution,
471
836
  sub: degree ? e.institution : void 0,
472
- dates: dateRange(e.startDate, {
473
- end: e.endDate,
474
- isCurrent: e.isCurrent,
475
- locale: d.resolvedLocale
476
- }),
837
+ dates: dates(e.startDate, { end: e.endDate, isCurrent: e.isCurrent }),
477
838
  body: e.description,
478
839
  url: e.url
479
840
  };
480
841
  })
481
842
  ),
482
- entryList(
483
- "Certifications",
843
+ certifications: entryList(
844
+ L.certifications,
484
845
  d.certifications.map((c) => ({
485
846
  heading: c.title,
486
847
  sub: c.issuingOrganization,
487
- dates: dateRange(c.issueDate, { end: c.expirationDate, locale: d.resolvedLocale }),
848
+ dates: dates(c.issueDate, { end: c.expirationDate }),
488
849
  url: c.url
489
850
  }))
490
851
  ),
491
- entryList(
492
- "Publications",
852
+ publications: entryList(
853
+ L.publications,
493
854
  d.publications.map((x) => ({
494
855
  heading: x.title,
495
856
  sub: nonEmpty([x.publisher, x.coAuthors?.join(", ")], " \xB7 "),
496
- dates: dateRange(x.date, { locale: d.resolvedLocale }),
857
+ dates: dates(x.date, {}),
497
858
  body: x.description,
498
859
  url: x.url ?? (x.doi ? `https://doi.org/${x.doi}` : void 0)
499
860
  }))
500
861
  ),
501
- entryList(
502
- "Honors & awards",
862
+ honors: entryList(
863
+ L.honors,
503
864
  d.honors.map((x) => ({
504
865
  heading: x.title,
505
866
  sub: x.issuer,
506
- dates: dateRange(x.date, { locale: d.resolvedLocale }),
867
+ dates: dates(x.date, {}),
507
868
  body: x.description,
508
869
  url: x.url
509
870
  }))
510
871
  ),
511
- entryList(
512
- "Memberships",
872
+ memberships: entryList(
873
+ L.memberships,
513
874
  d.memberships.map((x) => ({
514
875
  heading: x.role ?? x.organization,
515
876
  sub: x.role ? x.organization : void 0,
516
- dates: dateRange(x.startDate, {
517
- end: x.endDate,
518
- isCurrent: x.isCurrent,
519
- locale: d.resolvedLocale
520
- }),
521
- body: x.description,
522
- url: x.url
523
- }))
524
- ),
525
- entryList(
526
- "Volunteering",
527
- d.volunteering.map((x) => ({
528
- heading: x.role,
529
- sub: nonEmpty([x.organization, x.cause], " \xB7 "),
530
- dates: dateRange(x.startDate, {
531
- end: x.endDate,
532
- isCurrent: x.isCurrent,
533
- locale: d.resolvedLocale
534
- }),
877
+ dates: dates(x.startDate, { end: x.endDate, isCurrent: x.isCurrent }),
535
878
  body: x.description,
536
879
  url: x.url
537
880
  }))
538
881
  ),
539
- entryList(
540
- "Courses",
882
+ courses: entryList(
883
+ L.courses,
541
884
  d.courses.map((x) => ({
542
885
  heading: x.title,
543
886
  sub: x.provider,
544
- dates: dateRange(x.completionDate, { locale: d.resolvedLocale }),
887
+ dates: dates(x.completionDate, {}),
545
888
  body: x.description,
546
889
  url: x.certificateUrl
547
890
  }))
548
891
  ),
549
- entryList(
550
- "Patents",
892
+ patents: entryList(
893
+ L.patents,
551
894
  d.patents.map((x) => ({
552
895
  heading: x.title,
553
896
  sub: nonEmpty([x.patentNumber, x.office, x.status, x.coInventors?.join(", ")], " \xB7 "),
554
- dates: dateRange(x.filingDate ?? x.grantDate, { locale: d.resolvedLocale }),
897
+ dates: dates(x.filingDate ?? x.grantDate, {}),
555
898
  body: x.description,
556
899
  url: x.url
557
900
  }))
558
901
  ),
559
- entryList(
560
- "Test scores",
902
+ testScores: entryList(
903
+ L.testScores,
561
904
  d.testScores.map((x) => ({
562
905
  heading: `${x.title}: ${x.score}`,
563
- dates: dateRange(x.date, { locale: d.resolvedLocale }),
906
+ dates: dates(x.date, {}),
564
907
  body: x.description,
565
908
  url: x.url
566
909
  }))
567
910
  ),
568
- renderLanguages(d.languages),
569
- renderRecommendations(d.recommendations),
570
- renderContact(d.contact)
911
+ languages: renderLanguages(d.languages, L.languages),
912
+ recommendations: renderRecommendations(d.recommendations, L.recommendations),
913
+ highlights: renderHighlights(d.highlights, L.highlights, d.settings.highlightsIntro, locale),
914
+ contact: renderContact(d.contact, L.contact)
915
+ };
916
+ const localeNavHtml = input.localeNav.length > 1 ? renderLocaleNav(input.localeNav, L.localeNav) : "";
917
+ const seenKeys = /* @__PURE__ */ new Set();
918
+ const orderedKeys = [];
919
+ for (const key of d.settings.sectionOrder ?? []) {
920
+ if (SECTION_KEYS.includes(key) && !seenKeys.has(key)) {
921
+ seenKeys.add(key);
922
+ orderedKeys.push(key);
923
+ }
924
+ }
925
+ for (const key of SECTION_KEYS) {
926
+ if (!seenKeys.has(key)) orderedKeys.push(key);
927
+ }
928
+ const body = [
929
+ renderHeader(p, localeNavHtml),
930
+ // Featured links at the top; the remaining links are a fixed bottom section.
931
+ renderFeaturedLinks(d.links, L.featuredLinks),
932
+ ...orderedKeys.map((key) => keep(key, sections[key])),
933
+ renderOtherLinks(d.links, L.otherLinks),
934
+ input.slots?.mainEnd ?? ""
571
935
  ].filter(Boolean).join("\n");
572
- const footer = d.settings.showPoweredBy === true ? '<footer class="powered">Powered by takuhon</footer>' : "";
936
+ const spdx = d.meta?.contentLicense?.spdxId;
937
+ const year = typeof input.year === "number" && Number.isFinite(input.year) ? input.year : void 0;
938
+ const licenseText = year !== void 0 ? `\xA9 ${year} ${escapeHtml(p.displayName)} \u2014 ${escapeHtml(spdx ?? "")}` : `\xA9 ${escapeHtml(p.displayName)} \u2014 ${escapeHtml(spdx ?? "")}`;
939
+ const licenseLine = spdx ? `<p class="license">${licenseText}</p>` : "";
940
+ const poweredByLine = d.settings.showPoweredBy !== false ? `<p class="powered-by">${escapeHtml(L.poweredBy)} <a href="https://takuhon.org/" rel="noopener">Takuhon</a></p>` : "";
941
+ const footerInner = `${licenseLine}${poweredByLine}`;
942
+ const footer = footerInner ? `<footer class="powered">${footerInner}</footer>` : "";
573
943
  const contactScript = input.contact ? `<script src="/contact-widget.js" data-site-key="${escapeHtml(input.contact.siteKey)}"` + (input.contact.endpoint ? ` data-endpoint="${escapeHtml(input.contact.endpoint)}"` : "") + " defer></script>\n" : "";
944
+ const skipLink = `<a class="skip-link" href="#main">${escapeHtml(L.skipLink)}</a>
945
+ `;
946
+ const bodyEnd = input.slots?.bodyEnd ?? "";
574
947
  return `<!DOCTYPE html>
575
948
  <html lang="${escapeHtml(d.resolvedLocale)}">
576
949
  <head>
577
950
  ${head}
578
951
  </head>
579
952
  <body>
580
- <main>
953
+ ${skipLink}<main id="main">
581
954
  ${body}
582
955
  </main>
583
956
  ${footer ? `${footer}
584
- ` : ""}${contactScript}</body>
957
+ ` : ""}${contactScript}${bodyEnd}</body>
585
958
  </html>
586
959
  `;
587
960
  }
@@ -706,7 +1079,33 @@ function pathLocaleFromUrl(url) {
706
1079
  // src/public-app.ts
707
1080
  var FALLBACK_VERSION = "bundled-fixture";
708
1081
  var TURNSTILE_ORIGIN = "https://challenges.cloudflare.com";
709
- function buildPublicCsp(contact) {
1082
+ var CSP_SOURCE = /^[!-~]+$/;
1083
+ var CSP_HASH = /^'sha(256|384|512)-[A-Za-z0-9+/]+={0,2}'$/;
1084
+ var CSP_BLANKET = /* @__PURE__ */ new Set(["'unsafe-inline'", "'unsafe-eval'"]);
1085
+ function sanitizeCsp(ext) {
1086
+ const dropped = [];
1087
+ const keep = (tokens, ok) => (tokens ?? []).filter((t) => {
1088
+ if (ok(t)) return true;
1089
+ dropped.push(t);
1090
+ return false;
1091
+ });
1092
+ const source = (t) => CSP_SOURCE.test(t) && !t.includes(";") && !t.includes(",") && !CSP_BLANKET.has(t.toLowerCase());
1093
+ return {
1094
+ clean: {
1095
+ scriptSrc: keep(ext.scriptSrc, source),
1096
+ connectSrc: keep(ext.connectSrc, source),
1097
+ workerSrc: keep(ext.workerSrc, source),
1098
+ scriptHashes: keep(ext.scriptHashes, (t) => CSP_HASH.test(t))
1099
+ },
1100
+ dropped
1101
+ };
1102
+ }
1103
+ function buildPublicCsp(contact, ext) {
1104
+ const turnstile = contact ? [TURNSTILE_ORIGIN] : [];
1105
+ const scriptSrc = ["'self'", ...turnstile, ...ext?.scriptSrc ?? [], ...ext?.scriptHashes ?? []];
1106
+ const connectSrc = ["'self'", ...turnstile, ...ext?.connectSrc ?? []];
1107
+ const frameSrc = [...turnstile];
1108
+ const workerSrc = ext?.workerSrc ?? [];
710
1109
  return [
711
1110
  "default-src 'self'",
712
1111
  // `https:` lets the server-rendered profile page load remote avatar images
@@ -714,10 +1113,11 @@ function buildPublicCsp(contact) {
714
1113
  // already blocks non-http(s) schemes); `data:` covers inline placeholders.
715
1114
  "img-src 'self' https: data:",
716
1115
  "style-src 'self' 'unsafe-inline'",
717
- contact ? `script-src 'self' ${TURNSTILE_ORIGIN}` : "script-src 'self'",
1116
+ `script-src ${scriptSrc.join(" ")}`,
718
1117
  "font-src 'self'",
719
- contact ? `connect-src 'self' ${TURNSTILE_ORIGIN}` : "connect-src 'self'",
720
- ...contact ? [`frame-src ${TURNSTILE_ORIGIN}`] : [],
1118
+ `connect-src ${connectSrc.join(" ")}`,
1119
+ ...frameSrc.length > 0 ? [`frame-src ${frameSrc.join(" ")}`] : [],
1120
+ ...workerSrc.length > 0 ? [`worker-src ${workerSrc.join(" ")}`] : [],
721
1121
  "frame-ancestors 'none'",
722
1122
  "base-uri 'self'",
723
1123
  "form-action 'self'",
@@ -738,6 +1138,19 @@ async function loadProfile(deps) {
738
1138
  }
739
1139
  function createPublicApp(deps) {
740
1140
  const app = new Hono({ getPath: localePrefixGetPath });
1141
+ const cspExt = deps.render?.csp;
1142
+ let pageCsp = PUBLIC_CSP;
1143
+ let pageCspWithContact = PUBLIC_CSP_WITH_CONTACT;
1144
+ if (cspExt) {
1145
+ const { clean, dropped } = sanitizeCsp(cspExt);
1146
+ if (dropped.length > 0) {
1147
+ console.warn(
1148
+ `[takuhon] Ignored ${dropped.length} invalid render.csp token(s): ${dropped.join(", ")}`
1149
+ );
1150
+ }
1151
+ pageCsp = buildPublicCsp(false, clean);
1152
+ pageCspWithContact = buildPublicCsp(true, clean);
1153
+ }
741
1154
  app.use("*", async (c, next) => {
742
1155
  await next();
743
1156
  const h = c.res.headers;
@@ -746,9 +1159,11 @@ function createPublicApp(deps) {
746
1159
  h.set("x-frame-options", "DENY");
747
1160
  h.set("referrer-policy", "strict-origin-when-cross-origin");
748
1161
  h.set("permissions-policy", "camera=(), microphone=(), geolocation=(), interest-cohort=()");
1162
+ const contact = c.get("contactEnabled") === true;
1163
+ const page = c.get("profilePage") === true;
749
1164
  h.set(
750
1165
  "content-security-policy",
751
- c.get("contactEnabled") ? PUBLIC_CSP_WITH_CONTACT : PUBLIC_CSP
1166
+ page ? contact ? pageCspWithContact : pageCsp : contact ? PUBLIC_CSP_WITH_CONTACT : PUBLIC_CSP
752
1167
  );
753
1168
  h.set("access-control-allow-origin", "*");
754
1169
  h.set("access-control-expose-headers", "ETag");
@@ -793,6 +1208,14 @@ function createPublicApp(deps) {
793
1208
  } : void 0;
794
1209
  if (contact) c.set("contactEnabled", true);
795
1210
  const html = renderProfileHtml({
1211
+ // Host composition seams (slots / labels / omitSections). `csp` is a
1212
+ // response-header concern handled above, not a renderer input, so it is
1213
+ // intentionally excluded here.
1214
+ ...deps.render ? {
1215
+ slots: deps.render.slots,
1216
+ labels: deps.render.labels,
1217
+ omitSections: deps.render.omitSections
1218
+ } : {},
796
1219
  localized,
797
1220
  canonicalUrl: `${origin}${localePath(current)}`,
798
1221
  alternates: [
@@ -802,11 +1225,13 @@ function createPublicApp(deps) {
802
1225
  localeNav: locales.map((l) => ({ locale: l, href: localePath(l), current: l === current })),
803
1226
  jsonLd: profile.settings.enableJsonLd !== false,
804
1227
  activitySnapshot: snapshot ?? void 0,
805
- contact
1228
+ contact,
1229
+ year: (/* @__PURE__ */ new Date()).getFullYear()
806
1230
  });
807
1231
  c.header("etag", `"${version}"`);
808
1232
  c.header("cache-control", "public, max-age=300");
809
1233
  c.header("vary", "Accept-Language, Cookie");
1234
+ c.set("profilePage", true);
810
1235
  return c.html(html);
811
1236
  });
812
1237
  app.get("/health", (c) => {
@@ -1761,7 +2186,8 @@ function generateSite(profile, options = {}) {
1761
2186
  alternates,
1762
2187
  localeNav,
1763
2188
  jsonLd,
1764
- activitySnapshot
2189
+ activitySnapshot,
2190
+ year: (/* @__PURE__ */ new Date()).getFullYear()
1765
2191
  });
1766
2192
  pages.push({
1767
2193
  route: isDefault ? "/" : `/${locale}/`,