@jant/core 0.3.35 → 0.3.37

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 (307) 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/module-RjUF93sV.js +716 -0
  6. package/dist/client/assets/native-48B9X9Wg.js +1 -0
  7. package/dist/client/assets/url-FWFqPJPb.js +1 -0
  8. package/dist/client/client.css +1 -1
  9. package/dist/client/client.js +4564 -3013
  10. package/dist/index.js +12885 -8161
  11. package/package.json +23 -6
  12. package/src/__tests__/helpers/app.ts +10 -10
  13. package/src/__tests__/helpers/db.ts +91 -87
  14. package/src/app.tsx +157 -31
  15. package/src/auth.ts +20 -2
  16. package/src/client/archive-nav.js +187 -0
  17. package/src/client/audio-player.ts +478 -0
  18. package/src/client/audio-processor.ts +84 -0
  19. package/src/{lib → client}/avatar-upload.ts +4 -3
  20. package/src/{lib → client}/collection-form-bridge.ts +2 -2
  21. package/src/{ui → client}/components/__tests__/jant-collection-form.test.ts +26 -9
  22. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1140 -0
  23. package/src/client/components/__tests__/jant-compose-editor.test.ts +504 -0
  24. package/src/{ui → client}/components/__tests__/jant-post-form.test.ts +37 -17
  25. package/src/{ui → client}/components/__tests__/jant-settings-avatar.test.ts +2 -2
  26. package/src/{ui → client}/components/__tests__/jant-settings-general.test.ts +3 -3
  27. package/src/client/components/collection-sidebar-types.ts +43 -0
  28. package/src/{ui → client}/components/collection-types.ts +3 -4
  29. package/src/client/components/compose-types.ts +174 -0
  30. package/src/client/components/jant-collection-form.ts +667 -0
  31. package/src/client/components/jant-collection-sidebar.ts +805 -0
  32. package/src/client/components/jant-compose-dialog.ts +2161 -0
  33. package/src/client/components/jant-compose-editor.ts +1813 -0
  34. package/src/client/components/jant-compose-fullscreen.ts +283 -0
  35. package/src/client/components/jant-media-lightbox.ts +259 -0
  36. package/src/{ui → client}/components/jant-nav-manager.ts +97 -298
  37. package/src/{ui → client}/components/jant-post-form.ts +141 -12
  38. package/src/client/components/jant-post-menu.ts +1019 -0
  39. package/src/{ui → client}/components/jant-settings-avatar.ts +3 -3
  40. package/src/{ui → client}/components/jant-settings-general.ts +38 -4
  41. package/src/client/components/jant-text-preview.ts +232 -0
  42. package/src/{ui → client}/components/nav-manager-types.ts +6 -18
  43. package/src/{ui → client}/components/post-form-template.ts +137 -38
  44. package/src/{ui → client}/components/post-form-types.ts +15 -4
  45. package/src/client/compose-bridge.ts +583 -0
  46. package/src/{lib → client}/image-processor.ts +26 -8
  47. package/src/client/lazy-slugify.ts +51 -0
  48. package/src/client/media-metadata.ts +247 -0
  49. package/src/client/multipart-upload.ts +160 -0
  50. package/src/{lib → client}/nav-manager-bridge.ts +1 -1
  51. package/src/{lib → client}/post-form-bridge.ts +53 -2
  52. package/src/{lib → client}/settings-bridge.ts +3 -15
  53. package/src/client/thread-context.ts +140 -0
  54. package/src/client/tiptap/bubble-menu.ts +205 -0
  55. package/src/client/tiptap/create-editor.ts +86 -0
  56. package/src/client/tiptap/exitable-marks.ts +73 -0
  57. package/src/client/tiptap/extensions.ts +65 -0
  58. package/src/client/tiptap/image-node.ts +482 -0
  59. package/src/client/tiptap/link-toolbar.ts +371 -0
  60. package/src/client/tiptap/more-break.ts +50 -0
  61. package/src/client/tiptap/paste-image.ts +129 -0
  62. package/src/client/tiptap/slash-commands.ts +438 -0
  63. package/src/{lib → client}/toast.ts +101 -3
  64. package/src/client/types/sortablejs.d.ts +44 -0
  65. package/src/client/upload-with-metadata.ts +54 -0
  66. package/src/client/video-processor.ts +207 -0
  67. package/src/client.ts +27 -17
  68. package/src/db/__tests__/migrations.test.ts +118 -0
  69. package/src/db/index.ts +52 -0
  70. package/src/db/migrations/0000_baseline.sql +269 -0
  71. package/src/db/migrations/0001_fts_setup.sql +31 -0
  72. package/src/db/migrations/meta/0000_snapshot.json +703 -119
  73. package/src/db/migrations/meta/0001_snapshot.json +1337 -0
  74. package/src/db/migrations/meta/_journal.json +4 -39
  75. package/src/db/schema.ts +409 -140
  76. package/src/i18n/__tests__/detect.test.ts +115 -0
  77. package/src/i18n/context.tsx +2 -2
  78. package/src/i18n/detect.ts +85 -1
  79. package/src/i18n/i18n.ts +1 -1
  80. package/src/i18n/index.ts +2 -1
  81. package/src/i18n/locales/en.po +783 -1087
  82. package/src/i18n/locales/en.ts +1 -1
  83. package/src/i18n/locales/zh-Hans.po +867 -812
  84. package/src/i18n/locales/zh-Hans.ts +1 -1
  85. package/src/i18n/locales/zh-Hant.po +878 -823
  86. package/src/i18n/locales/zh-Hant.ts +1 -1
  87. package/src/i18n/middleware.ts +6 -0
  88. package/src/index.ts +5 -7
  89. package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
  90. package/src/lib/__tests__/constants.test.ts +0 -1
  91. package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
  92. package/src/lib/__tests__/nanoid.test.ts +26 -0
  93. package/src/lib/__tests__/resolve-config.test.ts +2 -2
  94. package/src/lib/__tests__/schemas.test.ts +186 -65
  95. package/src/lib/__tests__/slug.test.ts +126 -0
  96. package/src/lib/__tests__/sse.test.ts +6 -6
  97. package/src/lib/__tests__/summary.test.ts +264 -0
  98. package/src/lib/__tests__/theme.test.ts +1 -1
  99. package/src/lib/__tests__/timeline.test.ts +33 -30
  100. package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
  101. package/src/lib/__tests__/url.test.ts +2 -2
  102. package/src/lib/__tests__/view.test.ts +140 -65
  103. package/src/lib/blurhash-placeholder.ts +102 -0
  104. package/src/lib/constants.ts +3 -1
  105. package/src/lib/emoji-catalog.ts +963 -0
  106. package/src/lib/errors.ts +11 -8
  107. package/src/lib/feed.ts +77 -31
  108. package/src/lib/html.ts +2 -1
  109. package/src/lib/icon-catalog.ts +5033 -1
  110. package/src/lib/icons.ts +3 -2
  111. package/src/lib/index.ts +0 -1
  112. package/src/lib/markdown-to-tiptap.ts +286 -0
  113. package/src/lib/media-helpers.ts +22 -12
  114. package/src/lib/nanoid.ts +29 -0
  115. package/src/lib/navigation.ts +1 -1
  116. package/src/lib/render.tsx +24 -5
  117. package/src/lib/resolve-config.ts +13 -2
  118. package/src/lib/schemas.ts +226 -58
  119. package/src/lib/search-snippet.ts +34 -0
  120. package/src/lib/slug.ts +96 -0
  121. package/src/lib/sse.ts +6 -6
  122. package/src/lib/storage.ts +115 -7
  123. package/src/lib/summary.ts +158 -0
  124. package/src/lib/theme.ts +11 -8
  125. package/src/lib/timeline.ts +76 -34
  126. package/src/lib/tiptap-render.ts +191 -0
  127. package/src/lib/tiptap-to-markdown.ts +305 -0
  128. package/src/lib/upload.ts +263 -14
  129. package/src/lib/url.ts +37 -22
  130. package/src/lib/view.ts +236 -55
  131. package/src/middleware/__tests__/auth.test.ts +191 -11
  132. package/src/middleware/__tests__/onboarding.test.ts +12 -10
  133. package/src/middleware/auth.ts +63 -9
  134. package/src/middleware/error-handler.ts +3 -3
  135. package/src/middleware/onboarding.ts +1 -1
  136. package/src/middleware/secure-headers.ts +40 -0
  137. package/src/preset.css +83 -2
  138. package/src/routes/__tests__/compose.test.ts +17 -24
  139. package/src/routes/api/__tests__/collections.test.ts +109 -61
  140. package/src/routes/api/__tests__/nav-items.test.ts +46 -29
  141. package/src/routes/api/__tests__/posts.test.ts +132 -68
  142. package/src/routes/api/__tests__/search.test.ts +15 -2
  143. package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
  144. package/src/routes/api/collections.ts +57 -31
  145. package/src/routes/api/custom-urls.ts +80 -0
  146. package/src/routes/api/export.ts +31 -0
  147. package/src/routes/api/nav-items.ts +23 -19
  148. package/src/routes/api/posts.ts +81 -62
  149. package/src/routes/api/search.ts +3 -4
  150. package/src/routes/api/upload-multipart.ts +245 -0
  151. package/src/routes/api/upload.ts +92 -24
  152. package/src/routes/auth/__tests__/setup.test.ts +20 -60
  153. package/src/routes/auth/reset.tsx +5 -4
  154. package/src/routes/auth/setup.tsx +39 -31
  155. package/src/routes/auth/signin.tsx +13 -14
  156. package/src/routes/compose.tsx +27 -63
  157. package/src/routes/dash/__tests__/settings-avatar.test.ts +44 -9
  158. package/src/routes/dash/custom-urls.tsx +414 -0
  159. package/src/routes/dash/settings.tsx +475 -99
  160. package/src/routes/feed/__tests__/rss.test.ts +22 -23
  161. package/src/routes/feed/rss.ts +6 -2
  162. package/src/routes/feed/sitemap.ts +2 -12
  163. package/src/routes/pages/__tests__/collections.test.ts +5 -6
  164. package/src/routes/pages/__tests__/featured.test.ts +36 -18
  165. package/src/routes/pages/archive.tsx +177 -37
  166. package/src/routes/pages/collection.tsx +43 -14
  167. package/src/routes/pages/collections.tsx +11 -2
  168. package/src/routes/pages/featured.tsx +27 -3
  169. package/src/routes/pages/home.tsx +15 -14
  170. package/src/routes/pages/latest.tsx +1 -11
  171. package/src/routes/pages/new.tsx +39 -0
  172. package/src/routes/pages/page.tsx +95 -49
  173. package/src/routes/pages/search.tsx +1 -1
  174. package/src/services/__tests__/api-token.test.ts +135 -0
  175. package/src/services/__tests__/collection.test.ts +275 -227
  176. package/src/services/__tests__/custom-url.test.ts +213 -0
  177. package/src/services/__tests__/media.test.ts +162 -22
  178. package/src/services/__tests__/navigation.test.ts +109 -68
  179. package/src/services/__tests__/post-timeline.test.ts +205 -32
  180. package/src/services/__tests__/post.test.ts +800 -230
  181. package/src/services/__tests__/search.test.ts +67 -10
  182. package/src/services/__tests__/settings.test.ts +3 -3
  183. package/src/services/api-token.ts +166 -0
  184. package/src/services/auth.ts +17 -2
  185. package/src/services/collection.ts +397 -131
  186. package/src/services/custom-url.ts +188 -0
  187. package/src/services/export.ts +802 -0
  188. package/src/services/index.ts +26 -19
  189. package/src/services/media.ts +100 -22
  190. package/src/services/navigation.ts +158 -47
  191. package/src/services/path.ts +339 -0
  192. package/src/services/post.ts +764 -172
  193. package/src/services/search.ts +161 -74
  194. package/src/services/settings.ts +6 -2
  195. package/src/styles/components.css +293 -62
  196. package/src/styles/tokens.css +93 -5
  197. package/src/styles/ui.css +4349 -766
  198. package/src/types/bindings.ts +8 -0
  199. package/src/types/config.ts +34 -4
  200. package/src/types/constants.ts +17 -2
  201. package/src/types/entities.ts +83 -37
  202. package/src/types/operations.ts +20 -27
  203. package/src/types/props.ts +52 -17
  204. package/src/types/views.ts +48 -24
  205. package/src/ui/color-themes.ts +133 -23
  206. package/src/ui/compose/ComposeDialog.tsx +255 -16
  207. package/src/ui/compose/ComposePrompt.tsx +1 -1
  208. package/src/ui/dash/CrudPageHeader.tsx +1 -1
  209. package/src/ui/dash/ListItemRow.tsx +1 -1
  210. package/src/ui/dash/StatusBadge.tsx +12 -2
  211. package/src/ui/dash/appearance/AdvancedContent.tsx +71 -59
  212. package/src/ui/dash/appearance/ColorThemeContent.tsx +48 -33
  213. package/src/ui/dash/appearance/FontThemeContent.tsx +65 -73
  214. package/src/ui/dash/appearance/NavigationContent.tsx +106 -135
  215. package/src/ui/dash/index.ts +0 -3
  216. package/src/ui/dash/settings/AccountContent.tsx +87 -146
  217. package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
  218. package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
  219. package/src/ui/dash/settings/AvatarContent.tsx +78 -0
  220. package/src/ui/dash/settings/GeneralContent.tsx +3 -62
  221. package/src/ui/dash/settings/SessionsContent.tsx +159 -0
  222. package/src/ui/dash/settings/SettingsRootContent.tsx +266 -0
  223. package/src/ui/feed/LinkCard.tsx +89 -40
  224. package/src/ui/feed/NoteCard.tsx +39 -25
  225. package/src/ui/feed/PostStatusBadges.tsx +67 -0
  226. package/src/ui/feed/QuoteCard.tsx +38 -23
  227. package/src/ui/feed/ThreadPreview.tsx +90 -26
  228. package/src/ui/feed/TimelineFeed.tsx +3 -2
  229. package/src/ui/feed/TimelineItem.tsx +15 -6
  230. package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
  231. package/src/ui/feed/thread-preview-state.ts +61 -0
  232. package/src/ui/font-themes.ts +2 -2
  233. package/src/ui/layouts/BaseLayout.tsx +2 -2
  234. package/src/ui/layouts/SiteLayout.tsx +116 -103
  235. package/src/ui/pages/ArchivePage.tsx +923 -95
  236. package/src/ui/pages/CollectionPage.tsx +6 -35
  237. package/src/ui/pages/CollectionsPage.tsx +2 -1
  238. package/src/ui/pages/ComposePage.tsx +54 -0
  239. package/src/ui/pages/FeaturedPage.tsx +2 -1
  240. package/src/ui/pages/HomePage.tsx +1 -1
  241. package/src/ui/pages/PostPage.tsx +30 -45
  242. package/src/ui/pages/SearchPage.tsx +182 -38
  243. package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
  244. package/src/ui/shared/CollectionsSidebar.tsx +239 -4
  245. package/src/ui/shared/MediaGallery.tsx +475 -41
  246. package/src/ui/shared/PostFooter.tsx +204 -0
  247. package/src/ui/shared/StarRating.tsx +27 -0
  248. package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
  249. package/src/ui/shared/index.ts +0 -1
  250. package/src/db/migrations/0000_square_wallflower.sql +0 -118
  251. package/src/db/migrations/0001_add_search_fts.sql +0 -34
  252. package/src/db/migrations/0002_add_media_attachments.sql +0 -3
  253. package/src/db/migrations/0003_add_navigation_links.sql +0 -8
  254. package/src/db/migrations/0004_add_storage_provider.sql +0 -3
  255. package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
  256. package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
  257. package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
  258. package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
  259. package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
  260. package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
  261. package/src/db/migrations/0011_add_path_registry.sql +0 -23
  262. package/src/db/migrations/meta/0003_snapshot.json +0 -821
  263. package/src/lib/__tests__/sqid.test.ts +0 -65
  264. package/src/lib/collections-reorder.ts +0 -28
  265. package/src/lib/compose-bridge.ts +0 -280
  266. package/src/lib/media-upload.ts +0 -148
  267. package/src/lib/sqid.ts +0 -79
  268. package/src/routes/api/__tests__/pages.test.ts +0 -218
  269. package/src/routes/api/pages.ts +0 -73
  270. package/src/routes/dash/__tests__/pages.test.ts +0 -226
  271. package/src/routes/dash/appearance.tsx +0 -240
  272. package/src/routes/dash/collections.tsx +0 -211
  273. package/src/routes/dash/index.tsx +0 -103
  274. package/src/routes/dash/media.tsx +0 -132
  275. package/src/routes/dash/pages.tsx +0 -239
  276. package/src/routes/dash/posts.tsx +0 -334
  277. package/src/routes/dash/redirects.tsx +0 -257
  278. package/src/routes/pages/post.tsx +0 -59
  279. package/src/services/__tests__/page.test.ts +0 -298
  280. package/src/services/__tests__/path-registry.test.ts +0 -165
  281. package/src/services/__tests__/redirect.test.ts +0 -159
  282. package/src/services/page.ts +0 -203
  283. package/src/services/path-registry.ts +0 -160
  284. package/src/services/redirect.ts +0 -97
  285. package/src/types/sortablejs.d.ts +0 -29
  286. package/src/ui/components/__tests__/jant-compose-dialog.test.ts +0 -512
  287. package/src/ui/components/__tests__/jant-compose-editor.test.ts +0 -272
  288. package/src/ui/components/compose-types.ts +0 -75
  289. package/src/ui/components/jant-collection-form.ts +0 -512
  290. package/src/ui/components/jant-compose-dialog.ts +0 -495
  291. package/src/ui/components/jant-compose-editor.ts +0 -814
  292. package/src/ui/dash/PageForm.tsx +0 -185
  293. package/src/ui/dash/PostList.tsx +0 -95
  294. package/src/ui/dash/appearance/AppearanceNav.tsx +0 -60
  295. package/src/ui/dash/collections/CollectionForm.tsx +0 -166
  296. package/src/ui/dash/collections/CollectionsListContent.tsx +0 -146
  297. package/src/ui/dash/collections/IconPickerGrid.tsx +0 -50
  298. package/src/ui/dash/collections/ViewCollectionContent.tsx +0 -103
  299. package/src/ui/dash/media/MediaListContent.tsx +0 -201
  300. package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
  301. package/src/ui/dash/pages/PagesContent.tsx +0 -74
  302. package/src/ui/dash/posts/PostForm.tsx +0 -248
  303. package/src/ui/dash/settings/SettingsNav.tsx +0 -52
  304. package/src/ui/layouts/DashLayout.tsx +0 -165
  305. package/src/ui/pages/SinglePage.tsx +0 -23
  306. package/src/ui/shared/ThreadView.tsx +0 -136
  307. /package/src/{ui → client}/components/settings-types.ts +0 -0
