@jant/core 0.6.16 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (508) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/import-site.js +60 -4
  4. package/bin/commands/reset-password.js +21 -8
  5. package/bin/commands/site/snapshot/export.js +1 -1
  6. package/bin/commands/site/snapshot/import.js +1 -1
  7. package/bin/lib/d1-query.js +5 -16
  8. package/bin/lib/node-env.js +40 -4
  9. package/bin/lib/site-selection.js +85 -29
  10. package/bin/lib/wrangler-cli.js +28 -2
  11. package/bin/lib/wrangler-json.js +35 -0
  12. package/dist/{app-C7_4oab9.js → app-K_Aa1MMn.js} +52476 -13330
  13. package/dist/client/.vite/manifest.json +255 -90
  14. package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
  15. package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
  16. package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
  17. package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
  18. package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
  19. package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
  20. package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
  21. package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
  22. package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
  23. package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
  24. package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
  25. package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
  26. package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
  27. package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
  28. package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
  29. package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
  30. package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
  31. package/dist/client/_assets/client-GiYENVw8.css +2 -0
  32. package/dist/client/_assets/client-VnFxJN7G.js +295 -0
  33. package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
  34. package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
  35. package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
  36. package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
  37. package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
  38. package/dist/{export-Dl5KCiEs.js → github-sync-BPAvT999.js} +2254 -107
  39. package/dist/index.js +2 -5
  40. package/dist/node.js +5 -7
  41. package/package.json +3 -3
  42. package/src/__tests__/auth-session-freshness.test.ts +57 -0
  43. package/src/__tests__/basecoat-variants.test.ts +121 -0
  44. package/src/__tests__/bin/assets-upload.test.ts +68 -0
  45. package/src/__tests__/export-hugo-build.test.ts +260 -0
  46. package/src/__tests__/export-service.test.ts +231 -0
  47. package/src/__tests__/helpers/component-css.ts +91 -0
  48. package/src/__tests__/helpers/db.ts +33 -0
  49. package/src/__tests__/helpers/export-fixtures.ts +3 -0
  50. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  51. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  52. package/src/app.tsx +71 -16
  53. package/src/auth.ts +18 -1
  54. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  55. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  56. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  57. package/src/client/__tests__/compose-bridge.test.ts +272 -0
  58. package/src/client/__tests__/compose-launch.test.ts +62 -1
  59. package/src/client/__tests__/compose-shortcuts.test.ts +38 -9
  60. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  61. package/src/client/__tests__/copy-field.test.ts +162 -0
  62. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  63. package/src/client/__tests__/image-processor.test.ts +324 -2
  64. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  65. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  66. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  67. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  68. package/src/client/__tests__/toast.test.ts +23 -0
  69. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  70. package/src/client/__tests__/video-processor.test.ts +181 -0
  71. package/src/client/audio-processor.ts +24 -15
  72. package/src/client/audio-waveform.ts +49 -0
  73. package/src/client/collection-dialog-host.ts +72 -0
  74. package/src/client/collection-navigation.ts +51 -16
  75. package/src/client/collection-page-actions.ts +26 -0
  76. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  77. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  78. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  79. package/src/client/components/__tests__/jant-command-palette.test.ts +28 -0
  80. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  81. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1250 -53
  82. package/src/client/components/__tests__/jant-compose-editor.test.ts +544 -40
  83. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +0 -1
  84. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  85. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  86. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  87. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  88. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  89. package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
  90. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  91. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  92. package/src/client/components/collection-dialog-types.ts +49 -0
  93. package/src/client/components/collection-manager-types.ts +32 -1
  94. package/src/client/components/collection-types.ts +1 -15
  95. package/src/client/components/compose-types.ts +75 -1
  96. package/src/client/components/jant-collection-dialog.ts +722 -0
  97. package/src/client/components/jant-collection-directory.ts +567 -182
  98. package/src/client/components/jant-collection-form.ts +53 -232
  99. package/src/client/components/jant-command-palette.ts +100 -49
  100. package/src/client/components/jant-compose-dialog.ts +1486 -707
  101. package/src/client/components/jant-compose-editor.ts +673 -336
  102. package/src/client/components/jant-compose-fullscreen.ts +51 -43
  103. package/src/client/components/jant-config-editor.ts +65 -53
  104. package/src/client/components/jant-confirm-dialog.ts +13 -11
  105. package/src/client/components/jant-locale-picker.ts +418 -0
  106. package/src/client/components/jant-media-lightbox.ts +146 -136
  107. package/src/client/components/jant-nav-manager.ts +690 -366
  108. package/src/client/components/jant-post-menu.ts +1086 -282
  109. package/src/client/components/jant-post-picker.ts +324 -0
  110. package/src/client/components/jant-repo-picker.ts +138 -106
  111. package/src/client/components/jant-settings-avatar.ts +31 -27
  112. package/src/client/components/jant-settings-general.ts +617 -543
  113. package/src/client/components/jant-settings-language.ts +1087 -0
  114. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  115. package/src/client/components/jant-text-preview.ts +94 -86
  116. package/src/client/components/nav-manager-types.ts +62 -1
  117. package/src/client/components/settings-types.ts +14 -29
  118. package/src/client/components/smart-collection-conditions.ts +285 -0
  119. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  120. package/src/client/compose-bridge.ts +214 -141
  121. package/src/client/compose-launch.ts +76 -2
  122. package/src/client/compose-shortcuts.ts +6 -8
  123. package/src/client/compose-triggers.ts +50 -0
  124. package/src/client/copy-field.ts +108 -0
  125. package/src/client/custom-url-menu.ts +60 -4
  126. package/src/client/icons.ts +14 -0
  127. package/src/client/image-plan.ts +165 -0
  128. package/src/client/image-processor.ts +195 -117
  129. package/src/client/image-worker-client.ts +45 -0
  130. package/src/client/image-worker.ts +135 -0
  131. package/src/client/lazy-entries.ts +95 -0
  132. package/src/client/lazy-slugify.ts +2 -2
  133. package/src/client/media-scroll-hint.ts +43 -2
  134. package/src/client/mediabunny.ts +29 -0
  135. package/src/client/post-picker.ts +41 -0
  136. package/src/client/runtime-paths.ts +74 -1
  137. package/src/client/settings-bridge.ts +4 -10
  138. package/src/client/site-header-nav.js +18 -6
  139. package/src/client/smart-collection-dialog-host.ts +116 -0
  140. package/src/client/smart-collection-page-actions.ts +208 -0
  141. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  142. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  143. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  144. package/src/client/tiptap/bubble-menu.ts +47 -16
  145. package/src/client/tiptap/floating-position.ts +216 -2
  146. package/src/client/tiptap/inline-image-upload.ts +7 -3
  147. package/src/client/tiptap/link-toolbar.ts +22 -9
  148. package/src/client/toast.ts +13 -0
  149. package/src/client/types/vite-worker.d.ts +12 -0
  150. package/src/client/upload-with-metadata.ts +3 -13
  151. package/src/client/video-processor.ts +351 -107
  152. package/src/client-auth.ts +14 -31
  153. package/src/client-compose.ts +19 -0
  154. package/src/client-manage.ts +13 -0
  155. package/src/client-settings.ts +14 -0
  156. package/src/client-site.ts +10 -3
  157. package/src/client.ts +4 -0
  158. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  159. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  160. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  161. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  162. package/src/db/migrations/0032_married_tomas.sql +3 -0
  163. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  164. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  165. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  166. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  167. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  168. package/src/db/migrations/meta/_journal.json +21 -0
  169. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  170. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  171. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  172. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  173. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  174. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  175. package/src/db/migrations/pg/meta/_journal.json +21 -0
  176. package/src/db/pg/schema.ts +195 -27
  177. package/src/db/post-visibility.ts +117 -0
  178. package/src/db/schema.ts +222 -27
  179. package/src/i18n/__tests__/detect.test.ts +26 -81
  180. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  181. package/src/i18n/context.tsx +12 -2
  182. package/src/i18n/detect.ts +7 -66
  183. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  184. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  185. package/src/i18n/locales/public/en.po +490 -158
  186. package/src/i18n/locales/public/en.ts +1 -1
  187. package/src/i18n/locales/public/zh-Hans.po +487 -155
  188. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  189. package/src/i18n/locales/public/zh-Hant.po +487 -155
  190. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  191. package/src/i18n/locales/settings/en.po +551 -69
  192. package/src/i18n/locales/settings/en.ts +1 -1
  193. package/src/i18n/locales/settings/zh-Hans.po +597 -115
  194. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  195. package/src/i18n/locales/settings/zh-Hant.po +610 -128
  196. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  197. package/src/i18n/locales.ts +94 -0
  198. package/src/i18n/middleware.ts +12 -2
  199. package/src/i18n/supported-locales.ts +80 -0
  200. package/src/lib/__tests__/api-settings.test.ts +11 -2
  201. package/src/lib/__tests__/asset-path.test.ts +92 -0
  202. package/src/lib/__tests__/deferred.test.ts +90 -0
  203. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  204. package/src/lib/__tests__/discover.test.ts +410 -0
  205. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  206. package/src/lib/__tests__/feed.test.ts +1526 -15
  207. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  208. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  209. package/src/lib/__tests__/http-cache.test.ts +162 -0
  210. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  211. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  212. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  213. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  214. package/src/lib/__tests__/navigation.test.ts +135 -1
  215. package/src/lib/__tests__/resolve-config.test.ts +64 -3
  216. package/src/lib/__tests__/schemas.test.ts +42 -4
  217. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  218. package/src/lib/__tests__/slug.test.ts +70 -0
  219. package/src/lib/__tests__/slugify.test.ts +85 -0
  220. package/src/lib/__tests__/storage.test.ts +32 -0
  221. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  222. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  223. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  224. package/src/lib/__tests__/url.test.ts +78 -72
  225. package/src/lib/__tests__/view-language.test.ts +413 -0
  226. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  227. package/src/lib/address.ts +57 -0
  228. package/src/lib/api-posts.ts +12 -0
  229. package/src/lib/asset-path.ts +98 -1
  230. package/src/lib/build-env.ts +16 -0
  231. package/src/lib/collection-groups.ts +5 -1
  232. package/src/lib/collection-paths.ts +3 -10
  233. package/src/lib/constants.ts +28 -3
  234. package/src/lib/copy-field.ts +36 -0
  235. package/src/lib/crypto.ts +40 -0
  236. package/src/lib/csp-builder.ts +2 -1
  237. package/src/lib/deferred.ts +79 -0
  238. package/src/lib/discover-ping.ts +141 -0
  239. package/src/lib/discover.ts +437 -0
  240. package/src/lib/env.ts +134 -0
  241. package/src/lib/errors.ts +33 -0
  242. package/src/lib/feed-policy.ts +236 -2
  243. package/src/lib/feed.ts +926 -73
  244. package/src/lib/filter-dimensions.ts +1349 -0
  245. package/src/lib/github-sync-site-config.ts +3 -0
  246. package/src/lib/hosted-signin.ts +1 -1
  247. package/src/lib/http-cache.ts +148 -0
  248. package/src/lib/hugo-markdown.ts +16 -0
  249. package/src/lib/icons.ts +9 -2
  250. package/src/lib/ids.ts +3 -0
  251. package/src/lib/jant-docs.ts +42 -0
  252. package/src/lib/lang-detect.ts +308 -0
  253. package/src/lib/media-helpers.ts +13 -1
  254. package/src/lib/navigation.ts +78 -11
  255. package/src/lib/pagination.ts +28 -24
  256. package/src/lib/per-language-surfaces.ts +67 -0
  257. package/src/lib/post-meta.ts +19 -0
  258. package/src/lib/render.tsx +55 -7
  259. package/src/lib/resolve-config.ts +53 -5
  260. package/src/lib/schemas.ts +201 -10
  261. package/src/lib/site-header-fragment.tsx +2 -1
  262. package/src/lib/site-unavailable-page.ts +47 -0
  263. package/src/lib/slug.ts +51 -7
  264. package/src/lib/slugify.ts +113 -0
  265. package/src/lib/startup-config.ts +4 -6
  266. package/src/lib/storage.ts +7 -0
  267. package/src/lib/summary.ts +50 -0
  268. package/src/lib/thread-fold.ts +161 -0
  269. package/src/lib/time.ts +32 -0
  270. package/src/lib/timeline.ts +42 -1
  271. package/src/lib/tiptap-render.ts +1 -2
  272. package/src/lib/upload.ts +16 -13
  273. package/src/lib/url.ts +139 -86
  274. package/src/lib/version.ts +10 -8
  275. package/src/lib/view-language.ts +436 -0
  276. package/src/lib/view.ts +51 -23
  277. package/src/lib/viewer-context.ts +62 -0
  278. package/src/middleware/__tests__/auth.test.ts +70 -2
  279. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  280. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  281. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  282. package/src/middleware/__tests__/session.test.ts +181 -33
  283. package/src/middleware/auth.ts +50 -31
  284. package/src/middleware/cache-control.ts +75 -10
  285. package/src/middleware/config.ts +6 -6
  286. package/src/middleware/error-handler.ts +12 -0
  287. package/src/middleware/onboarding.ts +32 -10
  288. package/src/middleware/secure-headers.ts +1 -1
  289. package/src/middleware/session.ts +96 -8
  290. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  291. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  292. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  293. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  294. package/src/node/__tests__/runtime.test.ts +37 -0
  295. package/src/node/index.ts +1 -0
  296. package/src/preset.css +76 -22
  297. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  298. package/src/routes/api/__tests__/collections.test.ts +114 -0
  299. package/src/routes/api/__tests__/discover.test.ts +222 -0
  300. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  301. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  302. package/src/routes/api/__tests__/settings.test.ts +24 -3
  303. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  304. package/src/routes/api/collections.ts +49 -6
  305. package/src/routes/api/custom-urls.ts +0 -1
  306. package/src/routes/api/discover.ts +95 -0
  307. package/src/routes/api/export.ts +3 -0
  308. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  309. package/src/routes/api/nav-items.ts +55 -8
  310. package/src/routes/api/posts.ts +159 -0
  311. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  312. package/src/routes/api/public/archive.ts +77 -81
  313. package/src/routes/api/public/posts.ts +23 -6
  314. package/src/routes/api/smart-collections.ts +132 -0
  315. package/src/routes/api/telegram.ts +2 -40
  316. package/src/routes/api/uploads.ts +1 -2
  317. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  318. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  319. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  320. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  321. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  322. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  323. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  324. package/src/routes/auth/setup.tsx +934 -186
  325. package/src/routes/auth/signin.tsx +10 -8
  326. package/src/routes/compose.tsx +5 -0
  327. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  328. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  329. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  330. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  331. package/src/routes/dash/custom-urls.tsx +1 -37
  332. package/src/routes/dash/settings.tsx +502 -41
  333. package/src/routes/discover-announce.ts +96 -0
  334. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  335. package/src/routes/feed/feed.ts +69 -40
  336. package/src/routes/feed/sitemap.ts +95 -11
  337. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  338. package/src/routes/pages/__tests__/archive-params.test.ts +370 -6
  339. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  340. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  341. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  342. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  343. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  344. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  345. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  346. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  347. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  348. package/src/routes/pages/archive.tsx +471 -369
  349. package/src/routes/pages/collection.tsx +94 -96
  350. package/src/routes/pages/collections.tsx +38 -50
  351. package/src/routes/pages/featured.tsx +32 -13
  352. package/src/routes/pages/home.tsx +25 -6
  353. package/src/routes/pages/language.tsx +113 -0
  354. package/src/routes/pages/latest.tsx +26 -16
  355. package/src/routes/pages/new.tsx +3 -0
  356. package/src/routes/pages/page.tsx +178 -17
  357. package/src/routes/pages/partials.tsx +41 -0
  358. package/src/routes/pages/search.tsx +18 -3
  359. package/src/routes/pages/smart-collection.tsx +340 -0
  360. package/src/routes/pages/subscribe.tsx +182 -0
  361. package/src/runtime/__tests__/index.test.ts +19 -0
  362. package/src/runtime/__tests__/node.test.ts +129 -4
  363. package/src/runtime/cloudflare.ts +21 -2
  364. package/src/runtime/index.ts +27 -1
  365. package/src/runtime/node.ts +18 -1
  366. package/src/runtime/site.ts +95 -24
  367. package/src/services/__tests__/auth.test.ts +116 -7
  368. package/src/services/__tests__/collection.test.ts +108 -4
  369. package/src/services/__tests__/language.test.ts +495 -0
  370. package/src/services/__tests__/navigation.test.ts +213 -10
  371. package/src/services/__tests__/path.test.ts +180 -0
  372. package/src/services/__tests__/post-language.test.ts +830 -0
  373. package/src/services/__tests__/post.test.ts +46 -9
  374. package/src/services/__tests__/settings.test.ts +228 -17
  375. package/src/services/__tests__/site-admin.test.ts +5 -5
  376. package/src/services/__tests__/smart-collection.test.ts +651 -0
  377. package/src/services/about-page.ts +22 -1
  378. package/src/services/auth.ts +51 -49
  379. package/src/services/bootstrap.ts +188 -38
  380. package/src/services/collection-directory-position.ts +76 -0
  381. package/src/services/collection.ts +331 -46
  382. package/src/services/custom-url.ts +27 -22
  383. package/src/services/export-theme/assets/client-site.css +1 -1
  384. package/src/services/export-theme/assets/client-site.js +123 -123
  385. package/src/services/export-theme/layouts/_default/rss.xml +77 -1
  386. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  387. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  388. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  389. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  390. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  391. package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
  392. package/src/services/export-theme/styles/main.css +13 -1
  393. package/src/services/export.ts +278 -19
  394. package/src/services/index.ts +34 -3
  395. package/src/services/language.ts +412 -0
  396. package/src/services/mcp.ts +3 -1
  397. package/src/services/media.ts +14 -6
  398. package/src/services/navigation.ts +429 -67
  399. package/src/services/path.ts +221 -6
  400. package/src/services/post.ts +1021 -152
  401. package/src/services/search.ts +39 -2
  402. package/src/services/settings.ts +238 -43
  403. package/src/services/site-admin.ts +24 -7
  404. package/src/services/site.ts +77 -1
  405. package/src/services/smart-collection.ts +636 -0
  406. package/src/services/telegram.ts +20 -22
  407. package/src/services/upload-session.ts +1 -3
  408. package/src/style-author.css +27 -0
  409. package/src/styles/components-author.css +1622 -0
  410. package/src/styles/components.css +24 -1571
  411. package/src/styles/site-media.css +16 -2
  412. package/src/styles/tokens.css +82 -12
  413. package/src/styles/ui-author.css +7602 -0
  414. package/src/styles/ui.css +1087 -7219
  415. package/src/types/app-context.ts +7 -0
  416. package/src/types/bindings.ts +3 -0
  417. package/src/types/config.ts +94 -15
  418. package/src/types/constants.ts +188 -16
  419. package/src/types/entities.ts +87 -1
  420. package/src/types/operations.ts +64 -2
  421. package/src/types/props.ts +157 -31
  422. package/src/types/views.ts +55 -1
  423. package/src/ui/__tests__/font-themes.test.ts +52 -12
  424. package/src/ui/compose/ComposeDialog.tsx +201 -7
  425. package/src/ui/compose/ComposePrompt.tsx +1 -5
  426. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  427. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  428. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  429. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  430. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  431. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  432. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  433. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  434. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  435. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  436. package/src/ui/dash/settings/GeneralContent.tsx +275 -113
  437. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  438. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  439. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  440. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  441. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  442. package/src/ui/dash/settings/discover-copy.ts +116 -0
  443. package/src/ui/feed/LinkCard.tsx +55 -45
  444. package/src/ui/feed/NoteCard.tsx +9 -4
  445. package/src/ui/feed/PostStatusBadges.tsx +90 -43
  446. package/src/ui/feed/QuoteCard.tsx +55 -30
  447. package/src/ui/feed/ThreadPreview.tsx +6 -2
  448. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  449. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  450. package/src/ui/feed/thread-preview-state.ts +20 -6
  451. package/src/ui/font-themes.ts +39 -29
  452. package/src/ui/layouts/BaseLayout.tsx +155 -10
  453. package/src/ui/layouts/SiteLayout.tsx +192 -34
  454. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  455. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  456. package/src/ui/pages/ArchivePage.tsx +282 -289
  457. package/src/ui/pages/CollectionPage.tsx +86 -76
  458. package/src/ui/pages/CollectionsPage.tsx +5 -0
  459. package/src/ui/pages/ComposePage.tsx +8 -0
  460. package/src/ui/pages/PostPage.tsx +47 -0
  461. package/src/ui/pages/SearchPage.tsx +3 -3
  462. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  463. package/src/ui/pages/SubscribePage.tsx +236 -0
  464. package/src/ui/pages/__tests__/ArchivePage.test.tsx +65 -9
  465. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  466. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  467. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  468. package/src/ui/shared/CollectionsManager.tsx +70 -17
  469. package/src/ui/shared/CopyField.tsx +127 -0
  470. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  471. package/src/ui/shared/MediaGallery.tsx +125 -46
  472. package/src/ui/shared/Pagination.tsx +20 -5
  473. package/src/ui/shared/PostFooter.tsx +20 -11
  474. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  475. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  476. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  477. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  478. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  479. package/src/ui/shared/archive-labels.ts +98 -0
  480. package/src/ui/shared/collection-management-labels.ts +94 -16
  481. package/src/ui/shared/navigation-labels.ts +45 -71
  482. package/src/ui/shared/post-article-attributes.ts +2 -0
  483. package/src/ui/shared/smart-collection-labels.ts +343 -0
  484. package/dist/app-C75Zc8dD.js +0 -6
  485. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  486. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  487. package/dist/client/_assets/chunks/url-CU9pEbk5.js +0 -1
  488. package/dist/client/_assets/client-Bq0vre8Y.js +0 -296
  489. package/dist/client/_assets/client-D3VZyG4L.css +0 -2
  490. package/dist/client/_assets/client-auth-F2rb9WfF.js +0 -5571
  491. package/dist/env-BPYViDJJ.js +0 -281
  492. package/dist/github-api-BNF35Lkx.js +0 -176
  493. package/dist/github-app-DdiD-bC4.js +0 -275
  494. package/dist/github-sync-Bqg62IvV.js +0 -4
  495. package/dist/github-sync-HdK2EgvJ.js +0 -431
  496. package/dist/url-CbLAtlZe.js +0 -900
  497. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  498. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  499. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  500. package/src/client/collection-created-notice.ts +0 -66
  501. package/src/client/collection-form-bridge.ts +0 -191
  502. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  503. package/src/client/components/jant-post-form.ts +0 -422
  504. package/src/client/components/post-form-template.ts +0 -352
  505. package/src/client/components/post-form-types.ts +0 -96
  506. package/src/client/media-metadata.ts +0 -247
  507. package/src/client/post-form-bridge.ts +0 -193
  508. package/src/ui/pages/CollectionEditorPage.tsx +0 -175
