@takuhon/api 1.3.0 → 1.4.1

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,110 @@ 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-org{margin:0;font-size:var(--takuhon-font-size-lg)}
428
+ .vol-org a{color:inherit;text-decoration:none}
429
+ .vol-org a:hover{color:var(--takuhon-color-primary);text-decoration:underline}
430
+ .vol-role{margin:var(--takuhon-space-1) 0 0;color:var(--takuhon-color-text-muted);font-size:var(--takuhon-font-size-sm)}
431
+ .vol-desc{margin:var(--takuhon-space-1) 0 0;font-size:var(--takuhon-font-size-sm)}
432
+ .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}
433
+ .vol-secondary:hover{color:var(--takuhon-color-text);border-color:var(--takuhon-color-accent)}
434
+ .vol-secondary .brand-icon{width:1em;height:1em;opacity:.85}
277
435
  .rec{margin:0 0 var(--takuhon-space-4)}
278
436
  .rec blockquote{margin:0;padding-left:var(--takuhon-space-3);border-left:3px solid var(--takuhon-color-border)}
279
437
  .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)}`;
438
+ 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)}
439
+ 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}
440
+ nav.locales a{color:var(--takuhon-color-primary);text-decoration:none}
441
+ nav.locales a:hover{text-decoration:underline}
442
+ nav.locales [aria-current]{color:var(--takuhon-color-text);font-weight:700;background:var(--takuhon-color-surface)}
443
+ .activity svg{width:100%;height:auto}
444
+ 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)}
445
+ footer.powered p{margin:0 0 var(--takuhon-space-2)}
446
+ footer.powered p:last-child{margin-bottom:0}
447
+ footer.powered .powered-by a{color:var(--takuhon-color-primary);text-decoration:none}
448
+ footer.powered .powered-by a:hover{text-decoration:underline}
449
+ .highlights .highlights-intro{color:var(--takuhon-color-text-muted);margin:0 0 var(--takuhon-space-3)}
450
+ .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}
451
+ .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}
452
+ @media (min-width:640px){.highlight-card{flex-basis:calc((100% - var(--takuhon-space-3)) / 2)}}
453
+ @media (min-width:960px){.highlight-card{flex-basis:calc((100% - 2 * var(--takuhon-space-3)) / 3)}}
454
+ .highlight-card:hover{border-color:var(--takuhon-color-accent)}
455
+ .highlight-card:focus-within{outline:2px solid var(--takuhon-color-accent);outline-offset:2px}
456
+ .highlight-thumb{aspect-ratio:1 / 1;background:var(--takuhon-color-bg)}
457
+ .highlight-thumb img{display:block;width:100%;height:100%;object-fit:cover}
458
+ .highlight-body{padding:var(--takuhon-space-3);display:flex;flex-direction:column;gap:var(--takuhon-space-2)}
459
+ .highlight-body>*{margin:0}
460
+ .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)}
461
+ .highlight-badge .brand-icon{width:1em;height:1em;opacity:.85}
462
+ .highlight-title{font-size:var(--takuhon-font-size-base)}
463
+ .highlight-title a{color:var(--takuhon-color-text);text-decoration:none}
464
+ .highlight-title a:hover{color:var(--takuhon-color-primary)}
465
+ .highlight-title a::after{content:"";position:absolute;inset:0}
466
+ .highlight-title a:focus-visible{outline:none}
467
+ .highlight-date{font-size:var(--takuhon-font-size-sm);color:var(--takuhon-color-text-muted)}
468
+ .highlight-desc{font-size:var(--takuhon-font-size-sm)}
469
+ .highlight-cta{font-size:var(--takuhon-font-size-sm);font-weight:600;color:var(--takuhon-color-primary)}`;
470
+ 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>';
471
+ function externalLink(url, innerEscaped) {
472
+ return `<a href="${escapeHtml(url)}" rel="noopener">${innerEscaped}${EXTERNAL_ICON}</a>`;
473
+ }
283
474
  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);
475
+ const headHref = entry.url ? safeUrl(entry.url) : void 0;
476
+ const heading = headHref ? externalLink(headHref, escapeHtml(entry.heading)) : escapeHtml(entry.heading);
286
477
  const parts = [`<h3>${heading}</h3>`];
287
- if (entry.sub) parts.push(`<p class="sub">${escapeHtml(entry.sub)}</p>`);
478
+ if (entry.role) parts.push(`<p class="role-badge">${escapeHtml(entry.role)}</p>`);
479
+ if (entry.sub) {
480
+ const subHref = entry.subUrl ? safeUrl(entry.subUrl) : void 0;
481
+ const sub = subHref ? externalLink(subHref, escapeHtml(entry.sub)) : escapeHtml(entry.sub);
482
+ parts.push(`<p class="sub">${sub}</p>`);
483
+ }
288
484
  if (entry.dates) parts.push(`<p class="meta">${entry.dates}</p>`);