@@ -12,11 +12,48 @@ import { z } from "zod";
12
12
  import {
13
13
  FORMATS,
14
14
  STATUSES,
15
+ VISIBILITIES,
15
16
  SORT_ORDERS,
16
17
  NAV_ITEM_TYPES,
17
18
  MAX_MEDIA_ATTACHMENTS,
18
19
  } from "../types.js";
19
20
  import { ValidationError } from "./errors.js";
21
+ import { sanitizeUrl, normalizePath } from "./url.js";
22
+
23
+ // =============================================================================
24
+ // Shared Transforms
25
+ // =============================================================================
26
+
27
+ /**
28
+ * Strip C0 control characters (except HT, LF, CR) that can break rendering
29
+ * or interfere with FTS5 highlight sentinels (STX/ETX).
30
+ */
31
+ // eslint-disable-next-line no-control-regex -- intentionally matching C0 control characters
32
+ const CONTROL_CHAR_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g;
33
+
34
+ /**
35
+ * Normalize an email address for storage and lookup.
36
+ *
37
+ * @param email - Raw email input
38
+ * @returns Trimmed, lowercased email
39
+ * @example
40
+ * ```ts
41
+ * normalizeEmail(" User@Example.COM ");
42
+ * // Returns: "user@example.com"
43
+ * ```
44
+ */
45
+ export function normalizeEmail(email: string): string {
46
+ return email.trim().toLowerCase();
47
+ }
48
+
49
+ /** Trim, strip control characters, and collapse to undefined when empty. */
50
+ function sanitizeText(maxLength: number) {
51
+ return z
52
+ .string()
53
+ .trim()
54
+ .max(maxLength)
55
+ .transform((s) => s.replace(CONTROL_CHAR_RE, "") || undefined);
56
+ }
20
57
 
