@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,829 @@
1
+ /**
2
+ * Language and translation endpoints.
3
+ *
4
+ * These are the write paths the post menu drives. The interesting cases are the
5
+ * refusals: a language the site does not publish, and a translation link that
6
+ * would put two posts of the same language in one group. Both have to come back
7
+ * as readable messages, because the menu shows them verbatim.
8
+ */
9
+
10
+ import { beforeEach, describe, expect, it } from "vitest";
11
+ import { createTestApp } from "../../../__tests__/helpers/app.js";
12
+ import { postsApiRoutes } from "../posts.js";
13
+
14
+ function createApiTestApp() {
15
+ const testApp = createTestApp({ authenticated: true });
16
+ testApp.app.route("/api/posts", postsApiRoutes);
17
+ return testApp;
18
+ }
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
+ async function enableMultilingual(
28
+ services: ReturnType<typeof createTestApp>["services"],
29
+ ) {
30
+ await services.settings.set("SITE_LANGUAGE", "zh-Hans");
31
+ await services.settings.set("ADDITIONAL_LANGUAGES", "en");
32
+ await services.settings.set("MULTILINGUAL_ENABLED", "true");
33
+ }
34
+
35
+ describe("PUT /api/posts/:id/language", () => {
36
+ let app: ReturnType<typeof createApiTestApp>["app"];
37
+ let services: ReturnType<typeof createApiTestApp>["services"];
38
+
39
+ beforeEach(async () => {
40
+ const testApp = createApiTestApp();
41
+ app = testApp.app;
42
+ services = testApp.services;
43
+ await enableMultilingual(services);
44
+ });
45
+
46
+ it("changes the language of the whole thread", async () => {
47
+ const root = await services.posts.create({
48
+ format: "note",
49
+ body: noteBody("root"),
50
+ language: "zh-Hans",
51
+ status: "published",
52
+ });
53
+ const reply = await services.posts.create({
54
+ format: "note",
55
+ body: noteBody("reply"),
56
+ replyToId: root.id,
57
+ status: "published",
58
+ });
59
+
60
+ const res = await app.request(`/api/posts/${root.id}/language`, {
61
+ method: "PUT",
62
+ headers: { "Content-Type": "application/json" },
63
+ body: JSON.stringify({ language: "en" }),
64
+ });
65
+
66
+ expect(res.status).toBe(200);
67
+ expect((await services.posts.getById(root.id))?.language).toBe("en");
68
+ // A Thread is written in one language — that is what makes every language
69
+ // filter a plain column predicate.
70
+ expect((await services.posts.getById(reply.id))?.language).toBe("en");
71
+ });
72
+
73
+ it("refuses a language the site does not publish", async () => {
74
+ const post = await services.posts.create({
75
+ format: "note",
76
+ body: noteBody("root"),
77
+ language: "zh-Hans",
78
+ status: "published",
79
+ });
80
+
81
+ const res = await app.request(`/api/posts/${post.id}/language`, {
82
+ method: "PUT",
83
+ headers: { "Content-Type": "application/json" },
84
+ body: JSON.stringify({ language: "ja" }),
85
+ });
86
+
87
+ expect(res.status).toBe(400);
88
+ expect((await res.json()) as { error: string }).toMatchObject({
89
+ error: expect.stringContaining("does not publish that language"),
90
+ });
91
+ });
92
+
93
+ it("rejects a malformed tag before it reaches the service", async () => {
94
+ const post = await services.posts.create({
95
+ format: "note",
96
+ body: noteBody("root"),
97
+ status: "published",
98
+ });
99
+
100
+ const res = await app.request(`/api/posts/${post.id}/language`, {
101
+ method: "PUT",
102
+ headers: { "Content-Type": "application/json" },
103
+ body: JSON.stringify({ language: "not a tag" }),
104
+ });
105
+
106
+ expect(res.status).toBe(400);
107
+ });
108
+ });
109
+
110
+ describe("translation links", () => {
111
+ let app: ReturnType<typeof createApiTestApp>["app"];
112
+ let services: ReturnType<typeof createApiTestApp>["services"];
113
+
114
+ beforeEach(async () => {
115
+ const testApp = createApiTestApp();
116
+ app = testApp.app;
117
+ services = testApp.services;
118
+ await enableMultilingual(services);
119
+ });
120
+
121
+ async function seedPair() {
122
+ const zh = await services.posts.create({
123
+ format: "note",
124
+ title: "中文文章",
125
+ body: noteBody("中文"),
126
+ language: "zh-Hans",
127
+ status: "published",
128
+ });
129
+ const en = await services.posts.create({
130
+ format: "note",
131
+ title: "English post",
132
+ body: noteBody("English"),
133
+ language: "en",
134
+ status: "published",
135
+ });
136
+ return { zh, en };
137
+ }
138
+
139
+ it("links two posts and lists them from either side", async () => {
140
+ const { zh, en } = await seedPair();
141
+
142
+ const link = await app.request(`/api/posts/${zh.id}/translations`, {
143
+ method: "POST",
144
+ headers: { "Content-Type": "application/json" },
145
+ body: JSON.stringify({ postId: en.id }),
146
+ });
147
+ expect(link.status).toBe(200);
148
+
149
+ const fromZh = (await (
150
+ await app.request(`/api/posts/${zh.id}/translations`)
151
+ ).json()) as { translations: Array<{ id: string; language: string }> };
152
+ expect(fromZh.translations).toEqual([
153
+ expect.objectContaining({ id: en.id, language: "en" }),
154
+ ]);
155
+
156
+ const fromEn = (await (
157
+ await app.request(`/api/posts/${en.id}/translations`)
158
+ ).json()) as { translations: Array<{ id: string }> };
159
+ expect(fromEn.translations).toEqual([
160
+ expect.objectContaining({ id: zh.id }),
161
+ ]);
162
+ });
163
+
164
+ it("refuses to put two posts of one language in a group", async () => {
165
+ const { zh } = await seedPair();
166
+ const otherZh = await services.posts.create({
167
+ format: "note",
168
+ title: "另一篇中文",
169
+ body: noteBody("中文"),
170
+ language: "zh-Hans",
171
+ status: "published",
172
+ });
173
+
174
+ const res = await app.request(`/api/posts/${zh.id}/translations`, {
175
+ method: "POST",
176
+ headers: { "Content-Type": "application/json" },
177
+ body: JSON.stringify({ postId: otherZh.id }),
178
+ });
179
+
180
+ expect(res.status).toBe(409);
181
+ expect(((await res.json()) as { error: string }).error).toMatch(
182
+ /same language|already/i,
183
+ );
184
+ });
185
+
186
+ it("unlinks, and clears the group rather than leaving one post in it", async () => {
187
+ const { zh, en } = await seedPair();
188
+ await services.posts.linkTranslation(zh.id, en.id);
189
+
190
+ const res = await app.request(`/api/posts/${zh.id}/translations`, {
191
+ method: "DELETE",
192
+ });
193
+ expect(res.status).toBe(200);
194
+
195
+ expect(
196
+ (await services.posts.getById(zh.id))?.translationGroupId,
197
+ ).toBeNull();
198
+ // The other side would otherwise be a one-member group: a translation of
199
+ // nothing.
200
+ expect(
201
+ (await services.posts.getById(en.id))?.translationGroupId,
202
+ ).toBeNull();
203
+ });
204
+
205
+ it("returns an empty list for a post with no translations", async () => {
206
+ const { zh } = await seedPair();
207
+
208
+ const body = (await (
209
+ await app.request(`/api/posts/${zh.id}/translations`)
210
+ ).json()) as { translations: unknown[] };
211
+ expect(body.translations).toEqual([]);
212
+ });
213
+ });
214
+
215
+ describe("GET /api/posts/:id/translations/candidates", () => {
216
+ let app: ReturnType<typeof createApiTestApp>["app"];
217
+ let services: ReturnType<typeof createApiTestApp>["services"];
218
+
219
+ beforeEach(async () => {
220
+ const testApp = createApiTestApp();
221
+ app = testApp.app;
222
+ services = testApp.services;
223
+ await services.settings.set("SITE_LANGUAGE", "zh-Hans");
224
+ await services.settings.set("ADDITIONAL_LANGUAGES", "en,ja");
225
+ await services.settings.set("MULTILINGUAL_ENABLED", "true");
226
+ });
227
+
228
+ async function candidatesFor(postId: string, query: string) {
229
+ const res = await app.request(
230
+ `/api/posts/${postId}/translations/candidates?q=${encodeURIComponent(query)}`,
231
+ );
232
+ expect(res.status).toBe(200);
233
+ const body = (await res.json()) as {
234
+ candidates: Array<{ id: string; title: string | null }>;
235
+ };
236
+ return body.candidates;
237
+ }
238
+
239
+ it("offers a published post in another language", async () => {
240
+ const zh = await services.posts.create({
241
+ format: "note",
242
+ title: "咖啡笔记",
243
+ body: noteBody("中文"),
244
+ language: "zh-Hans",
245
+ status: "published",
246
+ });
247
+ const en = await services.posts.create({
248
+ format: "note",
249
+ title: "Coffee notes",
250
+ body: noteBody("English"),
251
+ language: "en",
252
+ status: "published",
253
+ });
254
+
255
+ expect(await candidatesFor(zh.id, "Coffee")).toEqual([
256
+ expect.objectContaining({ id: en.id }),
257
+ ]);
258
+ });
259
+
260
+ it("hides posts in a language this one already speaks for", async () => {
261
+ // Everything the menu offers has to work when clicked — making the author
262
+ // hit the conflict error to find out is not a filter.
263
+ const zh = await services.posts.create({
264
+ format: "note",
265
+ title: "咖啡笔记",
266
+ body: noteBody("中文"),
267
+ language: "zh-Hans",
268
+ status: "published",
269
+ });
270
+ await services.posts.create({
271
+ format: "note",
272
+ title: "咖啡笔记 coffee",
273
+ body: noteBody("中文"),
274
+ language: "zh-Hans",
275
+ status: "published",
276
+ });
277
+
278
+ expect(await candidatesFor(zh.id, "咖啡")).toEqual([]);
279
+ });
280
+
281
+ it("hides a language the group already holds", async () => {
282
+ const zh = await services.posts.create({
283
+ format: "note",
284
+ title: "咖啡笔记",
285
+ body: noteBody("中文"),
286
+ language: "zh-Hans",
287
+ status: "published",
288
+ });
289
+ const en = await services.posts.create({
290
+ format: "note",
291
+ title: "Coffee notes",
292
+ body: noteBody("English"),
293
+ language: "en",
294
+ status: "published",
295
+ });
296
+ await services.posts.linkTranslation(zh.id, en.id);
297
+
298
+ const other = await services.posts.create({
299
+ format: "note",
300
+ title: "Coffee, again",
301
+ body: noteBody("English"),
302
+ language: "en",
303
+ status: "published",
304
+ });
305
+ expect(await candidatesFor(zh.id, "Coffee")).not.toContainEqual(
306
+ expect.objectContaining({ id: other.id }),
307
+ );
308
+ });
309
+
310
+ it("hides a post already in another group, which cannot be merged", async () => {
311
+ const a = await services.posts.create({
312
+ format: "note",
313
+ title: "Coffee A",
314
+ body: noteBody("English"),
315
+ language: "en",
316
+ status: "published",
317
+ });
318
+ const b = await services.posts.create({
319
+ format: "note",
320
+ title: "咖啡 Coffee B",
321
+ body: noteBody("中文"),
322
+ language: "zh-Hans",
323
+ status: "published",
324
+ });
325
+ const c = await services.posts.create({
326
+ format: "note",
327
+ title: "Coffee C",
328
+ body: noteBody("日本語"),
329
+ language: "ja",
330
+ status: "published",
331
+ });
332
+ await services.posts.linkTranslation(b.id, c.id);
333
+ // `a` has no group, so joining b+c is fine — that group has no English yet.
334
+ expect(await candidatesFor(a.id, "Coffee")).toContainEqual(
335
+ expect.objectContaining({ id: b.id }),
336
+ );
337
+
338
+ const d = await services.posts.create({
339
+ format: "note",
340
+ title: "Coffee D",
341
+ body: noteBody("日本語"),
342
+ language: "ja",
343
+ status: "published",
344
+ });
345
+ await services.posts.linkTranslation(a.id, d.id);
346
+ // Now both sides have groups, which `linkTranslation` refuses to merge.
347
+ expect(await candidatesFor(a.id, "Coffee")).not.toContainEqual(
348
+ expect.objectContaining({ id: b.id }),
349
+ );
350
+ });
351
+
352
+ it("hides drafts, replies, and the post itself", async () => {
353
+ const zh = await services.posts.create({
354
+ format: "note",
355
+ title: "咖啡 coffee",
356
+ body: noteBody("中文"),
357
+ language: "zh-Hans",
358
+ status: "published",
359
+ });
360
+ await services.posts.create({
361
+ format: "note",
362
+ title: "Coffee draft",
363
+ body: noteBody("English"),
364
+ language: "en",
365
+ status: "draft",
366
+ });
367
+ const root = await services.posts.create({
368
+ format: "note",
369
+ title: "Coffee root",
370
+ body: noteBody("English"),
371
+ language: "en",
372
+ status: "published",
373
+ });
374
+ await services.posts.create({
375
+ format: "note",
376
+ title: "Coffee reply",
377
+ body: noteBody("English"),
378
+ replyToId: root.id,
379
+ status: "published",
380
+ });
381
+
382
+ // The root is fair game; its reply and the draft are not, and neither is
383
+ // the post doing the asking.
384
+ expect(await candidatesFor(zh.id, "coffee")).toEqual([
385
+ expect.objectContaining({ id: root.id }),
386
+ ]);
387
+ });
388
+
389
+ it("matches body text, not just titles", async () => {
390
+ const zh = await services.posts.create({
391
+ format: "note",
392
+ body: noteBody("中文"),
393
+ language: "zh-Hans",
394
+ status: "published",
395
+ });
396
+ const en = await services.posts.create({
397
+ format: "note",
398
+ body: noteBody("An untitled note about espresso."),
399
+ language: "en",
400
+ status: "published",
401
+ });
402
+
403
+ expect(await candidatesFor(zh.id, "espresso")).toEqual([
404
+ expect.objectContaining({ id: en.id }),
405
+ ]);
406
+ });
407
+ });
408
+
409
+ describe("GET /api/posts/:id/translations/resolve", () => {
410
+ let app: ReturnType<typeof createApiTestApp>["app"];
411
+ let services: ReturnType<typeof createApiTestApp>["services"];
412
+
413
+ beforeEach(async () => {
414
+ const testApp = createApiTestApp();
415
+ app = testApp.app;
416
+ services = testApp.services;
417
+ await services.settings.set("SITE_LANGUAGE", "zh-Hans");
418
+ await services.settings.set("ADDITIONAL_LANGUAGES", "en,ja");
419
+ await services.settings.set("MULTILINGUAL_ENABLED", "true");
420
+ });
421
+
422
+ async function resolveFor(postId: string, url: string) {
423
+ const res = await app.request(
424
+ `/api/posts/${postId}/translations/resolve?url=${encodeURIComponent(url)}`,
425
+ );
426
+ expect(res.status).toBe(200);
427
+ const body = (await res.json()) as {
428
+ resolution: { kind: string; candidate?: { id: string } };
429
+ };
430
+ return body.resolution;
431
+ }
432
+
433
+ async function publishedZh() {
434
+ return services.posts.create({
435
+ format: "note",
436
+ title: "咖啡笔记",
437
+ body: noteBody("中文"),
438
+ language: "zh-Hans",
439
+ status: "published",
440
+ });
441
+ }
442
+
443
+ it("finds the post at a pasted path", async () => {
444
+ const zh = await publishedZh();
445
+ const en = await services.posts.create({
446
+ format: "note",
447
+ title: "Coffee notes",
448
+ body: noteBody("English"),
449
+ language: "en",
450
+ status: "published",
451
+ });
452
+
453
+ expect(await resolveFor(zh.id, `/${en.slug}`)).toEqual(
454
+ expect.objectContaining({
455
+ kind: "ok",
456
+ candidate: expect.objectContaining({ id: en.id, language: "en" }),
457
+ }),
458
+ );
459
+ });
460
+
461
+ it("finds it through a full URL and a language prefix alike", async () => {
462
+ const zh = await publishedZh();
463
+ const en = await services.posts.create({
464
+ format: "note",
465
+ title: "Coffee notes",
466
+ body: noteBody("English"),
467
+ language: "en",
468
+ status: "published",
469
+ });
470
+
471
+ // What the browser's address bar hands over, and what the English view of
472
+ // the same page looks like — one post, three ways of naming it.
473
+ for (const address of [
474
+ `http://localhost:8787/${en.slug}`,
475
+ `/en/${en.slug}`,
476
+ `/${en.slug}?utm_source=newsletter`,
477
+ ]) {
478
+ expect(await resolveFor(zh.id, address)).toEqual(
479
+ expect.objectContaining({ kind: "ok" }),
480
+ );
481
+ }
482
+ });
483
+
484
+ it("follows a stored redirect to where the post lives now", async () => {
485
+ const zh = await publishedZh();
486
+ const en = await services.posts.create({
487
+ format: "note",
488
+ title: "Coffee notes",
489
+ body: noteBody("English"),
490
+ slug: "coffee-new",
491
+ language: "en",
492
+ status: "published",
493
+ });
494
+ await services.paths.create({
495
+ path: "coffee-old",
496
+ kind: "redirect",
497
+ redirectToPath: "coffee-new",
498
+ redirectType: 301,
499
+ });
500
+
501
+ // The author copied the address that still works in their browser; where
502
+ // it lands is the post they mean.
503
+ expect(await resolveFor(zh.id, "/coffee-old")).toEqual(
504
+ expect.objectContaining({
505
+ kind: "ok",
506
+ candidate: expect.objectContaining({ id: en.id }),
507
+ }),
508
+ );
509
+ });
510
+
511
+ it("names the reason instead of coming back empty", async () => {
512
+ const zh = await publishedZh();
513
+ const draft = await services.posts.create({
514
+ format: "note",
515
+ title: "Coffee draft",
516
+ body: noteBody("English"),
517
+ language: "en",
518
+ status: "draft",
519
+ });
520
+ const sameLanguage = await services.posts.create({
521
+ format: "note",
522
+ title: "另一篇",
523
+ body: noteBody("中文"),
524
+ language: "zh-Hans",
525
+ status: "published",
526
+ });
527
+
528
+ expect((await resolveFor(zh.id, `/${draft.slug}`)).kind).toBe(
529
+ "unpublished",
530
+ );
531
+ expect((await resolveFor(zh.id, `/${sameLanguage.slug}`)).kind).toBe(
532
+ "same_language",
533
+ );
534
+ expect((await resolveFor(zh.id, `/${zh.slug}`)).kind).toBe("same_thread");
535
+ expect((await resolveFor(zh.id, "/nothing-here")).kind).toBe("not_found");
536
+ });
537
+
538
+ it("says which language is in the way when one already is", async () => {
539
+ const zh = await publishedZh();
540
+ const en = await services.posts.create({
541
+ format: "note",
542
+ title: "Coffee notes",
543
+ body: noteBody("English"),
544
+ language: "en",
545
+ status: "published",
546
+ });
547
+ await services.posts.linkTranslation(zh.id, en.id);
548
+
549
+ const other = await services.posts.create({
550
+ format: "note",
551
+ title: "Coffee, again",
552
+ body: noteBody("English"),
553
+ language: "en",
554
+ status: "published",
555
+ });
556
+
557
+ expect(await resolveFor(zh.id, `/${other.slug}`)).toEqual(
558
+ expect.objectContaining({ kind: "language_taken", language: "en" }),
559
+ );
560
+ });
561
+
562
+ it("refuses a merge of two translation groups", async () => {
563
+ const zh = await publishedZh();
564
+ const ja = await services.posts.create({
565
+ format: "note",
566
+ title: "コーヒー",
567
+ body: noteBody("日本語"),
568
+ language: "ja",
569
+ status: "published",
570
+ });
571
+ await services.posts.linkTranslation(zh.id, ja.id);
572
+
573
+ const enA = await services.posts.create({
574
+ format: "note",
575
+ title: "Coffee A",
576
+ body: noteBody("English"),
577
+ language: "en",
578
+ status: "published",
579
+ });
580
+ const jaB = await services.posts.create({
581
+ format: "note",
582
+ title: "コーヒー B",
583
+ body: noteBody("日本語"),
584
+ language: "ja",
585
+ status: "published",
586
+ });
587
+ await services.posts.linkTranslation(enA.id, jaB.id);
588
+
589
+ expect((await resolveFor(zh.id, `/${enA.slug}`)).kind).toBe(
590
+ "group_conflict",
591
+ );
592
+ });
593
+
594
+ it("takes a reply's address as naming its thread", async () => {
595
+ const zh = await publishedZh();
596
+ const root = await services.posts.create({
597
+ format: "note",
598
+ title: "Coffee root",
599
+ body: noteBody("English"),
600
+ language: "en",
601
+ status: "published",
602
+ });
603
+ const reply = await services.posts.create({
604
+ format: "note",
605
+ body: noteBody("A reply"),
606
+ replyToId: root.id,
607
+ status: "published",
608
+ });
609
+
610
+ expect(await resolveFor(zh.id, `/${reply.slug}`)).toEqual(
611
+ expect.objectContaining({
612
+ kind: "ok",
613
+ candidate: expect.objectContaining({ id: root.id }),
614
+ }),
615
+ );
616
+ });
617
+
618
+ it("does not look up somebody else's URL", async () => {
619
+ const zh = await publishedZh();
620
+
621
+ expect(await resolveFor(zh.id, "https://example.com/coffee")).toEqual({
622
+ kind: "external",
623
+ address: "https://example.com/coffee",
624
+ });
625
+ });
626
+ });
627
+
628
+ describe("display labels", () => {
629
+ let app: ReturnType<typeof createApiTestApp>["app"];
630
+ let services: ReturnType<typeof createApiTestApp>["services"];
631
+
632
+ beforeEach(async () => {
633
+ const testApp = createApiTestApp();
634
+ app = testApp.app;
635
+ services = testApp.services;
636
+ await enableMultilingual(services);
637
+ });
638
+
639
+ it("names an untitled note by its opening line, not its slug", async () => {
640
+ // A slug is a URL. Showing "yidcy" where a name belongs tells the author
641
+ // nothing about which post they are looking at.
642
+ const zh = await services.posts.create({
643
+ format: "note",
644
+ body: noteBody("中文"),
645
+ language: "zh-Hans",
646
+ status: "published",
647
+ });
648
+ const en = await services.posts.create({
649
+ format: "note",
650
+ body: noteBody("An untitled note about espresso and its many moods."),
651
+ language: "en",
652
+ status: "published",
653
+ });
654
+
655
+ const candidates = (await (
656
+ await app.request(
657
+ `/api/posts/${zh.id}/translations/candidates?q=espresso`,
658
+ )
659
+ ).json()) as { candidates: Array<{ id: string; label: string }> };
660
+
661
+ expect(candidates.candidates[0]).toMatchObject({
662
+ id: en.id,
663
+ label: "An untitled note about espresso and its many moods.",
664
+ });
665
+ });
666
+
667
+ it("prefers a real title when there is one", async () => {
668
+ const zh = await services.posts.create({
669
+ format: "note",
670
+ body: noteBody("中文"),
671
+ language: "zh-Hans",
672
+ status: "published",
673
+ });
674
+ const en = await services.posts.create({
675
+ format: "note",
676
+ title: "Coffee notes",
677
+ body: noteBody("Something about espresso."),
678
+ language: "en",
679
+ status: "published",
680
+ });
681
+ await services.posts.linkTranslation(zh.id, en.id);
682
+
683
+ const body = (await (
684
+ await app.request(`/api/posts/${zh.id}/translations`)
685
+ ).json()) as { translations: Array<{ label: string }> };
686
+
687
+ expect(body.translations[0]?.label).toBe("Coffee notes");
688
+ });
689
+
690
+ it("gives the single-post API the same derived name", async () => {
691
+ // The composer's "Writing the X version of …" banner reads this.
692
+ const post = await services.posts.create({
693
+ format: "note",
694
+ body: noteBody("An untitled note the composer has to name somehow."),
695
+ status: "published",
696
+ });
697
+
698
+ const body = (await (
699
+ await app.request(`/api/posts/${post.id}`)
700
+ ).json()) as { displayTitle: string };
701
+
702
+ expect(body.displayTitle).toBe(
703
+ "An untitled note the composer has to name somehow.",
704
+ );
705
+ });
706
+ });
707
+
708
+ describe("changing the language while editing", () => {
709
+ it("applies to the whole thread", async () => {
710
+ const testApp = createApiTestApp();
711
+ await enableMultilingual(testApp.services);
712
+ const root = await testApp.services.posts.create({
713
+ format: "note",
714
+ bodyMarkdown: "Root",
715
+ language: "zh-Hans",
716
+ status: "published",
717
+ });
718
+ const reply = await testApp.services.posts.create({
719
+ format: "note",
720
+ bodyMarkdown: "Reply",
721
+ replyToId: root.id,
722
+ status: "published",
723
+ });
724
+
725
+ const res = await testApp.app.request(`/api/posts/${root.id}`, {
726
+ method: "PUT",
727
+ headers: { "Content-Type": "application/json" },
728
+ body: JSON.stringify({ language: "en" }),
729
+ });
730
+
731
+ expect(res.status).toBe(200);
732
+ expect((await testApp.services.posts.getById(root.id))?.language).toBe(
733
+ "en",
734
+ );
735
+ expect((await testApp.services.posts.getById(reply.id))?.language).toBe(
736
+ "en",
737
+ );
738
+ });
739
+
740
+ it("refuses when the translation group already holds that language", async () => {
741
+ const testApp = createApiTestApp();
742
+ await enableMultilingual(testApp.services);
743
+ const zh = await testApp.services.posts.create({
744
+ format: "note",
745
+ bodyMarkdown: "中文",
746
+ language: "zh-Hans",
747
+ status: "published",
748
+ });
749
+ const en = await testApp.services.posts.create({
750
+ format: "note",
751
+ bodyMarkdown: "English",
752
+ language: "en",
753
+ status: "published",
754
+ });
755
+ await testApp.services.posts.linkTranslation(zh.id, en.id);
756
+
757
+ const res = await testApp.app.request(`/api/posts/${zh.id}`, {
758
+ method: "PUT",
759
+ headers: { "Content-Type": "application/json" },
760
+ body: JSON.stringify({ language: "en", title: "New title" }),
761
+ });
762
+
763
+ expect(res.status).toBe(409);
764
+ // The refusal lands before anything else is written.
765
+ const after = await testApp.services.posts.getById(zh.id);
766
+ expect(after?.language).toBe("zh-Hans");
767
+ expect(after?.title).toBeNull();
768
+ });
769
+ });
770
+
771
+ describe("language on create", () => {
772
+ it("reads the language out of the text when nobody chose one", async () => {
773
+ const testApp = createApiTestApp();
774
+ await enableMultilingual(testApp.services);
775
+
776
+ const res = await testApp.app.request("/api/posts", {
777
+ method: "POST",
778
+ headers: { "Content-Type": "application/json" },
779
+ body: JSON.stringify({
780
+ format: "note",
781
+ bodyMarkdown: "This one is written in English.",
782
+ status: "published",
783
+ }),
784
+ });
785
+
786
+ expect(res.status).toBe(201);
787
+ expect((await res.json()) as { language: string }).toMatchObject({
788
+ language: "en",
789
+ });
790
+ });
791
+
792
+ it("keeps an explicit choice over what the text looks like", async () => {
793
+ const testApp = createApiTestApp();
794
+ await enableMultilingual(testApp.services);
795
+
796
+ const res = await testApp.app.request("/api/posts", {
797
+ method: "POST",
798
+ headers: { "Content-Type": "application/json" },
799
+ body: JSON.stringify({
800
+ format: "note",
801
+ bodyMarkdown: "这段是中文。",
802
+ language: "en",
803
+ status: "published",
804
+ }),
805
+ });
806
+
807
+ expect((await res.json()) as { language: string }).toMatchObject({
808
+ language: "en",
809
+ });
810
+ });
811
+
812
+ it("stamps nothing on a site that publishes one language", async () => {
813
+ const testApp = createApiTestApp();
814
+
815
+ const res = await testApp.app.request("/api/posts", {
816
+ method: "POST",
817
+ headers: { "Content-Type": "application/json" },
818
+ body: JSON.stringify({
819
+ format: "note",
820
+ bodyMarkdown: "This one is written in English.",
821
+ status: "published",
822
+ }),
823
+ });
824
+
825
+ expect((await res.json()) as { language: string | null }).toMatchObject({
826
+ language: null,
827
+ });
828
+ });
829
+ });