@jant/core 0.6.16 → 0.7.0

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 (508) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/import-site.js +60 -4
  4. package/bin/commands/reset-password.js +21 -8
  5. package/bin/commands/site/snapshot/export.js +1 -1
  6. package/bin/commands/site/snapshot/import.js +1 -1
  7. package/bin/lib/d1-query.js +5 -16
  8. package/bin/lib/node-env.js +40 -4
  9. package/bin/lib/site-selection.js +85 -29
  10. package/bin/lib/wrangler-cli.js +28 -2
  11. package/bin/lib/wrangler-json.js +35 -0
  12. package/dist/{app-C7_4oab9.js → app-K_Aa1MMn.js} +52476 -13330
  13. package/dist/client/.vite/manifest.json +255 -90
  14. package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
  15. package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
  16. package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
  17. package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
  18. package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
  19. package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
  20. package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
  21. package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
  22. package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
  23. package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
  24. package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
  25. package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
  26. package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
  27. package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
  28. package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
  29. package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
  30. package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
  31. package/dist/client/_assets/client-GiYENVw8.css +2 -0
  32. package/dist/client/_assets/client-VnFxJN7G.js +295 -0
  33. package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
  34. package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
  35. package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
  36. package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
  37. package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
  38. package/dist/{export-Dl5KCiEs.js → github-sync-BPAvT999.js} +2254 -107
  39. package/dist/index.js +2 -5
  40. package/dist/node.js +5 -7
  41. package/package.json +3 -3
  42. package/src/__tests__/auth-session-freshness.test.ts +57 -0
  43. package/src/__tests__/basecoat-variants.test.ts +121 -0
  44. package/src/__tests__/bin/assets-upload.test.ts +68 -0
  45. package/src/__tests__/export-hugo-build.test.ts +260 -0
  46. package/src/__tests__/export-service.test.ts +231 -0
  47. package/src/__tests__/helpers/component-css.ts +91 -0
  48. package/src/__tests__/helpers/db.ts +33 -0
  49. package/src/__tests__/helpers/export-fixtures.ts +3 -0
  50. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  51. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  52. package/src/app.tsx +71 -16
  53. package/src/auth.ts +18 -1
  54. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  55. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  56. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  57. package/src/client/__tests__/compose-bridge.test.ts +272 -0
  58. package/src/client/__tests__/compose-launch.test.ts +62 -1
  59. package/src/client/__tests__/compose-shortcuts.test.ts +38 -9
  60. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  61. package/src/client/__tests__/copy-field.test.ts +162 -0
  62. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  63. package/src/client/__tests__/image-processor.test.ts +324 -2
  64. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  65. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  66. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  67. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  68. package/src/client/__tests__/toast.test.ts +23 -0
  69. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  70. package/src/client/__tests__/video-processor.test.ts +181 -0
  71. package/src/client/audio-processor.ts +24 -15
  72. package/src/client/audio-waveform.ts +49 -0
  73. package/src/client/collection-dialog-host.ts +72 -0
  74. package/src/client/collection-navigation.ts +51 -16
  75. package/src/client/collection-page-actions.ts +26 -0
  76. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  77. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  78. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  79. package/src/client/components/__tests__/jant-command-palette.test.ts +28 -0
  80. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  81. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1250 -53
  82. package/src/client/components/__tests__/jant-compose-editor.test.ts +544 -40
  83. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +0 -1
  84. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  85. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  86. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  87. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  88. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  89. package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
  90. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  91. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  92. package/src/client/components/collection-dialog-types.ts +49 -0
  93. package/src/client/components/collection-manager-types.ts +32 -1
  94. package/src/client/components/collection-types.ts +1 -15
  95. package/src/client/components/compose-types.ts +75 -1
  96. package/src/client/components/jant-collection-dialog.ts +722 -0
  97. package/src/client/components/jant-collection-directory.ts +567 -182
  98. package/src/client/components/jant-collection-form.ts +53 -232
  99. package/src/client/components/jant-command-palette.ts +100 -49
  100. package/src/client/components/jant-compose-dialog.ts +1486 -707
  101. package/src/client/components/jant-compose-editor.ts +673 -336
  102. package/src/client/components/jant-compose-fullscreen.ts +51 -43
  103. package/src/client/components/jant-config-editor.ts +65 -53
  104. package/src/client/components/jant-confirm-dialog.ts +13 -11
  105. package/src/client/components/jant-locale-picker.ts +418 -0
  106. package/src/client/components/jant-media-lightbox.ts +146 -136
  107. package/src/client/components/jant-nav-manager.ts +690 -366
  108. package/src/client/components/jant-post-menu.ts +1086 -282
  109. package/src/client/components/jant-post-picker.ts +324 -0
  110. package/src/client/components/jant-repo-picker.ts +138 -106
  111. package/src/client/components/jant-settings-avatar.ts +31 -27
  112. package/src/client/components/jant-settings-general.ts +617 -543
  113. package/src/client/components/jant-settings-language.ts +1087 -0
  114. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  115. package/src/client/components/jant-text-preview.ts +94 -86
  116. package/src/client/components/nav-manager-types.ts +62 -1
  117. package/src/client/components/settings-types.ts +14 -29
  118. package/src/client/components/smart-collection-conditions.ts +285 -0
  119. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  120. package/src/client/compose-bridge.ts +214 -141
  121. package/src/client/compose-launch.ts +76 -2
  122. package/src/client/compose-shortcuts.ts +6 -8
  123. package/src/client/compose-triggers.ts +50 -0
  124. package/src/client/copy-field.ts +108 -0
  125. package/src/client/custom-url-menu.ts +60 -4
  126. package/src/client/icons.ts +14 -0
  127. package/src/client/image-plan.ts +165 -0
  128. package/src/client/image-processor.ts +195 -117
  129. package/src/client/image-worker-client.ts +45 -0
  130. package/src/client/image-worker.ts +135 -0
  131. package/src/client/lazy-entries.ts +95 -0
  132. package/src/client/lazy-slugify.ts +2 -2
  133. package/src/client/media-scroll-hint.ts +43 -2
  134. package/src/client/mediabunny.ts +29 -0
  135. package/src/client/post-picker.ts +41 -0
  136. package/src/client/runtime-paths.ts +74 -1
  137. package/src/client/settings-bridge.ts +4 -10
  138. package/src/client/site-header-nav.js +18 -6
  139. package/src/client/smart-collection-dialog-host.ts +116 -0
  140. package/src/client/smart-collection-page-actions.ts +208 -0
  141. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  142. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  143. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  144. package/src/client/tiptap/bubble-menu.ts +47 -16
  145. package/src/client/tiptap/floating-position.ts +216 -2
  146. package/src/client/tiptap/inline-image-upload.ts +7 -3
  147. package/src/client/tiptap/link-toolbar.ts +22 -9
  148. package/src/client/toast.ts +13 -0
  149. package/src/client/types/vite-worker.d.ts +12 -0
  150. package/src/client/upload-with-metadata.ts +3 -13
  151. package/src/client/video-processor.ts +351 -107
  152. package/src/client-auth.ts +14 -31
  153. package/src/client-compose.ts +19 -0
  154. package/src/client-manage.ts +13 -0
  155. package/src/client-settings.ts +14 -0
  156. package/src/client-site.ts +10 -3
  157. package/src/client.ts +4 -0
  158. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  159. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  160. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  161. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  162. package/src/db/migrations/0032_married_tomas.sql +3 -0
  163. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  164. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  165. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  166. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  167. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  168. package/src/db/migrations/meta/_journal.json +21 -0
  169. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  170. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  171. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  172. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  173. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  174. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  175. package/src/db/migrations/pg/meta/_journal.json +21 -0
  176. package/src/db/pg/schema.ts +195 -27
  177. package/src/db/post-visibility.ts +117 -0
  178. package/src/db/schema.ts +222 -27
  179. package/src/i18n/__tests__/detect.test.ts +26 -81
  180. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  181. package/src/i18n/context.tsx +12 -2
  182. package/src/i18n/detect.ts +7 -66
  183. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  184. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  185. package/src/i18n/locales/public/en.po +490 -158
  186. package/src/i18n/locales/public/en.ts +1 -1
  187. package/src/i18n/locales/public/zh-Hans.po +487 -155
  188. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  189. package/src/i18n/locales/public/zh-Hant.po +487 -155
  190. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  191. package/src/i18n/locales/settings/en.po +551 -69
  192. package/src/i18n/locales/settings/en.ts +1 -1
  193. package/src/i18n/locales/settings/zh-Hans.po +597 -115
  194. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  195. package/src/i18n/locales/settings/zh-Hant.po +610 -128
  196. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  197. package/src/i18n/locales.ts +94 -0
  198. package/src/i18n/middleware.ts +12 -2
  199. package/src/i18n/supported-locales.ts +80 -0
  200. package/src/lib/__tests__/api-settings.test.ts +11 -2
  201. package/src/lib/__tests__/asset-path.test.ts +92 -0
  202. package/src/lib/__tests__/deferred.test.ts +90 -0
  203. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  204. package/src/lib/__tests__/discover.test.ts +410 -0
  205. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  206. package/src/lib/__tests__/feed.test.ts +1526 -15
  207. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  208. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  209. package/src/lib/__tests__/http-cache.test.ts +162 -0
  210. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  211. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  212. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  213. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  214. package/src/lib/__tests__/navigation.test.ts +135 -1
  215. package/src/lib/__tests__/resolve-config.test.ts +64 -3
  216. package/src/lib/__tests__/schemas.test.ts +42 -4
  217. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  218. package/src/lib/__tests__/slug.test.ts +70 -0
  219. package/src/lib/__tests__/slugify.test.ts +85 -0
  220. package/src/lib/__tests__/storage.test.ts +32 -0
  221. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  222. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  223. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  224. package/src/lib/__tests__/url.test.ts +78 -72
  225. package/src/lib/__tests__/view-language.test.ts +413 -0
  226. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  227. package/src/lib/address.ts +57 -0
  228. package/src/lib/api-posts.ts +12 -0
  229. package/src/lib/asset-path.ts +98 -1
  230. package/src/lib/build-env.ts +16 -0
  231. package/src/lib/collection-groups.ts +5 -1
  232. package/src/lib/collection-paths.ts +3 -10
  233. package/src/lib/constants.ts +28 -3
  234. package/src/lib/copy-field.ts +36 -0
  235. package/src/lib/crypto.ts +40 -0
  236. package/src/lib/csp-builder.ts +2 -1
  237. package/src/lib/deferred.ts +79 -0
  238. package/src/lib/discover-ping.ts +141 -0
  239. package/src/lib/discover.ts +437 -0
  240. package/src/lib/env.ts +134 -0
  241. package/src/lib/errors.ts +33 -0
  242. package/src/lib/feed-policy.ts +236 -2
  243. package/src/lib/feed.ts +926 -73
  244. package/src/lib/filter-dimensions.ts +1349 -0
  245. package/src/lib/github-sync-site-config.ts +3 -0
  246. package/src/lib/hosted-signin.ts +1 -1
  247. package/src/lib/http-cache.ts +148 -0
  248. package/src/lib/hugo-markdown.ts +16 -0
  249. package/src/lib/icons.ts +9 -2
  250. package/src/lib/ids.ts +3 -0
  251. package/src/lib/jant-docs.ts +42 -0
  252. package/src/lib/lang-detect.ts +308 -0
  253. package/src/lib/media-helpers.ts +13 -1
  254. package/src/lib/navigation.ts +78 -11
  255. package/src/lib/pagination.ts +28 -24
  256. package/src/lib/per-language-surfaces.ts +67 -0
  257. package/src/lib/post-meta.ts +19 -0
  258. package/src/lib/render.tsx +55 -7
  259. package/src/lib/resolve-config.ts +53 -5
  260. package/src/lib/schemas.ts +201 -10
  261. package/src/lib/site-header-fragment.tsx +2 -1
  262. package/src/lib/site-unavailable-page.ts +47 -0
  263. package/src/lib/slug.ts +51 -7
  264. package/src/lib/slugify.ts +113 -0
  265. package/src/lib/startup-config.ts +4 -6
  266. package/src/lib/storage.ts +7 -0
  267. package/src/lib/summary.ts +50 -0
  268. package/src/lib/thread-fold.ts +161 -0
  269. package/src/lib/time.ts +32 -0
  270. package/src/lib/timeline.ts +42 -1
  271. package/src/lib/tiptap-render.ts +1 -2
  272. package/src/lib/upload.ts +16 -13
  273. package/src/lib/url.ts +139 -86
  274. package/src/lib/version.ts +10 -8
  275. package/src/lib/view-language.ts +436 -0
  276. package/src/lib/view.ts +51 -23
  277. package/src/lib/viewer-context.ts +62 -0
  278. package/src/middleware/__tests__/auth.test.ts +70 -2
  279. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  280. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  281. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  282. package/src/middleware/__tests__/session.test.ts +181 -33
  283. package/src/middleware/auth.ts +50 -31
  284. package/src/middleware/cache-control.ts +75 -10
  285. package/src/middleware/config.ts +6 -6
  286. package/src/middleware/error-handler.ts +12 -0
  287. package/src/middleware/onboarding.ts +32 -10
  288. package/src/middleware/secure-headers.ts +1 -1
  289. package/src/middleware/session.ts +96 -8
  290. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  291. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  292. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  293. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  294. package/src/node/__tests__/runtime.test.ts +37 -0
  295. package/src/node/index.ts +1 -0
  296. package/src/preset.css +76 -22
  297. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  298. package/src/routes/api/__tests__/collections.test.ts +114 -0
  299. package/src/routes/api/__tests__/discover.test.ts +222 -0
  300. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  301. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  302. package/src/routes/api/__tests__/settings.test.ts +24 -3
  303. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  304. package/src/routes/api/collections.ts +49 -6
  305. package/src/routes/api/custom-urls.ts +0 -1
  306. package/src/routes/api/discover.ts +95 -0
  307. package/src/routes/api/export.ts +3 -0
  308. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  309. package/src/routes/api/nav-items.ts +55 -8
  310. package/src/routes/api/posts.ts +159 -0
  311. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  312. package/src/routes/api/public/archive.ts +77 -81
  313. package/src/routes/api/public/posts.ts +23 -6
  314. package/src/routes/api/smart-collections.ts +132 -0
  315. package/src/routes/api/telegram.ts +2 -40
  316. package/src/routes/api/uploads.ts +1 -2
  317. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  318. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  319. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  320. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  321. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  322. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  323. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  324. package/src/routes/auth/setup.tsx +934 -186
  325. package/src/routes/auth/signin.tsx +10 -8
  326. package/src/routes/compose.tsx +5 -0
  327. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  328. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  329. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  330. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  331. package/src/routes/dash/custom-urls.tsx +1 -37
  332. package/src/routes/dash/settings.tsx +502 -41
  333. package/src/routes/discover-announce.ts +96 -0
  334. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  335. package/src/routes/feed/feed.ts +69 -40
  336. package/src/routes/feed/sitemap.ts +95 -11
  337. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  338. package/src/routes/pages/__tests__/archive-params.test.ts +370 -6
  339. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  340. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  341. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  342. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  343. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  344. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  345. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  346. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  347. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  348. package/src/routes/pages/archive.tsx +471 -369
  349. package/src/routes/pages/collection.tsx +94 -96
  350. package/src/routes/pages/collections.tsx +38 -50
  351. package/src/routes/pages/featured.tsx +32 -13
  352. package/src/routes/pages/home.tsx +25 -6
  353. package/src/routes/pages/language.tsx +113 -0
  354. package/src/routes/pages/latest.tsx +26 -16
  355. package/src/routes/pages/new.tsx +3 -0
  356. package/src/routes/pages/page.tsx +178 -17
  357. package/src/routes/pages/partials.tsx +41 -0
  358. package/src/routes/pages/search.tsx +18 -3
  359. package/src/routes/pages/smart-collection.tsx +340 -0
  360. package/src/routes/pages/subscribe.tsx +182 -0
  361. package/src/runtime/__tests__/index.test.ts +19 -0
  362. package/src/runtime/__tests__/node.test.ts +129 -4
  363. package/src/runtime/cloudflare.ts +21 -2
  364. package/src/runtime/index.ts +27 -1
  365. package/src/runtime/node.ts +18 -1
  366. package/src/runtime/site.ts +95 -24
  367. package/src/services/__tests__/auth.test.ts +116 -7
  368. package/src/services/__tests__/collection.test.ts +108 -4
  369. package/src/services/__tests__/language.test.ts +495 -0
  370. package/src/services/__tests__/navigation.test.ts +213 -10
  371. package/src/services/__tests__/path.test.ts +180 -0
  372. package/src/services/__tests__/post-language.test.ts +830 -0
  373. package/src/services/__tests__/post.test.ts +46 -9
  374. package/src/services/__tests__/settings.test.ts +228 -17
  375. package/src/services/__tests__/site-admin.test.ts +5 -5
  376. package/src/services/__tests__/smart-collection.test.ts +651 -0
  377. package/src/services/about-page.ts +22 -1
  378. package/src/services/auth.ts +51 -49
  379. package/src/services/bootstrap.ts +188 -38
  380. package/src/services/collection-directory-position.ts +76 -0
  381. package/src/services/collection.ts +331 -46
  382. package/src/services/custom-url.ts +27 -22
  383. package/src/services/export-theme/assets/client-site.css +1 -1
  384. package/src/services/export-theme/assets/client-site.js +123 -123
  385. package/src/services/export-theme/layouts/_default/rss.xml +77 -1
  386. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  387. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  388. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  389. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  390. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  391. package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
  392. package/src/services/export-theme/styles/main.css +13 -1
  393. package/src/services/export.ts +278 -19
  394. package/src/services/index.ts +34 -3
  395. package/src/services/language.ts +412 -0
  396. package/src/services/mcp.ts +3 -1
  397. package/src/services/media.ts +14 -6
  398. package/src/services/navigation.ts +429 -67
  399. package/src/services/path.ts +221 -6
  400. package/src/services/post.ts +1021 -152
  401. package/src/services/search.ts +39 -2
  402. package/src/services/settings.ts +238 -43
  403. package/src/services/site-admin.ts +24 -7
  404. package/src/services/site.ts +77 -1
  405. package/src/services/smart-collection.ts +636 -0
  406. package/src/services/telegram.ts +20 -22
  407. package/src/services/upload-session.ts +1 -3
  408. package/src/style-author.css +27 -0
  409. package/src/styles/components-author.css +1622 -0
  410. package/src/styles/components.css +24 -1571
  411. package/src/styles/site-media.css +16 -2
  412. package/src/styles/tokens.css +82 -12
  413. package/src/styles/ui-author.css +7602 -0
  414. package/src/styles/ui.css +1087 -7219
  415. package/src/types/app-context.ts +7 -0
  416. package/src/types/bindings.ts +3 -0
  417. package/src/types/config.ts +94 -15
  418. package/src/types/constants.ts +188 -16
  419. package/src/types/entities.ts +87 -1
  420. package/src/types/operations.ts +64 -2
  421. package/src/types/props.ts +157 -31
  422. package/src/types/views.ts +55 -1
  423. package/src/ui/__tests__/font-themes.test.ts +52 -12
  424. package/src/ui/compose/ComposeDialog.tsx +201 -7
  425. package/src/ui/compose/ComposePrompt.tsx +1 -5
  426. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  427. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  428. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  429. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  430. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  431. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  432. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  433. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  434. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  435. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  436. package/src/ui/dash/settings/GeneralContent.tsx +275 -113
  437. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  438. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  439. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  440. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  441. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  442. package/src/ui/dash/settings/discover-copy.ts +116 -0
  443. package/src/ui/feed/LinkCard.tsx +55 -45
  444. package/src/ui/feed/NoteCard.tsx +9 -4
  445. package/src/ui/feed/PostStatusBadges.tsx +90 -43
  446. package/src/ui/feed/QuoteCard.tsx +55 -30
  447. package/src/ui/feed/ThreadPreview.tsx +6 -2
  448. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  449. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  450. package/src/ui/feed/thread-preview-state.ts +20 -6
  451. package/src/ui/font-themes.ts +39 -29
  452. package/src/ui/layouts/BaseLayout.tsx +155 -10
  453. package/src/ui/layouts/SiteLayout.tsx +192 -34
  454. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  455. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  456. package/src/ui/pages/ArchivePage.tsx +282 -289
  457. package/src/ui/pages/CollectionPage.tsx +86 -76
  458. package/src/ui/pages/CollectionsPage.tsx +5 -0
  459. package/src/ui/pages/ComposePage.tsx +8 -0
  460. package/src/ui/pages/PostPage.tsx +47 -0
  461. package/src/ui/pages/SearchPage.tsx +3 -3
  462. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  463. package/src/ui/pages/SubscribePage.tsx +236 -0
  464. package/src/ui/pages/__tests__/ArchivePage.test.tsx +65 -9
  465. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  466. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  467. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  468. package/src/ui/shared/CollectionsManager.tsx +70 -17
  469. package/src/ui/shared/CopyField.tsx +127 -0
  470. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  471. package/src/ui/shared/MediaGallery.tsx +125 -46
  472. package/src/ui/shared/Pagination.tsx +20 -5
  473. package/src/ui/shared/PostFooter.tsx +20 -11
  474. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  475. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  476. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  477. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  478. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  479. package/src/ui/shared/archive-labels.ts +98 -0
  480. package/src/ui/shared/collection-management-labels.ts +94 -16
  481. package/src/ui/shared/navigation-labels.ts +45 -71
  482. package/src/ui/shared/post-article-attributes.ts +2 -0
  483. package/src/ui/shared/smart-collection-labels.ts +343 -0
  484. package/dist/app-C75Zc8dD.js +0 -6
  485. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  486. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  487. package/dist/client/_assets/chunks/url-CU9pEbk5.js +0 -1
  488. package/dist/client/_assets/client-Bq0vre8Y.js +0 -296
  489. package/dist/client/_assets/client-D3VZyG4L.css +0 -2
  490. package/dist/client/_assets/client-auth-F2rb9WfF.js +0 -5571
  491. package/dist/env-BPYViDJJ.js +0 -281
  492. package/dist/github-api-BNF35Lkx.js +0 -176
  493. package/dist/github-app-DdiD-bC4.js +0 -275
  494. package/dist/github-sync-Bqg62IvV.js +0 -4
  495. package/dist/github-sync-HdK2EgvJ.js +0 -431
  496. package/dist/url-CbLAtlZe.js +0 -900
  497. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  498. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  499. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  500. package/src/client/collection-created-notice.ts +0 -66
  501. package/src/client/collection-form-bridge.ts +0 -191
  502. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  503. package/src/client/components/jant-post-form.ts +0 -422
  504. package/src/client/components/post-form-template.ts +0 -352
  505. package/src/client/components/post-form-types.ts +0 -96
  506. package/src/client/media-metadata.ts +0 -247
  507. package/src/client/post-form-bridge.ts +0 -193
  508. package/src/ui/pages/CollectionEditorPage.tsx +0 -175
