@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
@@ -2,21 +2,32 @@ 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 {
6
- getCollectionsDirectoryPath,
7
- getNewCollectionPath,
8
- } from "../../lib/collection-paths.js";
9
- import { toPublicPath } from "../../lib/url.js";
10
5
  import { CollectionDirectory } from "./CollectionDirectory.js";
11
- import { getCollectionMutationLabels } from "./collection-management-labels.js";
6
+ import {
7
+ getCollectionDialogLabels,
8
+ getCollectionMutationLabels,
9
+ } from "./collection-management-labels.js";
10
+ import {
11
+ getSmartCollectionDialogLabels,
12
+ getSmartCollectionLabels,
13
+ } from "./smart-collection-labels.js";
14
+ import { getIconSvg } from "../../lib/icons.js";
12
15
 
13
16
  const escapeJson = (data: unknown) =>
14
17
  JSON.stringify(data).replace(/</g, "\\u003c");
15
18
 
16
19
  export interface CollectionsManagerProps {
17
20
  items: CollectionDirectoryItem[];
21
+ /** Collections and smart collections already in the site navigation. */
18
22
  navigationCollectionIds?: string[];
19
23
  sitePathPrefix?: string;
24
+ /**
25
+ * Site path prefix plus the language prefix of the view being rendered.
26
+ * Collection pages exist once per language, so a reader on `/en/collections`
27
+ * must land on `/en/{slug}` rather than being dropped into the primary view.
28
+ * Required for the same reason as on {@link CollectionDirectoryProps}.
29
+ */
30
+ basePath: string;
20
31
  siteOrigin?: string;
21
32
  }
22
33
 
