@takuhon/api 1.0.0 → 1.3.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/NOTICE CHANGED
@@ -8,3 +8,38 @@ Licensed under the Apache License, Version 2.0 (see LICENSE).
8
8
 
9
9
  Third-party dependencies retain their own licenses; see this package's
10
10
  node_modules listing for details.
11
+
12
+ -------------------------------------------------------------------------------
13
+ Bundled brand-logo icons (src/html/brand-icons.ts)
14
+
15
+ The standard HTML renderer inlines monochrome brand-logo glyphs for social link
16
+ types. These path definitions are derived from:
17
+
18
+ - Simple Icons (https://simpleicons.org) — CC0-1.0 (public domain). All glyphs
19
+ except LinkedIn: github, gitlab, x, mastodon, bluesky, instagram, youtube,
20
+ threads, facebook, rss.
21
+
22
+ - Bootstrap Icons (https://icons.getbootstrap.com) — MIT License,
23
+ Copyright (c) 2019-2024 The Bootstrap Authors. The LinkedIn glyph only
24
+ (Simple Icons removed the LinkedIn mark over trademark policy).
25
+
26
+ Permission is hereby granted, free of charge, to any person obtaining a copy
27
+ of this software and associated documentation files (the "Software"), to deal
28
+ in the Software without restriction, including without limitation the rights
29
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
30
+ copies of the Software, and to permit persons to whom the Software is
31
+ furnished to do so, subject to the following conditions:
32
+
33
+ The above copyright notice and this permission notice shall be included in all
34
+ copies or substantial portions of the Software.
35
+
36
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
37
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
38
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
39
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
40
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
41
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
42
+ SOFTWARE.
43
+
44
+ Brand logos are trademarks of their respective owners; their inclusion does not
45
+ imply endorsement.
package/dist/index.d.ts CHANGED
@@ -56,6 +56,16 @@ interface ProblemResponseInput {
56
56
  }
57
57
  declare function problemResponse(c: Context, input: ProblemResponseInput): Response;
58
58
 
59
+ declare module 'hono' {
60
+ interface ContextVariableMap {
61
+ /**
62
+ * Set by the `/` handler when it embeds the contact widget, so the
63
+ * security-headers middleware serves the Turnstile-allowing CSP variant for
64
+ * that one response and the strict default everywhere else.
65
+ */
66
+ contactEnabled?: boolean;
67
+ }
68
+ }
59
69
  interface PublicAppDeps {
60
70
  storage: TakuhonStorage;
61
71
  /**
@@ -292,6 +302,18 @@ interface RenderInput {
292
302
  * metric-less) snapshot omits the section entirely.
293
303
  */
294
304
  activitySnapshot?: ActivitySnapshot;
305
+ /**
306
+ * When set, embed the `@takuhon/contact` widget: a `<link>` to its stylesheet
307
+ * in `<head>` and a deferred `<script>` whose config travels as `data-*`
308
+ * attributes (no inline script, so the page CSP needs no `'unsafe-inline'`).
309
+ * The caller gates it on `settings.contact.enabled` and a present site key;
310
+ * the adapter is responsible for serving `/contact-widget.{js,css}` and for
311
+ * relaxing its CSP to allow the Turnstile origin.
312
+ */
313
+ contact?: {
314
+ siteKey: string;
315
+ endpoint?: string;
316
+ };
295
317
  }
296
318
  /** Render a complete static HTML document for one locale-resolved profile. */
297
319
  declare function renderProfileHtml(input: RenderInput): string;
package/dist/index.js CHANGED
@@ -85,35 +85,201 @@ function nonEmpty(values, separator) {
85
85
  return joined.length > 0 ? joined : void 0;
86
86
  }
87
87
 
88
+ // src/html/brand-icons.ts
89
+ var BRAND_ICONS = {
90
+ github: {
91
+ viewBox: "0 0 24 24",
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"
93
+ },
94
+ gitlab: {
95
+ viewBox: "0 0 24 24",
96
+ path: "m23.6004 9.5927-.0337-.0862L20.3.9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.4619-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z"
97
+ },
98
+ linkedin: {
99
+ viewBox: "0 0 16 16",
100
+ path: "M0 1.146C0 .513.526 0 1.175 0h13.65C15.474 0 16 .513 16 1.146v13.708c0 .633-.526 1.146-1.175 1.146H1.175C.526 16 0 15.487 0 14.854zm4.943 12.248V6.169H2.542v7.225zm-1.2-8.212c.837 0 1.358-.554 1.358-1.248-.015-.709-.52-1.248-1.342-1.248S2.4 3.226 2.4 3.934c0 .694.521 1.248 1.327 1.248zm4.908 8.212V9.359c0-.216.016-.432.08-.586.173-.431.568-.878 1.232-.878.869 0 1.216.662 1.216 1.634v3.865h2.401V9.25c0-2.22-1.184-3.252-2.764-3.252-1.274 0-1.845.7-2.165 1.193v.025h-.016l.016-.025V6.169h-2.4c.03.678 0 7.225 0 7.225z"
101
+ },
102
+ x: {
103
+ viewBox: "0 0 24 24",
104
+ path: "M14.234 10.162 22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258 24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299-.929-1.329L3.076 1.56h3.182l5.965 8.532.929 1.329 7.754 11.09h-3.182z"
105
+ },
106
+ mastodon: {
107
+ viewBox: "0 0 24 24",
108
+ path: "M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"
109
+ },
110
+ bluesky: {
111
+ viewBox: "0 0 24 24",
112
+ path: "M5.202 2.857C7.954 4.922 10.913 9.11 12 11.358c1.087-2.247 4.046-6.436 6.798-8.501C20.783 1.366 24 .213 24 3.883c0 .732-.42 6.156-.667 7.037-.856 3.061-3.978 3.842-6.755 3.37 4.854.826 6.089 3.562 3.422 6.299-5.065 5.196-7.28-1.304-7.847-2.97-.104-.305-.152-.448-.153-.327 0-.121-.05.022-.153.327-.568 1.666-2.782 8.166-7.847 2.97-2.667-2.737-1.432-5.473 3.422-6.3-2.777.473-5.899-.308-6.755-3.369C.42 10.04 0 4.615 0 3.883c0-3.67 3.217-2.517 5.202-1.026"
113
+ },
114
+ instagram: {
115
+ viewBox: "0 0 24 24",
116
+ path: "M7.0301.084c-1.2768.0602-2.1487.264-2.911.5634-.7888.3075-1.4575.72-2.1228 1.3877-.6652.6677-1.075 1.3368-1.3802 2.127-.2954.7638-.4956 1.6365-.552 2.914-.0564 1.2775-.0689 1.6882-.0626 4.947.0062 3.2586.0206 3.6671.0825 4.9473.061 1.2765.264 2.1482.5635 2.9107.308.7889.72 1.4573 1.388 2.1228.6679.6655 1.3365 1.0743 2.1285 1.38.7632.295 1.6361.4961 2.9134.552 1.2773.056 1.6884.069 4.9462.0627 3.2578-.0062 3.668-.0207 4.9478-.0814 1.28-.0607 2.147-.2652 2.9098-.5633.7889-.3086 1.4578-.72 2.1228-1.3881.665-.6682 1.0745-1.3378 1.3795-2.1284.2957-.7632.4966-1.636.552-2.9124.056-1.2809.0692-1.6898.063-4.948-.0063-3.2583-.021-3.6668-.0817-4.9465-.0607-1.2797-.264-2.1487-.5633-2.9117-.3084-.7889-.72-1.4568-1.3876-2.1228C21.2982 1.33 20.628.9208 19.8378.6165 19.074.321 18.2017.1197 16.9244.0645 15.6471.0093 15.236-.005 11.977.0014 8.718.0076 8.31.0215 7.0301.0839m.1402 21.6932c-1.17-.0509-1.8053-.2453-2.2287-.408-.5606-.216-.96-.4771-1.3819-.895-.422-.4178-.6811-.8186-.9-1.378-.1644-.4234-.3624-1.058-.4171-2.228-.0595-1.2645-.072-1.6442-.079-4.848-.007-3.2037.0053-3.583.0607-4.848.05-1.169.2456-1.805.408-2.2282.216-.5613.4762-.96.895-1.3816.4188-.4217.8184-.6814 1.3783-.9003.423-.1651 1.0575-.3614 2.227-.4171 1.2655-.06 1.6447-.072 4.848-.079 3.2033-.007 3.5835.005 4.8495.0608 1.169.0508 1.8053.2445 2.228.408.5608.216.96.4754 1.3816.895.4217.4194.6816.8176.9005 1.3787.1653.4217.3617 1.056.4169 2.2263.0602 1.2655.0739 1.645.0796 4.848.0058 3.203-.0055 3.5834-.061 4.848-.051 1.17-.245 1.8055-.408 2.2294-.216.5604-.4763.96-.8954 1.3814-.419.4215-.8181.6811-1.3783.9-.4224.1649-1.0577.3617-2.2262.4174-1.2656.0595-1.6448.072-4.8493.079-3.2045.007-3.5825-.006-4.848-.0608M16.953 5.5864A1.44 1.44 0 1 0 18.39 4.144a1.44 1.44 0 0 0-1.437 1.4424M5.8385 12.012c.0067 3.4032 2.7706 6.1557 6.173 6.1493 3.4026-.0065 6.157-2.7701 6.1506-6.1733-.0065-3.4032-2.771-6.1565-6.174-6.1498-3.403.0067-6.156 2.771-6.1496 6.1738M8 12.0077a4 4 0 1 1 4.008 3.9921A3.9996 3.9996 0 0 1 8 12.0077"
117
+ },
118
+ youtube: {
119
+ viewBox: "0 0 24 24",
120
+ path: "M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"
121
+ },
122
+ threads: {
123
+ viewBox: "0 0 24 24",
124
+ path: "M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-1.104-3.96-3.898-5.984-8.304-6.015-2.91.022-5.11.936-6.54 2.717C4.307 6.504 3.616 8.914 3.589 12c.027 3.086.718 5.496 2.057 7.164 1.43 1.783 3.631 2.698 6.54 2.717 2.623-.02 4.358-.631 5.8-2.045 1.647-1.613 1.618-3.593 1.09-4.798-.31-.71-.873-1.3-1.634-1.75-.192 1.352-.622 2.446-1.284 3.272-.886 1.102-2.14 1.704-3.73 1.79-1.202.065-2.361-.218-3.259-.801-1.063-.689-1.685-1.74-1.752-2.964-.065-1.19.408-2.285 1.33-3.082.88-.76 2.119-1.207 3.583-1.291a13.853 13.853 0 0 1 3.02.142c-.126-.742-.375-1.332-.75-1.757-.513-.586-1.308-.883-2.359-.89h-.029c-.844 0-1.992.232-2.721 1.32L7.734 7.847c.98-1.454 2.568-2.256 4.478-2.256h.044c3.194.02 5.097 1.975 5.287 5.388.108.046.216.094.321.142 1.49.7 2.58 1.761 3.154 3.07.797 1.82.871 4.79-1.548 7.158-1.85 1.81-4.094 2.628-7.277 2.65Zm1.003-11.69c-.242 0-.487.007-.739.021-1.836.103-2.98.946-2.916 2.143.067 1.256 1.452 1.839 2.784 1.767 1.224-.065 2.818-.543 3.086-3.71a10.5 10.5 0 0 0-2.215-.221z"
125
+ },
126
+ facebook: {
127
+ viewBox: "0 0 24 24",
128
+ path: "M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z"
129
+ },
130
+ rss: {
131
+ viewBox: "0 0 24 24",
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
+ };
135
+ var BRAND_ICON_ALIASES = {
136
+ blog: "rss"
137
+ };
138
+ function brandIconSvg(type) {
139
+ const icon = BRAND_ICONS[BRAND_ICON_ALIASES[type] ?? type];
140
+ if (!icon) return "";
141
+ 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
+ }
143
+
88
144
  // src/html/build-html.ts
89
145
  function escapeJsonLd(json) {
90
146
  return json.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
91
147
  }
92
- var CSS = `:root{--fg:#1a1a1a;--muted:#666;--accent:#0b5fff;--line:#e5e5e5}
93
- *{box-sizing:border-box}
94
- body{margin:0;color:var(--fg);font:16px/1.6 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;background:#fff}
95
- main{max-width:42rem;margin:0 auto;padding:2rem 1.25rem}
96
- a{color:var(--accent)}
97
- h1{font-size:1.9rem;margin:.2rem 0}
98
- h2{font-size:1.15rem;margin:2rem 0 .75rem;padding-bottom:.3rem;border-bottom:1px solid var(--line)}
99
- h3{font-size:1rem;margin:0}
100
- header .avatar{width:96px;height:96px;border-radius:50%;object-fit:cover}
101
- .tagline{font-size:1.1rem;color:var(--muted);margin:.2rem 0}
102
- .location{color:var(--muted);margin:.2rem 0}
103
- .bio{margin:.75rem 0}
148
+ var DEFAULT_TOKENS = {
149
+ "--takuhon-color-bg": "#ffffff",
150
+ "--takuhon-color-surface": "#f6f7f9",
151
+ "--takuhon-color-text": "#1f2933",
152
+ "--takuhon-color-text-muted": "#52606d",
153
+ "--takuhon-color-border": "#d8dee7",
154
+ "--takuhon-color-primary": "#2563eb",
155
+ "--takuhon-color-primary-contrast": "#ffffff",
156
+ "--takuhon-color-accent": "#4f46e5",
157
+ "--takuhon-font-family": "system-ui,-apple-system,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif"
158
+ };
159
+ var DEFAULT_TOKENS_DARK = {
160
+ "--takuhon-color-bg": "#0f172a",
161
+ "--takuhon-color-surface": "#1e293b",
162
+ "--takuhon-color-text": "#e2e8f0",
163
+ "--takuhon-color-text-muted": "#94a3b8",
164
+ "--takuhon-color-border": "#334155",
165
+ "--takuhon-color-primary": "#60a5fa",
166
+ "--takuhon-color-primary-contrast": "#0f172a",
167
+ "--takuhon-color-accent": "#818cf8"
168
+ };
169
+ var INTERNAL_TOKENS = {
170
+ "--takuhon-space-1": "4px",
171
+ "--takuhon-space-2": "8px",
172
+ "--takuhon-space-3": "12px",
173
+ "--takuhon-space-4": "16px",
174
+ "--takuhon-space-5": "24px",
175
+ "--takuhon-space-6": "32px",
176
+ "--takuhon-radius-sm": "6px",
177
+ "--takuhon-radius-md": "12px",
178
+ "--takuhon-radius-full": "9999px",
179
+ "--takuhon-tap-target": "44px",
180
+ "--takuhon-font-size-sm": "14px",
181
+ "--takuhon-font-size-base": "16px",
182
+ "--takuhon-font-size-lg": "18px",
183
+ "--takuhon-font-size-xl": "22px",
184
+ "--takuhon-font-size-2xl": "28px",
185
+ "--takuhon-line-height": "1.7",
186
+ "--takuhon-max-content-width": "720px"
187
+ };
188
+ var COLOR_TOKEN_VARS = {
189
+ bg: "--takuhon-color-bg",
190
+ surface: "--takuhon-color-surface",
191
+ text: "--takuhon-color-text",
192
+ textMuted: "--takuhon-color-text-muted",
193
+ border: "--takuhon-color-border",
194
+ accent: "--takuhon-color-accent",
195
+ primary: "--takuhon-color-primary",
196
+ primaryContrast: "--takuhon-color-primary-contrast"
197
+ };
198
+ 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
+ var SAFE_FONT = /^[A-Za-z0-9\s,'"._-]+$/;
200
+ function safeValue(value, pattern, maxLength) {
201
+ if (typeof value !== "string") return void 0;
202
+ const v = value.trim();
203
+ if (v === "" || v.length > maxLength || !pattern.test(v)) return void 0;
204
+ return v;
205
+ }
206
+ var safeColor = (value) => safeValue(value, SAFE_COLOR, 64);
207
+ var safeFont = (value) => safeValue(value, SAFE_FONT, 256);
208
+ function colorOverrides(colors) {
209
+ if (!colors) return [];
210
+ const out = [];
211
+ for (const key of Object.keys(COLOR_TOKEN_VARS)) {
212
+ const safe = safeColor(colors[key]);
213
+ if (safe !== void 0) out.push([COLOR_TOKEN_VARS[key], safe]);
214
+ }
215
+ return out;
216
+ }
217
+ function rootBlock(pairs) {
218
+ return `:root{${[...pairs].map(([k, v]) => `${k}:${v}`).join(";")}}`;
219
+ }
220
+ function buildTokenCss(appearance) {
221
+ const light = new Map([
222
+ ...Object.entries(INTERNAL_TOKENS),
223
+ ...Object.entries(DEFAULT_TOKENS)
224
+ ]);
225
+ if (appearance) {
226
+ const font = safeFont(appearance.fontFamily);
227
+ if (font !== void 0) light.set("--takuhon-font-family", font);
228
+ for (const [cssVar, value] of colorOverrides(appearance.colors)) {
229
+ light.set(cssVar, value);
230
+ }
231
+ }
232
+ const dark = new Map(Object.entries(DEFAULT_TOKENS_DARK));
233
+ for (const [cssVar, value] of colorOverrides(appearance?.colorsDark)) {
234
+ dark.set(cssVar, value);
235
+ }
236
+ return `${rootBlock(light)}
237
+ @media (prefers-color-scheme:dark){${rootBlock(dark)}}`;
238
+ }
239
+ var CSS = `*{box-sizing:border-box}
240
+ html{font-size:100%}
241
+ body{margin:0;color:var(--takuhon-color-text);background:var(--takuhon-color-bg);font-family:var(--takuhon-font-family);font-size:var(--takuhon-font-size-base);line-height:var(--takuhon-line-height);-webkit-text-size-adjust:100%}
242
+ main{max-width:var(--takuhon-max-content-width);margin:0 auto;padding:var(--takuhon-space-6) var(--takuhon-space-4)}
243
+ a{color:var(--takuhon-color-primary)}
244
+ a:focus-visible{outline:2px solid var(--takuhon-color-accent);outline-offset:2px;border-radius:var(--takuhon-radius-sm)}
245
+ h1{font-size:var(--takuhon-font-size-2xl);font-weight:700;line-height:1.2;margin:0 0 var(--takuhon-space-2)}
246
+ 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
+ h3{font-size:var(--takuhon-font-size-lg);font-weight:600;margin:0}
248
+ header{margin-bottom:var(--takuhon-space-6);display:flow-root}
249
+ 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
+ .tagline{font-size:var(--takuhon-font-size-lg);color:var(--takuhon-color-text-muted);margin:0 0 var(--takuhon-space-2)}
251
+ .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
+ section{margin:0 0 var(--takuhon-space-6)}
104
254
  ul{padding:0;margin:0;list-style:none}
105
- .entries>li{margin:0 0 1.1rem}
106
- .sub{margin:.1rem 0;font-weight:600}
107
- .meta{margin:.1rem 0;color:var(--muted);font-size:.9rem}
108
- .links{display:flex;flex-wrap:wrap;gap:.5rem 1rem;margin:.75rem 0}
109
- .skills,.tags{display:flex;flex-wrap:wrap;gap:.4rem}
110
- .skills>li,.tags>li{background:#f2f2f2;border-radius:1rem;padding:.15rem .6rem;font-size:.85rem}
111
- .rec{margin:0 0 1.1rem}
112
- .rec blockquote{margin:0;padding-left:.9rem;border-left:3px solid var(--line)}
113
- .rec figcaption{color:var(--muted);font-size:.9rem;margin-top:.3rem}
114
- nav.locales{display:flex;gap:.75rem;margin-bottom:1rem;font-size:.9rem}
255
+ .entries>li{margin:0 0 var(--takuhon-space-4)}
256
+ .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
+ .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)}
259
+ .entries--timeline>li.is-current::before{background:var(--takuhon-color-accent)}
260
+ .entries--timeline .meta{order:-1;margin:0 0 var(--takuhon-space-1)}
261
+ .entries--cards{display:grid;gap:var(--takuhon-space-3)}
262
+ .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
+ .entries--cards>li.is-highlighted{border-color:var(--takuhon-color-accent)}
264
+ .sub{margin:var(--takuhon-space-1) 0 0;font-weight:600}
265
+ .meta{margin:var(--takuhon-space-1) 0 0;color:var(--takuhon-color-text-muted);font-size:var(--takuhon-font-size-sm)}
266
+ .featured-links,.other-links{margin:0 0 var(--takuhon-space-6)}
267
+ .featured-links>ul,.other-links>ul{list-style:none;padding:0;margin:0;display:grid;gap:var(--takuhon-space-2)}
268
+ .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)}
270
+ .featured-links a:hover,.other-links a:hover{border-color:var(--takuhon-color-accent)}
271
+ .link-main{display:inline-flex;align-items:center;gap:var(--takuhon-space-2);min-width:0}
272
+ .brand-icon{width:1.15em;height:1.15em;flex:none;opacity:.85}
273
+ .skills,.tags{display:flex;flex-wrap:wrap;gap:var(--takuhon-space-2)}
274
+ .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)}
275
+ .skills-groups{display:grid;gap:var(--takuhon-space-4)}
276
+ .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}
277
+ .rec{margin:0 0 var(--takuhon-space-4)}
278
+ .rec blockquote{margin:0;padding-left:var(--takuhon-space-3);border-left:3px solid var(--takuhon-color-border)}
279
+ .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)}
115
281
  .activity svg{max-width:100%;height:auto}
