@jant/core 0.3.36 → 0.3.38

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 (271) hide show
  1. package/bin/commands/export.js +1 -1
  2. package/bin/commands/import-site.js +529 -0
  3. package/bin/commands/reset-password.js +3 -2
  4. package/dist/client/assets/heic-to-DIRPI3VF.js +1 -0
  5. package/dist/client/assets/url-FWFqPJPb.js +1 -0
  6. package/dist/client/client.css +1 -1
  7. package/dist/client/client.js +4012 -3276
  8. package/dist/index.js +10285 -5809
  9. package/package.json +11 -3
  10. package/src/__tests__/helpers/app.ts +9 -9
  11. package/src/__tests__/helpers/db.ts +91 -93
  12. package/src/app.tsx +157 -27
  13. package/src/auth.ts +20 -2
  14. package/src/client/archive-nav.js +187 -0
  15. package/src/client/audio-player.ts +478 -0
  16. package/src/client/audio-processor.ts +84 -0
  17. package/src/client/avatar-upload.ts +3 -2
  18. package/src/client/components/__tests__/jant-compose-dialog.test.ts +645 -49
  19. package/src/client/components/__tests__/jant-compose-editor.test.ts +208 -16
  20. package/src/client/components/__tests__/jant-post-form.test.ts +19 -9
  21. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
  22. package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
  23. package/src/client/components/collection-sidebar-types.ts +7 -9
  24. package/src/client/components/compose-types.ts +101 -4
  25. package/src/client/components/jant-collection-form.ts +43 -7
  26. package/src/client/components/jant-collection-sidebar.ts +88 -84
  27. package/src/client/components/jant-compose-dialog.ts +1655 -219
  28. package/src/client/components/jant-compose-editor.ts +732 -168
  29. package/src/client/components/jant-compose-fullscreen.ts +23 -78
  30. package/src/client/components/jant-media-lightbox.ts +2 -0
  31. package/src/client/components/jant-nav-manager.ts +24 -284
  32. package/src/client/components/jant-post-form.ts +89 -9
  33. package/src/client/components/jant-post-menu.ts +1019 -0
  34. package/src/client/components/jant-settings-avatar.ts +3 -3
  35. package/src/client/components/jant-settings-general.ts +38 -4
  36. package/src/client/components/jant-text-preview.ts +232 -0
  37. package/src/client/components/nav-manager-types.ts +4 -19
  38. package/src/client/components/post-form-template.ts +107 -12
  39. package/src/client/components/post-form-types.ts +11 -4
  40. package/src/client/compose-bridge.ts +410 -109
  41. package/src/client/image-processor.ts +26 -8
  42. package/src/client/media-metadata.ts +247 -0
  43. package/src/client/multipart-upload.ts +160 -0
  44. package/src/client/post-form-bridge.ts +52 -1
  45. package/src/client/settings-bridge.ts +0 -12
  46. package/src/client/thread-context.ts +140 -0
  47. package/src/client/tiptap/create-editor.ts +46 -0
  48. package/src/client/tiptap/extensions.ts +5 -0
  49. package/src/client/tiptap/image-node.ts +2 -8
  50. package/src/client/tiptap/paste-image.ts +2 -13
  51. package/src/client/tiptap/slash-commands.ts +173 -63
  52. package/src/client/toast.ts +101 -3
  53. package/src/client/types/sortablejs.d.ts +15 -0
  54. package/src/client/upload-with-metadata.ts +54 -0
  55. package/src/client/video-processor.ts +207 -0
  56. package/src/client.ts +5 -2
  57. package/src/db/__tests__/migrations.test.ts +118 -0
  58. package/src/db/index.ts +52 -0
  59. package/src/db/migrations/0000_baseline.sql +269 -0
  60. package/src/db/migrations/0001_fts_setup.sql +31 -0
  61. package/src/db/migrations/meta/0000_snapshot.json +703 -119
  62. package/src/db/migrations/meta/0001_snapshot.json +1337 -0
  63. package/src/db/migrations/meta/_journal.json +4 -39
  64. package/src/db/schema.ts +409 -145
  65. package/src/i18n/__tests__/detect.test.ts +115 -0
  66. package/src/i18n/context.tsx +2 -2
  67. package/src/i18n/detect.ts +85 -1
  68. package/src/i18n/i18n.ts +1 -1
  69. package/src/i18n/index.ts +2 -1
  70. package/src/i18n/locales/en.po +487 -1217
  71. package/src/i18n/locales/en.ts +1 -1
  72. package/src/i18n/locales/zh-Hans.po +613 -996
  73. package/src/i18n/locales/zh-Hans.ts +1 -1
  74. package/src/i18n/locales/zh-Hant.po +624 -1007
  75. package/src/i18n/locales/zh-Hant.ts +1 -1
  76. package/src/i18n/middleware.ts +6 -0
  77. package/src/index.ts +5 -7
  78. package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
  79. package/src/lib/__tests__/constants.test.ts +0 -1
  80. package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
  81. package/src/lib/__tests__/nanoid.test.ts +26 -0
  82. package/src/lib/__tests__/schemas.test.ts +181 -63
  83. package/src/lib/__tests__/slug.test.ts +126 -0
  84. package/src/lib/__tests__/sse.test.ts +6 -6
  85. package/src/lib/__tests__/summary.test.ts +264 -0
  86. package/src/lib/__tests__/theme.test.ts +1 -1
  87. package/src/lib/__tests__/timeline.test.ts +33 -30
  88. package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
  89. package/src/lib/__tests__/view.test.ts +141 -66
  90. package/src/lib/blurhash-placeholder.ts +102 -0
  91. package/src/lib/constants.ts +3 -1
  92. package/src/lib/emoji-catalog.ts +885 -68
  93. package/src/lib/errors.ts +11 -8
  94. package/src/lib/feed.ts +78 -32
  95. package/src/lib/html.ts +2 -1
  96. package/src/lib/icon-catalog.ts +5033 -1
  97. package/src/lib/icons.ts +3 -2
  98. package/src/lib/index.ts +0 -1
  99. package/src/lib/markdown-to-tiptap.ts +286 -0
  100. package/src/lib/media-helpers.ts +12 -3
  101. package/src/lib/nanoid.ts +29 -0
  102. package/src/lib/navigation.ts +1 -1
  103. package/src/lib/render.tsx +20 -2
  104. package/src/lib/resolve-config.ts +6 -2
  105. package/src/lib/schemas.ts +224 -55
  106. package/src/lib/search-snippet.ts +34 -0
  107. package/src/lib/slug.ts +96 -0
  108. package/src/lib/sse.ts +6 -6
  109. package/src/lib/storage.ts +115 -7
  110. package/src/lib/summary.ts +66 -0
  111. package/src/lib/theme.ts +11 -8
  112. package/src/lib/timeline.ts +74 -34
  113. package/src/lib/tiptap-render.ts +5 -10
  114. package/src/lib/tiptap-to-markdown.ts +305 -0
  115. package/src/lib/upload.ts +190 -29
  116. package/src/lib/url.ts +31 -0
  117. package/src/lib/view.ts +204 -37
  118. package/src/middleware/__tests__/auth.test.ts +191 -11
  119. package/src/middleware/__tests__/onboarding.test.ts +12 -10
  120. package/src/middleware/auth.ts +63 -9
  121. package/src/middleware/onboarding.ts +1 -1
  122. package/src/middleware/secure-headers.ts +40 -0
  123. package/src/preset.css +45 -2
  124. package/src/routes/__tests__/compose.test.ts +17 -24
  125. package/src/routes/api/__tests__/collections.test.ts +109 -61
  126. package/src/routes/api/__tests__/nav-items.test.ts +46 -29
  127. package/src/routes/api/__tests__/posts.test.ts +132 -68
  128. package/src/routes/api/__tests__/search.test.ts +15 -2
  129. package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
  130. package/src/routes/api/collections.ts +51 -42
  131. package/src/routes/api/custom-urls.ts +80 -0
  132. package/src/routes/api/export.ts +31 -0
  133. package/src/routes/api/nav-items.ts +23 -19
  134. package/src/routes/api/posts.ts +43 -39
  135. package/src/routes/api/search.ts +3 -4
  136. package/src/routes/api/upload-multipart.ts +245 -0
  137. package/src/routes/api/upload.ts +85 -19
  138. package/src/routes/auth/__tests__/setup.test.ts +20 -60
  139. package/src/routes/auth/setup.tsx +26 -33
  140. package/src/routes/auth/signin.tsx +3 -7
  141. package/src/routes/compose.tsx +10 -55
  142. package/src/routes/dash/__tests__/settings-avatar.test.ts +1 -1
  143. package/src/routes/dash/custom-urls.tsx +414 -0
  144. package/src/routes/dash/settings.tsx +304 -232
  145. package/src/routes/feed/__tests__/rss.test.ts +27 -28
  146. package/src/routes/feed/rss.ts +6 -4
  147. package/src/routes/feed/sitemap.ts +2 -12
  148. package/src/routes/pages/__tests__/collections.test.ts +5 -6
  149. package/src/routes/pages/__tests__/featured.test.ts +41 -22
  150. package/src/routes/pages/archive.tsx +175 -39
  151. package/src/routes/pages/collection.tsx +22 -10
  152. package/src/routes/pages/collections.tsx +3 -3
  153. package/src/routes/pages/featured.tsx +28 -4
  154. package/src/routes/pages/home.tsx +16 -15
  155. package/src/routes/pages/latest.tsx +1 -11
  156. package/src/routes/pages/new.tsx +39 -0
  157. package/src/routes/pages/page.tsx +95 -49
  158. package/src/routes/pages/search.tsx +1 -1
  159. package/src/services/__tests__/api-token.test.ts +135 -0
  160. package/src/services/__tests__/collection.test.ts +275 -227
  161. package/src/services/__tests__/custom-url.test.ts +213 -0
  162. package/src/services/__tests__/media.test.ts +162 -22
  163. package/src/services/__tests__/navigation.test.ts +109 -68
  164. package/src/services/__tests__/post-timeline.test.ts +205 -32
  165. package/src/services/__tests__/post.test.ts +713 -234
  166. package/src/services/__tests__/search.test.ts +67 -10
  167. package/src/services/api-token.ts +166 -0
  168. package/src/services/auth.ts +17 -2
  169. package/src/services/collection.ts +397 -131
  170. package/src/services/custom-url.ts +188 -0
  171. package/src/services/export.ts +802 -0
  172. package/src/services/index.ts +26 -19
  173. package/src/services/media.ts +100 -22
  174. package/src/services/navigation.ts +158 -47
  175. package/src/services/path.ts +339 -0
  176. package/src/services/post.ts +687 -154
  177. package/src/services/search.ts +160 -75
  178. package/src/styles/components.css +58 -7
  179. package/src/styles/tokens.css +84 -6
  180. package/src/styles/ui.css +2964 -457
  181. package/src/types/bindings.ts +4 -1
  182. package/src/types/config.ts +12 -4
  183. package/src/types/constants.ts +15 -3
  184. package/src/types/entities.ts +74 -35
  185. package/src/types/operations.ts +11 -24
  186. package/src/types/props.ts +51 -16
  187. package/src/types/views.ts +45 -22
  188. package/src/ui/color-themes.ts +133 -23
  189. package/src/ui/compose/ComposeDialog.tsx +239 -17
  190. package/src/ui/compose/ComposePrompt.tsx +1 -1
  191. package/src/ui/dash/CrudPageHeader.tsx +1 -1
  192. package/src/ui/dash/ListItemRow.tsx +1 -1
  193. package/src/ui/dash/StatusBadge.tsx +3 -1
  194. package/src/ui/dash/appearance/AdvancedContent.tsx +22 -1
  195. package/src/ui/dash/appearance/ColorThemeContent.tsx +22 -2
  196. package/src/ui/dash/appearance/FontThemeContent.tsx +1 -1
  197. package/src/ui/dash/appearance/NavigationContent.tsx +5 -45
  198. package/src/ui/dash/index.ts +0 -3
  199. package/src/ui/dash/settings/AccountContent.tsx +3 -57
  200. package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
  201. package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
  202. package/src/ui/dash/settings/AvatarContent.tsx +8 -0
  203. package/src/ui/dash/settings/SessionsContent.tsx +159 -0
  204. package/src/ui/dash/settings/SettingsRootContent.tsx +45 -15
  205. package/src/ui/feed/LinkCard.tsx +89 -40
  206. package/src/ui/feed/NoteCard.tsx +39 -25
  207. package/src/ui/feed/PostStatusBadges.tsx +67 -0
  208. package/src/ui/feed/QuoteCard.tsx +38 -23
  209. package/src/ui/feed/ThreadPreview.tsx +90 -26
  210. package/src/ui/feed/TimelineFeed.tsx +3 -2
  211. package/src/ui/feed/TimelineItem.tsx +15 -6
  212. package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
  213. package/src/ui/feed/thread-preview-state.ts +61 -0
  214. package/src/ui/font-themes.ts +2 -2
  215. package/src/ui/layouts/BaseLayout.tsx +2 -2
  216. package/src/ui/layouts/SiteLayout.tsx +105 -92
  217. package/src/ui/pages/ArchivePage.tsx +923 -98
  218. package/src/ui/pages/ComposePage.tsx +54 -0
  219. package/src/ui/pages/PostPage.tsx +30 -45
  220. package/src/ui/pages/SearchPage.tsx +181 -37
  221. package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
  222. package/src/ui/shared/CollectionsSidebar.tsx +47 -37
  223. package/src/ui/shared/MediaGallery.tsx +445 -149
  224. package/src/ui/shared/PostFooter.tsx +204 -0
  225. package/src/ui/shared/StarRating.tsx +27 -0
  226. package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
  227. package/src/ui/shared/index.ts +0 -1
  228. package/dist/client/assets/url-8Dj-5CLW.js +0 -1
  229. package/src/client/media-upload.ts +0 -161
  230. package/src/client/page-slug-bridge.ts +0 -42
  231. package/src/db/migrations/0000_square_wallflower.sql +0 -118
  232. package/src/db/migrations/0001_add_search_fts.sql +0 -34
  233. package/src/db/migrations/0002_add_media_attachments.sql +0 -3
  234. package/src/db/migrations/0003_add_navigation_links.sql +0 -8
  235. package/src/db/migrations/0004_add_storage_provider.sql +0 -3
  236. package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
  237. package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
  238. package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
  239. package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
  240. package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
  241. package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
  242. package/src/db/migrations/0011_add_path_registry.sql +0 -23
  243. package/src/db/migrations/0012_add_tiptap_columns.sql +0 -2
  244. package/src/db/migrations/0013_replace_featured_with_visibility.sql +0 -8
  245. package/src/db/migrations/meta/0003_snapshot.json +0 -821
  246. package/src/lib/__tests__/sqid.test.ts +0 -65
  247. package/src/lib/sqid.ts +0 -79
  248. package/src/routes/api/__tests__/pages.test.ts +0 -218
  249. package/src/routes/api/pages.ts +0 -73
  250. package/src/routes/dash/__tests__/pages.test.ts +0 -226
  251. package/src/routes/dash/index.tsx +0 -109
  252. package/src/routes/dash/media.tsx +0 -135
  253. package/src/routes/dash/pages.tsx +0 -245
  254. package/src/routes/dash/posts.tsx +0 -338
  255. package/src/routes/dash/redirects.tsx +0 -263
  256. package/src/routes/pages/post.tsx +0 -59
  257. package/src/services/__tests__/page.test.ts +0 -298
  258. package/src/services/__tests__/path-registry.test.ts +0 -165
  259. package/src/services/__tests__/redirect.test.ts +0 -159
  260. package/src/services/page.ts +0 -216
  261. package/src/services/path-registry.ts +0 -160
  262. package/src/services/redirect.ts +0 -97
  263. package/src/ui/dash/PageForm.tsx +0 -187
  264. package/src/ui/dash/PostList.tsx +0 -95
  265. package/src/ui/dash/media/MediaListContent.tsx +0 -206
  266. package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
  267. package/src/ui/dash/pages/PagesContent.tsx +0 -75
  268. package/src/ui/dash/posts/PostForm.tsx +0 -260
  269. package/src/ui/layouts/DashLayout.tsx +0 -247
  270. package/src/ui/pages/SinglePage.tsx +0 -23
  271. package/src/ui/shared/ThreadView.tsx +0 -136