@@ -0,0 +1,340 @@
1
+ /**
2
+ * Smart Collection Page Route
3
+ *
4
+ * A smart collection is always public, so this file carries no page guard, no
5
+ * feed guard, and no "can this reader see it" branch. What it does carry is the
6
+ * ordinary per-post visibility floor, identical to a manual collection page:
7
+ * an author signed in may see more threads than an anonymous reader, on the
8
+ * page and in the count alike.
9
+ */
10
+
11
+ import type { Context } from "hono";
12
+ import type { Bindings, SmartCollection } from "../../types.js";
13
+ import type { AppVariables } from "../../types/app-context.js";
14
+ import { SmartCollectionPage } from "../../ui/pages/SmartCollectionPage.js";
15
+ import { getNavigationData } from "../../lib/navigation.js";
16
+ import { formatPageLabel, parsePageNumber } from "../../lib/pagination.js";
17
+ import { buildPageTitle } from "../../lib/page-title.js";
18
+ import { renderPublicPage } from "../../lib/render.js";
19
+ import { assembleTimelineItems } from "../../lib/timeline.js";
20
+ import { defaultFeedRenderer } from "../../lib/feed.js";
21
+ import {
22
+ buildFeedDiscoveryFields,
23
+ getFeedEntryUpdatedAt,
24
+ getFeedLimit,
25
+ getRssPublishedBefore,
26
+ renderFeed,
27
+ } from "../../lib/feed-policy.js";
28
+ import { toPlainText as markdownToPlainText } from "../../lib/markdown.js";
29
+ import { buildMediaMap } from "../../lib/media-helpers.js";
30
+ import { createMediaContext, toPostViews } from "../../lib/view.js";
31
+ import { toAbsoluteSiteUrl } from "../../lib/url.js";
32
+ import {
33
+ buildSurfaceAlternates,
34
+ getViewLang,
35
+ toViewPath,
36
+ viewBasePath,
37
+ } from "../../lib/view-language.js";
38
+ import { getCollectionPagePath } from "../../lib/collection-paths.js";
39
+ import { buildCollectionVocabulary } from "../../lib/filter-dimensions.js";
40
+ import {
41
+ buildSmartCollectionArchiveHref,
42
+ describeSmartCollection,
43
+ } from "../../ui/shared/smart-collection-labels.js";
44
+ import { getI18n } from "../../i18n/index.js";
45
+ import { SMART_COLLECTION_SORT_ORDERS } from "../../types.js";
46
+ import type { SmartCollectionSortOrder } from "../../types.js";
47
+ import { supportsCollectionRatingSort } from "../../lib/collection-sort.js";
48
+
49
+ type Env = { Bindings: Bindings; Variables: AppVariables };
50
+
51
+ /**
52
+ * The collection vocabulary, loaded only when a condition names one.
53
+ *
54
+ * The condition line has to name the collection it filters by, and the archive
55
+ * link has to spell its slug — but most smart collections name none, and the
56
+ * lookup is a round trip.
57
+ */
58
+ async function loadConditionVocabulary(
59
+ c: Context<Env>,
60
+ smartCollection: SmartCollection,
61
+ ) {
62
+ const ids = smartCollection.selection.collection ?? [];
63
+ if (ids.length === 0) return buildCollectionVocabulary([]);
64
+ return buildCollectionVocabulary(await c.var.services.collections.list());
65
+ }
66
+
67
+ function readSort(
68
+ value: string | undefined,
69
+ fallback: SmartCollectionSortOrder,
70
+ showRatingSort: boolean,
71
+ ): SmartCollectionSortOrder {
72
+ // `updated` was a fourth order before `newest` came to mean the same thing.
73
+ // Links to it are still in the wild, and they still resolve to what the
74
+ // reader chose, so the accept-old rule costs one line here.
75
+ const named = value === "updated" ? "newest" : value;
76
+ const requested = (
77
+ SMART_COLLECTION_SORT_ORDERS as readonly string[]
78
+ ).includes(named ?? "")
79
+ ? (named as SmartCollectionSortOrder)
80
+ : fallback;
81
+ // Silently falls back rather than showing an order that would look arbitrary
82
+ // on a set where almost nothing is rated — the same rule a collection page
83
+ // applies to its own rating sort.
84
+ return requested === "rating_desc" && !showRatingSort ? "newest" : requested;
85
+ }
86
+
87
+ /**
88
+ * Render a smart collection page.
89
+ *
90
+ * @param c - Hono context
91
+ * @param slug - The smart collection's address
92
+ * @returns The rendered page, or null when no smart collection lives there
93
+ */
94
+ export async function renderSmartCollectionPage(
95
+ c: Context<Env>,
96
+ slug: string,
97
+ ): Promise<Response | null> {
98
+ const page = parsePageNumber(c.req.query("page"));
99
+ const paginatedPageTitle = formatPageLabel(page);
100
+
101
+ const [smartCollection, navData] = await Promise.all([
102
+ c.var.services.smartCollections.getBySlug(slug),
103
+ getNavigationData(c),
104
+ ]);
105
+ if (!smartCollection) return null;
106
+
107
+ const canonicalPagePath = getCollectionPagePath(smartCollection.slug);
108
+ const viewer = {
109
+ isAuthenticated: navData.isAuthenticated,
110
+ lang: getViewLang(c) ?? undefined,
111
+ };
112
+
113
+ // The rating order is offered only where it would say something. One cheap
114
+ // bounded count answers that, exactly as the collection page does it.
115
+ const ratedCount = await c.var.services.posts.countUpTo(
116
+ {
117
+ ...c.var.services.smartCollections.toPostFilters(smartCollection, viewer),
118
+ hasRating: true,
119
+ },
120
+ 2,
121
+ );
122
+ const showRatingSort = supportsCollectionRatingSort(ratedCount);
123
+ const defaultSort = readSort(smartCollection.sort, "newest", showRatingSort);
124
+ // The reader's `?sort=` wins over the stored default, the same way it does on
125
+ // a collection page. Condition params in the URL are ignored: membership is
126
+ // edited in the dialog, not by hand in the address bar.
127
+ const currentSort = readSort(
128
+ c.req.query("sort"),
129
+ defaultSort,
130
+ showRatingSort,
131
+ );
132
+
133
+ const pageSize = c.var.appConfig.pageSize;
134
+ const filters = c.var.services.smartCollections.toPostFilters(
135
+ { ...smartCollection, sort: currentSort },
136
+ viewer,
137
+ );
138
+
139
+ const [totalThreadCount, posts, collectionVocabulary] = await Promise.all([
140
+ c.var.services.posts.count(filters),
141
+ c.var.services.posts.list({
142
+ ...filters,
143
+ limit: pageSize,
144
+ offset: (page - 1) * pageSize,
145
+ }),
146
+ loadConditionVocabulary(c, smartCollection),
147
+ ]);
148
+
149
+ const totalPages = Math.max(1, Math.ceil(totalThreadCount / pageSize));
150
+ const items = await assembleTimelineItems(c, posts);
151
+
152
+ const i18n = getI18n(c);
153
+ const dimensionCtx = { collections: collectionVocabulary };
154
+ const feedHref = c.var.appConfig.rssFeedsEnabled
155
+ ? `${canonicalPagePath}/feed`
156
+ : undefined;
157
+
158
+ return renderPublicPage(c, {
159
+ title:
160
+ page > 1
161
+ ? buildPageTitle(
162
+ smartCollection.title,
163
+ paginatedPageTitle,
164
+ navData.siteName,
165
+ )
166
+ : buildPageTitle(smartCollection.title, navData.siteName),
167
+ description: smartCollection.description
168
+ ? markdownToPlainText(smartCollection.description)
169
+ : undefined,
170
+ // A page the author declared, so it is always indexable and always has a
171
+ // canonical and an hreflang set — unlike an archive URL a reader assembled.
172
+ alternateLanguages: buildSurfaceAlternates(c),
173
+ pageFeed: feedHref
174
+ ? { href: toViewPath(c, feedHref), title: smartCollection.title }
175
+ : undefined,
176
+ navData,
177
+ content: (
178
+ <SmartCollectionPage
179
+ smartCollection={smartCollection}
180
+ items={items}
181
+ totalThreadCount={totalThreadCount}
182
+ currentPage={page}
183
+ totalPages={totalPages}
184
+ pagePath={canonicalPagePath}
185
+ baseUrl={
186
+ currentSort === defaultSort
187
+ ? toViewPath(c, canonicalPagePath)
188
+ : toViewPath(c, `${canonicalPagePath}?sort=${currentSort}`)
189
+ }
190
+ currentSort={currentSort}
191
+ defaultSort={defaultSort}
192
+ showRatingSort={showRatingSort}
193
+ conditionSummary={describeSmartCollection(
194
+ smartCollection.selection,
195
+ i18n,
196
+ dimensionCtx,
197
+ )}
198
+ conditionHref={buildSmartCollectionArchiveHref(
199
+ smartCollection.selection,
200
+ dimensionCtx,
201
+ )}
202
+ isAuthenticated={navData.isAuthenticated}
203
+ isInNavigation={navData.links.some(
204
+ (item) =>
205
+ item.type === "smart_collection" &&
206
+ item.smartCollectionId === smartCollection.id,
207
+ )}
208
+ sitePathPrefix={navData.sitePathPrefix}
209
+ basePath={navData.basePath}
210
+ feedHref={feedHref}
211
+ />
212
+ ),
213
+ });
214
+ }
215
+
216
+ /**
217
+ * Render a smart collection's Atom feed.
218
+ *
219
+ * No auth guard: a feed is anonymous by construction, and a smart collection
220
+ * can never name a set only its author can see.
221
+ *
222
+ * @param c - Hono context
223
+ * @param slug - The smart collection's address
224
+ * @returns The feed response, or null when no smart collection lives there
225
+ */
226
+ export async function renderSmartCollectionFeed(
227
+ c: Context<Env>,
228
+ slug: string,
229
+ ): Promise<Response | null> {
230
+ const smartCollection = await c.var.services.smartCollections.getBySlug(slug);
231
+ if (!smartCollection) return null;
232
+
233
+ const { appConfig, services } = c.var;
234
+ const canonicalFeedPath = `${getCollectionPagePath(
235
+ smartCollection.slug,
236
+ )}/feed`;
237
+ const publishedBefore = getRssPublishedBefore(
238
+ appConfig.rssPublishDelaySeconds,
239
+ );
240
+
241
+ const filters = services.smartCollections.toPostFilters(smartCollection, {
242
+ isAuthenticated: false,
243
+ lang: getViewLang(c) ?? undefined,
244
+ });
245
+ const posts = await services.posts.list({
246
+ ...filters,
247
+ // The publication delay never moves: a just-published post is not announced
248
+ // early even when a year condition bounds the same column more tightly.
249
+ publishedBefore:
250
+ filters.publishedBefore === undefined
251
+ ? publishedBefore
252
+ : Math.min(filters.publishedBefore, publishedBefore),
253
+ limit: getFeedLimit(c),
254
+ });
255
+
256
+ const rootIds = posts.filter((p) => p.threadId === p.id).map((p) => p.id);
257
+ const postIds = posts.map((post) => post.id);
258
+ const [threadMap, rawMediaMap, aliasesMap] = await Promise.all([
259
+ services.posts.getPublishedThreads(rootIds, { publishedBefore }),
260
+ services.media.getByPostIds(postIds),
261
+ services.paths.getPostAliases(postIds),
262
+ ]);
263
+
264
+ const replyIds: string[] = [];
265
+ for (const [rootId, thread] of threadMap) {
266
+ for (const reply of thread) {
267
+ if (reply.id !== rootId) replyIds.push(reply.id);
268
+ }
269
+ }
270
+ const replyMediaMap =
271
+ replyIds.length > 0
272
+ ? await services.media.getByPostIds(replyIds)
273
+ : new Map<string, never[]>();
274
+
275
+ const mediaCtx = createMediaContext(appConfig);
276
+ const allRawMedia = new Map(rawMediaMap);
277
+ for (const [id, media] of replyMediaMap) {
278
+ allRawMedia.set(id, media);
279
+ }
280
+ const mediaMap = buildMediaMap(
281
+ allRawMedia,
282
+ mediaCtx.r2PublicUrl,
283
+ mediaCtx.imageTransformUrl,
284
+ mediaCtx.s3PublicUrl,
285
+ mediaCtx.localPublicUrl,
286
+ mediaCtx.sitePathPrefix,
287
+ );
288
+ const aliasMap = new Map<string, string>();
289
+ for (const [id, aliases] of aliasesMap) {
290
+ if (aliases[0]) aliasMap.set(id, aliases[0]);
291
+ }
292
+
293
+ const postViews = toPostViews(
294
+ posts.map((post) => ({
295
+ ...post,
296
+ mediaAttachments: mediaMap.get(post.id) ?? [],
297
+ })),
298
+ mediaCtx,
299
+ undefined,
300
+ aliasMap,
301
+ ).map((postView, index) => {
302
+ const post = posts[index] as (typeof posts)[number];
303
+ const thread = threadMap.get(post.id);
304
+ const replies =
305
+ thread && thread.length > 1
306
+ ? toPostViews(
307
+ thread
308
+ .filter((reply) => reply.id !== post.id)
309
+ .map((reply) => ({
310
+ ...reply,
311
+ mediaAttachments: mediaMap.get(reply.id) ?? [],
312
+ })),
313
+ mediaCtx,
314
+ )
315
+ : undefined;
316
+
317
+ return {
318
+ ...postView,
319
+ feedUpdatedAt: getFeedEntryUpdatedAt(post, thread),
320
+ threadReplies: replies,
321
+ };
322
+ });
323
+
324
+ const feedData = {
325
+ ...buildFeedDiscoveryFields(c),
326
+ siteName: appConfig.siteName,
327
+ siteDescription: markdownToPlainText(appConfig.siteDescription),
328
+ siteUrl: appConfig.siteUrl,
329
+ siteLanguage: getViewLang(c) ?? appConfig.siteLanguage,
330
+ title: buildPageTitle(appConfig.siteName, smartCollection.title),
331
+ selfUrl: toAbsoluteSiteUrl(
332
+ `${viewBasePath(c)}${canonicalFeedPath}`,
333
+ appConfig.siteUrl,
334
+ appConfig.sitePathPrefix,
335
+ ),
336
+ posts: postViews,
337
+ };
338
+
339
+ return renderFeed(defaultFeedRenderer(feedData));
340
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Subscribe Page Route
3
+ *
4
+ * `/subscribe` — how to follow this site, for a reader who does not already
5
+ * know. The nav's feed entry points here rather than at `/feed`, which hands a
6
+ * browser a screen of Atom XML.
7
+ *
8
+ * Three feeds, always, on every site: the main feed, the opposite end of the
9
+ * same list, and the complete record. Nothing here is conditioned on what the
10
+ * site happens to contain — every Jant site's subscribe page looks the same, so
11
+ * the page needs no service calls and no database round-trip at all.
12
+ *
13
+ * Collection feeds are deliberately absent. Wanting one collection and not the
14
+ * rest is a rare intent on a single-author site, and it already has a better
15
+ * home: the feed icon on the collection you are reading. Listing them here
16
+ * would tax every reader's attention for a few readers' benefit. The full route
17
+ * table lives in `docs/feeds.md`.
18
+ */
19
+
20
+ import { Hono } from "hono";
21
+ import type { Context } from "hono";
22
+ import { msg } from "@lingui/core/macro";
23
+ import type { Bindings } from "../../types.js";
24
+ import type { AppVariables } from "../../types/app-context.js";
25
+ import { getI18n } from "../../i18n/index.js";
26
+ import { getNavigationData } from "../../lib/navigation.js";
27
+ import { buildPageTitle } from "../../lib/page-title.js";
28
+ import { renderPublicPage } from "../../lib/render.js";
29
+ import { toAbsoluteSiteUrl, toPublicPath } from "../../lib/url.js";
30
+ import {
31
+ buildSurfaceAlternates,
32
+ viewBasePath,
33
+ } from "../../lib/view-language.js";
34
+ import { SubscribePage } from "../../ui/pages/SubscribePage.js";
35
+ import type { SubscribeFeed } from "../../ui/pages/SubscribePage.js";
36
+
37
+ type Env = { Bindings: Bindings; Variables: AppVariables };
38
+
39
+ export const subscribeRoutes = new Hono<Env>();
40
+
41
+ /**
42
+ * Render the subscribe page for the current view language.
43
+ *
44
+ * @param c - Hono context
45
+ * @returns The page, or 404 when feeds are switched off site-wide
46
+ */
47
+ export async function renderSubscribePage(c: Context<Env>): Promise<Response> {
48
+ const { appConfig } = c.var;
49
+
50
+ // With feeds off there is nothing to subscribe to, and the nav entry is
51
+ // filtered out too — so the page 404s rather than listing dead addresses.
52
+ if (!appConfig.rssFeedsEnabled) return c.notFound();
53
+
54
+ const navData = await getNavigationData(c);
55
+ const i18n = getI18n(c);
56
+
57
+ // Readers paste these into a reader, so they have to be whole addresses, and
58
+ // they have to stay inside the language view they were read from.
59
+ const siteUrl =
60
+ appConfig.siteUrl ||
61
+ new URL(toPublicPath("/", appConfig.sitePathPrefix), c.req.url).toString();
62
+ const feedUrl = (path: string): string =>
63
+ toAbsoluteSiteUrl(
64
+ `${viewBasePath(c)}${path}`,
65
+ siteUrl,
66
+ appConfig.sitePathPrefix,
67
+ );
68
+
69
+ // The page each feed is the feed of. Relative, unlike the addresses: these
70
+ // are links to follow here and now, not strings to paste elsewhere.
71
+ const pageUrl = (path: string): string =>
72
+ toPublicPath(`${viewBasePath(c)}${path}`, appConfig.sitePathPrefix);
73
+
74
+ const latestLabel = i18n._(
75
+ msg({
76
+ message: "Latest",
77
+ comment: "@context: Subscribe page name for the latest public posts feed",
78
+ }),
79
+ );
80
+ const latestDescription = i18n._(
81
+ msg({
82
+ message: "The same posts as the home page.",
83
+ comment:
84
+ "@context: Subscribe page description of the latest posts feed. The home page and this feed run the same query, so they carry the same posts.",
85
+ }),
86
+ );
87
+ // `/latest` redirects to `/` — the home page is the canonical latest
88
+ // timeline, and its query is the one this feed runs.
89
+ const latestPageUrl = pageUrl("/");
90
+ const featuredLabel = i18n._(
91
+ msg({
92
+ message: "Featured",
93
+ comment: "@context: Subscribe page name for the featured posts feed",
94
+ }),
95
+ );
96
+ const featuredDescription = i18n._(
97
+ msg({
98
+ message: "Only the posts marked as featured.",
99
+ comment:
100
+ "@context: Subscribe page description of the featured posts feed",
101
+ }),
102
+ );
103
+ const featuredPageUrl = pageUrl("/featured");
104
+
105
+ const mainIsLatest = appConfig.mainRssFeed === "latest";
106
+
107
+ const mainFeed: SubscribeFeed = {
108
+ label: i18n._(
109
+ msg({
110
+ message: "Main feed",
111
+ comment: "@context: Subscribe page name for the site's main feed",
112
+ }),
113
+ ),
114
+ url: feedUrl("/feed"),
115
+ // Named for what it currently carries. `/feed` is either end of the list
116
+ // depending on the site's setting, and "main feed" alone says neither.
117
+ // The description and the page it links to are the same pair the row
118
+ // below uses for that feed, so the two rows can never disagree.
119
+ description: mainIsLatest ? latestDescription : featuredDescription,
120
+ pageUrl: mainIsLatest ? latestPageUrl : featuredPageUrl,
121
+ };
122
+
123
+ // The other end of the same list. Offering `/latest/feed` beside a `/feed`
124
+ // that already returns the latest posts would be two names for one feed —
125
+ // exactly the confusion this page exists to end. The stable aliases are an
126
+ // author's concern and stay in General settings and the docs.
127
+ const otherEnd: SubscribeFeed = mainIsLatest
128
+ ? {
129
+ label: featuredLabel,
130
+ url: feedUrl("/featured/feed"),
131
+ description: featuredDescription,
132
+ pageUrl: featuredPageUrl,
133
+ }
134
+ : {
135
+ label: latestLabel,
136
+ url: feedUrl("/latest/feed"),
137
+ description: latestDescription,
138
+ pageUrl: latestPageUrl,
139
+ };
140
+
141
+ const archiveFeed: SubscribeFeed = {
142
+ label: i18n._(
143
+ msg({
144
+ message: "All",
145
+ comment:
146
+ "@context: Subscribe page name for the full archive feed. Matches the Archive page's own nav label.",
147
+ }),
148
+ ),
149
+ url: feedUrl("/archive/feed"),
150
+ // Says what it adds over the others rather than sounding like the default
151
+ // choice, so a reader who wants everything recognizes it and a reader who
152
+ // wants the main line knows to skip it.
153
+ description: i18n._(
154
+ msg({
155
+ message:
156
+ "Every published post, including those kept off the home page.",
157
+ comment:
158
+ "@context: Subscribe page description of the full archive feed. Says what it adds over the other two rather than sounding like the default pick.",
159
+ }),
160
+ ),
161
+ pageUrl: pageUrl("/archive"),
162
+ };
163
+
164
+ return renderPublicPage(c, {
165
+ title: buildPageTitle(
166
+ i18n._(
167
+ msg({
168
+ message: "Subscribe",
169
+ comment: "@context: Browser page title for the subscribe page",
170
+ }),
171
+ ),
172
+ navData.siteName,
173
+ ),
174
+ alternateLanguages: buildSurfaceAlternates(c),
175
+ navData,
176
+ content: (
177
+ <SubscribePage mainFeed={mainFeed} otherFeeds={[otherEnd, archiveFeed]} />
178
+ ),
179
+ });
180
+ }
181
+
182
+ subscribeRoutes.get("/", renderSubscribePage);
@@ -0,0 +1,19 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { hasSessionCookie } from "../index.js";
3
+
4
+ describe("hasSessionCookie", () => {
5
+ it.each([
6
+ ["__Secure-better-auth.session_token=abc", true],
7
+ ["better-auth.session_token=abc", true],
8
+ ["theme=dark; __Secure-better-auth.session_token=abc; lang=en", true],
9
+ ["theme=dark", false],
10
+ ["", false],
11
+ [null, false],
12
+ [undefined, false],
13
+ // A cookie merely *named* after the session must not count — only a value
14
+ // assignment does, so a stray marker cookie can't force primary reads.
15
+ ["mysession_token", false],
16
+ ])("hasSessionCookie(%s) → %s", (cookieHeader, expected) => {
17
+ expect(hasSessionCookie(cookieHeader)).toBe(expected);
18
+ });
19
+ });
@@ -5,10 +5,15 @@ import {
5
5
  } from "../../__tests__/helpers/db.js";
6
6
  import { sqliteSchemaBundle } from "../../db/schema-bundle.js";
7
7
  import { createRequestRuntime } from "../index.js";
8
- import { createNodeRequestRuntime } from "../node.js";
8
+ import { createNodeCliRuntime, createNodeRequestRuntime } from "../node.js";
9
+ import type { CliSiteSelector } from "../site.js";
9
10
  import type { Bindings } from "../../types.js";
10
11
  import { siteDomains, sites } from "../../db/schema.js";
11
- import { ConflictError, NotFoundError } from "../../lib/errors.js";
12
+ import {
13
+ ConflictError,
14
+ NotFoundError,
15
+ SiteUnavailableError,
16
+ } from "../../lib/errors.js";
12
17
  import { TRANSIENT_SINGLE_SITE_ID } from "../../services/site.js";
13
18
 
14
19
  afterEach(() => {
@@ -217,7 +222,7 @@ describe("createNodeRequestRuntime", () => {
217
222
  );
218
223
  });
219
224
 
220
- it("treats suspended host-based sites as not found", async () => {
225
+ it("treats suspended host-based sites as unavailable, not missing", async () => {
221
226
  const { db, sqlite } = createTestDatabase();
222
227
  const consoleError = vi
223
228
  .spyOn(console, "error")
@@ -250,9 +255,129 @@ describe("createNodeRequestRuntime", () => {
250
255
  } as Bindings,
251
256
  "http://suspended.localtest.me/",
252
257
  ),
253
- ).rejects.toBeInstanceOf(NotFoundError);
258
+ ).rejects.toBeInstanceOf(SiteUnavailableError);
254
259
  expect(consoleError).toHaveBeenCalledWith(
255
260
  "[Jant] Hosted site resolution failed: host=suspended.localtest.me path=/ reason=site-not-active siteId=sit_test00000000000000000000000 siteKey=default siteStatus=suspended",
256
261
  );
257
262
  });
258
263
  });