116
- footer.powered{max-width:42rem;margin:0 auto;padding:1.5rem 1.25rem;color:var(--muted);font-size:.85rem}`;
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)}`;
117
283
  function renderEntry(entry) {
118
284
  const href = entry.url ? safeUrl(entry.url) : void 0;
119
285
  const heading = href ? `<a href="${escapeHtml(href)}">${escapeHtml(entry.heading)}</a>` : escapeHtml(entry.heading);
@@ -126,11 +292,13 @@ function renderEntry(entry) {
126
292
  `<ul class="tags">${entry.tags.map((t) => `<li>${escapeHtml(t)}</li>`).join("")}</ul>`
127
293
  );
128
294
  }
129
- return `<li>${parts.join("")}</li>`;
295
+ const cls = [entry.current ? "is-current" : "", entry.highlighted ? "is-highlighted" : ""].filter(Boolean).join(" ");
296
+ return `<li${cls ? ` class="${cls}"` : ""}>${parts.join("")}</li>`;
130
297
  }
131
- function entryList(title, entries) {
298
+ function entryList(title, entries, variant) {
132
299
  if (entries.length === 0) return "";
133
- return `<section><h2>${escapeHtml(title)}</h2><ul class="entries">${entries.map(renderEntry).join("")}</ul></section>`;
300
+ const cls = variant ? `entries entries--${variant}` : "entries";
301
+ return `<section><h2>${escapeHtml(title)}</h2><ul class="${cls}">${entries.map(renderEntry).join("")}</ul></section>`;
134
302
  }
135
303
  function renderHeader(p) {
136
304
  const parts = [];
@@ -146,19 +314,55 @@ function renderHeader(p) {
146
314
  if (p.bio) parts.push(`<p class="bio">${escapeHtml(p.bio)}</p>`);
147
315
  return `<header>${parts.join("")}</header>`;
148
316
  }
317
+ 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>`;
320
+ const href = safeUrl(link.url);
321
+ return href ? `<li><a href="${escapeHtml(href)}" rel="me noopener">${main}</a></li>` : `<li>${main}</li>`;
322
+ }
323
+ function byOrder(a, b) {
324
+ return (a.order ?? 0) - (b.order ?? 0);
325
+ }
149
326
  function renderLinks(links) {
150
327
  if (links.length === 0) return "";
151
- const items = links.map((l) => {
152
- const text = escapeHtml(l.label ?? l.url);
153
- const href = safeUrl(l.url);
154
- return href ? `<li><a href="${escapeHtml(href)}">${text}</a></li>` : `<li>${text}</li>`;
155
- }).join("");
156
- return `<nav aria-label="Links"><ul class="links">${items}</ul></nav>`;
328
+ const featured = links.filter((l) => l.featured === true).sort(byOrder);
329
+ 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");
157
335
  }
158
- function renderSkills(skills) {
336
+ function renderSkills(skills, categories) {
159
337
  if (skills.length === 0) return "";
160
- const items = skills.map((s) => `<li>${escapeHtml(s.label)}</li>`).join("");
161
- return `<section><h2>Skills</h2><ul class="skills">${items}</ul></section>`;
338
+ const chips = (list) => `<ul class="skills">${list.map((s) => `<li>${escapeHtml(s.label)}</li>`).join("")}</ul>`;
339
+ if (!categories || categories.length === 0) {
340
+ return `<section><h2>Skills</h2>${chips(skills)}</section>`;
341
+ }
342
+ const UNCAT = "";
343
+ const buckets = /* @__PURE__ */ new Map();
344
+ for (const s of skills) {
345
+ const key = s.category ?? UNCAT;
346
+ const arr = buckets.get(key) ?? [];
347
+ arr.push(s);
348
+ buckets.set(key, arr);
349
+ }
350
+ const group = (heading, list) => `<div class="skill-group">${heading !== void 0 ? `<h3>${escapeHtml(heading)}</h3>` : ""}${chips(list)}</div>`;
351
+ const seen = /* @__PURE__ */ new Set();
352
+ const groups = [];
353
+ for (const cat of categories) {
354
+ const list = buckets.get(cat.id);
355
+ if (!list || list.length === 0) continue;
356
+ seen.add(cat.id);
357
+ groups.push(group(cat.label, list));
358
+ }
359
+ for (const [key, list] of buckets) {
360
+ if (key === UNCAT || seen.has(key)) continue;
361
+ groups.push(group(key, list));
362
+ }
363
+ const uncategorized = buckets.get(UNCAT);
364
+ 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>`;
162
366
  }
163
367
  function renderLanguages(languages) {
164
368
  if (languages.length === 0) return "";
@@ -220,7 +424,9 @@ function renderProfileHtml(input) {
220
424
  (a) => `<link rel="alternate" hreflang="${escapeHtml(a.hreflang)}" href="${escapeHtml(a.href)}">`
221
425
  ),
222
426
  input.jsonLd ? renderJsonLdScript(d) : "",
223
- `<style>${CSS}</style>`
427
+ input.contact ? '<link rel="stylesheet" href="/contact-widget.css">' : "",
428
+ `<style>${buildTokenCss(d.settings.appearance)}
429
+ ${CSS}</style>`
224
430
  ].filter(Boolean).join("\n ");
225
431
  const body = [
226
432
  input.localeNav.length > 1 ? renderLocaleNav(input.localeNav) : "",
@@ -237,8 +443,10 @@ function renderProfileHtml(input) {
237
443
  locale: d.resolvedLocale
238
444
  }),
239
445
  body: c.description,
240
- url: c.url
241
- }))
446
+ url: c.url,
447
+ current: c.isCurrent
448
+ })),
449
+ "timeline"
242
450
  ),
