@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
@@ -5,10 +5,19 @@
5
5
  * - Resizes oversized images (caps the short side; the long side rides free)
6
6
  * - Strips all metadata (privacy)
7
7
  * - Converts to WebP format (JPEG fallback when WebP encoding is unavailable)
8
+ * - Samples a blurhash placeholder from the decoded pixels
8
9
  *
9
- * EXIF orientation is handled automatically by the browser — modern
10
- * engines (Chrome 81+, Safari 13.1+, Firefox 93+) apply orientation
11
- * both in `<img>` rendering and in canvas `drawImage`.
10
+ * The work runs in a Web Worker (`image-worker.ts`) wherever the browser can
11
+ * decode and encode there — every engine with OffscreenCanvas. Decoding a
12
+ * photo and encoding the result are the heaviest things the composer does on
13
+ * the client; on the main thread they froze the page for their whole
14
+ * duration, and several photos at once froze it for seconds. Browsers without
15
+ * OffscreenCanvas, and any file the worker cannot decode, take the in-page
16
+ * canvas path below.
17
+ *
18
+ * EXIF orientation is handled by the browser on both paths — `<img>`, canvas
19
+ * `drawImage`, and `createImageBitmap` with `imageOrientation: "from-image"`
20
+ * all report and draw the oriented image.
12
21
  *
13
22
  * Long and wide screenshots (chat logs, articles, wide tables) lose their
14
23
  * text legibility if the short side is scaled down, so the resize step caps
@@ -17,25 +26,104 @@
17
26
  * untouched, so images of any length are supported.
18
27
  */
19
28
 
20
- /** Cap for the shorter image side — the side that determines text sharpness. */
21
- const MAX_SHORT_SIDE = 1920;
29
+ import { encode } from "blurhash";
30
+ import {
31
+ DEFAULT_IMAGE_PROCESS_OPTIONS,
32
+ blurhashDimensions,
33
+ planImageProcessing,
34
+ resolveEncodedMimeType,
35
+ type ImageProcessOptions,
36
+ type ImageWorkerRequest,
37
+ type ImageWorkerResponse,
38
+ } from "./image-plan.js";
39
+ import {
40
+ createImageWorker,
41
+ supportsImageWorker,
42
+ } from "./image-worker-client.js";
22
43
 
23
- /**
24
- * Largest long side we can still redraw on a canvas. A canvas bounded by
25
- * MAX_SHORT_SIDE × MAX_LONG_SIDE (1920 × 8192 ≈ 15.7M px) stays under the
26
- * ~16.7M-pixel area limit older mobile Safari enforces. Anything longer
27
- * can't be re-encoded, so it uploads as-is.
28
- */
29
- const MAX_LONG_SIDE = 8192;
44
+ type ProcessOptions = Partial<ImageProcessOptions>;
45
+
46
+ export interface ProcessResult {
47
+ blob: Blob;
48
+ width: number;
49
+ height: number;
50
+ /** False when `blob` is the untouched original (too large to re-encode). */
51
+ processed: boolean;
52
+ /** Absent only when the pixels could not be sampled. */
53
+ blurhash?: string;
54
+ }
55
+
56
+ export interface ProcessToFileResult {
57
+ file: File;
58
+ width: number;
59
+ height: number;
60
+ blurhash?: string;
61
+ }
62
+
63
+ // ── Worker path ──────────────────────────────────────────────────────────
64
+
65
+ interface PendingRequest {
66
+ resolve(result: ProcessResult): void;
67
+ reject(error: Error): void;
68
+ }
69
+
70
+ let worker: Worker | null = null;
71
+ let nextRequestId = 1;
72
+ const pending = new Map<number, PendingRequest>();
73
+
74
+ /** One worker for the page, started on the first image and kept warm. */
75
+ function getWorker(): Worker {
76
+ if (worker) return worker;
77
+ const created = createImageWorker();
78
+ created.addEventListener(
79
+ "message",
80
+ (event: MessageEvent<ImageWorkerResponse>) => {
81
+ const response = event.data;
82
+ const request = pending.get(response.id);
83
+ if (!request) return;
84
+ pending.delete(response.id);
85
+ if (response.ok) {
86
+ request.resolve({
87
+ blob: response.blob,
88
+ width: response.width,
89
+ height: response.height,
90
+ processed: response.processed,
91
+ blurhash: response.blurhash,
92
+ });
93
+ } else {
94
+ request.reject(new Error(response.error));
95
+ }
96
+ },
97
+ );
98
+ // A worker that dies takes every in-flight image with it. Fail them over to
99
+ // the in-page path and start clean for the next one.
100
+ created.addEventListener("error", (event) => {
101
+ if (worker !== created) return;
102
+ worker = null;
103
+ created.terminate();
104
+ const requests = [...pending.values()];
105
+ pending.clear();
106
+ const error = new Error(event.message || "Image worker failed");
107
+ for (const request of requests) request.reject(error);
108
+ });
109
+ worker = created;
110
+ return created;
111
+ }
30
112
 