21
58
  /**
22
59
  * Post format enum schema
@@ -46,6 +83,15 @@ export const NavItemTypeSchema = z.enum(NAV_ITEM_TYPES);
46
83
  */
47
84
  export const RedirectTypeSchema = z.enum(["301", "302"]);
48
85
 
86
+ /**
87
+ * Custom URL target type enum schema
88
+ */
89
+ export const CustomUrlTargetTypeSchema = z.enum([
90
+ "post",
91
+ "collection",
92
+ "redirect",
93
+ ]);
94
+
49
95
  /**
50
96
  * Rating schema (1-5 integer)
51
97
  */
@@ -59,75 +105,160 @@ export const RatingSchema = z.coerce
59
105
  .transform((v) => (v === 0 ? undefined : v));
60
106
 
61
107
  /**
62
- * API request body schema for creating a post
108
+ * Base post fields (shared between create and update schemas)
63
109
  */
64
- export const CreatePostSchema = z.object({
110
+ const PostFieldsSchema = z.object({
65
111
  format: FormatSchema,
112
+ slug: z
113
+ .string()
114
+ .min(1)
115
+ .transform(normalizeSlug)
116
+ .pipe(
117
+ z
118
+ .string()
119
+ .min(1)
120
+ .regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/),
121
+ )
122
+ .optional()
123
+ .or(z.literal("").transform(() => undefined)),
66
124
  path: z
67
125
  .string()
68
- .regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\/[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/)
126
+ .min(1)
127
+ .transform(normalizePath)
128
+ .pipe(z.string().min(1))
129
+ .optional()
130
+ .or(z.literal("").transform(() => undefined)),
131
+ title: sanitizeText(300)
69
132
  .optional()
70
133
  .or(z.literal("").transform(() => undefined)),
71
- title: z.string().optional(),
72
134
  body: z.string().optional(),
135
+ bodyMarkdown: z.string().optional(),
73
136
  status: StatusSchema.optional(),
74
- featured: z
75
- .union([z.boolean(), z.literal("on").transform(() => true)])
76
- .optional(),
137
+ visibility: z.enum(VISIBILITIES).optional(),
77
138
  pinned: z
78
139
  .union([z.boolean(), z.literal("on").transform(() => true)])
79
140
  .optional(),
80
- url: z.url().optional().or(z.literal("")),
141
+ featured: z.boolean().optional(),
142
+ url: z
143
+ .url()
144
+ .refine((val) => sanitizeUrl(val) !== "", {
145
+ message: "URL must use http:, https:, or mailto: protocol",
146
+ })
147
+ .optional()
148
+ .or(z.literal("")),
81
149
  quoteText: z.string().optional(),
82
150
  rating: RatingSchema,
83
151
  collectionIds: z
84
- .array(z.coerce.number().int().positive())
152
+ .array(z.string().min(1))
85
153
  .optional()
86
154
  .or(z.literal("").transform(() => undefined)),
87
- replyToId: z.string().optional(), // Sqid format
155
+ replyToId: z.string().optional(),
88
156
  publishedAt: z.number().int().positive().optional(),
89
157
  mediaIds: z.array(z.string()).max(MAX_MEDIA_ATTACHMENTS).optional(),
90
158
  mediaAlts: z.record(z.string(), z.string()).optional(),
91
159
  });
92
160
 
93
- /**
94
- * API request body schema for updating a post
95
- */
96
- export const UpdatePostSchema = CreatePostSchema.partial();
161
+ /** Mutual exclusivity: body and bodyMarkdown cannot both be provided */
162
+ function refineBodyExclusivity<
163
+ T extends { body?: string; bodyMarkdown?: string },
164
+ >(schema: z.ZodType<T>) {
165
+ return schema.refine((data) => !(data.body && data.bodyMarkdown), {
166
+ message: "Provide either body or bodyMarkdown, not both",
167
+ path: ["bodyMarkdown"],
168
+ });
169
+ }
170
+
171
+ function hasNonEmptyText(value: string | null | undefined): boolean {
172
+ return typeof value === "string" && value.trim().length > 0;
173
+ }
174
+
175
+ function refineCreatePostFormatShape<
176
+ T extends { format: string; url?: string; quoteText?: string },
177
+ >(schema: z.ZodType<T>) {
178
+ return schema.superRefine((data, ctx) => {
179
+ const hasUrl = hasNonEmptyText(data.url);
180
+ const hasQuoteText = hasNonEmptyText(data.quoteText);
181
+
182
+ if (data.format === "note") {
183
+ if (hasUrl) {
184
+ ctx.addIssue({
185
+ code: z.ZodIssueCode.custom,
186
+ path: ["url"],
187
+ message: "Notes can't include a URL.",
188
+ });
189
+ }
190
+ if (hasQuoteText) {
191
+ ctx.addIssue({
192
+ code: z.ZodIssueCode.custom,
193
+ path: ["quoteText"],
194
+ message: "Notes can't include quoted text.",
195
+ });
196
+ }
197
+ }
198
+
199
+ if (data.format === "link") {
200
+ if (!hasUrl) {
201
+ ctx.addIssue({
202
+ code: z.ZodIssueCode.custom,
203
+ path: ["url"],
204
+ message: "Link posts need a URL.",
205
+ });
206
+ }
207
+ if (hasQuoteText) {
208
+ ctx.addIssue({
209
+ code: z.ZodIssueCode.custom,
210
+ path: ["quoteText"],
211
+ message: "Link posts can't include quoted text.",
212
+ });
213
+ }
214
+ }
215
+
216
+ if (data.format === "quote" && !hasQuoteText) {
217
+ ctx.addIssue({
218
+ code: z.ZodIssueCode.custom,
219
+ path: ["quoteText"],
220
+ message: "Quote posts need quoted text.",
221
+ });
222
+ }
223
+ });
224
+ }
225
+
226
+ /** Mutual exclusivity: slug and path cannot both be provided */
227
+ function refineSlugPathExclusivity<T extends { slug?: string; path?: string }>(
228
+ schema: z.ZodType<T>,
229
+ ) {
230
+ return schema.refine((data) => !(data.slug && data.path), {
231
+ message: "Provide either slug or path, not both",
232
+ path: ["path"],
233
+ });
234
+ }
97
235
 
98
236
  /**
99
- * API request body schema for creating a page
237
+ * API request body schema for creating a post
100
238
  */
101
- export const CreatePageSchema = z.object({
102
- slug: z
103
- .string()
104
- .min(1)
105
- .transform(normalizeSlug)
106
- .pipe(
107
- z
108
- .string()
109
- .min(1)
110
- .regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/),
111
- ),
112
- title: z.string().optional(),
113
- body: z.string().optional(),
114
- status: StatusSchema.optional(),
115
- });
239
+ export const CreatePostSchema = refineSlugPathExclusivity(
240
+ refineCreatePostFormatShape(refineBodyExclusivity(PostFieldsSchema)),
241
+ );
116
242
 
