@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.
- package/dist/{app-Ctl0T0zO.js → app-BI9bnCkO.js} +1 -1
- package/dist/{app-GbfwoeDJ.js → app-CtJDxZBb.js} +341 -252
- package/dist/client/.vite/manifest.json +1 -1
- package/dist/client/_assets/client-BQH7AQ24.css +2 -0
- package/dist/index.js +1 -1
- package/dist/node.js +2 -2
- package/package.json +1 -1
- package/src/db/migrations/0019_bored_magus.sql +2 -0
- package/src/db/migrations/meta/0019_snapshot.json +2238 -0
- package/src/db/migrations/meta/_journal.json +7 -0
- package/src/db/migrations/pg/0017_bright_beyonder.sql +2 -0
- package/src/db/migrations/pg/meta/0017_snapshot.json +2862 -0
- package/src/db/migrations/pg/meta/_journal.json +7 -0
- package/src/db/pg/schema.ts +4 -0
- package/src/db/schema.ts +4 -0
- package/src/lib/icons.ts +37 -0
- package/src/routes/api/internal/sites.ts +1 -0
- package/src/services/__tests__/site-admin.test.ts +85 -0
- package/src/services/site-admin.ts +66 -1
- package/src/styles/ui.css +12 -0
- package/src/ui/feed/LinkCard.tsx +3 -20
- package/src/ui/feed/LinkPreview.tsx +5 -19
- package/src/ui/feed/PostStatusBadges.tsx +4 -38
- package/src/ui/layouts/BaseLayout.tsx +14 -29
- package/src/ui/shared/DecorativeQuoteMark.tsx +2 -13
- package/src/ui/shared/Icon.tsx +60 -0
- package/src/ui/shared/IconSprite.tsx +57 -0
- package/src/ui/shared/PostFooter.tsx +6 -62
- package/src/ui/shared/custom-icons.ts +132 -0
- package/src/ui/shared/icon-collector.ts +37 -0
- package/dist/client/_assets/client-C_kImWZj.css +0 -2
package/src/db/pg/schema.ts
CHANGED
|
@@ -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;
|
package/src/ui/feed/LinkCard.tsx
CHANGED
|
@@ -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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
51
|
+
<Icon
|
|
52
|
+
name="link-preview-badge-play"
|
|
62
53
|
class="link-preview-badge-icon"
|
|
63
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
+
};
|