@jant/core 0.3.6 → 0.3.7

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 (81) hide show
  1. package/dist/app.d.ts.map +1 -1
  2. package/dist/app.js +7 -21
  3. package/dist/db/schema.d.ts +36 -0
  4. package/dist/db/schema.d.ts.map +1 -1
  5. package/dist/db/schema.js +2 -0
  6. package/dist/i18n/locales/en.d.ts.map +1 -1
  7. package/dist/i18n/locales/en.js +1 -1
  8. package/dist/i18n/locales/zh-Hans.d.ts.map +1 -1
  9. package/dist/i18n/locales/zh-Hans.js +1 -1
  10. package/dist/i18n/locales/zh-Hant.d.ts.map +1 -1
  11. package/dist/i18n/locales/zh-Hant.js +1 -1
  12. package/dist/index.d.ts +2 -2
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +1 -1
  15. package/dist/lib/schemas.d.ts +17 -0
  16. package/dist/lib/schemas.d.ts.map +1 -1
  17. package/dist/lib/schemas.js +32 -2
  18. package/dist/lib/sse.d.ts +3 -3
  19. package/dist/lib/sse.d.ts.map +1 -1
  20. package/dist/lib/sse.js +7 -8
  21. package/dist/routes/api/posts.d.ts.map +1 -1
  22. package/dist/routes/api/posts.js +101 -5
  23. package/dist/routes/dash/media.js +38 -0
  24. package/dist/routes/dash/posts.d.ts.map +1 -1
  25. package/dist/routes/dash/posts.js +45 -6
  26. package/dist/routes/feed/rss.d.ts.map +1 -1
  27. package/dist/routes/feed/rss.js +10 -1
  28. package/dist/routes/pages/home.d.ts.map +1 -1
  29. package/dist/routes/pages/home.js +37 -4
  30. package/dist/routes/pages/post.d.ts.map +1 -1
  31. package/dist/routes/pages/post.js +28 -2
  32. package/dist/services/collection.d.ts +1 -0
  33. package/dist/services/collection.d.ts.map +1 -1
  34. package/dist/services/collection.js +13 -0
  35. package/dist/services/media.d.ts +7 -0
  36. package/dist/services/media.d.ts.map +1 -1
  37. package/dist/services/media.js +54 -1
  38. package/dist/theme/components/MediaGallery.d.ts +13 -0
  39. package/dist/theme/components/MediaGallery.d.ts.map +1 -0
  40. package/dist/theme/components/MediaGallery.js +107 -0
  41. package/dist/theme/components/PostForm.d.ts +6 -1
  42. package/dist/theme/components/PostForm.d.ts.map +1 -1
  43. package/dist/theme/components/PostForm.js +158 -2
  44. package/dist/theme/components/index.d.ts +1 -0
  45. package/dist/theme/components/index.d.ts.map +1 -1
  46. package/dist/theme/components/index.js +1 -0
  47. package/dist/types.d.ts +24 -0
  48. package/dist/types.d.ts.map +1 -1
  49. package/dist/types.js +27 -0
  50. package/package.json +1 -1
  51. package/src/__tests__/helpers/app.ts +6 -1
  52. package/src/__tests__/helpers/db.ts +10 -0
  53. package/src/app.tsx +7 -25
  54. package/src/db/migrations/0002_add_media_attachments.sql +3 -0
  55. package/src/db/schema.ts +2 -0
  56. package/src/i18n/locales/en.po +81 -37
  57. package/src/i18n/locales/en.ts +1 -1
  58. package/src/i18n/locales/zh-Hans.po +81 -37
  59. package/src/i18n/locales/zh-Hans.ts +1 -1
  60. package/src/i18n/locales/zh-Hant.po +81 -37
  61. package/src/i18n/locales/zh-Hant.ts +1 -1
  62. package/src/index.ts +8 -1
  63. package/src/lib/__tests__/schemas.test.ts +89 -1
  64. package/src/lib/__tests__/sse.test.ts +13 -1
  65. package/src/lib/schemas.ts +47 -1
  66. package/src/lib/sse.ts +10 -11
  67. package/src/routes/api/__tests__/posts.test.ts +239 -0
  68. package/src/routes/api/posts.ts +134 -5
  69. package/src/routes/dash/media.tsx +50 -0
  70. package/src/routes/dash/posts.tsx +79 -7
  71. package/src/routes/feed/rss.ts +14 -1
  72. package/src/routes/pages/home.tsx +80 -36
  73. package/src/routes/pages/post.tsx +36 -3
  74. package/src/services/__tests__/collection.test.ts +102 -0
  75. package/src/services/__tests__/media.test.ts +248 -0
  76. package/src/services/collection.ts +19 -0
  77. package/src/services/media.ts +76 -1
  78. package/src/theme/components/MediaGallery.tsx +128 -0
  79. package/src/theme/components/PostForm.tsx +170 -2
  80. package/src/theme/components/index.ts +1 -0
  81. package/src/types.ts +36 -0
@@ -26,7 +26,7 @@ msgstr "← 返回收藏夾"
26
26
  #. @context: Navigation link
27
27
  #. @context: Navigation link
28
28
  #: src/routes/pages/collection.tsx:76
29
- #: src/routes/pages/post.tsx:51
29
+ #: src/routes/pages/post.tsx:63
30
30
  msgid "← Back to home"
31
31
  msgstr "← 返回首頁"
32
32
 
@@ -50,6 +50,11 @@ msgstr "302(臨時)"
50
50
  msgid "Account"
51
51
  msgstr "帳戶"
52
52
 
53
+ #. @context: Button to open media picker
54
+ #: src/theme/components/PostForm.tsx:178
55
+ msgid "Add Media"
56
+ msgstr ""
57
+
53
58
  #. @context: Archive filter - all types
54
59
  #: src/routes/pages/archive.tsx:115
55
60
  msgid "All"
@@ -63,7 +68,7 @@ msgstr "外觀"
63
68
  #. @context: Archive page title
64
69
  #. @context: Navigation link to archive page
65
70
  #: src/routes/pages/archive.tsx:102
66
- #: src/routes/pages/home.tsx:30
71
+ #: src/routes/pages/home.tsx:40
67
72
  msgid "Archive"
68
73
  msgstr "檔案館"
69
74
 
@@ -71,7 +76,7 @@ msgstr "檔案館"
71
76
  #. @context: Post type label - article
72
77
  #. @context: Post type option
73
78
  #: src/routes/pages/archive.tsx:28
