@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
@@ -53,11 +53,14 @@ function renderArchivePage(
53
53
  totalCount: 1,
54
54
  currentPage: 1,
55
55
  totalPages: 1,
56
- filters: {},
56
+ filters: { selection: {} },
57
57
  availableYears: [2026],
58
58
  availableCollections: [],
59
59
  isAuthenticated: false,
60
60
  timeZone: "UTC",
61
+ // The fixture above is grid data, so these cases pin the grid layout.
62
+ // The list default gets its own case at the end of the file.
63
+ defaultLayout: "grid",
61
64
  ...props,
62
65
  }),
63
66
  );
@@ -93,22 +96,46 @@ describe("ArchivePage", () => {
93
96
  it("serializes visibility latest_hidden as the hidden URL alias", () => {
94
97
  const html = renderArchivePage({
95
98
  isAuthenticated: true,
96
- filters: { visibility: "latest_hidden" },
99
+ filters: { selection: { visibility: "latest_hidden" } },
97
100
  });
98
101
 
99
102
  expect(html).toContain("visibility=hidden");
100
103
  expect(html).not.toContain("visibility=latest_hidden");
101
104
  });
102
105
 
106
+ // The archive already shows a signed-out reader every non-private post, so
107
+ // the dimension names sets they can reach. `private` is the exception, and
108
+ // it is absent rather than present-and-broken.
109
+ it("offers the visibility filter without private to a signed-out reader", () => {
110
+ const html = renderArchivePage();
111
+
112
+ expect(html).toContain('id="af-visibility"');
113
+ expect(html).toContain("visibility=public");
114
+ expect(html).toContain("visibility=hidden");
115
+ expect(html).toContain("visibility=featured");
116
+ expect(html).not.toContain("visibility=private");
117
+ });
118
+
119
+ it("offers private to the author", () => {
120
+ const html = renderArchivePage({ isAuthenticated: true });
121
+
122
+ expect(html).toContain('id="af-visibility"');
123
+ expect(html).toContain("visibility=private");
124
+ });
125
+
103
126
  it("marks the thread filter active when filtering single posts", () => {
104
- const html = renderArchivePage({ filters: { hasReplies: false } });
127
+ const html = renderArchivePage({
128
+ filters: { selection: { replies: false } },
129
+ });
105
130
 
106
131
  expect(html).toContain("archive-chip-active");
107
132
  expect(html).toContain("Single posts");
108
133
  });
109
134
 
110
135
  it("uses a recognizable quote mark for the quote format filter", () => {
111
- const html = renderArchivePage({ filters: { format: "quote" } });
136
+ const html = renderArchivePage({
137
+ filters: { selection: { format: "quote" } },
138
+ });
112
139
 
113
140
  expect(html).toContain('class="lucide lucide-quote"');
114
141
  expect(html).not.toContain('class="lucide lucide-text-quote"');
@@ -132,7 +159,9 @@ describe("ArchivePage", () => {
132
159
  });
133
160
 
134
161
  it("marks the updated sort active and keeps a way back", () => {
135
- const html = renderArchivePage({ filters: { sort: "updated" } });
162
+ const html = renderArchivePage({
163
+ filters: { selection: {}, sort: "updated" },
164
+ });
136
165
 
137
166
  expect(html).toContain(
138
167
  '<a href="/archive?sort=updated" class="archive-view-btn archive-view-btn-active" role="radio" aria-checked="true" aria-label="Sort by when each thread was last added to"',
@@ -160,20 +189,47 @@ describe("ArchivePage", () => {
160
189
  '<span class="archive-month-header-label">March 2026</span>',
161
190
  );
162
191
 
163
- const updated = renderArchivePage({ filters: { sort: "updated" } });
192
+ const updated = renderArchivePage({
193
+ filters: { selection: {}, sort: "updated" },
194
+ });
164
195
  expect(updated).toContain(
165
196
  '<span class="archive-month-header-label">Updated March 2026</span>',
166
197
  );
167
198
  expect(updated).toContain("newest changes first");
168
199
  });
169
200
 
170
- it("keeps the sort selection in filter and view links", () => {
201
+ it("keeps the sort selection in filter and layout links", () => {
171
202
  const html = renderArchivePage({
172
- filters: { sort: "updated" },
203
+ filters: { selection: {}, sort: "updated" },
173
204
  availableCollections: [{ slug: "tech", title: "Tech" }],
174
205
  });
175
206
 
176
207
  expect(html).toContain("collection=tech&amp;sort=updated");
177
- expect(html).toContain("view=list&amp;sort=updated");
208
+ expect(html).toContain("layout=list&amp;sort=updated");
209
+ });
210
+
211
+ // Neither layout is implied by an absent param any more, so both toggle
212
+ // links name themselves. A link shared with a layout chosen keeps that layout
213
+ // even if the site's ARCHIVE_DEFAULT_LAYOUT later changes.
214
+ it("writes both layout options into the URL explicitly", () => {
215
+ const html = renderArchivePage();
216
+
217
+ expect(html).toContain('href="/archive?layout=grid"');
218
+ expect(html).toContain('href="/archive?layout=list"');
219
+ expect(html).not.toContain("view=grid");
220
+ expect(html).not.toContain("view=list");
221
+ });
222
+
223
+ it("renders the flat list when the site default layout is list", () => {
224
+ const html = renderArchivePage({
225
+ groups: [],
226
+ items: [],
227
+ defaultLayout: undefined,
228
+ totalCount: 0,
229
+ });
230
+
231
+ expect(html).toContain(
232
+ '<a href="/archive?layout=list" class="archive-view-btn archive-view-btn-active"',
233
+ );
178
234
  });
179
235
  });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The directory's two kinds of row read against each other.
3
+ *
4
+ * A collection and a smart collection sit in one list, one above the other, and
5
+ * a reader compares them. So they carry the same two measures — how many
6
+ * threads, and how long ago the newest one moved — and the only thing the smart
7
+ * row says differently is the funnel marker naming where its members came from.
8
+ */
9
+
10
+ import type { Context } from "hono";
11
+ import { renderToString } from "hono/jsx/dom/server";
12
+ import { describe, expect, it } from "vitest";
13
+ import { I18nProvider } from "../../../i18n/context.js";
14
+ import { createI18n } from "../../../i18n/i18n.js";
15
+ import { CollectionsPage } from "../CollectionsPage.js";
16
+ import type { CollectionDirectoryItem } from "../../../types.js";
17
+
18
+ const COLLECTION_ACTIVITY_AT = 1_700_000_000;
19
+ const SMART_ACTIVITY_AT = 1_700_086_400;
20
+
21
+ const items: CollectionDirectoryItem[] = [
22
+ {
23
+ id: "cdi_test00000000000000000000000",
24
+ type: "collection",
25
+ collection: {
26
+ id: "col_test000000000000000000000000",
27
+ siteId: "sit_test000000000000000000000000",
28
+ slug: "recipes",
29
+ title: "Recipes",
30
+ description: null,
31
+ sortOrder: "manual",
32
+ createdAt: 1_700_000_000,
33
+ updatedAt: 1_700_000_000,
34
+ threadCount: 2,
35
+ recentActivityAt: COLLECTION_ACTIVITY_AT,
36
+ },
37
+ },
38
+ {
39
+ id: "cdi_test00000000000000000000001",
40
+ type: "smart_collection",
41
+ smartCollection: {
42
+ id: "smc_test000000000000000000000000",
43
+ siteId: "sit_test000000000000000000000000",
44
+ slug: "quotes",
45
+ title: "Quotes",
46
+ description: null,
47
+ selection: { format: "quote" },
48
+ sort: "newest",
49
+ layout: null,
50
+ createdAt: 1_700_000_000,
51
+ updatedAt: 1_700_000_000,
52
+ threadCount: 5,
53
+ recentActivityAt: SMART_ACTIVITY_AT,
54
+ },
55
+ },
56
+ ];
57
+
58
+ function render(isAuthenticated: boolean) {
59
+ const i18n = createI18n("en");
60
+ const c = {
61
+ get(key: string) {
62
+ if (key === "i18n") return i18n;
63
+ return undefined;
64
+ },
65
+ } as unknown as Context;
66
+
67
+ I18nProvider({ c, children: "" });
68
+
69
+ return renderToString(
70
+ CollectionsPage({
71
+ items,
72
+ isAuthenticated,
73
+ navigationCollectionIds: [],
74
+ sitePathPrefix: "",
75
+ basePath: "",
76
+ siteOrigin: "",
77
+ }),
78
+ );
79
+ }
80
+
81
+ describe("collections directory rows", () => {
82
+ it("dates a smart collection row the way it dates a collection row", () => {
83
+ const html = render(false);
84
+
85
+ expect(html.match(/class="collection-directory-updated"/g)).toHaveLength(2);
86
+ expect(html).toContain(
87
+ new Date(COLLECTION_ACTIVITY_AT * 1000).toISOString(),
88
+ );
89
+ expect(html).toContain(new Date(SMART_ACTIVITY_AT * 1000).toISOString());
90
+ expect(html).toContain("5 threads");
91
+ });
92
+ });
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Collection links must keep the reader in the language view they are in.
3
+ *
4
+ * A collection page is served once per language (`langGet("/*")` in
5
+ * routes/pages/language.tsx), so a link from `/en/collections` that points at
6
+ * `/{slug}` does not just look odd — it silently moves the reader to the
7
+ * primary language's copy of that collection.
8
+ *
9
+ * The signed-in directory renders through `CollectionsManager`, the signed-out
10
+ * one directly through `CollectionDirectory`. Both are covered here because the
11
+ * bug this pins down existed in only one of them.
12
+ */
13
+
14
+ import type { Context } from "hono";
15
+ import { renderToString } from "hono/jsx/dom/server";
16
+ import { describe, expect, it } from "vitest";
17
+ import { I18nProvider } from "../../../i18n/context.js";
18
+ import { createI18n } from "../../../i18n/i18n.js";
19
+ import { CollectionsPage } from "../CollectionsPage.js";
20
+ import type { CollectionDirectoryItem } from "../../../types.js";
21
+
22
+ const items: CollectionDirectoryItem[] = [
23
+ {
24
+ id: "cdi_test00000000000000000000000",
25
+ type: "collection",
26
+ collection: {
27
+ id: "col_test000000000000000000000000",
28
+ siteId: "sit_test000000000000000000000000",
29
+ slug: "recipes",
30
+ title: "Recipes",
31
+ description: null,
32
+ sortOrder: "manual",
33
+ createdAt: 1_700_000_000,
34
+ updatedAt: 1_700_000_000,
35
+ threadCount: 2,
36
+ recentActivityAt: 1_700_000_000,
37
+ },
38
+ },
39
+ ];
40
+
41
+ function render(isAuthenticated: boolean, basePath: string) {
42
+ const i18n = createI18n("en");
43
+ const c = {
44
+ get(key: string) {
45
+ if (key === "i18n") return i18n;
46
+ return undefined;
47
+ },
48
+ } as unknown as Context;
49
+
50
+ I18nProvider({ c, children: "" });
51
+
52
+ return renderToString(
53
+ CollectionsPage({
54
+ items,
55
+ isAuthenticated,
56
+ navigationCollectionIds: [],
57
+ sitePathPrefix: "",
58
+ basePath,
59
+ siteOrigin: "",
60
+ }),
61
+ );
62
+ }
63
+
64
+ describe("collections directory links", () => {
65
+ it("keeps a signed-out reader inside the language view", () => {
66
+ const html = render(false, "/en");
67
+ expect(html).toContain('href="/en/recipes"');
68
+ expect(html).not.toContain('href="/recipes"');
69
+ });
70
+
71
+ it("keeps the signed-in author inside the language view", () => {
72
+ const html = render(true, "/en");
73
+ expect(html).toContain('href="/en/recipes"');
74
+ expect(html).not.toContain('href="/recipes"');
75
+ });
76
+
77
+ it("links at the root when there is no language view", () => {
78
+ expect(render(false, "")).toContain('href="/recipes"');
79
+ expect(render(true, "")).toContain('href="/recipes"');
80
+ });
81
+ });
@@ -2,7 +2,15 @@ import { msg } from "@lingui/core/macro";
2
2
  import type { FC } from "hono/jsx";
3
3
  import { useLingui } from "../../i18n/context.js";
4
4
  import type { CollectionDirectoryItem } from "../../types.js";
5
- import { getCollectionSelectionPath } from "../../lib/collection-paths.js";
5
+ import {
6
+ getCollectionPagePath,
7
+ getCollectionSelectionPath,
8
+ } from "../../lib/collection-paths.js";
9
+ import { getIconSvg } from "../../lib/icons.js";
10
+ import {
11
+ describeSmartCollection,
12
+ getSmartCollectionLabels,
13
+ } from "./smart-collection-labels.js";
6
14
  import { getDividerCollectionGroup } from "../../lib/collection-groups.js";
7
15
  import { render as renderMarkdown } from "../../lib/markdown.js";
8
16
  import { formatRelativeAge, toISOString } from "../../lib/time.js";
@@ -12,13 +20,35 @@ export interface CollectionDirectoryProps {
12
20
  items: CollectionDirectoryItem[];
13
21
  emptyMessage?: string;
14
22
  sitePathPrefix?: string;
23
+ /**
24
+ * Public path prefix for collection links, carrying the language prefix in a
25
+ * language view.
26
+ *
27
+ * Required rather than defaulting to `sitePathPrefix`: forgetting it does not
28
+ * fail, it silently links a reader in `/en` back into the primary view, which
29
+ * is a bug that looks like a working link. Pass `navData.basePath`.
30
+ */
31
+ basePath: string;
15
32
  siteOrigin?: string;
33
+ /**
34
+ * Whether to offer each collection's feed beside it.
35
+ *
36
+ * This is the only place a reader is handed a collection feed: `/subscribe`
37
+ * deliberately lists the site-wide feeds only, because wanting one collection
38
+ * and not the rest is a rare intent that would tax every reader's attention
39
+ * there. Here it sits next to the collection someone is already looking at.
40
+ *
41
+ * Defaults off so the dashboard's collection manager — where the point is
42
+ * organizing, not subscribing — does not grow feed icons by accident.
43
+ */
44
+ feedsEnabled?: boolean;
16
45
  }
17
46
 
18
47
  const hasDirectoryContent = (items: CollectionDirectoryItem[]) =>
19
48
  items.some(
20
49
  (item) =>
21
50
  (item.type === "collection" && item.collection) ||
51
+ (item.type === "smart_collection" && item.smartCollection) ||
22
52
  (item.type === "link" && item.label && item.url),
23
53
  );
24
54
 
@@ -37,6 +67,7 @@ const hasDirectoryContent = (items: CollectionDirectoryItem[]) =>
37
67
  const computeSequenceLabels = (items: CollectionDirectoryItem[]): string[] => {
38
68
  const isContentItem = (item: CollectionDirectoryItem) =>
39
69
  (item.type === "collection" && item.collection) ||
70
+ (item.type === "smart_collection" && item.smartCollection) ||
40
71
  (item.type === "link" && item.label && item.url);
41
72
 
42
73
  // First pass: determine group sizes.
@@ -109,13 +140,43 @@ const computeSequenceLabels = (items: CollectionDirectoryItem[]): string[] => {
109
140
  return labels;
110
141
  };
111
142
 
143
+ /**
144
+ * The feed icon that rides a directory row's summary line.
145
+ *
146
+ * Same affordance as the one on the collection's own page and on the archive:
147
+ * small, quiet, and recognizable to a reader who knows what it means. Readers
148
+ * who do not are served by /subscribe instead, which is why collection feeds
149
+ * are not listed there.
150
+ *
151
+ * @param props - Where the feed lives and what to call it
152
+ * @returns The icon link, or nothing when feeds are switched off
153
+ */
154
+ const CollectionFeedLink: FC<{
155
+ href: string | null;
156
+ title: string;
157
+ }> = ({ href, title }) =>
158
+ href ? (
159
+ <a href={href} class="feed-link" title={title} rel="noopener noreferrer">
160
+ <span dangerouslySetInnerHTML={{ __html: getIconSvg("rss") ?? "" }} />
161
+ </a>
162
+ ) : null;
163
+
112
164
  export const CollectionDirectory: FC<CollectionDirectoryProps> = ({
113
165
  items,
114
166
  emptyMessage,
115
167
  sitePathPrefix = "",
168
+ basePath,
116
169
  siteOrigin = "",
170
+ feedsEnabled = false,
117
171
  }) => {
118
172
  const { i18n } = useLingui();
173
+ const feedTitle = i18n._(
174
+ msg({
175
+ message: "RSS feed",
176
+ comment:
177
+ "@context: Tooltip for the RSS feed icon beside a collection in the collections directory",
178
+ }),
179
+ );
119
180
 
120
181
  if (!hasDirectoryContent(items)) {
121
182
  return (
@@ -152,7 +213,7 @@ export const CollectionDirectory: FC<CollectionDirectoryProps> = ({
152
213
  <a
153
214
  href={toPublicPath(
154
215
  getCollectionSelectionPath(group.slugExpression),
155
- sitePathPrefix,
216
+ basePath,
156
217
  )}
157
218
  class="collection-directory-divider-link collection-directory-divider-text"
158
219
  >
@@ -254,6 +315,112 @@ export const CollectionDirectory: FC<CollectionDirectoryProps> = ({
254
315
  );
255
316
  }
256
317
 
318
+ if (item.type === "smart_collection") {
319
+ const smartCollection = item.smartCollection;
320
+ if (!smartCollection) return null;
321
+ const sequence = sequenceLabels[index];
322
+ // The full sentence is the marker's tooltip; its accessible name is
323
+ // just the kind, because the marker sits inside the title link and a
324
+ // whole sentence there would swamp the link's name.
325
+ const conditions = describeSmartCollection(
326
+ smartCollection.selection,
327
+ i18n,
328
+ );
329
+ const smartCollectionNoun = getSmartCollectionLabels(i18n).noun;
330
+
331
+ return (
332
+ <div
333
+ key={item.id}
334
+ class="collection-directory-item"
335
+ data-smart-collection={smartCollection.slug}
336
+ >
337
+ <div class="collection-directory-main">
338
+ <span class="collection-directory-sequence" aria-hidden="true">
339
+ {sequence}
340
+ </span>
341
+ <div class="collection-directory-title-row">
342
+ <a
343
+ href={toPublicPath(
344
+ getCollectionPagePath(smartCollection.slug),
345
+ basePath,
346
+ )}
347
+ class="collection-directory-title-link"
348
+ >
349
+ <span class="collection-directory-title">
350
+ {smartCollection.title}
351
+ {/* Shown to every reader, not only the author: which
352
+ kind of collection this is changes how the list
353
+ reads. It goes in the same slot the link rows put
354
+ their marker in, which centres it on the title
355
+ without any arithmetic. */}
356
+ <span
357
+ class="collection-directory-smart-icon"
358
+ title={conditions}
359
+ aria-label={smartCollectionNoun}
360
+ role="img"
361
+ dangerouslySetInnerHTML={{
362
+ __html: getIconSvg("funnel", "icon-fine") ?? "",
363
+ }}
364
+ />
365
+ </span>
366
+ </a>
367
+ </div>
368
+ {smartCollection.description && (
369
+ <div
370
+ class="collection-directory-description prose"
371
+ dangerouslySetInnerHTML={{
372
+ __html: renderMarkdown(smartCollection.description, {
373
+ namespace: smartCollection.id,
374
+ }),
375
+ }}
376
+ />
377
+ )}
378
+ <p class="collection-directory-summary">
379
+ <span class="collection-directory-meta">
380
+ {smartCollection.threadCount}{" "}
381
+ {smartCollection.threadCount === 1
382
+ ? i18n._(
383
+ msg({
384
+ message: "thread",
385
+ comment: "@context: Singular thread count label",
386
+ }),
387
+ )
388
+ : i18n._(
389
+ msg({
390
+ message: "threads",
391
+ comment: "@context: Plural thread count label",
392
+ }),
393
+ )}
394
+ </span>
395
+ {/* The same two measures a collection row carries. The
396
+ conditions are what differs between the two kinds; how
397
+ full and how fresh the result is is not. */}
398
+ <span
399
+ class="collection-directory-meta-separator"
400
+ aria-hidden="true"
401
+ >
402
+ /
403
+ </span>
404
+ <time
405
+ class="collection-directory-updated"
406
+ dateTime={toISOString(smartCollection.recentActivityAt)}
407
+ >
408
+ {formatRelativeAge(smartCollection.recentActivityAt)}
409
+ </time>
410
+ <CollectionFeedLink
411
+ href={
412
+ feedsEnabled
413
+ ? `${toPublicPath(getCollectionPagePath(smartCollection.slug), basePath)}/feed`
414
+ : null
415
+ }
416
+ title={feedTitle}
417
+ />
418
+ </p>
419
+ </div>
420
+ </div>
421
+ );
422
+ }
423
+
257
424
  const collection = item.collection;
258
425
  if (!collection) return null;
259
426
  const sequence = sequenceLabels[index];
@@ -268,7 +435,7 @@ export const CollectionDirectory: FC<CollectionDirectoryProps> = ({
268
435
  <a
269
436
  href={toPublicPath(
270
437
  getCollectionSelectionPath(collection.slug),
271
- sitePathPrefix,
438
+ basePath,
272
439
  )}
273
440
  class="collection-directory-title-link"
274
441
  >
@@ -316,6 +483,14 @@ export const CollectionDirectory: FC<CollectionDirectoryProps> = ({
316
483
  >
317
484
  {formatRelativeAge(collection.recentActivityAt)}
318
485
  </time>
486
+ <CollectionFeedLink
487
+ href={
488
+ feedsEnabled
489
+ ? `${toPublicPath(getCollectionSelectionPath(collection.slug), basePath)}/feed`
490
+ : null
491
+ }
492
+ title={feedTitle}
493
+ />
319
494
  </p>
320
495
  </div>
321
496
  </div>