@jant/core 0.6.15 → 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 (537) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/import-site.js +67 -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-C-oi_t8W.js → app-K_Aa1MMn.js} +52623 -13193
  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-CsFx6F_M.js → github-sync-BPAvT999.js} +2343 -110
  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__/mise-config.test.ts +22 -0
  51. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  52. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  53. package/src/app.tsx +71 -16
  54. package/src/auth.ts +18 -1
  55. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  56. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  57. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  58. package/src/client/__tests__/compose-bridge.test.ts +524 -2
  59. package/src/client/__tests__/compose-launch.test.ts +62 -1
  60. package/src/client/__tests__/compose-shortcuts.test.ts +57 -8
  61. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  62. package/src/client/__tests__/copy-field.test.ts +162 -0
  63. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  64. package/src/client/__tests__/image-processor.test.ts +324 -2
  65. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  66. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  67. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  68. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  69. package/src/client/__tests__/toast.test.ts +88 -1
  70. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  71. package/src/client/__tests__/video-processor.test.ts +181 -0
  72. package/src/client/audio-processor.ts +24 -15
  73. package/src/client/audio-waveform.ts +49 -0
  74. package/src/client/collection-dialog-host.ts +72 -0
  75. package/src/client/collection-navigation.ts +51 -16
  76. package/src/client/collection-page-actions.ts +26 -0
  77. package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
  78. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  79. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  80. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  81. package/src/client/components/__tests__/jant-command-palette.test.ts +79 -0
  82. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  83. package/src/client/components/__tests__/jant-compose-dialog.test.ts +3283 -389
  84. package/src/client/components/__tests__/jant-compose-editor.test.ts +581 -86
  85. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +34 -89
  86. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  87. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  88. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  89. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  90. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  91. package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
  92. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  93. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  94. package/src/client/components/collection-dialog-types.ts +49 -0
  95. package/src/client/components/collection-manager-types.ts +32 -1
  96. package/src/client/components/collection-types.ts +1 -15
  97. package/src/client/components/compose-format-convert.ts +5 -1
  98. package/src/client/components/compose-types.ts +121 -3
  99. package/src/client/components/jant-collection-dialog.ts +722 -0
  100. package/src/client/components/jant-collection-directory.ts +567 -182
  101. package/src/client/components/jant-collection-form.ts +53 -232
  102. package/src/client/components/jant-command-palette.ts +106 -49
  103. package/src/client/components/jant-compose-dialog.ts +2757 -1194
  104. package/src/client/components/jant-compose-editor.ts +866 -397
  105. package/src/client/components/jant-compose-fullscreen.ts +53 -47
  106. package/src/client/components/jant-config-editor.ts +65 -53
  107. package/src/client/components/jant-confirm-dialog.ts +13 -11
  108. package/src/client/components/jant-locale-picker.ts +418 -0
  109. package/src/client/components/jant-media-lightbox.ts +146 -136
  110. package/src/client/components/jant-nav-manager.ts +690 -366
  111. package/src/client/components/jant-post-menu.ts +1142 -268
  112. package/src/client/components/jant-post-picker.ts +324 -0
  113. package/src/client/components/jant-repo-picker.ts +138 -106
  114. package/src/client/components/jant-settings-avatar.ts +31 -27
  115. package/src/client/components/jant-settings-general.ts +617 -543
  116. package/src/client/components/jant-settings-language.ts +1087 -0
  117. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  118. package/src/client/components/jant-text-preview.ts +94 -86
  119. package/src/client/components/nav-manager-types.ts +62 -1
  120. package/src/client/components/settings-types.ts +14 -29
  121. package/src/client/components/smart-collection-conditions.ts +285 -0
  122. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  123. package/src/client/compose-bridge.ts +336 -242
  124. package/src/client/compose-launch.ts +89 -2
  125. package/src/client/compose-shortcuts.ts +9 -7
  126. package/src/client/compose-triggers.ts +50 -0
  127. package/src/client/copy-field.ts +108 -0
  128. package/src/client/custom-url-menu.ts +60 -4
  129. package/src/client/icons.ts +14 -0
  130. package/src/client/image-plan.ts +165 -0
  131. package/src/client/image-processor.ts +195 -117
  132. package/src/client/image-worker-client.ts +45 -0
  133. package/src/client/image-worker.ts +135 -0
  134. package/src/client/lazy-entries.ts +95 -0
  135. package/src/client/lazy-slugify.ts +2 -2
  136. package/src/client/media-scroll-hint.ts +43 -2
  137. package/src/client/mediabunny.ts +29 -0
  138. package/src/client/post-picker.ts +41 -0
  139. package/src/client/post-refresh.ts +135 -0
  140. package/src/client/runtime-paths.ts +74 -1
  141. package/src/client/settings-bridge.ts +4 -10
  142. package/src/client/site-header-nav.js +18 -6
  143. package/src/client/smart-collection-dialog-host.ts +116 -0
  144. package/src/client/smart-collection-page-actions.ts +208 -0
  145. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  146. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  147. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  148. package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
  149. package/src/client/tiptap/bubble-menu.ts +47 -16
  150. package/src/client/tiptap/floating-position.ts +216 -2
  151. package/src/client/tiptap/inline-image-upload.ts +7 -3
  152. package/src/client/tiptap/link-input-rules.ts +4 -4
  153. package/src/client/tiptap/link-toolbar.ts +22 -9
  154. package/src/client/toast.ts +143 -38
  155. package/src/client/types/vite-worker.d.ts +12 -0
  156. package/src/client/upload-with-metadata.ts +3 -13
  157. package/src/client/video-processor.ts +351 -107
  158. package/src/client-auth.ts +14 -31
  159. package/src/client-compose.ts +19 -0
  160. package/src/client-manage.ts +13 -0
  161. package/src/client-settings.ts +14 -0
  162. package/src/client-site.ts +10 -3
  163. package/src/client.ts +4 -0
  164. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  165. package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
  166. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  167. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  168. package/src/db/__tests__/thread-activity.test.ts +113 -0
  169. package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
  170. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  171. package/src/db/migrations/0031_many_ego.sql +3 -0
  172. package/src/db/migrations/0032_married_tomas.sql +3 -0
  173. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  174. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  175. package/src/db/migrations/meta/0031_snapshot.json +2562 -0
  176. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  177. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  178. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  179. package/src/db/migrations/meta/_journal.json +28 -0
  180. package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
  181. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  182. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  183. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  184. package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
  185. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  186. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  187. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  188. package/src/db/migrations/pg/meta/_journal.json +28 -0
  189. package/src/db/pg/schema.ts +213 -27
  190. package/src/db/post-visibility.ts +117 -0
  191. package/src/db/schema.ts +242 -27
  192. package/src/db/thread-activity.ts +101 -0
  193. package/src/i18n/__tests__/detect.test.ts +26 -81
  194. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  195. package/src/i18n/context.tsx +12 -2
  196. package/src/i18n/detect.ts +7 -66
  197. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  198. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  199. package/src/i18n/locales/public/en.po +568 -168
  200. package/src/i18n/locales/public/en.ts +1 -1
  201. package/src/i18n/locales/public/zh-Hans.po +562 -162
  202. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  203. package/src/i18n/locales/public/zh-Hant.po +562 -162
  204. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  205. package/src/i18n/locales/settings/en.po +551 -69
  206. package/src/i18n/locales/settings/en.ts +1 -1
  207. package/src/i18n/locales/settings/zh-Hans.po +597 -115
  208. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  209. package/src/i18n/locales/settings/zh-Hant.po +610 -128
  210. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  211. package/src/i18n/locales.ts +94 -0
  212. package/src/i18n/middleware.ts +12 -2
  213. package/src/i18n/supported-locales.ts +80 -0
  214. package/src/lib/__tests__/api-settings.test.ts +11 -2
  215. package/src/lib/__tests__/asset-path.test.ts +92 -0
  216. package/src/lib/__tests__/deferred.test.ts +90 -0
  217. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  218. package/src/lib/__tests__/discover.test.ts +410 -0
  219. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  220. package/src/lib/__tests__/feed.test.ts +1691 -12
  221. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  222. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  223. package/src/lib/__tests__/http-cache.test.ts +162 -0
  224. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  225. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  226. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  227. package/src/lib/__tests__/markdown.test.ts +16 -0
  228. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  229. package/src/lib/__tests__/navigation.test.ts +135 -1
  230. package/src/lib/__tests__/resolve-config.test.ts +64 -3
  231. package/src/lib/__tests__/schemas.test.ts +42 -4
  232. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  233. package/src/lib/__tests__/slug.test.ts +88 -0
  234. package/src/lib/__tests__/slugify.test.ts +85 -0
  235. package/src/lib/__tests__/storage.test.ts +32 -0
  236. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  237. package/src/lib/__tests__/translit.test.ts +87 -0
  238. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  239. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  240. package/src/lib/__tests__/url.test.ts +93 -34
  241. package/src/lib/__tests__/view-language.test.ts +413 -0
  242. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  243. package/src/lib/address.ts +57 -0
  244. package/src/lib/api-posts.ts +12 -0
  245. package/src/lib/asset-path.ts +98 -1
  246. package/src/lib/build-env.ts +16 -0
  247. package/src/lib/collection-groups.ts +5 -1
  248. package/src/lib/collection-paths.ts +3 -10
  249. package/src/lib/constants.ts +28 -3
  250. package/src/lib/copy-field.ts +36 -0
  251. package/src/lib/crypto.ts +40 -0
  252. package/src/lib/csp-builder.ts +2 -1
  253. package/src/lib/deferred.ts +79 -0
  254. package/src/lib/discover-ping.ts +141 -0
  255. package/src/lib/discover.ts +437 -0
  256. package/src/lib/env.ts +134 -0
  257. package/src/lib/errors.ts +33 -0
  258. package/src/lib/feed-policy.ts +236 -2
  259. package/src/lib/feed.ts +1021 -66
  260. package/src/lib/filter-dimensions.ts +1349 -0
  261. package/src/lib/github-sync-site-config.ts +3 -0
  262. package/src/lib/hosted-signin.ts +1 -1
  263. package/src/lib/http-cache.ts +148 -0
  264. package/src/lib/hugo-markdown.ts +21 -0
  265. package/src/lib/icons.ts +9 -2
  266. package/src/lib/ids.ts +3 -0
  267. package/src/lib/jant-docs.ts +42 -0
  268. package/src/lib/lang-detect.ts +308 -0
  269. package/src/lib/link-preview.ts +25 -0
  270. package/src/lib/markdown-manager.ts +6 -1
  271. package/src/lib/media-helpers.ts +13 -1
  272. package/src/lib/navigation.ts +78 -11
  273. package/src/lib/pagination.ts +28 -24
  274. package/src/lib/per-language-surfaces.ts +67 -0
  275. package/src/lib/post-display.ts +42 -9
  276. package/src/lib/post-meta.ts +19 -0
  277. package/src/lib/render.tsx +55 -7
  278. package/src/lib/resolve-config.ts +53 -5
  279. package/src/lib/schemas.ts +201 -10
  280. package/src/lib/site-header-fragment.tsx +2 -1
  281. package/src/lib/site-unavailable-page.ts +47 -0
  282. package/src/lib/slug.ts +51 -7
  283. package/src/lib/slugify.ts +113 -0
  284. package/src/lib/startup-config.ts +4 -6
  285. package/src/lib/storage.ts +7 -0
  286. package/src/lib/summary.ts +50 -0
  287. package/src/lib/thread-fold.ts +161 -0
  288. package/src/lib/time.ts +32 -0
  289. package/src/lib/timeline.ts +81 -10
  290. package/src/lib/tiptap-render.ts +5 -4
  291. package/src/lib/translit.ts +288 -0
  292. package/src/lib/upload.ts +16 -13
  293. package/src/lib/url.ts +188 -24
  294. package/src/lib/version.ts +10 -8
  295. package/src/lib/view-language.ts +436 -0
  296. package/src/lib/view.ts +79 -23
  297. package/src/lib/viewer-context.ts +62 -0
  298. package/src/middleware/__tests__/auth.test.ts +70 -2
  299. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  300. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  301. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  302. package/src/middleware/__tests__/session.test.ts +181 -33
  303. package/src/middleware/auth.ts +50 -31
  304. package/src/middleware/cache-control.ts +75 -10
  305. package/src/middleware/config.ts +6 -6
  306. package/src/middleware/error-handler.ts +12 -0
  307. package/src/middleware/onboarding.ts +32 -10
  308. package/src/middleware/secure-headers.ts +1 -1
  309. package/src/middleware/session.ts +96 -8
  310. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  311. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  312. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  313. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  314. package/src/node/__tests__/runtime.test.ts +37 -0
  315. package/src/node/index.ts +1 -0
  316. package/src/preset.css +76 -22
  317. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  318. package/src/routes/api/__tests__/collections.test.ts +114 -0
  319. package/src/routes/api/__tests__/discover.test.ts +222 -0
  320. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  321. package/src/routes/api/__tests__/palette.test.ts +44 -0
  322. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  323. package/src/routes/api/__tests__/settings.test.ts +24 -3
  324. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  325. package/src/routes/api/collections.ts +49 -6
  326. package/src/routes/api/custom-urls.ts +0 -1
  327. package/src/routes/api/discover.ts +95 -0
  328. package/src/routes/api/export.ts +3 -0
  329. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  330. package/src/routes/api/nav-items.ts +55 -8
  331. package/src/routes/api/posts.ts +172 -9
  332. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  333. package/src/routes/api/public/__tests__/posts.test.ts +38 -0
  334. package/src/routes/api/public/archive.ts +77 -81
  335. package/src/routes/api/public/posts.ts +30 -6
  336. package/src/routes/api/smart-collections.ts +132 -0
  337. package/src/routes/api/telegram.ts +2 -40
  338. package/src/routes/api/uploads.ts +1 -2
  339. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  340. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  341. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  342. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  343. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  344. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  345. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  346. package/src/routes/auth/setup.tsx +934 -186
  347. package/src/routes/auth/signin.tsx +10 -8
  348. package/src/routes/compose.tsx +11 -2
  349. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  350. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  351. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  352. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  353. package/src/routes/dash/custom-urls.tsx +1 -37
  354. package/src/routes/dash/settings.tsx +502 -41
  355. package/src/routes/discover-announce.ts +96 -0
  356. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  357. package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
  358. package/src/routes/feed/feed.ts +69 -40
  359. package/src/routes/feed/sitemap.ts +95 -11
  360. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  361. package/src/routes/pages/__tests__/archive-params.test.ts +657 -1
  362. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  363. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  364. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  365. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  366. package/src/routes/pages/__tests__/featured.test.ts +6 -2
  367. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  368. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  369. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  370. package/src/routes/pages/__tests__/preview.test.ts +3 -1
  371. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  372. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  373. package/src/routes/pages/archive.tsx +507 -357
  374. package/src/routes/pages/collection.tsx +94 -96
  375. package/src/routes/pages/collections.tsx +38 -50
  376. package/src/routes/pages/featured.tsx +32 -13
  377. package/src/routes/pages/home.tsx +25 -6
  378. package/src/routes/pages/language.tsx +113 -0
  379. package/src/routes/pages/latest.tsx +26 -16
  380. package/src/routes/pages/new.tsx +3 -0
  381. package/src/routes/pages/page.tsx +183 -19
  382. package/src/routes/pages/partials.tsx +41 -0
  383. package/src/routes/pages/search.tsx +18 -3
  384. package/src/routes/pages/smart-collection.tsx +340 -0
  385. package/src/routes/pages/subscribe.tsx +182 -0
  386. package/src/runtime/__tests__/index.test.ts +19 -0
  387. package/src/runtime/__tests__/node.test.ts +129 -4
  388. package/src/runtime/cloudflare.ts +21 -2
  389. package/src/runtime/index.ts +27 -1
  390. package/src/runtime/node.ts +18 -1
  391. package/src/runtime/site.ts +95 -24
  392. package/src/services/__tests__/auth.test.ts +116 -7
  393. package/src/services/__tests__/collection.test.ts +139 -4
  394. package/src/services/__tests__/language.test.ts +495 -0
  395. package/src/services/__tests__/navigation.test.ts +213 -10
  396. package/src/services/__tests__/path.test.ts +180 -0
  397. package/src/services/__tests__/post-language.test.ts +830 -0
  398. package/src/services/__tests__/post-timeline.test.ts +142 -11
  399. package/src/services/__tests__/post.test.ts +323 -9
  400. package/src/services/__tests__/settings.test.ts +228 -17
  401. package/src/services/__tests__/site-admin.test.ts +5 -5
  402. package/src/services/__tests__/smart-collection.test.ts +651 -0
  403. package/src/services/about-page.ts +22 -1
  404. package/src/services/auth.ts +51 -49
  405. package/src/services/bootstrap.ts +188 -38
  406. package/src/services/collection-directory-position.ts +76 -0
  407. package/src/services/collection.ts +330 -47
  408. package/src/services/custom-url.ts +27 -22
  409. package/src/services/export-theme/assets/client-site.css +1 -1
  410. package/src/services/export-theme/assets/client-site.js +123 -123
  411. package/src/services/export-theme/layouts/_default/rss.xml +77 -1
  412. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  413. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  414. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  415. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  416. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  417. package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
  418. package/src/services/export-theme/styles/main.css +13 -1
  419. package/src/services/export.ts +281 -24
  420. package/src/services/index.ts +34 -3
  421. package/src/services/language.ts +412 -0
  422. package/src/services/mcp.ts +3 -1
  423. package/src/services/media.ts +14 -6
  424. package/src/services/navigation.ts +429 -67
  425. package/src/services/path.ts +225 -7
  426. package/src/services/post.ts +1318 -252
  427. package/src/services/search.ts +48 -2
  428. package/src/services/settings.ts +238 -43
  429. package/src/services/site-admin.ts +24 -7
  430. package/src/services/site.ts +77 -1
  431. package/src/services/smart-collection.ts +636 -0
  432. package/src/services/telegram.ts +20 -22
  433. package/src/services/upload-session.ts +1 -3
  434. package/src/style-author.css +27 -0
  435. package/src/styles/components-author.css +1622 -0
  436. package/src/styles/components.css +24 -1585
  437. package/src/styles/site-media.css +20 -6
  438. package/src/styles/tokens.css +144 -13
  439. package/src/styles/ui-author.css +7602 -0
  440. package/src/styles/ui.css +1224 -7276
  441. package/src/types/app-context.ts +7 -0
  442. package/src/types/bindings.ts +3 -0
  443. package/src/types/config.ts +94 -15
  444. package/src/types/constants.ts +188 -16
  445. package/src/types/entities.ts +92 -1
  446. package/src/types/operations.ts +64 -2
  447. package/src/types/props.ts +168 -30
  448. package/src/types/views.ts +61 -1
  449. package/src/ui/__tests__/font-themes.test.ts +52 -12
  450. package/src/ui/compose/ComposeDialog.tsx +215 -21
  451. package/src/ui/compose/ComposePrompt.tsx +1 -5
  452. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  453. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  454. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  455. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  456. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  457. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  458. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  459. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  460. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  461. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  462. package/src/ui/dash/settings/GeneralContent.tsx +275 -113
  463. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  464. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  465. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  466. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  467. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  468. package/src/ui/dash/settings/discover-copy.ts +116 -0
  469. package/src/ui/feed/LinkCard.tsx +55 -45
  470. package/src/ui/feed/LinkPreview.tsx +2 -7
  471. package/src/ui/feed/NoteCard.tsx +9 -4
  472. package/src/ui/feed/PostStatusBadges.tsx +103 -17
  473. package/src/ui/feed/QuoteCard.tsx +55 -30
  474. package/src/ui/feed/ThreadPreview.tsx +6 -2
  475. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  476. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  477. package/src/ui/feed/thread-preview-state.ts +20 -6
  478. package/src/ui/font-themes.ts +39 -29
  479. package/src/ui/layouts/BaseLayout.tsx +155 -10
  480. package/src/ui/layouts/SiteLayout.tsx +192 -34
  481. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  482. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  483. package/src/ui/pages/ArchivePage.tsx +401 -301
  484. package/src/ui/pages/CollectionPage.tsx +86 -76
  485. package/src/ui/pages/CollectionsPage.tsx +5 -0
  486. package/src/ui/pages/ComposePage.tsx +8 -0
  487. package/src/ui/pages/PostPage.tsx +47 -0
  488. package/src/ui/pages/SearchPage.tsx +3 -3
  489. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  490. package/src/ui/pages/SubscribePage.tsx +236 -0
  491. package/src/ui/pages/__tests__/ArchivePage.test.tsx +122 -3
  492. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  493. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  494. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  495. package/src/ui/shared/CollectionsManager.tsx +70 -17
  496. package/src/ui/shared/CopyField.tsx +127 -0
  497. package/src/ui/shared/DraftPreviewBar.tsx +25 -9
  498. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  499. package/src/ui/shared/MediaGallery.tsx +125 -46
  500. package/src/ui/shared/Pagination.tsx +20 -5
  501. package/src/ui/shared/PostFooter.tsx +55 -23
  502. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  503. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  504. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  505. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  506. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  507. package/src/ui/shared/archive-labels.ts +98 -0
  508. package/src/ui/shared/collection-management-labels.ts +94 -16
  509. package/src/ui/shared/custom-icons.ts +5 -0
  510. package/src/ui/shared/navigation-labels.ts +45 -71
  511. package/src/ui/shared/post-article-attributes.ts +8 -0
  512. package/src/ui/shared/smart-collection-labels.ts +343 -0
  513. package/dist/app-BxCOR3Uc.js +0 -6
  514. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  515. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  516. package/dist/client/_assets/chunks/url-pLre2DM_.js +0 -1
  517. package/dist/client/_assets/client-B_eGKN5p.css +0 -2
  518. package/dist/client/_assets/client-DxY5BFe8.js +0 -296
  519. package/dist/client/_assets/client-auth-CakPESv9.js +0 -5533
  520. package/dist/env-OHRKGcMj.js +0 -281
  521. package/dist/github-api-BgSiE71w.js +0 -176
  522. package/dist/github-app-BbklkFmU.js +0 -275
  523. package/dist/github-sync-Cq1pzzOI.js +0 -431
  524. package/dist/github-sync-ppWXN3jb.js +0 -4
  525. package/dist/url-BMYO-Zlt.js +0 -395
  526. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  527. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  528. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  529. package/src/client/collection-created-notice.ts +0 -66
  530. package/src/client/collection-form-bridge.ts +0 -191
  531. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  532. package/src/client/components/jant-post-form.ts +0 -422
  533. package/src/client/components/post-form-template.ts +0 -352
  534. package/src/client/components/post-form-types.ts +0 -96
  535. package/src/client/media-metadata.ts +0 -247
  536. package/src/client/post-form-bridge.ts +0 -193
  537. package/src/ui/pages/CollectionEditorPage.tsx +0 -175