117
243
  /**
118
- * API request body schema for updating a page
244
+ * API request body schema for updating a post
119
245
  */
120
- export const UpdatePageSchema = CreatePageSchema.partial();
246
+ export const UpdatePostSchema = refineSlugPathExclusivity(
247
+ refineBodyExclusivity(PostFieldsSchema.partial()),
248
+ );
121
249
 
122
250
  /**
123
251
  * API request body schema for creating a navigation item
124
252
  */
125
253
  export const CreateNavItemSchema = z.object({
126
254
  type: NavItemTypeSchema,
127
- label: z.string().min(1),
128
- url: z.string().min(1),
129
- pageId: z.coerce.number().int().positive().optional(),
130
- position: z.coerce.number().int().min(0).optional(),
255
+ label: sanitizeText(100).pipe(z.string().min(1)),
256
+ url: z
257
+ .string()
258
+ .min(1)
259
+ .refine((val) => sanitizeUrl(val) !== "", {
260
+ message: "URL must use http:, https:, or mailto: protocol",
261
+ }),
131
262
  });
132
263
 
133
264
  /**
@@ -149,11 +280,29 @@ export const CreateCollectionSchema = z.object({
149
280
  .min(1)
150
281
  .regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/),
151
282
  ),
152
- title: z.string().min(1),
153
- description: z.string().optional(),
154
- icon: z.string().optional(),
283
+ title: sanitizeText(300).pipe(z.string().min(1)),
284
+ description: sanitizeText(500)
285
+ .optional()
286
+ .or(z.literal("").transform(() => undefined)),
287
+ icon: z
288
+ .string()
289
+ .optional()
290
+ .refine(
291
+ (val) => {
292
+ if (!val || !val.startsWith("{")) return true;
293
+ try {
294
+ const parsed = JSON.parse(val) as Record<string, unknown>;
295
+ if (typeof parsed.color === "string") {
296
+ return /^#[0-9a-f]{3,6}$/i.test(parsed.color);
297
+ }
298
+ return true;
299
+ } catch {
300
+ return true; // non-JSON icons (legacy emoji) are fine
301
+ }
302
+ },
303
+ { message: "Icon color must be a valid hex color (e.g. #fff, #ff0000)" },
304
+ ),
155
305
  sortOrder: SortOrderSchema.optional(),
156
- position: z.coerce.number().int().min(0).optional(),
157
306
  });
158
307
 
159
308
  /**
@@ -161,6 +310,24 @@ export const CreateCollectionSchema = z.object({
161
310
  */
