@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
@@ -0,0 +1,402 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import type Database from "better-sqlite3";
3
+ import {
4
+ createTestDatabase,
5
+ DEFAULT_TEST_SITE_ID,
6
+ } from "../../__tests__/helpers/db.js";
7
+
8
+ /**
9
+ * The three tables a smart collection can be placed in, checked by inserting
10
+ * real rows.
11
+ *
12
+ * Reading a CHECK constraint and believing it is not the same as running it.
13
+ * The last time these constraints were written by hand, a test shaped exactly
14
+ * like this one found three bugs in a single pass: a branch that forgot to
15
+ * require its own foreign key, a branch that allowed two of them at once, and a
16
+ * unique index whose partial predicate never matched.
17
+ *
18
+ * Each case states the row it inserts and what the schema promises about it, so
19
+ * a failure names the rule rather than the constraint.
20
+ */
21
+
22
+ const SITE = DEFAULT_TEST_SITE_ID;
23
+ const NOW = 1_700_000_000;
24
+
25
+ function ids(prefix: string, n: number): string[] {
26
+ return Array.from(
27
+ { length: n },
28
+ (_, i) => `${prefix}_0000000000000000000000000${i}`,
29
+ );
30
+ }
31
+
32
+ describe("smart collection placement constraints", () => {
33
+ let sqlite: Database.Database;
34
+ let smartA: string;
35
+ let smartB: string;
36
+ let collectionA: string;
37
+ let postA: string;
38
+
39
+ beforeEach(() => {
40
+ ({ sqlite } = createTestDatabase());
41
+ [smartA, smartB] = ids("smc", 2) as [string, string];
42
+ [collectionA] = ids("col", 1) as [string];
43
+ [postA] = ids("pst", 1) as [string];
44
+
45
+ sqlite
46
+ .prepare(
47
+ `INSERT INTO collection (id, site_id, title, sort_order, created_at, updated_at)
48
+ VALUES (?, ?, 'Books', 'newest', ?, ?)`,
49
+ )
50
+ .run(collectionA, SITE, NOW, NOW);
51
+
52
+ for (const id of [smartA, smartB]) {
53
+ sqlite
54
+ .prepare(
55
+ `INSERT INTO smart_collection (id, site_id, title, sort, created_at, updated_at)
56
+ VALUES (?, ?, 'Quotes', 'newest', ?, ?)`,
57
+ )
58
+ .run(id, SITE, NOW, NOW);
59
+ }
60
+
61
+ sqlite
62
+ .prepare(
63
+ `INSERT INTO post (
64
+ id, site_id, thread_id, format, status, visibility,
65
+ body_html, body_text, created_at, updated_at
66
+ ) VALUES (?, ?, ?, 'note', 'published', 'public', '', '', ?, ?)`,
67
+ )
68
+ .run(postA, SITE, postA, NOW, NOW);
69
+ });
70
+
71
+ function insertPath(values: Record<string, unknown>) {
72
+ const row = {
73
+ id: `pth_${Math.random().toString(36).slice(2, 12)}`,
74
+ site_id: SITE,
75
+ path: `p-${Math.random().toString(36).slice(2, 8)}`,
76
+ kind: "slug",
77
+ post_id: null,
78
+ collection_id: null,
79
+ smart_collection_id: null,
80
+ redirect_to_path: null,
81
+ redirect_type: null,
82
+ archive_query: null,
83
+ created_at: NOW,
84
+ updated_at: NOW,
85
+ ...values,
86
+ };
87
+ const keys = Object.keys(row);
88
+ sqlite
89
+ .prepare(
90
+ `INSERT INTO path_registry (${keys.join(", ")})
91
+ VALUES (${keys.map(() => "?").join(", ")})`,
92
+ )
93
+ .run(...keys.map((key) => row[key]));
94
+ }
95
+
96
+ function insertDirectoryItem(values: Record<string, unknown>) {
97
+ const row = {
98
+ id: `cdi_${Math.random().toString(36).slice(2, 12)}`,
99
+ site_id: SITE,
100
+ type: "smart_collection",
101
+ collection_id: null,
102
+ smart_collection_id: null,
103
+ label: null,
104
+ url: null,
105
+ description: null,
106
+ position: `a${Math.random().toString(36).slice(2, 8)}`,
107
+ created_at: NOW,
108
+ updated_at: NOW,
109
+ ...values,
110
+ };
111
+ const keys = Object.keys(row);
112
+ sqlite
113
+ .prepare(
114
+ `INSERT INTO collection_directory_item (${keys.join(", ")})
115
+ VALUES (${keys.map(() => "?").join(", ")})`,
116
+ )
117
+ .run(...keys.map((key) => row[key]));
118
+ }
119
+
120
+ function insertNavItem(values: Record<string, unknown>) {
121
+ const row = {
122
+ id: `nav_${Math.random().toString(36).slice(2, 12)}`,
123
+ site_id: SITE,
124
+ type: "smart_collection",
125
+ system_key: null,
126
+ collection_id: null,
127
+ smart_collection_id: null,
128
+ post_id: null,
129
+ label: "",
130
+ url: "/quotes",
131
+ placement: "header",
132
+ position: `a${Math.random().toString(36).slice(2, 8)}`,
133
+ created_at: NOW,
134
+ updated_at: NOW,
135
+ ...values,
136
+ };
137
+ const keys = Object.keys(row);
138
+ sqlite
139
+ .prepare(
140
+ `INSERT INTO nav_item (${keys.join(", ")})
141
+ VALUES (${keys.map(() => "?").join(", ")})`,
142
+ )
143
+ .run(...keys.map((key) => row[key]));
144
+ }
145
+
146
+ describe("path_registry", () => {
147
+ it("accepts a slug pointing at a smart collection", () => {
148
+ expect(() =>
149
+ insertPath({ smart_collection_id: smartA, path: "quotes" }),
150
+ ).not.toThrow();
151
+ });
152
+
153
+ it("accepts an alias pointing at a smart collection", () => {
154
+ expect(() =>
155
+ insertPath({
156
+ kind: "alias",
157
+ smart_collection_id: smartA,
158
+ path: "old-quotes",
159
+ }),
160
+ ).not.toThrow();
161
+ });
162
+
163
+ it("refuses a slug that points at two things at once", () => {
164
+ expect(() =>
165
+ insertPath({ smart_collection_id: smartA, collection_id: collectionA }),
166
+ ).toThrow();
167
+ expect(() =>
168
+ insertPath({ smart_collection_id: smartA, post_id: postA }),
169
+ ).toThrow();
170
+ });
171
+
172
+ it("refuses a redirect or archive row that names a smart collection", () => {
173
+ expect(() =>
174
+ insertPath({
175
+ kind: "redirect",
176
+ smart_collection_id: smartA,
177
+ redirect_to_path: "quotes",
178
+ redirect_type: 301,
179
+ }),
180
+ ).toThrow();
181
+ expect(() =>
182
+ insertPath({
183
+ kind: "archive",
184
+ smart_collection_id: smartA,
185
+ archive_query: "format=quote",
186
+ }),
187
+ ).toThrow();
188
+ });
189
+
190
+ it("allows only one slug per smart collection", () => {
191
+ insertPath({ smart_collection_id: smartA, path: "quotes" });
192
+ expect(() =>
193
+ insertPath({ smart_collection_id: smartA, path: "quotes-2" }),
194
+ ).toThrow();
195
+
196
+ // An alias is not a second slug, so it is still allowed.
197
+ expect(() =>
198
+ insertPath({
199
+ kind: "alias",
200
+ smart_collection_id: smartA,
201
+ path: "old-quotes",
202
+ }),
203
+ ).not.toThrow();
204
+ });
205
+
206
+ it("keeps smart collections in the same address space as everything else", () => {
207
+ insertPath({ smart_collection_id: smartA, path: "quotes" });
208
+ expect(() =>
209
+ insertPath({ collection_id: collectionA, path: "quotes" }),
210
+ ).toThrow();
211
+ expect(() => insertPath({ post_id: postA, path: "quotes" })).toThrow();
212
+ });
213
+ });
214
+
215
+ describe("collection_directory_item", () => {
216
+ it("accepts a smart collection row", () => {
217
+ expect(() =>
218
+ insertDirectoryItem({ smart_collection_id: smartA }),
219
+ ).not.toThrow();
220
+ });
221
+
222
+ it("refuses a smart collection row with no target", () => {
223
+ expect(() => insertDirectoryItem({})).toThrow();
224
+ });
225
+
226
+ it("refuses a smart collection row carrying a label or url", () => {
227
+ expect(() =>
228
+ insertDirectoryItem({ smart_collection_id: smartA, label: "Quotes" }),
229
+ ).toThrow();
230
+ expect(() =>
231
+ insertDirectoryItem({ smart_collection_id: smartA, url: "/quotes" }),
232
+ ).toThrow();
233
+ });
234
+
235
+ it("refuses every other row type carrying a smart collection", () => {
236
+ expect(() =>
237
+ insertDirectoryItem({
238
+ type: "collection",
239
+ collection_id: collectionA,
240
+ smart_collection_id: smartA,
241
+ }),
242
+ ).toThrow();
243
+ expect(() =>
244
+ insertDirectoryItem({ type: "divider", smart_collection_id: smartA }),
245
+ ).toThrow();
246
+ expect(() =>
247
+ insertDirectoryItem({
248
+ type: "link",
249
+ label: "Elsewhere",
250
+ url: "https://example.com",
251
+ smart_collection_id: smartA,
252
+ }),
253
+ ).toThrow();
254
+ });
255
+
256
+ it("places a smart collection at most once", () => {
257
+ insertDirectoryItem({ smart_collection_id: smartA });
258
+ expect(() =>
259
+ insertDirectoryItem({ smart_collection_id: smartA }),
260
+ ).toThrow();
261
+ expect(() =>
262
+ insertDirectoryItem({ smart_collection_id: smartB }),
263
+ ).not.toThrow();
264
+ });
265
+ });
266
+
267
+ describe("nav_item", () => {
268
+ it("accepts a smart collection nav item", () => {
269
+ expect(() =>
270
+ insertNavItem({ smart_collection_id: smartA }),
271
+ ).not.toThrow();
272
+ });
273
+
274
+ it("refuses a smart collection nav item with no target", () => {
275
+ expect(() => insertNavItem({})).toThrow();
276
+ });
277
+
278
+ it("refuses every other nav type carrying a smart collection", () => {
279
+ expect(() =>
280
+ insertNavItem({
281
+ type: "link",
282
+ smart_collection_id: smartA,
283
+ label: "Elsewhere",
284
+ }),
285
+ ).toThrow();
286
+ expect(() =>
287
+ insertNavItem({
288
+ type: "system",
289
+ system_key: "latest",
290
+ smart_collection_id: smartA,
291
+ }),
292
+ ).toThrow();
293
+ expect(() =>
294
+ insertNavItem({
295
+ type: "collection",
296
+ collection_id: collectionA,
297
+ smart_collection_id: smartA,
298
+ }),
299
+ ).toThrow();
300
+ expect(() =>
301
+ insertNavItem({
302
+ type: "page",
303
+ post_id: postA,
304
+ smart_collection_id: smartA,
305
+ }),
306
+ ).toThrow();
307
+ });
308
+
309
+ it("adds a smart collection to navigation at most once", () => {
310
+ insertNavItem({ smart_collection_id: smartA });
311
+ expect(() => insertNavItem({ smart_collection_id: smartA })).toThrow();
312
+ expect(() =>
313
+ insertNavItem({ smart_collection_id: smartB }),
314
+ ).not.toThrow();
315
+ });
316
+ });
317
+
318
+ describe("smart_collection", () => {
319
+ it("refuses a private visibility condition", () => {
320
+ // The one value a published page can never name. Enforced in the column,
321
+ // not only in Zod, so no import path or backfill can slip one in.
322
+ expect(() =>
323
+ sqlite
324
+ .prepare(
325
+ `INSERT INTO smart_collection (id, site_id, title, visibility, sort, created_at, updated_at)
326
+ VALUES (?, ?, 'Private', 'private', 'newest', ?, ?)`,
327
+ )
328
+ .run(`smc_private0000000000000000000`, SITE, NOW, NOW),
329
+ ).toThrow();
330
+ });
331
+
332
+ it("refuses a sort or layout it cannot render", () => {
333
+ expect(() =>
334
+ sqlite
335
+ .prepare(
336
+ `INSERT INTO smart_collection (id, site_id, title, sort, created_at, updated_at)
337
+ VALUES (?, ?, 'Bad sort', 'rating_asc', ?, ?)`,
338
+ )
339
+ .run(`smc_badsort00000000000000000000`, SITE, NOW, NOW),
340
+ ).toThrow();
341
+ expect(() =>
342
+ sqlite
343
+ .prepare(
344
+ `INSERT INTO smart_collection (id, site_id, title, sort, layout, created_at, updated_at)
345
+ VALUES (?, ?, 'Bad layout', 'newest', 'carousel', ?, ?)`,
346
+ )
347
+ .run(`smc_badlayout00000000000000000`, SITE, NOW, NOW),
348
+ ).toThrow();
349
+ });
350
+
351
+ it("leaves a deleted collection's id in place rather than widening", () => {
352
+ sqlite
353
+ .prepare(`UPDATE smart_collection SET collection_id = ? WHERE id = ?`)
354
+ .run(collectionA, smartA);
355
+
356
+ // No foreign key on this column, alone in the schema. RESTRICT would fail
357
+ // anonymously and make every bulk delete an ordering rule; SET NULL would
358
+ // turn a curated page into the whole archive. The refusal an author sees
359
+ // is `smartCollections.assertCollectionUnused()`, and what is left if
360
+ // something reaches around it is a condition naming a collection that no
361
+ // longer exists — which matches nothing.
362
+ expect(() =>
363
+ sqlite.prepare(`DELETE FROM collection WHERE id = ?`).run(collectionA),
364
+ ).not.toThrow();
365
+
366
+ const row = sqlite
367
+ .prepare(`SELECT collection_id FROM smart_collection WHERE id = ?`)
368
+ .get(smartA) as { collection_id: string | null };
369
+ expect(row.collection_id).toBe(collectionA);
370
+ });
371
+
372
+ it("refuses a year no timestamp could carry", () => {
373
+ // `Date.UTC` returns NaN past year 275760, and a NaN bound is a
374
+ // comparison every row fails with nothing to report.
375
+ for (const year of [1970, 10000]) {
376
+ expect(() =>
377
+ sqlite
378
+ .prepare(
379
+ `INSERT INTO smart_collection (id, site_id, title, year, sort, created_at, updated_at)
380
+ VALUES (?, ?, 'Bad year', ?, 'newest', ?, ?)`,
381
+ )
382
+ .run(
383
+ `smc_year${year}000000000000000000`.slice(0, 30),
384
+ SITE,
385
+ year,
386
+ NOW,
387
+ NOW,
388
+ ),
389
+ ).toThrow();
390
+ }
391
+
392
+ expect(() =>
393
+ sqlite
394
+ .prepare(
395
+ `INSERT INTO smart_collection (id, site_id, title, year, sort, created_at, updated_at)
396
+ VALUES (?, ?, 'Edge years', 9999, 'newest', ?, ?)`,
397
+ )
398
+ .run(`smc_year9999000000000000000000`.slice(0, 30), SITE, NOW, NOW),
399
+ ).not.toThrow();
400
+ });
401
+ });
402
+ });
@@ -0,0 +1,113 @@
1
+ import { and, eq, sql } from "drizzle-orm";
2
+ import { describe, expect, it } from "vitest";
3
+ import {
4
+ createTestDatabase,
5
+ DEFAULT_TEST_SITE_ID,
6
+ } from "../../__tests__/helpers/db.js";
7
+ import { posts, threadCollections } from "../schema.js";
8
+ import {
9
+ buildRootActivityExpr,
10
+ getRootActivityAt,
11
+ rootActivityColumns,
12
+ } from "../thread-activity.js";
13
+
14
+ /**
15
+ * The SQL assertions here look pedantic, but they guard a failure mode that
16
+ * produces no error and no obviously wrong output: Drizzle omits the table
17
+ * qualifier on interpolated columns in a single-table projection, so a
18
+ * correlated subquery silently binds to its own alias instead of the outer
19
+ * row. That is why the subquery flavor takes raw fragments.
20
+ */
21
+ describe("thread activity definition", () => {
22
+ const normalize = (value: string) => value.replace(/\s+/g, " ");
23
+
24
+ it("prefers last activity, then publication, then the row's own write", () => {
25
+ expect(
26
+ getRootActivityAt({
27
+ lastActivityAt: 300,
28
+ publishedAt: 200,
29
+ updatedAt: 100,
30
+ }),
31
+ ).toBe(300);
32
+ expect(
33
+ getRootActivityAt({
34
+ lastActivityAt: null,
35
+ publishedAt: 200,
36
+ updatedAt: 100,
37
+ }),
38
+ ).toBe(200);
39
+ expect(
40
+ getRootActivityAt({
41
+ lastActivityAt: null,
42
+ publishedAt: null,
43
+ updatedAt: 100,
44
+ }),
45
+ ).toBe(100);
46
+ });
47
+
48
+ it("never falls back to a plain edit when real activity exists", () => {
49
+ // An edited root keeps its activity timestamp — the whole point.
50
+ expect(
51
+ getRootActivityAt({
52
+ lastActivityAt: 1000,
53
+ publishedAt: 1000,
54
+ updatedAt: 9999,
55
+ }),
56
+ ).toBe(1000);
57
+ });
58
+
59
+ it("qualifies the subquery flavor against the root alias", () => {
60
+ const { db } = createTestDatabase();
61
+ const expr = sql<number>`(
62
+ SELECT ${buildRootActivityExpr(rootActivityColumns("root"))}
63
+ FROM post AS root
64
+ WHERE root.site_id = ${DEFAULT_TEST_SITE_ID}
65
+ AND root.id = ${posts.threadId}
66
+ )`.as("activity_at");
67
+
68
+ const generated = normalize(
69
+ (db as never as { select: (fields: unknown) => never })
70
+ .select({ threadId: posts.threadId, activityAt: expr })
71
+ // The join is what makes Drizzle qualify `posts.threadId` below.
72
+ .from(posts)
73
+ .innerJoin(
74
+ threadCollections,
75
+ and(
76
+ eq(threadCollections.siteId, posts.siteId),
77
+ eq(threadCollections.threadId, posts.threadId),
78
+ ),
79
+ )
80
+ .groupBy(posts.threadId)
81
+ .toSQL().sql,
82
+ );
83
+
84
+ // Inner columns resolve against the subquery's own alias...
85
+ expect(generated).toContain(
86
+ "COALESCE( root.last_activity_at, root.published_at, root.updated_at )",
87
+ );
88
+ // ...and the correlation still points at the outer row, not at `root`.
89
+ expect(generated).toContain('root.id = "post"."thread_id"');
90
+ });
91
+
92
+ it("uses the root's own columns when rows are already roots", () => {
93
+ const { db } = createTestDatabase();
94
+ const generated = normalize(
95
+ (db as never as { select: (fields: unknown) => never })
96
+ .select({
97
+ id: posts.id,
98
+ activityAt: buildRootActivityExpr({
99
+ lastActivityAt: posts.lastActivityAt,
100
+ publishedAt: posts.publishedAt,
101
+ updatedAt: posts.updatedAt,
102
+ }),
103
+ })
104
+ .from(posts)
105
+ .innerJoin(threadCollections, eq(threadCollections.threadId, posts.id))
106
+ .toSQL().sql,
107
+ );
108
+
109
+ expect(generated).toContain(
110
+ 'COALESCE( "post"."last_activity_at", "post"."published_at", "post"."updated_at" )',
111
+ );
112
+ });
113
+ });
@@ -0,0 +1,68 @@
1
+ -- Split "last announced" from "last changed" for Threads.
2
+ --
3
+ -- Until now a quiet reply was implemented by *skipping* the recalculation of
4
+ -- post.last_activity_at, and the quietness itself was never stored. That made
5
+ -- the flag non-durable: the next recalculation (deleting any post in the
6
+ -- Thread, changing a published_at, republishing) recomputed
7
+ -- MAX(published_at) over the whole Thread, absorbed the quiet reply, and
8
+ -- bumped the Thread on Latest anyway.
9
+ --
10
+ -- post.quiet_reply now persists the author's choice, so both timestamps are
11
+ -- pure functions of the rows:
12
+ -- last_activity_at = MAX(published_at) excluding quiet replies (Latest)
13
+ -- thread_updated_at = MAX(published_at) including quiet replies (archive)
14
+ --
15
+ -- Step 1 recovers the quiet decisions that are still visible in the data.
16
+ -- A published reply can only sit later than its root's recorded
17
+ -- last_activity_at if the recalculation was skipped for it — that is the
18
+ -- signature of a quiet reply. Decisions that were already absorbed by a later
19
+ -- recalculation are unrecoverable and stay false, which matches the ordering
20
+ -- those sites are already seeing.
21
+ --
22
+ -- Steps 2 and 3 then recompute both timestamps from scratch.
23
+ --
24
+ -- Idempotent: after step 3 a quiet reply is still later than the recomputed
25
+ -- last_activity_at (it is excluded from that MAX), so a rerun re-selects
26
+ -- exactly the same rows, and every non-quiet published reply is covered by
27
+ -- the MAX and can never be selected.
28
+
29
+ -- Step 1: recover still-detectable quiet replies.
30
+ UPDATE "post"
31
+ SET "quiet_reply" = TRUE
32
+ WHERE "post"."reply_to_id" IS NOT NULL
33
+ AND "post"."published_at" IS NOT NULL
34
+ AND EXISTS (
35
+ SELECT 1
36
+ FROM "post" AS root
37
+ WHERE root."site_id" = "post"."site_id"
38
+ AND root."id" = "post"."thread_id"
39
+ AND root."last_activity_at" IS NOT NULL
40
+ AND "post"."published_at" > root."last_activity_at"
41
+ );
42
+
43
+ -- Step 2: thread_updated_at — newest published post in the Thread, quiet included.
44
+ UPDATE "post"
45
+ SET "thread_updated_at" = (
46
+ SELECT MAX(member."published_at")
47
+ FROM "post" AS member
48
+ WHERE member."site_id" = "post"."site_id"
49
+ AND member."thread_id" = "post"."id"
50
+ AND member."status" = 'published'
51
+ )
52
+ WHERE "post"."reply_to_id" IS NULL;
53
+
54
+ -- Step 3: last_activity_at — newest announced post in the Thread.
55
+ -- Threads with nothing published (drafts) keep whatever they had.
56
+ UPDATE "post"
57
+ SET "last_activity_at" = COALESCE(
58
+ (
59
+ SELECT MAX(member."published_at")
60
+ FROM "post" AS member
61
+ WHERE member."site_id" = "post"."site_id"
62
+ AND member."thread_id" = "post"."id"
63
+ AND member."status" = 'published'
64
+ AND member."quiet_reply" = FALSE
65
+ ),
66
+ "post"."last_activity_at"
67
+ )
68
+ WHERE "post"."reply_to_id" IS NULL;
@@ -0,0 +1,31 @@
1
+ -- Clear page and collection nav labels that merely mirror their target's title.
2
+ -- Previously these items stored a snapshot of the post or collection title taken
3
+ -- when the item was added, so renaming the target left the nav entry behind. Now
4
+ -- an empty label means "follow the target" and any non-empty value is a label the
5
+ -- author typed, which wins in every language view.
6
+ --
7
+ -- Rows whose stored label differs from the current title were customized and are
8
+ -- left alone. Idempotent: rerunning matches nothing once the labels are empty.
9
+ UPDATE "nav_item"
10
+ SET "label" = ''
11
+ WHERE "type" = 'page'
12
+ AND "post_id" IS NOT NULL
13
+ AND "label" <> ''
14
+ AND "label" = (
15
+ SELECT trim("post"."title")
16
+ FROM "post"
17
+ WHERE "post"."id" = "nav_item"."post_id"
18
+ AND "post"."site_id" = "nav_item"."site_id"
19
+ );
20
+
21
+ UPDATE "nav_item"
22
+ SET "label" = ''
23
+ WHERE "type" = 'collection'
24
+ AND "collection_id" IS NOT NULL
25
+ AND "label" <> ''
26
+ AND "label" = (
27
+ SELECT trim("collection"."title")
28
+ FROM "collection"
29
+ WHERE "collection"."id" = "nav_item"."collection_id"
30
+ AND "collection"."site_id" = "nav_item"."site_id"
31
+ );
@@ -0,0 +1,3 @@
1
+ ALTER TABLE `post` ADD `quiet_reply` integer DEFAULT false NOT NULL;--> statement-breakpoint
2
+ ALTER TABLE `post` ADD `thread_updated_at` integer;--> statement-breakpoint
3
+ CREATE INDEX `idx_post_site_root_thread_updated` ON `post` (`site_id`,`thread_updated_at`,`id`) WHERE "post"."reply_to_id" IS NULL AND "post"."status" = 'published';
@@ -0,0 +1,3 @@
1
+ ALTER TABLE `post` ADD `language` text;--> statement-breakpoint
2
+ ALTER TABLE `post` ADD `translation_group_id` text;--> statement-breakpoint
3
+ CREATE UNIQUE INDEX `uq_post_site_translation_group_language` ON `post` (`site_id`,`translation_group_id`,`language`) WHERE "post"."translation_group_id" IS NOT NULL;