@jant/core 0.3.32 → 0.3.34

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.
Files changed (97) hide show
  1. package/dist/client/client.css +1 -1
  2. package/dist/client/client.js +1442 -989
  3. package/dist/index.js +1431 -1057
  4. package/package.json +1 -1
  5. package/src/__tests__/helpers/app.ts +6 -3
  6. package/src/__tests__/helpers/db.ts +3 -0
  7. package/src/app.tsx +1 -1
  8. package/src/client.ts +2 -1
  9. package/src/db/migrations/0011_add_path_registry.sql +23 -0
  10. package/src/db/schema.ts +12 -1
  11. package/src/i18n/locales/en.po +225 -91
  12. package/src/i18n/locales/en.ts +1 -1
  13. package/src/i18n/locales/zh-Hans.po +201 -152
  14. package/src/i18n/locales/zh-Hans.ts +1 -1
  15. package/src/i18n/locales/zh-Hant.po +201 -152
  16. package/src/i18n/locales/zh-Hant.ts +1 -1
  17. package/src/lib/__tests__/excerpt.test.ts +25 -0
  18. package/src/lib/__tests__/resolve-config.test.ts +26 -2
  19. package/src/lib/__tests__/timeline.test.ts +2 -1
  20. package/src/lib/compose-bridge.ts +30 -1
  21. package/src/lib/excerpt.ts +16 -7
  22. package/src/lib/nav-manager-bridge.ts +54 -0
  23. package/src/lib/navigation.ts +7 -4
  24. package/src/lib/render.tsx +5 -2
  25. package/src/lib/resolve-config.ts +7 -0
  26. package/src/lib/view.ts +42 -10
  27. package/src/middleware/error-handler.ts +16 -0
  28. package/src/routes/api/__tests__/posts.test.ts +80 -0
  29. package/src/routes/api/__tests__/settings.test.ts +1 -1
  30. package/src/routes/api/posts.ts +6 -29
  31. package/src/routes/api/upload.ts +2 -14
  32. package/src/routes/auth/__tests__/setup.test.ts +3 -2
  33. package/src/routes/auth/setup.tsx +1 -1
  34. package/src/routes/compose.tsx +13 -5
  35. package/src/routes/dash/__tests__/pages.test.ts +2 -1
  36. package/src/routes/dash/__tests__/settings-avatar.test.ts +151 -33
  37. package/src/routes/dash/appearance.tsx +71 -4
  38. package/src/routes/dash/collections.tsx +15 -21
  39. package/src/routes/dash/media.tsx +1 -13
  40. package/src/routes/dash/pages.tsx +5 -150
  41. package/src/routes/dash/posts.tsx +25 -32
  42. package/src/routes/dash/redirects.tsx +9 -11
  43. package/src/routes/dash/settings.tsx +29 -111
  44. package/src/routes/feed/__tests__/rss.test.ts +5 -1
  45. package/src/routes/pages/__tests__/collections.test.ts +2 -1
  46. package/src/routes/pages/__tests__/featured.test.ts +2 -1
  47. package/src/routes/pages/page.tsx +20 -25
  48. package/src/services/__tests__/collection.test.ts +2 -1
  49. package/src/services/__tests__/media.test.ts +78 -1
  50. package/src/services/__tests__/navigation.test.ts +2 -1
  51. package/src/services/__tests__/page.test.ts +78 -1
  52. package/src/services/__tests__/path-registry.test.ts +165 -0
  53. package/src/services/__tests__/post-timeline.test.ts +2 -1
  54. package/src/services/__tests__/post.test.ts +103 -1
  55. package/src/services/__tests__/redirect.test.ts +53 -4
  56. package/src/services/__tests__/search.test.ts +2 -1
  57. package/src/services/__tests__/settings.test.ts +153 -0
  58. package/src/services/index.ts +12 -4
  59. package/src/services/media.ts +72 -4
  60. package/src/services/page.ts +64 -17
  61. package/src/services/path-registry.ts +160 -0
  62. package/src/services/post.ts +119 -24
  63. package/src/services/redirect.ts +23 -3
  64. package/src/services/settings.ts +181 -0
  65. package/src/styles/components.css +135 -0
  66. package/src/styles/tokens.css +6 -1
  67. package/src/styles/ui.css +70 -26
  68. package/src/types/bindings.ts +1 -0
  69. package/src/types/config.ts +7 -2
  70. package/src/types/constants.ts +9 -1
  71. package/src/types/sortablejs.d.ts +8 -2
  72. package/src/types/views.ts +1 -1
  73. package/src/ui/color-themes.ts +31 -31
  74. package/src/ui/components/__tests__/jant-settings-avatar.test.ts +0 -3
  75. package/src/ui/components/__tests__/jant-settings-general.test.ts +2 -6
  76. package/src/ui/components/jant-compose-dialog.ts +3 -2
  77. package/src/ui/components/jant-compose-editor.ts +17 -2
  78. package/src/ui/components/jant-nav-manager.ts +1067 -0
  79. package/src/ui/components/jant-settings-general.ts +2 -35
  80. package/src/ui/components/nav-manager-types.ts +72 -0
  81. package/src/ui/components/settings-types.ts +0 -3
  82. package/src/ui/compose/ComposePrompt.tsx +3 -11
  83. package/src/ui/dash/appearance/AdvancedContent.tsx +0 -3
  84. package/src/ui/dash/appearance/AppearanceNav.tsx +12 -8
  85. package/src/ui/dash/appearance/ColorThemeContent.tsx +1 -4
  86. package/src/ui/dash/appearance/FontThemeContent.tsx +0 -3
  87. package/src/ui/dash/appearance/NavigationContent.tsx +302 -0
  88. package/src/ui/dash/pages/PagesContent.tsx +74 -0
  89. package/src/ui/dash/settings/AccountContent.tsx +0 -3
  90. package/src/ui/dash/settings/GeneralContent.tsx +1 -19
  91. package/src/ui/dash/settings/SettingsNav.tsx +2 -6
  92. package/src/ui/feed/NoteCard.tsx +2 -2
  93. package/src/ui/layouts/DashLayout.tsx +83 -86
  94. package/src/ui/layouts/SiteLayout.tsx +82 -21
  95. package/src/lib/nav-reorder.ts +0 -26
  96. package/src/ui/dash/pages/LinkFormContent.tsx +0 -119
  97. package/src/ui/dash/pages/UnifiedPagesContent.tsx +0 -203
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Dashboard Pages & Navigation Routes
2
+ * Dashboard Pages Routes
3
3
  *