@@ -0,0 +1,830 @@
1
+ /**
2
+ * Multilingual data foundation: the language column, the thread-uniform rule
3
+ * it depends on, translation groups, and the enable-time backfill.
4
+ */
5
+
6
+ import { describe, it, expect, beforeEach } from "vitest";
7
+ import { and, eq, isNull } from "drizzle-orm";
8
+ import {
9
+ createTestDatabase,
10
+ DEFAULT_TEST_SITE_ID,
11
+ } from "../../__tests__/helpers/db.js";
12
+ import { posts, settings, sites } from "../../db/schema.js";
13
+ import { createCollectionService } from "../collection.js";
14
+ import { createPostService } from "../post.js";
15
+ import { createSettingsService } from "../settings.js";
16
+ import type { Database } from "../../db/index.js";
17
+
18
+ const OTHER_SITE_ID = "sit_other0000000000000000000000";
19
+
20
+ function noteBody(text: string): string {
21
+ return JSON.stringify({
22
+ type: "doc",
23
+ content: [{ type: "paragraph", content: [{ type: "text", text }] }],
24
+ });
25
+ }
26
+
27
+ describe("post language", () => {
28
+ let db: Database;
29
+ let postService: ReturnType<typeof createPostService>;
30
+
31
+ beforeEach(() => {
32
+ const testDb = createTestDatabase();
33
+ db = testDb.db as unknown as Database;
34
+ postService = createPostService(
35
+ db,
36
+ { slugIdLength: 5 },
37
+ DEFAULT_TEST_SITE_ID,
38
+ );
39
+ });
40
+
41
+ describe("storage and normalization", () => {
42
+ it("stores nothing when the caller supplies no language", async () => {
43
+ const post = await postService.create({
44
+ format: "note",
45
+ body: noteBody("Hello"),
46
+ });
47
+
48
+ expect(post.language).toBeNull();
49
+ expect(post.translationGroupId).toBeNull();
50
+ });
51
+
52
+ it("normalizes a supplied tag to canonical BCP 47 case", async () => {
53
+ const post = await postService.create({
54
+ format: "note",
55
+ body: noteBody("你好"),
56
+ language: "ZH-hans",
57
+ });
58
+
59
+ expect(post.language).toBe("zh-Hans");
60
+ });
61
+
62
+ it("rejects a tag that is not BCP 47", async () => {
63
+ await expect(
64
+ postService.create({
65
+ format: "note",
66
+ body: noteBody("Hello"),
67
+ language: "not a locale!!!",
68
+ }),
69
+ ).rejects.toThrow(/BCP 47/);
70
+ });
71
+ });
72
+
73
+ describe("thread-uniform rule", () => {
74
+ it("gives a reply the root's language, ignoring what the caller asked for", async () => {
75
+ const root = await postService.create({
76
+ format: "note",
77
+ body: noteBody("根"),
78
+ language: "zh-Hans",
79
+ });
80
+ const reply = await postService.create({
81
+ format: "note",
82
+ body: noteBody("回复"),
83
+ replyToId: root.id,
84
+ language: "en",
85
+ });
86
+
87
+ expect(reply.language).toBe("zh-Hans");
88
+ });
89
+
90
+ it("rewrites every post in the thread when the language changes", async () => {
91
+ const root = await postService.create({
92
+ format: "note",
93
+ body: noteBody("root"),
94
+ language: "en",
95
+ });
96
+ const reply = await postService.create({
97
+ format: "note",
98
+ body: noteBody("reply"),
99
+ replyToId: root.id,
100
+ });
101
+
102
+ await postService.setThreadLanguage(root.id, "ja");
103
+
104
+ expect((await postService.getById(root.id))?.language).toBe("ja");
105
+ expect((await postService.getById(reply.id))?.language).toBe("ja");
106
+ });
107
+
108
+ it("accepts any post in the thread as the handle for the change", async () => {
109
+ const root = await postService.create({
110
+ format: "note",
111
+ body: noteBody("root"),
112
+ language: "en",
113
+ });
114
+ const reply = await postService.create({
115
+ format: "note",
116
+ body: noteBody("reply"),
117
+ replyToId: root.id,
118
+ });
119
+
120
+ await postService.setThreadLanguage(reply.id, "zh-Hant");
121
+
122
+ expect((await postService.getById(root.id))?.language).toBe("zh-Hant");
123
+ expect((await postService.getById(reply.id))?.language).toBe("zh-Hant");
124
+ });
125
+ });
126
+
127
+ describe("lang filter", () => {
128
+ beforeEach(async () => {
129
+ await postService.create({
130
+ format: "note",
131
+ body: noteBody("中文一"),
132
+ language: "zh-Hans",
133
+ });
134
+ await postService.create({
135
+ format: "note",
136
+ body: noteBody("中文二"),
137
+ language: "zh-Hans",
138
+ });
139
+ await postService.create({
140
+ format: "note",
141
+ body: noteBody("English one"),
142
+ language: "en",
143
+ });
144
+ });
145
+
146
+ it("returns only posts in the requested language", async () => {
147
+ const zh = await postService.list({ lang: "zh-Hans" });
148
+ const en = await postService.list({ lang: "en" });
149
+
150
+ expect(zh).toHaveLength(2);
151
+ expect(en).toHaveLength(1);
152
+ });
153
+
154
+ it("counts with the same filter", async () => {
155
+ expect(await postService.count({ lang: "zh-Hans" })).toBe(2);
156
+ expect(await postService.count({ lang: "en" })).toBe(1);
157
+ expect(await postService.count({ lang: "ja" })).toBe(0);
158
+ });
159
+
160
+ it("returns everything when no language is requested", async () => {
161
+ expect(await postService.list()).toHaveLength(3);
162
+ });
163
+
164
+ it("matches exactly, without language-family folding", async () => {
165
+ expect(await postService.count({ lang: "zh" })).toBe(0);
166
+ });
167
+ });
168
+
169
+ describe("enable-time backfill", () => {
170
+ it("stamps every post that has no language yet", async () => {
171
+ await postService.create({ format: "note", body: noteBody("a") });
172
+ await postService.create({ format: "note", body: noteBody("b") });
173
+
174
+ expect(await postService.countMissingLanguage()).toBe(2);
175
+ expect(await postService.materializeMissingLanguage("zh-Hans")).toBe(2);
176
+ expect(await postService.countMissingLanguage()).toBe(0);
177
+ expect(await postService.countByLanguage("zh-Hans")).toBe(2);
178
+ });
179
+
180
+ it("stamps replies too, so threads stay uniform", async () => {
181
+ const root = await postService.create({
182
+ format: "note",
183
+ body: noteBody("root"),
184
+ });
185
+ const reply = await postService.create({
186
+ format: "note",
187
+ body: noteBody("reply"),
188
+ replyToId: root.id,
189
+ });
190
+
191
+ await postService.materializeMissingLanguage("en");
192
+
193
+ expect((await postService.getById(root.id))?.language).toBe("en");
194
+ expect((await postService.getById(reply.id))?.language).toBe("en");
195
+ });
196
+
197
+ it("is idempotent and leaves already-marked posts alone", async () => {
198
+ await postService.create({
199
+ format: "note",
200
+ body: noteBody("english"),
201
+ language: "en",
202
+ });
203
+ const untagged = await postService.create({
204
+ format: "note",
205
+ body: noteBody("untagged"),
206
+ });
207
+
208
+ expect(await postService.materializeMissingLanguage("zh-Hans")).toBe(1);
209
+ expect(await postService.materializeMissingLanguage("zh-Hans")).toBe(0);
210
+ expect(await postService.countByLanguage("en")).toBe(1);
211
+ expect((await postService.getById(untagged.id))?.language).toBe(
212
+ "zh-Hans",
213
+ );
214
+ });
215
+
216
+ it("never touches another site's posts", async () => {
217
+ await db.insert(sites).values({
218
+ id: OTHER_SITE_ID,
219
+ key: "other",
220
+ name: "Other",
221
+ status: "active",
222
+ createdAt: 1,
223
+ updatedAt: 1,
224
+ });
225
+ const otherService = createPostService(
226
+ db,
227
+ { slugIdLength: 5 },
228
+ OTHER_SITE_ID,
229
+ );
230
+ const foreign = await otherService.create({
231
+ format: "note",
232
+ body: noteBody("foreign"),
233
+ });
234
+ await postService.create({ format: "note", body: noteBody("mine") });
235
+
236
+ expect(await postService.materializeMissingLanguage("zh-Hans")).toBe(1);
237
+
238
+ const stillMissing = await db
239
+ .select({ id: posts.id })
240
+ .from(posts)
241
+ .where(and(eq(posts.siteId, OTHER_SITE_ID), isNull(posts.language)));
242
+ expect(stillMissing.map((row) => row.id)).toEqual([foreign.id]);
243
+ });
244
+ });
245
+
246
+ describe("translation groups", () => {
247
+ it("links a new translation to its source, minting the group once", async () => {
248
+ const source = await postService.create({
249
+ format: "note",
250
+ title: "书评",
251
+ body: noteBody("中文正文"),
252
+ language: "zh-Hans",
253
+ });
254
+ const translation = await postService.create({
255
+ format: "note",
256
+ title: "Book review",
257
+ body: noteBody("English body"),
258
+ language: "en",
259
+ translationOfId: source.id,
260
+ });
261
+
262
+ const refreshedSource = await postService.getById(source.id);
263
+ expect(translation.translationGroupId).toMatch(/^tgr_[a-z0-9]{26}$/);
264
+ expect(refreshedSource?.translationGroupId).toBe(
265
+ translation.translationGroupId,
266
+ );
267
+ });
268
+
269
+ it("makes the pair visible to each other", async () => {
270
+ const source = await postService.create({
271
+ format: "note",
272
+ body: noteBody("中文"),
273
+ language: "zh-Hans",
274
+ });
275
+ const translation = await postService.create({
276
+ format: "note",
277
+ body: noteBody("English"),
278
+ language: "en",
279
+ translationOfId: source.id,
280
+ });
281
+
282
+ expect(
283
+ (await postService.listTranslations(source.id)).map((p) => p.id),
284
+ ).toEqual([translation.id]);
285
+ expect(
286
+ (await postService.listTranslations(translation.id)).map((p) => p.id),
287
+ ).toEqual([source.id]);
288
+ });
289
+
290
+ it("joins a third language to the same group automatically", async () => {
291
+ const zh = await postService.create({
292
+ format: "note",
293
+ body: noteBody("中文"),
294
+ language: "zh-Hans",
295
+ });
296
+ const en = await postService.create({
297
+ format: "note",
298
+ body: noteBody("English"),
299
+ language: "en",
300
+ translationOfId: zh.id,
301
+ });
302
+ const ja = await postService.create({
303
+ format: "note",
304
+ body: noteBody("日本語"),
305
+ language: "ja",
306
+ translationOfId: en.id,
307
+ });
308
+
309
+ // `zh` was returned before the group existed; re-read it.
310
+ const groupId = (await postService.getById(zh.id))?.translationGroupId;
311
+ expect(groupId).toMatch(/^tgr_/);
312
+ expect(en.translationGroupId).toBe(groupId);
313
+ expect(ja.translationGroupId).toBe(groupId);
314
+ expect(await postService.listTranslations(zh.id)).toHaveLength(2);
315
+ });
316
+
317
+ it("refuses a language the group already holds", async () => {
318
+ const zh = await postService.create({
319
+ format: "note",
320
+ body: noteBody("中文"),
321
+ language: "zh-Hans",
322
+ });
323
+ await postService.create({
324
+ format: "note",
325
+ title: "English version",
326
+ body: noteBody("English"),
327
+ language: "en",
328
+ translationOfId: zh.id,
329
+ });
330
+
331
+ await expect(
332
+ postService.create({
333
+ format: "note",
334
+ body: noteBody("Another English"),
335
+ language: "en",
336
+ translationOfId: zh.id,
337
+ }),
338
+ ).rejects.toThrow(/already the English version/);
339
+ });
340
+
341
+ it("refuses a translation written in the source's own language", async () => {
342
+ const zh = await postService.create({
343
+ format: "note",
344
+ body: noteBody("中文"),
345
+ language: "zh-Hans",
346
+ });
347
+
348
+ await expect(
349
+ postService.create({
350
+ format: "note",
351
+ body: noteBody("更多中文"),
352
+ language: "zh-Hans",
353
+ translationOfId: zh.id,
354
+ }),
355
+ ).rejects.toThrow(/already written in this language/);
356
+ });
357
+
358
+ it("refuses to hang a group off a reply", async () => {
359
+ const root = await postService.create({
360
+ format: "note",
361
+ body: noteBody("root"),
362
+ language: "en",
363
+ });
364
+ const reply = await postService.create({
365
+ format: "note",
366
+ body: noteBody("reply"),
367
+ replyToId: root.id,
368
+ });
369
+
370
+ await expect(
371
+ postService.create({
372
+ format: "note",
373
+ body: noteBody("中文"),
374
+ language: "zh-Hans",
375
+ translationOfId: reply.id,
376
+ }),
377
+ ).rejects.toThrow(/thread's first post/);
378
+ });
379
+
380
+ it("refuses a translation that is itself a reply", async () => {
381
+ const source = await postService.create({
382
+ format: "note",
383
+ body: noteBody("source"),
384
+ language: "en",
385
+ });
386
+ const otherRoot = await postService.create({
387
+ format: "note",
388
+ body: noteBody("other"),
389
+ language: "en",
390
+ });
391
+
392
+ await expect(
393
+ postService.create({
394
+ format: "note",
395
+ body: noteBody("中文"),
396
+ language: "zh-Hans",
397
+ replyToId: otherRoot.id,
398
+ translationOfId: source.id,
399
+ }),
400
+ ).rejects.toThrow(/thread's first post/);
401
+ });
402
+
403
+ it("refuses a source that belongs to another site", async () => {
404
+ await db.insert(sites).values({
405
+ id: OTHER_SITE_ID,
406
+ key: "other",
407
+ name: "Other",
408
+ status: "active",
409
+ createdAt: 1,
410
+ updatedAt: 1,
411
+ });
412
+ const otherService = createPostService(
413
+ db,
414
+ { slugIdLength: 5 },
415
+ OTHER_SITE_ID,
416
+ );
417
+ const foreign = await otherService.create({
418
+ format: "note",
419
+ body: noteBody("foreign"),
420
+ language: "en",
421
+ });
422
+
423
+ await expect(
424
+ postService.create({
425
+ format: "note",
426
+ body: noteBody("中文"),
427
+ language: "zh-Hans",
428
+ translationOfId: foreign.id,
429
+ }),
430
+ ).rejects.toThrow(/not found/i);
431
+ });
432
+
433
+ it("leaves nothing behind when the translation fails to save", async () => {
434
+ const source = await postService.create({
435
+ format: "note",
436
+ body: noteBody("中文"),
437
+ language: "zh-Hans",
438
+ });
439
+
440
+ await expect(
441
+ postService.create({
442
+ format: "note",
443
+ body: noteBody("English"),
444
+ translationOfId: source.id,
445
+ }),
446
+ ).rejects.toThrow(/Choose a language/);
447
+
448
+ expect((await postService.getById(source.id))?.translationGroupId).toBe(
449
+ null,
450
+ );
451
+ expect(await postService.count()).toBe(1);
452
+ });
453
+
454
+ it("blocks a language change that would collide inside the group", async () => {
455
+ const zh = await postService.create({
456
+ format: "note",
457
+ title: "中文标题",
458
+ body: noteBody("中文"),
459
+ language: "zh-Hans",
460
+ });
461
+ const en = await postService.create({
462
+ format: "note",
463
+ title: "English title",
464
+ body: noteBody("English"),
465
+ language: "en",
466
+ translationOfId: zh.id,
467
+ });
468
+
469
+ await expect(
470
+ postService.setThreadLanguage(en.id, "zh-Hans"),
471
+ ).rejects.toThrow(/中文标题/);
472
+ expect((await postService.getById(en.id))?.language).toBe("en");
473
+ });
474
+
475
+ it("allows a language change that stays unique inside the group", async () => {
476
+ const zh = await postService.create({
477
+ format: "note",
478
+ body: noteBody("中文"),
479
+ language: "zh-Hans",
480
+ });
481
+ const en = await postService.create({
482
+ format: "note",
483
+ body: noteBody("English"),
484
+ language: "en",
485
+ translationOfId: zh.id,
486
+ });
487
+
488
+ await postService.setThreadLanguage(en.id, "ja");
489
+ expect((await postService.getById(en.id))?.language).toBe("ja");
490
+ });
491
+
492
+ it("links two posts that were published separately", async () => {
493
+ const zh = await postService.create({
494
+ format: "note",
495
+ body: noteBody("中文"),
496
+ language: "zh-Hans",
497
+ });
498
+ const en = await postService.create({
499
+ format: "note",
500
+ body: noteBody("English"),
501
+ language: "en",
502
+ });
503
+
504
+ await postService.linkTranslation(zh.id, en.id);
505
+
506
+ const refreshed = await postService.getById(zh.id);
507
+ expect(refreshed?.translationGroupId).toMatch(/^tgr_/);
508
+ expect((await postService.getById(en.id))?.translationGroupId).toBe(
509
+ refreshed?.translationGroupId,
510
+ );
511
+ });
512
+
513
+ it("refuses to merge two existing groups", async () => {
514
+ const zh = await postService.create({
515
+ format: "note",
516
+ body: noteBody("中文"),
517
+ language: "zh-Hans",
518
+ });
519
+ await postService.create({
520
+ format: "note",
521
+ body: noteBody("English"),
522
+ language: "en",
523
+ translationOfId: zh.id,
524
+ });
525
+ const ja = await postService.create({
526
+ format: "note",
527
+ body: noteBody("日本語"),
528
+ language: "ja",
529
+ });
530
+ await postService.create({
531
+ format: "note",
532
+ body: noteBody("한국어"),
533
+ language: "ko",
534
+ translationOfId: ja.id,
535
+ });
536
+
537
+ await expect(postService.linkTranslation(zh.id, ja.id)).rejects.toThrow(
538
+ /Unlink one of them first/,
539
+ );
540
+ });
541
+
542
+ it("refuses to link two posts sharing a language", async () => {
543
+ const a = await postService.create({
544
+ format: "note",
545
+ body: noteBody("one"),
546
+ language: "en",
547
+ });
548
+ const b = await postService.create({
549
+ format: "note",
550
+ body: noteBody("two"),
551
+ language: "en",
552
+ });
553
+
554
+ await expect(postService.linkTranslation(a.id, b.id)).rejects.toThrow(
555
+ /same language/,
556
+ );
557
+ });
558
+
559
+ it("clears the group entirely when unlinking leaves one post", async () => {
560
+ const zh = await postService.create({
561
+ format: "note",
562
+ body: noteBody("中文"),
563
+ language: "zh-Hans",
564
+ });
565
+ const en = await postService.create({
566
+ format: "note",
567
+ body: noteBody("English"),
568
+ language: "en",
569
+ translationOfId: zh.id,
570
+ });
571
+
572
+ await postService.unlinkTranslation(en.id);
573
+
574
+ expect((await postService.getById(en.id))?.translationGroupId).toBeNull();
575
+ expect((await postService.getById(zh.id))?.translationGroupId).toBeNull();
576
+ });
577
+
578
+ it("keeps the group when unlinking still leaves two members", async () => {
579
+ const zh = await postService.create({
580
+ format: "note",
581
+ body: noteBody("中文"),
582
+ language: "zh-Hans",
583
+ });
584
+ const en = await postService.create({
585
+ format: "note",
586
+ body: noteBody("English"),
587
+ language: "en",
588
+ translationOfId: zh.id,
589
+ });
590
+ const ja = await postService.create({
591
+ format: "note",
592
+ body: noteBody("日本語"),
593
+ language: "ja",
594
+ translationOfId: zh.id,
595
+ });
596
+
597
+ await postService.unlinkTranslation(ja.id);
598
+
599
+ const groupId = (await postService.getById(zh.id))?.translationGroupId;
600
+ expect(groupId).toMatch(/^tgr_/);
601
+ expect((await postService.getById(en.id))?.translationGroupId).toBe(
602
+ groupId,
603
+ );
604
+ expect((await postService.getById(ja.id))?.translationGroupId).toBeNull();
605
+ });
606
+
607
+ it("rejects a duplicate language inside a group at the database level", async () => {
608
+ const zh = await postService.create({
609
+ format: "note",
610
+ body: noteBody("中文"),
611
+ language: "zh-Hans",
612
+ });
613
+ const en = await postService.create({
614
+ format: "note",
615
+ body: noteBody("English"),
616
+ language: "en",
617
+ translationOfId: zh.id,
618
+ });
619
+ const other = await postService.create({
620
+ format: "note",
621
+ body: noteBody("Another English"),
622
+ language: "en",
623
+ });
624
+
625
+ await expect(
626
+ db
627
+ .update(posts)
628
+ .set({ translationGroupId: en.translationGroupId })
629
+ .where(eq(posts.id, other.id)),
630
+ ).rejects.toThrow(/UNIQUE/i);
631
+ });
632
+
633
+ it("allows many posts outside any group to share a language", async () => {
634
+ await postService.create({
635
+ format: "note",
636
+ body: noteBody("one"),
637
+ language: "en",
638
+ });
639
+ await postService.create({
640
+ format: "note",
641
+ body: noteBody("two"),
642
+ language: "en",
643
+ });
644
+
645
+ expect(await postService.countByLanguage("en")).toBe(2);
646
+ });
647
+ });
648
+
649
+ describe("thread-member-grained queries", () => {
650
+ // Collection and featured queries group thread *members* by thread_id, so
651
+ // a member row's language has to agree with its root's. The thread-uniform
652
+ // rule is what makes the plain column predicate safe there.
653
+ it("filters collection threads by language, replies included", async () => {
654
+ const collectionService = createCollectionService(
655
+ db,
656
+ DEFAULT_TEST_SITE_ID,
657
+ );
658
+ const collection = await collectionService.create({
659
+ slug: "reading",
660
+ title: "Reading",
661
+ });
662
+
663
+ const zhRoot = await postService.create({
664
+ format: "note",
665
+ body: noteBody("中文根"),
666
+ language: "zh-Hans",
667
+ collectionIds: [collection.id],
668
+ });
669
+ await postService.create({
670
+ format: "note",
671
+ body: noteBody("中文回复"),
672
+ replyToId: zhRoot.id,
673
+ });
674
+ await postService.create({
675
+ format: "note",
676
+ body: noteBody("English root"),
677
+ language: "en",
678
+ collectionIds: [collection.id],
679
+ });
680
+
681
+ const zhIds = await postService.listCollectionThreadRootIds(
682
+ collection.id,
683
+ { lang: "zh-Hans" },
684
+ );
685
+ const enIds = await postService.listCollectionThreadRootIds(
686
+ collection.id,
687
+ { lang: "en" },
688
+ );
689
+
690
+ expect(zhIds).toEqual([zhRoot.id]);
691
+ expect(enIds).toHaveLength(1);
692
+ expect(
693
+ await postService.countCollectionThreadRoots(collection.id, {
694
+ lang: "zh-Hans",
695
+ }),
696
+ ).toBe(1);
697
+ });
698
+
699
+ it("filters featured threads by language", async () => {
700
+ const zh = await postService.create({
701
+ format: "note",
702
+ body: noteBody("中文"),
703
+ language: "zh-Hans",
704
+ featured: true,
705
+ });
706
+ await postService.create({
707
+ format: "note",
708
+ body: noteBody("English"),
709
+ language: "en",
710
+ featured: true,
711
+ });
712
+
713
+ expect(
714
+ await postService.listFeaturedThreadRootIds({ lang: "zh-Hans" }),
715
+ ).toEqual([zh.id]);
716
+ expect(await postService.countFeaturedThreadRoots({ lang: "en" })).toBe(
717
+ 1,
718
+ );
719
+ });
720
+ });
721
+
722
+ describe("slug conflicts", () => {
723
+ beforeEach(async () => {
724
+ const settingsService = createSettingsService(db, DEFAULT_TEST_SITE_ID);
725
+ await settingsService.set("SITE_LANGUAGE", "zh-Hans");
726
+ });
727
+
728
+ it("prefers a language suffix over a random one for a secondary language", async () => {
729
+ await postService.create({
730
+ format: "note",
731
+ title: "Book Review",
732
+ body: noteBody("中文"),
733
+ language: "zh-Hans",
734
+ });
735
+ const translated = await postService.create({
736
+ format: "note",
737
+ title: "Book Review",
738
+ body: noteBody("English"),
739
+ language: "en",
740
+ });
741
+
742
+ expect(translated.slug).toBe("book-review-en");
743
+ });
744
+
745
+ it("lowercases a script-tagged language for the suffix", async () => {
746
+ await postService.create({
747
+ format: "note",
748
+ title: "Book Review",
749
+ body: noteBody("中文"),
750
+ language: "zh-Hans",
751
+ });
752
+ const translated = await postService.create({
753
+ format: "note",
754
+ title: "Book Review",
755
+ body: noteBody("繁體"),
756
+ language: "zh-Hant",
757
+ });
758
+
759
+ expect(translated.slug).toBe("book-review-zh-hant");
760
+ });
761
+
762
+ it("falls back to a random suffix for the primary language", async () => {
763
+ await postService.create({
764
+ format: "note",
765
+ title: "Book Review",
766
+ body: noteBody("中文一"),
767
+ language: "zh-Hans",
768
+ });
769
+ const second = await postService.create({
770
+ format: "note",
771
+ title: "Book Review",
772
+ body: noteBody("中文二"),
773
+ language: "zh-Hans",
774
+ });
775
+
776
+ expect(second.slug).not.toBe("book-review-zh-hans");
777
+ expect(second.slug).toMatch(/^book-review-[a-z0-9]{5}$/);
778
+ });
779
+
780
+ it("falls back to a random suffix when the language slug is also taken", async () => {
781
+ await postService.create({
782
+ format: "note",
783
+ title: "Book Review",
784
+ body: noteBody("中文"),
785
+ language: "zh-Hans",
786
+ });
787
+ const first = await postService.create({
788
+ format: "note",
789
+ title: "Book Review",
790
+ body: noteBody("English"),
791
+ language: "en",
792
+ });
793
+ const second = await postService.create({
794
+ format: "note",
795
+ title: "Book Review",
796
+ body: noteBody("More English"),
797
+ language: "en",
798
+ });
799
+
800
+ expect(first.slug).toBe("book-review-en");
801
+ expect(second.slug).toMatch(/^book-review-[a-z0-9]{5}$/);
802
+ });
803
+
804
+ it("leaves an uncontested slug untouched", async () => {
805
+ const post = await postService.create({
806
+ format: "note",
807
+ title: "Book Review",
808
+ body: noteBody("English"),
809
+ language: "en",
810
+ });
811
+
812
+ expect(post.slug).toBe("book-review");
813
+ });
814
+
815
+ it("does not query the site language for posts without one", async () => {
816
+ const post = await postService.create({
817
+ format: "note",
818
+ title: "Book Review",
819
+ body: noteBody("plain"),
820
+ });
821
+ const rows = await db
822
+ .select({ key: settings.key })
823
+ .from(settings)
824
+ .where(eq(settings.siteId, DEFAULT_TEST_SITE_ID));
825
+
826
+ expect(post.slug).toBe("book-review");
827
+ expect(rows.map((row) => row.key)).toContain("SITE_LANGUAGE");
828
+ });
829
+ });
830
+ });