264
+
265
+ describe("createNodeCliRuntime", () => {
266
+ const OTHER_SITE_ID = "sit_other0000000000000000000000";
267
+
268
+ function nodeDatabaseBindings(
269
+ testDb: ReturnType<typeof createTestDatabase>,
270
+ env: Partial<Bindings> = {},
271
+ ): Bindings {
272
+ return {
273
+ NODE_DATABASE: {
274
+ db: testDb.db,
275
+ dialect: "sqlite",
276
+ rawQuery: createSqliteRawQuery(testDb.sqlite),
277
+ schema: sqliteSchemaBundle,
278
+ },
279
+ ...env,
280
+ } as Bindings;
281
+ }
282
+
283
+ /** The default test site plus a second tenant on its own host. */
284
+ function createDatabaseWithTwoSites() {
285
+ const testDb = createTestDatabase();
286
+ testDb.sqlite
287
+ .prepare(
288
+ `
289
+ INSERT INTO site (id, key, status, created_at, updated_at)
290
+ VALUES (?, 'other', 'active', 1774200001, 1774200001)
291
+ `,
292
+ )
293
+ .run(OTHER_SITE_ID);
294
+ testDb.sqlite
295
+ .prepare(
296
+ `
297
+ INSERT INTO site_domain (id, site_id, host, path_prefix, kind, redirect_to_primary, created_at, updated_at)
298
+ VALUES ('std_other_1', ?, 'other.localtest.me', NULL, 'primary', 1, 1774200001, 1774200001)
299
+ `,
300
+ )
301
+ .run(OTHER_SITE_ID);
302
+ return testDb;
303
+ }
304
+
305
+ // A hosted database holds every tenant, so there is no "only site" to fall
306
+ // back to. The single-site lookup would blame SITE_RESOLUTION_MODE, which is
307
+ // already host-based; the CLI has to say which flag picks the site.
308
+ it("requires a site selector in host-based mode with several sites", async () => {
309
+ const testDb = createDatabaseWithTwoSites();
310
+
311
+ await expect(
312
+ createNodeCliRuntime(
313
+ nodeDatabaseBindings(testDb, { SITE_RESOLUTION_MODE: "host-based" }),
314
+ ),
315
+ ).rejects.toThrow(
316
+ "host-based mode needs a target site. Pass --site <key|id>, --host <host>, or --url <url>.",
317
+ );
318
+ });
319
+
320
+ it.each<[string, CliSiteSelector]>([
321
+ ["key", { kind: "site", idOrKey: "other" }],
322
+ ["id", { kind: "site", idOrKey: OTHER_SITE_ID }],
323
+ ["host", { kind: "host", host: "other.localtest.me", pathPrefix: null }],
324
+ ])(
325
+ "scopes a host-based runtime to the site selected by %s",
326
+ async (_label, selector) => {
327
+ const testDb = createDatabaseWithTwoSites();
328
+
329
+ const runtime = await createNodeCliRuntime(
330
+ nodeDatabaseBindings(testDb, { SITE_RESOLUTION_MODE: "host-based" }),
331
+ selector,
332
+ );
333
+ await runtime.services.settings.set("PASSWORD_RESET_TOKEN", "hash:1");
334
+
335
+ expect(runtime.currentSite.id).toBe(OTHER_SITE_ID);
336
+ expect(runtime.currentSiteDomain?.host).toBe("other.localtest.me");
337
+ expect(
338
+ testDb.sqlite
339
+ .prepare(
340
+ `SELECT site_id FROM site_setting WHERE key = 'PASSWORD_RESET_TOKEN'`,
341
+ )
342
+ .pluck()
343
+ .all(),
344
+ ).toEqual([OTHER_SITE_ID]);
345
+ },
346
+ );
347
+
348
+ it("names the selector that matched no site", async () => {
349
+ const testDb = createDatabaseWithTwoSites();
350
+ const env = nodeDatabaseBindings(testDb, {
351
+ SITE_RESOLUTION_MODE: "host-based",
352
+ });
353
+
354
+ await expect(
355
+ createNodeCliRuntime(env, { kind: "site", idOrKey: "missing" }),
356
+ ).rejects.toThrow("No site found for --site missing.");
357
+ await expect(
358
+ createNodeCliRuntime(env, {
359
+ kind: "host",
360
+ host: "other.localtest.me",
361
+ pathPrefix: "/blog",
362
+ }),
363
+ ).rejects.toThrow(
364
+ 'No site found for host "other.localtest.me" and path prefix "/blog".',
365
+ );
366
+ });
367
+
368
+ it("uses the instance's one site in single-site mode", async () => {
369
+ const runtime = await createNodeCliRuntime(
370
+ nodeDatabaseBindings(createTestDatabase()),
371
+ );
372
+
373
+ expect(runtime.currentSite.id).toBe(DEFAULT_TEST_SITE_ID);
374
+ });
375
+
376
+ // Here the single-site error is the accurate one: the database really does
377
+ // belong to a host-based install.
378
+ it("reports a multi-site database in single-site mode as misconfigured", async () => {
379
+ await expect(
380
+ createNodeCliRuntime(nodeDatabaseBindings(createDatabaseWithTwoSites())),
381
+ ).rejects.toThrow("single-site mode found multiple sites in the database:");
382
+ });
383
+ });