162
311
  export const UpdateCollectionSchema = CreateCollectionSchema.partial();
163
312
 
313
+ /**
314
+ * API request body schema for creating a custom URL
315
+ */
316
+ export const CreateCustomUrlSchema = z.object({
317
+ path: z
318
+ .string()
319
+ .min(1)
320
+ .max(512)
321
+ .regex(
322
+ /^\/[a-z0-9][a-z0-9\-/]*$/,
323
+ "Path must start with / and contain only lowercase alphanumeric characters, hyphens, and slashes",
324
+ ),
325
+ targetType: CustomUrlTargetTypeSchema,
326
+ targetId: z.string().optional(),
327
+ toPath: z.string().optional(),
328
+ redirectType: RedirectTypeSchema.optional(),
329
+ });
330
+
164
331
  // =============================================================================
165
332
  // Auth Schemas
166
333
  // =============================================================================
@@ -169,17 +336,26 @@ export const UpdateCollectionSchema = CreateCollectionSchema.partial();
169
336
  * Setup form validation schema
170
337
  */
171
338
  export const SetupSchema = z.object({
172
- name: z.string().min(1, "Name is required"),
173
- email: z.string().email("Invalid email address"),
174
- password: z.string().min(8, "Password must be at least 8 characters"),
339
+ siteName: z.string().min(1, "Site name is required"),
340
+ email: z
341
+ .string()
342
+ .transform(normalizeEmail)
343
+ .pipe(z.string().email("Invalid email address")),
344
+ password: z
345
+ .string()
346
+ .min(8, "Password must be at least 8 characters")
347
+ .max(128),
175
348
  });
