@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
@@ -18,6 +18,42 @@ import {
18
18
  MAX_MEDIA_ATTACHMENTS,
19
19
  } from "../types.js";
20
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
+ }
21
57
 
22
58
  /**
23
59
  * Post format enum schema
@@ -47,6 +83,15 @@ export const NavItemTypeSchema = z.enum(NAV_ITEM_TYPES);
47
83
  */
48
84
  export const RedirectTypeSchema = z.enum(["301", "302"]);
49
85
 
86
+ /**
87
+ * Custom URL target type enum schema
88
+ */
89
+ export const CustomUrlTargetTypeSchema = z.enum([
90
+ "post",
91
+ "collection",
92
+ "redirect",
93
+ ]);
94
+
50
95
  /**
51
96
  * Rating schema (1-5 integer)
52
97
  */
@@ -60,73 +105,160 @@ export const RatingSchema = z.coerce
60
105
  .transform((v) => (v === 0 ? undefined : v));
61
106
 
62
107
  /**
63
- * API request body schema for creating a post
108
+ * Base post fields (shared between create and update schemas)
64
109
  */
65
- export const CreatePostSchema = z.object({
110
+ const PostFieldsSchema = z.object({
66
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)),
67
124
  path: z
68
125
  .string()
69
- .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)
70
132
  .optional()
71
133
  .or(z.literal("").transform(() => undefined)),
72
- title: z.string().optional(),
73
134
  body: z.string().optional(),
135
+ bodyMarkdown: z.string().optional(),
74
136
  status: StatusSchema.optional(),
75
137
  visibility: z.enum(VISIBILITIES).optional(),
76
138
  pinned: z
77
139
  .union([z.boolean(), z.literal("on").transform(() => true)])
78
140
  .optional(),
79
- 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("")),
80
149
  quoteText: z.string().optional(),
81
150
  rating: RatingSchema,
82
151
  collectionIds: z
83
- .array(z.coerce.number().int().positive())
152
+ .array(z.string().min(1))
84
153
  .optional()
85
154
  .or(z.literal("").transform(() => undefined)),
86
- replyToId: z.string().optional(), // Sqid format
155
+ replyToId: z.string().optional(),
87
156
  publishedAt: z.number().int().positive().optional(),
88
157
  mediaIds: z.array(z.string()).max(MAX_MEDIA_ATTACHMENTS).optional(),
89
158
  mediaAlts: z.record(z.string(), z.string()).optional(),
90
159
  });
91
160
 
92
- /**
93
- * API request body schema for updating a post
94
- */
95
- 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
+ }
96
235
 
97
236
  /**
98
- * API request body schema for creating a page
237
+ * API request body schema for creating a post
99
238
  */
100
- export const CreatePageSchema = z.object({
101
- slug: z
102
- .string()
103
- .min(1)
104
- .transform(normalizeSlug)
105
- .pipe(
106
- z
107
- .string()
108
- .min(1)
109
- .regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/),
110
- ),
111
- title: z.string().optional(),
112
- body: z.string().optional(),
113
- status: StatusSchema.optional(),
114
- });
239
+ export const CreatePostSchema = refineSlugPathExclusivity(
240
+ refineCreatePostFormatShape(refineBodyExclusivity(PostFieldsSchema)),
241
+ );
115
242
 
116
243
  /**
117
- * API request body schema for updating a page
244
+ * API request body schema for updating a post
118
245
  */
119
- export const UpdatePageSchema = CreatePageSchema.partial();
246
+ export const UpdatePostSchema = refineSlugPathExclusivity(
247
+ refineBodyExclusivity(PostFieldsSchema.partial()),
248
+ );
120
249
 
121
250
  /**
122
251
  * API request body schema for creating a navigation item
123
252
  */
124
253
  export const CreateNavItemSchema = z.object({
125
254
  type: NavItemTypeSchema,
126
- label: z.string().min(1),
127
- url: z.string().min(1),
128
- pageId: z.coerce.number().int().positive().optional(),
129
- 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
+ }),
130
262
  });
131
263
 
132
264
  /**
@@ -148,11 +280,29 @@ export const CreateCollectionSchema = z.object({
148
280
  .min(1)
149
281
  .regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/),
150
282
  ),
151
- title: z.string().min(1),
152
- description: z.string().optional(),
153
- 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
+ ),
154
305
  sortOrder: SortOrderSchema.optional(),
155
- position: z.coerce.number().int().min(0).optional(),
156
306
  });
157
307
 
158
308
  /**
@@ -160,6 +310,24 @@ export const CreateCollectionSchema = z.object({
160
310
  */
161
311
  export const UpdateCollectionSchema = CreateCollectionSchema.partial();
162
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
+
163
331
  // =============================================================================
164
332
  // Auth Schemas
165
333
  // =============================================================================
@@ -168,17 +336,26 @@ export const UpdateCollectionSchema = CreateCollectionSchema.partial();
168
336
  * Setup form validation schema
169
337
  */
170
338
  export const SetupSchema = z.object({
171
- name: z.string().min(1, "Name is required"),
172
- email: z.string().email("Invalid email address"),
173
- 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),
174
348
  });
175
349
 
176
350
  /**
177
351
  * Sign-in form validation schema
178
352
  */
179
353
  export const SigninSchema = z.object({
180
- email: z.string().email("Invalid email address"),
181
- 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),
182
359
  });
183
360
 
184
361
  /**
@@ -186,7 +363,10 @@ export const SigninSchema = z.object({
186
363
  */
187
364
  export const ResetPasswordSchema = z
188
365
  .object({
189
- 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),
190
370
  confirmPassword: z.string().min(1),
191
371
  token: z.string().min(1),
192
372
  })
@@ -220,17 +400,6 @@ export function normalizeSlug(s: string): string {
220
400
  .replace(/^-|-$/g, "");
221
401
  }
222
402
 
223
- // =============================================================================
224
- // Reorder Schemas
225
- // =============================================================================
226
-
227
- /**
228
- * Reorder request schema for simple ID-based reordering
229
- */
230
- export const ReorderSchema = z.object({
231
- ids: z.array(z.coerce.number().int().positive()),
232
- });
233
-
234
403
  // =============================================================================
235
404
  // Form Data Helpers
236
405
  // =============================================================================
@@ -299,7 +468,7 @@ export function validateMediaCount(mediaIds: string[]): string | null {
299
468
  * @returns Validated data
300
469
  * @example
301
470
  * ```ts
302
- * const body = parseValidated(CreatePageSchema, await c.req.json());
471
+ * const body = parseValidated(CreatePostSchema, await c.req.json());
303
472
  * ```
304
473
  */
305
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(