@@ -1,216 +0,0 @@
1
- /**
2
- * Page Service
3
- *
4
- * CRUD operations for standalone pages (about, now, etc.)
5
- */
6
-
7
- import { eq, desc, sql, and } from "drizzle-orm";
8
- import type { Database } from "../db/index.js";
9
- import { pages, navItems } from "../db/schema.js";
10
- import { now } from "../lib/time.js";
11
- import { render as renderMarkdown } from "../lib/markdown.js";
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";
15
-
16
- export interface PageFilters {
17
- status?: Status;
18
- }
19
-
20
- export interface PageService {
21
- getById(id: number): Promise<Page | null>;
22
- getBySlug(slug: string): Promise<Page | null>;
23
- list(filters?: PageFilters): Promise<Page[]>;
24
- listNotInNav(): Promise<Page[]>;
25
- create(data: CreatePage): Promise<Page>;
26
- update(id: number, data: UpdatePage): Promise<Page | null>;
27
- delete(id: number): Promise<boolean>;
28
- }
29
-
30
- /** Check if an error (or any of its causes) is a SQLite UNIQUE constraint violation */
31
- function isUniqueConstraintError(err: unknown): boolean {
32
- let current: unknown = err;
33
- while (current) {
34
- const msg = String(current);
35
- if (
36
- msg.includes("UNIQUE constraint") ||
37
- msg.includes("SQLITE_CONSTRAINT")
38
- ) {
39
- return true;
40
- }
41
- current =
42
- current instanceof Error && current.cause !== current
43
- ? current.cause
44
- : undefined;
45
- }
46
- return false;
47
- }
48
-
49
- export function createPageService(
50
- db: Database,
51
- pathRegistry: PathRegistryService,
52
- ): PageService {
53
- function toPage(row: typeof pages.$inferSelect): Page {
54
- return {
55
- id: row.id,
56
- slug: row.slug,
57
- title: row.title,
58
- body: row.body,
59
- bodyHtml: row.bodyHtml,
60
- status: row.status as Status,
61
- createdAt: row.createdAt,
62
- updatedAt: row.updatedAt,
63
- };
64
- }
65
-
66
- return {
67
- async getById(id) {
68
- const result = await db
69
- .select()
70
- .from(pages)
71
- .where(eq(pages.id, id))
72
- .limit(1);
73
- return result[0] ? toPage(result[0]) : null;
74
- },
75
-
76
- async getBySlug(slug) {
77
- const result = await db
78
- .select()
79
- .from(pages)
80
- .where(eq(pages.slug, slug))
81
- .limit(1);
82
- return result[0] ? toPage(result[0]) : null;
83
- },
84
-
85
- async list(filters?: PageFilters) {
86
- const conditions = [];
87
- if (filters?.status) {
88
- conditions.push(eq(pages.status, filters.status));
89
- }
90
- const rows = await db
91
- .select()
92
- .from(pages)
93
- .where(conditions.length > 0 ? and(...conditions) : undefined)
94
- .orderBy(desc(pages.createdAt));
95
- return rows.map(toPage);
96
- },
97
-
98
- async listNotInNav() {
99
- const rows = await db
100
- .select()
101
- .from(pages)
102
- .where(
103
- sql`${pages.id} NOT IN (SELECT ${navItems.pageId} FROM ${navItems} WHERE ${navItems.pageId} IS NOT NULL)`,
104
- )
105
- .orderBy(desc(pages.createdAt));
106
- return rows.map(toPage);
107
- },
108
-
109
- async create(data) {
110
- // Validate and reserve path before DB insert — throws friendly
111
- // ConflictError/ValidationError instead of a raw UNIQUE constraint error.
112
- // Uses placeholder owner ID; corrected to real ID after insert.
113
- await pathRegistry.claim(data.slug, "page", 0);
114
-
115
- const timestamp = now();
116
- const bodyHtml = data.body ? renderMarkdown(data.body) : null;
117
-
118
- let page: Page;
119
- try {
120
- const result = await db
121
- .insert(pages)
122
- .values({
123
- slug: data.slug,
124
- title: data.title ?? null,
125
- body: data.body ?? null,
126
- bodyHtml,
127
- status: data.status ?? "published",
128
- createdAt: timestamp,
129
- updatedAt: timestamp,
130
- })
131
- .returning();
132
-
133
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
134
- page = toPage(result[0]!);
135
- } catch (err) {
136
- await pathRegistry.release(data.slug);
137
- // Surface DB unique constraint failures as a friendly error
138
- if (isUniqueConstraintError(err)) {
139
- throw new ConflictError(`Slug "${data.slug}" is already in use`);
140
- }
141
- throw err;
142
- }
143
-
144
- // Update registry with actual page ID
145
- await pathRegistry.release(data.slug);
146
- await pathRegistry.claim(data.slug, "page", page.id);
147
-
148
- return page;
149
- },
150
-
151
- async update(id, data) {
152
- const existing = await this.getById(id);
153
- if (!existing) return null;
154
-
155
- const slugChanging =
156
- data.slug !== undefined && data.slug !== existing.slug;
157
-
158
- // If slug is changing, claim the new path first (validates before modifying)
159
- if (slugChanging) {
160
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by slugChanging check
161
- await pathRegistry.claim(data.slug!, "page", id);
162
- }
163
-
164
- const timestamp = now();
165
- const updates: Partial<typeof pages.$inferInsert> = {
166
- updatedAt: timestamp,
167
- };
168
-
169
- if (data.slug !== undefined) updates.slug = data.slug;
170
- if (data.title !== undefined) updates.title = data.title;
171
- if (data.status !== undefined) updates.status = data.status;
172
-
173
- if (data.body !== undefined) {
174
- updates.body = data.body;
175
- updates.bodyHtml = data.body ? renderMarkdown(data.body) : null;
176
- }
177
-
178
- // If slug changed, update related nav_items
179
- if (slugChanging) {
180
- await db
181
- .update(navItems)
182
- .set({ url: `/${data.slug}`, updatedAt: timestamp })
183
- .where(eq(navItems.pageId, id));
184
- }
185
-
186
- // If title changed, update related nav_items label
187
- if (data.title !== undefined && data.title !== existing.title) {
188
- await db
189
- .update(navItems)
190
- .set({ label: data.title ?? existing.slug, updatedAt: timestamp })
191
- .where(eq(navItems.pageId, id));
192
- }
193
-
194
- const result = await db
195
- .update(pages)
196
- .set(updates)
197
- .where(eq(pages.id, id))
198
- .returning();
199
-
200
- // Release old slug from registry after successful update
201
- if (slugChanging) {
202
- await pathRegistry.release(existing.slug);
203
- }
204
-
205
- return result[0] ? toPage(result[0]) : null;
206
- },
207
-
208
- async delete(id) {
209
- // Release path registry entries for this page
210
- await pathRegistry.releaseByOwner("page", id);
211
- // nav_items with page_id FK have ON DELETE CASCADE, so they auto-delete
212
- const result = await db.delete(pages).where(eq(pages.id, id)).returning();
213
- return result.length > 0;
214
- },
215
- };
216
- }
@@ -1,160 +0,0 @@
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
- }
@@ -1,97 +0,0 @@
1
- /**
2
- * Redirect Service
3
- *
4
- * URL redirect management for path changes
5
- */
6
-
7
- import { eq } from "drizzle-orm";
8
- import type { Database } from "../db/index.js";
9
- import { redirects } from "../db/schema.js";
10
- import { now } from "../lib/time.js";
11
- import { normalizePath } from "../lib/url.js";
12
- import type { Redirect } from "../types.js";
13
- import type { PathRegistryService } from "./path-registry.js";
14
- import { ConflictError } from "../lib/errors.js";
15
-
16
- export interface RedirectService {
17
- getByPath(fromPath: string): Promise<Redirect | null>;
18
- create(fromPath: string, toPath: string, type?: 301 | 302): Promise<Redirect>;
19
- delete(id: number): Promise<boolean>;
20
- list(): Promise<Redirect[]>;
21
- }
22
-
23
- export function createRedirectService(
24
- db: Database,
25
- pathRegistry: PathRegistryService,
26
- ): RedirectService {
27
- function toRedirect(row: typeof redirects.$inferSelect): Redirect {
28
- return {
29
- id: row.id,
30
- fromPath: row.fromPath,
31
- toPath: row.toPath,
32
- type: row.type as 301 | 302,
33
- createdAt: row.createdAt,
34
- };
35
- }
36
-
37
- return {
38
- async getByPath(fromPath) {
39
- const normalized = normalizePath(fromPath);
40
- const result = await db
41
- .select()
42
- .from(redirects)
43
- .where(eq(redirects.fromPath, normalized))
44
- .limit(1);
45
- return result[0] ? toRedirect(result[0]) : null;
46
- },
47
-
48
- async create(fromPath, toPath, type = 301) {
49
- const timestamp = now();
50
- const normalizedFrom = normalizePath(fromPath);
51
-
52
- // Check if path is claimed by a non-redirect entity
53
- const existingClaim = await pathRegistry.getByPath(normalizedFrom);
54
- if (existingClaim && existingClaim.ownerType !== "redirect") {
55
- throw new ConflictError(`Path "${normalizedFrom}" is already in use`);
56
- }
57
-
58
- // Delete existing redirect from this path if any (upsert behavior)
59
- if (existingClaim?.ownerType === "redirect") {
60
- await pathRegistry.release(normalizedFrom);
61
- }
62
- await db.delete(redirects).where(eq(redirects.fromPath, normalizedFrom));
63
-
64
- const result = await db
65
- .insert(redirects)
66
- .values({
67
- fromPath: normalizedFrom,
68
- toPath,
69
- type,
70
- createdAt: timestamp,
71
- })
72
- .returning();
73
-
74
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
75
- const redirect = toRedirect(result[0]!);
76
-
77
- await pathRegistry.claim(normalizedFrom, "redirect", redirect.id);
78
-
79
- return redirect;
80
- },
81
-
82
- async delete(id) {
83
- // Release path registry entries for this redirect
84
- await pathRegistry.releaseByOwner("redirect", id);
85
- const result = await db
86
- .delete(redirects)
87
- .where(eq(redirects.id, id))
88
- .returning();
89
- return result.length > 0;
90
- },
91
-
92
- async list() {
93
- const rows = await db.select().from(redirects);
94
- return rows.map(toRedirect);
95
- },
96
- };
97
- }
@@ -1,187 +0,0 @@
1
- /**
2
- * Page Creation/Edit Form
3
- *
4
- * For managing standalone pages (about, now, etc.)
5
- */
6
-
7
- import type { FC } from "hono/jsx";
8
- import type { Page } from "../../types.js";
9
- import { useLingui } from "@lingui/react/macro";
10
-
11
- export interface PageFormProps {
12
- page?: Page;
13
- action: string;
14
- cancelUrl?: string;
15
- }
16
-
17
- export const PageForm: FC<PageFormProps> = ({
18
- page,
19
- action,
20
- cancelUrl = "/dash/pages",
21
- }) => {
22
- const { t } = useLingui();
23
- const isEdit = !!page;
24
-
25
- const signals = JSON.stringify({
26
- title: page?.title ?? "",
27
- slug: page?.slug ?? "",
28
- body: page?.body ?? "",
29
- status: page?.status ?? "published",
30
- }).replace(/</g, "\\u003c");
31
-
32
- return (
33
- <form
34
- data-page-form
35
- {...(isEdit ? { "data-page-edit": "" } : {})}
36
- data-signals={signals}
37
- data-on:submit__prevent={`@post('${action}')`}
38
- data-indicator="_loading"
39
- class="flex flex-col gap-4"
40
- >
41
- <div id="page-form-message"></div>
42
-
43
- {/* Title */}
44
- <div class="field">
45
- <label class="label">
46
- {t({
47
- message: "Title",
48
- comment: "@context: Page form field label - title",
49
- })}
50
- </label>
51
- <input
52
- type="text"
53
- data-bind="title"
54
- class="input"
55
- placeholder={t({
56
- message: "Page title...",
57
- comment: "@context: Page title placeholder",
58
- })}
59
- required
60
- />
61
- </div>
62
-
63
- {/* Slug */}
64
- <div class="field">
65
- <label class="label">
66
- {t({
67
- message: "Slug",
68
- comment: "@context: Page form field label - URL slug",
69
- })}
70
- </label>
71
- <div class="flex items-center gap-2">
72
- <span class="text-muted-foreground">/</span>
73
- <input
74
- type="text"
75
- data-bind="slug"
76
- class="input flex-1"
77
- placeholder="about"
78
- pattern="[a-z0-9\-]+"
79
- title={t({
80
- message: "Lowercase letters, numbers, and hyphens only",
81
- comment: "@context: Page slug validation message",
82
- })}
83
- required
84
- />
85
- </div>
86
- <p class="text-xs text-muted-foreground mt-1">
87
- {t({
88
- message:
89
- "The URL path for this page. Use lowercase letters, numbers, and hyphens.",
90
- comment: "@context: Page slug helper text",
91
- })}
92
- </p>
93
- </div>
94
-
95
- {/* Body */}
96
- <div class="field">
97
- <label class="label">
98
- {t({
99
- message: "Content",
100
- comment: "@context: Page form field label - content",
101
- })}
102
- </label>
103
- <textarea
104
- data-bind="body"
105
- class="textarea min-h-48"
106
- placeholder={t({
107
- message: "Page content (Markdown supported)...",
108
- comment: "@context: Page content placeholder",
109
- })}
110
- required
111
- >
112
- {page?.body ?? ""}
113
- </textarea>
114
- </div>
115
-
116
- {/* Status */}
117
- <div class="field">
118
- <label class="label">
119
- {t({
120
- message: "Status",
121
- comment: "@context: Page form field label - publish status",
122
- })}
123
- </label>
124
- <select data-bind="status" class="select">
125
- <option
126
- value="published"
127
- selected={page?.status === "published" || !page}
128
- >
129
- {t({
130
- message: "Published",
131
- comment: "@context: Page status option - published",
132
- })}
133
- </option>
134
- <option value="draft" selected={page?.status === "draft"}>
135
- {t({
136
- message: "Draft",
137
- comment: "@context: Page status option - draft",
138
- })}
139
- </option>
140
- </select>
141
- <p class="text-xs text-muted-foreground mt-1">
142
- {t({
143
- message:
144
- "Published pages are accessible via their slug. Drafts are not visible.",
145
- comment: "@context: Page status helper text",
146
- })}
147
- </p>
148
- </div>
149
-
150
- {/* Submit */}
151
- <div class="flex gap-2">
152
- <button type="submit" class="btn" data-attr:disabled="$_loading">
153
- <svg
154
- data-show="$_loading"
155
- style="display:none"
156
- class="animate-spin size-4"
157
- xmlns="http://www.w3.org/2000/svg"
158
- viewBox="0 0 24 24"
159
- fill="none"
160
- stroke="currentColor"
161
- stroke-width="2"
162
- stroke-linecap="round"
163
- stroke-linejoin="round"
164
- role="status"
165
- >
166
- <path d="M21 12a9 9 0 1 1-6.219-8.56" />
167
- </svg>
168
- {isEdit
169
- ? t({
170
- message: "Update Page",
171
- comment: "@context: Button to update existing page",
172
- })
173
- : t({
174
- message: "Create Page",
175
- comment: "@context: Button to create new page",
176
- })}
177
- </button>
178
- <a href={cancelUrl} class="btn-outline">
179
- {t({
180
- message: "Cancel",
181
- comment: "@context: Button to cancel and go back",
182
- })}
183
- </a>
184
- </div>
185
- </form>
186
- );
187
- };