@jant/core 0.3.22 → 0.3.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.js +23 -5
- package/dist/db/schema.js +72 -47
- package/dist/i18n/locales/en.js +1 -1
- package/dist/i18n/locales/zh-Hans.js +1 -1
- package/dist/i18n/locales/zh-Hant.js +1 -1
- package/dist/index.js +5 -6
- package/dist/lib/constants.js +1 -4
- package/dist/lib/excerpt.js +76 -0
- package/dist/lib/feed.js +18 -7
- package/dist/lib/navigation.js +4 -5
- package/dist/lib/render.js +1 -1
- package/dist/lib/schemas.js +80 -38
- package/dist/lib/theme-components.js +8 -11
- package/dist/lib/time.js +56 -1
- package/dist/lib/timeline.js +119 -0
- package/dist/lib/view.js +62 -73
- package/dist/routes/api/posts.js +29 -35
- package/dist/routes/api/search.js +5 -6
- package/dist/routes/api/upload.js +13 -13
- package/dist/routes/dash/collections.js +22 -40
- package/dist/routes/dash/index.js +2 -2
- package/dist/routes/dash/navigation.js +25 -24
- package/dist/routes/dash/pages.js +42 -57
- package/dist/routes/dash/posts.js +27 -35
- package/dist/routes/feed/rss.js +2 -4
- package/dist/routes/feed/sitemap.js +10 -7
- package/dist/routes/pages/archive.js +12 -11
- package/dist/routes/pages/collection.js +11 -5
- package/dist/routes/pages/home.js +53 -61
- package/dist/routes/pages/page.js +60 -29
- package/dist/routes/pages/post.js +5 -12
- package/dist/routes/pages/search.js +3 -4
- package/dist/services/collection.js +52 -64
- package/dist/services/index.js +5 -3
- package/dist/services/navigation.js +29 -53
- package/dist/services/page.js +80 -0
- package/dist/services/post.js +68 -69
- package/dist/services/search.js +24 -18
- package/dist/theme/components/MediaGallery.js +19 -91
- package/dist/theme/components/PageForm.js +15 -15
- package/dist/theme/components/PostForm.js +136 -129
- package/dist/theme/components/PostList.js +13 -8
- package/dist/theme/components/ThreadView.js +3 -3
- package/dist/theme/components/TypeBadge.js +3 -14
- package/dist/theme/components/VisibilityBadge.js +33 -23
- package/dist/theme/components/index.js +0 -2
- package/dist/theme/index.js +10 -16
- package/dist/theme/layouts/index.js +0 -1
- package/dist/themes/threads/ThreadsSiteLayout.js +172 -0
- package/dist/themes/threads/index.js +81 -0
- package/dist/{theme → themes/threads}/pages/ArchivePage.js +31 -47
- package/dist/themes/threads/pages/CollectionPage.js +65 -0
- package/dist/{theme → themes/threads}/pages/HomePage.js +4 -5
- package/dist/{theme → themes/threads}/pages/PostPage.js +10 -8
- package/dist/{theme → themes/threads}/pages/SearchPage.js +8 -8
- package/dist/{theme → themes/threads}/pages/SinglePage.js +5 -6
- package/dist/{theme/components → themes/threads}/timeline/LinkCard.js +20 -11
- package/dist/themes/threads/timeline/NoteCard.js +53 -0
- package/dist/themes/threads/timeline/QuoteCard.js +59 -0
- package/dist/{theme/components → themes/threads}/timeline/ThreadPreview.js +5 -6
- package/dist/themes/threads/timeline/TimelineFeed.js +58 -0
- package/dist/{theme/components → themes/threads}/timeline/TimelineItem.js +8 -17
- package/dist/themes/threads/timeline/TimelineLoadMore.js +23 -0
- package/dist/themes/threads/timeline/groupByDate.js +22 -0
- package/dist/themes/threads/timeline/timelineMore.js +107 -0
- package/dist/types.js +24 -40
- package/package.json +2 -1
- package/src/__tests__/helpers/app.ts +4 -0
- package/src/__tests__/helpers/db.ts +51 -74
- package/src/app.tsx +27 -6
- package/src/db/migrations/0005_v2_schema_migration.sql +268 -0
- package/src/db/migrations/meta/_journal.json +7 -0
- package/src/db/schema.ts +63 -46
- package/src/i18n/locales/en.po +216 -164
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +216 -164
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +216 -164
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/index.ts +30 -15
- package/src/lib/__tests__/excerpt.test.ts +125 -0
- package/src/lib/__tests__/schemas.test.ts +166 -105
- package/src/lib/__tests__/theme-components.test.ts +4 -25
- package/src/lib/__tests__/time.test.ts +62 -0
- package/src/{routes/api → lib}/__tests__/timeline.test.ts +108 -66
- package/src/lib/__tests__/view.test.ts +217 -67
- package/src/lib/constants.ts +1 -4
- package/src/lib/excerpt.ts +87 -0
- package/src/lib/feed.ts +22 -7
- package/src/lib/navigation.ts +6 -7
- package/src/lib/render.tsx +1 -1
- package/src/lib/schemas.ts +118 -52
- package/src/lib/theme-components.ts +10 -13
- package/src/lib/time.ts +64 -0
- package/src/lib/timeline.ts +170 -0
- package/src/lib/view.ts +81 -83
- package/src/preset.css +45 -0
- package/src/routes/api/__tests__/posts.test.ts +50 -108
- package/src/routes/api/__tests__/search.test.ts +2 -3
- package/src/routes/api/posts.ts +30 -30
- package/src/routes/api/search.ts +4 -4
- package/src/routes/api/upload.ts +16 -6
- package/src/routes/dash/collections.tsx +18 -40
- package/src/routes/dash/index.tsx +2 -2
- package/src/routes/dash/navigation.tsx +27 -26
- package/src/routes/dash/pages.tsx +45 -60
- package/src/routes/dash/posts.tsx +44 -52
- package/src/routes/feed/rss.ts +2 -1
- package/src/routes/feed/sitemap.ts +14 -4
- package/src/routes/pages/archive.tsx +14 -10
- package/src/routes/pages/collection.tsx +17 -6
- package/src/routes/pages/home.tsx +56 -81
- package/src/routes/pages/page.tsx +64 -27
- package/src/routes/pages/post.tsx +5 -14
- package/src/routes/pages/search.tsx +2 -2
- package/src/services/__tests__/collection.test.ts +257 -158
- package/src/services/__tests__/media.test.ts +18 -18
- package/src/services/__tests__/navigation.test.ts +161 -87
- package/src/services/__tests__/post-timeline.test.ts +92 -88
- package/src/services/__tests__/post.test.ts +342 -206
- package/src/services/__tests__/search.test.ts +19 -25
- package/src/services/collection.ts +71 -113
- package/src/services/index.ts +9 -8
- package/src/services/navigation.ts +38 -71
- package/src/services/page.ts +124 -0
- package/src/services/post.ts +93 -103
- package/src/services/search.ts +38 -27
- package/src/styles/components.css +0 -54
- package/src/theme/components/MediaGallery.tsx +27 -96
- package/src/theme/components/PageForm.tsx +21 -21
- package/src/theme/components/PostForm.tsx +122 -118
- package/src/theme/components/PostList.tsx +58 -49
- package/src/theme/components/ThreadView.tsx +6 -3
- package/src/theme/components/TypeBadge.tsx +9 -17
- package/src/theme/components/VisibilityBadge.tsx +40 -23
- package/src/theme/components/index.ts +0 -13
- package/src/theme/index.ts +10 -16
- package/src/theme/layouts/index.ts +0 -1
- package/src/themes/threads/ThreadsSiteLayout.tsx +194 -0
- package/src/themes/threads/index.ts +100 -0
- package/src/{theme → themes/threads}/pages/ArchivePage.tsx +52 -55
- package/src/themes/threads/pages/CollectionPage.tsx +61 -0
- package/src/{theme → themes/threads}/pages/HomePage.tsx +5 -6
- package/src/{theme → themes/threads}/pages/PostPage.tsx +11 -8
- package/src/{theme → themes/threads}/pages/SearchPage.tsx +9 -13
- package/src/themes/threads/pages/SinglePage.tsx +23 -0
- package/src/themes/threads/style.css +336 -0
- package/src/{theme/components → themes/threads}/timeline/LinkCard.tsx +21 -13
- package/src/themes/threads/timeline/NoteCard.tsx +58 -0
- package/src/themes/threads/timeline/QuoteCard.tsx +63 -0
- package/src/{theme/components → themes/threads}/timeline/ThreadPreview.tsx +6 -6
- package/src/themes/threads/timeline/TimelineFeed.tsx +62 -0
- package/src/{theme/components → themes/threads}/timeline/TimelineItem.tsx +9 -20
- package/src/themes/threads/timeline/TimelineLoadMore.tsx +35 -0
- package/src/themes/threads/timeline/groupByDate.ts +30 -0
- package/src/themes/threads/timeline/timelineMore.tsx +130 -0
- package/src/types.ts +242 -98
- package/dist/routes/api/timeline.js +0 -120
- package/dist/theme/components/timeline/ArticleCard.js +0 -46
- package/dist/theme/components/timeline/ImageCard.js +0 -83
- package/dist/theme/components/timeline/NoteCard.js +0 -34
- package/dist/theme/components/timeline/QuoteCard.js +0 -48
- package/dist/theme/components/timeline/TimelineFeed.js +0 -46
- package/dist/theme/components/timeline/index.js +0 -8
- package/dist/theme/layouts/SiteLayout.js +0 -131
- package/dist/theme/pages/CollectionPage.js +0 -63
- package/dist/theme/pages/index.js +0 -11
- package/src/routes/api/timeline.tsx +0 -159
- package/src/theme/components/timeline/ArticleCard.tsx +0 -45
- package/src/theme/components/timeline/ImageCard.tsx +0 -70
- package/src/theme/components/timeline/NoteCard.tsx +0 -34
- package/src/theme/components/timeline/QuoteCard.tsx +0 -48
- package/src/theme/components/timeline/TimelineFeed.tsx +0 -56
- package/src/theme/components/timeline/index.ts +0 -8
- package/src/theme/layouts/SiteLayout.tsx +0 -132
- package/src/theme/pages/CollectionPage.tsx +0 -60
- package/src/theme/pages/SinglePage.tsx +0 -24
- package/src/theme/pages/index.ts +0 -13
|
@@ -1 +1 @@
|
|
|
1
|
-
/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"+7Wr2a\":[\"Edit: \",[\"title\"]],\"+MACwa\":[\"尚未有任何收藏。\"],\"+bHzpy\":[\"顯示連結的文字\"],\"+owNNn\":[\"文章\"],\"+zy2Nq\":[\"類型\"],\"/0D1Xp\":[\"編輯收藏集\"],\"/Rj5P4\":[\"您的姓名\"],\"07Epll\":[\"這將為您的網站和儀表板設置主題。所有顏色主題都支持深色模式。\"],\"0JkyS7\":[\"建立您的第一個頁面\"],\"0a6MpL\":[\"新重定向\"],\"1CU1Td\":[\"網址安全識別碼(小寫、數字、連字符)\"],\"1DBGsz\":[\"筆記\"],\"1o+wgo\":[\"例如:The Verge,約翰·多伊\"],\"2N0qpv\":[\"文章標題...\"],\"2fUwEY\":[\"選擇媒體\"],\"2q/Q7x\":[\"可見性\"],\"2rJGtU\":[\"頁面標題...\"],\"3Yvsaz\":[\"302(臨時)\"],\"4/SFQS\":[\"查看網站\"],\"40TVQj\":[\"自訂路徑(選填)\"],\"4KzVT6\":[\"刪除頁面\"],\"4b3oEV\":[\"內容\"],\"4mDPGp\":[\"此頁面的 URL 路徑。使用小寫字母、數字和連字符。\"],\"6WdDG7\":[\"頁面\"],\"6YtxFj\":[\"名稱\"],\"7G4SBz\":[\"頁面內容(支持Markdown)...\"],\"7Mk+/h\":[\"更新收藏集\"],\"7Q1KKN\":[\"來源路徑\"],\"7aECQB\":[\"無效或已過期的連結\"],\"7nGhhM\":[\"你在想什麼?\"],\"7p5kLi\":[\"儀表板\"],\"7vhWI8\":[\"新密碼\"],\"87a/t/\":[\"標籤\"],\"8ZsakT\":[\"密碼\"],\"90Luob\":[[\"count\"],\" 條回覆\"],\"A1taO8\":[\"搜尋\"],\"AeXO77\":[\"帳戶\"],\"AyHO4m\":[\"這個收藏是關於什麼的?\"],\"B373X+\":[\"編輯文章\"],\"B495Gs\":[\"檔案館\"],\"BjF0Jv\":[\"僅限小寫字母、數字和連字符\"],\"D9Oea+\":[\"永久鏈接\"],\"DCKkhU\":[\"當前密碼\"],\"DHhJ7s\":[\"上一頁\"],\"DPfwMq\":[\"完成\"],\"DoJzLz\":[\"收藏夾\"],\"E80cJw\":[\"刪除此媒體將永久從存儲中移除它。\"],\"EEYbdt\":[\"發佈\"],\"EGwzOK\":[\"完成設置\"],\"EkH9pt\":[\"更新\"],\"FGrimz\":[\"新帖子\"],\"FkMol5\":[\"精選\"],\"Fxf4jq\":[\"描述(可選)\"],\"GA5A5H\":[\"刪除收藏夾\"],\"GX2VMa\":[\"建立您的管理員帳戶。\"],\"GbVAnd\":[\"此密碼重設連結無效或已過期。請生成一個新的連結。\"],\"GorKul\":[\"歡迎來到 Jant\"],\"GrZ6fH\":[\"新頁面\"],\"GxkJXS\":[\"上傳中...\"],\"HfyyXl\":[\"My Blog\"],\"HiETwV\":[\"安靜(正常)\"],\"Hzi9AA\":[\"未找到任何帖子。\"],\"I6gXOa\":[\"路徑\"],\"I8hDlV\":[\"至少需要 1 張圖片才能發佈圖片帖子。\"],\"IUwGEM\":[\"保存更改\"],\"IagCbF\":[\"網址\"],\"J4FNfC\":[\"此集合中沒有帖子。\"],\"JIBC/T\":[\"Supported formats: JPEG, PNG, GIF, WebP, SVG. Max size: 10MB.\"],\"Jed1wB\":[\"需要幫助嗎?請訪問<0>文檔</0>。\"],\"JiP4aa\":[\"已發佈的頁面可以通過其路徑訪問。草稿不可見。\"],\"K9NcLu\":[\"使用此 URL 將媒體嵌入到您的帖子中。\"],\"KbS2K9\":[\"重設密碼\"],\"KiJn9B\":[\"備註\"],\"KmGXnO\":[\"Are you sure you want to delete this post? This cannot be undone.\"],\"L85WcV\":[\"縮略名\"],\"LkvLQe\":[\"尚未有頁面。\"],\"M1RvTd\":[\"點擊圖片以查看完整大小\"],\"M8kJqa\":[\"草稿\"],\"M9xgHy\":[\"重定向\"],\"MHrjPM\":[\"標題\"],\"MWBOxm\":[\"收藏(可選)\"],\"MZbQHL\":[\"未找到結果。\"],\"Mhf/H/\":[\"建立重定向\"],\"MqghUt\":[\"搜尋帖子...\"],\"N40H+G\":[\"所有\"],\"O3oNi5\":[\"電子郵件\"],\"OCNZaU\":[\"重定向來源的路徑\"],\"ODiSoW\":[\"尚未有帖子。\"],\"ONWvwQ\":[\"上傳\"],\"Pbm2/N\":[\"創建收藏夾\"],\"QEbNBb\":[\"路徑(例如 /archive)或完整網址(例如 https://example.com)\"],\"RDjuBN\":[\"Setup\"],\"Rj01Fz\":[\"連結\"],\"RwGhWy\":[\"包含 \",[\"count\"],\" 則帖子的主題\"],\"SJmfuf\":[\"網站名稱\"],\"ST+lN2\":[\"尚未上傳任何媒體。\"],\"Tt5T6+\":[\"文章\"],\"TxE+Mj\":[\"1 條回覆\"],\"Tz0i8g\":[\"設定\"],\"U5v6Gh\":[\"編輯頁面\"],\"UDMjsP\":[\"快速操作\"],\"UGT5vp\":[\"保存設定\"],\"UxKoFf\":[\"導航\"],\"VUSy8D\":[\"Search failed. Please try again.\"],\"VhMDMg\":[\"更改密碼\"],\"WDcQq9\":[\"不公開\"],\"Weq9zb\":[\"一般設定\"],\"WmZ/rP\":[\"到路徑\"],\"Y+7JGK\":[\"創建頁面\"],\"Z3FXyt\":[\"載入中...\"],\"ZQKLI1\":[\"危險區域\"],\"ZhhOwV\":[\"引用\"],\"aAIQg2\":[\"外觀\"],\"aaGV/9\":[\"新連結\"],\"an5hVd\":[\"圖片\"],\"b+/jO6\":[\"301(永久)\"],\"bHYIks\":[\"登出\"],\"biOepV\":[\"← Back to home\"],\"cnGeoo\":[\"刪除\"],\"dEgA5A\":[\"取消\"],\"e6Jr7Q\":[\"← 返回收藏夾\"],\"ePK91l\":[\"編輯\"],\"eWLklq\":[\"引用\"],\"eneWvv\":[\"草稿\"],\"er8+x7\":[\"示範帳戶已預填。只需點擊登入。\"],\"f6e0Ry\":[\"文章\"],\"fG7BxZ\":[\"Upload images via the API: POST /api/upload with a file form field.\"],\"fttd2R\":[\"我的收藏\"],\"gDx5MG\":[\"編輯連結\"],\"hG89Ed\":[\"圖片\"],\"hWOZIv\":[\"請輸入您的新密碼。\"],\"hXzOVo\":[\"下一頁\"],\"he3ygx\":[\"複製\"],\"iH8pgl\":[\"返回\"],\"ig4hg2\":[\"Let's set up your site.\"],\"jpctdh\":[\"查看\"],\"k1ifdL\":[\"處理中...\"],\"kd7eBB\":[\"建立連結\"],\"mTOYla\":[\"View all posts →\"],\"n1ekoW\":[\"登入\"],\"oJFOZk\":[\"來源名稱(選填)\"],\"oYPBa0\":[\"更新頁面\"],\"p2/GCq\":[\"確認密碼\"],\"pRhYH2\":[\"收藏中的帖子 (\",[\"count\"],\")\"],\"pZq3aX\":[\"上傳失敗。請再試一次。\"],\"qMyM2u\":[\"來源網址(選填)\"],\"qiXmlF\":[\"添加媒體\"],\"r1MpXi\":[\"安靜\"],\"rFmBG3\":[\"顏色主題\"],\"rdUucN\":[\"預覽\"],\"rzNUSl\":[\"包含 1 則貼文的主題\"],\"sGajR7\":[\"線程開始\"],\"smzF8S\":[\"顯示 \",[\"remainingCount\"],\" 個更多 \",[\"0\"]],\"ssqvZi\":[\"保存個人資料\"],\"t/YqKh\":[\"移除\"],\"tfrt7B\":[\"未配置任何重定向。\"],\"tiq7kl\":[\"頁面 \",[\"page\"]],\"u2f7vd\":[\"網站描述\"],\"u3wRF+\":[\"已發佈\"],\"u6Hp4N\":[\"Markdown\"],\"uAQUqI\":[\"狀態\"],\"vERlcd\":[\"個人資料\"],\"vXIe7J\":[\"語言\"],\"vzU4k9\":[\"新收藏集\"],\"wEF6Ix\":[\"目的地路徑或 URL\"],\"wK4OTM\":[\"標題(選填)\"],\"wM5UXj\":[\"刪除媒體\"],\"wRR604\":[\"頁面\"],\"wdGjkd\":[\"未配置導航連結。\"],\"wja8aL\":[\"無標題\"],\"x+doid\":[\"圖片會自動優化:調整大小至最大 1920 像素,轉換為 WebP 格式,並去除元數據。\"],\"x0mzE0\":[\"創建你的第一篇帖子\"],\"x4RuFo\":[\"Back to home\"],\"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\":[\"尚未有任何收藏。\"],\"+bHzpy\":[\"顯示連結的文字\"],\"+owNNn\":[\"文章\"],\"+zy2Nq\":[\"類型\"],\"/0D1Xp\":[\"編輯收藏集\"],\"/Rj5P4\":[\"您的姓名\"],\"07Epll\":[\"這將為您的網站和儀表板設置主題。所有顏色主題都支持深色模式。\"],\"0JkyS7\":[\"建立您的第一個頁面\"],\"0a6MpL\":[\"新重定向\"],\"1CU1Td\":[\"網址安全識別碼(小寫、數字、連字符)\"],\"1DBGsz\":[\"筆記\"],\"1o+wgo\":[\"例如:The Verge,約翰·多伊\"],\"2N0qpv\":[\"文章標題...\"],\"2fUwEY\":[\"選擇媒體\"],\"2q/Q7x\":[\"可見性\"],\"2rJGtU\":[\"頁面標題...\"],\"3Yvsaz\":[\"302(臨時)\"],\"4/SFQS\":[\"查看網站\"],\"40TVQj\":[\"自訂路徑(選填)\"],\"4KzVT6\":[\"刪除頁面\"],\"4b3oEV\":[\"內容\"],\"4mDPGp\":[\"此頁面的 URL 路徑。使用小寫字母、數字和連字符。\"],\"6WdDG7\":[\"頁面\"],\"6YtxFj\":[\"名稱\"],\"7G4SBz\":[\"頁面內容(支持Markdown)...\"],\"7Mk+/h\":[\"更新收藏集\"],\"7Q1KKN\":[\"來源路徑\"],\"7aECQB\":[\"無效或已過期的連結\"],\"7nGhhM\":[\"你在想什麼?\"],\"7p5kLi\":[\"儀表板\"],\"7vhWI8\":[\"新密碼\"],\"87a/t/\":[\"標籤\"],\"8ZsakT\":[\"密碼\"],\"90Luob\":[[\"count\"],\" 條回覆\"],\"A1taO8\":[\"搜尋\"],\"AeXO77\":[\"帳戶\"],\"AyHO4m\":[\"這個收藏是關於什麼的?\"],\"B373X+\":[\"編輯文章\"],\"B495Gs\":[\"檔案館\"],\"BjF0Jv\":[\"僅限小寫字母、數字和連字符\"],\"D9Oea+\":[\"永久鏈接\"],\"DCKkhU\":[\"當前密碼\"],\"DHhJ7s\":[\"上一頁\"],\"DPfwMq\":[\"完成\"],\"DoJzLz\":[\"收藏夾\"],\"E80cJw\":[\"刪除此媒體將永久從存儲中移除它。\"],\"EEYbdt\":[\"發佈\"],\"EGwzOK\":[\"完成設置\"],\"EdQY6l\":[\"None\"],\"EkH9pt\":[\"更新\"],\"FGrimz\":[\"新帖子\"],\"FkMol5\":[\"精選\"],\"Fxf4jq\":[\"描述(可選)\"],\"GA5A5H\":[\"刪除收藏夾\"],\"GX2VMa\":[\"建立您的管理員帳戶。\"],\"GbVAnd\":[\"此密碼重設連結無效或已過期。請生成一個新的連結。\"],\"GorKul\":[\"歡迎來到 Jant\"],\"GrZ6fH\":[\"新頁面\"],\"GxkJXS\":[\"上傳中...\"],\"HfyyXl\":[\"My Blog\"],\"HiETwV\":[\"安靜(正常)\"],\"Hzi9AA\":[\"未找到任何帖子。\"],\"I6gXOa\":[\"路徑\"],\"I8hDlV\":[\"至少需要 1 張圖片才能發佈圖片帖子。\"],\"IUwGEM\":[\"保存更改\"],\"IagCbF\":[\"網址\"],\"J4FNfC\":[\"此集合中沒有帖子。\"],\"JIBC/T\":[\"Supported formats: JPEG, PNG, GIF, WebP, SVG. Max size: 10MB.\"],\"Jed1wB\":[\"需要幫助嗎?請訪問<0>文檔</0>。\"],\"JiP4aa\":[\"已發佈的頁面可以通過其路徑訪問。草稿不可見。\"],\"K9NcLu\":[\"使用此 URL 將媒體嵌入到您的帖子中。\"],\"KbS2K9\":[\"重設密碼\"],\"KiJn9B\":[\"備註\"],\"KmGXnO\":[\"Are you sure you want to delete this post? This cannot be undone.\"],\"L85WcV\":[\"縮略名\"],\"LkvLQe\":[\"尚未有頁面。\"],\"M1RvTd\":[\"點擊圖片以查看完整大小\"],\"M8kJqa\":[\"草稿\"],\"M9xgHy\":[\"重定向\"],\"MHrjPM\":[\"標題\"],\"MLSRl9\":[\"Quote Text\"],\"MWBOxm\":[\"收藏(可選)\"],\"MZbQHL\":[\"未找到結果。\"],\"Mhf/H/\":[\"建立重定向\"],\"MqghUt\":[\"搜尋帖子...\"],\"N40H+G\":[\"所有\"],\"O3oNi5\":[\"電子郵件\"],\"OCNZaU\":[\"重定向來源的路徑\"],\"ODiSoW\":[\"尚未有帖子。\"],\"ONWvwQ\":[\"上傳\"],\"Pbm2/N\":[\"創建收藏夾\"],\"QEbNBb\":[\"路徑(例如 /archive)或完整網址(例如 https://example.com)\"],\"QLkhbH\":[\"The text being quoted...\"],\"RDjuBN\":[\"Setup\"],\"Rj01Fz\":[\"連結\"],\"RwGhWy\":[\"包含 \",[\"count\"],\" 則帖子的主題\"],\"SJmfuf\":[\"網站名稱\"],\"ST+lN2\":[\"尚未上傳任何媒體。\"],\"Tt5T6+\":[\"文章\"],\"TxE+Mj\":[\"1 條回覆\"],\"Tz0i8g\":[\"設定\"],\"U5v6Gh\":[\"編輯頁面\"],\"UDMjsP\":[\"快速操作\"],\"UGT5vp\":[\"保存設定\"],\"UxKoFf\":[\"導航\"],\"VUSy8D\":[\"Search failed. Please try again.\"],\"VhMDMg\":[\"更改密碼\"],\"WDcQq9\":[\"不公開\"],\"Weq9zb\":[\"一般設定\"],\"WmZ/rP\":[\"到路徑\"],\"Y+7JGK\":[\"創建頁面\"],\"Z3FXyt\":[\"載入中...\"],\"ZQKLI1\":[\"危險區域\"],\"ZhhOwV\":[\"引用\"],\"aAIQg2\":[\"外觀\"],\"aaGV/9\":[\"新連結\"],\"an5hVd\":[\"圖片\"],\"b+/jO6\":[\"301(永久)\"],\"bHYIks\":[\"登出\"],\"biOepV\":[\"← Back to home\"],\"cnGeoo\":[\"刪除\"],\"dEgA5A\":[\"取消\"],\"e6Jr7Q\":[\"← 返回收藏夾\"],\"ePK91l\":[\"編輯\"],\"eWLklq\":[\"引用\"],\"eneWvv\":[\"草稿\"],\"er8+x7\":[\"示範帳戶已預填。只需點擊登入。\"],\"f6e0Ry\":[\"文章\"],\"fG7BxZ\":[\"Upload images via the API: POST /api/upload with a file form field.\"],\"fttd2R\":[\"我的收藏\"],\"gDx5MG\":[\"編輯連結\"],\"hG89Ed\":[\"圖片\"],\"hWOZIv\":[\"請輸入您的新密碼。\"],\"hXzOVo\":[\"下一頁\"],\"he3ygx\":[\"複製\"],\"iBc+/N\":[\"Custom URL path. Leave empty to use default /p/ID format.\"],\"iDAqU6\":[\"URL (optional)\"],\"iH8pgl\":[\"返回\"],\"ig4hg2\":[\"Let's set up your site.\"],\"jSRrXo\":[\"Published pages are accessible via their slug. Drafts are not visible.\"],\"jpctdh\":[\"查看\"],\"k1ifdL\":[\"處理中...\"],\"kI1qVD\":[\"Format\"],\"kNiQp6\":[\"Pinned\"],\"kd7eBB\":[\"建立連結\"],\"mTOYla\":[\"View all posts →\"],\"mnkknn\":[\"Collection (optional)\"],\"n1ekoW\":[\"登入\"],\"oJFOZk\":[\"來源名稱(選填)\"],\"oYPBa0\":[\"更新頁面\"],\"p2/GCq\":[\"確認密碼\"],\"pRhYH2\":[\"收藏中的帖子 (\",[\"count\"],\")\"],\"pZq3aX\":[\"上傳失敗。請再試一次。\"],\"qMyM2u\":[\"來源網址(選填)\"],\"qiXmlF\":[\"添加媒體\"],\"quFPTj\":[\"Custom Slug (optional)\"],\"r1MpXi\":[\"安靜\"],\"rFmBG3\":[\"顏色主題\"],\"rdUucN\":[\"預覽\"],\"rzNUSl\":[\"包含 1 則貼文的主題\"],\"sGajR7\":[\"線程開始\"],\"smzF8S\":[\"顯示 \",[\"remainingCount\"],\" 個更多 \",[\"0\"]],\"ssqvZi\":[\"保存個人資料\"],\"t/YqKh\":[\"移除\"],\"tfrt7B\":[\"未配置任何重定向。\"],\"tiq7kl\":[\"頁面 \",[\"page\"]],\"u2f7vd\":[\"網站描述\"],\"u3wRF+\":[\"已發佈\"],\"u6Hp4N\":[\"Markdown\"],\"uAQUqI\":[\"狀態\"],\"vERlcd\":[\"個人資料\"],\"vXIe7J\":[\"語言\"],\"vzU4k9\":[\"新收藏集\"],\"wEF6Ix\":[\"目的地路徑或 URL\"],\"wK4OTM\":[\"標題(選填)\"],\"wM5UXj\":[\"刪除媒體\"],\"wRR604\":[\"頁面\"],\"wdGjkd\":[\"未配置導航連結。\"],\"wja8aL\":[\"無標題\"],\"x+doid\":[\"圖片會自動優化:調整大小至最大 1920 像素,轉換為 WebP 格式,並去除元數據。\"],\"x0mzE0\":[\"創建你的第一篇帖子\"],\"x4RuFo\":[\"Back to home\"],\"xYilR2\":[\"媒體\"],\"y28hnO\":[\"文章\"],\"yQ2kGp\":[\"載入更多\"],\"yjkELF\":[\"確認新密碼\"],\"yzF66j\":[\"連結\"],\"z8ajIE\":[\"找到 1 個結果\"],\"zH6KqE\":[\"找到 \",[\"count\"],\" 個結果\"]}")as Messages;
|
package/src/index.ts
CHANGED
|
@@ -4,35 +4,46 @@
|
|
|
4
4
|
* @packageDocumentation
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { createApp as _createApp } from "./app.js";
|
|
8
|
-
|
|
9
7
|
// Main app factory
|
|
10
8
|
export { createApp } from "./app.js";
|
|
11
9
|
export type { App, AppVariables } from "./app.js";
|
|
12
10
|
|
|
11
|
+
// Default theme
|
|
12
|
+
export { theme as threadsTheme } from "./themes/threads/index.js";
|
|
13
|
+
export type { ThemeOptions as ThreadsThemeOptions } from "./themes/threads/index.js";
|
|
14
|
+
|
|
13
15
|
// Types
|
|
14
16
|
export type {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
+
Format,
|
|
18
|
+
Status,
|
|
19
|
+
SortOrder,
|
|
20
|
+
NavItemType,
|
|
17
21
|
Bindings,
|
|
18
22
|
Post,
|
|
23
|
+
Page,
|
|
19
24
|
Media,
|
|
20
25
|
MediaAttachment,
|
|
21
26
|
PostWithMedia,
|
|
22
27
|
Collection,
|
|
23
|
-
|
|
28
|
+
NavItem,
|
|
24
29
|
Redirect,
|
|
25
30
|
Setting,
|
|
26
|
-
NavigationLink,
|
|
27
31
|
CreatePost,
|
|
28
32
|
UpdatePost,
|
|
33
|
+
CreatePage,
|
|
34
|
+
UpdatePage,
|
|
35
|
+
CreateNavItem,
|
|
36
|
+
UpdateNavItem,
|
|
37
|
+
CreateCollection,
|
|
38
|
+
UpdateCollection,
|
|
29
39
|
JantConfig,
|
|
30
40
|
JantTheme,
|
|
31
41
|
ThemeComponents,
|
|
32
42
|
// View Model types (for theme authors)
|
|
33
43
|
PostView,
|
|
44
|
+
PageView,
|
|
34
45
|
MediaView,
|
|
35
|
-
|
|
46
|
+
NavItemView,
|
|
36
47
|
SearchResultView,
|
|
37
48
|
TimelineItemView,
|
|
38
49
|
ArchiveGroup,
|
|
@@ -40,6 +51,10 @@ export type {
|
|
|
40
51
|
TimelineCardProps,
|
|
41
52
|
ThreadPreviewProps,
|
|
42
53
|
TimelineFeedProps,
|
|
54
|
+
TimelineLoadMoreProps,
|
|
55
|
+
DateGroup,
|
|
56
|
+
TimelinePatch,
|
|
57
|
+
TimelineMoreProps,
|
|
43
58
|
// Site layout
|
|
44
59
|
SiteLayoutProps,
|
|
45
60
|
// Page-level props (for theme authors)
|
|
@@ -57,10 +72,12 @@ export type {
|
|
|
57
72
|
} from "./types.js";
|
|
58
73
|
|
|
59
74
|
export {
|
|
60
|
-
|
|
61
|
-
|
|
75
|
+
FORMATS,
|
|
76
|
+
STATUSES,
|
|
77
|
+
SORT_ORDERS,
|
|
78
|
+
NAV_ITEM_TYPES,
|
|
62
79
|
MAX_MEDIA_ATTACHMENTS,
|
|
63
|
-
|
|
80
|
+
MAX_PINNED_POSTS,
|
|
64
81
|
} from "./types.js";
|
|
65
82
|
|
|
66
83
|
// Utilities (for theme authors)
|
|
@@ -75,8 +92,9 @@ export {
|
|
|
75
92
|
toPostView,
|
|
76
93
|
toPostViews,
|
|
77
94
|
toMediaView,
|
|
78
|
-
|
|
79
|
-
|
|
95
|
+
toPageView,
|
|
96
|
+
toNavItemView,
|
|
97
|
+
toNavItemViews,
|
|
80
98
|
toSearchResultView,
|
|
81
99
|
toArchiveGroups,
|
|
82
100
|
} from "./lib/view.js";
|
|
@@ -96,6 +114,3 @@ export {
|
|
|
96
114
|
defaultAtomRenderer,
|
|
97
115
|
defaultSitemapRenderer,
|
|
98
116
|
} from "./lib/feed.js";
|
|
99
|
-
|
|
100
|
-
// Default export for running core directly (e.g., for development)
|
|
101
|
-
export default _createApp();
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML Excerpt Utility Tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from "vitest";
|
|
6
|
+
import { stripHtml, getHtmlExcerpt } from "../excerpt.js";
|
|
7
|
+
|
|
8
|
+
// =============================================================================
|
|
9
|
+
// stripHtml
|
|
10
|
+
// =============================================================================
|
|
11
|
+
|
|
12
|
+
describe("stripHtml", () => {
|
|
13
|
+
it("removes simple HTML tags", () => {
|
|
14
|
+
expect(stripHtml("<p>Hello world</p>")).toBe("Hello world");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("removes nested tags", () => {
|
|
18
|
+
expect(stripHtml("<p>Hello <strong>world</strong></p>")).toBe(
|
|
19
|
+
"Hello world",
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("handles self-closing tags", () => {
|
|
24
|
+
expect(stripHtml("Hello<br/>world")).toBe("Helloworld");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("returns empty string for empty input", () => {
|
|
28
|
+
expect(stripHtml("")).toBe("");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("returns plain text unchanged", () => {
|
|
32
|
+
expect(stripHtml("no tags here")).toBe("no tags here");
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// =============================================================================
|
|
37
|
+
// getHtmlExcerpt
|
|
38
|
+
// =============================================================================
|
|
39
|
+
|
|
40
|
+
describe("getHtmlExcerpt", () => {
|
|
41
|
+
it("returns short content as-is with hasMore=false", () => {
|
|
42
|
+
const html = "<p>Short post.</p>";
|
|
43
|
+
const result = getHtmlExcerpt(html);
|
|
44
|
+
expect(result.excerpt).toBe("<p>Short post.</p>");
|
|
45
|
+
expect(result.hasMore).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("takes first two paragraphs when total fits under 500 chars", () => {
|
|
49
|
+
const p1 = `<p>${"A".repeat(200)}</p>`;
|
|
50
|
+
const p2 = `<p>${"B".repeat(200)}</p>`;
|
|
51
|
+
const p3 = `<p>${"C".repeat(200)}</p>`;
|
|
52
|
+
const html = p1 + p2 + p3;
|
|
53
|
+
|
|
54
|
+
const result = getHtmlExcerpt(html);
|
|
55
|
+
expect(result.excerpt).toBe(p1 + p2);
|
|
56
|
+
expect(result.hasMore).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("keeps at least one paragraph even if it exceeds 500 chars", () => {
|
|
60
|
+
const p1 = `<p>${"A".repeat(600)}</p>`;
|
|
61
|
+
const p2 = `<p>${"B".repeat(100)}</p>`;
|
|
62
|
+
const html = p1 + p2;
|
|
63
|
+
|
|
64
|
+
const result = getHtmlExcerpt(html);
|
|
65
|
+
expect(result.excerpt).toBe(p1);
|
|
66
|
+
expect(result.hasMore).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("returns all paragraphs when total is under 500 chars", () => {
|
|
70
|
+
const p1 = "<p>First paragraph.</p>";
|
|
71
|
+
const p2 = "<p>Second paragraph.</p>";
|
|
72
|
+
const p3 = "<p>Third paragraph.</p>";
|
|
73
|
+
const html = p1 + p2 + p3;
|
|
74
|
+
|
|
75
|
+
const result = getHtmlExcerpt(html);
|
|
76
|
+
expect(result.excerpt).toBe(html);
|
|
77
|
+
expect(result.hasMore).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("honors <!--more--> marker", () => {
|
|
81
|
+
const html = "<p>Intro paragraph.</p><!--more--><p>Rest of the post.</p>";
|
|
82
|
+
const result = getHtmlExcerpt(html);
|
|
83
|
+
expect(result.excerpt).toBe("<p>Intro paragraph.</p>");
|
|
84
|
+
expect(result.hasMore).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("handles content with no paragraph tags", () => {
|
|
88
|
+
const html = "Just plain text without paragraphs";
|
|
89
|
+
const result = getHtmlExcerpt(html);
|
|
90
|
+
expect(result.excerpt).toBe(html);
|
|
91
|
+
expect(result.hasMore).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("handles single paragraph under 500 chars", () => {
|
|
95
|
+
const html = `<p>${"A".repeat(100)}</p>`;
|
|
96
|
+
const result = getHtmlExcerpt(html);
|
|
97
|
+
expect(result.excerpt).toBe(html);
|
|
98
|
+
expect(result.hasMore).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("counts plain text length, not HTML length", () => {
|
|
102
|
+
// Each paragraph has ~240 chars of plain text but more in HTML
|
|
103
|
+
const p1 = `<p>${"A".repeat(240)}</p>`;
|
|
104
|
+
const p2 = `<p>${"B".repeat(240)}</p>`;
|
|
105
|
+
const p3 = `<p>${"C".repeat(240)}</p>`;
|
|
106
|
+
const html = p1 + p2 + p3;
|
|
107
|
+
|
|
108
|
+
const result = getHtmlExcerpt(html);
|
|
109
|
+
// 240 + 240 = 480 < 500, so first two paragraphs fit
|
|
110
|
+
expect(result.excerpt).toBe(p1 + p2);
|
|
111
|
+
expect(result.hasMore).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("handles paragraphs with nested HTML elements", () => {
|
|
115
|
+
const p1 = `<p>Hello <strong>bold</strong> and <em>italic</em> text here.</p>`;
|
|
116
|
+
const p2 = `<p>${"B".repeat(500)}</p>`;
|
|
117
|
+
const html = p1 + p2;
|
|
118
|
+
|
|
119
|
+
const result = getHtmlExcerpt(html);
|
|
120
|
+
// First paragraph text is ~37 chars, second is 500 chars
|
|
121
|
+
// Total would exceed 500, but first paragraph was already added
|
|
122
|
+
expect(result.excerpt).toBe(p1);
|
|
123
|
+
expect(result.hasMore).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -1,45 +1,41 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
FormatSchema,
|
|
4
|
+
StatusSchema,
|
|
5
5
|
RedirectTypeSchema,
|
|
6
6
|
CreatePostSchema,
|
|
7
7
|
UpdatePostSchema,
|
|
8
8
|
parseFormData,
|
|
9
9
|
parseFormDataOptional,
|
|
10
|
-
|
|
10
|
+
validateMediaCount,
|
|
11
11
|
} from "../schemas.js";
|
|
12
12
|
import { z } from "zod";
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
describe("PostTypeSchema", () => {
|
|
20
|
-
it("accepts all valid post types", () => {
|
|
21
|
-
for (const type of POST_TYPES) {
|
|
22
|
-
expect(PostTypeSchema.parse(type)).toBe(type);
|
|
13
|
+
import { FORMATS, STATUSES, MAX_MEDIA_ATTACHMENTS } from "../../types.js";
|
|
14
|
+
|
|
15
|
+
describe("FormatSchema", () => {
|
|
16
|
+
it("accepts all valid formats", () => {
|
|
17
|
+
for (const format of FORMATS) {
|
|
18
|
+
expect(FormatSchema.parse(format)).toBe(format);
|
|
23
19
|
}
|
|
24
20
|
});
|
|
25
21
|
|
|
26
|
-
it("rejects invalid
|
|
27
|
-
expect(() =>
|
|
28
|
-
expect(() =>
|
|
29
|
-
expect(() =>
|
|
22
|
+
it("rejects invalid formats", () => {
|
|
23
|
+
expect(() => FormatSchema.parse("invalid")).toThrow();
|
|
24
|
+
expect(() => FormatSchema.parse("")).toThrow();
|
|
25
|
+
expect(() => FormatSchema.parse(123)).toThrow();
|
|
30
26
|
});
|
|
31
27
|
});
|
|
32
28
|
|
|
33
|
-
describe("
|
|
34
|
-
it("accepts all valid
|
|
35
|
-
for (const
|
|
36
|
-
expect(
|
|
29
|
+
describe("StatusSchema", () => {
|
|
30
|
+
it("accepts all valid statuses", () => {
|
|
31
|
+
for (const status of STATUSES) {
|
|
32
|
+
expect(StatusSchema.parse(status)).toBe(status);
|
|
37
33
|
}
|
|
38
34
|
});
|
|
39
35
|
|
|
40
|
-
it("rejects invalid
|
|
41
|
-
expect(() =>
|
|
42
|
-
expect(() =>
|
|
36
|
+
it("rejects invalid statuses", () => {
|
|
37
|
+
expect(() => StatusSchema.parse("public")).toThrow();
|
|
38
|
+
expect(() => StatusSchema.parse("private")).toThrow();
|
|
43
39
|
});
|
|
44
40
|
});
|
|
45
41
|
|
|
@@ -58,22 +54,22 @@ describe("RedirectTypeSchema", () => {
|
|
|
58
54
|
|
|
59
55
|
describe("CreatePostSchema", () => {
|
|
60
56
|
const validPost = {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
57
|
+
format: "note",
|
|
58
|
+
body: "Hello world",
|
|
59
|
+
status: "published",
|
|
64
60
|
};
|
|
65
61
|
|
|
66
62
|
it("accepts a valid post with required fields", () => {
|
|
67
63
|
const result = CreatePostSchema.parse(validPost);
|
|
68
|
-
expect(result.
|
|
69
|
-
expect(result.
|
|
70
|
-
expect(result.
|
|
64
|
+
expect(result.format).toBe("note");
|
|
65
|
+
expect(result.body).toBe("Hello world");
|
|
66
|
+
expect(result.status).toBe("published");
|
|
71
67
|
});
|
|
72
68
|
|
|
73
|
-
it("accepts all
|
|
74
|
-
for (const
|
|
69
|
+
it("accepts all formats", () => {
|
|
70
|
+
for (const format of FORMATS) {
|
|
75
71
|
expect(() =>
|
|
76
|
-
CreatePostSchema.parse({ ...validPost,
|
|
72
|
+
CreatePostSchema.parse({ ...validPost, format }),
|
|
77
73
|
).not.toThrow();
|
|
78
74
|
}
|
|
79
75
|
});
|
|
@@ -86,47 +82,67 @@ describe("CreatePostSchema", () => {
|
|
|
86
82
|
expect(result.title).toBe("My Post");
|
|
87
83
|
});
|
|
88
84
|
|
|
89
|
-
it("accepts valid
|
|
85
|
+
it("accepts valid slug format", () => {
|
|
90
86
|
const result = CreatePostSchema.parse({
|
|
91
87
|
...validPost,
|
|
92
|
-
|
|
88
|
+
slug: "my-post-slug",
|
|
93
89
|
});
|
|
94
|
-
expect(result.
|
|
90
|
+
expect(result.slug).toBe("my-post-slug");
|
|
95
91
|
});
|
|
96
92
|
|
|
97
|
-
it("accepts
|
|
98
|
-
const result = CreatePostSchema.parse({
|
|
99
|
-
|
|
93
|
+
it("accepts single-character slug", () => {
|
|
94
|
+
const result = CreatePostSchema.parse({
|
|
95
|
+
...validPost,
|
|
96
|
+
slug: "a",
|
|
97
|
+
});
|
|
98
|
+
expect(result.slug).toBe("a");
|
|
100
99
|
});
|
|
101
100
|
|
|
102
|
-
it("
|
|
101
|
+
it("accepts empty slug (transforms to undefined)", () => {
|
|
102
|
+
const result = CreatePostSchema.parse({ ...validPost, slug: "" });
|
|
103
|
+
expect(result.slug).toBeUndefined();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("rejects invalid slug format (uppercase)", () => {
|
|
103
107
|
expect(() =>
|
|
104
|
-
CreatePostSchema.parse({ ...validPost,
|
|
108
|
+
CreatePostSchema.parse({ ...validPost, slug: "MyPost" }),
|
|
105
109
|
).toThrow();
|
|
106
110
|
});
|
|
107
111
|
|
|
108
|
-
it("rejects invalid
|
|
112
|
+
it("rejects invalid slug format (special chars)", () => {
|
|
109
113
|
expect(() =>
|
|
110
|
-
CreatePostSchema.parse({ ...validPost,
|
|
114
|
+
CreatePostSchema.parse({ ...validPost, slug: "my post!" }),
|
|
111
115
|
).toThrow();
|
|
112
116
|
});
|
|
113
117
|
|
|
114
|
-
it("
|
|
118
|
+
it("rejects slug starting with hyphen", () => {
|
|
119
|
+
expect(() =>
|
|
120
|
+
CreatePostSchema.parse({ ...validPost, slug: "-my-post" }),
|
|
121
|
+
).toThrow();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("rejects slug ending with hyphen", () => {
|
|
125
|
+
expect(() =>
|
|
126
|
+
CreatePostSchema.parse({ ...validPost, slug: "my-post-" }),
|
|
127
|
+
).toThrow();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("accepts valid url", () => {
|
|
115
131
|
const result = CreatePostSchema.parse({
|
|
116
132
|
...validPost,
|
|
117
|
-
|
|
133
|
+
url: "https://example.com",
|
|
118
134
|
});
|
|
119
|
-
expect(result.
|
|
135
|
+
expect(result.url).toBe("https://example.com");
|
|
120
136
|
});
|
|
121
137
|
|
|
122
|
-
it("accepts empty
|
|
123
|
-
const result = CreatePostSchema.parse({ ...validPost,
|
|
124
|
-
expect(result.
|
|
138
|
+
it("accepts empty url", () => {
|
|
139
|
+
const result = CreatePostSchema.parse({ ...validPost, url: "" });
|
|
140
|
+
expect(result.url).toBe("");
|
|
125
141
|
});
|
|
126
142
|
|
|
127
|
-
it("rejects invalid
|
|
143
|
+
it("rejects invalid url", () => {
|
|
128
144
|
expect(() =>
|
|
129
|
-
CreatePostSchema.parse({ ...validPost,
|
|
145
|
+
CreatePostSchema.parse({ ...validPost, url: "not-a-url" }),
|
|
130
146
|
).toThrow();
|
|
131
147
|
});
|
|
132
148
|
|
|
@@ -181,10 +197,77 @@ describe("CreatePostSchema", () => {
|
|
|
181
197
|
).toThrow();
|
|
182
198
|
});
|
|
183
199
|
|
|
184
|
-
it("
|
|
200
|
+
it("accepts featured as boolean", () => {
|
|
201
|
+
const result = CreatePostSchema.parse({ ...validPost, featured: true });
|
|
202
|
+
expect(result.featured).toBe(true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("accepts featured as 'on' (transforms to true)", () => {
|
|
206
|
+
const result = CreatePostSchema.parse({ ...validPost, featured: "on" });
|
|
207
|
+
expect(result.featured).toBe(true);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("accepts pinned as boolean", () => {
|
|
211
|
+
const result = CreatePostSchema.parse({ ...validPost, pinned: true });
|
|
212
|
+
expect(result.pinned).toBe(true);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("accepts pinned as 'on' (transforms to true)", () => {
|
|
216
|
+
const result = CreatePostSchema.parse({ ...validPost, pinned: "on" });
|
|
217
|
+
expect(result.pinned).toBe(true);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("accepts optional quoteText", () => {
|
|
221
|
+
const result = CreatePostSchema.parse({
|
|
222
|
+
...validPost,
|
|
223
|
+
quoteText: "A wise person once said...",
|
|
224
|
+
});
|
|
225
|
+
expect(result.quoteText).toBe("A wise person once said...");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("accepts optional rating (1-5)", () => {
|
|
229
|
+
for (const rating of [1, 2, 3, 4, 5]) {
|
|
230
|
+
const result = CreatePostSchema.parse({ ...validPost, rating });
|
|
231
|
+
expect(result.rating).toBe(rating);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("rejects rating outside 1-5 range", () => {
|
|
236
|
+
expect(() => CreatePostSchema.parse({ ...validPost, rating: 0 })).toThrow();
|
|
237
|
+
expect(() => CreatePostSchema.parse({ ...validPost, rating: 6 })).toThrow();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("accepts empty string rating (transforms to undefined)", () => {
|
|
241
|
+
const result = CreatePostSchema.parse({ ...validPost, rating: "" });
|
|
242
|
+
expect(result.rating).toBeUndefined();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("accepts optional collectionId as positive integer", () => {
|
|
246
|
+
const result = CreatePostSchema.parse({ ...validPost, collectionId: 42 });
|
|
247
|
+
expect(result.collectionId).toBe(42);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("accepts empty string collectionId (transforms to undefined)", () => {
|
|
251
|
+
const result = CreatePostSchema.parse({ ...validPost, collectionId: "" });
|
|
252
|
+
expect(result.collectionId).toBeUndefined();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("accepts optional replyToId", () => {
|
|
256
|
+
const result = CreatePostSchema.parse({
|
|
257
|
+
...validPost,
|
|
258
|
+
replyToId: "abc123",
|
|
259
|
+
});
|
|
260
|
+
expect(result.replyToId).toBe("abc123");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("only requires format field", () => {
|
|
264
|
+
const result = CreatePostSchema.parse({ format: "note" });
|
|
265
|
+
expect(result.format).toBe("note");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("rejects missing format", () => {
|
|
185
269
|
expect(() => CreatePostSchema.parse({})).toThrow();
|
|
186
|
-
expect(() => CreatePostSchema.parse({
|
|
187
|
-
expect(() => CreatePostSchema.parse({ content: "hello" })).toThrow();
|
|
270
|
+
expect(() => CreatePostSchema.parse({ body: "hello" })).toThrow();
|
|
188
271
|
});
|
|
189
272
|
});
|
|
190
273
|
|
|
@@ -199,13 +282,13 @@ describe("UpdatePostSchema", () => {
|
|
|
199
282
|
expect(result.title).toBe("New Title");
|
|
200
283
|
});
|
|
201
284
|
|
|
202
|
-
it("accepts only
|
|
203
|
-
const result = UpdatePostSchema.parse({
|
|
204
|
-
expect(result.
|
|
285
|
+
it("accepts only format", () => {
|
|
286
|
+
const result = UpdatePostSchema.parse({ format: "link" });
|
|
287
|
+
expect(result.format).toBe("link");
|
|
205
288
|
});
|
|
206
289
|
|
|
207
290
|
it("still validates field types", () => {
|
|
208
|
-
expect(() => UpdatePostSchema.parse({
|
|
291
|
+
expect(() => UpdatePostSchema.parse({ format: "invalid" })).toThrow();
|
|
209
292
|
});
|
|
210
293
|
});
|
|
211
294
|
|
|
@@ -225,8 +308,8 @@ describe("parseFormData", () => {
|
|
|
225
308
|
|
|
226
309
|
it("throws for invalid value", () => {
|
|
227
310
|
const form = new FormData();
|
|
228
|
-
form.set("
|
|
229
|
-
expect(() => parseFormData(form, "
|
|
311
|
+
form.set("format", "invalid-format");
|
|
312
|
+
expect(() => parseFormData(form, "format", FormatSchema)).toThrow();
|
|
230
313
|
});
|
|
231
314
|
});
|
|
232
315
|
|
|
@@ -250,59 +333,37 @@ describe("parseFormDataOptional", () => {
|
|
|
250
333
|
|
|
251
334
|
it("throws for invalid value", () => {
|
|
252
335
|
const form = new FormData();
|
|
253
|
-
form.set("
|
|
254
|
-
expect(() => parseFormDataOptional(form, "
|
|
336
|
+
form.set("format", "invalid");
|
|
337
|
+
expect(() => parseFormDataOptional(form, "format", FormatSchema)).toThrow();
|
|
255
338
|
});
|
|
256
339
|
});
|
|
257
340
|
|
|
258
|
-
describe("
|
|
259
|
-
it("returns null for
|
|
260
|
-
expect(
|
|
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();
|
|
341
|
+
describe("validateMediaCount", () => {
|
|
342
|
+
it("returns null for empty media array", () => {
|
|
343
|
+
expect(validateMediaCount([])).toBeNull();
|
|
269
344
|
});
|
|
270
345
|
|
|
271
|
-
it("returns null for
|
|
272
|
-
|
|
346
|
+
it("returns null for media within limit", () => {
|
|
347
|
+
const media = Array.from({ length: 5 }, (_, i) => `id-${i}`);
|
|
348
|
+
expect(validateMediaCount(media)).toBeNull();
|
|
273
349
|
});
|
|
274
350
|
|
|
275
|
-
it("returns
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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();
|
|
351
|
+
it("returns null for exactly MAX_MEDIA_ATTACHMENTS", () => {
|
|
352
|
+
const media = Array.from(
|
|
353
|
+
{ length: MAX_MEDIA_ATTACHMENTS },
|
|
354
|
+
(_, i) => `id-${i}`,
|
|
355
|
+
);
|
|
356
|
+
expect(validateMediaCount(media)).toBeNull();
|
|
301
357
|
});
|
|
302
358
|
|
|
303
|
-
it("returns error when exceeding
|
|
304
|
-
const tooMany = Array.from(
|
|
305
|
-
|
|
306
|
-
|
|
359
|
+
it("returns error when exceeding MAX_MEDIA_ATTACHMENTS", () => {
|
|
360
|
+
const tooMany = Array.from(
|
|
361
|
+
{ length: MAX_MEDIA_ATTACHMENTS + 1 },
|
|
362
|
+
(_, i) => `id-${i}`,
|
|
363
|
+
);
|
|
364
|
+
const error = validateMediaCount(tooMany);
|
|
365
|
+
expect(error).toBe(
|
|
366
|
+
`Posts allow at most ${MAX_MEDIA_ATTACHMENTS} media attachments`,
|
|
367
|
+
);
|
|
307
368
|
});
|
|
308
369
|
});
|