@jant/core 0.6.16 → 0.7.1

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 (592) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/export.js +3 -1
  4. package/bin/commands/import-site.js +749 -220
  5. package/bin/commands/reset-password.js +21 -8
  6. package/bin/commands/setup.js +136 -0
  7. package/bin/commands/site/export.js +71 -34
  8. package/bin/commands/site/pull-media.js +2 -6
  9. package/bin/commands/site/snapshot/export.js +19 -61
  10. package/bin/commands/site/snapshot/import.js +23 -24
  11. package/bin/lib/d1-query.js +92 -18
  12. package/bin/lib/hugo-markdown.js +4 -0
  13. package/bin/lib/node-env.js +40 -4
  14. package/bin/lib/site-pull-media.js +21 -28
  15. package/bin/lib/site-selection.js +94 -29
  16. package/bin/lib/site-snapshot.js +338 -3
  17. package/bin/lib/sql-export.js +68 -5
  18. package/bin/lib/wrangler-cli.js +37 -2
  19. package/bin/lib/wrangler-json.js +35 -0
  20. package/bin/lib/zip-archive.js +187 -0
  21. package/dist/{app-C7_4oab9.js → app-ZcaI1kPN.js} +53190 -13529
  22. package/dist/client/.vite/manifest.json +255 -90
  23. package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
  24. package/dist/client/_assets/chunks/create-editor-B-m7X7S5.js +261 -0
  25. package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
  26. package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
  27. package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
  28. package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
  29. package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
  30. package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
  31. package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
  32. package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
  33. package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
  34. package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
  35. package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
  36. package/dist/client/_assets/chunks/sortable-list-BJyd-LXE.js +2 -0
  37. package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
  38. package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
  39. package/dist/client/_assets/chunks/unsafe-svg-BkscJx69.js +2 -0
  40. package/dist/client/_assets/client-DAhoqPdr.css +2 -0
  41. package/dist/client/_assets/client-auth-CAMfTrKW.js +828 -0
  42. package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
  43. package/dist/client/_assets/client-compose-D6rExKVK.js +2004 -0
  44. package/dist/client/_assets/client-knSEyUJO.js +295 -0
  45. package/dist/client/_assets/client-manage-sVOkqVbH.js +2170 -0
  46. package/dist/client/_assets/client-settings-CGR_nZ78.js +1112 -0
  47. package/dist/github-sync-Gw4orAHk.js +8398 -0
  48. package/dist/index.js +2 -5
  49. package/dist/node.js +11 -9
  50. package/package.json +15 -5
  51. package/src/__tests__/auth-session-freshness.test.ts +57 -0
  52. package/src/__tests__/basecoat-variants.test.ts +121 -0
  53. package/src/__tests__/bin/assets-upload.test.ts +68 -0
  54. package/src/__tests__/dev-scripts.test.ts +203 -0
  55. package/src/__tests__/export-collection-order.test.ts +276 -0
  56. package/src/__tests__/export-feed-ids.test.ts +184 -0
  57. package/src/__tests__/export-feed-order.test.ts +294 -0
  58. package/src/__tests__/export-hugo-build.test.ts +390 -0
  59. package/src/__tests__/export-import-roundtrip.test.ts +94 -0
  60. package/src/__tests__/export-service.test.ts +842 -28
  61. package/src/__tests__/export-smart-collection.test.ts +329 -0
  62. package/src/__tests__/helpers/component-css.ts +91 -0
  63. package/src/__tests__/helpers/db.ts +33 -0
  64. package/src/__tests__/helpers/export-fixtures.ts +3 -0
  65. package/src/__tests__/helpers/hugo-site.ts +146 -0
  66. package/src/__tests__/import-site-command.test.ts +487 -1
  67. package/src/__tests__/mise-config.test.ts +75 -4
  68. package/src/__tests__/node-dev-tasks.test.ts +264 -0
  69. package/src/__tests__/site-export-canonical-import.test.ts +149 -0
  70. package/src/__tests__/snapshot-canonical-replay.test.ts +179 -0
  71. package/src/__tests__/snapshot-settings.test.ts +97 -0
  72. package/src/__tests__/snapshot-tables.test.ts +219 -0
  73. package/src/__tests__/sql-export.test.ts +173 -0
  74. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  75. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  76. package/src/__tests__/zip-archive.test.ts +106 -0
  77. package/src/app.tsx +86 -22
  78. package/src/auth.ts +18 -1
  79. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  80. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  81. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  82. package/src/client/__tests__/compose-bridge.test.ts +272 -0
  83. package/src/client/__tests__/compose-launch.test.ts +62 -1
  84. package/src/client/__tests__/compose-shortcuts.test.ts +38 -9
  85. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  86. package/src/client/__tests__/copy-field.test.ts +162 -0
  87. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  88. package/src/client/__tests__/image-processor.test.ts +324 -2
  89. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  90. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  91. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  92. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  93. package/src/client/__tests__/toast.test.ts +23 -0
  94. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  95. package/src/client/__tests__/video-processor.test.ts +181 -0
  96. package/src/client/audio-processor.ts +24 -15
  97. package/src/client/audio-waveform.ts +49 -0
  98. package/src/client/collection-dialog-host.ts +72 -0
  99. package/src/client/collection-navigation.ts +51 -16
  100. package/src/client/collection-page-actions.ts +26 -0
  101. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  102. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  103. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  104. package/src/client/components/__tests__/jant-command-palette.test.ts +28 -0
  105. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  106. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1250 -53
  107. package/src/client/components/__tests__/jant-compose-editor.test.ts +544 -40
  108. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +0 -1
  109. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  110. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  111. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  112. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  113. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  114. package/src/client/components/__tests__/jant-settings-general.test.ts +561 -172
  115. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  116. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  117. package/src/client/components/collection-dialog-types.ts +49 -0
  118. package/src/client/components/collection-manager-types.ts +32 -1
  119. package/src/client/components/collection-types.ts +1 -15
  120. package/src/client/components/compose-types.ts +75 -1
  121. package/src/client/components/jant-collection-dialog.ts +722 -0
  122. package/src/client/components/jant-collection-directory.ts +567 -182
  123. package/src/client/components/jant-collection-form.ts +53 -232
  124. package/src/client/components/jant-command-palette.ts +100 -49
  125. package/src/client/components/jant-compose-dialog.ts +1486 -707
  126. package/src/client/components/jant-compose-editor.ts +673 -336
  127. package/src/client/components/jant-compose-fullscreen.ts +51 -43
  128. package/src/client/components/jant-config-editor.ts +65 -53
  129. package/src/client/components/jant-confirm-dialog.ts +13 -11
  130. package/src/client/components/jant-locale-picker.ts +418 -0
  131. package/src/client/components/jant-media-lightbox.ts +146 -136
  132. package/src/client/components/jant-nav-manager.ts +690 -366
  133. package/src/client/components/jant-post-menu.ts +1086 -282
  134. package/src/client/components/jant-post-picker.ts +324 -0
  135. package/src/client/components/jant-repo-picker-types.ts +6 -1
  136. package/src/client/components/jant-repo-picker.ts +137 -108
  137. package/src/client/components/jant-settings-avatar.ts +31 -27
  138. package/src/client/components/jant-settings-general.ts +622 -543
  139. package/src/client/components/jant-settings-language.ts +1087 -0
  140. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  141. package/src/client/components/jant-text-preview.ts +94 -86
  142. package/src/client/components/nav-manager-types.ts +62 -1
  143. package/src/client/components/settings-types.ts +14 -29
  144. package/src/client/components/smart-collection-conditions.ts +285 -0
  145. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  146. package/src/client/compose-bridge.ts +214 -141
  147. package/src/client/compose-launch.ts +76 -2
  148. package/src/client/compose-shortcuts.ts +6 -8
  149. package/src/client/compose-triggers.ts +50 -0
  150. package/src/client/copy-field.ts +108 -0
  151. package/src/client/custom-url-menu.ts +60 -4
  152. package/src/client/icons.ts +14 -0
  153. package/src/client/image-plan.ts +165 -0
  154. package/src/client/image-processor.ts +195 -117
  155. package/src/client/image-worker-client.ts +45 -0
  156. package/src/client/image-worker.ts +135 -0
  157. package/src/client/lazy-entries.ts +95 -0
  158. package/src/client/lazy-slugify.ts +2 -2
  159. package/src/client/media-scroll-hint.ts +43 -2
  160. package/src/client/mediabunny.ts +29 -0
  161. package/src/client/post-picker.ts +41 -0
  162. package/src/client/runtime-paths.ts +74 -1
  163. package/src/client/settings-bridge.ts +4 -10
  164. package/src/client/site-header-nav.js +18 -6
  165. package/src/client/smart-collection-dialog-host.ts +116 -0
  166. package/src/client/smart-collection-page-actions.ts +208 -0
  167. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  168. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  169. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  170. package/src/client/tiptap/__tests__/list-editing.test.ts +224 -86
  171. package/src/client/tiptap/__tests__/mark-exit.test.ts +1 -2
  172. package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +26 -0
  173. package/src/client/tiptap/bubble-menu.ts +47 -16
  174. package/src/client/tiptap/extensions.ts +0 -3
  175. package/src/client/tiptap/floating-position.ts +216 -2
  176. package/src/client/tiptap/inline-image-upload.ts +7 -3
  177. package/src/client/tiptap/link-toolbar.ts +22 -9
  178. package/src/client/tiptap/structural-keymap.ts +158 -47
  179. package/src/client/toast.ts +13 -0
  180. package/src/client/types/vite-worker.d.ts +12 -0
  181. package/src/client/upload-with-metadata.ts +3 -13
  182. package/src/client/video-processor.ts +351 -107
  183. package/src/client-auth.ts +14 -31
  184. package/src/client-compose.ts +19 -0
  185. package/src/client-manage.ts +13 -0
  186. package/src/client-settings.ts +14 -0
  187. package/src/client-site.ts +10 -3
  188. package/src/client.ts +4 -0
  189. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  190. package/src/db/__tests__/d1-query.test.ts +56 -1
  191. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  192. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  193. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  194. package/src/db/migrations/0032_married_tomas.sql +3 -0
  195. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  196. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  197. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  198. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  199. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  200. package/src/db/migrations/meta/_journal.json +21 -0
  201. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  202. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  203. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  204. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  205. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  206. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  207. package/src/db/migrations/pg/meta/_journal.json +21 -0
  208. package/src/db/pg/schema.ts +195 -27
  209. package/src/db/post-visibility.ts +117 -0
  210. package/src/db/schema.ts +222 -27
  211. package/src/i18n/__tests__/detect.test.ts +26 -81
  212. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  213. package/src/i18n/context.tsx +12 -2
  214. package/src/i18n/detect.ts +7 -66
  215. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  216. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  217. package/src/i18n/locales/public/en.po +490 -158
  218. package/src/i18n/locales/public/en.ts +1 -1
  219. package/src/i18n/locales/public/zh-Hans.po +487 -155
  220. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  221. package/src/i18n/locales/public/zh-Hant.po +487 -155
  222. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  223. package/src/i18n/locales/settings/en.po +555 -73
  224. package/src/i18n/locales/settings/en.ts +1 -1
  225. package/src/i18n/locales/settings/zh-Hans.po +601 -119
  226. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  227. package/src/i18n/locales/settings/zh-Hant.po +614 -132
  228. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  229. package/src/i18n/locales.ts +94 -0
  230. package/src/i18n/middleware.ts +12 -2
  231. package/src/i18n/supported-locales.ts +80 -0
  232. package/src/lib/__tests__/api-settings.test.ts +11 -2
  233. package/src/lib/__tests__/asset-path.test.ts +92 -0
  234. package/src/lib/__tests__/deferred.test.ts +90 -0
  235. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  236. package/src/lib/__tests__/discover.test.ts +410 -0
  237. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  238. package/src/lib/__tests__/feed.test.ts +1526 -15
  239. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  240. package/src/lib/__tests__/github-sync-repo-name.test.ts +40 -0
  241. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  242. package/src/lib/__tests__/http-cache.test.ts +162 -0
  243. package/src/lib/__tests__/image.test.ts +27 -1
  244. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  245. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  246. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  247. package/src/lib/__tests__/markdown-to-tiptap.test.ts +105 -0
  248. package/src/lib/__tests__/markdown.test.ts +10 -0
  249. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  250. package/src/lib/__tests__/navigation.test.ts +135 -1
  251. package/src/lib/__tests__/resolve-config.test.ts +131 -3
  252. package/src/lib/__tests__/schemas.test.ts +69 -5
  253. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  254. package/src/lib/__tests__/slug.test.ts +70 -0
  255. package/src/lib/__tests__/slugify.test.ts +85 -0
  256. package/src/lib/__tests__/storage.test.ts +32 -0
  257. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  258. package/src/lib/__tests__/timeline.test.ts +87 -0
  259. package/src/lib/__tests__/tiptap-to-markdown.test.ts +172 -4
  260. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  261. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  262. package/src/lib/__tests__/url.test.ts +78 -72
  263. package/src/lib/__tests__/view-language.test.ts +413 -0
  264. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  265. package/src/lib/address.ts +57 -0
  266. package/src/lib/api-posts.ts +12 -0
  267. package/src/lib/asset-path.ts +98 -1
  268. package/src/lib/build-env.ts +16 -0
  269. package/src/lib/collection-groups.ts +5 -1
  270. package/src/lib/collection-paths.ts +3 -10
  271. package/src/lib/constants.ts +28 -3
  272. package/src/lib/copy-field.ts +36 -0
  273. package/src/lib/crypto.ts +40 -0
  274. package/src/lib/csp-builder.ts +2 -1
  275. package/src/lib/deferred.ts +79 -0
  276. package/src/lib/discover-ping.ts +141 -0
  277. package/src/lib/discover.ts +439 -0
  278. package/src/lib/env.ts +134 -0
  279. package/src/lib/errors.ts +33 -0
  280. package/src/lib/feed-policy.ts +236 -2
  281. package/src/lib/feed.ts +926 -73
  282. package/src/lib/filter-dimensions.ts +1349 -0
  283. package/src/lib/github-sync-repo-name.ts +45 -0
  284. package/src/lib/github-sync-site-config.ts +3 -0
  285. package/src/lib/hosted-signin.ts +1 -1
  286. package/src/lib/http-cache.ts +148 -0
  287. package/src/lib/hugo-markdown.ts +62 -0
  288. package/src/lib/icons.ts +9 -2
  289. package/src/lib/ids.ts +3 -0
  290. package/src/lib/image.ts +17 -4
  291. package/src/lib/jant-docs.ts +42 -0
  292. package/src/lib/lang-detect.ts +308 -0
  293. package/src/lib/markdown-manager.ts +392 -2
  294. package/src/lib/media-helpers.ts +13 -1
  295. package/src/lib/navigation.ts +78 -11
  296. package/src/lib/pagination.ts +28 -24
  297. package/src/lib/per-language-surfaces.ts +67 -0
  298. package/src/lib/post-body-html.ts +11 -4
  299. package/src/lib/post-meta.ts +19 -0
  300. package/src/lib/render.tsx +55 -7
  301. package/src/lib/resolve-config.ts +111 -6
  302. package/src/lib/schemas.ts +251 -15
  303. package/src/lib/site-header-fragment.tsx +2 -1
  304. package/src/lib/site-unavailable-page.ts +47 -0
  305. package/src/lib/slug.ts +51 -7
  306. package/src/lib/slugify.ts +113 -0
  307. package/src/lib/startup-config.ts +4 -6
  308. package/src/lib/storage.ts +7 -0
  309. package/src/lib/summary.ts +50 -0
  310. package/src/lib/thread-fold.ts +161 -0
  311. package/src/lib/time.ts +32 -0
  312. package/src/lib/timeline.ts +43 -2
  313. package/src/lib/tiptap-render.ts +1 -2
  314. package/src/lib/tiptap-to-markdown.ts +13 -7
  315. package/src/lib/upload.ts +16 -13
  316. package/src/lib/url.ts +159 -86
  317. package/src/lib/version.ts +10 -8
  318. package/src/lib/view-language.ts +436 -0
  319. package/src/lib/view.ts +53 -25
  320. package/src/lib/viewer-context.ts +62 -0
  321. package/src/middleware/__tests__/auth.test.ts +70 -2
  322. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  323. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  324. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  325. package/src/middleware/__tests__/session.test.ts +181 -33
  326. package/src/middleware/auth.ts +50 -31
  327. package/src/middleware/cache-control.ts +75 -10
  328. package/src/middleware/config.ts +6 -6
  329. package/src/middleware/error-handler.ts +12 -0
  330. package/src/middleware/onboarding.ts +32 -10
  331. package/src/middleware/secure-headers.ts +1 -1
  332. package/src/middleware/session.ts +96 -8
  333. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  334. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  335. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  336. package/src/node/__tests__/cli-setup.test.ts +163 -0
  337. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  338. package/src/node/__tests__/cli-site-snapshot.test.ts +67 -0
  339. package/src/node/__tests__/cli-snapshot-meta.test.ts +20 -0
  340. package/src/node/__tests__/runtime.test.ts +75 -0
  341. package/src/node/index.ts +3 -0
  342. package/src/node/request-handler.ts +3 -1
  343. package/src/preset.css +76 -22
  344. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  345. package/src/routes/api/__tests__/collections.test.ts +114 -0
  346. package/src/routes/api/__tests__/discover.test.ts +222 -0
  347. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  348. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  349. package/src/routes/api/__tests__/posts.test.ts +24 -0
  350. package/src/routes/api/__tests__/settings.test.ts +24 -3
  351. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  352. package/src/routes/api/__tests__/upload.test.ts +34 -0
  353. package/src/routes/api/collections.ts +49 -6
  354. package/src/routes/api/custom-urls.ts +0 -1
  355. package/src/routes/api/discover.ts +95 -0
  356. package/src/routes/api/export.ts +6 -3
  357. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  358. package/src/routes/api/internal/sites.ts +0 -1
  359. package/src/routes/api/nav-items.ts +55 -8
  360. package/src/routes/api/posts.ts +161 -0
  361. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  362. package/src/routes/api/public/archive.ts +77 -81
  363. package/src/routes/api/public/posts.ts +44 -7
  364. package/src/routes/api/smart-collections.ts +132 -0
  365. package/src/routes/api/telegram.ts +2 -40
  366. package/src/routes/api/upload.ts +10 -3
  367. package/src/routes/api/uploads.ts +1 -2
  368. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  369. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  370. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  371. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  372. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  373. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  374. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  375. package/src/routes/auth/setup.tsx +934 -186
  376. package/src/routes/auth/signin.tsx +10 -8
  377. package/src/routes/compose.tsx +9 -0
  378. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  379. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  380. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  381. package/src/routes/dash/__tests__/github-sync-app.test.ts +365 -0
  382. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  383. package/src/routes/dash/custom-urls.tsx +1 -37
  384. package/src/routes/dash/settings.tsx +714 -111
  385. package/src/routes/discover-announce.ts +96 -0
  386. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  387. package/src/routes/feed/feed.ts +69 -40
  388. package/src/routes/feed/sitemap.ts +95 -11
  389. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  390. package/src/routes/pages/__tests__/archive-params.test.ts +370 -6
  391. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  392. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  393. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  394. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  395. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  396. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  397. package/src/routes/pages/__tests__/post-page-round-trips.test.ts +211 -0
  398. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  399. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  400. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  401. package/src/routes/pages/__tests__/thread-order.test.ts +179 -0
  402. package/src/routes/pages/archive.tsx +487 -379
  403. package/src/routes/pages/collection.tsx +94 -96
  404. package/src/routes/pages/collections.tsx +38 -50
  405. package/src/routes/pages/featured.tsx +43 -18
  406. package/src/routes/pages/home.tsx +25 -6
  407. package/src/routes/pages/language.tsx +113 -0
  408. package/src/routes/pages/latest.tsx +26 -16
  409. package/src/routes/pages/new.tsx +3 -0
  410. package/src/routes/pages/page.tsx +228 -45
  411. package/src/routes/pages/partials.tsx +41 -0
  412. package/src/routes/pages/search.tsx +22 -5
  413. package/src/routes/pages/smart-collection.tsx +340 -0
  414. package/src/routes/pages/subscribe.tsx +182 -0
  415. package/src/runtime/__tests__/index.test.ts +19 -0
  416. package/src/runtime/__tests__/node.test.ts +129 -4
  417. package/src/runtime/__tests__/readiness.test.ts +23 -0
  418. package/src/runtime/cloudflare.ts +21 -2
  419. package/src/runtime/index.ts +27 -1
  420. package/src/runtime/node.ts +67 -1
  421. package/src/runtime/readiness.ts +15 -0
  422. package/src/runtime/site.ts +95 -24
  423. package/src/services/__tests__/auth.test.ts +116 -7
  424. package/src/services/__tests__/bootstrap-setup-instance.test.ts +230 -0
  425. package/src/services/__tests__/collection.test.ts +108 -4
  426. package/src/services/__tests__/custom-url.test.ts +34 -0
  427. package/src/services/__tests__/github-app-installations.test.ts +153 -0
  428. package/src/services/__tests__/github-sync-push.test.ts +46 -0
  429. package/src/services/__tests__/language.test.ts +495 -0
  430. package/src/services/__tests__/media.test.ts +31 -0
  431. package/src/services/__tests__/navigation.test.ts +213 -10
  432. package/src/services/__tests__/path.test.ts +236 -0
  433. package/src/services/__tests__/post-language.test.ts +830 -0
  434. package/src/services/__tests__/post-timeline.test.ts +127 -0
  435. package/src/services/__tests__/post.test.ts +120 -9
  436. package/src/services/__tests__/settings.test.ts +228 -17
  437. package/src/services/__tests__/site-admin.test.ts +5 -5
  438. package/src/services/__tests__/smart-collection.test.ts +651 -0
  439. package/src/services/about-page.ts +22 -1
  440. package/src/services/auth.ts +51 -49
  441. package/src/services/bootstrap.ts +408 -39
  442. package/src/services/collection-directory-position.ts +76 -0
  443. package/src/services/collection.ts +331 -46
  444. package/src/services/custom-url.ts +29 -24
  445. package/src/services/export-theme/assets/client-site.css +1 -1
  446. package/src/services/export-theme/assets/client-site.js +123 -123
  447. package/src/services/export-theme/layouts/_default/alias.html +27 -1
  448. package/src/services/export-theme/layouts/_default/list.html +2 -63
  449. package/src/services/export-theme/layouts/_default/rss.xml +103 -38
  450. package/src/services/export-theme/layouts/collections/list.html +3 -3
  451. package/src/services/export-theme/layouts/featured/list.html +1 -4
  452. package/src/services/export-theme/layouts/index.html +40 -26
  453. package/src/services/export-theme/layouts/partials/collection-members.html +100 -0
  454. package/src/services/export-theme/layouts/partials/collection-threads.html +33 -0
  455. package/src/services/export-theme/layouts/partials/featured-members.html +35 -0
  456. package/src/services/export-theme/layouts/partials/featured-thread.html +1 -1
  457. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  458. package/src/services/export-theme/layouts/partials/footer.html +1 -1
  459. package/src/services/export-theme/layouts/partials/head.html +1 -1
  460. package/src/services/export-theme/layouts/partials/header.html +5 -3
  461. package/src/services/export-theme/layouts/partials/jant-data.html +23 -0
  462. package/src/services/export-theme/layouts/partials/latest-members.html +48 -0
  463. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  464. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  465. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  466. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  467. package/src/services/export-theme/layouts/partials/smart-collection-members.html +129 -0
  468. package/src/services/export-theme/layouts/partials/thread-preview.html +41 -31
  469. package/src/services/export-theme/layouts/post/list.html +1 -1
  470. package/src/services/export-theme/layouts/smart_collection/list.html +24 -0
  471. package/src/services/export-theme/styles/main.css +13 -2
  472. package/src/services/export-theme/theme.toml +1 -1
  473. package/src/services/export.ts +880 -72
  474. package/src/services/github-app-installations.ts +171 -4
  475. package/src/services/github-sync.ts +69 -15
  476. package/src/services/index.ts +34 -3
  477. package/src/services/language.ts +412 -0
  478. package/src/services/mcp.ts +22 -2
  479. package/src/services/media.ts +22 -7
  480. package/src/services/navigation.ts +429 -67
  481. package/src/services/path.ts +254 -6
  482. package/src/services/post.ts +1234 -190
  483. package/src/services/search.ts +39 -2
  484. package/src/services/settings.ts +238 -43
  485. package/src/services/site-admin.ts +27 -14
  486. package/src/services/site.ts +160 -32
  487. package/src/services/smart-collection.ts +636 -0
  488. package/src/services/telegram.ts +20 -22
  489. package/src/services/upload-session.ts +1 -3
  490. package/src/style-author.css +27 -0
  491. package/src/styles/components-author.css +1622 -0
  492. package/src/styles/components.css +24 -1571
  493. package/src/styles/site-media.css +16 -2
  494. package/src/styles/tokens.css +82 -12
  495. package/src/styles/ui-author.css +7602 -0
  496. package/src/styles/ui.css +1090 -7216
  497. package/src/types/app-context.ts +23 -0
  498. package/src/types/bindings.ts +8 -0
  499. package/src/types/config.ts +94 -15
  500. package/src/types/constants.ts +188 -16
  501. package/src/types/entities.ts +87 -1
  502. package/src/types/operations.ts +74 -2
  503. package/src/types/props.ts +157 -31
  504. package/src/types/views.ts +55 -1
  505. package/src/ui/__tests__/font-themes.test.ts +52 -12
  506. package/src/ui/compose/ComposeDialog.tsx +201 -7
  507. package/src/ui/compose/ComposePrompt.tsx +1 -5
  508. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  509. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  510. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  511. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  512. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  513. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  514. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  515. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  516. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  517. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  518. package/src/ui/dash/settings/GeneralContent.tsx +277 -113
  519. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  520. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  521. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  522. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  523. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  524. package/src/ui/dash/settings/discover-copy.ts +116 -0
  525. package/src/ui/feed/LinkCard.tsx +55 -45
  526. package/src/ui/feed/NoteCard.tsx +9 -4
  527. package/src/ui/feed/PostStatusBadges.tsx +90 -43
  528. package/src/ui/feed/QuoteCard.tsx +55 -30
  529. package/src/ui/feed/ThreadPreview.tsx +6 -2
  530. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  531. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  532. package/src/ui/feed/thread-preview-state.ts +20 -6
  533. package/src/ui/font-themes.ts +39 -29
  534. package/src/ui/layouts/BaseLayout.tsx +155 -10
  535. package/src/ui/layouts/SiteLayout.tsx +192 -34
  536. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  537. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  538. package/src/ui/pages/ArchivePage.tsx +282 -289
  539. package/src/ui/pages/CollectionPage.tsx +86 -76
  540. package/src/ui/pages/CollectionsPage.tsx +5 -0
  541. package/src/ui/pages/ComposePage.tsx +8 -0
  542. package/src/ui/pages/PostPage.tsx +47 -0
  543. package/src/ui/pages/SearchPage.tsx +3 -3
  544. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  545. package/src/ui/pages/SubscribePage.tsx +236 -0
  546. package/src/ui/pages/__tests__/ArchivePage.test.tsx +65 -9
  547. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  548. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  549. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  550. package/src/ui/shared/CollectionsManager.tsx +70 -17
  551. package/src/ui/shared/CopyField.tsx +127 -0
  552. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  553. package/src/ui/shared/MediaGallery.tsx +125 -46
  554. package/src/ui/shared/Pagination.tsx +20 -5
  555. package/src/ui/shared/PostFooter.tsx +20 -11
  556. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  557. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  558. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  559. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  560. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  561. package/src/ui/shared/archive-labels.ts +98 -0
  562. package/src/ui/shared/collection-management-labels.ts +94 -16
  563. package/src/ui/shared/navigation-labels.ts +45 -71
  564. package/src/ui/shared/post-article-attributes.ts +2 -0
  565. package/src/ui/shared/smart-collection-labels.ts +343 -0
  566. package/dist/app-C75Zc8dD.js +0 -6
  567. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  568. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  569. package/dist/client/_assets/chunks/url-CU9pEbk5.js +0 -1
  570. package/dist/client/_assets/client-Bq0vre8Y.js +0 -296
  571. package/dist/client/_assets/client-D3VZyG4L.css +0 -2
  572. package/dist/client/_assets/client-auth-F2rb9WfF.js +0 -5571
  573. package/dist/env-BPYViDJJ.js +0 -281
  574. package/dist/export-Dl5KCiEs.js +0 -5562
  575. package/dist/github-api-BNF35Lkx.js +0 -176
  576. package/dist/github-app-DdiD-bC4.js +0 -275
  577. package/dist/github-sync-Bqg62IvV.js +0 -4
  578. package/dist/github-sync-HdK2EgvJ.js +0 -431
  579. package/dist/url-CbLAtlZe.js +0 -900
  580. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  581. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  582. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  583. package/src/client/collection-created-notice.ts +0 -66
  584. package/src/client/collection-form-bridge.ts +0 -191
  585. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  586. package/src/client/components/jant-post-form.ts +0 -422
  587. package/src/client/components/post-form-template.ts +0 -352
  588. package/src/client/components/post-form-types.ts +0 -96
  589. package/src/client/media-metadata.ts +0 -247
  590. package/src/client/post-form-bridge.ts +0 -193
  591. package/src/client/tiptap/exitable-marks.ts +0 -73
  592. 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
+ });