74
- #: src/theme/components/PostForm.tsx:48
79
+ #: src/theme/components/PostForm.tsx:67
75
80
  #: src/theme/components/TypeBadge.tsx:20
76
81
  msgid "Article"
77
82
  msgstr "文章"
@@ -81,6 +86,11 @@ msgstr "文章"
81
86
  msgid "Articles"
82
87
  msgstr "文章"
83
88
 
89
+ #. @context: Hint for image post type media requirement
90
+ #: src/theme/components/PostForm.tsx:130
91
+ msgid "At least 1 image required for image posts."
92
+ msgstr ""
93
+
84
94
  #. @context: Button to go back to media list
85
95
  #: src/routes/dash/media.tsx:245
86
96
  msgid "Back"
@@ -104,7 +114,7 @@ msgstr "回到首頁"
104
114
  #: src/routes/dash/collections.tsx:345
105
115
  #: src/routes/dash/redirects.tsx:167
106
116
  #: src/theme/components/PageForm.tsx:161
107
- #: src/theme/components/PostForm.tsx:181
117
+ #: src/theme/components/PostForm.tsx:312
108
118
  msgid "Cancel"
109
119
  msgstr "取消"
110
120
 
@@ -127,6 +137,11 @@ msgstr "點擊圖片以查看完整大小"
127
137
  msgid "Collections"
128
138
  msgstr "收藏夾"
129
139
 
140
+ #. @context: Post form field - assign to collections
141
+ #: src/theme/components/PostForm.tsx:261
142
+ msgid "Collections (optional)"
143
+ msgstr ""
144
+
130
145
  #. @context: Appearance settings heading
131
146
  #: src/routes/dash/settings.tsx:301
132
147
  msgid "Color theme"
@@ -143,14 +158,14 @@ msgid "Confirm New Password"
143
158
  msgstr "確認新密碼"
144
159
 
145
160
  #. @context: Password reset form field
146
- #: src/app.tsx:505
161
+ #: src/app.tsx:487
147
162
  msgid "Confirm Password"
148
163
  msgstr "確認密碼"
149
164
 
150
165
  #. @context: Page form field label - content
151
166
  #. @context: Post form field
152
167
  #: src/theme/components/PageForm.tsx:95
153
- #: src/theme/components/PostForm.tsx:84
168
+ #: src/theme/components/PostForm.tsx:103
154
169
  msgid "Content"
155
170
  msgstr "內容"
156
171
 
@@ -197,7 +212,7 @@ msgid "Current Password"
197
212
  msgstr "當前密碼"
198
213
 
199
214
  #. @context: Post form field
200
- #: src/theme/components/PostForm.tsx:154
215
+ #: src/theme/components/PostForm.tsx:285
201
216
  msgid "Custom Path (optional)"
202
217
  msgstr "自訂路徑(選填)"
203
218
 
@@ -252,11 +267,16 @@ msgstr "示範帳戶已預填。只需點擊登入。"
252
267
  msgid "Description (optional)"
253
268
  msgstr "描述(可選)"
254
269
 
270
+ #. @context: Close media picker button
271
+ #: src/theme/components/PostForm.tsx:334
272
+ msgid "Done"
273
+ msgstr ""
274
+
255
275
  #. @context: Page status option - draft
256
276
  #. @context: Post visibility badge - draft
257
277
  #. @context: Post visibility option
258
278
  #: src/theme/components/PageForm.tsx:132
259
- #: src/theme/components/PostForm.tsx:143
279
+ #: src/theme/components/PostForm.tsx:249
260
280
  #: src/theme/components/VisibilityBadge.tsx:38
261
281
  msgid "Draft"
262
282
  msgstr "草稿"
@@ -266,6 +286,11 @@ msgstr "草稿"
266
286
  msgid "Drafts"
267
287
  msgstr "草稿"
268
288
 
289
+ #. @context: Source name placeholder
290
+ #: src/theme/components/PostForm.tsx:214
291
+ msgid "e.g. The Verge, John Doe"
292
+ msgstr ""
293
+
269
294
  #. @context: Button to edit collection
270
295
  #. @context: Button to edit collection
271
296
  #. @context: Button to edit item
@@ -277,7 +302,7 @@ msgstr "草稿"
277
302
  #: src/routes/dash/collections.tsx:210
278
303
  #: src/routes/dash/pages.tsx:64
279
304
  #: src/routes/dash/pages.tsx:134
280
- #: src/routes/dash/posts.tsx:124
305
+ #: src/routes/dash/posts.tsx:142
281
306
  #: src/theme/components/ActionButtons.tsx:72
282
307
  #: src/theme/components/PostList.tsx:46
283
308
  msgid "Edit"
@@ -294,7 +319,7 @@ msgid "Edit Page"
294
319
  msgstr "編輯頁面"
295
320
 
296
321
  #. @context: Page heading
297
- #: src/routes/dash/posts.tsx:153
322
+ #: src/routes/dash/posts.tsx:185
298
323
  msgid "Edit Post"
299
324
  msgstr "編輯文章"
300
325
 
@@ -306,15 +331,15 @@ msgid "Email"
306
331
  msgstr "電子郵件"
307
332
 
308
333
  #. @context: Password reset page description
309
- #: src/app.tsx:475
334
+ #: src/app.tsx:457
310
335
  msgid "Enter your new password."
311
336
  msgstr "請輸入您的新密碼。"
312
337
 
313
338
  #. @context: Post visibility badge
314
339
  #. @context: Post visibility badge - featured
315
340
  #. @context: Post visibility option
316
- #: src/routes/pages/home.tsx:75
317
- #: src/theme/components/PostForm.tsx:131
341
+ #: src/routes/pages/home.tsx:90
342
+ #: src/theme/components/PostForm.tsx:237
318
343
  #: src/theme/components/VisibilityBadge.tsx:26
319
344
  msgid "Featured"
320
345
  msgstr "精選"
@@ -345,7 +370,7 @@ msgstr "一般設定"
345
370
  #. @context: Post type label - image
346
371
  #. @context: Post type option
347
372
  #: src/routes/pages/archive.tsx:37
348
- #: src/theme/components/PostForm.tsx:57
373
+ #: src/theme/components/PostForm.tsx:76
349
374
  #: src/theme/components/TypeBadge.tsx:29
350
375
  msgid "Image"
351
376
  msgstr "圖片"
@@ -361,7 +386,7 @@ msgid "Images are automatically optimized: resized to max 1920px, converted to W
361
386
  msgstr "圖片會自動優化:調整大小至最大 1920 像素,轉換為 WebP 格式,並去除元數據。"
362
387
 
