@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.
- package/dist/client/client.css +1 -1
- package/dist/client/client.js +1442 -989
- package/dist/index.js +1431 -1057
- package/package.json +1 -1
- package/src/__tests__/helpers/app.ts +6 -3
- package/src/__tests__/helpers/db.ts +3 -0
- package/src/app.tsx +1 -1
- package/src/client.ts +2 -1
- package/src/db/migrations/0011_add_path_registry.sql +23 -0
- package/src/db/schema.ts +12 -1
- package/src/i18n/locales/en.po +225 -91
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +201 -152
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +201 -152
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/lib/__tests__/excerpt.test.ts +25 -0
- package/src/lib/__tests__/resolve-config.test.ts +26 -2
- package/src/lib/__tests__/timeline.test.ts +2 -1
- package/src/lib/compose-bridge.ts +30 -1
- package/src/lib/excerpt.ts +16 -7
- package/src/lib/nav-manager-bridge.ts +54 -0
- package/src/lib/navigation.ts +7 -4
- package/src/lib/render.tsx +5 -2
- package/src/lib/resolve-config.ts +7 -0
- package/src/lib/view.ts +42 -10
- package/src/middleware/error-handler.ts +16 -0
- package/src/routes/api/__tests__/posts.test.ts +80 -0
- package/src/routes/api/__tests__/settings.test.ts +1 -1
- package/src/routes/api/posts.ts +6 -29
- package/src/routes/api/upload.ts +2 -14
- package/src/routes/auth/__tests__/setup.test.ts +3 -2
- package/src/routes/auth/setup.tsx +1 -1
- package/src/routes/compose.tsx +13 -5
- package/src/routes/dash/__tests__/pages.test.ts +2 -1
- package/src/routes/dash/__tests__/settings-avatar.test.ts +151 -33
- package/src/routes/dash/appearance.tsx +71 -4
- package/src/routes/dash/collections.tsx +15 -21
- package/src/routes/dash/media.tsx +1 -13
- package/src/routes/dash/pages.tsx +5 -150
- package/src/routes/dash/posts.tsx +25 -32
- package/src/routes/dash/redirects.tsx +9 -11
- package/src/routes/dash/settings.tsx +29 -111
- package/src/routes/feed/__tests__/rss.test.ts +5 -1
- package/src/routes/pages/__tests__/collections.test.ts +2 -1
- package/src/routes/pages/__tests__/featured.test.ts +2 -1
- package/src/routes/pages/page.tsx +20 -25
- package/src/services/__tests__/collection.test.ts +2 -1
- package/src/services/__tests__/media.test.ts +78 -1
- package/src/services/__tests__/navigation.test.ts +2 -1
- package/src/services/__tests__/page.test.ts +78 -1
- package/src/services/__tests__/path-registry.test.ts +165 -0
- package/src/services/__tests__/post-timeline.test.ts +2 -1
- package/src/services/__tests__/post.test.ts +103 -1
- package/src/services/__tests__/redirect.test.ts +53 -4
- package/src/services/__tests__/search.test.ts +2 -1
- package/src/services/__tests__/settings.test.ts +153 -0
- package/src/services/index.ts +12 -4
- package/src/services/media.ts +72 -4
- package/src/services/page.ts +64 -17
- package/src/services/path-registry.ts +160 -0
- package/src/services/post.ts +119 -24
- package/src/services/redirect.ts +23 -3
- package/src/services/settings.ts +181 -0
- package/src/styles/components.css +135 -0
- package/src/styles/tokens.css +6 -1
- package/src/styles/ui.css +70 -26
- package/src/types/bindings.ts +1 -0
- package/src/types/config.ts +7 -2
- package/src/types/constants.ts +9 -1
- package/src/types/sortablejs.d.ts +8 -2
- package/src/types/views.ts +1 -1
- package/src/ui/color-themes.ts +31 -31
- package/src/ui/components/__tests__/jant-settings-avatar.test.ts +0 -3
- package/src/ui/components/__tests__/jant-settings-general.test.ts +2 -6
- package/src/ui/components/jant-compose-dialog.ts +3 -2
- package/src/ui/components/jant-compose-editor.ts +17 -2
- package/src/ui/components/jant-nav-manager.ts +1067 -0
- package/src/ui/components/jant-settings-general.ts +2 -35
- package/src/ui/components/nav-manager-types.ts +72 -0
- package/src/ui/components/settings-types.ts +0 -3
- package/src/ui/compose/ComposePrompt.tsx +3 -11
- package/src/ui/dash/appearance/AdvancedContent.tsx +0 -3
- package/src/ui/dash/appearance/AppearanceNav.tsx +12 -8
- package/src/ui/dash/appearance/ColorThemeContent.tsx +1 -4
- package/src/ui/dash/appearance/FontThemeContent.tsx +0 -3
- package/src/ui/dash/appearance/NavigationContent.tsx +302 -0
- package/src/ui/dash/pages/PagesContent.tsx +74 -0
- package/src/ui/dash/settings/AccountContent.tsx +0 -3
- package/src/ui/dash/settings/GeneralContent.tsx +1 -19
- package/src/ui/dash/settings/SettingsNav.tsx +2 -6
- package/src/ui/feed/NoteCard.tsx +2 -2
- package/src/ui/layouts/DashLayout.tsx +83 -86
- package/src/ui/layouts/SiteLayout.tsx +82 -21
- package/src/lib/nav-reorder.ts +0 -26
- package/src/ui/dash/pages/LinkFormContent.tsx +0 -119
- package/src/ui/dash/pages/UnifiedPagesContent.tsx +0 -203
|
@@ -95,6 +95,159 @@ describe("SettingsService", () => {
|
|
|
95
95
|
});
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
+
describe("updateGeneral", () => {
|
|
99
|
+
const defaults = {
|
|
100
|
+
siteName: "",
|
|
101
|
+
siteDescription: "",
|
|
102
|
+
siteFooter: "",
|
|
103
|
+
siteLanguage: "en",
|
|
104
|
+
homeDefaultView: "latest",
|
|
105
|
+
headerNavMaxVisible: "3",
|
|
106
|
+
timeZone: "UTC",
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
it("sets non-empty values", async () => {
|
|
110
|
+
await settingsService.updateGeneral(
|
|
111
|
+
{ ...defaults, siteName: "My Blog", siteDescription: "A blog" },
|
|
112
|
+
{ oldLanguage: "en", fallbackSiteName: "Jant" },
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
expect(await settingsService.get("SITE_NAME")).toBe("My Blog");
|
|
116
|
+
expect(await settingsService.get("SITE_DESCRIPTION")).toBe("A blog");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("removes empty values", async () => {
|
|
120
|
+
await settingsService.set("SITE_NAME", "Old Name");
|
|
121
|
+
await settingsService.updateGeneral(
|
|
122
|
+
{ ...defaults, siteName: "" },
|
|
123
|
+
{ oldLanguage: "en", fallbackSiteName: "Jant" },
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
expect(await settingsService.get("SITE_NAME")).toBeNull();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("trims whitespace from values", async () => {
|
|
130
|
+
await settingsService.updateGeneral(
|
|
131
|
+
{ ...defaults, siteName: " Trimmed " },
|
|
132
|
+
{ oldLanguage: "en", fallbackSiteName: "Jant" },
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
expect(await settingsService.get("SITE_NAME")).toBe("Trimmed");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("removes HOME_DEFAULT_VIEW when set to default", async () => {
|
|
139
|
+
await settingsService.set("HOME_DEFAULT_VIEW", "featured");
|
|
140
|
+
await settingsService.updateGeneral(
|
|
141
|
+
{ ...defaults, homeDefaultView: "latest" },
|
|
142
|
+
{ oldLanguage: "en", fallbackSiteName: "Jant" },
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
expect(await settingsService.get("HOME_DEFAULT_VIEW")).toBeNull();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("stores HOME_DEFAULT_VIEW when set to featured", async () => {
|
|
149
|
+
await settingsService.updateGeneral(
|
|
150
|
+
{ ...defaults, homeDefaultView: "featured" },
|
|
151
|
+
{ oldLanguage: "en", fallbackSiteName: "Jant" },
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
expect(await settingsService.get("HOME_DEFAULT_VIEW")).toBe("featured");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("removes HEADER_NAV_MAX_VISIBLE when set to default (3)", async () => {
|
|
158
|
+
await settingsService.set("HEADER_NAV_MAX_VISIBLE", "5");
|
|
159
|
+
await settingsService.updateGeneral(
|
|
160
|
+
{ ...defaults, headerNavMaxVisible: "3" },
|
|
161
|
+
{ oldLanguage: "en", fallbackSiteName: "Jant" },
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
expect(await settingsService.get("HEADER_NAV_MAX_VISIBLE")).toBeNull();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("stores HEADER_NAV_MAX_VISIBLE when non-default", async () => {
|
|
168
|
+
await settingsService.updateGeneral(
|
|
169
|
+
{ ...defaults, headerNavMaxVisible: "5" },
|
|
170
|
+
{ oldLanguage: "en", fallbackSiteName: "Jant" },
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
expect(await settingsService.get("HEADER_NAV_MAX_VISIBLE")).toBe("5");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("removes TIME_ZONE when set to UTC", async () => {
|
|
177
|
+
await settingsService.set("TIME_ZONE", "America/New_York");
|
|
178
|
+
await settingsService.updateGeneral(
|
|
179
|
+
{ ...defaults, timeZone: "UTC" },
|
|
180
|
+
{ oldLanguage: "en", fallbackSiteName: "Jant" },
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
expect(await settingsService.get("TIME_ZONE")).toBeNull();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("stores TIME_ZONE when non-default", async () => {
|
|
187
|
+
await settingsService.updateGeneral(
|
|
188
|
+
{ ...defaults, timeZone: "America/New_York" },
|
|
189
|
+
{ oldLanguage: "en", fallbackSiteName: "Jant" },
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
expect(await settingsService.get("TIME_ZONE")).toBe("America/New_York");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("detects language change", async () => {
|
|
196
|
+
const result = await settingsService.updateGeneral(
|
|
197
|
+
{ ...defaults, siteLanguage: "sv" },
|
|
198
|
+
{ oldLanguage: "en", fallbackSiteName: "Jant" },
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
expect(result.languageChanged).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("returns no language change when same", async () => {
|
|
205
|
+
const result = await settingsService.updateGeneral(defaults, {
|
|
206
|
+
oldLanguage: "en",
|
|
207
|
+
fallbackSiteName: "Jant",
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
expect(result.languageChanged).toBe(false);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("returns display name from siteName when non-empty", async () => {
|
|
214
|
+
const result = await settingsService.updateGeneral(
|
|
215
|
+
{ ...defaults, siteName: "My Blog" },
|
|
216
|
+
{ oldLanguage: "en", fallbackSiteName: "Jant" },
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
expect(result.displayName).toBe("My Blog");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("returns fallback display name when siteName is empty", async () => {
|
|
223
|
+
const result = await settingsService.updateGeneral(defaults, {
|
|
224
|
+
oldLanguage: "en",
|
|
225
|
+
fallbackSiteName: "Jant",
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
expect(result.displayName).toBe("Jant");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("stores footer when non-empty", async () => {
|
|
232
|
+
await settingsService.updateGeneral(
|
|
233
|
+
{ ...defaults, siteFooter: "© 2026" },
|
|
234
|
+
{ oldLanguage: "en", fallbackSiteName: "Jant" },
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
expect(await settingsService.get("SITE_FOOTER")).toBe("© 2026");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("removes footer when empty", async () => {
|
|
241
|
+
await settingsService.set("SITE_FOOTER", "Old footer");
|
|
242
|
+
await settingsService.updateGeneral(
|
|
243
|
+
{ ...defaults, siteFooter: "" },
|
|
244
|
+
{ oldLanguage: "en", fallbackSiteName: "Jant" },
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
expect(await settingsService.get("SITE_FOOTER")).toBeNull();
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
98
251
|
describe("onboarding", () => {
|
|
99
252
|
it("returns false when onboarding is not complete", async () => {
|
|
100
253
|
const result = await settingsService.isOnboardingComplete();
|
package/src/services/index.ts
CHANGED
|
@@ -17,6 +17,10 @@ import {
|
|
|
17
17
|
import { createSearchService, type SearchService } from "./search.js";
|
|
18
18
|
import { createNavItemService, type NavItemService } from "./navigation.js";
|
|
19
19
|
import { createAuthService, type AuthService } from "./auth.js";
|
|
20
|
+
import {
|
|
21
|
+
createPathRegistryService,
|
|
22
|
+
type PathRegistryService,
|
|
23
|
+
} from "./path-registry.js";
|
|
20
24
|
|
|
21
25
|
export interface Services {
|
|
22
26
|
settings: SettingsService;
|
|
@@ -28,15 +32,18 @@ export interface Services {
|
|
|
28
32
|
search: SearchService;
|
|
29
33
|
navItems: NavItemService;
|
|
30
34
|
auth: AuthService;
|
|
35
|
+
pathRegistry: PathRegistryService;
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
export function createServices(db: Database, d1: D1Database): Services {
|
|
34
39
|
const settings = createSettingsService(db);
|
|
40
|
+
const pathRegistry = createPathRegistryService(db);
|
|
35
41
|
return {
|
|
36
42
|
settings,
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
43
|
+
pathRegistry,
|
|
44
|
+
posts: createPostService(db, pathRegistry),
|
|
45
|
+
pages: createPageService(db, pathRegistry),
|
|
46
|
+
redirects: createRedirectService(db, pathRegistry),
|
|
40
47
|
media: createMediaService(db),
|
|
41
48
|
collections: createCollectionService(db),
|
|
42
49
|
search: createSearchService(d1),
|
|
@@ -46,7 +53,7 @@ export function createServices(db: Database, d1: D1Database): Services {
|
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
export type { SettingsService } from "./settings.js";
|
|
49
|
-
export type { PostService, PostFilters } from "./post.js";
|
|
56
|
+
export type { PostService, PostFilters, PostDeleteDeps } from "./post.js";
|
|
50
57
|
export type { PageService, PageFilters } from "./page.js";
|
|
51
58
|
export type { RedirectService } from "./redirect.js";
|
|
52
59
|
export type { MediaService, MediaFilters } from "./media.js";
|
|
@@ -54,3 +61,4 @@ export type { CollectionService } from "./collection.js";
|
|
|
54
61
|
export type { SearchService, SearchResult, SearchOptions } from "./search.js";
|
|
55
62
|
export type { NavItemService } from "./navigation.js";
|
|
56
63
|
export type { AuthService } from "./auth.js";
|
|
64
|
+
export type { PathRegistryService, OwnerType } from "./path-registry.js";
|
package/src/services/media.ts
CHANGED
|
@@ -9,7 +9,10 @@ import { uuidv7 } from "uuidv7";
|
|
|
9
9
|
import type { Database } from "../db/index.js";
|
|
10
10
|
import { media } from "../db/schema.js";
|
|
11
11
|
import { now } from "../lib/time.js";
|
|
12
|
+
import type { StorageDriver } from "../lib/storage.js";
|
|
12
13
|
import type { Media } from "../types.js";
|
|
14
|
+
import { MAX_MEDIA_ATTACHMENTS } from "../types.js";
|
|
15
|
+
import { ValidationError } from "../lib/errors.js";
|
|
13
16
|
|
|
14
17
|
export interface MediaFilters {
|
|
15
18
|
limit?: number;
|
|
@@ -24,7 +27,29 @@ export interface MediaService {
|
|
|
24
27
|
getByPostIds(postIds: number[]): Promise<Map<number, Media[]>>;
|
|
25
28
|
list(filters?: MediaFilters): Promise<Media[]>;
|
|
26
29
|
create(data: CreateMediaData): Promise<Media>;
|
|
27
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Validate media IDs: checks count limit and verifies all IDs exist in the database.
|
|
32
|
+
* No-op when the array is empty.
|
|
33
|
+
*
|
|
34
|
+
* @param ids - Media IDs to validate
|
|
35
|
+
* @throws {ValidationError} When count exceeds MAX_MEDIA_ATTACHMENTS or any ID is missing
|
|
36
|
+
*/
|
|
37
|
+
validateIds(ids: string[]): Promise<void>;
|
|
38
|
+
/**
|
|
39
|
+
* Delete a media record and its storage file.
|
|
40
|
+
*
|
|
41
|
+
* @param id - Media record ID
|
|
42
|
+
* @param storage - Optional storage driver; when provided the file is deleted from storage
|
|
43
|
+
* @returns true if the record existed and was deleted
|
|
44
|
+
*/
|
|
45
|
+
delete(id: string, storage?: StorageDriver | null): Promise<boolean>;
|
|
46
|
+
/**
|
|
47
|
+
* Delete multiple media records and their storage files.
|
|
48
|
+
*
|
|
49
|
+
* @param ids - Media record IDs
|
|
50
|
+
* @param storage - Optional storage driver; when provided the files are deleted from storage
|
|
51
|
+
*/
|
|
52
|
+
deleteByIds(ids: string[], storage?: StorageDriver | null): Promise<void>;
|
|
28
53
|
getByStorageKey(storageKey: string): Promise<Media | null>;
|
|
29
54
|
attachToPost(postId: number, mediaIds: string[]): Promise<void>;
|
|
30
55
|
detachFromPost(postId: number): Promise<void>;
|
|
@@ -142,6 +167,21 @@ export function createMediaService(db: Database): MediaService {
|
|
|
142
167
|
return rows.map(toMedia);
|
|
143
168
|
},
|
|
144
169
|
|
|
170
|
+
async validateIds(ids) {
|
|
171
|
+
if (ids.length === 0) return;
|
|
172
|
+
|
|
173
|
+
if (ids.length > MAX_MEDIA_ATTACHMENTS) {
|
|
174
|
+
throw new ValidationError(
|
|
175
|
+
`Posts allow at most ${MAX_MEDIA_ATTACHMENTS} media attachments`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const existing = await this.getByIds(ids);
|
|
180
|
+
if (existing.length !== ids.length) {
|
|
181
|
+
throw new ValidationError("One or more media IDs are invalid");
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
|
|
145
185
|
async create(data) {
|
|
146
186
|
const id = data.id ?? uuidv7();
|
|
147
187
|
const timestamp = now();
|
|
@@ -207,9 +247,37 @@ export function createMediaService(db: Database): MediaService {
|
|
|
207
247
|
await db.update(media).set({ alt }).where(eq(media.id, id));
|
|
208
248
|
},
|
|
209
249
|
|
|
210
|
-
async delete(id) {
|
|
211
|
-
const
|
|
212
|
-
|
|
250
|
+
async delete(id, storage) {
|
|
251
|
+
const record = await this.getById(id);
|
|
252
|
+
if (!record) return false;
|
|
253
|
+
|
|
254
|
+
if (storage) {
|
|
255
|
+
await storage.delete(record.storageKey).catch((err) => {
|
|
256
|
+
// eslint-disable-next-line no-console -- Error logging is intentional
|
|
257
|
+
console.error("Storage delete error:", err);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
await db.delete(media).where(eq(media.id, id));
|
|
262
|
+
return true;
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
async deleteByIds(ids, storage) {
|
|
266
|
+
if (ids.length === 0) return;
|
|
267
|
+
|
|
268
|
+
if (storage) {
|
|
269
|
+
const records = await this.getByIds(ids);
|
|
270
|
+
await Promise.all(
|
|
271
|
+
records.map((r) =>
|
|
272
|
+
storage.delete(r.storageKey).catch((err) => {
|
|
273
|
+
// eslint-disable-next-line no-console -- Error logging is intentional
|
|
274
|
+
console.error("Storage delete error:", err);
|
|
275
|
+
}),
|
|
276
|
+
),
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
await db.delete(media).where(inArray(media.id, ids));
|
|
213
281
|
},
|
|
214
282
|
};
|
|
215
283
|
}
|
package/src/services/page.ts
CHANGED
|
@@ -10,6 +10,8 @@ import { pages, navItems } from "../db/schema.js";
|
|
|
10
10
|
import { now } from "../lib/time.js";
|
|
11
11
|
import { render as renderMarkdown } from "../lib/markdown.js";
|
|
12
12
|
import type { Page, Status, CreatePage, UpdatePage } from "../types.js";
|
|
13
|
+
import type { PathRegistryService } from "./path-registry.js";
|
|
14
|
+
import { ConflictError } from "../lib/errors.js";
|
|
13
15
|
|
|
14
16
|
export interface PageFilters {
|
|
15
17
|
status?: Status;
|
|
@@ -25,7 +27,16 @@ export interface PageService {
|
|
|
25
27
|
delete(id: number): Promise<boolean>;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
|
-
|
|
30
|
+
/** Check if an error is a SQLite UNIQUE constraint violation (D1 or better-sqlite3) */
|
|
31
|
+
function isUniqueConstraintError(err: unknown): boolean {
|
|
32
|
+
const msg = String(err);
|
|
33
|
+
return msg.includes("UNIQUE constraint") || msg.includes("SQLITE_CONSTRAINT");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createPageService(
|
|
37
|
+
db: Database,
|
|
38
|
+
pathRegistry: PathRegistryService,
|
|
39
|
+
): PageService {
|
|
29
40
|
function toPage(row: typeof pages.$inferSelect): Page {
|
|
30
41
|
return {
|
|
31
42
|
id: row.id,
|
|
@@ -83,31 +94,60 @@ export function createPageService(db: Database): PageService {
|
|
|
83
94
|
},
|
|
84
95
|
|
|
85
96
|
async create(data) {
|
|
86
|
-
|
|
97
|
+
// Validate and reserve path before DB insert — throws friendly
|
|
98
|
+
// ConflictError/ValidationError instead of a raw UNIQUE constraint error.
|
|
99
|
+
// Uses placeholder owner ID; corrected to real ID after insert.
|
|
100
|
+
await pathRegistry.claim(data.slug, "page", 0);
|
|
87
101
|
|
|
102
|
+
const timestamp = now();
|
|
88
103
|
const bodyHtml = data.body ? renderMarkdown(data.body) : null;
|
|
89
104
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
105
|
+
let page: Page;
|
|
106
|
+
try {
|
|
107
|
+
const result = await db
|
|
108
|
+
.insert(pages)
|
|
109
|
+
.values({
|
|
110
|
+
slug: data.slug,
|
|
111
|
+
title: data.title ?? null,
|
|
112
|
+
body: data.body ?? null,
|
|
113
|
+
bodyHtml,
|
|
114
|
+
status: data.status ?? "published",
|
|
115
|
+
createdAt: timestamp,
|
|
116
|
+
updatedAt: timestamp,
|
|
117
|
+
})
|
|
118
|
+
.returning();
|
|
119
|
+
|
|
120
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
|
|
121
|
+
page = toPage(result[0]!);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
await pathRegistry.release(data.slug);
|
|
124
|
+
// Surface DB unique constraint failures as a friendly error
|
|
125
|
+
if (isUniqueConstraintError(err)) {
|
|
126
|
+
throw new ConflictError(`Slug "${data.slug}" is already in use`);
|
|
127
|
+
}
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
102
130
|
|
|
103
|
-
//
|
|
104
|
-
|
|
131
|
+
// Update registry with actual page ID
|
|
132
|
+
await pathRegistry.release(data.slug);
|
|
133
|
+
await pathRegistry.claim(data.slug, "page", page.id);
|
|
134
|
+
|
|
135
|
+
return page;
|
|
105
136
|
},
|
|
106
137
|
|
|
107
138
|
async update(id, data) {
|
|
108
139
|
const existing = await this.getById(id);
|
|
109
140
|
if (!existing) return null;
|
|
110
141
|
|
|
142
|
+
const slugChanging =
|
|
143
|
+
data.slug !== undefined && data.slug !== existing.slug;
|
|
144
|
+
|
|
145
|
+
// If slug is changing, claim the new path first (validates before modifying)
|
|
146
|
+
if (slugChanging) {
|
|
147
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by slugChanging check
|
|
148
|
+
await pathRegistry.claim(data.slug!, "page", id);
|
|
149
|
+
}
|
|
150
|
+
|
|
111
151
|
const timestamp = now();
|
|
112
152
|
const updates: Partial<typeof pages.$inferInsert> = {
|
|
113
153
|
updatedAt: timestamp,
|
|
@@ -123,7 +163,7 @@ export function createPageService(db: Database): PageService {
|
|
|
123
163
|
}
|
|
124
164
|
|
|
125
165
|
// If slug changed, update related nav_items
|
|
126
|
-
if (
|
|
166
|
+
if (slugChanging) {
|
|
127
167
|
await db
|
|
128
168
|
.update(navItems)
|
|
129
169
|
.set({ url: `/${data.slug}`, updatedAt: timestamp })
|
|
@@ -144,10 +184,17 @@ export function createPageService(db: Database): PageService {
|
|
|
144
184
|
.where(eq(pages.id, id))
|
|
145
185
|
.returning();
|
|
146
186
|
|
|
187
|
+
// Release old slug from registry after successful update
|
|
188
|
+
if (slugChanging) {
|
|
189
|
+
await pathRegistry.release(existing.slug);
|
|
190
|
+
}
|
|
191
|
+
|
|
147
192
|
return result[0] ? toPage(result[0]) : null;
|
|
148
193
|
},
|
|
149
194
|
|
|
150
195
|
async delete(id) {
|
|
196
|
+
// Release path registry entries for this page
|
|
197
|
+
await pathRegistry.releaseByOwner("page", id);
|
|
151
198
|
// nav_items with page_id FK have ON DELETE CASCADE, so they auto-delete
|
|
152
199
|
const result = await db.delete(pages).where(eq(pages.id, id)).returning();
|
|
153
200
|
return result.length > 0;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path Registry Service
|
|
3
|
+
*
|
|
4
|
+
* Central registry for URL path ownership. Every entity (page, post, redirect)
|
|
5
|
+
* that claims a URL path registers it here. The table's PRIMARY KEY on path
|
|
6
|
+
* provides DB-level uniqueness. Reserved system paths are rejected at the
|
|
7
|
+
* service layer.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { eq, and } from "drizzle-orm";
|
|
11
|
+
import type { Database } from "../db/index.js";
|
|
12
|
+
import { pathRegistry } from "../db/schema.js";
|
|
13
|
+
import { now } from "../lib/time.js";
|
|
14
|
+
import { normalizePath } from "../lib/url.js";
|
|
15
|
+
import { isReservedPath } from "../lib/constants.js";
|
|
16
|
+
import { ValidationError, ConflictError } from "../lib/errors.js";
|
|
17
|
+
|
|
18
|
+
export type OwnerType = "page" | "post" | "redirect";
|
|
19
|
+
|
|
20
|
+
export interface PathRegistryEntry {
|
|
21
|
+
path: string;
|
|
22
|
+
ownerType: OwnerType;
|
|
23
|
+
ownerId: number;
|
|
24
|
+
createdAt: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PathRegistryService {
|
|
28
|
+
/**
|
|
29
|
+
* Claim a path for an entity. Rejects reserved paths and conflicts.
|
|
30
|
+
* Idempotent: re-claiming the same path for the same owner is a no-op.
|
|
31
|
+
*
|
|
32
|
+
* @param path - The URL path to claim
|
|
33
|
+
* @param ownerType - The type of entity claiming the path
|
|
34
|
+
* @param ownerId - The ID of the entity claiming the path
|
|
35
|
+
* @returns The registry entry
|
|
36
|
+
*/
|
|
37
|
+
claim(
|
|
38
|
+
path: string,
|
|
39
|
+
ownerType: OwnerType,
|
|
40
|
+
ownerId: number,
|
|
41
|
+
): Promise<PathRegistryEntry>;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Release a claimed path.
|
|
45
|
+
*
|
|
46
|
+
* @param path - The URL path to release
|
|
47
|
+
*/
|
|
48
|
+
release(path: string): Promise<void>;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Release all paths owned by a specific entity.
|
|
52
|
+
*
|
|
53
|
+
* @param ownerType - The type of entity
|
|
54
|
+
* @param ownerId - The ID of the entity
|
|
55
|
+
*/
|
|
56
|
+
releaseByOwner(ownerType: OwnerType, ownerId: number): Promise<void>;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Look up a path in the registry.
|
|
60
|
+
*
|
|
61
|
+
* @param path - The URL path to look up
|
|
62
|
+
* @returns The registry entry, or null if not claimed
|
|
63
|
+
*/
|
|
64
|
+
getByPath(path: string): Promise<PathRegistryEntry | null>;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Check if a path is available (not reserved and not claimed).
|
|
68
|
+
*
|
|
69
|
+
* @param path - The URL path to check
|
|
70
|
+
* @returns true if the path is available
|
|
71
|
+
*/
|
|
72
|
+
isAvailable(path: string): Promise<boolean>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function createPathRegistryService(db: Database): PathRegistryService {
|
|
76
|
+
function toEntry(row: typeof pathRegistry.$inferSelect): PathRegistryEntry {
|
|
77
|
+
return {
|
|
78
|
+
path: row.path,
|
|
79
|
+
ownerType: row.ownerType as OwnerType,
|
|
80
|
+
ownerId: row.ownerId,
|
|
81
|
+
createdAt: row.createdAt,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
async claim(path, ownerType, ownerId) {
|
|
87
|
+
const normalized = normalizePath(path);
|
|
88
|
+
|
|
89
|
+
if (isReservedPath(normalized)) {
|
|
90
|
+
throw new ValidationError(
|
|
91
|
+
`Path "${normalized}" is reserved and cannot be used`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Check existing claim
|
|
96
|
+
const existing = await db
|
|
97
|
+
.select()
|
|
98
|
+
.from(pathRegistry)
|
|
99
|
+
.where(eq(pathRegistry.path, normalized))
|
|
100
|
+
.limit(1);
|
|
101
|
+
|
|
102
|
+
if (existing[0]) {
|
|
103
|
+
const entry = toEntry(existing[0]);
|
|
104
|
+
// Idempotent: same owner re-claiming is a no-op
|
|
105
|
+
if (entry.ownerType === ownerType && entry.ownerId === ownerId) {
|
|
106
|
+
return entry;
|
|
107
|
+
}
|
|
108
|
+
throw new ConflictError(`Path "${normalized}" is already in use`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const timestamp = now();
|
|
112
|
+
await db.insert(pathRegistry).values({
|
|
113
|
+
path: normalized,
|
|
114
|
+
ownerType,
|
|
115
|
+
ownerId,
|
|
116
|
+
createdAt: timestamp,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return { path: normalized, ownerType, ownerId, createdAt: timestamp };
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
async release(path) {
|
|
123
|
+
const normalized = normalizePath(path);
|
|
124
|
+
await db.delete(pathRegistry).where(eq(pathRegistry.path, normalized));
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
async releaseByOwner(ownerType, ownerId) {
|
|
128
|
+
await db
|
|
129
|
+
.delete(pathRegistry)
|
|
130
|
+
.where(
|
|
131
|
+
and(
|
|
132
|
+
eq(pathRegistry.ownerType, ownerType),
|
|
133
|
+
eq(pathRegistry.ownerId, ownerId),
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
async getByPath(path) {
|
|
139
|
+
const normalized = normalizePath(path);
|
|
140
|
+
const result = await db
|
|
141
|
+
.select()
|
|
142
|
+
.from(pathRegistry)
|
|
143
|
+
.where(eq(pathRegistry.path, normalized))
|
|
144
|
+
.limit(1);
|
|
145
|
+
return result[0] ? toEntry(result[0]) : null;
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
async isAvailable(path) {
|
|
149
|
+
const normalized = normalizePath(path);
|
|
150
|
+
if (isReservedPath(normalized)) return false;
|
|
151
|
+
|
|
152
|
+
const existing = await db
|
|
153
|
+
.select()
|
|
154
|
+
.from(pathRegistry)
|
|
155
|
+
.where(eq(pathRegistry.path, normalized))
|
|
156
|
+
.limit(1);
|
|
157
|
+
return existing.length === 0;
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|