4
- * Unified management for pages and navigation items.
4
+ * Page CRUD management.
5
5
  */
6
6
 
7
7
  import { Hono } from "hono";
@@ -13,8 +13,7 @@ import { DashLayout } from "../../ui/layouts/DashLayout.js";
13
13
  import { PageForm, ActionButtons, DangerZone } from "../../ui/dash/index.js";
14
14
  import { dsRedirect, dsToast } from "../../lib/sse.js";
15
15
  import { CreatePageSchema } from "../../lib/schemas.js";
16
- import { UnifiedPagesContent } from "../../ui/dash/pages/UnifiedPagesContent.js";
17
- import { LinkFormContent } from "../../ui/dash/pages/LinkFormContent.js";
16
+ import { PagesContent } from "../../ui/dash/pages/PagesContent.js";
18
17
  import { getI18n } from "../../i18n/index.js";
19
18
 
20
19
  type Env = { Bindings: Bindings; Variables: AppVariables };
@@ -107,10 +106,7 @@ function EditPageContent({ page }: { page: Page }) {
107
106
  // =============================================================================
108
107
 
109
108
  pagesRoutes.get("/", async (c) => {
110
- const [navItems, otherPages] = await Promise.all([
111
- c.var.services.navItems.list(),
112
- c.var.services.pages.listNotInNav(),
113
- ]);
109
+ const pages = await c.var.services.pages.list();
114
110
  const siteName = c.var.appConfig.siteName;
115
111
 
116
112
  return c.html(
@@ -120,7 +116,7 @@ pagesRoutes.get("/", async (c) => {
120
116
  siteName={siteName}
121
117
  currentPath="/dash/pages"
122
118
  >
123
- <UnifiedPagesContent navItems={navItems} otherPages={otherPages} />
119
+ <PagesContent pages={pages} />
124
120
  </DashLayout>,
125
121
  );
126
122
  });
@@ -139,123 +135,6 @@ pagesRoutes.get("/new", async (c) => {
139
135
  );
140
136
  });
141
137
 
142
- pagesRoutes.get("/links/new", async (c) => {
143
- const siteName = c.var.appConfig.siteName;
144
- return c.html(
145
- <DashLayout
146
- c={c}
147
- title="New Link"
148
- siteName={siteName}
149
- currentPath="/dash/pages"
150
- >
151
- <LinkFormContent />
152
- </DashLayout>,
153
- );
154
- });
155
-
156
- pagesRoutes.post("/links", async (c) => {
157
- const i18n = getI18n(c);
158
- const body = await c.req.json<{ label: string; url: string }>();
159
- if (!body.label || !body.url) {
160
- return dsToast(
161
- i18n._(
162
- msg({
163
- message: "Label and URL are required",
164
- comment: "@context: Error toast when nav link fields are empty",
165
- }),
166
- ),
167
- "error",
168
- );
169
- }
170
-
171
- await c.var.services.navItems.create({
172
- type: "link",
173
- label: body.label,
174
- url: body.url,
175
- });
176
- return dsRedirect("/dash/pages");
177
- });
178
-
179
- pagesRoutes.post("/reorder", async (c) => {
180
- const i18n = getI18n(c);
181
- const body = await c.req.json<{ ids: number[] }>();
182
- if (!Array.isArray(body.ids)) {
183
- return dsToast(
184
- i18n._(
185
- msg({
186
- message: "Invalid request",
187
- comment: "@context: Error toast when reorder request is malformed",
188
- }),
189
- ),
190
- "error",
191
- );
192
- }
193
- await c.var.services.navItems.reorder(body.ids);
194
- return dsToast(
195
- i18n._(
196
- msg({
197
- message: "Order saved",
198
- comment: "@context: Toast after saving navigation item order",
199
- }),
200
- ),
201
- );
202
- });
203
-
204
- pagesRoutes.get("/links/:id/edit", async (c) => {
205
- const id = parseInt(c.req.param("id"), 10);
206
- if (isNaN(id)) return c.notFound();
207
-
208
- const item = await c.var.services.navItems.getById(id);
209
- if (!item) return c.notFound();
210
-
211
- const siteName = c.var.appConfig.siteName;
212
- return c.html(
213
- <DashLayout
214
- c={c}
215
- title="Edit Link"
216
- siteName={siteName}
217
- currentPath="/dash/pages"
218
- >
219
- <LinkFormContent item={item} isEdit />
220
- </DashLayout>,
221
- );
222
- });
223
-
224
- pagesRoutes.post("/links/:id", async (c) => {
225
- const i18n = getI18n(c);
226
- const id = parseInt(c.req.param("id"), 10);
227
- if (isNaN(id)) return c.notFound();
228
-
229
- const body = await c.req.json<{ label: string; url: string }>();
230
- if (!body.label || !body.url) {
231
- return dsToast(
232
- i18n._(
233
- msg({
234
- message: "Label and URL are required",
235
- comment: "@context: Error toast when nav link fields are empty",
236
- }),
237
- ),
238
- "error",
239
- );
240
- }
241
-
242
- const updated = await c.var.services.navItems.update(id, {
243
- label: body.label,
244
- url: body.url,
245
- });
246
- if (!updated) return c.notFound();
247
-
248
- return dsRedirect("/dash/pages");
249
- });
250
-
251
- pagesRoutes.post("/links/:id/delete", async (c) => {
252
- const id = parseInt(c.req.param("id"), 10);
253
- if (!isNaN(id)) {
254
- await c.var.services.navItems.delete(id);
255
- }
256
- return dsRedirect("/dash/pages");
257
- });
258
-
259
138
  pagesRoutes.post("/", async (c) => {
260
139
  const i18n = getI18n(c);
261
140
  const raw = await c.req.json();
@@ -282,30 +161,6 @@ pagesRoutes.post("/", async (c) => {
282
161
  return dsRedirect(`/dash/pages/${page.id}`);
283
162
  });
284
163
 
285
- pagesRoutes.post("/:id/add-to-nav", async (c) => {
286
- const id = parseInt(c.req.param("id"), 10);
287
- if (isNaN(id)) return c.notFound();
288
-
289
- const page = await c.var.services.pages.getById(id);
290
- if (!page) return c.notFound();
291
-
292
- await c.var.services.navItems.create({
293
- type: "page",
294
- label: page.title || page.slug,
295
- url: `/${page.slug}`,
296
- pageId: page.id,
297
- });
298
- return dsRedirect("/dash/pages");
299
- });
300
-
301
- pagesRoutes.post("/:id/remove-from-nav", async (c) => {
302
- const pageId = parseInt(c.req.param("id"), 10);
303
- if (isNaN(pageId)) return c.notFound();
304
-
305
- await c.var.services.navItems.deleteByPageId(pageId);
306
- return dsRedirect("/dash/pages");
307
- });
308
-
309
164
  pagesRoutes.get("/:id", async (c) => {
310
165
  const id = parseInt(c.req.param("id"), 10);
311
166
  if (isNaN(id)) return c.notFound();
@@ -21,6 +21,11 @@ import {
21
21
  } from "../../ui/dash/index.js";
22
22
  import * as sqid from "../../lib/sqid.js";
23
23
  import { dsRedirect } from "../../lib/sse.js";
24
+ import {
25
+ CreatePostSchema,
26
+ UpdatePostSchema,
27
+ parseValidated,
28
+ } from "../../lib/schemas.js";
24
29
  import {
25
30
  toPostViewsFromPosts,
26
31
  toPostViewFromPost,
@@ -103,25 +108,18 @@ postsRoutes.get("/new", async (c) => {
103
108
  // Create post
104
109
  postsRoutes.post("/", async (c) => {
105
110
  const wantsJson = c.req.header("Accept")?.includes("application/json");
106
- const body = await c.req.json<{
107
- format: string;
108
- title?: string;
109
- body: string;
110
- status: string;
111
- featured?: boolean;
112
- pinned?: boolean;
113
- url?: string;
114
- quoteText?: string;
115
- rating?: number;
116
- collectionIds?: number[];
117
- mediaIds?: string[];
118
- }>();
111
+ const body = parseValidated(CreatePostSchema, await c.req.json());
112
+
113
+ // Validate media IDs before creating post
114
+ if (body.mediaIds && body.mediaIds.length > 0) {
115
+ await c.var.services.media.validateIds(body.mediaIds);
116
+ }
119
117
 
120
118
  const post = await c.var.services.posts.create({
121
- format: body.format as Post["format"],
119
+ format: body.format,
122
120
  title: body.title || undefined,
123
121
  body: body.body,
124
- status: body.status as Post["status"],
122
+ status: body.status,
125
123
  featured: body.featured,
126
124
  pinned: body.pinned,
127
125
  url: body.url || undefined,
@@ -289,25 +287,18 @@ postsRoutes.post("/:id", async (c) => {
289
287
 
290
288
  const wantsJson = c.req.header("Accept")?.includes("application/json");
291
289
 
292
- const body = await c.req.json<{
293
- format: string;
294
- title?: string;
295
- body?: string;
296
- status: string;
297
- featured?: boolean;
298
- pinned?: boolean;
299
- url?: string;
300
- quoteText?: string;
301
- rating?: number;
302
- collectionIds?: number[];
303
- mediaIds?: string[];
304
- }>();
290
+ const body = parseValidated(UpdatePostSchema, await c.req.json());
291
+
292
+ // Validate media IDs if provided
293
+ if (body.mediaIds !== undefined) {
294
+ await c.var.services.media.validateIds(body.mediaIds);
295
+ }
305
296
 
306
297
  await c.var.services.posts.update(id, {
307
- format: body.format as Post["format"],
298
+ format: body.format,
308
299
  title: body.title || null,
309
300
  body: body.body || null,
310
- status: body.status as Post["status"],
301
+ status: body.status,
311
302
  featured: body.featured,
312
303
  pinned: body.pinned,
313
304
  url: body.url || null,
@@ -334,8 +325,10 @@ postsRoutes.post("/:id/delete", async (c) => {
334
325
  const id = sqid.decode(c.req.param("id"));
335
326
  if (!id) return c.notFound();
336
327
 
337
- await c.var.services.media.detachFromPost(id);
338
- await c.var.services.posts.delete(id);
328
+ await c.var.services.posts.delete(id, {
329
+ media: c.var.services.media,
330
+ storage: c.var.storage,
331
+ });
339
332
 
340
333
  return dsRedirect("/dash/posts");
341
334
  });
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { Hono } from "hono";
8
+ import { z } from "zod";
8
9
  import { useLingui } from "@lingui/react/macro";
9
10
  import type { Bindings, Redirect } from "../../types.js";
10
11
  import type { AppVariables } from "../../types/app-context.js";
@@ -12,9 +13,16 @@ import { DashLayout } from "../../ui/layouts/DashLayout.js";
12
13
  import { EmptyState, ListItemRow, ActionButtons } from "../../ui/dash/index.js";
13
14
  import { SettingsNav } from "../../ui/dash/settings/SettingsNav.js";
14
15
  import { dsRedirect } from "../../lib/sse.js";
16
+ import { RedirectTypeSchema, parseValidated } from "../../lib/schemas.js";
15
17
 
16
18
  type Env = { Bindings: Bindings; Variables: AppVariables };
17
19
 
20
+ const CreateRedirectBody = z.object({
21
+ fromPath: z.string().min(1),
22
+ toPath: z.string().min(1),
23
+ type: RedirectTypeSchema,
24
+ });
25
+
18
26
  export const redirectsRoutes = new Hono<Env>();
19
27
 
20
28
  function RedirectsListContent({ redirects }: { redirects: Redirect[] }) {
@@ -22,9 +30,6 @@ function RedirectsListContent({ redirects }: { redirects: Redirect[] }) {
22
30
 
23
31
  return (
24
32
  <>
25
- <h1 class="text-2xl font-semibold mb-2">
26
- {t({ message: "Settings", comment: "@context: Dashboard heading" })}
27
- </h1>
28
33
  <SettingsNav currentTab="redirects" />
29
34
 
30
35
  <div class="flex items-center justify-between mb-6">
@@ -88,9 +93,6 @@ function NewRedirectContent() {
88
93
 
89
94
  return (
90
95
  <>
91
- <h1 class="text-2xl font-semibold mb-2">
92
- {t({ message: "Settings", comment: "@context: Dashboard heading" })}
93
- </h1>
94
96
  <SettingsNav currentTab="redirects" />
95
97
 
96
98
  <h2 class="text-lg font-medium mb-6">
@@ -236,11 +238,7 @@ redirectsRoutes.get("/new", async (c) => {
236
238
 
237
239
  // Create redirect
238
240
  redirectsRoutes.post("/", async (c) => {
239
- const body = await c.req.json<{
240
- fromPath: string;
241
- toPath: string;
242
- type: string;
243
- }>();
241
+ const body = parseValidated(CreateRedirectBody, await c.req.json());
244
242
 
245
243
  const type = parseInt(body.type, 10) as 301 | 302;
246
244
  await c.var.services.redirects.create(body.fromPath, body.toPath, type);
@@ -11,10 +11,9 @@ import type { AppVariables } from "../../types/app-context.js";
11
11
  import { DashLayout } from "../../ui/layouts/DashLayout.js";
12
12
  import { sse, dsRedirect, dsToast } from "../../lib/sse.js";
13
13
  import { getI18n } from "../../i18n/index.js";
14
- import { arrayBufferToBase64 } from "../../lib/favicon.js";
15
14
  import { TIMEZONES } from "../../lib/timezones.js";
16
15
  import { escapeHtml } from "../../lib/html.js";
17
- import { validateUploadFile, generateStorageKey } from "../../lib/upload.js";
16
+ import { ValidationError } from "../../lib/errors.js";
18
17
  import { GeneralContent } from "../../ui/dash/settings/GeneralContent.js";
19
18
  import { AccountContent } from "../../ui/dash/settings/AccountContent.js";
20
19
 
@@ -46,7 +45,6 @@ settingsRoutes.get("/", async (c) => {
46
45
  siteName={dbSiteName || ""}
47
46
  siteDescription={dbSiteDescription || ""}
48
47
  siteLanguage={appConfig.siteLanguage}
49
- homeDefaultView={appConfig.homeDefaultView}
50
48
  siteNameFallback={appConfig.fallbacks.siteName}
51
49
  siteDescriptionFallback={appConfig.fallbacks.siteDescription}
52
50
  siteAvatarUrl={appConfig.siteAvatarUrl}
@@ -67,52 +65,16 @@ settingsRoutes.post("/", async (c) => {
67
65
  siteDescription: string;
68
66
  siteFooter: string;
69
67
  siteLanguage: string;
70
- homeDefaultView: string;
68
+ homeDefaultView?: string;
69
+ headerNavMaxVisible?: string;
71
70
  timeZone: string;
72
71
  }>();
73
72
 
74
- const { settings } = c.var.services;
75
-
76
- const oldLanguage = c.var.allSettings["SITE_LANGUAGE"] ?? "en";
77
-
78
- if (body.siteName.trim()) {
79
- await settings.set("SITE_NAME", body.siteName.trim());
80
- } else {
81
- await settings.remove("SITE_NAME");
82
- }
83
-
84
- if (body.siteDescription.trim()) {
85
- await settings.set("SITE_DESCRIPTION", body.siteDescription.trim());
86
- } else {
87
- await settings.remove("SITE_DESCRIPTION");
88
- }
89
-
90
- // Footer
91
- if (body.siteFooter?.trim()) {
92
- await settings.set("SITE_FOOTER", body.siteFooter.trim());
93
- } else {
94
- await settings.remove("SITE_FOOTER");
95
- }
96
-
97
- await settings.set("SITE_LANGUAGE", body.siteLanguage);
98
-
99
- // Save homepage default view (only store if non-default)
100
- if (body.homeDefaultView === "featured") {
101
- await settings.set("HOME_DEFAULT_VIEW", body.homeDefaultView);
102
- } else {
103
- await settings.remove("HOME_DEFAULT_VIEW");
104
- }
105
-
106
- // Timezone
107
- if (body.timeZone && body.timeZone !== "UTC") {
108
- await settings.set("TIME_ZONE", body.timeZone);
109
- } else {
110
- await settings.remove("TIME_ZONE");
111
- }
112
-
113
- const languageChanged = oldLanguage !== body.siteLanguage;
114
- const displayName =
115
- body.siteName.trim() || c.var.appConfig.fallbacks.siteName;
73
+ const { languageChanged, displayName } =
74
+ await c.var.services.settings.updateGeneral(body, {
75
+ oldLanguage: c.var.allSettings["SITE_LANGUAGE"] ?? "en",
76
+ fallbackSiteName: c.var.appConfig.fallbacks.siteName,
77
+ });
116
78
 
117
79
  // ── JSON response mode (used by Lit settings bridge) ──────────────
118
80
  const wantsJson = c.req.header("accept")?.includes("application/json");
@@ -160,7 +122,6 @@ settingsRoutes.post("/", async (c) => {
160
122
  _orig_siteDescription: body.siteDescription,
161
123
  _orig_siteFooter: body.siteFooter,
162
124
  _orig_siteLanguage: body.siteLanguage,
163
- _orig_homeDefaultView: body.homeDefaultView,
164
125
  _orig_timeZone: body.timeZone,
165
126
  _generalDirty: false,
166
127
  });
@@ -245,64 +206,30 @@ settingsRoutes.post("/avatar", async (c) => {
245
206
  );
246
207
  }
247
208
 
248
- const uploadError = validateUploadFile(file);
249
- if (uploadError) {
250
- return dsToast(uploadError, "error");
251
- }
252
-
253
- const { id, filename, storageKey } = generateStorageKey(file.name);
209
+ const faviconFile = formData.get("favicon") as File | null;
210
+ const appleTouchFile = formData.get("appleTouch") as File | null;
254
211
 
255
212
  try {
256
- await storage.put(storageKey, file.stream(), {
257
- contentType: file.type,
258
- });
259
-
260
- await c.var.services.media.create({
261
- id,
262
- filename,
263
- originalName: file.name,
264
- mimeType: file.type,
265
- size: file.size,
266
- storageKey,
267
- provider: c.var.appConfig.storageDriver,
268
- });
269
-
270
- await c.var.services.settings.set("SITE_AVATAR", storageKey);
271
-
272
- // Store favicon ICO as base64 in settings (tiny file, accessed every page load)
273
- const faviconFile = formData.get("favicon") as File | null;
274
- if (faviconFile) {
275
- const b64 = arrayBufferToBase64(await faviconFile.arrayBuffer());
276
- await c.var.services.settings.set("SITE_FAVICON_ICO", b64);
277
- }
278
-
279
- // Store apple-touch-icon in R2 (180x180 PNG, not tiny enough for base64)
280
- const appleTouchFile = formData.get("appleTouch") as File | null;
281
- if (appleTouchFile) {
282
- const appleTouchKey = "favicon/apple-touch-icon.png";
283
- await storage.put(
284
- appleTouchKey,
285
- new Uint8Array(await appleTouchFile.arrayBuffer()),
286
- { contentType: "image/png" },
287
- );
288
- await c.var.services.settings.set(
289
- "SITE_FAVICON_APPLE_TOUCH",
290
- appleTouchKey,
291
- );
292
- }
293
-
294
- // Set favicon version for cache-busting
295
- const now = new Date();
296
- const version =
297
- String(now.getUTCFullYear()) +
298
- String(now.getUTCMonth() + 1).padStart(2, "0") +
299
- String(now.getUTCDate()).padStart(2, "0") +
300
- String(now.getUTCHours()).padStart(2, "0") +
301
- String(now.getUTCMinutes()).padStart(2, "0");
302
- await c.var.services.settings.set("SITE_FAVICON_VERSION", version);
213
+ await c.var.services.settings.uploadAvatar(
214
+ {
215
+ file,
216
+ faviconIco: faviconFile ? await faviconFile.arrayBuffer() : undefined,
217
+ appleTouchIcon: appleTouchFile
218
+ ? await appleTouchFile.arrayBuffer()
219
+ : undefined,
220
+ },
221
+ {
222
+ media: c.var.services.media,
223
+ storage,
224
+ storageProvider: c.var.appConfig.storageDriver,
225
+ },
226
+ );
303
227
 
304
228
  return dsRedirect("/dash/settings?saved");
305
- } catch {
229
+ } catch (e) {
230
+ if (e instanceof ValidationError) {
231
+ return dsToast(e.message, "error");
232
+ }
306
233
  return dsToast(
307
234
  i18n._(
308
235
  msg({
@@ -316,16 +243,7 @@ settingsRoutes.post("/avatar", async (c) => {
316
243
  });
317
244
 
318
245
  settingsRoutes.post("/avatar/remove", async (c) => {
319
- const storage = c.var.storage;
320
- const appleTouchKey = c.var.allSettings["SITE_FAVICON_APPLE_TOUCH"];
321
- if (storage && appleTouchKey) {
322
- await storage.delete(appleTouchKey);
323
- }
324
-
325
- await c.var.services.settings.remove("SITE_AVATAR");
326
- await c.var.services.settings.remove("SITE_FAVICON_ICO");
327
- await c.var.services.settings.remove("SITE_FAVICON_APPLE_TOUCH");
328
- await c.var.services.settings.remove("SITE_FAVICON_VERSION");
246
+ await c.var.services.settings.removeAvatar(c.var.storage);
329
247
 
330
248
  // ── JSON response mode (used by Lit settings bridge) ──────────────
331
249
  const wantsJson = c.req.header("accept")?.includes("application/json");
@@ -6,6 +6,7 @@ import { createTestDatabase } from "../../../__tests__/helpers/db.js";
6
6
  import { createPostService } from "../../../services/post.js";
7
7
  import { createSettingsService } from "../../../services/settings.js";
8
8
  import { createMediaService } from "../../../services/media.js";
9
+ import { createPathRegistryService } from "../../../services/path-registry.js";
9
10
  import { resolveConfig } from "../../../lib/resolve-config.js";
10
11
  import { rssRoutes } from "../rss.js";
11
12
 
@@ -15,7 +16,10 @@ function createFeedTestApp(envOverrides: Partial<Bindings> = {}) {
15
16
  const { db } = createTestDatabase();
16
17
 
17
18
  const services = {
18
- posts: createPostService(db as never),
19
+ posts: createPostService(
20
+ db as never,
21
+ createPathRegistryService(db as never),
22
+ ),
19
23
  settings: createSettingsService(db as never),
20
24
  media: createMediaService(db as never),
21
25
  };
@@ -10,6 +10,7 @@ import { describe, it, expect, beforeEach } from "vitest";
10
10
  import { createTestDatabase } from "../../../__tests__/helpers/db.js";
11
11
  import { createCollectionService } from "../../../services/collection.js";
12
12
  import { createPostService } from "../../../services/post.js";
13
+ import { createPathRegistryService } from "../../../services/path-registry.js";
13
14
  import type { Database } from "../../../db/index.js";
14
15
 
15
16
  describe("Collections Listing Page - Data Logic", () => {
@@ -21,7 +22,7 @@ describe("Collections Listing Page - Data Logic", () => {
21
22
  const testDb = createTestDatabase();
22
23
  db = testDb.db as unknown as Database;
23
24
  collectionService = createCollectionService(db);
24
- postService = createPostService(db);
25
+ postService = createPostService(db, createPathRegistryService(db));
25
26
  });
26
27
 
27
28
  it("returns collections with post counts", async () => {
@@ -9,6 +9,7 @@
9
9
  import { describe, it, expect, beforeEach } from "vitest";
10
10
  import { createTestDatabase } from "../../../__tests__/helpers/db.js";
11
11
  import { createPostService } from "../../../services/post.js";
12
+ import { createPathRegistryService } from "../../../services/path-registry.js";
12
13
  import type { Database } from "../../../db/index.js";
13
14
 
14
15
  describe("Featured Page - Data Logic", () => {
@@ -18,7 +19,7 @@ describe("Featured Page - Data Logic", () => {
18
19
  beforeEach(() => {
19
20
  const testDb = createTestDatabase();
20
21
  db = testDb.db as unknown as Database;
21
- postService = createPostService(db);
22
+ postService = createPostService(db, createPathRegistryService(db));
22
23
  });
23
24
 
24
25
  it("returns only featured published posts", async () => {
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Custom Page Route
3
3
  *
4
- * Serves pages from the pages table and posts with custom paths.
4
+ * Serves pages and posts with custom paths via the path registry.
5
5
  * This is a catch-all route mounted at "/" - must be registered last.
6
- * Supports multi-level paths (e.g. /2024/my-post) for posts.
6
+ * The path registry eliminates ambiguity: each path maps to exactly
7
+ * one entity (page, post, or redirect).
7
8
  */
8
9
 
9
10
  import { Hono } from "hono";
@@ -25,34 +26,28 @@ pageRoutes.get("/*", async (c) => {
25
26
  const fullPath = c.req.path.slice(1); // Remove leading /
26
27
  if (!fullPath) return c.notFound();
27
28
 
28
- const isMultiSegment = fullPath.includes("/");
29
+ const entry = await c.var.services.pathRegistry.getByPath(fullPath);
29
30
 
30
- // Pages only have single-level slugs; skip page lookup for multi-segment paths
31
- if (!isMultiSegment) {
32
- const page = await c.var.services.pages.getBySlug(fullPath);
33
-
34
- if (page) {
35
- if (page.status === "draft") {
36
- return c.notFound();
37
- }
31
+ if (entry?.ownerType === "page") {
32
+ const page = await c.var.services.pages.getById(entry.ownerId);
33
+ if (!page || page.status === "draft") {
34
+ return c.notFound();
35
+ }
38
36
 
39
- const navData = await getNavigationData(c);
40
- const pageView = toPageView(page);
37
+ const navData = await getNavigationData(c);
38
+ const pageView = toPageView(page);
41
39
 
42
- return renderPublicPage(c, {
43
- title: `${page.title || fullPath} - ${navData.siteName}`,
44
- description: page.body?.slice(0, 160),
45
- navData,
46
- content: <SinglePage page={pageView} />,
47
- });
48
- }
40
+ return renderPublicPage(c, {
41
+ title: `${page.title || fullPath} - ${navData.siteName}`,
42
+ description: page.body?.slice(0, 160),
43
+ navData,
44
+ content: <SinglePage page={pageView} />,
45
+ });
49
46
  }
50
47
 
51
- // Posts support multi-level paths
52
- const post = await c.var.services.posts.getByPath(fullPath);
53
-
54
- if (post) {
55
- if (post.status === "draft") {
48
+ if (entry?.ownerType === "post") {
49
+ const post = await c.var.services.posts.getById(entry.ownerId);
50
+ if (!post || post.status === "draft") {
56
51
  return c.notFound();
57
52
  }
58
53
 
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from "vitest";
2
2
  import { createTestDatabase } from "../../__tests__/helpers/db.js";
3
3
  import { createCollectionService } from "../collection.js";
4
4
  import { createPostService } from "../post.js";
5
+ import { createPathRegistryService } from "../path-registry.js";
5
6
  import type { Database } from "../../db/index.js";
6
7
 
7
8
  describe("CollectionService", () => {
@@ -13,7 +14,7 @@ describe("CollectionService", () => {
13
14
  const testDb = createTestDatabase();
14
15
  db = testDb.db as unknown as Database;
15
16
  collectionService = createCollectionService(db);
16
- postService = createPostService(db);
17
+ postService = createPostService(db, createPathRegistryService(db));
17
18
  });
18
19
 
19
20
  describe("create", () => {