176
349
 
177
350
  /**
178
351
  * Sign-in form validation schema
179
352
  */
180
353
  export const SigninSchema = z.object({
181
- email: z.string().email("Invalid email address"),
182
- password: z.string().min(1, "Password is required"),
354
+ email: z
355
+ .string()
356
+ .transform(normalizeEmail)
357
+ .pipe(z.string().email("Invalid email address")),
358
+ password: z.string().min(1, "Password is required").max(128),
183
359
  });
184
360
 
185
361
  /**
@@ -187,7 +363,10 @@ export const SigninSchema = z.object({
187
363
  */
188
364
  export const ResetPasswordSchema = z
189
365
  .object({
190
- password: z.string().min(8, "Password must be at least 8 characters"),
366
+ password: z
367
+ .string()
368
+ .min(8, "Password must be at least 8 characters")
369
+ .max(128),
191
370
  confirmPassword: z.string().min(1),
192
371
  token: z.string().min(1),
193
372
  })
@@ -221,17 +400,6 @@ export function normalizeSlug(s: string): string {
221
400
  .replace(/^-|-$/g, "");
222
401
  }
223
402
 
224
- // =============================================================================
225
- // Reorder Schemas
226
- // =============================================================================
227
-
228
- /**
229
- * Reorder request schema for simple ID-based reordering
230
- */
231
- export const ReorderSchema = z.object({
232
- ids: z.array(z.coerce.number().int().positive()),
233
- });
234
-
235
403
  // =============================================================================