243
451
  entryList(
244
452
  "Projects",
@@ -247,10 +455,12 @@ function renderProfileHtml(input) {
247
455
  dates: dateRange(x.startDate, { end: x.endDate, locale: d.resolvedLocale }),
248
456
  body: x.description,
249
457
  url: x.url,
250
- tags: x.tags
251
- }))
458
+ tags: x.tags,
459
+ highlighted: x.highlighted
460
+ })),
461
+ "cards"
252
462
  ),
253
- renderSkills(d.skills),
463
+ renderSkills(d.skills, d.settings.skillCategories),
254
464
  renderActivity(input.activitySnapshot),
255
465
  entryList(
256
466
  "Education",
@@ -360,6 +570,7 @@ function renderProfileHtml(input) {
360
570
  renderContact(d.contact)
361
571
  ].filter(Boolean).join("\n");
362
572
  const footer = d.settings.showPoweredBy === true ? '<footer class="powered">Powered by takuhon</footer>' : "";
573
+ 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" : "";
363
574
  return `<!DOCTYPE html>
364
575
  <html lang="${escapeHtml(d.resolvedLocale)}">
365
576
  <head>
@@ -370,7 +581,7 @@ function renderProfileHtml(input) {
370
581
  ${body}
371
582
  </main>
372
583
  ${footer ? `${footer}
373
- ` : ""}</body>
584
+ ` : ""}${contactScript}</body>
374
585
  </html>
