@jant/core 0.6.14 → 0.6.16

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 (350) hide show
  1. package/bin/commands/import-site.js +88 -13
  2. package/bin/commands/migrate.js +55 -0
  3. package/bin/commands/posts/rebuild-html.js +184 -0
  4. package/bin/commands/site/snapshot/import.js +14 -10
  5. package/bin/lib/d1-query.js +23 -5
  6. package/bin/lib/migration-runner.js +29 -7
  7. package/bin/lib/node-database.js +32 -0
  8. package/bin/lib/site-snapshot.js +347 -46
  9. package/bin/lib/sql-export.js +1 -1
  10. package/bin/lib/thread-collection-migration.js +326 -0
  11. package/dist/app-C75Zc8dD.js +6 -0
  12. package/dist/{app-2i7-WQHg.js → app-C7_4oab9.js} +7774 -6425
  13. package/dist/client/.vite/manifest.json +8 -8
  14. package/dist/client/_assets/chunks/{url-pLre2DM_.js → url-CU9pEbk5.js} +1 -1
  15. package/dist/client/_assets/{client-BvID5ucz.js → client-Bq0vre8Y.js} +59 -38
  16. package/dist/client/_assets/client-D3VZyG4L.css +2 -0
  17. package/dist/client/_assets/client-auth-F2rb9WfF.js +5571 -0
  18. package/dist/{env-OHRKGcMj.js → env-BPYViDJJ.js} +1 -1
  19. package/dist/{export-DWn149b7.js → export-Dl5KCiEs.js} +1348 -128
  20. package/dist/{github-api-BgSiE71w.js → github-api-BNF35Lkx.js} +1 -1
  21. package/dist/{github-app-BbklkFmU.js → github-app-DdiD-bC4.js} +1 -1
  22. package/dist/{github-sync-C0zyF6XS.js → github-sync-Bqg62IvV.js} +3 -3
  23. package/dist/{github-sync-Ckk0sJxK.js → github-sync-HdK2EgvJ.js} +3 -3
  24. package/dist/index.js +5 -5
  25. package/dist/node.js +7 -7
  26. package/dist/{url-BMYO-Zlt.js → url-CbLAtlZe.js} +517 -12
  27. package/package.json +11 -10
  28. package/src/__tests__/bin/posts-rebuild-html.test.ts +137 -0
  29. package/src/__tests__/dev-navigation-defaults.test.ts +22 -0
  30. package/src/__tests__/export-hugo-build.test.ts +63 -4
  31. package/src/__tests__/export-import-roundtrip.test.ts +6 -4
  32. package/src/__tests__/export-service.test.ts +142 -9
  33. package/src/__tests__/helpers/app.ts +3 -0
  34. package/src/__tests__/helpers/export-fixtures.ts +2 -0
  35. package/src/__tests__/import-site-command.test.ts +65 -0
  36. package/src/__tests__/mise-config.test.ts +22 -0
  37. package/src/__tests__/vite-config.test.ts +45 -0
  38. package/src/app.tsx +16 -3
  39. package/src/client/__tests__/collection-created-notice.test.ts +36 -0
  40. package/src/client/__tests__/collection-form-bridge.test.ts +10 -1
  41. package/src/client/__tests__/collection-page-actions.test.ts +71 -2
  42. package/src/client/__tests__/compose-bridge.test.ts +316 -2
  43. package/src/client/__tests__/compose-shortcuts.test.ts +20 -0
  44. package/src/client/__tests__/feed-video-player.test.ts +16 -1
  45. package/src/client/__tests__/footnote-rail.test.ts +472 -0
  46. package/src/client/__tests__/hydrate-partial.test.ts +3 -0
  47. package/src/client/__tests__/toast.test.ts +82 -1
  48. package/src/client/collection-created-notice.ts +66 -0
  49. package/src/client/collection-form-bridge.ts +17 -0
  50. package/src/client/collection-navigation.ts +44 -0
  51. package/src/client/collection-page-actions.ts +57 -1
  52. package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
  53. package/src/client/components/__tests__/jant-collection-directory.test.ts +155 -6
  54. package/src/client/components/__tests__/jant-command-palette.test.ts +51 -0
  55. package/src/client/components/__tests__/jant-compose-dialog.test.ts +2268 -206
  56. package/src/client/components/__tests__/jant-compose-editor.test.ts +147 -59
  57. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +114 -64
  58. package/src/client/components/__tests__/jant-config-editor.test.ts +405 -0
  59. package/src/client/components/__tests__/jant-media-lightbox.test.ts +121 -0
  60. package/src/client/components/__tests__/jant-nav-manager.test.ts +538 -2
  61. package/src/client/components/__tests__/jant-post-form.test.ts +2 -2
  62. package/src/client/components/__tests__/jant-post-menu.test.ts +47 -5
  63. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
  64. package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
  65. package/src/client/components/collection-manager-types.ts +9 -3
  66. package/src/client/components/compose-format-convert.ts +5 -1
  67. package/src/client/components/compose-types.ts +55 -3
  68. package/src/client/components/jant-collection-directory.ts +165 -30
  69. package/src/client/components/jant-command-palette.ts +14 -7
  70. package/src/client/components/jant-compose-dialog.ts +1542 -694
  71. package/src/client/components/jant-compose-editor.ts +237 -99
  72. package/src/client/components/jant-compose-fullscreen.ts +27 -9
  73. package/src/client/components/jant-config-editor.ts +722 -0
  74. package/src/client/components/jant-media-lightbox.ts +100 -18
  75. package/src/client/components/jant-nav-manager.ts +1283 -54
  76. package/src/client/components/jant-post-form.ts +3 -3
  77. package/src/client/components/jant-post-menu.ts +177 -95
  78. package/src/client/components/nav-manager-types.ts +51 -2
  79. package/src/client/components/post-form-types.ts +1 -1
  80. package/src/client/compose-bridge.ts +147 -123
  81. package/src/client/compose-launch.ts +13 -0
  82. package/src/client/compose-shortcuts.ts +5 -1
  83. package/src/client/feed-video-player.ts +62 -2
  84. package/src/client/footnote-rail.ts +478 -0
  85. package/src/client/hydrate-partial.ts +2 -0
  86. package/src/client/media-lightbox-events.ts +63 -0
  87. package/src/client/post-refresh.ts +135 -0
  88. package/src/client/site-header-fragment.ts +38 -0
  89. package/src/client/tiptap/__tests__/blockquote-editing.test.ts +201 -0
  90. package/src/client/tiptap/__tests__/clear-formatting.test.ts +168 -0
  91. package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
  92. package/src/client/tiptap/__tests__/list-editing.test.ts +447 -0
  93. package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +727 -4
  94. package/src/client/tiptap/__tests__/table-controls.test.ts +263 -0
  95. package/src/client/tiptap/__tests__/table-size-picker.test.ts +179 -0
  96. package/src/client/tiptap/bubble-menu.ts +88 -10
  97. package/src/client/tiptap/continuous-lists.ts +102 -0
  98. package/src/client/tiptap/create-editor.ts +3 -0
  99. package/src/client/tiptap/extensions.ts +14 -5
  100. package/src/client/tiptap/link-input-rules.ts +4 -4
  101. package/src/client/tiptap/markdown-clipboard.ts +608 -11
  102. package/src/client/tiptap/slash-commands.ts +8 -1
  103. package/src/client/tiptap/structural-keymap.ts +211 -0
  104. package/src/client/tiptap/tab-indent.ts +8 -22
  105. package/src/client/tiptap/table-control-labels.ts +30 -0
  106. package/src/client/tiptap/table-controls.ts +537 -0
  107. package/src/client/tiptap/table-size-picker.ts +254 -0
  108. package/src/client/toast.ts +133 -39
  109. package/src/client-auth.ts +1 -0
  110. package/src/client.ts +1 -0
  111. package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
  112. package/src/db/__tests__/d1-query.test.ts +43 -1
  113. package/src/db/__tests__/migration-runner.test.ts +63 -0
  114. package/src/db/__tests__/migrations.test.ts +469 -0
  115. package/src/db/__tests__/thread-activity.test.ts +113 -0
  116. package/src/db/__tests__/thread-collection-migration.test.ts +364 -0
  117. package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
  118. package/src/db/migrations/0027_old_blue_blade.sql +39 -0
  119. package/src/db/migrations/0028_superb_post.sql +2 -0
  120. package/src/db/migrations/0029_boring_magma.sql +1 -0
  121. package/src/db/migrations/0030_typical_vivisector.sql +50 -0
  122. package/src/db/migrations/0031_many_ego.sql +3 -0
  123. package/src/db/migrations/meta/0027_snapshot.json +2511 -0
  124. package/src/db/migrations/meta/0028_snapshot.json +2511 -0
  125. package/src/db/migrations/meta/0029_snapshot.json +2519 -0
  126. package/src/db/migrations/meta/0030_snapshot.json +2541 -0
  127. package/src/db/migrations/meta/0031_snapshot.json +2562 -0
  128. package/src/db/migrations/meta/_journal.json +35 -0
  129. package/src/db/migrations/pg/0025_moaning_epoch.sql +39 -0
  130. package/src/db/migrations/pg/0026_talented_killer_shrike.sql +2 -0
  131. package/src/db/migrations/pg/0027_short_cannonball.sql +1 -0
  132. package/src/db/migrations/pg/0028_perfect_sharon_carter.sql +27 -0
  133. package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
  134. package/src/db/migrations/pg/meta/0025_snapshot.json +3204 -0
  135. package/src/db/migrations/pg/meta/0026_snapshot.json +3204 -0
  136. package/src/db/migrations/pg/meta/0027_snapshot.json +3211 -0
  137. package/src/db/migrations/pg/meta/0028_snapshot.json +3248 -0
  138. package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
  139. package/src/db/migrations/pg/meta/_journal.json +35 -0
  140. package/src/db/pg/schema.ts +46 -20
  141. package/src/db/rehearsal-fixtures/demo-current.json +39 -4
  142. package/src/db/rehearsal-fixtures/demo-current.sql +121 -6
  143. package/src/db/schema.ts +48 -20
  144. package/src/db/thread-activity.ts +101 -0
  145. package/src/i18n/__tests__/detect.test.ts +8 -2
  146. package/src/i18n/detect.ts +14 -5
  147. package/src/i18n/locales/public/en.po +222 -51
  148. package/src/i18n/locales/public/en.ts +1 -1
  149. package/src/i18n/locales/public/zh-Hans.po +216 -45
  150. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  151. package/src/i18n/locales/public/zh-Hant.po +216 -45
  152. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  153. package/src/i18n/locales/settings/en.po +513 -10
  154. package/src/i18n/locales/settings/en.ts +1 -1
  155. package/src/i18n/locales/settings/zh-Hans.po +515 -12
  156. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  157. package/src/i18n/locales/settings/zh-Hant.po +515 -12
  158. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  159. package/src/lib/__tests__/api-settings.test.ts +299 -0
  160. package/src/lib/__tests__/constants.test.ts +5 -0
  161. package/src/lib/__tests__/feed-policy.test.ts +25 -0
  162. package/src/lib/__tests__/feed.test.ts +168 -0
  163. package/src/lib/__tests__/footnotes-upgrade.test.ts +181 -0
  164. package/src/lib/__tests__/markdown-roundtrip-embed.test.ts +61 -0
  165. package/src/lib/__tests__/markdown-to-tiptap.test.ts +32 -4
  166. package/src/lib/__tests__/markdown.test.ts +24 -6
  167. package/src/lib/__tests__/navigation.test.ts +59 -2
  168. package/src/lib/__tests__/post-body-html.test.ts +176 -0
  169. package/src/lib/__tests__/post-display.test.ts +64 -0
  170. package/src/lib/__tests__/resolve-config.test.ts +113 -1
  171. package/src/lib/__tests__/slug.test.ts +18 -0
  172. package/src/lib/__tests__/summary.test.ts +82 -0
  173. package/src/lib/__tests__/timeline.test.ts +109 -70
  174. package/src/lib/__tests__/timezones.test.ts +31 -3
  175. package/src/lib/__tests__/tiptap-render.test.ts +268 -9
  176. package/src/lib/__tests__/tiptap-to-markdown.test.ts +6 -0
  177. package/src/lib/__tests__/translit.test.ts +87 -0
  178. package/src/lib/__tests__/url.test.ts +53 -0
  179. package/src/lib/__tests__/view.test.ts +53 -0
  180. package/src/lib/__tests__/worker-response-cache.test.ts +18 -1
  181. package/src/lib/api-settings.ts +239 -12
  182. package/src/lib/collection-sort.ts +7 -5
  183. package/src/lib/constants.ts +2 -0
  184. package/src/lib/feed-path.ts +25 -0
  185. package/src/lib/feed-policy.ts +64 -0
  186. package/src/lib/feed.ts +113 -11
  187. package/src/lib/footnotes.ts +264 -0
  188. package/src/lib/github-sync-site-config.ts +2 -0
  189. package/src/lib/hugo-markdown.ts +15 -3
  190. package/src/lib/jant-branding.ts +1 -0
  191. package/src/lib/link-preview.ts +25 -0
  192. package/src/lib/markdown-manager.ts +193 -5
  193. package/src/lib/markdown.ts +10 -3
  194. package/src/lib/navigation.ts +16 -3
  195. package/src/lib/post-body-html.ts +131 -0
  196. package/src/lib/post-display.ts +47 -8
  197. package/src/lib/render.tsx +9 -2
  198. package/src/lib/resolve-config.ts +100 -16
  199. package/src/lib/schemas.ts +133 -1
  200. package/src/lib/settings-paths.ts +5 -0
  201. package/src/lib/site-skill.ts +78 -0
  202. package/src/lib/summary.ts +16 -5
  203. package/src/lib/timeline.ts +137 -131
  204. package/src/lib/timezones.ts +54 -14
  205. package/src/lib/tiptap-render.ts +359 -83
  206. package/src/lib/translit.ts +288 -0
  207. package/src/lib/url.ts +123 -12
  208. package/src/lib/view.ts +58 -20
  209. package/src/lib/worker-response-cache.ts +11 -18
  210. package/src/middleware/__tests__/public-content-access.test.ts +91 -0
  211. package/src/middleware/config.ts +2 -2
  212. package/src/middleware/public-content-access.ts +87 -0
  213. package/src/node/__tests__/cli-export.test.ts +14 -0
  214. package/src/node/__tests__/cli-migrate.test.ts +6 -0
  215. package/src/node/__tests__/cli-site-snapshot.test.ts +205 -2
  216. package/src/node/__tests__/cli-snapshot-meta.test.ts +100 -0
  217. package/src/node/index.ts +2 -1
  218. package/src/preset.css +16 -2
  219. package/src/routes/__tests__/compose.test.ts +51 -0
  220. package/src/routes/__tests__/site-skill.test.ts +112 -0
  221. package/src/routes/api/__tests__/collections.test.ts +91 -19
  222. package/src/routes/api/__tests__/mcp.test.ts +65 -1
  223. package/src/routes/api/__tests__/nav-items.test.ts +91 -0
  224. package/src/routes/api/__tests__/palette.test.ts +44 -0
  225. package/src/routes/api/__tests__/public-access.test.ts +81 -0
  226. package/src/routes/api/__tests__/settings.test.ts +217 -0
  227. package/src/routes/api/collections.ts +22 -26
  228. package/src/routes/api/export.ts +2 -0
  229. package/src/routes/api/internal/__tests__/post-body-html.test.ts +163 -0
  230. package/src/routes/api/internal/__tests__/sites.test.ts +35 -2
  231. package/src/routes/api/internal/post-body-html.ts +43 -0
  232. package/src/routes/api/internal/sites.ts +24 -0
  233. package/src/routes/api/nav-items.ts +24 -1
  234. package/src/routes/api/posts.ts +14 -10
  235. package/src/routes/api/public/__tests__/archive.test.ts +35 -0
  236. package/src/routes/api/public/__tests__/posts.test.ts +246 -0
  237. package/src/routes/api/public/archive.ts +4 -0
  238. package/src/routes/api/public/posts.ts +48 -24
  239. package/src/routes/api/search.ts +3 -0
  240. package/src/routes/api/settings.ts +33 -0
  241. package/src/routes/auth/__tests__/setup.test.ts +7 -4
  242. package/src/routes/compose.tsx +8 -3
  243. package/src/routes/dash/settings.tsx +45 -2
  244. package/src/routes/feed/__tests__/feed.test.ts +210 -6
  245. package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
  246. package/src/routes/feed/feed.ts +23 -14
  247. package/src/routes/pages/__tests__/archive-params.test.ts +384 -3
  248. package/src/routes/pages/__tests__/collection-routing.test.ts +63 -5
  249. package/src/routes/pages/__tests__/collections.test.ts +9 -9
  250. package/src/routes/pages/__tests__/featured.test.ts +6 -2
  251. package/src/routes/pages/__tests__/preview.test.ts +176 -0
  252. package/src/routes/pages/__tests__/search.test.ts +69 -0
  253. package/src/routes/pages/archive.tsx +90 -22
  254. package/src/routes/pages/collection.tsx +32 -19
  255. package/src/routes/pages/collections.tsx +6 -1
  256. package/src/routes/pages/page.tsx +86 -11
  257. package/src/routes/site-skill.ts +18 -0
  258. package/src/services/__tests__/auth.test.ts +7 -5
  259. package/src/services/__tests__/collection.test.ts +190 -44
  260. package/src/services/__tests__/navigation.test.ts +229 -21
  261. package/src/services/__tests__/post-timeline.test.ts +249 -39
  262. package/src/services/__tests__/post.test.ts +844 -5
  263. package/src/services/__tests__/search.test.ts +48 -1
  264. package/src/services/__tests__/settings.test.ts +8 -0
  265. package/src/services/__tests__/site-admin.test.ts +121 -1
  266. package/src/services/auth.ts +2 -2
  267. package/src/services/bootstrap.ts +3 -2
  268. package/src/services/collection.ts +224 -167
  269. package/src/services/export-theme/assets/client-site.css +1 -1
  270. package/src/services/export-theme/assets/client-site.js +46 -25
  271. package/src/services/export-theme/layouts/_default/list.html +18 -4
  272. package/src/services/export-theme/layouts/_default/rss.xml +8 -12
  273. package/src/services/export-theme/layouts/featured/list.html +5 -4
  274. package/src/services/export-theme/layouts/partials/featured-thread.html +56 -0
  275. package/src/services/export-theme/layouts/partials/footer.html +1 -1
  276. package/src/services/export-theme/layouts/partials/media-gallery.html +1 -0
  277. package/src/services/export-theme/styles/main.css +30 -2
  278. package/src/services/export.ts +94 -85
  279. package/src/services/mcp.ts +37 -27
  280. package/src/services/media.ts +1 -1
  281. package/src/services/navigation.ts +185 -46
  282. package/src/services/path.ts +4 -1
  283. package/src/services/post.ts +1142 -342
  284. package/src/services/search.ts +17 -2
  285. package/src/services/settings.ts +2 -10
  286. package/src/services/site-admin.ts +63 -43
  287. package/src/style-cjk-jp.css +1 -1
  288. package/src/style-cjk-kr.css +1 -1
  289. package/src/style-cjk-tc.css +1 -1
  290. package/src/style-cjk.css +1 -1
  291. package/src/styles/components.css +540 -14
  292. package/src/styles/site-media.css +111 -19
  293. package/src/styles/tokens.css +76 -10
  294. package/src/styles/ui.css +2227 -1347
  295. package/src/types/bindings.ts +3 -0
  296. package/src/types/config.ts +243 -12
  297. package/src/types/constants.ts +22 -1
  298. package/src/types/entities.ts +14 -8
  299. package/src/types/operations.ts +18 -10
  300. package/src/types/props.ts +20 -2
  301. package/src/types/raw-assets.d.ts +5 -0
  302. package/src/types/views.ts +22 -4
  303. package/src/ui/__tests__/font-themes.test.ts +42 -30
  304. package/src/ui/compose/ComposeDialog.tsx +109 -19
  305. package/src/ui/dash/appearance/NavigationContent.tsx +332 -4
  306. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +23 -0
  307. package/src/ui/dash/settings/ConfigEditorContent.tsx +578 -0
  308. package/src/ui/dash/settings/GeneralContent.tsx +15 -5
  309. package/src/ui/dash/settings/SettingsRootContent.tsx +21 -1
  310. package/src/ui/dash/settings/__tests__/ConfigEditorContent.test.tsx +98 -0
  311. package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +15 -0
  312. package/src/ui/feed/CuratedThreadPreview.tsx +16 -4
  313. package/src/ui/feed/LinkCard.tsx +2 -12
  314. package/src/ui/feed/LinkPreview.tsx +2 -7
  315. package/src/ui/feed/NoteCard.tsx +10 -20
  316. package/src/ui/feed/PostStatusBadges.tsx +42 -3
  317. package/src/ui/feed/QuoteCard.tsx +2 -12
  318. package/src/ui/feed/ThreadPreview.tsx +26 -34
  319. package/src/ui/feed/TimelineFeed.tsx +2 -2
  320. package/src/ui/feed/__tests__/thread-preview.test.ts +197 -56
  321. package/src/ui/feed/__tests__/timeline-cards.test.ts +178 -3
  322. package/src/ui/feed/thread-preview-state.ts +5 -7
  323. package/src/ui/font-themes.ts +74 -26
  324. package/src/ui/layouts/BaseLayout.tsx +22 -8
  325. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +64 -1
  326. package/src/ui/pages/ArchivePage.tsx +131 -24
  327. package/src/ui/pages/CollectionPage.tsx +101 -22
  328. package/src/ui/pages/CollectionsPage.tsx +3 -1
  329. package/src/ui/pages/PostPage.tsx +39 -12
  330. package/src/ui/pages/SearchPage.tsx +6 -14
  331. package/src/ui/pages/__tests__/ArchivePage.test.tsx +70 -0
  332. package/src/ui/pages/__tests__/PostPage.test.tsx +88 -0
  333. package/src/ui/shared/CollectionDirectory.tsx +13 -9
  334. package/src/ui/shared/CollectionsManager.tsx +17 -7
  335. package/src/ui/shared/DraftPreviewBar.tsx +49 -0
  336. package/src/ui/shared/HomePageBranding.tsx +2 -2
  337. package/src/ui/shared/MediaGallery.tsx +1 -0
  338. package/src/ui/shared/PostFooter.tsx +38 -13
  339. package/src/ui/shared/__tests__/home-page-branding.test.tsx +1 -1
  340. package/src/ui/shared/__tests__/media-gallery.test.ts +1 -0
  341. package/src/ui/shared/__tests__/navigation-labels.test.ts +2 -2
  342. package/src/ui/shared/__tests__/post-footer.test.ts +15 -0
  343. package/src/ui/shared/collection-management-labels.ts +33 -2
  344. package/src/ui/shared/custom-icons.ts +5 -0
  345. package/src/ui/shared/navigation-labels.ts +2 -4
  346. package/src/ui/shared/post-article-attributes.ts +52 -0
  347. package/dist/app-DZDlmh7C.js +0 -6
  348. package/dist/client/_assets/client-Cevfpm8H.css +0 -2
  349. package/dist/client/_assets/client-auth-v3yvhbgZ.js +0 -4736
  350. package/src/client/tiptap/wrapping-input-rules.ts +0 -102