@@ -24,19 +35,13 @@ export const CollectionsManager: FC<CollectionsManagerProps> = ({
24
35
  items,
25
36
  navigationCollectionIds = [],
26
37
  sitePathPrefix = "",
38
+ basePath,
27
39
  siteOrigin = "",
28
40
  }) => {
29
41
  const { i18n } = useLingui();
30
- const collectionsHref = toPublicPath(
31
- getCollectionsDirectoryPath(),
32
- sitePathPrefix,
33
- );
34
- const newCollectionHref = toPublicPath(
35
- `${getNewCollectionPath()}?returnTo=${encodeURIComponent(collectionsHref)}`,
36
- sitePathPrefix,
37
- );
38
42
  const mutationLabels = getCollectionMutationLabels(i18n);
39
43
 
44
+ const smartLabels = getSmartCollectionLabels(i18n);
40
45
  const labels = {
41
46
  collectionsTitle: i18n._(
42
47
  msg({
@@ -44,6 +49,17 @@ export const CollectionsManager: FC<CollectionsManagerProps> = ({
44
49
  comment: "@context: Collections page heading",
45
50
  }),
46
51
  ),
52
+ smartCollectionNoun: smartLabels.noun,
53
+ newSmartCollection: smartLabels.newSmartCollection,
54
+ editSmartCollection: smartLabels.editSmartCollection,
55
+ deleteSmartCollection: smartLabels.deleteSmartCollection,
56
+ confirmDeleteSmartCollection: smartLabels.confirmDelete,
57
+ smartCollectionDeleted: i18n._(
58
+ msg({
59
+ message: "Smart collection deleted.",
60
+ comment: "@context: Confirmation after deleting a smart collection",
61
+ }),
62
+ ),
47
63
  organize: i18n._(
48
64
  msg({
49
65
  message: "Organize",
@@ -169,11 +185,12 @@ export const CollectionsManager: FC<CollectionsManagerProps> = ({
169
185
  class="collections-page-action-group"
170
186
  data-collections-toolbar
171
187
  >
172
- <a
173
- href={newCollectionHref}
188
+ <button
189
+ type="button"
174
190
  class="collections-page-toolbar-button"
175
191
  aria-label={labels.newCollection}
176
192
  title={labels.newCollection}
193
+ data-collections-action="collection"
177
194
  >
178
195
  <svg
179
196
  xmlns="http://www.w3.org/2000/svg"
@@ -190,7 +207,7 @@ export const CollectionsManager: FC<CollectionsManagerProps> = ({
190
207
  <path d="M12 5v14" />
191
208
  <path d="M5 12h14" />
192
209
  </svg>
193
- </a>
210
+ </button>
194
211
  <div class="relative">
195
212
  <button
196
213
  type="button"
@@ -250,6 +267,29 @@ export const CollectionsManager: FC<CollectionsManagerProps> = ({
250
267
  {labels.organize}
251
268
  </span>
252
269
  </button>
270
+ {/* Beside Add link and Add divider, not a second `+`:
271
+ the `+` is where a collection is created, and two plus
272
+ signs would immediately raise "which plus". */}
273
+ <button
274
+ type="button"
275
+ class="collections-page-menu-item"
276
+ data-collections-action="smart-collection"
277
+ >
278
+ {/* Full stroke, unlike the funnel that marks a smart
279
+ collection beside its title: here it sits among
280
+ menu icons drawn at the same weight, and `icon-fine`
281
+ would single it out as the lighter one. */}
282
+ <span
283
+ class="collections-page-menu-item-icon"
284
+ aria-hidden="true"
285
+ dangerouslySetInnerHTML={{
286
+ __html: getIconSvg("funnel") ?? "",
287
+ }}
288
+ />
289
+ <span class="collections-page-menu-item-label">
290
+ {smartLabels.newSmartCollection}
291
+ </span>
292
+ </button>
253
293
  <button
254
294
  type="button"
255
295
  class="collections-page-menu-item"
@@ -318,6 +358,18 @@ export const CollectionsManager: FC<CollectionsManagerProps> = ({
318
358
  </div>
319
359
  </header>
320
360
 
361
+ {/* The dialog is a Lit component and cannot reach the i18n catalogs, so
362
+ the page that can open it carries its strings. */}
363
+ <div
364
+ hidden
365
+ data-collection-dialog-labels={escapeJson(
366
+ getCollectionDialogLabels(i18n),
367
+ )}
368
+ data-smart-collection-dialog-labels={escapeJson(
369
+ getSmartCollectionDialogLabels(i18n),
370
+ )}
371
+ />
372
+
321
373
  <jant-collections-manager
322
374
  items={escapeJson(items)}
323
375
  labels={escapeJson(labels)}
@@ -327,6 +379,7 @@ export const CollectionsManager: FC<CollectionsManagerProps> = ({
327
379
  items={items}
328
380
  emptyMessage={labels.emptyState}
329
381
  sitePathPrefix={sitePathPrefix}
382
+ basePath={basePath}
330
383
  siteOrigin={siteOrigin}
331
384
  />
332
385
  </jant-collections-manager>
@@ -0,0 +1,127 @@
1
+ import type { FC } from "hono/jsx";
2
+ import { getIconSvg } from "../../lib/icons.js";
3
+ import {
4
+ COPY_FIELD_BUTTON_ATTR,
5
+ COPY_FIELD_BUTTON_CLASS,
6
+ COPY_FIELD_CLASS,
7
+ COPY_FIELD_CONTROL_CLASS,
8
+ COPY_FIELD_FAILED_ATTR,
9
+ COPY_FIELD_INPUT_CLASS,
10
+ COPY_FIELD_VALUE_ATTR,
11
+ } from "../../lib/copy-field.js";
12
+
13
+ export interface CopyFieldProps {
14
+ /** Field label, also the input's accessible name. */
15
+ label: string;
16
+ /**
17
+ * Optional page this address is the feed of, linked from the label.
18
+ *
19
+ * A feed lives at `{page}/feed`, so every address here has a page whose
20
+ * contents it carries. Linking the label lets a reader look at those posts
21
+ * before subscribing — the page is the only honest preview of the feed.
22
+ * Omitted (settings, where the author already knows the site) the label
23
+ * stays plain text.
24
+ */
25
+ labelHref?: string;
26
+ /**
27
+ * Tooltip for the linked label, saying what following it does. The link text
28
+ * is the feed's name ("Latest", "All"), which alone does not tell a reader
29
+ * that it leads to a page — and `title` supplements the accessible name
30
+ * rather than replacing it, so the visible text still wins.
31
+ */
32
+ labelTitle?: string;
33
+ /** The address to show and copy. Absolute — it is meant to leave this page. */
34
+ value: string;
35
+ /** Optional line under the label saying what this address gives you. */
36
+ description?: string;
37
+ /** Text on the copy button. */
38
+ copyLabel: string;
39
+ /** Toast shown after a successful copy. */
40
+ copiedMessage: string;
41
+ /** Toast shown when the clipboard is unavailable or refuses. */
42
+ failedMessage: string;
43
+ }
44
+
45
+ /**
46
+ * A read-only address with a copy button.
47
+ *
48
+ * Rendered whole on the server: the address is the content of the page this
49
+ * appears on, so it must not wait for JavaScript. `client/copy-field.ts` adds
50
+ * the copying and reveals the button, which ships hidden so that without the
51
+ * script the field degrades to an input the reader can select by hand rather
52
+ * than a button that does nothing.
53
+ *
54
+ * @param props - Label, address, and the strings the client needs for toasts
55
+ * @returns The field markup
56
+ * @example
57
+ * <CopyField
58
+ * label="Main feed"
59
+ * value="https://example.com/feed"
60
+ * copyLabel="Copy"
61
+ * copiedMessage="Feed URL copied."
62
+ * failedMessage="Could not copy. Select the address and copy it."
63
+ * />
64
+ */
65
+ export const CopyField: FC<CopyFieldProps> = ({
66
+ label,
67
+ labelHref,
68
+ labelTitle,
69
+ value,
70
+ description,
71
+ copyLabel,
72
+ copiedMessage,
73
+ failedMessage,
74
+ }) => (
75
+ <div class={COPY_FIELD_CLASS} data-copy-field-root>
76
+ <p class="text-sm font-medium">
77
+ {labelHref ? (
78
+ // The affordance is the arrow, not an underline. Three underlined
79
+ // labels stacked over three inputs read as a mis-styled form, and the
80
+ // rule under a two-word label is heavy enough to compete with the
81
+ // address it introduces. An arrow is visible at rest — which the
82
+ // underline was there to be — and says where the link goes besides.
83
+ // The underline still arrives on hover, where it costs nothing.
84
+ <a
85
+ href={labelHref}
86
+ title={labelTitle}
87
+ class="group inline-flex items-center gap-1 underline-offset-4 hover:underline focus-visible:underline"
88
+ >
89
+ {label}
90
+ <span
91
+ class="text-muted-foreground transition-transform duration-150 group-hover:translate-x-0.5 group-focus-visible:translate-x-0.5"
92
+ aria-hidden="true"
93
+ dangerouslySetInnerHTML={{
94
+ __html: getIconSvg("arrow-right", "size-3.5 icon-fine") ?? "",
95
+ }}
96
+ />
97
+ </a>
98
+ ) : (
99
+ label
100
+ )}
101
+ </p>
102
+ {description ? (
103
+ <p class="text-sm text-muted-foreground">{description}</p>
104
+ ) : null}
105
+ <div class={COPY_FIELD_CONTROL_CLASS}>
106
+ <input
107
+ type="text"
108
+ class={COPY_FIELD_INPUT_CLASS}
109
+ value={value}
110
+ readonly
111
+ aria-label={label}
112
+ {...{ [COPY_FIELD_VALUE_ATTR]: "" }}
113
+ />
114
+ <button
115
+ type="button"
116
+ class={COPY_FIELD_BUTTON_CLASS}
117
+ hidden
118
+ {...{
119
+ [COPY_FIELD_BUTTON_ATTR]: copiedMessage,
120
+ [COPY_FIELD_FAILED_ATTR]: failedMessage,
121
+ }}
122
+ >
123
+ {copyLabel}
124
+ </button>
125
+ </div>
126
+ </div>
127
+ );
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Empty Post Content
3
+ *
4
+ * The microformats floor for a card that renders neither a name nor any
5
+ * content of its own.
6
+ *
7
+ * A microformats2 parser invents an h-entry's `name` from its text content
8
+ * whenever the entry carries no explicit `p-name`, no `e-*` property, and no
9
+ * nested microformat. The author's card ships every `PostStatusBadges` badge in
10
+ * the DOM and hides the inapplicable ones with CSS — which a parser does not
11
+ * run — so a card with nothing else in it publishes a title of
12
+ * "PinnedPinnedPrivateDraft" followed by its own date.
13
+ *
14
+ * An explicit empty `e-content` states what is true, that this entry has no
15
+ * text content, and that is enough to stop the guess.
16
+ *
17
+ * It carries neither `prose` nor `data-post-body` on purpose: the body-width
18
+ * CSS and the client scripts that look up a post's body must not see it.
19
+ */
20
+
21
+ import type { FC } from "hono/jsx";
22
+
23
+ export const EmptyPostContent: FC = () => <div class="e-content"></div>;
@@ -4,6 +4,18 @@
4
4
  * Renders media attachments in a unified horizontal row: images with
5
5
  * lightbox support, videos with play overlay, audio/documents as 3:4
6
6
  * styled card tiles, and attached texts as summary cards.
7
+ *
8
+ * Attachments sit beside `e-content`, not inside it, so a consumer reading
9
+ * the h-entry would otherwise see a post with no media at all. The visual
10
+ * ones carry `u-photo` / `u-video` / `u-audio` — the standard microformats2
11
+ * properties, the same ones W3C Post Type Discovery reads — so what the
12
+ * timeline shows is what a parser gets. Placement follows the mf2 implied
13
+ * value rules: on the element whose own attribute already holds the URL,
14
+ * never on a wrapper that would resolve to something else.
15
+ *
16
+ * Documents and attached texts stay unmarked. There is no standard property
17
+ * for them, and inventing one would put a private vocabulary in public
18
+ * markup.
7
19
  */
8
20
 
9
21
  import type { FC } from "hono/jsx";
@@ -63,8 +75,73 @@ export function getMediaPlaceholderDataUrl(
63
75
  return blurhashToDataUrl(blurhash, decodeSize.width, decodeSize.height);
64
76
  }
65
77
 
78
+ /**
79
+ * Width for a post's only visual: its natural shape, capped at 24rem tall and
80
+ * never wider than the reading column the post's own text gets.
81
+ *
82
+ * The cap is `--layout-reading-width`, not `--layout-content-width` — below
83
+ * 1024px the latter flattens to 100% and a wide picture would outgrow the body
84
+ * copy it sits under. See the token comments in `styles/tokens.css`.
85
+ *
86
+ * @param ratio - Aspect ratio (width / height) from `getMediaAspectRatio`.
87
+ * @returns A CSS `width` value.
88
+ * @example
89
+ * getSingleVisualWidth(16 / 9);
90
+ * // "min(100%, calc(24rem * 1.777...), var(--layout-reading-width))"
91
+ */
66
92
  function getSingleVisualWidth(ratio: number): string {
67
- return `min(100%, calc(24rem * ${ratio}), var(--layout-content-width))`;
93
+ return `min(100%, calc(24rem * ${ratio}), var(--layout-reading-width))`;
94
+ }
95
+
96
+ /** Shortest a gallery strip gets, in CSS pixels. */
97
+ const ROW_MIN = 240;
98
+ /** Tallest a gallery strip gets — the 24rem cap a lone visual already has. */
99
+ const ROW_MAX = 384;
100
+ /** No single item in a strip is wider than this, in CSS pixels. */
101
+ const ITEM_MAX_WIDTH = 520;
102
+ /** Width the opening visual is measured at when it sets the row height. */
103
+ const NOMINAL_ITEM_WIDTH = 320;
104
+
105
+ /**
106
+ * Shared height for a multi-item gallery strip.
107
+ *
108
+ * Every visual in a strip gets this height and a width of
109
+ * `rowHeight * its own aspect ratio`, so one number decides the whole row.
110
+ * Three rules pick it, in order:
111
+ *
112
+ * 1. **The opener leads.** `NOMINAL_ITEM_WIDTH / leadRatio` is the height the
113
+ * first visual would have at a nominal 320px width, so a portrait opener
114
+ * earns a taller row than a landscape one.
115
+ * 2. **Nothing outgrows the strip.** The widest visual is held to
116
+ * `ITEM_MAX_WIDTH`, which stops a 16:9 video following a portrait picture
117
+ * from swallowing the whole content column.
118
+ * 3. **One band.** `ROW_MAX` matches the cap a lone visual gets on its own, so
119
+ * a second attachment never makes the first picture grow.
120
+ *
121
+ * Non-visual attachments stay out of this deliberately: a text file beside a
122
+ * picture is no reason to resize the picture. Their cards are sized *from* the
123
+ * result instead.
124
+ *
125
+ * @param visualRatios - Aspect ratios of the strip's images and videos in
126
+ * display order, each from {@link getMediaAspectRatio} so every value is
127
+ * finite and positive. Empty when the strip holds no visuals at all.
128
+ * @returns Row height in CSS pixels.
129
+ * @example
130
+ * getGalleryRowHeight([0.53, 1.78]); // 292 - portrait opener, then a 16:9 video
131
+ * getGalleryRowHeight([0.53, 0.53]); // 384 - two portraits
132
+ * getGalleryRowHeight([1.78, 1.78]); // 240 - two landscapes
133
+ * getGalleryRowHeight([]); // 240 - documents only
134
+ */
135
+ export function getGalleryRowHeight(visualRatios: number[]): number {
136
+ const leadRatio = visualRatios[0];
137
+ if (leadRatio === undefined) return ROW_MIN;
138
+
139
+ const byLead = NOMINAL_ITEM_WIDTH / leadRatio;
140
+ const byWidestItem = ITEM_MAX_WIDTH / Math.max(...visualRatios);
141
+
142
+ return Math.round(
143
+ Math.min(ROW_MAX, Math.max(ROW_MIN, Math.min(byLead, byWidestItem))),
144
+ );
68
145
  }
69
146
 
70
147
  /**
@@ -188,18 +265,20 @@ const FileIcon = ({
188
265
  );
189
266
  };
190
267
 
268
+ /*
269
+ * No `h-*` Tailwind utility belongs on anything inside a post: a microformats2
270
+ * parser treats every `h-`-prefixed class as a microformat root, so `h-auto` on
271
+ * a single image published a phantom `h-auto` child microformat — carrying the
272
+ * thumbnail as its `photo` — on the h-entry of every post with a picture.
273
+ * Nothing was lost by dropping it: Preflight already declares
274
+ * `img, video { max-width: 100%; height: auto }`.
275
+ */
191
276
  export const MediaGallery: FC<MediaGalleryProps> = ({
192
277
  attachments,
193
278
  postPermalink,
194
279
  }) => {
195
280
  if (attachments.length === 0) return null;
196
281
 
197
- // Category checks for layout decisions
198
- const hasNonVisualAttachment = attachments.some((a) => {
199
- const cat = getMediaCategory(a.mimeType);
200
- return cat !== "image" && cat !== "video";
201
- });
202
-
203
282
  // Build lightbox group from images + videos in position order
204
283
  // (documents/texts don't use lightbox)
205
284
  const lightboxItems = attachments
@@ -251,28 +330,13 @@ export const MediaGallery: FC<MediaGalleryProps> = ({
251
330
  firstItem !== undefined &&
252
331
  (firstItem._kind === "image" || firstItem._kind === "video");
253
332
 
254
- // When non-visual attachments are mixed with visuals, use a compact row
255
- const hasNonVisual = hasNonVisualAttachment;
256
- const COMPACT_HEIGHT = 220;
257
-
258
- // Row height adapts to the first visual item's aspect ratio
259
- const ROW_MIN = hasNonVisual ? 220 : 280;
260
- const ROW_MAX = hasNonVisual ? 300 : 440;
261
- let rowHeight = hasNonVisual ? COMPACT_HEIGHT : 360;
262
- if (!singleVisual && galleryItems.length > 1) {
263
- const firstVisual = galleryItems.find(
264
- (item) => item._kind === "image" || item._kind === "video",
265
- );
266
- if (firstVisual && "width" in firstVisual && "height" in firstVisual) {
267
- const firstRatio = getMediaAspectRatio(
268
- firstVisual.width,
269
- firstVisual.height,
270
- );
271
- rowHeight = Math.round(
272
- Math.min(ROW_MAX, Math.max(ROW_MIN, 320 / Math.max(firstRatio, 0.5))),
273
- );
274
- }
275
- }
333
+ // Strip geometry. A lone visual never reads this — it keeps its natural
334
+ // ratio — but every other shape does, cards included.
335
+ const rowHeight = getGalleryRowHeight(
336
+ galleryItems
337
+ .filter((item) => item._kind === "image" || item._kind === "video")
338
+ .map((item) => getMediaAspectRatio(item.width, item.height)),
339
+ );
276
340
 
277
341
  // Document card: 3:4 portrait, same height as row
278
342
  const DOC_RATIO = 3 / 4;
@@ -290,7 +354,7 @@ export const MediaGallery: FC<MediaGalleryProps> = ({
290
354
  ? JSON.stringify(lightboxItems)
291
355
  : undefined
292
356
  }
293
- class={`flex gap-2 ${singleVisual ? "" : "overflow-x-auto scroll-smooth"}`}
357
+ class={`flex gap-3 ${singleVisual ? "" : "overflow-x-auto scroll-smooth"}`}
294
358
  style={
295
359
  singleVisual
296
360
  ? undefined
@@ -349,11 +413,17 @@ export const MediaGallery: FC<MediaGalleryProps> = ({
349
413
  width={item.width}
350
414
  height={item.height}
351
415
  style={imageStyle}
352
- class={
416
+ /* `u-photo` here rather than on the enclosing link:
417
+ `img.u-photo` resolves to `src` and carries `alt`
418
+ with it, so a consumer gets the same thumbnail the
419
+ timeline shows and the text describing it. The link
420
+ wraps the full-size original, which is a click
421
+ target rather than the post's photo. */
422
+ class={`u-photo ${
353
423
  singleVisual
354
- ? "media-visual w-full h-auto rounded-lg"
424
+ ? "media-visual w-full rounded-lg"
355
425
  : "media-visual w-full object-cover"
356
- }
426
+ }`}
357
427
  loading="lazy"
358
428
  decoding="async"
359
429
  />
@@ -374,6 +444,19 @@ export const MediaGallery: FC<MediaGalleryProps> = ({
374
444
  ? undefined
375
445
  : `${Math.round(Math.max(160, rowHeight * ratio))}px`;
376
446
  const posterSrc = item.posterUrl || placeholder;
447
+ /* `video.u-photo` resolves to `poster`, which is the still
448
+ the timeline already shows. Only a real poster qualifies:
449
+ the blurhash fallback is a data URL, and publishing one as
450
+ a photo would hand a consumer a 20-pixel smear. */
451
+ const posterClass = item.posterUrl ? "u-photo " : "";
452
+ /* Fit travels with the size, and `site-media.css` sets none.
453
+ In a row the clip is cropped to the strip's height like the
454
+ pictures beside it; alone its box already has the clip's
455
+ shape, and `contain` letterboxes frames that disagree with
456
+ the stored size. */
457
+ const videoClass = `${posterClass}media-visual w-full ${
458
+ singleVisual ? "object-contain" : "object-cover"
459
+ }`;
377
460
  const aspectRatio =
378
461
  item.width && item.height
379
462
  ? `${item.width}/${item.height}`
@@ -408,7 +491,7 @@ export const MediaGallery: FC<MediaGalleryProps> = ({
408
491
  <a
409
492
  href={item.url}
410
493
  data-lightbox-index={item._lbIdx}
411
- class="media-visual-frame media-video-link"
494
+ class="u-video media-visual-frame media-video-link"
412
495
  >
413
496
  <video
414
497
  preload="none"
@@ -422,11 +505,7 @@ export const MediaGallery: FC<MediaGalleryProps> = ({
422
505
  data-video-src={item.url}
423
506
  data-feed-video-id={item.id}
424
507
  style={videoStyle}
425
- class={
426
- singleVisual
427
- ? "media-visual w-full h-auto"
428
- : "media-visual w-full object-cover"
429
- }
508
+ class={videoClass}
430
509
  />
431
510
  </a>
432
511
  <button
@@ -468,7 +547,7 @@ export const MediaGallery: FC<MediaGalleryProps> = ({
468
547
  key={item.id}
469
548
  href={item.url}
470
549
  data-lightbox-index={item._lbIdx}
471
- class={`${singleVisual ? "" : "shrink-0"} media-video-wrap media-visual-frame`}
550
+ class={`u-video ${singleVisual ? "" : "shrink-0"} media-video-wrap media-visual-frame`}
472
551
  style={
473
552
  singleVisual
474
553
  ? {
@@ -486,11 +565,7 @@ export const MediaGallery: FC<MediaGalleryProps> = ({
486
565
  width={item.width}
487
566
  height={item.height}
488
567
  style={videoStyle}
489
- class={
490
- singleVisual
491
- ? "media-visual w-full h-auto"
492
- : "media-visual w-full object-cover"
493
- }
568
+ class={videoClass}
494
569
  />
495
570
  <div class="media-video-play-overlay">
496
571
  <svg viewBox="0 0 24 24" fill="white">
@@ -514,7 +589,11 @@ export const MediaGallery: FC<MediaGalleryProps> = ({
514
589
  >
515
590
  {/* Hidden audio element — JS controls it */}
516
591
  <audio preload="none" class="media-audio-el">
517
- <source src={item.url} type={item.mimeType} />
592
+ <source
593
+ class="u-audio"
594
+ src={item.url}
595
+ type={item.mimeType}
596
+ />
518
597
  </audio>
519
598
 
520
599
  {/* Artwork area */}
@@ -8,6 +8,7 @@ import { msg } from "@lingui/core/macro";
8
8
  import type { FC } from "hono/jsx";
9
9
  import { useLingui } from "../../i18n/context.js";
10
10
  import { formatPageLabel, getPageNumbers } from "../../lib/pagination.js";
11
+ import { Icon } from "./Icon.js";
11
12
 
12
13
  export interface PaginationProps {
13
14
  /** Base URL for pagination links (e.g., "/archive", "/search?q=test") */
@@ -183,10 +184,24 @@ export const PagePagination: FC<PagePaginationProps> = ({
183
184
  // Numbered pagination when totalPages is known
184
185
  if (totalPages && totalPages > 1) {
185
186
  const pageNumbers = getPageNumbers(currentPage, totalPages);
187
+ // The icon shows only where the bar is too narrow for the words, and the
188
+ // word then stays as the link's accessible name (see `.page-pagination`).
189
+ const prevContent = (
190
+ <>
191
+ <Icon name="arrow-left" class="page-pagination-step-icon" />
192
+ <span class="page-pagination-step-label">{prevText}</span>
193
+ </>
194
+ );
195
+ const nextContent = (
196
+ <>
197
+ <span class="page-pagination-step-label">{nextText}</span>
198
+ <Icon name="arrow-right" class="page-pagination-step-icon" />
199
+ </>
200
+ );
186
201
 
187
202
  return (
188
203
  <nav
189
- class="flex items-center justify-center gap-4 py-6"
204
+ class="page-pagination flex flex-wrap items-center justify-start gap-4 py-6"
190
205
  aria-label="Pagination"
191
206
  >
192
207
  {hasPrev ? (
@@ -194,10 +209,10 @@ export const PagePagination: FC<PagePaginationProps> = ({
194
209
  href={buildUrl(currentPage - 1)}
195
210
  class="underline text-muted-foreground hover:text-foreground"
196
211
  >
197
- {prevText}
212
+ {prevContent}
198
213
  </a>
199
214
  ) : (
200
- <span class="text-muted-foreground/50">{prevText}</span>
215
+ <span class="text-muted-foreground/50">{prevContent}</span>
201
216
  )}
202
217
 
203
218
  {pageNumbers.map((page, i) =>
@@ -225,10 +240,10 @@ export const PagePagination: FC<PagePaginationProps> = ({
225
240
  href={buildUrl(currentPage + 1)}
226
241
  class="underline text-muted-foreground hover:text-foreground"
227
242
  >
228
- {nextText}
243
+ {nextContent}
229
244
  </a>
230
245
  ) : (
231
- <span class="text-muted-foreground/50">{nextText}</span>
246
+ <span class="text-muted-foreground/50">{nextContent}</span>
232
247
  )}
233
248
  </nav>
234
249
  );