375
586
  `;
376
587
  }
@@ -494,21 +705,27 @@ function pathLocaleFromUrl(url) {
494
705
 
495
706
  // src/public-app.ts
496
707
  var FALLBACK_VERSION = "bundled-fixture";
497
- var PUBLIC_CSP = [
498
- "default-src 'self'",
499
- // `https:` lets the server-rendered profile page load remote avatar images
500
- // (the schema permits any https avatar URL, and `safeUrl` in the renderer
501
- // already blocks non-http(s) schemes); `data:` covers inline placeholders.
502
- "img-src 'self' https: data:",
503
- "style-src 'self' 'unsafe-inline'",
504
- "script-src 'self'",
505
- "font-src 'self'",
506
- "connect-src 'self'",
507
- "frame-ancestors 'none'",
508
- "base-uri 'self'",
509
- "form-action 'self'",
510
- "upgrade-insecure-requests"
511
- ].join("; ");
708
+ var TURNSTILE_ORIGIN = "https://challenges.cloudflare.com";
709
+ function buildPublicCsp(contact) {
710
+ return [
711
+ "default-src 'self'",
712
+ // `https:` lets the server-rendered profile page load remote avatar images
713
+ // (the schema permits any https avatar URL, and `safeUrl` in the renderer
714
+ // already blocks non-http(s) schemes); `data:` covers inline placeholders.
715
+ "img-src 'self' https: data:",
716
+ "style-src 'self' 'unsafe-inline'",
717
+ contact ? `script-src 'self' ${TURNSTILE_ORIGIN}` : "script-src 'self'",
718
+ "font-src 'self'",
719
+ contact ? `connect-src 'self' ${TURNSTILE_ORIGIN}` : "connect-src 'self'",
720
+ ...contact ? [`frame-src ${TURNSTILE_ORIGIN}`] : [],
721
+ "frame-ancestors 'none'",
722
+ "base-uri 'self'",
723
+ "form-action 'self'",
724
+ "upgrade-insecure-requests"
725
+ ].join("; ");
726
+ }
727
+ var PUBLIC_CSP = buildPublicCsp(false);
728
+ var PUBLIC_CSP_WITH_CONTACT = buildPublicCsp(true);
512
729
  async function loadProfile(deps) {
513
730
  try {
514
731
  return await deps.storage.getProfile();
@@ -529,7 +746,10 @@ function createPublicApp(deps) {
529
746
  h.set("x-frame-options", "DENY");
530
747
  h.set("referrer-policy", "strict-origin-when-cross-origin");
531
748
  h.set("permissions-policy", "camera=(), microphone=(), geolocation=(), interest-cohort=()");
532
- h.set("content-security-policy", PUBLIC_CSP);
749
+ h.set(
750
+ "content-security-policy",
751
+ c.get("contactEnabled") ? PUBLIC_CSP_WITH_CONTACT : PUBLIC_CSP
752
+ );
533
753
  h.set("access-control-allow-origin", "*");
534
754
  h.set("access-control-expose-headers", "ETag");
535
755
  });
@@ -565,6 +785,13 @@ function createPublicApp(deps) {
565
785
  const origin = new URL(c.req.url).origin;
566
786
  const localePath = (l) => l === defaultLocale ? "/" : `/${l}/`;
567
787
  const snapshot = profile.settings.activity?.enabled === true && deps.activityStorage ? await deps.activityStorage.getActivitySnapshot() : null;
788
+ const contactSettings = profile.settings.contact;
789
+ const contactSiteKey = contactSettings?.turnstileSiteKey?.trim();
790
+ const contact = contactSettings?.enabled === true && contactSiteKey ? {
791
+ siteKey: contactSiteKey,
792
+ ...contactSettings.endpoint ? { endpoint: contactSettings.endpoint } : {}
793
+ } : void 0;
794
+ if (contact) c.set("contactEnabled", true);
568
795
  const html = renderProfileHtml({
569
796
  localized,
570
797
  canonicalUrl: `${origin}${localePath(current)}`,
@@ -574,7 +801,8 @@ function createPublicApp(deps) {
574
801
  ],
575
802
  localeNav: locales.map((l) => ({ locale: l, href: localePath(l), current: l === current })),
576
803
  jsonLd: profile.settings.enableJsonLd !== false,
577
- activitySnapshot: snapshot ?? void 0
804
+ activitySnapshot: snapshot ?? void 0,
805
+ contact
578
806
  });
579
807
  c.header("etag", `"${version}"`);
580
808
  c.header("cache-control", "public, max-age=300");