@jant/core 0.3.43 → 0.3.44

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.
@@ -120,6 +120,13 @@
120
120
  "when": 1776685653392,
121
121
  "tag": "0016_familiar_lionheart",
122
122
  "breakpoints": true
123
+ },
124
+ {
125
+ "idx": 17,
126
+ "version": "7",
127
+ "when": 1776712494794,
128
+ "tag": "0017_bright_beyonder",
129
+ "breakpoints": true
123
130
  }
124
131
  ]
125
132
  }
@@ -70,11 +70,15 @@ export const sites = pgTable(
70
70
  })
71
71
  .notNull()
72
72
  .default("active"),
73
+ provisioningIdempotencyKey: text("provisioning_idempotency_key"),
73
74
  createdAt: integer("created_at").notNull(),
74
75
  updatedAt: integer("updated_at").notNull(),
75
76
  },
76
77
  (table) => [
77
78
  uniqueIndex("uq_site_key").on(table.key),
79
+ uniqueIndex("uq_site_provisioning_idempotency_key")
80
+ .on(table.provisioningIdempotencyKey)
81
+ .where(sql`${table.provisioningIdempotencyKey} IS NOT NULL`),
78
82
  check(
79
83
  "chk_site_status",
80
84
  sql`${table.status} IN (${sqlTextEnum(SITE_STATUSES)})`,
package/src/db/schema.ts CHANGED
@@ -60,11 +60,15 @@ export const sites = sqliteTable(
60
60
  })
61
61
  .notNull()
62
62
  .default("active"),
63
+ provisioningIdempotencyKey: text("provisioning_idempotency_key"),
63
64
  createdAt: integer("created_at").notNull(),
64
65
  updatedAt: integer("updated_at").notNull(),
65
66
  },