31
- const DEFAULT_OPTIONS = {
32
- maxShortSide: MAX_SHORT_SIDE,
33
- maxLongSide: MAX_LONG_SIDE,
34
- quality: 0.85,
35
- mimeType: "image/webp" as const,
36
- };
113
+ function processInWorker(
114
+ file: File,
115
+ options: ImageProcessOptions,
116
+ ): Promise<ProcessResult> {
117
+ const target = getWorker();
118
+ return new Promise((resolve, reject) => {
119
+ const id = nextRequestId++;
120
+ pending.set(id, { resolve, reject });
121
+ const request: ImageWorkerRequest = { id, file, options };
122
+ target.postMessage(request);
123
+ });
124
+ }
37
125
 
38
- type ProcessOptions = Partial<typeof DEFAULT_OPTIONS>;
126
+ // ── In-page path ─────────────────────────────────────────────────────────
39
127
 
40
128
  /**
41
129
  * Load image from file
@@ -52,115 +140,73 @@ function loadImage(file: File): Promise<HTMLImageElement> {
52
140
  });
53
141
  }
54
142
 
55
- export interface ImageProcessPlan {
56
- /** When true, upload the original file untouched (too large to re-encode). */
57
- passthrough: boolean;
58
- /** Target dimensions — equal to the source dimensions when `passthrough`. */
59
- width: number;
60
- height: number;
61
- }
62
-
63
- /**
64
- * Decide how to handle an image given its source dimensions.
65
- *
66
- * - Long side over `maxLongSide` → `passthrough` (canvas can't redraw it).
67
- * - Short side within `maxShortSide` → keep dimensions, just re-encode.
68
- * - Otherwise → scale down so the short side hits `maxShortSide`.
69
- *
70
- * @param sourceWidth - Natural image width in pixels
71
- * @param sourceHeight - Natural image height in pixels
72
- * @param options - `maxShortSide` and `maxLongSide` caps
73
- * @returns The processing plan
74
- *
75
- * @example
76
- * ```ts
77
- * planImageProcessing(1080, 6000, { maxShortSide: 1920, maxLongSide: 8192 });
78
- * // { passthrough: false, width: 1080, height: 6000 }
79
- * ```
80
- */
81
- export function planImageProcessing(
82
- sourceWidth: number,
83
- sourceHeight: number,
84
- options: { maxShortSide: number; maxLongSide: number },
85
- ): ImageProcessPlan {
86
- const longSide = Math.max(sourceWidth, sourceHeight);
87
- if (longSide > options.maxLongSide) {
88
- return { passthrough: true, width: sourceWidth, height: sourceHeight };
89
- }
90
-
91
- const shortSide = Math.min(sourceWidth, sourceHeight);
92
- if (shortSide <= options.maxShortSide) {
93
- return { passthrough: false, width: sourceWidth, height: sourceHeight };
94
- }
95
-
96
- const scale = options.maxShortSide / shortSide;
97
- return {
98
- passthrough: false,
99
- width: Math.round(sourceWidth * scale),
100
- height: Math.round(sourceHeight * scale),
101
- };
143
+ function createCanvas(width: number, height: number) {
144
+ const canvas = document.createElement("canvas");
145
+ canvas.width = width;
146
+ canvas.height = height;
147
+ const ctx = canvas.getContext("2d");
148
+ if (!ctx) throw new Error("Failed to get canvas context");
149
+ return { canvas, ctx };
102
150
  }
103
151
 
104
- /**
105
- * Convert canvas to Blob, falling back to JPEG when the requested format
106
- * (typically WebP) is not supported by the browser (e.g. Safari).
107
- */
108
- async function canvasToBlob(
152
+ function canvasToBlob(
109
153
  canvas: HTMLCanvasElement,
110
- mimeType: string,
154
+ type: string,
111
155
  quality: number,
112
156
  ): Promise<Blob> {
113
- const blob = await new Promise<Blob>((resolve, reject) => {
157
+ return new Promise<Blob>((resolve, reject) => {
114
158
  canvas.toBlob(
115
159
  (b) => (b ? resolve(b) : reject(new Error("Failed to create blob"))),
116
- mimeType,
160
+ type,
117
161
  quality,
118
162
  );
119
163
  });
120
-
121
- // Browser silently falls back to PNG when it can't encode the requested
122
- // format. PNG ignores the quality parameter, producing oversized files.
123
- // Re-encode as JPEG instead so lossy compression still applies.
124
- if (mimeType !== "image/png" && blob.type === "image/png") {
125
- return new Promise<Blob>((resolve, reject) => {
126
- canvas.toBlob(
127
- (b) => (b ? resolve(b) : reject(new Error("Failed to create blob"))),
128
- "image/jpeg",
129
- quality,
130
- );
131
- });
132
- }
133
-
134
- return blob;
135
164
  }
136
165
 
137
- export interface ProcessResult {
138
- blob: Blob;
139
- width: number;
140
- height: number;
141
- /** False when `blob` is the untouched original (too large to re-encode). */
142
- processed: boolean;
166
+ const pageEncodeTypeByRequest = new Map<string, Promise<string>>();
167
+
168
+ /** The format `toBlob` really produces for `requested`, probed once. */
169
+ function resolveEncodeTypeOnPage(
170
+ requested: string,
171
+ quality: number,
172
+ ): Promise<string> {
173
+ let pending = pageEncodeTypeByRequest.get(requested);
174
+ if (!pending) {
175
+ pending = canvasToBlob(createCanvas(1, 1).canvas, requested, quality).then(
176
+ (probe) => resolveEncodedMimeType(requested, probe.type),
177
+ );
178
+ pageEncodeTypeByRequest.set(requested, pending);
179
+ }
180
+ return pending;
143
181
  }
144
182
 
145
- export interface ProcessToFileResult {
146
- file: File;
147
- width: number;
148
- height: number;
183
+ /** Best-effort: no blurhash beats no upload. */
184
+ function blurhashOf(img: HTMLImageElement): string | undefined {
185
+ try {
186
+ const { width, height } = blurhashDimensions(img.width, img.height);
187
+ const { ctx } = createCanvas(width, height);
188
+ ctx.drawImage(img, 0, 0, width, height);
189
+ return encode(
190
+ ctx.getImageData(0, 0, width, height).data,
191
+ width,
192
+ height,
193
+ 4,
194
+ 3,
195
+ );
196
+ } catch {
197
+ return undefined;
198
+ }
149
199
  }
150
200
 
151
- /**
152
- * Process image file
153
- */
154
- async function process(
201
+ async function processOnPage(
155
202
  file: File,
156
- options: ProcessOptions = {},
203
+ options: ImageProcessOptions,
157
204
  ): Promise<ProcessResult> {
158
- const opts = { ...DEFAULT_OPTIONS, ...options };
159
-
160
205
  const img = await loadImage(file);
161
206
 
162
207
  // img.width / img.height already reflect EXIF orientation in modern browsers
163
- const plan = planImageProcessing(img.width, img.height, opts);
208
+ const plan = planImageProcessing(img.width, img.height, options);
209
+ const blurhash = blurhashOf(img);
164
210
 
165
211
  // Too large to redraw on a canvas without crushing detail — keep the
166
212
  // original bytes so images of any length upload at full quality.
@@ -170,22 +216,48 @@ async function process(
170
216
  width: plan.width,
171
217
  height: plan.height,
172
218
  processed: false,
219
+ blurhash,
173
220
  };
174
221
  }
175
222
 
176
- const canvas = document.createElement("canvas");
177
- canvas.width = plan.width;
178
- canvas.height = plan.height;
179
-
180
- const ctx = canvas.getContext("2d");
181
- if (!ctx) throw new Error("Failed to get canvas context");
182
-
223
+ const { canvas, ctx } = createCanvas(plan.width, plan.height);
183
224
  // drawImage respects EXIF orientation — no manual rotation needed
184
225
  ctx.drawImage(img, 0, 0, plan.width, plan.height);
185
226
 
186
- const blob = await canvasToBlob(canvas, opts.mimeType, opts.quality);
227
+ const type = await resolveEncodeTypeOnPage(options.mimeType, options.quality);
228
+ const blob = await canvasToBlob(canvas, type, options.quality);
229
+
230
+ return {
231
+ blob,
232
+ width: plan.width,
233
+ height: plan.height,
234
+ processed: true,
235
+ blurhash,
236
+ };
237
+ }
238
+
239
+ // ── Facade ───────────────────────────────────────────────────────────────
187
240
 
188
- return { blob, width: plan.width, height: plan.height, processed: true };
241
+ /**
242
+ * Process image file
243
+ */
244
+ async function process(
245
+ file: File,
246
+ options: ProcessOptions = {},
247
+ ): Promise<ProcessResult> {
248
+ const opts = { ...DEFAULT_IMAGE_PROCESS_OPTIONS, ...options };
249
+
250
+ if (supportsImageWorker()) {
251
+ try {
252
+ return await processInWorker(file, opts);
253
+ } catch {
254
+ // The worker could not decode this file, or never started. `<img>`
255
+ // accepts a few things `createImageBitmap` does not, and if it fails
256
+ // too, that is the error worth reporting.
257
+ }
258
+ }
259
+
260
+ return processOnPage(file, opts);
189
261
  }
190
262
 
191
263
  /**
@@ -199,7 +271,12 @@ async function processToFile(
199
271
 
200
272
  // Original kept untouched — upload the file as-is.
201
273
  if (!result.processed) {
202
- return { file, width: result.width, height: result.height };
274
+ return {
275
+ file,
276
+ width: result.width,
277
+ height: result.height,
278
+ blurhash: result.blurhash,
279
+ };
203
280
  }
204
281
 
205
282
  // Use actual blob type — Safari falls back to JPEG when WebP encoding isn't supported
@@ -216,6 +293,7 @@ async function processToFile(
216
293
  file: new File([result.blob], newName, { type: result.blob.type }),
217
294
  width: result.width,
218
295
  height: result.height,
296
+ blurhash: result.blurhash,
219
297
  };
220
298
  }
221
299
 
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Where the image worker comes from.
3
+ *
4
+ * Kept apart from `ImageProcessor` so tests can stand in a fake worker, and so
5
+ * the `?worker&inline` import has exactly one home. Inline — Vite bundles the
6
+ * worker into the client script and starts it from a `blob:` URL — rather than
7
+ * a separate file, because client assets may be served from another origin
8
+ * (`ASSET_BASE_URL`) and a worker script must be same-origin with the page.
9
+ * CSP already admits `blob:` scripts for the media workers.
10
+ */
11
+
12
+ import ImageWorker from "./image-worker.js?worker&inline";
13
+
14
+ /**
15
+ * Whether this browser can decode and encode images inside a worker.
16
+ *
17
+ * @returns True when `Worker`, `OffscreenCanvas`, and `createImageBitmap` exist
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * if (supportsImageWorker()) worker = createImageWorker();
22
+ * ```
23
+ */
24
+ export function supportsImageWorker(): boolean {
25
+ return (
26
+ typeof Worker === "function" &&
27
+ typeof OffscreenCanvas === "function" &&
28
+ typeof createImageBitmap === "function"
29
+ );
30
+ }
31
+
32
+ /**
33
+ * Start a fresh image worker.
34
+ *
35
+ * @returns The worker, speaking the protocol in `image-plan.ts`
36
+ *
37
+ * @example
38
+ * ```ts
39
+ * const worker = createImageWorker();
40
+ * worker.postMessage({ id: 1, file, options });
41
+ * ```
42
+ */
43
+ export function createImageWorker(): Worker {
44
+ return new ImageWorker({ name: "jant-image" });
45
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Image processing worker.
3
+ *
4
+ * Decodes, resizes, re-encodes, and blurhashes an image entirely off the main
5
+ * thread: `createImageBitmap` decodes without touching the DOM, and
6
+ * `OffscreenCanvas.convertToBlob` encodes without a synchronous readback on
7
+ * the UI thread. Selecting a 12-megapixel photo used to hold the composer for
8
+ * a couple of hundred milliseconds per image; here it costs the page a
9
+ * `postMessage`.
10
+ *
11
+ * Protocol in `image-plan.ts`. A failure is reported back, not retried: the
12
+ * caller falls back to the in-page canvas path, which is also what browsers
13
+ * without OffscreenCanvas use.
14
+ */
15
+
16
+ import { encode } from "blurhash";
17
+ import {
18
+ blurhashDimensions,
19
+ planImageProcessing,
20
+ resolveEncodedMimeType,
21
+ type ImageProcessOptions,
22
+ type ImageWorkerRequest,
23
+ type ImageWorkerResponse,
24
+ type ImageWorkerSuccess,
25
+ } from "./image-plan.js";
26
+
27
+ // The DOM lib types `self` as a Window; the worker needs only the two members
28
+ // both scopes share, typed against this module's own protocol.
29
+ const scope = self as unknown as {
30
+ addEventListener(
31
+ type: "message",
32
+ listener: (event: MessageEvent<ImageWorkerRequest>) => void,
33
+ ): void;
34
+ postMessage(message: ImageWorkerResponse): void;
35
+ };
36
+
37
+ const encodeTypeByRequest = new Map<string, Promise<string>>();
38
+
39
+ /** The format `convertToBlob` really produces for `requested`, probed once. */
40
+ function resolveEncodeType(
41
+ requested: string,
42
+ quality: number,
43
+ ): Promise<string> {
44
+ let pending = encodeTypeByRequest.get(requested);
45
+ if (!pending) {
46
+ const { canvas } = draw(null, 1, 1);
47
+ pending = canvas
48
+ .convertToBlob({ type: requested, quality })
49
+ .then((probe) => resolveEncodedMimeType(requested, probe.type));
50
+ encodeTypeByRequest.set(requested, pending);
51
+ }
52
+ return pending;
53
+ }
54
+
55
+ function draw(
56
+ source: ImageBitmap | null,
57
+ width: number,
58
+ height: number,
59
+ ): { canvas: OffscreenCanvas; ctx: OffscreenCanvasRenderingContext2D } {
60
+ const canvas = new OffscreenCanvas(width, height);
61
+ const ctx = canvas.getContext("2d");
62
+ if (!ctx) throw new Error("Failed to get canvas context");
63
+ if (source) ctx.drawImage(source, 0, 0, width, height);
64
+ return { canvas, ctx };
65
+ }
66
+
67
+ /** Best-effort, like the metadata pass it replaces: no blurhash beats no upload. */
68
+ function blurhashOf(bitmap: ImageBitmap): string | undefined {
69
+ try {
70
+ const { width, height } = blurhashDimensions(bitmap.width, bitmap.height);
71
+ const { ctx } = draw(bitmap, width, height);
72
+ return encode(
73
+ ctx.getImageData(0, 0, width, height).data,
74
+ width,
75
+ height,
76
+ 4,
77
+ 3,
78
+ );
79
+ } catch {
80
+ return undefined;
81
+ }
82
+ }
83
+
84
+ async function processImage(
85
+ file: File,
86
+ options: ImageProcessOptions,
87
+ ): Promise<Omit<ImageWorkerSuccess, "id" | "ok">> {
88
+ // `from-image` applies EXIF orientation, so the bitmap's dimensions are the
89
+ // ones the photo is meant to be seen at — the same as `<img>` reports.
90
+ const bitmap = await createImageBitmap(file, {
91
+ imageOrientation: "from-image",
92
+ });
93
+ try {
94
+ const plan = planImageProcessing(bitmap.width, bitmap.height, options);
95
+ const blurhash = blurhashOf(bitmap);
96
+
97
+ // Too large to redraw on a canvas without crushing detail — keep the
98
+ // original bytes so images of any length upload at full quality.
99
+ if (plan.passthrough) {
100
+ return {
101
+ blob: file,
102
+ width: plan.width,
103
+ height: plan.height,
104
+ processed: false,
105
+ blurhash,
106
+ };
107
+ }
108
+
109
+ const { canvas } = draw(bitmap, plan.width, plan.height);
110
+ const type = await resolveEncodeType(options.mimeType, options.quality);
111
+ const blob = await canvas.convertToBlob({ type, quality: options.quality });
112
+ return {
113
+ blob,
114
+ width: plan.width,
115
+ height: plan.height,
116
+ processed: true,
117
+ blurhash,
118
+ };
119
+ } finally {
120
+ bitmap.close();
121
+ }
122
+ }
123
+
124
+ scope.addEventListener("message", async (event) => {
125
+ const { id, file, options } = event.data;
126
+ try {
127
+ scope.postMessage({ id, ok: true, ...(await processImage(file, options)) });
128
+ } catch (error) {
129
+ scope.postMessage({
130
+ id,
131
+ ok: false,
132
+ error: error instanceof Error ? error.message : String(error),
133
+ });
134
+ }
135
+ });
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The authenticated bundle, split by what a page needs.
3
+ *
4
+ * `client-auth.ts` is the shell every signed-in page loads: the post menu,
5
+ * command palette, shortcuts, and the triggers that open the composer. The
6
+ * heavier parts ship as their own entries and load through here:
7
+ *
8
+ * - `client-compose.ts` — the editor. Loaded the first time the composer
9
+ * opens (`compose-launch.ts`), at once on the compose page, and preloaded
10
+ * by the layout on every signed-in page so that first open costs no
11
+ * round trip.
12
+ * - `client-settings.ts` — the settings pages' components.
13
+ * - `client-manage.ts` — navigation manager, collection directory, and the
14
+ * collection page menus.
15
+ *
16
+ * The page decides: {@link loadEntriesForPage} looks for the elements and
17
+ * hooks the server renders only where an entry is needed, and keeps looking
18
+ * as fragments arrive, so a component never has to be registered up front.
19
+ * `docs/internal/lit-guide.md` says which entry a new component belongs in.
20
+ */
21
+
22
+ function once(load: () => Promise<unknown>): () => Promise<void> {
23
+ let loading: Promise<void> | undefined;
24
+ return () => (loading ??= load().then(() => undefined));
25
+ }
26
+
27
+ /** Load the composer entry; resolves once its elements are defined. */
28
+ export const ensureCompose = once(() => import("../client-compose.js"));
29
+
30
+ /** Load the settings entry; resolves once its elements are defined. */
31
+ export const ensureSettings = once(() => import("../client-settings.js"));
32
+
33
+ /** Load the management entry; resolves once its elements are defined. */
34
+ export const ensureManage = once(() => import("../client-manage.js"));
35
+
36
+ /**
37
+ * Markup the server renders only on pages that need an entry. A selector
38
+ * matching means the page cannot work without that entry, so it loads at
39
+ * once; the composer on an ordinary page is not listed because it loads on
40
+ * first open instead.
41
+ */
42
+ const PAGE_ENTRIES: ReadonlyArray<{
43
+ selector: string;
44
+ load: () => Promise<void>;
45
+ }> = [
46
+ { selector: '[data-page="compose"]', load: ensureCompose },
47
+ {
48
+ selector: [
49
+ "jant-settings-general",
50
+ "jant-settings-language",
51
+ "jant-settings-avatar",
52
+ "jant-config-editor",
53
+ "jant-repo-picker",
54
+ ].join(", "),
55
+ load: ensureSettings,
56
+ },
57
+ {
58
+ selector: [
59
+ "jant-nav-manager",
60
+ "jant-collections-manager",
61
+ "[data-collection-page-actions]",
62
+ "[data-smart-collection-page-actions]",
63
+ "[data-custom-url-actions]",
64
+ ].join(", "),
65
+ load: ensureManage,
66
+ },
67
+ ];
68
+
69
+ /**
70
+ * Load every entry whose markup is on the page, now and as the DOM changes.
71
+ *
72
+ * @example
73
+ * ```ts
74
+ * // client-auth.ts, once the shell is set up
75
+ * loadEntriesForPage();
76
+ * ```
77
+ */
78
+ export function loadEntriesForPage(): void {
79
+ const pending = new Set(PAGE_ENTRIES);
80
+ const observer = new globalThis.MutationObserver(check);
81
+
82
+ function check(): void {
83
+ for (const entry of pending) {
84
+ if (!document.querySelector(entry.selector)) continue;
85
+ pending.delete(entry);
86
+ void entry.load();
87
+ }
88
+ if (pending.size === 0) observer.disconnect();
89
+ }
90
+
91
+ check();
92
+ if (pending.size > 0) {
93
+ observer.observe(document.body, { childList: true, subtree: true });
94
+ }
95
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Lazy-loaded slug generation
3
3
  *
4
- * Wraps the `slugify` function from `url.ts` behind a dynamic import so
4
+ * Wraps the `slugify` function from `lib/slugify.ts` behind a dynamic import so
5
5
  * `limax` (used for i18n-aware transliteration) doesn't bloat the main
6
6
  * client bundle. Vite code-splits it into a separate chunk.
7
7
  *
@@ -22,7 +22,7 @@ let loadingPromise: Promise<SlugifyFn> | undefined;
22
22
  function load(): Promise<SlugifyFn> {
23
23
  if (slugifyFn) return Promise.resolve(slugifyFn);
24
24
  if (!loadingPromise) {
25
- loadingPromise = import("../lib/url.js").then((mod) => {
25
+ loadingPromise = import("../lib/slugify.js").then((mod) => {
26
26
  slugifyFn = mod.slugify;
27
27
  return mod.slugify;
28
28
  });