289
- if (entry.body) parts.push(`<p>${escapeHtml(entry.body)}</p>`);
485
+ if (entry.body) parts.push(`<p class="desc">${escapeHtml(entry.body)}</p>`);
290
486
  if (entry.tags && entry.tags.length > 0) {
291
487
  parts.push(
292
488
  `<ul class="tags">${entry.tags.map((t) => `<li>${escapeHtml(t)}</li>`).join("")}</ul>`
@@ -300,8 +496,65 @@ function entryList(title, entries, variant) {
300
496
  const cls = variant ? `entries entries--${variant}` : "entries";
301
497
  return `<section><h2>${escapeHtml(title)}</h2><ul class="${cls}">${entries.map(renderEntry).join("")}</ul></section>`;
302
498
  }
303
- function renderHeader(p) {
499
+ function renderInline(text) {
500
+ return escapeHtml(text).replace(/\*\*([^*]+?)\*\*/g, "<strong>$1</strong>");
501
+ }
502
+ function renderMarkdown(input) {
503
+ const lines = input.split("\n");
504
+ const out = [];
505
+ let i = 0;
506
+ while (i < lines.length) {
507
+ const trimmed = (lines[i] ?? "").trim();
508
+ if (trimmed === "") {
509
+ i++;
510
+ continue;
511
+ }
512
+ if (trimmed === "---") {
513
+ out.push("<hr>");
514
+ i++;
515
+ continue;
516
+ }
517
+ if (trimmed.startsWith("### ")) {
518
+ out.push(`<h4>${renderInline(trimmed.slice(4))}</h4>`);
519
+ i++;
520
+ continue;
521
+ }
522
+ if (trimmed.startsWith("## ")) {
523
+ out.push(`<h3>${renderInline(trimmed.slice(3))}</h3>`);
524
+ i++;
525
+ continue;
526
+ }
527
+ if (trimmed.startsWith("- ")) {
528
+ const items = [];
529
+ while (i < lines.length) {
530
+ const cur = (lines[i] ?? "").trim();
531
+ if (!cur.startsWith("- ")) break;
532
+ items.push(`<li>${renderInline(cur.slice(2))}</li>`);
533
+ i++;
534
+ }
535
+ out.push(`<ul>${items.join("")}</ul>`);
536
+ continue;
537
+ }
538
+ const para = [];
539
+ while (i < lines.length) {
540
+ const cur = (lines[i] ?? "").trim();
541
+ if (cur === "" || cur === "---" || cur.startsWith("## ") || cur.startsWith("### ") || cur.startsWith("- ")) {
542
+ break;
543
+ }
544
+ para.push(cur);
545
+ i++;
546
+ }
547
+ if (para.length > 0) out.push(`<p>${renderInline(para.join(" "))}</p>`);
548
+ }
549
+ return out.join("\n");
550
+ }
551
+ function renderBio(bio, heading) {
552
+ if (!bio) return "";
553
+ return `<section class="bio"><h2>${escapeHtml(heading)}</h2><div class="bio-body">${renderMarkdown(bio)}</div></section>`;
554
+ }
555
+ function renderHeader(p, localeNavHtml) {
304
556
  const parts = [];
557
+ if (localeNavHtml) parts.push(localeNavHtml);
305
558
  const avatarSrc = p.avatar?.url ? safeUrl(p.avatar.url) : void 0;
306
559
  if (avatarSrc) {
307
560
  parts.push(
@@ -311,33 +564,55 @@ function renderHeader(p) {
311
564
  parts.push(`<h1>${escapeHtml(p.displayName)}</h1>`);
312
565
  if (p.tagline) parts.push(`<p class="tagline">${escapeHtml(p.tagline)}</p>`);
313
566
  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
567
  return `<header>${parts.join("")}</header>`;
316
568
  }
569
+ var LINK_TYPE_DISPLAY = {
570
+ website: "Website",
571
+ blog: "Blog",
572
+ github: "GitHub",
573
+ gitlab: "GitLab",
574
+ linkedin: "LinkedIn",
575
+ x: "X (Twitter)",
576
+ mastodon: "Mastodon",
577
+ bluesky: "Bluesky",
578
+ instagram: "Instagram",
579
+ youtube: "YouTube",
580
+ threads: "Threads",
581
+ facebook: "Facebook",
582
+ email: "Email",
583
+ rss: "RSS"
584
+ };
585
+ function linkTypePill(link, label) {
586
+ const display = LINK_TYPE_DISPLAY[link.type];
587
+ if (!display || display === label) return "";
588
+ return `<span class="link-type">${escapeHtml(display)}</span>`;
589
+ }
317
590
  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>`;
591
+ const label = link.label ?? link.url;
592
+ const main = `<span class="link-main">${brandIconForLink(link)}<span>${escapeHtml(label)}</span></span>`;
593
+ const pill = link.label == null ? linkTypePill(link, label) : "";
320
594
  const href = safeUrl(link.url);
321
- return href ? `<li><a href="${escapeHtml(href)}" rel="me noopener">${main}</a></li>` : `<li>${main}</li>`;
595
+ return href ? `<li><a href="${escapeHtml(href)}" rel="me noopener">${main}${pill}</a></li>` : `<li>${main}${pill}</li>`;
322
596
  }
323
597
  function byOrder(a, b) {
324
598
  return (a.order ?? 0) - (b.order ?? 0);
325
599
  }
326
- function renderLinks(links) {
327
- if (links.length === 0) return "";
600
+ function renderFeaturedLinks(links, ariaLabel) {
328
601
  const featured = links.filter((l) => l.featured === true).sort(byOrder);
602
+ if (featured.length === 0) return "";
603
+ return `<nav class="featured-links" aria-label="${escapeHtml(ariaLabel)}"><ul>${featured.map(renderLinkItem).join("")}</ul></nav>`;
604
+ }
605
+ function renderOtherLinks(links, heading) {
329
606
  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");
607
+ if (others.length === 0) return "";
608
+ return `<section class="other-links"><h2>${escapeHtml(heading)}</h2><ul>${others.map(renderLinkItem).join("")}</ul></section>`;
335
609
  }
336
- function renderSkills(skills, categories) {
610
+ function renderSkills(skills, categories, heading) {
337
611
  if (skills.length === 0) return "";
612
+ const h = escapeHtml(heading);
338
613
  const chips = (list) => `<ul class="skills">${list.map((s) => `<li>${escapeHtml(s.label)}</li>`).join("")}</ul>`;
339
614
  if (!categories || categories.length === 0) {
340
- return `<section><h2>Skills</h2>${chips(skills)}</section>`;
615
+ return `<section><h2>${h}</h2>${chips(skills)}</section>`;
341
616
  }
342
617
  const UNCAT = "";
343
618
  const buckets = /* @__PURE__ */ new Map();
@@ -347,7 +622,7 @@ function renderSkills(skills, categories) {
347
622
  arr.push(s);
348
623
  buckets.set(key, arr);
349
624
  }
350
- const group = (heading, list) => `<div class="skill-group">${heading !== void 0 ? `<h3>${escapeHtml(heading)}</h3>` : ""}${chips(list)}</div>`;
625
+ const group = (heading2, list) => `<div class="skill-group">${heading2 !== void 0 ? `<h3>${escapeHtml(heading2)}</h3>` : ""}${chips(list)}</div>`;
351
626
  const seen = /* @__PURE__ */ new Set();
352
627
  const groups = [];
353
628
  for (const cat of categories) {
@@ -362,14 +637,41 @@ function renderSkills(skills, categories) {
362
637
  }
363
638
  const uncategorized = buckets.get(UNCAT);
364
639
  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>`;
640
+ return `<section><h2>${h}</h2><div class="skills-groups">${groups.join("")}</div></section>`;
366
641
  }
367
- function renderLanguages(languages) {
642
+ function hostLabel(url) {
643
+ try {
644
+ const host = new URL(url).hostname.replace(/^www\./, "");
645
+ return host || url;
646
+ } catch {
647
+ return url;
648
+ }
649
+ }
650
+ function secondaryLinkPill(link) {
651
+ const label = escapeHtml(link.label ?? hostLabel(link.url));
652
+ const href = safeUrl(link.url);
653
+ const glyph = href ? brandIconForHost(link.url) : "";
654
+ const inner = `${glyph}<span>${label}</span>`;
655
+ return href ? `<a class="vol-secondary" href="${escapeHtml(href)}" rel="noopener">${inner}</a>` : `<span class="vol-secondary">${inner}</span>`;
656
+ }
657
+ function renderVolunteering(items, heading) {
658
+ if (items.length === 0) return "";
659
+ const li = (v) => {
660
+ const orgHref = v.url ? safeUrl(v.url) : void 0;
661
+ const org = orgHref ? externalLink(orgHref, escapeHtml(v.organization)) : escapeHtml(v.organization);
662
+ const role = v.role ? `<p class="vol-role">${escapeHtml(v.role)}</p>` : "";
663
+ const desc = v.description ? `<p class="vol-desc">${escapeHtml(v.description)}</p>` : "";
664
+ const secondary = v.secondaryLink ? secondaryLinkPill(v.secondaryLink) : "";
665
+ return `<li class="vol"><div class="vol-head"><p class="vol-org">${org}</p>${role}</div>${desc}${secondary}</li>`;
666
+ };
667
+ return `<section><h2>${escapeHtml(heading)}</h2><ul class="vol-list">${items.map(li).join("")}</ul></section>`;
668
+ }
669
+ function renderLanguages(languages, heading) {
368
670
  if (languages.length === 0) return "";
369
671
  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>`;
672
+ return `<section><h2>${escapeHtml(heading)}</h2><ul class="entries">${items}</ul></section>`;
371
673
  }
372
- function renderRecommendations(recs) {
674
+ function renderRecommendations(recs, heading) {
373
675
  if (recs.length === 0) return "";
374
676
  const items = recs.map((r) => {
375
677
  const authorHref = r.author.url ? safeUrl(r.author.url) : void 0;
@@ -378,9 +680,9 @@ function renderRecommendations(recs) {
378
680
  const rel = r.relationship ? ` (${escapeHtml(r.relationship)})` : "";
379
681
  return `<figure class="rec"><blockquote>${escapeHtml(r.body)}</blockquote><figcaption>\u2014 ${caption}${rel}</figcaption></figure>`;
380
682
  }).join("");
381
- return `<section><h2>Recommendations</h2>${items}</section>`;
683
+ return `<section><h2>${escapeHtml(heading)}</h2>${items}</section>`;
382
684
  }
383
- function renderContact(contact) {
685
+ function renderContact(contact, heading) {
384
686
  const items = [];
385
687
  if (contact.email) {
386
688
  items.push(
@@ -392,33 +694,97 @@ function renderContact(contact) {
392
694
  items.push(`<li><a href="${escapeHtml(formHref)}">Contact form</a></li>`);
393
695
  }
394
696
  if (items.length === 0) return "";
395
- return `<section><h2>Contact</h2><ul class="entries">${items.join("")}</ul></section>`;
697
+ return `<section><h2>${escapeHtml(heading)}</h2><ul class="entries">${items.join("")}</ul></section>`;
698
+ }
699
+ var PLATFORM_DISPLAY = {
700
+ instagram: "Instagram",
701
+ x: "X",
702
+ linkedin: "LinkedIn",
703
+ github: "GitHub",
704
+ gitlab: "GitLab",
705
+ youtube: "YouTube",
706
+ threads: "Threads",
707
+ facebook: "Facebook",
708
+ mastodon: "Mastodon",
709
+ bluesky: "Bluesky",
710
+ zenn: "Zenn",
711
+ qiita: "Qiita",
712
+ note: "note",
713
+ blog: "Blog",
714
+ event: "Event",
715
+ artwork: "Artwork",
716
+ project: "Project"
717
+ };
718
+ function platformDisplay(platform) {
719
+ const label = PLATFORM_DISPLAY[platform.trim().toLowerCase()];
720
+ return typeof label === "string" ? label : platform;
721
+ }
722
+ function highlightBadge(h) {
723
+ return `<span class="highlight-badge">${brandIconForPlatform(h.platform, h.url)}<span>${escapeHtml(platformDisplay(h.platform))}</span></span>`;
724
+ }
725
+ function renderHighlightCard(h, locale) {
726
+ const imgSrc = h.image ? safeUrl(h.image) : void 0;
727
+ const img = imgSrc ? `<div class="highlight-thumb"><img src="${escapeHtml(imgSrc)}" alt="${escapeHtml(h.alt)}" loading="lazy" decoding="async"></div>` : "";
728
+ const href = h.url ? safeUrl(h.url) : void 0;
729
+ const name = platformDisplay(h.platform);
730
+ const cta = isJapaneseLocale(locale) ? `${name}\u3067\u898B\u308B` : `View on ${name}`;
731
+ const title = escapeHtml(h.title);
732
+ 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>`;
733
+ const date = h.postedAt ? `<p class="highlight-date">${timeTag(h.postedAt, locale)}</p>` : "";
734
+ const desc = h.description ? `<p class="highlight-desc">${escapeHtml(h.description)}</p>` : "";
735
+ const tags = h.tags && h.tags.length > 0 ? `<ul class="tags">${h.tags.map((t) => `<li>${escapeHtml(t)}</li>`).join("")}</ul>` : "";
736
+ const ctaEl = href ? `<span class="highlight-cta" aria-hidden="true">${escapeHtml(cta)} \u2192</span>` : "";
737
+ return `<li class="highlight-card">${img}<div class="highlight-body">${highlightBadge(h)}${titleEl}${date}${desc}${tags}${ctaEl}</div></li>`;
738
+ }
739
+ function renderHighlights(items, heading, intro, locale) {
740
+ if (items.length === 0) return "";
741
+ const introEl = intro ? `<p class="highlights-intro">${escapeHtml(intro)}</p>` : "";
742
+ const cards = items.map((h) => renderHighlightCard(h, locale)).join("");
743
+ return `<section class="highlights"><h2>${escapeHtml(heading)}</h2>${introEl}<ul class="highlights-track" role="list">${cards}</ul></section>`;
396
744
  }
397
- function renderActivity(snapshot) {
745
+ function renderActivity(snapshot, heading) {
398
746
  if (!snapshot) return "";
399
747
  const svg = renderActivitySvg(snapshot);
400
748
  if (svg === "") return "";
401
- return `<section class="activity"><h2>Activity</h2>${svg}</section>`;
749
+ return `<section class="activity"><h2>${escapeHtml(heading)}</h2>${svg}</section>`;
402
750
  }
403
751
  function renderJsonLdScript(data) {
404
752
  const payload = JSON.stringify(generateJsonLd(data));
405
753
  return `<script type="application/ld+json">${escapeJsonLd(payload)}</script>`;
406
754
  }
407
- function renderLocaleNav(localeNav) {
755
+ function renderLocaleNav(localeNav, ariaLabel) {
408
756
  const items = localeNav.map(
409
757
  (l) => l.current ? `<span aria-current="true">${escapeHtml(l.locale)}</span>` : `<a href="${escapeHtml(l.href)}">${escapeHtml(l.locale)}</a>`
410
758
  ).join("");
411
- return `<nav class="locales" aria-label="Language">${items}</nav>`;
759
+ return `<nav class="locales" aria-label="${escapeHtml(ariaLabel)}">${items}</nav>`;
412
760
  }
761
+ var DATE_SEPARATOR_JA = " \u301C ";
762
+ var DATE_SEPARATOR_DEFAULT = " \u2013 ";
413
763
  function renderProfileHtml(input) {
414
764
  const d = input.localized;
415
765
  const p = d.profile;
766
+ const locale = d.resolvedLocale;
416
767
  const description = p.tagline ?? p.bio ?? "";
768
+ const L = {
769
+ ...pickLabelPack(locale),
770
+ ...d.settings.sectionLabels ?? {},
771
+ ...input.labels
772
+ };
773
+ const omit = new Set(input.omitSections ?? []);
774
+ const keep = (key, html) => omit.has(key) ? "" : html;
775
+ const dateSeparator = isJapaneseLocale(locale) ? DATE_SEPARATOR_JA : DATE_SEPARATOR_DEFAULT;
776
+ const dates = (start, opts) => dateRange(start, { ...opts, locale, separator: dateSeparator });
417
777
  const head = [
418
778
  '<meta charset="utf-8">',
419
779
  '<meta name="viewport" content="width=device-width, initial-scale=1">',
420
780
  `<title>${escapeHtml(p.displayName)}</title>`,
421
781
  description ? `<meta name="description" content="${escapeHtml(description.slice(0, 300))}">` : "",
782
+ // Data-derived Open Graph tags (the renderer owns these; asset tags like
783
+ // og:image and PWA/theme-color are the host's, via the `head` slot).
784
+ '<meta property="og:type" content="profile">',
785
+ `<meta property="og:title" content="${escapeHtml(p.displayName)}">`,
786
+ description ? `<meta property="og:description" content="${escapeHtml(description.slice(0, 300))}">` : "",
787
+ input.canonicalUrl ? `<meta property="og:url" content="${escapeHtml(input.canonicalUrl)}">` : "",
422
788
  input.canonicalUrl ? `<link rel="canonical" href="${escapeHtml(input.canonicalUrl)}">` : "",
423
789
  ...input.alternates.map(
424
790
  (a) => `<link rel="alternate" hreflang="${escapeHtml(a.hreflang)}" href="${escapeHtml(a.href)}">`
@@ -426,162 +792,168 @@ function renderProfileHtml(input) {
426
792
  input.jsonLd ? renderJsonLdScript(d) : "",
427
793
  input.contact ? '<link rel="stylesheet" href="/contact-widget.css">' : "",
428
794
  `<style>${buildTokenCss(d.settings.appearance)}
429
- ${CSS}</style>`
795
+ ${CSS}</style>`,
796
+ // First-party <head> slot (raw, unescaped): OG image tags, manifest link, etc.
797
+ input.slots?.head ?? ""
430
798
  ].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",
799
+ const sections = {
800
+ about: renderBio(p.bio, L.about),
801
+ careers: entryList(
802
+ L.careers,
437
803
  d.careers.map((c) => ({
438
804
  heading: c.role,
439
805
  sub: c.organization,
440
- dates: dateRange(c.startDate, {
441
- end: c.endDate,
442
- isCurrent: c.isCurrent,
443
- locale: d.resolvedLocale
444
- }),
806
+ subUrl: c.url,
807
+ dates: dates(c.startDate, { end: c.endDate, isCurrent: c.isCurrent }),
445
808
  body: c.description,
446
- url: c.url,
447
809
  current: c.isCurrent
448
810
  })),
449
811
  "timeline"
450
812
  ),
451
- entryList(
452
- "Projects",
813
+ projects: entryList(
814
+ L.projects,
453
815
  d.projects.map((x) => ({
454
816
  heading: x.title,
455
- dates: dateRange(x.startDate, { end: x.endDate, locale: d.resolvedLocale }),
456
- body: x.description,
457
817
  url: x.url,
818
+ role: x.role,
819
+ dates: dates(x.startDate, { end: x.endDate }),
820
+ body: x.description,
458
821
  tags: x.tags,
459
822
  highlighted: x.highlighted
460
823
  })),
461
824
  "cards"
462
825
  ),
463
- renderSkills(d.skills, d.settings.skillCategories),
464
- renderActivity(input.activitySnapshot),
465
- entryList(
466
- "Education",
826
+ volunteering: renderVolunteering(d.volunteering, L.volunteering),
827
+ skills: renderSkills(d.skills, d.settings.skillCategories, L.skills),
828
+ activity: renderActivity(input.activitySnapshot, L.activity),
829
+ education: entryList(
830
+ L.education,
467
831
  d.education.map((e) => {
468
832
  const degree = nonEmpty([e.degree, e.fieldOfStudy], ", ");
469
833
  return {
470
834
  heading: degree ?? e.institution,
471
835
  sub: degree ? e.institution : void 0,
472
- dates: dateRange(e.startDate, {
473
- end: e.endDate,
474
- isCurrent: e.isCurrent,
475
- locale: d.resolvedLocale
476
- }),
836
+ dates: dates(e.startDate, { end: e.endDate, isCurrent: e.isCurrent }),
477
837
  body: e.description,
478
838
  url: e.url
479
839
  };
480
840
  })
481
841
  ),
482
- entryList(
483
- "Certifications",
842
+ certifications: entryList(
843
+ L.certifications,
484
844
  d.certifications.map((c) => ({
485
845
  heading: c.title,
486
846
  sub: c.issuingOrganization,
487
- dates: dateRange(c.issueDate, { end: c.expirationDate, locale: d.resolvedLocale }),
847
+ dates: dates(c.issueDate, { end: c.expirationDate }),
488
848
  url: c.url
489
849
  }))
490
850
  ),
491
- entryList(
492
- "Publications",
851
+ publications: entryList(
852
+ L.publications,
493
853
  d.publications.map((x) => ({
494
854
  heading: x.title,
495
855
  sub: nonEmpty([x.publisher, x.coAuthors?.join(", ")], " \xB7 "),
496
- dates: dateRange(x.date, { locale: d.resolvedLocale }),
856
+ dates: dates(x.date, {}),
497
857
  body: x.description,
498
858
  url: x.url ?? (x.doi ? `https://doi.org/${x.doi}` : void 0)
499
859
  }))
500
860
  ),
501
- entryList(
502
- "Honors & awards",
861
+ honors: entryList(
862
+ L.honors,
503
863
  d.honors.map((x) => ({
504
864
  heading: x.title,
505
865
  sub: x.issuer,
506
- dates: dateRange(x.date, { locale: d.resolvedLocale }),
866
+ dates: dates(x.date, {}),
507
867
  body: x.description,
508
868
  url: x.url
509
869
  }))
510
870
  ),
511
- entryList(
512
- "Memberships",
871
+ memberships: entryList(
872
+ L.memberships,
513
873
  d.memberships.map((x) => ({
514
874
  heading: x.role ?? x.organization,
515
875
  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
- }),
876
+ dates: dates(x.startDate, { end: x.endDate, isCurrent: x.isCurrent }),
535
877
  body: x.description,
536
878
  url: x.url
537
879
  }))
538
880
  ),
539
- entryList(
540
- "Courses",
881
+ courses: entryList(
882
+ L.courses,
541
883
  d.courses.map((x) => ({
542
884
  heading: x.title,
543
885
  sub: x.provider,
544
- dates: dateRange(x.completionDate, { locale: d.resolvedLocale }),
886
+ dates: dates(x.completionDate, {}),
545
887
  body: x.description,
546
888
  url: x.certificateUrl
547
889
  }))
548
890
  ),
549
- entryList(
550
- "Patents",
891
+ patents: entryList(
892
+ L.patents,
551
893
  d.patents.map((x) => ({
552
894
  heading: x.title,
553
895
  sub: nonEmpty([x.patentNumber, x.office, x.status, x.coInventors?.join(", ")], " \xB7 "),
554
- dates: dateRange(x.filingDate ?? x.grantDate, { locale: d.resolvedLocale }),
896
+ dates: dates(x.filingDate ?? x.grantDate, {}),
555
897
  body: x.description,
556
898
  url: x.url
557
899
  }))
558
900
  ),
559
- entryList(
560
- "Test scores",
901
+ testScores: entryList(
902
+ L.testScores,
561
903
  d.testScores.map((x) => ({
562
904
  heading: `${x.title}: ${x.score}`,
563
- dates: dateRange(x.date, { locale: d.resolvedLocale }),
905
+ dates: dates(x.date, {}),
564
906
  body: x.description,
565
907
  url: x.url
566
908
  }))
567
909
  ),
568
- renderLanguages(d.languages),
569
- renderRecommendations(d.recommendations),
570
- renderContact(d.contact)
910
+ languages: renderLanguages(d.languages, L.languages),
911
+ recommendations: renderRecommendations(d.recommendations, L.recommendations),
912
+ highlights: renderHighlights(d.highlights, L.highlights, d.settings.highlightsIntro, locale),
913
+ contact: renderContact(d.contact, L.contact)
914
+ };
915
+ const localeNavHtml = input.localeNav.length > 1 ? renderLocaleNav(input.localeNav, L.localeNav) : "";
916
+ const seenKeys = /* @__PURE__ */ new Set();
917
+ const orderedKeys = [];
918
+ for (const key of d.settings.sectionOrder ?? []) {
919
+ if (SECTION_KEYS.includes(key) && !seenKeys.has(key)) {
920
+ seenKeys.add(key);
921
+ orderedKeys.push(key);
922
+ }
923
+ }
924
+ for (const key of SECTION_KEYS) {
925
+ if (!seenKeys.has(key)) orderedKeys.push(key);
926
+ }
927
+ const body = [
928
+ renderHeader(p, localeNavHtml),
929
+ // Featured links at the top; the remaining links are a fixed bottom section.
930
+ renderFeaturedLinks(d.links, L.featuredLinks),
931
+ ...orderedKeys.map((key) => keep(key, sections[key])),
932
+ renderOtherLinks(d.links, L.otherLinks),
933
+ input.slots?.mainEnd ?? ""
571
934
  ].filter(Boolean).join("\n");
572
- const footer = d.settings.showPoweredBy === true ? '<footer class="powered">Powered by takuhon</footer>' : "";
935
+ const spdx = d.meta?.contentLicense?.spdxId;
936
+ const year = typeof input.year === "number" && Number.isFinite(input.year) ? input.year : void 0;
937
+ const licenseText = year !== void 0 ? `\xA9 ${year} ${escapeHtml(p.displayName)} \u2014 ${escapeHtml(spdx ?? "")}` : `\xA9 ${escapeHtml(p.displayName)} \u2014 ${escapeHtml(spdx ?? "")}`;
938
+ const licenseLine = spdx ? `<p class="license">${licenseText}</p>` : "";
939
+ const poweredByLine = d.settings.showPoweredBy !== false ? `<p class="powered-by">${escapeHtml(L.poweredBy)} <a href="https://takuhon.org/" rel="noopener">Takuhon</a></p>` : "";
940
+ const footerInner = `${licenseLine}${poweredByLine}`;
941
+ const footer = footerInner ? `<footer class="powered">${footerInner}</footer>` : "";
573
942
  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" : "";
943
+ const skipLink = `<a class="skip-link" href="#main">${escapeHtml(L.skipLink)}</a>
944
+ `;
945
+ const bodyEnd = input.slots?.bodyEnd ?? "";
574
946
  return `<!DOCTYPE html>
575
947
  <html lang="${escapeHtml(d.resolvedLocale)}">
576
948
  <head>
577
949
  ${head}
578
950
  </head>
579
951
  <body>
580
- <main>
952
+ ${skipLink}<main id="main">
581
953
  ${body}
582
954
  </main>
583
955
  ${footer ? `${footer}
584
- ` : ""}${contactScript}</body>
956
+ ` : ""}${contactScript}${bodyEnd}</body>
585
957
  </html>
586
958
  `;
587
959
  }
@@ -706,7 +1078,33 @@ function pathLocaleFromUrl(url) {
706
1078
  // src/public-app.ts
707
1079
  var FALLBACK_VERSION = "bundled-fixture";
708
1080
  var TURNSTILE_ORIGIN = "https://challenges.cloudflare.com";
709
- function buildPublicCsp(contact) {
1081
+ var CSP_SOURCE = /^[!-~]+$/;
1082
+ var CSP_HASH = /^'sha(256|384|512)-[A-Za-z0-9+/]+={0,2}'$/;
1083
+ var CSP_BLANKET = /* @__PURE__ */ new Set(["'unsafe-inline'", "'unsafe-eval'"]);
1084
+ function sanitizeCsp(ext) {
1085
+ const dropped = [];
1086
+ const keep = (tokens, ok) => (tokens ?? []).filter((t) => {
1087
+ if (ok(t)) return true;
1088
+ dropped.push(t);
1089
+ return false;
1090
+ });
1091
+ const source = (t) => CSP_SOURCE.test(t) && !t.includes(";") && !t.includes(",") && !CSP_BLANKET.has(t.toLowerCase());
1092
+ return {
1093
+ clean: {
1094
+ scriptSrc: keep(ext.scriptSrc, source),
1095
+ connectSrc: keep(ext.connectSrc, source),
1096
+ workerSrc: keep(ext.workerSrc, source),
1097
+ scriptHashes: keep(ext.scriptHashes, (t) => CSP_HASH.test(t))
1098
+ },
1099
+ dropped
1100
+ };
1101
+ }
1102
+ function buildPublicCsp(contact, ext) {
1103
+ const turnstile = contact ? [TURNSTILE_ORIGIN] : [];
1104
+ const scriptSrc = ["'self'", ...turnstile, ...ext?.scriptSrc ?? [], ...ext?.scriptHashes ?? []];
1105
+ const connectSrc = ["'self'", ...turnstile, ...ext?.connectSrc ?? []];
1106
+ const frameSrc = [...turnstile];
1107
+ const workerSrc = ext?.workerSrc ?? [];
710
1108
  return [
711
1109
  "default-src 'self'",
712
1110
  // `https:` lets the server-rendered profile page load remote avatar images
@@ -714,10 +1112,11 @@ function buildPublicCsp(contact) {
714
1112
  // already blocks non-http(s) schemes); `data:` covers inline placeholders.
715
1113
  "img-src 'self' https: data:",
716
1114
  "style-src 'self' 'unsafe-inline'",
717
- contact ? `script-src 'self' ${TURNSTILE_ORIGIN}` : "script-src 'self'",
1115
+ `script-src ${scriptSrc.join(" ")}`,
718
1116
  "font-src 'self'",
719
- contact ? `connect-src 'self' ${TURNSTILE_ORIGIN}` : "connect-src 'self'",
720
- ...contact ? [`frame-src ${TURNSTILE_ORIGIN}`] : [],
1117
+ `connect-src ${connectSrc.join(" ")}`,
1118
+ ...frameSrc.length > 0 ? [`frame-src ${frameSrc.join(" ")}`] : [],
1119
+ ...workerSrc.length > 0 ? [`worker-src ${workerSrc.join(" ")}`] : [],
721
1120
  "frame-ancestors 'none'",
722
1121
  "base-uri 'self'",
723
1122
  "form-action 'self'",
@@ -738,6 +1137,19 @@ async function loadProfile(deps) {
738
1137
  }
739
1138
  function createPublicApp(deps) {
740
1139
  const app = new Hono({ getPath: localePrefixGetPath });
1140
+ const cspExt = deps.render?.csp;
1141
+ let pageCsp = PUBLIC_CSP;
1142
+ let pageCspWithContact = PUBLIC_CSP_WITH_CONTACT;
1143
+ if (cspExt) {
1144
+ const { clean, dropped } = sanitizeCsp(cspExt);
1145
+ if (dropped.length > 0) {
1146
+ console.warn(
1147
+ `[takuhon] Ignored ${dropped.length} invalid render.csp token(s): ${dropped.join(", ")}`
1148
+ );
1149
+ }
1150
+ pageCsp = buildPublicCsp(false, clean);
1151
+ pageCspWithContact = buildPublicCsp(true, clean);
1152
+ }
741
1153
  app.use("*", async (c, next) => {
742
1154
  await next();
743
1155
  const h = c.res.headers;
@@ -746,9 +1158,11 @@ function createPublicApp(deps) {
746
1158
  h.set("x-frame-options", "DENY");
747
1159
  h.set("referrer-policy", "strict-origin-when-cross-origin");
748
1160
  h.set("permissions-policy", "camera=(), microphone=(), geolocation=(), interest-cohort=()");
1161
+ const contact = c.get("contactEnabled") === true;
1162
+ const page = c.get("profilePage") === true;
749
1163
  h.set(
750
1164
  "content-security-policy",
751
- c.get("contactEnabled") ? PUBLIC_CSP_WITH_CONTACT : PUBLIC_CSP
1165
+ page ? contact ? pageCspWithContact : pageCsp : contact ? PUBLIC_CSP_WITH_CONTACT : PUBLIC_CSP
752
1166
  );
753
1167
  h.set("access-control-allow-origin", "*");
754
1168
  h.set("access-control-expose-headers", "ETag");
@@ -793,6 +1207,14 @@ function createPublicApp(deps) {
793
1207
  } : void 0;
794
1208
  if (contact) c.set("contactEnabled", true);
795
1209
  const html = renderProfileHtml({
1210
+ // Host composition seams (slots / labels / omitSections). `csp` is a
1211
+ // response-header concern handled above, not a renderer input, so it is
1212
+ // intentionally excluded here.
1213
+ ...deps.render ? {
1214
+ slots: deps.render.slots,
1215
+ labels: deps.render.labels,
1216
+ omitSections: deps.render.omitSections
1217
+ } : {},
796
1218
  localized,
797
1219
  canonicalUrl: `${origin}${localePath(current)}`,
798
1220
  alternates: [
@@ -802,11 +1224,13 @@ function createPublicApp(deps) {
802
1224
  localeNav: locales.map((l) => ({ locale: l, href: localePath(l), current: l === current })),
803
1225
  jsonLd: profile.settings.enableJsonLd !== false,
804
1226
  activitySnapshot: snapshot ?? void 0,
805
- contact
1227
+ contact,
1228
+ year: (/* @__PURE__ */ new Date()).getFullYear()
806
1229
  });
807
1230
  c.header("etag", `"${version}"`);
808
1231
  c.header("cache-control", "public, max-age=300");
809
1232
  c.header("vary", "Accept-Language, Cookie");
1233
+ c.set("profilePage", true);
810
1234
  return c.html(html);
811
1235
  });
812
1236
  app.get("/health", (c) => {
@@ -1761,7 +2185,8 @@ function generateSite(profile, options = {}) {
1761
2185
  alternates,
1762
2186
  localeNav,
1763
2187
  jsonLd,
1764
- activitySnapshot
2188
+ activitySnapshot,
2189
+ year: (/* @__PURE__ */ new Date()).getFullYear()
1765
2190
  });
1766
2191
  pages.push({
1767
2192
  route: isDefault ? "/" : `/${locale}/`,