@@ -400,7 +400,7 @@ describe("PostService - Timeline features", () => {
400
400
  });
401
401
  });
402
402
 
403
- describe("getLastPostIdsByThread", () => {
403
+ describe("getThreadTailIds", () => {
404
404
  it("keeps independent root threads separate", async () => {
405
405
  const root1 = await postService.create({
406
406
  format: "note",
@@ -412,10 +412,7 @@ describe("PostService - Timeline features", () => {
412
412
  quoteText: "quoted",
413
413
  });
414
414
 
415
- const result = await postService.getLastPostIdsByThread([
416
- root1.id,
417
- root2.id,
418
- ]);
415
+ const result = await postService.getThreadTailIds([root1.id, root2.id]);
419
416
 
420
417
  expect(result.get(root1.id)).toBe(root1.id);
421
418
  expect(result.get(root2.id)).toBe(root2.id);
@@ -436,14 +433,57 @@ describe("PostService - Timeline features", () => {
436
433
  replyToId: root1.id,
437
434
  });
438
435
 
439
- const result = await postService.getLastPostIdsByThread([
440
- root1.id,
441
- root2.id,
442
- ]);
436
+ const result = await postService.getThreadTailIds([root1.id, root2.id]);
443
437
 
444
438
  expect(result.get(root1.id)).toBe(reply1.id);
445
439
  expect(result.get(root2.id)).toBe(root2.id);
446
440
  });
441
+
442
+ it("skips a trailing draft by default", async () => {
443
+ const root = await postService.create({
444
+ format: "note",
445
+ bodyMarkdown: "root",
446
+ });
447
+ const reply = await postService.create({
448
+ format: "note",
449
+ bodyMarkdown: "published reply",
450
+ replyToId: root.id,
451
+ });
452
+ await postService.create({
453
+ format: "note",
454
+ bodyMarkdown: "unfinished",
455
+ replyToId: reply.id,
456
+ status: "draft",
457
+ });
458
+
459
+ const result = await postService.getThreadTailIds([root.id]);
460
+
461
+ expect(result.get(root.id)).toBe(reply.id);
462
+ });
463
+
464
+ it("returns a trailing draft when drafts are included", async () => {
465
+ const root = await postService.create({
466
+ format: "note",
467
+ bodyMarkdown: "root",
468
+ });
469
+ const reply = await postService.create({
470
+ format: "note",
471
+ bodyMarkdown: "published reply",
472
+ replyToId: root.id,
473
+ });
474
+ const draft = await postService.create({
475
+ format: "note",
476
+ bodyMarkdown: "unfinished",
477
+ replyToId: reply.id,
478
+ status: "draft",
479
+ });
480
+
481
+ const result = await postService.getThreadTailIds([root.id], {
482
+ includeDrafts: true,
483
+ });
484
+
485
+ expect(result.get(root.id)).toBe(draft.id);
486
+ });
447
487
  });
448
488
 
449
489
  describe("timeline assembly", () => {
@@ -586,7 +626,98 @@ describe("PostService - Timeline features", () => {
586
626
  expect(rootIds).toEqual([newerRoot.id, olderRoot.id]);
587
627
  });
588
628
 
589
- it("orders a Thread by a newer root edit", async () => {
629
+ // Quietness used to live only in control flow: the reply skipped the
630
+ // recalculation and nothing recorded that it had. Any later recalculation
631
+ // recomputed MAX(published_at) over the whole Thread, swallowed the quiet
632
+ // reply, and bumped the Thread anyway. It is a stored column now, so the
633
+ // decision survives whatever else happens to the Thread.
634
+ it("keeps a quiet reply quiet after an unrelated post is deleted", async () => {
635
+ const collection = await collectionService.create({
636
+ slug: "durable",
637
+ title: "Durable",
638
+ });
639
+ const olderRoot = await postService.create({
640
+ format: "note",
641
+ bodyMarkdown: "Older root",
642
+ publishedAt: 1000,
643
+ });
644
+ const newerRoot = await postService.create({
645
+ format: "note",
646
+ bodyMarkdown: "Newer root",
647
+ publishedAt: 2000,
648
+ });
649
+ await collectionService.addThread(collection.id, olderRoot.id);
650
+ await collectionService.addThread(collection.id, newerRoot.id);
651
+
652
+ const normalReply = await postService.create({
653
+ format: "note",
654
+ bodyMarkdown: "Normal reply",
655
+ replyToId: olderRoot.id,
656
+ publishedAt: 1500,
657
+ });
658
+ await postService.create({
659
+ format: "note",
660
+ bodyMarkdown: "Quiet reply",
661
+ replyToId: normalReply.id,
662
+ publishedAt: 3000,
663
+ quietReply: true,
664
+ });
665
+
666
+ // Triggers a full recalculation of the Thread.
667
+ await postService.delete(normalReply.id);
668
+
669
+ const root = await postService.getById(olderRoot.id);
670
+ expect(root?.lastActivityAt).toBe(1000);
671
+ expect(root?.threadUpdatedAt).toBe(3000);
672
+
673
+ const rootIds = await postService.listCollectionThreadRootIds(
674
+ collection.id,
675
+ { status: "published", sortOrder: "newest" },
676
+ );
677
+ expect(rootIds).toEqual([newerRoot.id, olderRoot.id]);
678
+ });
679
+
680
+ it("records both activity timestamps for a quiet reply", async () => {
681
+ const root = await postService.create({
682
+ format: "note",
683
+ bodyMarkdown: "Root",
684
+ publishedAt: 1000,
685
+ });
686
+ await postService.create({
687
+ format: "note",
688
+ bodyMarkdown: "Quiet reply",
689
+ replyToId: root.id,
690
+ publishedAt: 3000,
691
+ quietReply: true,
692
+ });
693
+
694
+ const updated = await postService.getById(root.id);
695
+ expect(updated?.lastActivityAt).toBe(1000);
696
+ expect(updated?.threadUpdatedAt).toBe(3000);
697
+ });
698
+
699
+ it("moves both timestamps for a normal reply", async () => {
700
+ const root = await postService.create({
701
+ format: "note",
702
+ bodyMarkdown: "Root",
703
+ publishedAt: 1000,
704
+ });
705
+ await postService.create({
706
+ format: "note",
707
+ bodyMarkdown: "Normal reply",
708
+ replyToId: root.id,
709
+ publishedAt: 3000,
710
+ });
711
+
712
+ const updated = await postService.getById(root.id);
713
+ expect(updated?.lastActivityAt).toBe(3000);
714
+ expect(updated?.threadUpdatedAt).toBe(3000);
715
+ });
716
+
717
+ // Activity means the Thread gained a post. Editing an old post is not
718
+ // activity — otherwise fixing a typo would drag it back to the top of its
719
+ // collection, and "recently updated" would stop meaning anything.
720
+ it("does not reorder a Thread when its root is only edited", async () => {
590
721
  const collection = await collectionService.create({
591
722
  slug: "edited",
592
723
  title: "Edited",
@@ -617,7 +748,7 @@ describe("PostService - Timeline features", () => {
617
748
  },
618
749
  );
619
750
 
620
- expect(rootIds).toEqual([editedRoot.id, newerRoot.id]);
751
+ expect(rootIds).toEqual([newerRoot.id, editedRoot.id]);
621
752
  });
622
753
  });
623
754
 
@@ -570,20 +570,53 @@ describe("PostService", () => {
570
570
  const descending = await postService.list({
571
571
  sortOrder: "rating_desc",
572
572
  });
573
- const ascending = await postService.list({
574
- sortOrder: "rating_asc",
575
- });
576
573
 
577
574
  expect(descending.map((post) => post.bodyText)).toEqual([
578
575
  "five stars",
579
576
  "three stars",
580
577
  "unrated newest",
581
578
  ]);
582
- expect(ascending.map((post) => post.bodyText)).toEqual([
583
- "three stars",
584
- "five stars",
585
- "unrated newest",
579
+ });
580
+
581
+ it("orders by time for an explicit newest, ratings and all", async () => {
582
+ // `newest` used to have no branch of its own in the ORDER BY and fell
583
+ // into the one written for the ascending rating order, so anything that
584
+ // named it — a smart collection page, most visibly — got its posts
585
+ // grouped by rating instead of dated. Rated rows here on purpose.
586
+ await postService.create({
587
+ format: "note",
588
+ bodyMarkdown: "oldest, best",
589
+ publishedAt: 1000,
590
+ rating: 5,
591
+ });
592
+ await postService.create({
593
+ format: "note",
594
+ bodyMarkdown: "middle, worst",
595
+ publishedAt: 2000,
596
+ rating: 1,
597
+ });
598
+ await postService.create({
599
+ format: "note",
600
+ bodyMarkdown: "newest, unrated",
601
+ publishedAt: 3000,
602
+ });
603
+
604
+ const ordered = await postService.list({ sortOrder: "newest" });
605
+ expect(ordered.map((post) => post.bodyText)).toEqual([
606
+ "newest, unrated",
607
+ "middle, worst",
608
+ "oldest, best",
586
609
  ]);
610
+
611
+ // The keyset cursor reads the same keys or pagination skips rows.
612
+ expect(
613
+ (
614
+ await postService.list({
615
+ sortOrder: "newest",
616
+ cursor: ordered[0]?.id,
617
+ })
618
+ ).map((post) => post.bodyText),
619
+ ).toEqual(["middle, worst", "oldest, best"]);
587
620
  });
588
621
 
589
622
  it("orders drafts by updatedAt descending", async () => {
@@ -1470,6 +1503,9 @@ describe("PostService", () => {
1470
1503
  expect(published?.status).toBe("published");
1471
1504
  expect(published?.publishedAt).toBeTypeOf("number");
1472
1505
  expect((published?.publishedAt ?? 0) >= post.updatedAt).toBe(true);
1506
+ expect(
1507
+ (await postService.list({ status: "draft" })).map((draft) => draft.id),
1508
+ ).not.toContain(post.id);
1473
1509
  });
1474
1510
 
1475
1511
  it("clears publishedAt when converting a published post back to draft", async () => {
@@ -1567,7 +1603,7 @@ describe("PostService", () => {
1567
1603
  expect(updated?.pinnedAt).toBeTypeOf("number");
1568
1604
  });
1569
1605
 
1570
- it("updates slug and its page navigation URL", async () => {
1606
+ it("updates slug and title on its page navigation entry", async () => {
1571
1607
  const post = await postService.create({
1572
1608
  format: "note",
1573
1609
  title: "Test page",
@@ -1582,11 +1618,15 @@ describe("PostService", () => {
1582
1618
 
1583
1619
  const updated = await postService.update(post.id, {
1584
1620
  slug: "new-slug",
1621
+ title: "Renamed page",
1585
1622
  });
1586
1623
 
1587
1624
  expect(updated?.slug).toBe("new-slug");
1625
+ // The URL is synced on write; the title is read through, so renaming the
1626
+ // page carries its navigation entry along with no sync step at all.
1588
1627
  expect(await navItemService.getById(navItem.id)).toMatchObject({
1589
- label: "Test page",
1628
+ label: "",
1629
+ targetTitle: "Renamed page",
1590
1630
  url: "/new-slug",
1591
1631
  });
1592
1632
  });
@@ -1856,6 +1896,92 @@ describe("PostService", () => {
1856
1896
  });
1857
1897
  });
1858
1898
 
1899
+ describe("createThreadWithAttachments", () => {
1900
+ // No attachments in these cases, so only the up-front id check is reached.
1901
+ const deps = {
1902
+ media: { validateIds: async () => [] },
1903
+ } as unknown as Parameters<
1904
+ typeof postService.createThreadWithAttachments
1905
+ >[1];
1906
+
1907
+ it("dates every reply from the root when the root is backdated", async () => {
1908
+ const backdated = 1710000000; // 2024-03-09
1909
+
1910
+ const created = await postService.createThreadWithAttachments(
1911
+ [
1912
+ {
1913
+ data: {
1914
+ format: "note",
1915
+ bodyMarkdown: "root",
1916
+ publishedAt: backdated,
1917
+ },
1918
+ attachments: [],
1919
+ },
1920
+ { data: { format: "note", bodyMarkdown: "second" }, attachments: [] },
1921
+ { data: { format: "note", bodyMarkdown: "third" }, attachments: [] },
1922
+ ],
1923
+ deps,
1924
+ undefined,
1925
+ );
1926
+
1927
+ // Without inheritance the replies would land on today and the thread
1928
+ // would read as if it spanned two years.
1929
+ expect(created.map((p) => p.publishedAt)).toEqual([
1930
+ backdated,
1931
+ backdated,
1932
+ backdated,
1933
+ ]);
1934
+ // Order still comes from createdAt/id, so the chain is intact.
1935
+ expect(created[1].replyToId).toBe(created[0].id);
1936
+ expect(created[2].replyToId).toBe(created[1].id);
1937
+ });
1938
+
1939
+ it("keeps a reply's own date when one is given", async () => {
1940
+ const rootAt = 1710000000;
1941
+ const replyAt = 1720000000;
1942
+
1943
+ const created = await postService.createThreadWithAttachments(
1944
+ [
1945
+ {
1946
+ data: { format: "note", bodyMarkdown: "root", publishedAt: rootAt },
1947
+ attachments: [],
1948
+ },
1949
+ {
1950
+ data: {
1951
+ format: "note",
1952
+ bodyMarkdown: "second",
1953
+ publishedAt: replyAt,
1954
+ },
1955
+ attachments: [],
1956
+ },
1957
+ ],
1958
+ deps,
1959
+ undefined,
1960
+ );
1961
+
1962
+ expect(created.map((p) => p.publishedAt)).toEqual([rootAt, replyAt]);
1963
+ });
1964
+
1965
+ it("leaves draft threads undated", async () => {
1966
+ const created = await postService.createThreadWithAttachments(
1967
+ [
1968
+ {
1969
+ data: { format: "note", bodyMarkdown: "root", status: "draft" },
1970
+ attachments: [],
1971
+ },
1972
+ {
1973
+ data: { format: "note", bodyMarkdown: "second", status: "draft" },
1974
+ attachments: [],
1975
+ },
1976
+ ],
1977
+ deps,
1978
+ undefined,
1979
+ );
1980
+
1981
+ expect(created.map((p) => p.publishedAt)).toEqual([null, null]);
1982
+ });
1983
+ });
1984
+
1859
1985
  describe("threads", () => {
1860
1986
  it("sets threadId on reply to a root post", async () => {
1861
1987
  const root = await postService.create({
@@ -1915,6 +2041,143 @@ describe("PostService", () => {
1915
2041
  );
1916
2042
  });
1917
2043
 
2044
+ it("refuses to feature an unpublished post", async () => {
2045
+ await expect(
2046
+ postService.create({
2047
+ format: "note",
2048
+ bodyMarkdown: "not ready",
2049
+ status: "draft",
2050
+ featured: true,
2051
+ }),
2052
+ ).rejects.toThrow("Publish this post before featuring it.");
2053
+
2054
+ const draft = await postService.create({
2055
+ format: "note",
2056
+ bodyMarkdown: "not ready",
2057
+ status: "draft",
2058
+ });
2059
+ await expect(
2060
+ postService.update(draft.id, { featured: true }),
2061
+ ).rejects.toThrow("Publish this post before featuring it.");
2062
+ });
2063
+
2064
+ it("keeps a featured flag through a trip back to draft", async () => {
2065
+ const post = await postService.create({
2066
+ format: "note",
2067
+ bodyMarkdown: "published",
2068
+ featured: true,
2069
+ });
2070
+
2071
+ const unpublished = await postService.update(post.id, {
2072
+ status: "draft",
2073
+ });
2074
+
2075
+ expect(unpublished?.status).toBe("draft");
2076
+ expect(unpublished?.featuredAt).not.toBeNull();
2077
+ });
2078
+
2079
+ it("publishes a reply whose parent is still a draft", async () => {
2080
+ const root = await postService.create({
2081
+ format: "note",
2082
+ bodyMarkdown: "root",
2083
+ });
2084
+ const draft = await postService.create({
2085
+ format: "note",
2086
+ bodyMarkdown: "unfinished",
2087
+ replyToId: root.id,
2088
+ status: "draft",
2089
+ });
2090
+
2091
+ const next = await postService.create({
2092
+ format: "note",
2093
+ bodyMarkdown: "continues past the draft",
2094
+ replyToId: draft.id,
2095
+ });
2096
+
2097
+ // Readers never saw the draft, so publishing past it reads as
2098
+ // continuous. The draft stays parked until it is dealt with on its own.
2099
+ expect(next.status).toBe("published");
2100
+ expect(draft.status).toBe("draft");
2101
+ });
2102
+
2103
+ it("publishes only the post asked for, leaving other drafts alone", async () => {
2104
+ const root = await postService.create({
2105
+ format: "note",
2106
+ bodyMarkdown: "root",
2107
+ });
2108
+ const first = await postService.create({
2109
+ format: "note",
2110
+ bodyMarkdown: "first draft",
2111
+ replyToId: root.id,
2112
+ status: "draft",
2113
+ });
2114
+ const second = await postService.create({
2115
+ format: "note",
2116
+ bodyMarkdown: "second draft",
2117
+ replyToId: first.id,
2118
+ status: "draft",
2119
+ });
2120
+
2121
+ await postService.update(second.id, { status: "published" });
2122
+
2123
+ const statusById = new Map(
2124
+ (await postService.getThread(root.id)).map((p) => [p.id, p.status]),
2125
+ );
2126
+ expect(statusById.get(first.id)).toBe("draft");
2127
+ expect(statusById.get(second.id)).toBe("published");
2128
+ });
2129
+
2130
+ it("keeps a draft between two published replies", async () => {
2131
+ const root = await postService.create({
2132
+ format: "note",
2133
+ bodyMarkdown: "root",
2134
+ });
2135
+ const middle = await postService.create({
2136
+ format: "note",
2137
+ bodyMarkdown: "middle",
2138
+ replyToId: root.id,
2139
+ });
2140
+ const last = await postService.create({
2141
+ format: "note",
2142
+ bodyMarkdown: "last",
2143
+ replyToId: middle.id,
2144
+ });
2145
+
2146
+ await postService.update(middle.id, { status: "draft" });
2147
+
2148
+ const statusById = new Map(
2149
+ (await postService.getThread(root.id)).map((p) => [p.id, p.status]),
2150
+ );
2151
+ // Unpublishing one post touches only that post. The public thread simply
2152
+ // skips it; nothing else in the chain changes.
2153
+ expect(statusById.get(root.id)).toBe("published");
2154
+ expect(statusById.get(middle.id)).toBe("draft");
2155
+ expect(statusById.get(last.id)).toBe("published");
2156
+ });
2157
+
2158
+ it("names the draft when an unpublished reply holds the thread tail", async () => {
2159
+ const root = await postService.create({
2160
+ format: "note",
2161
+ bodyMarkdown: "root",
2162
+ });
2163
+ await postService.create({
2164
+ format: "note",
2165
+ bodyMarkdown: "unfinished",
2166
+ replyToId: root.id,
2167
+ status: "draft",
2168
+ });
2169
+
2170
+ await expect(
2171
+ postService.create({
2172
+ format: "note",
2173
+ bodyMarkdown: "reply",
2174
+ replyToId: root.id,
2175
+ }),
2176
+ ).rejects.toThrow(
2177
+ "This thread ends with an unpublished draft. Finish that draft or discard it, then reply.",
2178
+ );
2179
+ });
2180
+
1918
2181
  it("inherits status from root post", async () => {
1919
2182
  const root = await postService.create({
1920
2183
  format: "note",
@@ -2025,6 +2288,57 @@ describe("PostService", () => {
2025
2288
  expect(thread[0]?.bodyText).toBe("root");
2026
2289
  });
2027
2290
 
2291
+ it("getThreadPosition counts the chain from the root down to a post", async () => {
2292
+ const root = await postService.create({
2293
+ format: "note",
2294
+ bodyMarkdown: "root",
2295
+ });
2296
+ const reply1 = await postService.create({
2297
+ format: "note",
2298
+ bodyMarkdown: "reply1",
2299
+ replyToId: root.id,
2300
+ });
2301
+ const reply2 = await postService.create({
2302
+ format: "note",
2303
+ bodyMarkdown: "reply2",
2304
+ replyToId: reply1.id,
2305
+ });
2306
+
2307
+ expect(await postService.getThreadPosition(root.id)).toBe(1);
2308
+ expect(await postService.getThreadPosition(reply1.id)).toBe(2);
2309
+ expect(await postService.getThreadPosition(reply2.id)).toBe(3);
2310
+ });
2311
+
2312
+ it("getThreadPosition walks the reply chain rather than counting members", async () => {
2313
+ // `create` only allows replying to the tail, so today a thread is a
2314
+ // straight chain and depth happens to equal its size. The walk is what
2315
+ // makes the two agree — counting `threadId` members would give the same
2316
+ // answer here and the wrong one the moment a draft chain is incomplete.
2317
+ const root = await postService.create({
2318
+ format: "note",
2319
+ bodyMarkdown: "root",
2320
+ });
2321
+ const tail = await postService.create({
2322
+ format: "note",
2323
+ bodyMarkdown: "tail",
2324
+ replyToId: root.id,
2325
+ });
2326
+
2327
+ // A second post filed under the same thread without joining the chain:
2328
+ // it is not above the tail, so it must not push the tail's position.
2329
+ await postService.create({
2330
+ format: "note",
2331
+ bodyMarkdown: "unrelated",
2332
+ });
2333
+
2334
+ expect(await postService.getThreadPosition(tail.id)).toBe(2);
2335
+ expect(await postService.getThread(root.id)).toHaveLength(2);
2336
+ });
2337
+
2338
+ it("getThreadPosition returns 0 for a post that does not exist", async () => {
2339
+ expect(await postService.getThreadPosition("pst_missing")).toBe(0);
2340
+ });
2341
+
2028
2342
  it("getThread excludes deleted posts", async () => {
2029
2343
  const root = await postService.create({
2030
2344
  format: "note",