66
67
  (table) => [
67
68
  uniqueIndex("uq_site_key").on(table.key),
69
+ uniqueIndex("uq_site_provisioning_idempotency_key")
70
+ .on(table.provisioningIdempotencyKey)
71
+ .where(sql`${table.provisioningIdempotencyKey} IS NOT NULL`),
68
72
  check(
69
73
  "chk_site_status",
70
74
  sql`${table.status} IN (${sqlTextEnum(SITE_STATUSES)})`,
package/src/lib/icons.ts CHANGED
@@ -36,3 +36,40 @@ export function getIconSvg(name: string): string | null {
36
36
  const svg = (lucideIcons as Record<string, string>)[pascalName];
37
37
  return typeof svg === "string" ? svg : null;
38
38
  }
39
+
40
+ /**
41
+ * Get the inner SVG contents for a Lucide icon (the path children only,
42
+ * without the outer <svg> wrapper). Used by the icon sprite to build
43
+ * <symbol> definitions.
44
+ *
45
+ * @param name - Kebab-case icon name
46
+ * @returns Inner SVG markup (e.g. "<path ... />"), or null when unknown
47
+ *
48
+ * @example
49
+ * ```ts
50
+ * getIconInnerSvg("book-open");
51
+ * // -> "<path d=\"...\"/><path d=\"...\"/>"
52
+ * ```
53
+ */
54
+ export function getIconInnerSvg(name: string): string | null {
55
+ const svg = getIconSvg(name);
56
+ if (!svg) return null;
57
+ // lucide-static output looks like:
58
+ // <svg ... viewBox="0 0 24 24" ...><path .../>...<line .../></svg>
59
+ // Strip the outer <svg ...> and </svg>.
60
+ const openTagEnd = svg.indexOf(">");
61
+ const closeTagStart = svg.lastIndexOf("</svg>");
62
+ if (openTagEnd < 0 || closeTagStart < 0) return null;
63
+ return svg.slice(openTagEnd + 1, closeTagStart);
64
+ }
65
+
66
+ /**
67
+ * Default stroke/fill attributes inherited by <symbol> children when a
68
+ * lucide icon is referenced via <use>. These mirror the attributes lucide
69
+ * normally sets on the outer <svg> so the currentColor-based theming keeps
70
+ * working through <use>.
71
+ */
72
+ export const LUCIDE_SYMBOL_ATTRS =
73
+ 'fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"';
74
+
75
+ export const LUCIDE_VIEWBOX = "0 0 24 24";
@@ -33,6 +33,7 @@ const CreateManagedSiteSchema = z.object({
33
33
  siteName: z.string().trim().min(1).max(120),
34
34
  siteLanguage: z.string().trim().max(35).optional(),
35
35
  timeZone: z.string().trim().max(100).optional(),
36
+ idempotencyKey: z.string().trim().min(1).max(128).optional(),
36
37
  });
37
38
 
38
39
  const ManagedSiteDomainSchema = z.object({
@@ -45,4 +45,89 @@ describe("SiteAdminService", () => {
45
45
 
46
46
  expect(siteRows).toEqual([{ key: "demo-cloud" }]);
47
47
  });
48
+
49
+ it("returns the existing site when replayed with the same idempotency key", async () => {
50
+ const { db } = createTestDatabase();
51
+ const service = createSiteAdminService(db, sqliteSchemaBundle, "sqlite", {
52
+ siteResolutionMode: "host-based",
53
+ });
54
+
55
+ const first = await service.createManagedSite({
56
+ key: "idem-site",
57
+ primaryHost: "idem-site.example.com",
58
+ siteName: "Idempotent Site",
59
+ idempotencyKey: "job_abc",
60
+ });
61
+
62
+ const second = await service.createManagedSite({
63
+ key: "idem-site",
64
+ primaryHost: "idem-site.example.com",
65
+ siteName: "Idempotent Site",
66
+ idempotencyKey: "job_abc",
67
+ });
68
+
69
+ expect(second.site.id).toBe(first.site.id);
70
+ expect(second.domain.id).toBe(first.domain.id);
71
+ });
72
+
73
+ it("rejects reuse of an idempotency key with different key or primary host", async () => {
74
+ const { db } = createTestDatabase();
75
+ const service = createSiteAdminService(db, sqliteSchemaBundle, "sqlite", {
76
+ siteResolutionMode: "host-based",
77
+ });
78
+
79
+ await service.createManagedSite({
80
+ key: "idem-site",
81
+ primaryHost: "idem-site.example.com",
82
+ siteName: "Idempotent Site",
83
+ idempotencyKey: "job_xyz",
84
+ });
85
+
86
+ await expect(
87
+ service.createManagedSite({
88
+ key: "other-site",
89
+ primaryHost: "idem-site.example.com",
90
+ siteName: "Other Site",
91
+ idempotencyKey: "job_xyz",
92
+ }),
93
+ ).rejects.toEqual(
94
+ new ConflictError(
95
+ "Idempotency key was reused with a different site key or primary host.",
96
+ ),
97
+ );
98
+
99
+ await expect(
100
+ service.createManagedSite({
101
+ key: "idem-site",
102
+ primaryHost: "different-host.example.com",
103
+ siteName: "Idempotent Site",
104
+ idempotencyKey: "job_xyz",
105
+ }),
106
+ ).rejects.toEqual(
107
+ new ConflictError(
108
+ "Idempotency key was reused with a different site key or primary host.",
109
+ ),
110
+ );
111
+ });
112
+
113
+ it("treats requests without an idempotency key as independent creations", async () => {
114
+ const { db } = createTestDatabase();
115
+ const service = createSiteAdminService(db, sqliteSchemaBundle, "sqlite", {
116
+ siteResolutionMode: "host-based",
117
+ });
118
+
119
+ await service.createManagedSite({
120
+ key: "no-idem-site",
121
+ primaryHost: "no-idem-site.example.com",
122
+ siteName: "No Idem Site",
123
+ });
124
+
125
+ await expect(
126
+ service.createManagedSite({
127
+ key: "no-idem-site",
128
+ primaryHost: "no-idem-site-2.example.com",
129
+ siteName: "No Idem Site",
130
+ }),
131
+ ).rejects.toEqual(new ConflictError("Site key is already in use."));
132
+ });
48
133
  });
@@ -1,4 +1,4 @@
1
- import { eq, sql } from "drizzle-orm";
1
+ import { and, eq, sql } from "drizzle-orm";
2
2
  import {
3
3
  executeStatement,
4
4
  type Database,
@@ -45,6 +45,13 @@ export interface CreateManagedSiteInput {
45
45
  siteName: string;
46
46
  siteLanguage?: string | null;
47
47
  timeZone?: string | null;
48
+ /**
49
+ * Optional caller-supplied idempotency key. When provided, retrying the same
50
+ * request returns the previously created site instead of a 409 conflict.
51
+ * Reusing the key with a different `key` or `primaryHost` is rejected as a
52
+ * client bug.
53
+ */
54
+ idempotencyKey?: string | null;
48
55
  }
49
56
 
50
57
  export interface ManagedSiteResult {
@@ -194,6 +201,47 @@ export function createSiteAdminService(
194
201
  return `${protocol}//${domain.host}${pathPrefix}`;
195
202
  }
196
203
 
204
+ async function loadByIdempotencyKey(
205
+ targetDb: Database,
206
+ idempotencyKey: string,
207
+ ): Promise<ManagedSiteResult | null> {
208
+ const siteRow = (
209
+ await targetDb
210
+ .select()
211
+ .from(sites)
212
+ .where(eq(sites.provisioningIdempotencyKey, idempotencyKey))
213
+ .limit(1)
214
+ )[0];
215
+ if (!siteRow) {
216
+ return null;
217
+ }
218
+
219
+ const domainRow = (
220
+ await targetDb
221
+ .select()
222
+ .from(siteDomains)
223
+ .where(
224
+ and(
225
+ eq(siteDomains.siteId, siteRow.id),
226
+ eq(siteDomains.kind, "primary"),
227
+ ),
228
+ )
229
+ .limit(1)
230
+ )[0];
231
+ if (!domainRow) {
232
+ // A site row without a primary domain means the original creation aborted
233
+ // mid-transaction on a dialect without real transactions. Treat as not
234
+ // found so the caller can retry; the partial unique index will surface a
235
+ // genuine duplicate.
236
+ return null;
237
+ }
238
+
239
+ return {
240
+ site: toSite(siteRow),
241
+ domain: toSiteDomain(domainRow),
242
+ };
243
+ }
244
+
197
245
  async function createWithDatabase(
198
246
  targetDb: Database,
199
247
  input: CreateManagedSiteInput,
@@ -201,6 +249,22 @@ export function createSiteAdminService(
201
249
  const siteKey = input.key.trim();
202
250
  const primaryHost = input.primaryHost.trim().toLowerCase();
203
251
  const siteName = input.siteName.trim();
252
+ const idempotencyKey = input.idempotencyKey?.trim() || null;
253
+
254
+ if (idempotencyKey) {
255
+ const existing = await loadByIdempotencyKey(targetDb, idempotencyKey);
256
+ if (existing) {
257
+ if (
258
+ existing.site.key !== siteKey ||
259
+ existing.domain.host !== primaryHost
260
+ ) {
261
+ throw new ConflictError(
262
+ "Idempotency key was reused with a different site key or primary host.",
263
+ );
264
+ }
265
+ return existing;
266
+ }
267
+ }
204
268
 
205
269
  const existingSite = await targetDb
206
270
  .select({ id: sites.id })
@@ -231,6 +295,7 @@ export function createSiteAdminService(
231
295
  id: siteId,
232
296
  key: siteKey,
233
297
  status: "active",
298
+ provisioningIdempotencyKey: idempotencyKey,
234
299
  createdAt: timestamp,
235
300
  updatedAt: timestamp,
236
301
  })
package/src/styles/ui.css CHANGED
@@ -2100,6 +2100,10 @@
2100
2100
  width: 30px;
2101
2101
  height: 9px;
2102
2102
  margin: 4rem 0;
2103
+ /* Center over the post-body column. On desktop the body column is
2104
+ `var(--layout-content-width)` (55%). Between 760-1024px it switches
2105
+ to `min(100%, 35rem)` via preset.css, so the hr must track that
2106
+ same rule — otherwise the glyph drifts left of the content center. */
2103
2107
  margin-left: calc(var(--layout-content-width) / 2 - 15px);
2104
2108
  color: var(--site-feed-divider-color);
2105
2109
  background-color: currentColor;
@@ -2113,6 +2117,14 @@
2113
2117
  mask-size: contain;
2114
2118
  }
2115
2119
 
2120
+ @media (max-width: 1024px) {
2121
+ .site-content hr.feed-divider {
2122
+ /* Match preset.css `min(100%, 35rem)` body-column width so the hr
2123
+ stays visually aligned with the post column at this breakpoint. */
2124
+ margin-left: calc(min(100%, 35rem) / 2 - 15px);
2125
+ }
2126
+ }
2127
+
2116
2128
  .feed-card {
2117
2129
  position: relative;
2118
2130
  padding: 1rem 1.1rem 0.95rem;
@@ -14,6 +14,7 @@ import { PostStatusBadges } from "./PostStatusBadges.js";
14
14
  import { sanitizeUrl, extractDisplayDomain } from "../../lib/url.js";
15
15
  import { MediaGallery } from "../shared/MediaGallery.js";
16
16
  import { LinkPreview } from "./LinkPreview.js";
17
+ import { Icon } from "../shared/Icon.js";
17
18
 
18
19
  export const LinkCard: FC<TimelineCardProps> = ({
19
20
  post,
@@ -39,30 +40,12 @@ export const LinkCard: FC<TimelineCardProps> = ({
39
40
  target="_blank"
40
41
  rel="noopener noreferrer"
41
42
  >
42
- <svg
43
- class="feed-link-domain-icon"
44
- xmlns="http://www.w3.org/2000/svg"
45
- fill="none"
46
- viewBox="0 0 24 24"
47
- stroke-width="2"
48
- stroke="currentColor"
49
- >
50
- <path d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
51
- </svg>
43
+ <Icon name="link-domain" class="feed-link-domain-icon" />
52
44
  <span>{domain}</span>
53
45
  </a>
54
46
  ) : (
55
47
  <div class="feed-link-domain">
56
- <svg
57
- class="feed-link-domain-icon"
58
- xmlns="http://www.w3.org/2000/svg"
59
- fill="none"
60
- viewBox="0 0 24 24"
61
- stroke-width="2"
62
- stroke="currentColor"
63
- >
64
- <path d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
65
- </svg>
48
+ <Icon name="link-domain" class="feed-link-domain-icon" />
66
49
  <span>{domain}</span>
67
50
  </div>
68
51
  ));
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { FC } from "hono/jsx";
9
+ import { Icon } from "../shared/Icon.js";
9
10
 
10
11
  interface LinkPreviewProps {
11
12
  imageUrl: string;
@@ -41,31 +42,16 @@ export const LinkPreview: FC<LinkPreviewProps> = ({
41
42
  <img src={imageUrl} alt="" class="link-preview-image" loading="lazy" />
42
43
  {isVideo && (
43
44
  <div class="link-preview-play" aria-hidden="true">
44
- <svg
45
- class="link-preview-play-icon"
46
- viewBox="0 0 68 48"
47
- xmlns="http://www.w3.org/2000/svg"
48
- >
49
- <path
50
- class="link-preview-play-bg"
51
- d="M66.52 7.74c-.78-2.93-2.49-5.41-5.42-6.19C55.79.13 34 0 34 0S12.21.13 6.9 1.55C3.97 2.33 2.27 4.81 1.48 7.74.06 13.05 0 24 0 24s.06 10.95 1.48 16.26c.78 2.93 2.49 5.41 5.42 6.19C12.21 47.87 34 48 34 48s21.79-.13 27.1-1.55c2.93-.78 4.64-3.26 5.42-6.19C67.94 34.95 68 24 68 24s-.06-10.95-1.48-16.26z"
52
- fill="rgba(0,0,0,.65)"
53
- />
54
- <path d="M45 24L27 14v20" fill="#fff" />
55
- </svg>
45
+ <Icon name="link-preview-play" class="link-preview-play-icon" />
56
46
  </div>
57
47
  )}
58
48
  {providerLabel && (
59
49
  <span class="link-preview-badge" aria-hidden="true">
60
50
  {isVideo && (
61
- <svg
51
+ <Icon
52
+ name="link-preview-badge-play"
62
53
  class="link-preview-badge-icon"
63
- viewBox="0 0 16 16"
64
- xmlns="http://www.w3.org/2000/svg"
65
- fill="currentColor"
66
- >
67
- <path d="M5.5 3.5v9l7-4.5z" />
68
- </svg>
54
+ />
69
55
  )}
70
56
  {providerLabel}
71
57
  </span>
@@ -9,55 +9,21 @@
9
9
  */
10
10
 
11
11
  import type { FC } from "hono/jsx";
12
+ import { Icon } from "../shared/Icon.js";
12
13
 
13
14
  export const PostStatusBadges: FC = () => {
14
15
  return (
15
16
  <div class="post-status-badges">
16
17
  <span class="post-status-badge post-status-pinned">
17
- <svg
18
- xmlns="http://www.w3.org/2000/svg"
19
- viewBox="0 0 24 24"
20
- fill="none"
21
- stroke="currentColor"
22
- stroke-width="1.75"
23
- stroke-linecap="round"
24
- stroke-linejoin="round"
25
- >
26
- <line x1="12" x2="12" y1="17" y2="22" />
27
- <path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z" />
28
- </svg>
18
+ <Icon name="post-status-pin" />
29
19
  Pinned
30
20
  </span>
31
21
  <span class="post-status-badge post-status-pinned-in-collection">
32
- <svg
33
- xmlns="http://www.w3.org/2000/svg"
34
- viewBox="0 0 24 24"
35
- fill="none"
36
- stroke="currentColor"
37
- stroke-width="1.75"
38
- stroke-linecap="round"
39
- stroke-linejoin="round"
40
- >
41
- <line x1="12" x2="12" y1="17" y2="22" />
42
- <path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z" />
43
- </svg>
22
+ <Icon name="post-status-pin" />
44
23
  Pinned
45
24
  </span>
46
25
  <span class="post-status-badge post-status-private">
47
- <svg
48
- xmlns="http://www.w3.org/2000/svg"
49
- viewBox="0 0 24 24"
50
- fill="none"
51
- stroke="currentColor"
52
- stroke-width="1.75"
53
- stroke-linecap="round"
54
- stroke-linejoin="round"
55
- >
56
- <path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" />
57
- <path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" />
58
- <path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" />
59
- <path d="m2 2 20 20" />
60
- </svg>
26
+ <Icon name="post-status-private" />
61
27
  Private
62
28
  </span>
63
29
  </div>
@@ -32,6 +32,9 @@ import {
32
32
  IS_VITE_DEV,
33
33
  } from "../../lib/version.js";
34
34
  import { I18nProvider } from "../../i18n/index.js";
35
+ import { resetIconCollector } from "../shared/icon-collector.js";
36
+ import { Icon } from "../shared/Icon.js";
37
+ import { IconSprite } from "../shared/IconSprite.js";
35
38
 
36
39
  export interface ToastProps {
37
40
  message: string;
@@ -78,6 +81,11 @@ export const BaseLayout: FC<PropsWithChildren<BaseLayoutProps>> = ({
78
81
  clientBundle,
79
82
  children,
80
83
  }) => {
84
+ // Start a fresh icon collection scope for this request. <Icon> usages in
85
+ // children register names here; <IconSprite> at the end of <body> reads
86
+ // the collected set and emits the <symbol> definitions once.
87
+ resetIconCollector();
88
+
81
89
  // Read lang from Hono context if available, otherwise use prop or default
82
90
  const resolvedLang = lang ?? (c ? c.get("lang") : "en");
83
91
 
@@ -343,47 +351,24 @@ export const BaseLayout: FC<PropsWithChildren<BaseLayoutProps>> = ({
343
351
  data-init="el.closest('[popover]').showPopover(); history.replaceState({}, '', location.pathname); setTimeout(() => { el.classList.add('toast-out'); el.addEventListener('animationend', () => el.remove()) }, 3000)"
344
352
  >
345
353
  {toast.type === "error" ? (
346
- <svg
347
- xmlns="http://www.w3.org/2000/svg"
348
- fill="none"
349
- viewBox="0 0 24 24"
350
- stroke-width="2"
351
- stroke="currentColor"
352
- >
353
- <circle cx="12" cy="12" r="10" />
354
- <path d="m15 9-6 6M9 9l6 6" />
355
- </svg>
354
+ <Icon name="toast-error" />
356
355
  ) : (
357
- <svg
358
- xmlns="http://www.w3.org/2000/svg"
359
- fill="none"
360
- viewBox="0 0 24 24"
361
- stroke-width="2"
362
- stroke="currentColor"
363
- >
364
- <circle cx="12" cy="12" r="10" />
365
- <path d="m9 12 2 2 4-4" />
366
- </svg>
356
+ <Icon name="toast-success" />
367
357
  )}
368
358
  <span>{toast.message}</span>
369
359
  <button
370
360
  class="toast-close"
371
361
  data-on:click="el.closest('.toast').classList.add('toast-out'); el.closest('.toast').addEventListener('animationend', () => el.closest('.toast').remove())"
372
362
  >
373
- <svg
374
- xmlns="http://www.w3.org/2000/svg"
375
- fill="none"
376
- viewBox="0 0 24 24"
377
- stroke-width="2"
378
- stroke="currentColor"
379
- >
380
- <path d="M18 6 6 18M6 6l12 12" />
381
- </svg>
363
+ <Icon name="toast-close" />
382
364
  </button>
383
365
  </div>
384
366
  )}
385
367
  </div>
386
368
  {customBodyEndHtml && raw(customBodyEndHtml)}
369
+ {/* Icon sprite: must come after all <Icon> usages so the
370
+ request-scoped collector has seen every name. */}
371
+ <IconSprite />
387
372
  </body>
388
373
  </html>
389
374
  </>
@@ -1,8 +1,5 @@
1
1
  import type { FC } from "hono/jsx";
2
- import {
3
- DECORATIVE_QUOTE_MARK_PATHS,
4
- DECORATIVE_QUOTE_MARK_VIEWBOX,
5
- } from "../../lib/decorative-quote-mark.js";
2
+ import { Icon } from "./Icon.js";
6
3
 
7
4
  interface DecorativeQuoteMarkProps {
8
5
  class?: string;
@@ -22,14 +19,6 @@ export const DecorativeQuoteMark: FC<DecorativeQuoteMarkProps> = ({
22
19
  data-direction={direction}
23
20
  aria-hidden="true"
24
21
  >
25
- <svg
26
- viewBox={DECORATIVE_QUOTE_MARK_VIEWBOX}
27
- role="presentation"
28
- focusable="false"
29
- >
30
- {DECORATIVE_QUOTE_MARK_PATHS.map((path) => (
31
- <path fill="currentColor" d={path} />
32
- ))}
33
- </svg>
22
+ <Icon name="decorative-quote" />
34
23
  </span>
35
24
  );
@@ -0,0 +1,60 @@
1
+ /**
2
+ * <Icon> — sprite-based SVG icon for SSR pages.
3
+ *
4
+ * Renders a lightweight <svg><use href="#icon-${name}"/></svg> stub and
5
+ * registers the icon name with the request-scoped collector so the final
6
+ * sprite (rendered by <IconSprite>) contains exactly the icons used on
7
+ * this page.
8
+ *
9
+ * Name can refer to any lucide-static icon (kebab-case) or one of the
10
+ * custom symbols defined in `custom-icons.ts`. Unknown names render an
11
+ * empty <svg> — the same failure mode as the previous getIconSvg() path.
12
+ *
13
+ * Size: outer <svg> width/height in pixels. Defaults to 24 (lucide default).
14
+ * Pass `class` to add CSS classes, e.g. for sizing via stylesheet instead
15
+ * of inline width/height.
16
+ */
17
+
18
+ import type { FC } from "hono/jsx";
19
+ import { collectIcon } from "./icon-collector.js";
20
+ import { getIconViewBox } from "./custom-icons.js";
21
+
22
+ export interface IconProps {
23
+ /** Kebab-case icon name (lucide or custom). */
24
+ name: string;
25
+ /** Width/height in px. Omit to size via CSS. */
26
+ size?: number;
27
+ /** CSS class for the outer <svg>. */
28
+ class?: string;
29
+ /** Accessible label. If set, the icon is not aria-hidden. */
30
+ "aria-label"?: string;
31
+ /**
32
+ * Whether this icon is decorative. Defaults to true (aria-hidden) when
33
+ * no aria-label is provided.
34
+ */
35
+ "aria-hidden"?: boolean | "true" | "false";
36
+ }
37
+
38
+ export const Icon: FC<IconProps> = ({
39
+ name,
40
+ size,
41
+ class: cls,
42
+ "aria-label": ariaLabel,
43
+ "aria-hidden": ariaHidden,
44
+ }) => {
45
+ collectIcon(name);
46
+
47
+ const hidden = ariaHidden ?? (ariaLabel ? undefined : true);
48
+
49
+ return (
50
+ <svg
51
+ viewBox={getIconViewBox(name)}
52
+ {...(size !== undefined ? { width: size, height: size } : {})}
53
+ {...(cls ? { class: cls } : {})}
54
+ {...(ariaLabel ? { "aria-label": ariaLabel, role: "img" } : {})}
55
+ {...(hidden ? { "aria-hidden": "true" } : {})}
56
+ >
57
+ <use href={`#icon-${name}`} />
58
+ </svg>
59
+ );
60
+ };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * <IconSprite> — emits the SVG symbol definitions used by this render.
3
+ *
4
+ * Must be rendered AFTER all <Icon> usages in the document (e.g. at the
5
+ * end of <body>) so the collector has the full set of icon names. Hono
6
+ * JSX stringifies synchronously in document order, so children declared
7
+ * earlier in the tree are evaluated before this component.
8
+ *
9
+ * <use href="#icon-x"> anywhere in the document resolves correctly even
10
+ * when the <symbol> definition comes after the reference, since browsers
11
+ * wire up the references after the full document is parsed.
12
+ */
13
+
14
+ import type { FC } from "hono/jsx";
15
+ import { raw } from "hono/html";
16
+ import {
17
+ getIconInnerSvg,
18
+ LUCIDE_SYMBOL_ATTRS,
19
+ LUCIDE_VIEWBOX,
20
+ } from "../../lib/icons.js";
21
+ import { getCollectedIcons } from "./icon-collector.js";
22
+ import { getCustomSymbol } from "./custom-icons.js";
23
+
24
+ function buildSymbol(name: string): string | null {
25
+ const custom = getCustomSymbol(name);
26
+ if (custom) {
27
+ return `<symbol id="icon-${name}" viewBox="${custom.viewBox}">${custom.inner}</symbol>`;
28
+ }
29
+ const inner = getIconInnerSvg(name);
30
+ if (inner === null) return null;
31
+ return `<symbol id="icon-${name}" viewBox="${LUCIDE_VIEWBOX}" ${LUCIDE_SYMBOL_ATTRS}>${inner}</symbol>`;
32
+ }
33
+
34
+ export const IconSprite: FC = () => {
35
+ const names = Array.from(getCollectedIcons()).sort();
36
+ const symbols = names
37
+ .map(buildSymbol)
38
+ .filter((s): s is string => s !== null)
39
+ .join("");
40
+
41
+ if (!symbols) return null;
42
+
43
+ // Hidden from layout and AT; provides the <symbol> definitions only.
44
+ // Use raw() as a child instead of dangerouslySetInnerHTML because Hono
45
+ // wraps <svg> with an internal nameSpaceContext child, which makes
46
+ // dangerouslySetInnerHTML conflict with children and throw.
47
+ return (
48
+ <svg
49
+ xmlns="http://www.w3.org/2000/svg"
50
+ style="display:none"
51
+ aria-hidden="true"
52
+ data-icon-sprite
53
+ >
54
+ {raw(symbols)}
55
+ </svg>
56
+ );
57
+ };