@@ -1,5 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
+ import { eq } from "drizzle-orm";
2
3
  import { createTestApp } from "../../../../__tests__/helpers/app.js";
4
+ import { posts } from "../../../../db/schema.js";
3
5
  import { publicPostsApiRoutes } from "../posts.js";
4
6
 
5
7
  describe("Public Posts API Routes", () => {
@@ -51,6 +53,44 @@ describe("Public Posts API Routes", () => {
51
53
  expect(body.posts[0]).not.toHaveProperty("body");
52
54
  });
53
55
 
56
+ // Consumers need both axes: `lastActivityAt` answers "when was this
57
+ // announced", `threadUpdatedAt` answers "when did it actually change".
58
+ // Exposing only the first left no way to tell a quiet addition happened.
59
+ it("reports both thread activity timestamps", async () => {
60
+ const { app, services } = createTestApp({ authenticated: false });
61
+ app.route("/api/public/posts", publicPostsApiRoutes);
62
+
63
+ const root = await services.posts.create({
64
+ format: "note",
65
+ title: "Quietly extended",
66
+ bodyMarkdown: "root",
67
+ publishedAt: 1000,
68
+ });
69
+ await services.posts.create({
70
+ format: "note",
71
+ bodyMarkdown: "quiet addendum",
72
+ replyToId: root.id,
73
+ publishedAt: 3000,
74
+ quietReply: true,
75
+ });
76
+
77
+ const res = await app.request("/api/public/posts");
78
+ expect(res.status).toBe(200);
79
+ const body = (await res.json()) as {
80
+ posts: {
81
+ title: string | null;
82
+ quietReply: boolean;
83
+ lastActivityAt: number;
84
+ threadUpdatedAt: number;
85
+ }[];
86
+ };
87
+
88
+ const entry = body.posts.find((p) => p.title === "Quietly extended");
89
+ expect(entry?.lastActivityAt).toBe(1000);
90
+ expect(entry?.threadUpdatedAt).toBe(3000);
91
+ expect(entry?.quietReply).toBe(false);
92
+ });
93
+
54
94
  it("supports format and limit filters", async () => {
55
95
  const { app, services } = createTestApp({ authenticated: false });
56
96
  app.route("/api/public/posts", publicPostsApiRoutes);
@@ -100,6 +140,32 @@ describe("Public Posts API Routes", () => {
100
140
  expect(body.posts[0]).not.toHaveProperty("bodyText");
101
141
  });
102
142
 
143
+ it("returns canonical v3 HTML when the stored projection is stale", async () => {
144
+ const { app, services, db } = createTestApp({ authenticated: false });
145
+ app.route("/api/public/posts", publicPostsApiRoutes);
146
+ const post = await services.posts.create({
147
+ format: "note",
148
+ title: "Footnote API",
149
+ bodyMarkdown: "API body[^1]\n\n[^1]: API definition",
150
+ });
151
+ await db
152
+ .update(posts)
153
+ .set({
154
+ bodyHtml: '<span class="sidenote">legacy</span>',
155
+ bodyHtmlVersion: 1,
156
+ })
157
+ .where(eq(posts.id, post.id));
158
+
159
+ const response = await app.request("/api/public/posts");
160
+ const body = await response.json();
161
+
162
+ expect(response.status).toBe(200);
163
+ expect(body.posts[0].bodyHtml).toContain('role="doc-noteref"');
164
+ expect(body.posts[0].bodyHtml).toMatch(/id="fn-[a-z0-9]{13}-1"/);
165
+ expect(body.posts[0].bodyHtml).not.toContain(post.id);
166
+ expect(body.posts[0].bodyHtml).not.toContain("legacy");
167
+ });
168
+
103
169
  it("filters posts by a single collection slug", async () => {
104
170
  const { app, services } = createTestApp({ authenticated: false });
105
171
  app.route("/api/public/posts", publicPostsApiRoutes);
@@ -194,12 +260,20 @@ describe("Public Posts API Routes", () => {
194
260
  title: "Older",
195
261
  bodyMarkdown: "first",
196
262
  collectionIds: [collection.id],
263
+ publishedAt: 1000,
197
264
  });
198
265
  const newer = await services.posts.create({
199
266
  format: "note",
200
267
  title: "Newer",
201
268
  bodyMarkdown: "second",
202
269
  collectionIds: [collection.id],
270
+ publishedAt: 2000,
271
+ });
272
+ await services.posts.create({
273
+ format: "note",
274
+ bodyMarkdown: "Later reply to the older Thread",
275
+ replyToId: older.id,
276
+ publishedAt: 3000,
203
277
  });
204
278
 
205
279
  const res = await app.request(
@@ -213,6 +287,145 @@ describe("Public Posts API Routes", () => {
213
287
  expect(body.posts[1].id).toBe(newer.id);
214
288
  });
215
289
 
290
+ it("sorts collection results by Thread activity from Child Posts", async () => {
291
+ const { app, services } = createTestApp({ authenticated: false });
292
+ app.route("/api/public/posts", publicPostsApiRoutes);
293
+
294
+ const collection = await services.collections.create({
295
+ slug: "active-threads",
296
+ title: "Active Threads",
297
+ });
298
+ const olderRoot = await services.posts.create({
299
+ format: "note",
300
+ title: "Older root",
301
+ bodyMarkdown: "older",
302
+ collectionIds: [collection.id],
303
+ publishedAt: 1000,
304
+ });
305
+ const newerRoot = await services.posts.create({
306
+ format: "note",
307
+ title: "Newer root",
308
+ bodyMarkdown: "newer",
309
+ collectionIds: [collection.id],
310
+ publishedAt: 2000,
311
+ });
312
+ await services.posts.create({
313
+ format: "note",
314
+ bodyMarkdown: "new activity",
315
+ replyToId: olderRoot.id,
316
+ publishedAt: 3000,
317
+ });
318
+
319
+ const res = await app.request(
320
+ "/api/public/posts?collection=active-threads&sort=newest",
321
+ );
322
+ expect(res.status).toBe(200);
323
+
324
+ const body = await res.json();
325
+ expect(body.posts.map((post: { id: string }) => post.id)).toEqual([
326
+ olderRoot.id,
327
+ newerRoot.id,
328
+ ]);
329
+ });
330
+
331
+ it("sorts and cursor-paginates collection Threads by Child ratings", async () => {
332
+ const { app, services } = createTestApp({ authenticated: false });
333
+ app.route("/api/public/posts", publicPostsApiRoutes);
334
+
335
+ const collection = await services.collections.create({
336
+ slug: "rated-threads",
337
+ title: "Rated Threads",
338
+ sortOrder: "rating_desc",
339
+ });
340
+ const createRatedThread = async (
341
+ title: string,
342
+ publishedAt: number,
343
+ rating: number,
344
+ ) => {
345
+ const root = await services.posts.create({
346
+ format: "note",
347
+ title,
348
+ bodyMarkdown: `${title} root`,
349
+ collectionIds: [collection.id],
350
+ publishedAt,
351
+ });
352
+ await services.posts.create({
353
+ format: "note",
354
+ bodyMarkdown: `${title} rated Child`,
355
+ replyToId: root.id,
356
+ publishedAt: publishedAt + 100,
357
+ rating,
358
+ });
359
+ return root;
360
+ };
361
+
362
+ const highest = await createRatedThread("Highest", 1000, 5);
363
+ const middle = await createRatedThread("Middle", 2000, 3);
364
+ const lowest = await createRatedThread("Lowest", 3000, 1);
365
+
366
+ const firstRes = await app.request(
367
+ "/api/public/posts?collection=rated-threads&limit=1",
368
+ );
369
+ expect(firstRes.status).toBe(200);
370
+ const first = await firstRes.json();
371
+ expect(first.posts[0].id).toBe(highest.id);
372
+ expect(first.nextCursor).toBe(highest.id);
373
+
374
+ const secondRes = await app.request(
375
+ `/api/public/posts?collection=rated-threads&limit=1&cursor=${first.nextCursor}`,
376
+ );
377
+ expect(secondRes.status).toBe(200);
378
+ const second = await secondRes.json();
379
+ expect(second.posts[0].id).toBe(middle.id);
380
+ expect(second.nextCursor).toBe(middle.id);
381
+
382
+ const thirdRes = await app.request(
383
+ `/api/public/posts?collection=rated-threads&limit=1&cursor=${second.nextCursor}`,
384
+ );
385
+ expect(thirdRes.status).toBe(200);
386
+ const third = await thirdRes.json();
387
+ expect(third.posts[0].id).toBe(lowest.id);
388
+ });
389
+
390
+ it("keeps root format and Latest visibility filters for Collections", async () => {
391
+ const { app, services } = createTestApp({ authenticated: false });
392
+ app.route("/api/public/posts", publicPostsApiRoutes);
393
+
394
+ const collection = await services.collections.create({
395
+ slug: "mixed",
396
+ title: "Mixed",
397
+ });
398
+ const note = await services.posts.create({
399
+ format: "note",
400
+ title: "Visible note",
401
+ bodyMarkdown: "note",
402
+ collectionIds: [collection.id],
403
+ });
404
+ await services.posts.create({
405
+ format: "link",
406
+ title: "Visible link",
407
+ url: "https://example.com",
408
+ collectionIds: [collection.id],
409
+ });
410
+ await services.posts.create({
411
+ format: "note",
412
+ title: "Hidden note",
413
+ bodyMarkdown: "hidden",
414
+ visibility: "latest_hidden",
415
+ collectionIds: [collection.id],
416
+ });
417
+
418
+ const res = await app.request(
419
+ "/api/public/posts?collection=mixed&format=note",
420
+ );
421
+ expect(res.status).toBe(200);
422
+
423
+ const body = await res.json();
424
+ expect(body.posts.map((post: { id: string }) => post.id)).toEqual([
425
+ note.id,
426
+ ]);
427
+ });
428
+
216
429
  it("allows overriding collection sort order via sort parameter", async () => {
217
430
  const { app, services } = createTestApp({ authenticated: false });
218
431
  app.route("/api/public/posts", publicPostsApiRoutes);
@@ -282,6 +495,39 @@ describe("Public Posts API Routes", () => {
282
495
  expect(body).not.toHaveProperty("body");
283
496
  });
284
497
 
498
+ it("returns the shared Thread Collections for a child post", async () => {
499
+ const { app, services } = createTestApp({ authenticated: false });
500
+ app.route("/api/public/posts", publicPostsApiRoutes);
501
+
502
+ const collection = await services.collections.create({
503
+ slug: "shared",
504
+ title: "Shared",
505
+ });
506
+ const root = await services.posts.create({
507
+ format: "note",
508
+ bodyMarkdown: "root",
509
+ collectionIds: [collection.id],
510
+ });
511
+ const child = await services.posts.create({
512
+ format: "note",
513
+ bodyMarkdown: "child",
514
+ replyToId: root.id,
515
+ });
516
+
517
+ const res = await app.request(`/api/public/posts/${child.slug}`);
518
+ expect(res.status).toBe(200);
519
+ await expect(res.json()).resolves.toMatchObject({
520
+ id: child.id,
521
+ collections: [
522
+ {
523
+ id: collection.id,
524
+ slug: "shared",
525
+ title: "Shared",
526
+ },
527
+ ],
528
+ });
529
+ });
530
+
285
531
  it("returns markdown instead of rendered fields when content=markdown", async () => {
286
532
  const { app, services } = createTestApp({ authenticated: false });
287
533
  app.route("/api/public/posts", publicPostsApiRoutes);
@@ -5,11 +5,14 @@ import type { AppVariables } from "../../../types/app-context.js";
5
5
  import { MEDIA_KINDS } from "../../../types.js";
6
6
  import { FormatSchema, parseValidated } from "../../../lib/schemas.js";
7
7
  import { toPublicPost } from "./posts.js";
8
+ import { requirePublicApiEnabled } from "../../../middleware/public-content-access.js";
8
9
 
9
10
  type Env = { Bindings: Bindings; Variables: AppVariables };
10
11
 
11
12
  export const publicArchiveApiRoutes = new Hono<Env>();
12
13
 
14
+ publicArchiveApiRoutes.use("*", requirePublicApiEnabled());
15
+
13
16
  const MediaKindSchema = z.enum(MEDIA_KINDS);
14
17
  const MEDIA_KIND_LIST = MEDIA_KINDS.join(", ");
15
18
  const INVALID_MEDIA_MESSAGE =
@@ -108,6 +111,7 @@ publicArchiveApiRoutes.get("/", async (c) => {
108
111
  hasMedia: mediaPresence ?? hasMedia,
109
112
  hasTitle: title ?? hasTitle,
110
113
  hasReplies: replies,
114
+ ignorePinnedSort: true,
111
115
  });
112
116
 
113
117
  const postIds = posts.map((post) => post.id);
@@ -21,11 +21,14 @@ import {
21
21
  resolveCollectionSortOrder,
22
22
  supportsCollectionRatingSort,
23
23
  } from "../../../lib/collection-sort.js";
24
+ import { requirePublicApiEnabled } from "../../../middleware/public-content-access.js";
24
25
 
25
26
  type Env = { Bindings: Bindings; Variables: AppVariables };
26
27
 
27
28
  export const publicPostsApiRoutes = new Hono<Env>();
28
29
 
30
+ publicPostsApiRoutes.use("*", requirePublicApiEnabled());
31
+
29
32
  const ListPublicPostsQuerySchema = z.object({
30
33
  format: FormatSchema.optional(),
31
34
  collection: z.string().optional(),
@@ -58,10 +61,15 @@ export type PublicPostBaseResponse = {
58
61
  previewImageUrl: string | null;
59
62
  replyToId: string | null;
60
63
  threadId: string;
64
+ /** Reply published without announcing its Thread. Always false on roots. */
65
+ quietReply: boolean;
61
66
  pinnedAt: number | null;
62
67
  featuredAt: number | null;
63
68
  publishedAt: number | null;
69
+ /** Root only: newest post in the Thread, quiet replies excluded. */
64
70
  lastActivityAt: number;
71
+ /** Root only: newest post in the Thread, quiet replies included. */
72
+ threadUpdatedAt: number;
65
73
  createdAt: number;
66
74
  updatedAt: number;
67
75
  attachments: ReturnType<typeof toApiAttachment>[];
@@ -97,7 +105,7 @@ function isPublicDetailVisible(post: Post | null): post is Post {
97
105
  export function toPublicPost(
98
106
  post: Post,
99
107
  mediaList: Media[],
100
- postCollections: Collection[],
108
+ threadCollections: Collection[],
101
109
  appConfig: AppVariables["appConfig"],
102
110
  options?: { content?: "markdown" },
103
111
  ): PublicPostResponse {
@@ -143,10 +151,12 @@ export function toPublicPost(
143
151
  previewImageUrl,
144
152
  replyToId: post.replyToId,
145
153
  threadId: post.threadId,
154
+ quietReply: post.quietReply,
146
155
  pinnedAt: post.pinnedAt,
147
156
  featuredAt: post.featuredAt,
148
157
  publishedAt: post.publishedAt,
149
158
  lastActivityAt: post.lastActivityAt,
159
+ threadUpdatedAt: post.threadUpdatedAt,
150
160
  createdAt: post.createdAt,
151
161
  updatedAt: post.updatedAt,
152
162
  attachments: mediaList.map((media) =>
@@ -159,7 +169,7 @@ export function toPublicPost(
159
169
  sitePathPrefix,
160
170
  ),
161
171
  ),
162
- collections: postCollections.map((collection) => ({
172
+ collections: threadCollections.map((collection) => ({
163
173
  id: collection.id,
164
174
  slug: collection.slug,
165
175
  title: collection.title,
@@ -226,16 +236,19 @@ publicPostsApiRoutes.get("/", async (c) => {
226
236
  ? "newest"
227
237
  : primaryCollection.sortOrder;
228
238
 
229
- const ratedPostCount = await c.var.services.posts.countUpTo(
230
- {
239
+ const ratedThreadCount =
240
+ await c.var.services.posts.countCollectionThreadRootsUpToForCollections(
231
241
  collectionIds,
232
- status: "published",
233
- excludePrivate: true,
234
- hasRating: true,
235
- },
236
- 2,
237
- );
238
- const showRatingSort = supportsCollectionRatingSort(ratedPostCount);
242
+ {
243
+ status: "published",
244
+ excludePrivate: true,
245
+ excludeLatestHidden: true,
246
+ rootFormat: format,
247
+ hasRating: true,
248
+ },
249
+ 2,
250
+ );
251
+ const showRatingSort = supportsCollectionRatingSort(ratedThreadCount);
239
252
  const defaultSort = resolveCollectionSortOrder(
240
253
  undefined,
241
254
  requestedDefaultSort,
@@ -244,17 +257,28 @@ publicPostsApiRoutes.get("/", async (c) => {
244
257
  sortOrder = resolveCollectionSortOrder(sort, defaultSort, showRatingSort);
245
258
  }
246
259
 
247
- const posts = await c.var.services.posts.list({
248
- format,
249
- collectionIds,
250
- sortOrder,
251
- status: "published",
252
- cursor: cursor ?? undefined,
253
- limit,
254
- excludePrivate: true,
255
- excludeLatestHidden: true,
256
- excludeReplies: true,
257
- });
260
+ const posts = collectionIds
261
+ ? await c.var.services.posts.listCollectionThreadRootsForCollections(
262
+ collectionIds,
263
+ {
264
+ status: "published",
265
+ excludePrivate: true,
266
+ excludeLatestHidden: true,
267
+ rootFormat: format,
268
+ sortOrder,
269
+ cursor: cursor ?? undefined,
270
+ limit,
271
+ },
272
+ )
273
+ : await c.var.services.posts.list({
274
+ format,
275
+ status: "published",
276
+ cursor: cursor ?? undefined,
277
+ limit,
278
+ excludePrivate: true,
279
+ excludeLatestHidden: true,
280
+ excludeReplies: true,
281
+ });
258
282
 
259
283
  const postIds = posts.map((post) => post.id);
260
284
  const [mediaMap, collectionsMap] = await Promise.all([
@@ -289,13 +313,13 @@ publicPostsApiRoutes.get("/:slug", async (c) => {
289
313
  throw new NotFoundError("Post");
290
314
  }
291
315
 
292
- const [mediaList, postCollections] = await Promise.all([
316
+ const [mediaList, threadCollections] = await Promise.all([
293
317
  c.var.services.media.getByPostId(post.id),
294
318
  c.var.services.collections.getCollectionsByPostId(post.id),
295
319
  ]);
296
320
 
297
321
  return c.json(
298
- toPublicPost(post, mediaList, postCollections, c.var.appConfig, {
322
+ toPublicPost(post, mediaList, threadCollections, c.var.appConfig, {
299
323
  content,
300
324
  }),
301
325
  );
@@ -8,11 +8,14 @@ import type { AppVariables } from "../../types/app-context.js";
8
8
  import { ValidationError, ExternalServiceError } from "../../lib/errors.js";
9
9
  import { toSearchApiResult } from "../../lib/api-search.js";
10
10
  import { rateLimit } from "../../middleware/rate-limit.js";
11
+ import { requirePublicApiAccess } from "../../middleware/public-content-access.js";
11
12
 
12
13
  type Env = { Bindings: Bindings; Variables: AppVariables };
13
14
 
14
15
  export const searchApiRoutes = new Hono<Env>();
15
16
 
17
+ searchApiRoutes.use("*", requirePublicApiAccess());
18
+
16
19
  // Per-IP rate limit. The request-time wrapper is needed because the
17
20
  // per-minute cap is pulled from `appConfig` which is only available on
18
21
  // `c.var`; constructing the middleware once at module load would capture
@@ -13,7 +13,10 @@ import { parseValidated } from "../../lib/schemas.js";
13
13
  import { ValidationError } from "../../lib/errors.js";
14
14
  import { syncHostedControlPlaneSiteAvatar } from "../../lib/hosted-control-plane-sync.js";
15
15
  import {
16
+ buildConfigEditorFields,
16
17
  buildEditableSettingsResponse,
18
+ demoLockedSettingKeys,
19
+ isResettableConfigEditorKey,
17
20
  partitionEditableSettingUpdates,
18
21
  partitionImportableSettingUpdates,
19
22
  } from "../../lib/api-settings.js";
@@ -31,6 +34,7 @@ settingsApiRoutes.get("/", requireAuthApi(), async (c) => {
31
34
  settings: buildEditableSettingsResponse(
32
35
  allSettings,
33
36
  c.var.appConfig.demoMode,
37
+ c.env,
34
38
  ),
35
39
  });
36
40
  });
@@ -61,6 +65,7 @@ settingsApiRoutes.put("/", requireAuthApi(), async (c) => {
61
65
  settings: buildEditableSettingsResponse(
62
66
  allSettings,
63
67
  c.var.appConfig.demoMode,
68
+ c.env,
64
69
  ),
65
70
  ...(rejectedKeys.length > 0 && { rejectedKeys }),
66
71
  });
@@ -216,3 +221,31 @@ settingsApiRoutes.delete("/avatar", requireAuthApi(), async (c) => {
216
221
  }
217
222
  return c.json({ success: true });
218
223
  });
224
+
225
+ // Remove one runtime override so the environment/default fallback applies.
226
+ // Registered after the concrete avatar route so `DELETE /avatar` remains
227
+ // unambiguous.
228
+ settingsApiRoutes.delete("/:key", requireAuthApi(), async (c) => {
229
+ const key = c.req.param("key");
230
+ const demoMode = c.var.appConfig.demoMode;
231
+
232
+ if (
233
+ !isResettableConfigEditorKey(key) ||
234
+ (demoMode && demoLockedSettingKeys.has(key))
235
+ ) {
236
+ throw new ValidationError("This setting can't be reset.", {
237
+ rejectedKeys: [key],
238
+ });
239
+ }
240
+
241
+ await c.var.services.settings.remove(key);
242
+ const allSettings = await c.var.services.settings.getAll();
243
+ const setting = buildConfigEditorFields(allSettings, c.env, demoMode).find(
244
+ (field) => field.key === key,
245
+ );
246
+
247
+ return c.json({
248
+ settings: buildEditableSettingsResponse(allSettings, demoMode, c.env),
249
+ setting,
250
+ });
251
+ });
@@ -38,14 +38,13 @@ describe("Setup bootstrap logic", () => {
38
38
  };
39
39
  });
40
40
 
41
- it("creates six nav items with the built-in default navigation", async () => {
41
+ it("creates the five-item default navigation without Latest", async () => {
42
42
  await runSetupBootstrap(services);
43
43
 
44
44
  const navItemsList = await services.db.select().from(navItems);
45
- expect(navItemsList).toHaveLength(6);
45
+ expect(navItemsList).toHaveLength(5);
46
46
 
47
47
  expect(navItemsList.map((item) => item.systemKey)).toEqual([
48
- "latest",
49
48
  "featured",
50
49
  "collections",
51
50
  "archive",
@@ -125,7 +124,11 @@ describe("Setup bootstrap logic", () => {
125
124
  const navItemsList = await services.db.select().from(navItems);
126
125
  const systemItems = navItemsList.filter((item) => item.type === "system");
127
126
 
128
- expect(systemItems).toHaveLength(6);
127
+ expect(systemItems).toHaveLength(5);
128
+ expect(systemItems.map((item) => item.systemKey)).not.toContain("latest");
129
+ expect(
130
+ systemItems.find((item) => item.systemKey === "collections")?.label,
131
+ ).toBe("Collections");
129
132
  });
130
133
 
131
134
  it("creates a site shell when setup runs after a factory reset", async () => {
@@ -211,7 +211,9 @@ composeRoutes.post("/thread", async (c) => {
211
211
  postSchemas.map((data, index) => ({
212
212
  data: {
213
213
  format: data.format,
214
- slug: index === 0 ? data.slug || undefined : undefined,
214
+ // Every post may carry its own permalink; blank means the service
215
+ // generates one (title-derived for the root, random id for replies).
216
+ slug: data.slug || undefined,
215
217
  title:
216
218
  data.format === "quote"
217
219
  ? data.sourceName || undefined
@@ -226,9 +228,12 @@ composeRoutes.post("/thread", async (c) => {
226
228
  : data.url || undefined,
227
229
  quoteText: data.quoteText || undefined,
228
230
  rating: data.rating || undefined,
229
- collectionIds: index === 0 ? data.collectionIds : [],
231
+ collectionIds: index === 0 ? data.collectionIds : undefined,
230
232
  replyToId: index === 0 ? data.replyToId : undefined,
231
- publishedAt: index === 0 ? data.publishedAt : undefined,
233
+ quietReply: data.quietReply,
234
+ // Replies may carry their own date; when they don't, the service
235
+ // inherits the root's rather than stamping "now".
236
+ publishedAt: data.publishedAt,
232
237
  },
233
238
  attachments: data.attachments,
234
239
  })),