363
388
  #. @context: Password reset error heading
364
- #: src/app.tsx:540
389
+ #: src/app.tsx:522
365
390
  msgid "Invalid or Expired Link"
366
391
  msgstr "無效或已過期的連結"
367
392
 
@@ -374,7 +399,7 @@ msgstr "語言"
374
399
  #. @context: Post type label - link
375
400
  #. @context: Post type option
376
401
  #: src/routes/pages/archive.tsx:32
377
- #: src/theme/components/PostForm.tsx:51
402
+ #: src/theme/components/PostForm.tsx:70
378
403
  #: src/theme/components/TypeBadge.tsx:24
379
404
  msgid "Link"
380
405
  msgstr "連結"
@@ -389,6 +414,11 @@ msgstr "連結"
389
414
  msgid "Load more"
390
415
  msgstr "載入更多"
391
416
 
417
+ #. @context: Loading state for media picker
418
+ #: src/theme/components/PostForm.tsx:345
419
+ msgid "Loading..."
420
+ msgstr ""
421
+
392
422
  #. @context: Page path validation message
393
423
  #: src/theme/components/PageForm.tsx:76
394
424
  msgid "Lowercase letters, numbers, and hyphens only"
@@ -401,7 +431,9 @@ msgstr "Markdown"
401
431
 
402
432
  #. @context: Dashboard navigation - media library
403
433
  #. @context: Media main heading
434
+ #. @context: Post form field - media attachments
404
435
  #: src/routes/dash/media.tsx:138
436
+ #: src/theme/components/PostForm.tsx:121
405
437
  #: src/theme/layouts/DashLayout.tsx:105
406
438
  msgid "Media"
407
439
  msgstr "媒體"
@@ -439,7 +471,7 @@ msgstr "新頁面"
439
471
 
440
472
  #. @context: Password form field
441
473
  #. @context: Password reset form field
442
- #: src/app.tsx:489
474
+ #: src/app.tsx:471
443
475
  #: src/routes/dash/settings.tsx:417
444
476
  msgid "New Password"
445
477
  msgstr "新密碼"
@@ -498,7 +530,7 @@ msgstr "此集合中沒有帖子。"
498
530
 
499
531
  #. @context: Empty state message on home page
500
532
  #. @context: Empty state message when no posts exist
501
- #: src/routes/pages/home.tsx:44
533
+ #: src/routes/pages/home.tsx:54
502
534
  #: src/theme/components/PostList.tsx:25
503
535
  msgid "No posts yet."
504
536
  msgstr "尚未有帖子。"
@@ -517,7 +549,7 @@ msgstr "未找到結果。"
517
549
  #. @context: Post type label - note
518
550
  #. @context: Post type option
519
551
  #: src/routes/pages/archive.tsx:27
520
- #: src/theme/components/PostForm.tsx:45
552
+ #: src/theme/components/PostForm.tsx:64
521
553
  #: src/theme/components/TypeBadge.tsx:19
522
554
  msgid "Note"
523
555
  msgstr "備註"
@@ -574,18 +606,18 @@ msgstr "路徑"
574
606
 
575
607
  #. @context: Link to individual post in thread
576
608
  #. @context: Link to permanent URL of post
577
- #: src/routes/pages/post.tsx:41
609
+ #: src/routes/pages/post.tsx:53
578
610
  #: src/theme/components/ThreadView.tsx:68
579
611
  msgid "Permalink"
580
612
  msgstr "永久鏈接"
581
613
 
582
614
  #. @context: Default post title
583
- #: src/routes/dash/posts.tsx:113
615
+ #: src/routes/dash/posts.tsx:131
584
616
  msgid "Post"
585
617
  msgstr "文章"
586
618
 
587
619
  #. @context: Post title placeholder
588
- #: src/theme/components/PostForm.tsx:74
620
+ #: src/theme/components/PostForm.tsx:93
589
621
  msgid "Post title..."
590
622
  msgstr "文章標題..."
591
623
 
@@ -624,7 +656,7 @@ msgid "Profile"
624
656
  msgstr "個人資料"
625
657
 
626
658
  #. @context: Button to publish new post
627
- #: src/theme/components/PostForm.tsx:175
659
+ #: src/theme/components/PostForm.tsx:306
628
660
  msgid "Publish"
629
661
  msgstr "發佈"
630
662
 
@@ -651,7 +683,7 @@ msgid "Quiet"
651
683
  msgstr "安靜"
652
684
 
653
685
  #. @context: Post visibility option
654
- #: src/theme/components/PostForm.tsx:125
686
+ #: src/theme/components/PostForm.tsx:231
655
687
  msgid "Quiet (normal)"
656
688
  msgstr "安靜(正常)"
657
689
 
@@ -659,7 +691,7 @@ msgstr "安靜(正常)"
659
691
  #. @context: Post type label - quote
660
692
  #. @context: Post type option
661
693
  #: src/routes/pages/archive.tsx:33
662
- #: src/theme/components/PostForm.tsx:54
694
+ #: src/theme/components/PostForm.tsx:73
663
695
  #: src/theme/components/TypeBadge.tsx:25
664
696
  msgid "Quote"
665
697
  msgstr "引用"
@@ -677,14 +709,16 @@ msgid "Redirects"
677
709
  msgstr "重定向"
678
710
 
679
711
  #. @context: Button to remove post from collection
712
+ #. @context: Remove media attachment button
680
713
  #: src/routes/dash/collections.tsx:257
714
+ #: src/theme/components/PostForm.tsx:161
681
715
  msgid "Remove"
682
716
  msgstr "移除"
683
717
 
684
718
  #. @context: Password reset form submit button
685
719
  #. @context: Password reset page heading
686
- #: src/app.tsx:469
687
- #: src/app.tsx:520
720
+ #: src/app.tsx:451
721
+ #: src/app.tsx:502
688
722
  msgid "Reset Password"
689
723
  msgstr "重設密碼"
690
724
 
@@ -710,6 +744,11 @@ msgstr "搜尋"
710
744
  msgid "Search posts..."
711
745
  msgstr "搜尋帖子..."
712
746
 
747
+ #. @context: Media picker dialog title
748
+ #: src/theme/components/PostForm.tsx:324
749
+ msgid "Select Media"
750
+ msgstr ""
751
+
713
752
  #. @context: Dashboard heading
714
753
  #. @context: Dashboard heading
715
754
  #. @context: Dashboard heading
@@ -750,8 +789,13 @@ msgstr "網站名稱"
750
789
  msgid "Slug"
751
790
  msgstr "縮略名"