236
404
  // Form Data Helpers
237
405
  // =============================================================================
@@ -300,7 +468,7 @@ export function validateMediaCount(mediaIds: string[]): string | null {
300
468
  * @returns Validated data
301
469
  * @example
302
470
  * ```ts
303
- * const body = parseValidated(CreatePageSchema, await c.req.json());
471
+ * const body = parseValidated(CreatePostSchema, await c.req.json());
304
472
  * ```
305
473
  */
306
474
  export function parseValidated<T>(schema: z.ZodSchema<T>, data: unknown): T {
@@ -0,0 +1,34 @@
1
+ import { escapeHtml } from "./html.js";
2
+
3
+ /**
4
+ * Search Snippet Utilities
5
+ *
6
+ * Application-layer text highlighting for search results.
7
+ * Used for fields not covered by FTS5 snippet() (title, quoteText).
8
+ *
9
+ * @param text - Plain text to highlight (already stored content, not user input)
10
+ * @param query - Raw search query string (space-separated terms)
11
+ * @returns HTML-safe string with matched terms wrapped in <mark> tags; escaped original if no terms
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * highlightText("Hello world", "world")
16
+ * // → "Hello <mark>world</mark>"
17
+ *
18
+ * highlightText("TypeScript basics", "type script")
19
+ * // → "<mark>TypeScript</mark> basics"
20
+ * ```
21
+ */
22
+ export function highlightText(text: string, query: string): string {
23
+ const escaped = escapeHtml(text);
24
+ const terms = query
25
+ .trim()
26
+ .split(/\s+/)
27
+ .filter((t) => t.length > 0)
28
+ .map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
29
+
30
+ if (terms.length === 0) return escaped;
31
+
32
+ const pattern = new RegExp(`(${terms.join("|")})`, "gi");
33
+ return escaped.replace(pattern, "<mark>$1</mark>");
34
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Slug Generation
3
+ *
4
+ * Generates URL slugs for posts with conflict resolution.
5
+ * Handles three cases: user-provided slug, title-based slug, and random-only slug.
6
+ */
7
+
8
+ import { slugify } from "./url.js";
9
+ import { generateRandomId } from "./nanoid.js";
10
+ import { isReservedPath } from "./constants.js";
11
+ import { ValidationError, ConflictError } from "./errors.js";
12
+
13
+ const MAX_RETRIES = 10;
14
+
15
+ export interface SlugOptions {
16
+ /** User-provided slug (takes priority) */
17
+ slug?: string;
18
+ /** Post title (used for slug generation if no explicit slug) */
19
+ title?: string;
20
+ /** Length of random IDs */
21
+ idLength: number;
22
+ /** Callback to check if a slug is available (checks posts.slug + custom_urls.path) */
23
+ isAvailable: (slug: string) => Promise<boolean>;
24
+ }
25
+
26
+ /**
27
+ * Generates a post slug with conflict resolution.
28
+ *
29
+ * Resolution order:
30
+ * 1. User-provided slug → validate format, check reserved, check availability
31
+ * 2. Title exists → slugify(title), append -{randomId} if conflict
32
+ * 3. No title → pure random ID
33
+ *
34
+ * @param opts - Slug generation options
35
+ * @returns A unique, valid slug
36
+ *
37
+ * @example
38
+ * ```ts
39
+ * // User-provided
40
+ * await generatePostSlug({ slug: "my-post", idLength: 5, isAvailable: check });
41
+ *
42
+ * // Title-based
43
+ * await generatePostSlug({ title: "Hello World", idLength: 5, isAvailable: check });
44
+ *
45
+ * // Random
46
+ * await generatePostSlug({ idLength: 5, isAvailable: check });
47
+ * ```
48
+ */
49
+ export async function generatePostSlug(opts: SlugOptions): Promise<string> {
50
+ const { slug, title, idLength, isAvailable } = opts;
51
+
52
+ // Case 1: User-provided slug
53
+ if (slug) {
54
+ if (isReservedPath(slug)) {
55
+ throw new ValidationError(
56
+ `Slug "${slug}" is reserved and cannot be used`,
57
+ );
58
+ }
59
+ const available = await isAvailable(slug);
60
+ if (!available) {
61
+ throw new ConflictError(`Slug "${slug}" is already in use`);
62
+ }
63
+ return slug;
64
+ }
65
+
66
+ // Case 2: Title-based slug
67
+ if (title) {
68
+ const base = slugify(title);
69
+ if (base && !isReservedPath(base)) {
70
+ const available = await isAvailable(base);
71
+ if (available) return base;
72
+ }
73
+
74
+ // Append random suffix on conflict or reserved base
75
+ for (let i = 0; i < MAX_RETRIES; i++) {
76
+ const candidate = `${base || generateRandomId(idLength)}-${generateRandomId(idLength)}`;
77
+ if (!isReservedPath(candidate) && (await isAvailable(candidate))) {
78
+ return candidate;
79
+ }
80
+ }
81
+ throw new ConflictError(
82
+ "Could not generate a unique slug after multiple attempts",
83
+ );
84
+ }
85
+
86
+ // Case 3: Pure random
87
+ for (let i = 0; i < MAX_RETRIES; i++) {
88
+ const candidate = generateRandomId(idLength);
89
+ if (!isReservedPath(candidate) && (await isAvailable(candidate))) {
90
+ return candidate;
91
+ }
92
+ }
93
+ throw new ConflictError(
94
+ "Could not generate a unique slug after multiple attempts",
95
+ );
96
+ }
package/src/lib/sse.ts CHANGED
@@ -91,7 +91,7 @@ export interface SSEStream {
91
91
  *
92
92
  * @example
93
93
  * ```ts
94
- * await stream.redirect('/dash/posts');
94
+ * await stream.redirect('/settings');
95
95
  * ```
96
96
  */
97
97
  redirect(url: string): void;
@@ -132,7 +132,7 @@ export interface SSEStream {
132
132
  /** Build the redirect script tag for Datastar patch-elements */
133
133
  function buildRedirectScript(url: string): string {
134
134
  const escapedUrl = url.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
135
- return `<script data-effect="el.remove()">window.location.href='${escapedUrl}'</script>`;
135
+ return `<div data-init="window.location.href='${escapedUrl}'; el.remove()"></div>`;
136
136
  }
137
137
 
138
138
  /** Build a toast notification HTML element */
@@ -147,7 +147,7 @@ function buildToastHtml(message: string, type: "success" | "error"): string {
147
147
  .replace(/&/g, "&amp;")
148
148
  .replace(/</g, "&lt;")
149
149
  .replace(/>/g, "&gt;");
150
- return `<div class="toast ${cls}" data-init="setTimeout(() => { el.classList.add('toast-out'); el.addEventListener('animationend', () => el.remove()) }, 3000)">${icon}<span>${escapedMessage}</span>${closeBtn}</div>`;
150
+ return `<div class="toast ${cls}" data-init="el.closest('[popover]')?.showPopover(); setTimeout(() => { el.classList.add('toast-out'); el.addEventListener('animationend', () => el.remove()) }, 3000)">${icon}<span>${escapedMessage}</span>${closeBtn}</div>`;
151
151
  }
152
152
 
153
153
  // ---------------------------------------------------------------------------
@@ -193,7 +193,7 @@ function formatEvent(eventType: string, dataLines: readonly string[]): string {
193
193
  * // With cookie forwarding (for auth)
194
194
  * app.post("/signin", (c) => {
195
195
  * return sse(c, async (stream) => {
196
- * await stream.redirect('/dash');
196
+ * await stream.redirect('/settings');
197
197
  * }, { headers: { 'Set-Cookie': cookieValue } });
198
198
  * });
199
199
  * ```
@@ -304,10 +304,10 @@ export function sse(
304
304
  *
305
305
  * @example
306
306
  * ```ts
307
- * return dsRedirect("/dash/posts");
307
+ * return dsRedirect("/settings");
308
308
  *
309
309
  * // With cookie forwarding (for auth)
310
- * return dsRedirect("/dash", { headers: authResponse.headers });
310
+ * return dsRedirect("/settings", { headers: authResponse.headers });
311
311
  * ```
312
312
  */
313
313
  export function dsRedirect(