752
791
 
792
+ #. @context: Post form field - name of the source website or author
793
+ #: src/theme/components/PostForm.tsx:204
794
+ msgid "Source Name (optional)"
795
+ msgstr ""
796
+
753
797
  #. @context: Post form field
754
- #: src/theme/components/PostForm.tsx:102
798
+ #: src/theme/components/PostForm.tsx:188
755
799
  msgid "Source URL (optional)"
756
800
  msgstr "來源網址(選填)"
757
801
 
@@ -776,7 +820,7 @@ msgid "The URL path for this page. Use lowercase letters, numbers, and hyphens."
776
820
  msgstr "此頁面的 URL 路徑。使用小寫字母、數字和連字符。"
777
821
 
778
822
  #. @context: Password reset error description
779
- #: src/app.tsx:548
823
+ #: src/app.tsx:530
780
824
  msgid "This password reset link is invalid or has expired. Please generate a new one."
781
825
  msgstr "此密碼重設連結無效或已過期。請生成一個新的連結。"
782
826
 
@@ -810,7 +854,7 @@ msgid "Title"
810
854
  msgstr "標題"
811
855
 
812
856
  #. @context: Post form field
813
- #: src/theme/components/PostForm.tsx:65
857
+ #: src/theme/components/PostForm.tsx:84
814
858
  msgid "Title (optional)"
815
859
  msgstr "標題(選填)"
816
860
 
@@ -822,13 +866,13 @@ msgstr "到路徑"
822
866
  #. @context: Post form field - post type
823
867
  #. @context: Redirect form field
824
868
  #: src/routes/dash/redirects.tsx:141
825
- #: src/theme/components/PostForm.tsx:38
869
+ #: src/theme/components/PostForm.tsx:57
826
870
  msgid "Type"
827
871
  msgstr "類型"
828
872
 
829
873
  #. @context: Post visibility badge - unlisted
830
874
  #. @context: Post visibility option
831
- #: src/theme/components/PostForm.tsx:137
875
+ #: src/theme/components/PostForm.tsx:243
832
876
  #: src/theme/components/VisibilityBadge.tsx:34
833
877
  msgid "Unlisted"
834
878
  msgstr "不公開"
@@ -841,7 +885,7 @@ msgid "Untitled"
841
885
  msgstr "無標題"
842
886
 
843
887
  #. @context: Button to update existing post
844
- #: src/theme/components/PostForm.tsx:171
888
+ #: src/theme/components/PostForm.tsx:302
845
889
  msgid "Update"
846
890
  msgstr "更新"
847
891
 
@@ -896,14 +940,14 @@ msgstr "使用此 URL 將媒體嵌入到您的帖子中。"
896
940
  #: src/routes/dash/collections.tsx:215
897
941
  #: src/routes/dash/pages.tsx:73
898
942
  #: src/routes/dash/pages.tsx:143
899
- #: src/routes/dash/posts.tsx:129
943
+ #: src/routes/dash/posts.tsx:147
900
944
  #: src/theme/components/ActionButtons.tsx:76
901
945
  #: src/theme/components/PostList.tsx:51
902
946
  msgid "View"
903
947
  msgstr "查看"
904
948
 
905
949
  #. @context: Link to view all posts on archive page
906
- #: src/routes/pages/home.tsx:93
950
+ #: src/routes/pages/home.tsx:109
907
951
  msgid "View all posts →"
908
952
  msgstr "查看所有文章 →"
909
953
 
@@ -913,7 +957,7 @@ msgid "View Site"
913
957
  msgstr "查看網站"
914
958
 
915
959
  #. @context: Post form field
916
- #: src/theme/components/PostForm.tsx:118
960
+ #: src/theme/components/PostForm.tsx:224
917
961
  msgid "Visibility"
918
962
  msgstr "可見性"
919
963
 
@@ -923,7 +967,7 @@ msgid "Welcome to Jant"
923
967
  msgstr "歡迎來到 Jant"
924
968
 
925
969
  #. @context: Post content placeholder
926
- #: src/theme/components/PostForm.tsx:89
970
+ #: src/theme/components/PostForm.tsx:108
927
971
  msgid "What's on your mind?"
928
972
  msgstr "你在想什麼?"
929
973
 
@@ -1 +1 @@
1
- /*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"+7Wr2a\":[\"Edit: \",[\"title\"]],\"+MACwa\":[\"尚未有任何收藏。\"],\"+owNNn\":[\"文章\"],\"+zy2Nq\":[\"類型\"],\"/0D1Xp\":[\"編輯收藏集\"],\"/Rj5P4\":[\"您的姓名\"],\"07Epll\":[\"這將為您的網站和儀表板設置主題。所有顏色主題都支持深色模式。\"],\"0JkyS7\":[\"建立您的第一個頁面\"],\"0a6MpL\":[\"新重定向\"],\"1CU1Td\":[\"網址安全識別碼(小寫、數字、連字符)\"],\"1DBGsz\":[\"筆記\"],\"2N0qpv\":[\"文章標題...\"],\"2q/Q7x\":[\"可見性\"],\"2rJGtU\":[\"頁面標題...\"],\"3Yvsaz\":[\"302(臨時)\"],\"4/SFQS\":[\"查看網站\"],\"40TVQj\":[\"自訂路徑(選填)\"],\"4KzVT6\":[\"刪除頁面\"],\"4b3oEV\":[\"內容\"],\"4mDPGp\":[\"此頁面的 URL 路徑。使用小寫字母、數字和連字符。\"],\"6WdDG7\":[\"頁面\"],\"6YtxFj\":[\"名稱\"],\"7G4SBz\":[\"頁面內容(支持Markdown)...\"],\"7Mk+/h\":[\"更新收藏集\"],\"7Q1KKN\":[\"來源路徑\"],\"7aECQB\":[\"無效或已過期的連結\"],\"7nGhhM\":[\"你在想什麼?\"],\"7p5kLi\":[\"儀表板\"],\"7vhWI8\":[\"新密碼\"],\"8ZsakT\":[\"密碼\"],\"90Luob\":[[\"count\"],\" 條回覆\"],\"A1taO8\":[\"搜尋\"],\"AeXO77\":[\"帳戶\"],\"AyHO4m\":[\"這個收藏是關於什麼的?\"],\"B373X+\":[\"編輯文章\"],\"B495Gs\":[\"檔案館\"],\"BjF0Jv\":[\"僅限小寫字母、數字和連字符\"],\"D9Oea+\":[\"永久鏈接\"],\"DCKkhU\":[\"當前密碼\"],\"DHhJ7s\":[\"上一頁\"],\"DoJzLz\":[\"收藏夾\"],\"E80cJw\":[\"刪除此媒體將永久從存儲中移除它。\"],\"EEYbdt\":[\"發佈\"],\"EGwzOK\":[\"完成設置\"],\"EkH9pt\":[\"更新\"],\"FGrimz\":[\"新帖子\"],\"FkMol5\":[\"精選\"],\"Fxf4jq\":[\"描述(可選)\"],\"GA5A5H\":[\"刪除收藏夾\"],\"GX2VMa\":[\"建立您的管理員帳戶。\"],\"GbVAnd\":[\"此密碼重設連結無效或已過期。請生成一個新的連結。\"],\"GorKul\":[\"歡迎來到 Jant\"],\"GrZ6fH\":[\"新頁面\"],\"GxkJXS\":[\"上傳中...\"],\"HfyyXl\":[\"My Blog\"],\"HiETwV\":[\"安靜(正常)\"],\"Hzi9AA\":[\"未找到任何帖子。\"],\"I6gXOa\":[\"路徑\"],\"IagCbF\":[\"網址\"],\"J4FNfC\":[\"此集合中沒有帖子。\"],\"JIBC/T\":[\"Supported formats: JPEG, PNG, GIF, WebP, SVG. Max size: 10MB.\"],\"Jed1wB\":[\"需要幫助嗎?請訪問<0>文檔</0>。\"],\"JiP4aa\":[\"已發佈的頁面可以通過其路徑訪問。草稿不可見。\"],\"K9NcLu\":[\"使用此 URL 將媒體嵌入到您的帖子中。\"],\"KbS2K9\":[\"重設密碼\"],\"KiJn9B\":[\"備註\"],\"L85WcV\":[\"縮略名\"],\"LkvLQe\":[\"尚未有頁面。\"],\"M1RvTd\":[\"點擊圖片以查看完整大小\"],\"M8kJqa\":[\"草稿\"],\"M9xgHy\":[\"重定向\"],\"MHrjPM\":[\"標題\"],\"MZbQHL\":[\"未找到結果。\"],\"Mhf/H/\":[\"建立重定向\"],\"MqghUt\":[\"搜尋帖子...\"],\"N40H+G\":[\"所有\"],\"O3oNi5\":[\"電子郵件\"],\"OCNZaU\":[\"重定向來源的路徑\"],\"ODiSoW\":[\"尚未有帖子。\"],\"ONWvwQ\":[\"上傳\"],\"Pbm2/N\":[\"創建收藏夾\"],\"RDjuBN\":[\"Setup\"],\"Rj01Fz\":[\"連結\"],\"RwGhWy\":[\"包含 \",[\"count\"],\" 則帖子的主題\"],\"SJmfuf\":[\"網站名稱\"],\"ST+lN2\":[\"尚未上傳任何媒體。\"],\"Tt5T6+\":[\"文章\"],\"TxE+Mj\":[\"1 條回覆\"],\"Tz0i8g\":[\"設定\"],\"U5v6Gh\":[\"編輯頁面\"],\"UDMjsP\":[\"快速操作\"],\"UGT5vp\":[\"保存設定\"],\"VUSy8D\":[\"Search failed. Please try again.\"],\"VhMDMg\":[\"更改密碼\"],\"WDcQq9\":[\"不公開\"],\"Weq9zb\":[\"一般設定\"],\"WmZ/rP\":[\"到路徑\"],\"Y+7JGK\":[\"創建頁面\"],\"ZQKLI1\":[\"危險區域\"],\"ZhhOwV\":[\"引用\"],\"aAIQg2\":[\"外觀\"],\"an5hVd\":[\"圖片\"],\"b+/jO6\":[\"301(永久)\"],\"bHYIks\":[\"登出\"],\"biOepV\":[\"← 返回首頁\"],\"cnGeoo\":[\"刪除\"],\"dEgA5A\":[\"取消\"],\"e6Jr7Q\":[\"← 返回收藏夾\"],\"ePK91l\":[\"編輯\"],\"eWLklq\":[\"引用\"],\"eneWvv\":[\"草稿\"],\"er8+x7\":[\"示範帳戶已預填。只需點擊登入。\"],\"f6e0Ry\":[\"文章\"],\"fG7BxZ\":[\"Upload images via the API: POST /api/upload with a file form field.\"],\"fttd2R\":[\"我的收藏\"],\"hG89Ed\":[\"圖片\"],\"hWOZIv\":[\"請輸入您的新密碼。\"],\"hXzOVo\":[\"下一頁\"],\"he3ygx\":[\"複製\"],\"iH8pgl\":[\"返回\"],\"ig4hg2\":[\"Let's set up your site.\"],\"jpctdh\":[\"查看\"],\"k1ifdL\":[\"處理中...\"],\"mTOYla\":[\"查看所有文章 →\"],\"n1ekoW\":[\"登入\"],\"oYPBa0\":[\"更新頁面\"],\"p2/GCq\":[\"確認密碼\"],\"pRhYH2\":[\"收藏中的帖子 (\",[\"count\"],\")\"],\"pZq3aX\":[\"上傳失敗。請再試一次。\"],\"qMyM2u\":[\"來源網址(選填)\"],\"r1MpXi\":[\"安靜\"],\"rFmBG3\":[\"顏色主題\"],\"rdUucN\":[\"預覽\"],\"rzNUSl\":[\"包含 1 則貼文的主題\"],\"sGajR7\":[\"線程開始\"],\"ssqvZi\":[\"保存個人資料\"],\"t/YqKh\":[\"移除\"],\"tfrt7B\":[\"未配置任何重定向。\"],\"tiq7kl\":[\"頁面 \",[\"page\"]],\"u2f7vd\":[\"網站描述\"],\"u3wRF+\":[\"已發佈\"],\"u6Hp4N\":[\"Markdown\"],\"uAQUqI\":[\"狀態\"],\"vERlcd\":[\"個人資料\"],\"vXIe7J\":[\"語言\"],\"vzU4k9\":[\"新收藏集\"],\"wEF6Ix\":[\"目的地路徑或 URL\"],\"wK4OTM\":[\"標題(選填)\"],\"wM5UXj\":[\"刪除媒體\"],\"wRR604\":[\"頁面\"],\"wja8aL\":[\"無標題\"],\"x+doid\":[\"圖片會自動優化:調整大小至最大 1920 像素,轉換為 WebP 格式,並去除元數據。\"],\"x0mzE0\":[\"創建你的第一篇帖子\"],\"x4RuFo\":[\"回到首頁\"],\"xYilR2\":[\"媒體\"],\"y28hnO\":[\"文章\"],\"yQ2kGp\":[\"載入更多\"],\"yjkELF\":[\"確認新密碼\"],\"yzF66j\":[\"連結\"],\"z8ajIE\":[\"找到 1 個結果\"],\"zH6KqE\":[\"找到 \",[\"count\"],\" 個結果\"]}")as Messages;
1
+ /*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"+7Wr2a\":[\"Edit: \",[\"title\"]],\"+MACwa\":[\"尚未有任何收藏。\"],\"+owNNn\":[\"文章\"],\"+zy2Nq\":[\"類型\"],\"/0D1Xp\":[\"編輯收藏集\"],\"/Rj5P4\":[\"您的姓名\"],\"07Epll\":[\"這將為您的網站和儀表板設置主題。所有顏色主題都支持深色模式。\"],\"0JkyS7\":[\"建立您的第一個頁面\"],\"0a6MpL\":[\"新重定向\"],\"1CU1Td\":[\"網址安全識別碼(小寫、數字、連字符)\"],\"1DBGsz\":[\"筆記\"],\"1o+wgo\":[\"e.g. The Verge, John Doe\"],\"2N0qpv\":[\"文章標題...\"],\"2fUwEY\":[\"Select Media\"],\"2q/Q7x\":[\"可見性\"],\"2rJGtU\":[\"頁面標題...\"],\"3Yvsaz\":[\"302(臨時)\"],\"4/SFQS\":[\"查看網站\"],\"40TVQj\":[\"自訂路徑(選填)\"],\"4KzVT6\":[\"刪除頁面\"],\"4b3oEV\":[\"內容\"],\"4mDPGp\":[\"此頁面的 URL 路徑。使用小寫字母、數字和連字符。\"],\"6WdDG7\":[\"頁面\"],\"6YtxFj\":[\"名稱\"],\"7G4SBz\":[\"頁面內容(支持Markdown)...\"],\"7Mk+/h\":[\"更新收藏集\"],\"7Q1KKN\":[\"來源路徑\"],\"7aECQB\":[\"無效或已過期的連結\"],\"7nGhhM\":[\"你在想什麼?\"],\"7p5kLi\":[\"儀表板\"],\"7vhWI8\":[\"新密碼\"],\"8ZsakT\":[\"密碼\"],\"90Luob\":[[\"count\"],\" 條回覆\"],\"A1taO8\":[\"搜尋\"],\"AeXO77\":[\"帳戶\"],\"AyHO4m\":[\"這個收藏是關於什麼的?\"],\"B373X+\":[\"編輯文章\"],\"B495Gs\":[\"檔案館\"],\"BjF0Jv\":[\"僅限小寫字母、數字和連字符\"],\"D9Oea+\":[\"永久鏈接\"],\"DCKkhU\":[\"當前密碼\"],\"DHhJ7s\":[\"上一頁\"],\"DPfwMq\":[\"Done\"],\"DoJzLz\":[\"收藏夾\"],\"E80cJw\":[\"刪除此媒體將永久從存儲中移除它。\"],\"EEYbdt\":[\"發佈\"],\"EGwzOK\":[\"完成設置\"],\"EkH9pt\":[\"更新\"],\"FGrimz\":[\"新帖子\"],\"FkMol5\":[\"精選\"],\"Fxf4jq\":[\"描述(可選)\"],\"GA5A5H\":[\"刪除收藏夾\"],\"GX2VMa\":[\"建立您的管理員帳戶。\"],\"GbVAnd\":[\"此密碼重設連結無效或已過期。請生成一個新的連結。\"],\"GorKul\":[\"歡迎來到 Jant\"],\"GrZ6fH\":[\"新頁面\"],\"GxkJXS\":[\"上傳中...\"],\"HfyyXl\":[\"My Blog\"],\"HiETwV\":[\"安靜(正常)\"],\"Hzi9AA\":[\"未找到任何帖子。\"],\"I6gXOa\":[\"路徑\"],\"I8hDlV\":[\"At least 1 image required for image posts.\"],\"IagCbF\":[\"網址\"],\"J4FNfC\":[\"此集合中沒有帖子。\"],\"JIBC/T\":[\"Supported formats: JPEG, PNG, GIF, WebP, SVG. Max size: 10MB.\"],\"Jed1wB\":[\"需要幫助嗎?請訪問<0>文檔</0>。\"],\"JiP4aa\":[\"已發佈的頁面可以通過其路徑訪問。草稿不可見。\"],\"K9NcLu\":[\"使用此 URL 將媒體嵌入到您的帖子中。\"],\"KbS2K9\":[\"重設密碼\"],\"KiJn9B\":[\"備註\"],\"L85WcV\":[\"縮略名\"],\"LkvLQe\":[\"尚未有頁面。\"],\"M1RvTd\":[\"點擊圖片以查看完整大小\"],\"M8kJqa\":[\"草稿\"],\"M9xgHy\":[\"重定向\"],\"MHrjPM\":[\"標題\"],\"MWBOxm\":[\"Collections (optional)\"],\"MZbQHL\":[\"未找到結果。\"],\"Mhf/H/\":[\"建立重定向\"],\"MqghUt\":[\"搜尋帖子...\"],\"N40H+G\":[\"所有\"],\"O3oNi5\":[\"電子郵件\"],\"OCNZaU\":[\"重定向來源的路徑\"],\"ODiSoW\":[\"尚未有帖子。\"],\"ONWvwQ\":[\"上傳\"],\"Pbm2/N\":[\"創建收藏夾\"],\"RDjuBN\":[\"Setup\"],\"Rj01Fz\":[\"連結\"],\"RwGhWy\":[\"包含 \",[\"count\"],\" 則帖子的主題\"],\"SJmfuf\":[\"網站名稱\"],\"ST+lN2\":[\"尚未上傳任何媒體。\"],\"Tt5T6+\":[\"文章\"],\"TxE+Mj\":[\"1 條回覆\"],\"Tz0i8g\":[\"設定\"],\"U5v6Gh\":[\"編輯頁面\"],\"UDMjsP\":[\"快速操作\"],\"UGT5vp\":[\"保存設定\"],\"VUSy8D\":[\"Search failed. Please try again.\"],\"VhMDMg\":[\"更改密碼\"],\"WDcQq9\":[\"不公開\"],\"Weq9zb\":[\"一般設定\"],\"WmZ/rP\":[\"到路徑\"],\"Y+7JGK\":[\"創建頁面\"],\"Z3FXyt\":[\"Loading...\"],\"ZQKLI1\":[\"危險區域\"],\"ZhhOwV\":[\"引用\"],\"aAIQg2\":[\"外觀\"],\"an5hVd\":[\"圖片\"],\"b+/jO6\":[\"301(永久)\"],\"bHYIks\":[\"登出\"],\"biOepV\":[\"← 返回首頁\"],\"cnGeoo\":[\"刪除\"],\"dEgA5A\":[\"取消\"],\"e6Jr7Q\":[\"← 返回收藏夾\"],\"ePK91l\":[\"編輯\"],\"eWLklq\":[\"引用\"],\"eneWvv\":[\"草稿\"],\"er8+x7\":[\"示範帳戶已預填。只需點擊登入。\"],\"f6e0Ry\":[\"文章\"],\"fG7BxZ\":[\"Upload images via the API: POST /api/upload with a file form field.\"],\"fttd2R\":[\"我的收藏\"],\"hG89Ed\":[\"圖片\"],\"hWOZIv\":[\"請輸入您的新密碼。\"],\"hXzOVo\":[\"下一頁\"],\"he3ygx\":[\"複製\"],\"iH8pgl\":[\"返回\"],\"ig4hg2\":[\"Let's set up your site.\"],\"jpctdh\":[\"查看\"],\"k1ifdL\":[\"處理中...\"],\"mTOYla\":[\"查看所有文章 →\"],\"n1ekoW\":[\"登入\"],\"oJFOZk\":[\"Source Name (optional)\"],\"oYPBa0\":[\"更新頁面\"],\"p2/GCq\":[\"確認密碼\"],\"pRhYH2\":[\"收藏中的帖子 (\",[\"count\"],\")\"],\"pZq3aX\":[\"上傳失敗。請再試一次。\"],\"qMyM2u\":[\"來源網址(選填)\"],\"qiXmlF\":[\"Add Media\"],\"r1MpXi\":[\"安靜\"],\"rFmBG3\":[\"顏色主題\"],\"rdUucN\":[\"預覽\"],\"rzNUSl\":[\"包含 1 則貼文的主題\"],\"sGajR7\":[\"線程開始\"],\"ssqvZi\":[\"保存個人資料\"],\"t/YqKh\":[\"移除\"],\"tfrt7B\":[\"未配置任何重定向。\"],\"tiq7kl\":[\"頁面 \",[\"page\"]],\"u2f7vd\":[\"網站描述\"],\"u3wRF+\":[\"已發佈\"],\"u6Hp4N\":[\"Markdown\"],\"uAQUqI\":[\"狀態\"],\"vERlcd\":[\"個人資料\"],\"vXIe7J\":[\"語言\"],\"vzU4k9\":[\"新收藏集\"],\"wEF6Ix\":[\"目的地路徑或 URL\"],\"wK4OTM\":[\"標題(選填)\"],\"wM5UXj\":[\"刪除媒體\"],\"wRR604\":[\"頁面\"],\"wja8aL\":[\"無標題\"],\"x+doid\":[\"圖片會自動優化:調整大小至最大 1920 像素,轉換為 WebP 格式,並去除元數據。\"],\"x0mzE0\":[\"創建你的第一篇帖子\"],\"x4RuFo\":[\"回到首頁\"],\"xYilR2\":[\"媒體\"],\"y28hnO\":[\"文章\"],\"yQ2kGp\":[\"載入更多\"],\"yjkELF\":[\"確認新密碼\"],\"yzF66j\":[\"連結\"],\"z8ajIE\":[\"找到 1 個結果\"],\"zH6KqE\":[\"找到 \",[\"count\"],\" 個結果\"]}")as Messages;
package/src/index.ts CHANGED
@@ -17,6 +17,8 @@ export type {
17
17
  Bindings,
18
18
  Post,
19
19
  Media,
20
+ MediaAttachment,
21
+ PostWithMedia,
20
22
  Collection,
21
23
  PostCollection,
22
24
  Redirect,
@@ -28,7 +30,12 @@ export type {
28
30
  ThemeComponents,
29
31
  } from "./types.js";
30
32
 
31
- export { POST_TYPES, VISIBILITY_LEVELS } from "./types.js";
33
+ export {
34
+ POST_TYPES,
35
+ VISIBILITY_LEVELS,
36
+ MAX_MEDIA_ATTACHMENTS,
37
+ POST_TYPE_MEDIA_RULES,
38
+ } from "./types.js";
32
39
 
33
40
  // Utilities (for theme authors)
34
41
  export * as time from "./lib/time.js";
@@ -7,9 +7,14 @@ import {
7
7
  UpdatePostSchema,
8
8
  parseFormData,
9
9
  parseFormDataOptional,
10
+ validateMediaForPostType,
10
11
  } from "../schemas.js";
11
12
  import { z } from "zod";
12
- import { POST_TYPES, VISIBILITY_LEVELS } from "../../types.js";
13
+ import {
14
+ POST_TYPES,
15
+ VISIBILITY_LEVELS,
16
+ MAX_MEDIA_ATTACHMENTS,
17
+ } from "../../types.js";
13
18
 
14
19
  describe("PostTypeSchema", () => {
15
20
  it("accepts all valid post types", () => {
@@ -145,6 +150,37 @@ describe("CreatePostSchema", () => {
145
150
  ).toThrow();
146
151
  });
147
152
 
153
+ it("accepts valid mediaIds", () => {
154
+ const result = CreatePostSchema.parse({
155
+ ...validPost,
156
+ mediaIds: ["id-1", "id-2"],
157
+ });
158
+ expect(result.mediaIds).toEqual(["id-1", "id-2"]);
159
+ });
160
+
161
+ it("accepts empty mediaIds array", () => {
162
+ const result = CreatePostSchema.parse({
163
+ ...validPost,
164
+ mediaIds: [],
165
+ });
166
+ expect(result.mediaIds).toEqual([]);
167
+ });
168
+
169
+ it("accepts omitted mediaIds", () => {
170
+ const result = CreatePostSchema.parse(validPost);
171
+ expect(result.mediaIds).toBeUndefined();
172
+ });
173
+
174
+ it("rejects mediaIds over MAX_MEDIA_ATTACHMENTS", () => {
175
+ const tooMany = Array.from(
176
+ { length: MAX_MEDIA_ATTACHMENTS + 1 },
177
+ (_, i) => `id-${i}`,
178
+ );
179
+ expect(() =>
180
+ CreatePostSchema.parse({ ...validPost, mediaIds: tooMany }),
181
+ ).toThrow();
182
+ });
183
+
148
184
  it("rejects missing required fields", () => {
149
185
  expect(() => CreatePostSchema.parse({})).toThrow();
150
186
  expect(() => CreatePostSchema.parse({ type: "note" })).toThrow();
@@ -218,3 +254,55 @@ describe("parseFormDataOptional", () => {
218
254
  expect(() => parseFormDataOptional(form, "type", PostTypeSchema)).toThrow();
219
255
  });
220
256
  });
257
+
258
+ describe("validateMediaForPostType", () => {
259
+ it("returns null for note with no media", () => {
260
+ expect(validateMediaForPostType("note", [])).toBeNull();
261
+ });
262
+
263
+ it("returns null for note with media", () => {
264
+ expect(validateMediaForPostType("note", ["id-1", "id-2"])).toBeNull();
265
+ });
266
+
267
+ it("returns null for article with media", () => {
268
+ expect(validateMediaForPostType("article", ["id-1"])).toBeNull();
269
+ });
270
+
271
+ it("returns null for image with at least 1 media", () => {
272
+ expect(validateMediaForPostType("image", ["id-1"])).toBeNull();
273
+ });
274
+
275
+ it("returns error for image with no media", () => {
276
+ const error = validateMediaForPostType("image", []);
277
+ expect(error).toBe("image posts require at least 1 media attachment");
278
+ });
279
+
280
+ it("returns null for link with 0 or 1 media", () => {
281
+ expect(validateMediaForPostType("link", [])).toBeNull();
282
+ expect(validateMediaForPostType("link", ["id-1"])).toBeNull();
283
+ });
284
+
285
+ it("returns error for link with more than 1 media", () => {
286
+ const error = validateMediaForPostType("link", ["id-1", "id-2"]);
287
+ expect(error).toBe("link posts allow at most 1 media attachment");
288
+ });
289
+
290
+ it("returns error for page with any media", () => {
291
+ const error = validateMediaForPostType("page", ["id-1"]);
292
+ expect(error).toBe("page posts do not allow media attachments");
293
+ });
294
+
295
+ it("returns null for page with no media", () => {
296
+ expect(validateMediaForPostType("page", [])).toBeNull();
297
+ });
298
+
299
+ it("returns null for quote with media", () => {
300
+ expect(validateMediaForPostType("quote", ["id-1", "id-2"])).toBeNull();
301
+ });
302
+
303
+ it("returns error when exceeding max for note", () => {
304
+ const tooMany = Array.from({ length: 21 }, (_, i) => `id-${i}`);
305
+ const error = validateMediaForPostType("note", tooMany);
306
+ expect(error).toBe("note posts allow at most 20 media attachments");
307
+ });
308
+ });
@@ -25,13 +25,25 @@ describe("dsRedirect", () => {
25
25
  expect(body).toContain("\\'");
26
26
  });
27
27
 
28
- it("merges additional headers", () => {
28
+ it("merges additional headers from plain object", () => {
29
29
  const res = dsRedirect("/dash", {
30
30
  headers: { "Set-Cookie": "session=abc" },
31
31
  });
32
32
  expect(res.headers.get("Set-Cookie")).toBe("session=abc");
33
33
  expect(res.headers.get("Content-Type")).toBe("text/html");
34
34
  });
35
+
36
+ it("merges additional headers from Headers instance", () => {
37
+ const headers = new Headers();
38
+ headers.append("set-cookie", "session=abc; Path=/; HttpOnly");
39
+ headers.append("set-cookie", "data=xyz; Path=/; Max-Age=300");
40
+ const res = dsRedirect("/dash", { headers });
41
+ const cookies = res.headers.getSetCookie();
42
+ expect(cookies).toHaveLength(2);
43
+ expect(cookies[0]).toBe("session=abc; Path=/; HttpOnly");
44
+ expect(cookies[1]).toBe("data=xyz; Path=/; Max-Age=300");
45
+ expect(res.headers.get("Content-Type")).toBe("text/html");
46
+ });
35
47
  });
36
48
 
37
49
  describe("dsToast", () => {
@@ -9,7 +9,13 @@
9
9
  */
10
10
 
11
11
  import { z } from "zod";
12
- import { POST_TYPES, VISIBILITY_LEVELS } from "../types.js";
12
+ import {
13
+ POST_TYPES,
14
+ VISIBILITY_LEVELS,
15
+ MAX_MEDIA_ATTACHMENTS,
16
+ POST_TYPE_MEDIA_RULES,
17
+ } from "../types.js";
18
+ import type { PostType } from "../types.js";
13
19
 
14
20
  /**
15
21
  * Post type enum schema
@@ -46,6 +52,7 @@ export const CreatePostSchema = z.object({
46
52
  .or(z.literal("")),
47
53
  replyToId: z.string().optional(), // Sqid format
48
54
  publishedAt: z.number().int().positive().optional(),
55
+ mediaIds: z.array(z.string()).max(MAX_MEDIA_ATTACHMENTS).optional(),
49
56
  });
50
57
 
51
58
  /**
@@ -94,3 +101,42 @@ export function parseFormDataOptional<T>(
94
101
  }
95
102
  return schema.parse(value);
96
103
  }
104
+
105
+ /**
106
+ * Validates media attachment count against post type rules.
107
+ *
108
+ * @param type - The post type to validate against
109
+ * @param mediaIds - Array of media IDs to attach
110
+ * @returns null if valid, error string if invalid
111
+ *
112
+ * @example
113
+ * ```ts
114
+ * const error = validateMediaForPostType("image", []);
115
+ * // Returns: "image posts require at least 1 media attachment"
116
+ * ```
117
+ */
118
+ export function validateMediaForPostType(
119
+ type: PostType,
120
+ mediaIds: string[],
121
+ ): string | null {
122
+ const rules = POST_TYPE_MEDIA_RULES[type];
123
+
124
+ if (rules === null) {
125
+ if (mediaIds.length > 0) {
126
+ return `${type} posts do not allow media attachments`;
127
+ }
128
+ return null;
129
+ }
130
+
131
+ const [min, max] = rules;
132
+
133
+ if (mediaIds.length < min) {
134
+ return `${type} posts require at least ${min} media attachment${min !== 1 ? "s" : ""}`;
135
+ }
136
+
137
+ if (mediaIds.length > max) {
138
+ return `${type} posts allow at most ${max} media attachment${max !== 1 ? "s" : ""}`;
139
+ }
140
+
141
+ return null;
142
+ }