@jant/core 0.6.15 → 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 (537) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/import-site.js +67 -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-C-oi_t8W.js → app-K_Aa1MMn.js} +52623 -13193
  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-CsFx6F_M.js → github-sync-BPAvT999.js} +2343 -110
  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__/mise-config.test.ts +22 -0
  51. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  52. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  53. package/src/app.tsx +71 -16
  54. package/src/auth.ts +18 -1
  55. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  56. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  57. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  58. package/src/client/__tests__/compose-bridge.test.ts +524 -2
  59. package/src/client/__tests__/compose-launch.test.ts +62 -1
  60. package/src/client/__tests__/compose-shortcuts.test.ts +57 -8
  61. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  62. package/src/client/__tests__/copy-field.test.ts +162 -0
  63. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  64. package/src/client/__tests__/image-processor.test.ts +324 -2
  65. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  66. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  67. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  68. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  69. package/src/client/__tests__/toast.test.ts +88 -1
  70. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  71. package/src/client/__tests__/video-processor.test.ts +181 -0
  72. package/src/client/audio-processor.ts +24 -15
  73. package/src/client/audio-waveform.ts +49 -0
  74. package/src/client/collection-dialog-host.ts +72 -0
  75. package/src/client/collection-navigation.ts +51 -16
  76. package/src/client/collection-page-actions.ts +26 -0
  77. package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
  78. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  79. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  80. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  81. package/src/client/components/__tests__/jant-command-palette.test.ts +79 -0
  82. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  83. package/src/client/components/__tests__/jant-compose-dialog.test.ts +3283 -389
  84. package/src/client/components/__tests__/jant-compose-editor.test.ts +581 -86
  85. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +34 -89
  86. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  87. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  88. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  89. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  90. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  91. package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
  92. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  93. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  94. package/src/client/components/collection-dialog-types.ts +49 -0
  95. package/src/client/components/collection-manager-types.ts +32 -1
  96. package/src/client/components/collection-types.ts +1 -15
  97. package/src/client/components/compose-format-convert.ts +5 -1
  98. package/src/client/components/compose-types.ts +121 -3
  99. package/src/client/components/jant-collection-dialog.ts +722 -0
  100. package/src/client/components/jant-collection-directory.ts +567 -182
  101. package/src/client/components/jant-collection-form.ts +53 -232
  102. package/src/client/components/jant-command-palette.ts +106 -49
  103. package/src/client/components/jant-compose-dialog.ts +2757 -1194
  104. package/src/client/components/jant-compose-editor.ts +866 -397
  105. package/src/client/components/jant-compose-fullscreen.ts +53 -47
  106. package/src/client/components/jant-config-editor.ts +65 -53
  107. package/src/client/components/jant-confirm-dialog.ts +13 -11
  108. package/src/client/components/jant-locale-picker.ts +418 -0
  109. package/src/client/components/jant-media-lightbox.ts +146 -136
  110. package/src/client/components/jant-nav-manager.ts +690 -366
  111. package/src/client/components/jant-post-menu.ts +1142 -268
  112. package/src/client/components/jant-post-picker.ts +324 -0
  113. package/src/client/components/jant-repo-picker.ts +138 -106
  114. package/src/client/components/jant-settings-avatar.ts +31 -27
  115. package/src/client/components/jant-settings-general.ts +617 -543
  116. package/src/client/components/jant-settings-language.ts +1087 -0
  117. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  118. package/src/client/components/jant-text-preview.ts +94 -86
  119. package/src/client/components/nav-manager-types.ts +62 -1
  120. package/src/client/components/settings-types.ts +14 -29
  121. package/src/client/components/smart-collection-conditions.ts +285 -0
  122. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  123. package/src/client/compose-bridge.ts +336 -242
  124. package/src/client/compose-launch.ts +89 -2
  125. package/src/client/compose-shortcuts.ts +9 -7
  126. package/src/client/compose-triggers.ts +50 -0
  127. package/src/client/copy-field.ts +108 -0
  128. package/src/client/custom-url-menu.ts +60 -4
  129. package/src/client/icons.ts +14 -0
  130. package/src/client/image-plan.ts +165 -0
  131. package/src/client/image-processor.ts +195 -117
  132. package/src/client/image-worker-client.ts +45 -0
  133. package/src/client/image-worker.ts +135 -0
  134. package/src/client/lazy-entries.ts +95 -0
  135. package/src/client/lazy-slugify.ts +2 -2
  136. package/src/client/media-scroll-hint.ts +43 -2
  137. package/src/client/mediabunny.ts +29 -0
  138. package/src/client/post-picker.ts +41 -0
  139. package/src/client/post-refresh.ts +135 -0
  140. package/src/client/runtime-paths.ts +74 -1
  141. package/src/client/settings-bridge.ts +4 -10
  142. package/src/client/site-header-nav.js +18 -6
  143. package/src/client/smart-collection-dialog-host.ts +116 -0
  144. package/src/client/smart-collection-page-actions.ts +208 -0
  145. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  146. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  147. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  148. package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
  149. package/src/client/tiptap/bubble-menu.ts +47 -16
  150. package/src/client/tiptap/floating-position.ts +216 -2
  151. package/src/client/tiptap/inline-image-upload.ts +7 -3
  152. package/src/client/tiptap/link-input-rules.ts +4 -4
  153. package/src/client/tiptap/link-toolbar.ts +22 -9
  154. package/src/client/toast.ts +143 -38
  155. package/src/client/types/vite-worker.d.ts +12 -0
  156. package/src/client/upload-with-metadata.ts +3 -13
  157. package/src/client/video-processor.ts +351 -107
  158. package/src/client-auth.ts +14 -31
  159. package/src/client-compose.ts +19 -0
  160. package/src/client-manage.ts +13 -0
  161. package/src/client-settings.ts +14 -0
  162. package/src/client-site.ts +10 -3
  163. package/src/client.ts +4 -0
  164. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  165. package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
  166. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  167. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  168. package/src/db/__tests__/thread-activity.test.ts +113 -0
  169. package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
  170. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  171. package/src/db/migrations/0031_many_ego.sql +3 -0
  172. package/src/db/migrations/0032_married_tomas.sql +3 -0
  173. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  174. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  175. package/src/db/migrations/meta/0031_snapshot.json +2562 -0
  176. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  177. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  178. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  179. package/src/db/migrations/meta/_journal.json +28 -0
  180. package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
  181. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  182. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  183. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  184. package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
  185. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  186. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  187. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  188. package/src/db/migrations/pg/meta/_journal.json +28 -0
  189. package/src/db/pg/schema.ts +213 -27
  190. package/src/db/post-visibility.ts +117 -0
  191. package/src/db/schema.ts +242 -27
  192. package/src/db/thread-activity.ts +101 -0
  193. package/src/i18n/__tests__/detect.test.ts +26 -81
  194. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  195. package/src/i18n/context.tsx +12 -2
  196. package/src/i18n/detect.ts +7 -66
  197. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  198. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  199. package/src/i18n/locales/public/en.po +568 -168
  200. package/src/i18n/locales/public/en.ts +1 -1
  201. package/src/i18n/locales/public/zh-Hans.po +562 -162
  202. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  203. package/src/i18n/locales/public/zh-Hant.po +562 -162
  204. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  205. package/src/i18n/locales/settings/en.po +551 -69
  206. package/src/i18n/locales/settings/en.ts +1 -1
  207. package/src/i18n/locales/settings/zh-Hans.po +597 -115
  208. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  209. package/src/i18n/locales/settings/zh-Hant.po +610 -128
  210. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  211. package/src/i18n/locales.ts +94 -0
  212. package/src/i18n/middleware.ts +12 -2
  213. package/src/i18n/supported-locales.ts +80 -0
  214. package/src/lib/__tests__/api-settings.test.ts +11 -2
  215. package/src/lib/__tests__/asset-path.test.ts +92 -0
  216. package/src/lib/__tests__/deferred.test.ts +90 -0
  217. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  218. package/src/lib/__tests__/discover.test.ts +410 -0
  219. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  220. package/src/lib/__tests__/feed.test.ts +1691 -12
  221. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  222. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  223. package/src/lib/__tests__/http-cache.test.ts +162 -0
  224. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  225. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  226. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  227. package/src/lib/__tests__/markdown.test.ts +16 -0
  228. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  229. package/src/lib/__tests__/navigation.test.ts +135 -1
  230. package/src/lib/__tests__/resolve-config.test.ts +64 -3
  231. package/src/lib/__tests__/schemas.test.ts +42 -4
  232. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  233. package/src/lib/__tests__/slug.test.ts +88 -0
  234. package/src/lib/__tests__/slugify.test.ts +85 -0
  235. package/src/lib/__tests__/storage.test.ts +32 -0
  236. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  237. package/src/lib/__tests__/translit.test.ts +87 -0
  238. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  239. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  240. package/src/lib/__tests__/url.test.ts +93 -34
  241. package/src/lib/__tests__/view-language.test.ts +413 -0
  242. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  243. package/src/lib/address.ts +57 -0
  244. package/src/lib/api-posts.ts +12 -0
  245. package/src/lib/asset-path.ts +98 -1
  246. package/src/lib/build-env.ts +16 -0
  247. package/src/lib/collection-groups.ts +5 -1
  248. package/src/lib/collection-paths.ts +3 -10
  249. package/src/lib/constants.ts +28 -3
  250. package/src/lib/copy-field.ts +36 -0
  251. package/src/lib/crypto.ts +40 -0
  252. package/src/lib/csp-builder.ts +2 -1
  253. package/src/lib/deferred.ts +79 -0
  254. package/src/lib/discover-ping.ts +141 -0
  255. package/src/lib/discover.ts +437 -0
  256. package/src/lib/env.ts +134 -0
  257. package/src/lib/errors.ts +33 -0
  258. package/src/lib/feed-policy.ts +236 -2
  259. package/src/lib/feed.ts +1021 -66
  260. package/src/lib/filter-dimensions.ts +1349 -0
  261. package/src/lib/github-sync-site-config.ts +3 -0
  262. package/src/lib/hosted-signin.ts +1 -1
  263. package/src/lib/http-cache.ts +148 -0
  264. package/src/lib/hugo-markdown.ts +21 -0
  265. package/src/lib/icons.ts +9 -2
  266. package/src/lib/ids.ts +3 -0
  267. package/src/lib/jant-docs.ts +42 -0
  268. package/src/lib/lang-detect.ts +308 -0
  269. package/src/lib/link-preview.ts +25 -0
  270. package/src/lib/markdown-manager.ts +6 -1
  271. package/src/lib/media-helpers.ts +13 -1
  272. package/src/lib/navigation.ts +78 -11
  273. package/src/lib/pagination.ts +28 -24
  274. package/src/lib/per-language-surfaces.ts +67 -0
  275. package/src/lib/post-display.ts +42 -9
  276. package/src/lib/post-meta.ts +19 -0
  277. package/src/lib/render.tsx +55 -7
  278. package/src/lib/resolve-config.ts +53 -5
  279. package/src/lib/schemas.ts +201 -10
  280. package/src/lib/site-header-fragment.tsx +2 -1
  281. package/src/lib/site-unavailable-page.ts +47 -0
  282. package/src/lib/slug.ts +51 -7
  283. package/src/lib/slugify.ts +113 -0
  284. package/src/lib/startup-config.ts +4 -6
  285. package/src/lib/storage.ts +7 -0
  286. package/src/lib/summary.ts +50 -0
  287. package/src/lib/thread-fold.ts +161 -0
  288. package/src/lib/time.ts +32 -0
  289. package/src/lib/timeline.ts +81 -10
  290. package/src/lib/tiptap-render.ts +5 -4
  291. package/src/lib/translit.ts +288 -0
  292. package/src/lib/upload.ts +16 -13
  293. package/src/lib/url.ts +188 -24
  294. package/src/lib/version.ts +10 -8
  295. package/src/lib/view-language.ts +436 -0
  296. package/src/lib/view.ts +79 -23
  297. package/src/lib/viewer-context.ts +62 -0
  298. package/src/middleware/__tests__/auth.test.ts +70 -2
  299. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  300. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  301. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  302. package/src/middleware/__tests__/session.test.ts +181 -33
  303. package/src/middleware/auth.ts +50 -31
  304. package/src/middleware/cache-control.ts +75 -10
  305. package/src/middleware/config.ts +6 -6
  306. package/src/middleware/error-handler.ts +12 -0
  307. package/src/middleware/onboarding.ts +32 -10
  308. package/src/middleware/secure-headers.ts +1 -1
  309. package/src/middleware/session.ts +96 -8
  310. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  311. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  312. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  313. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  314. package/src/node/__tests__/runtime.test.ts +37 -0
  315. package/src/node/index.ts +1 -0
  316. package/src/preset.css +76 -22
  317. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  318. package/src/routes/api/__tests__/collections.test.ts +114 -0
  319. package/src/routes/api/__tests__/discover.test.ts +222 -0
  320. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  321. package/src/routes/api/__tests__/palette.test.ts +44 -0
  322. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  323. package/src/routes/api/__tests__/settings.test.ts +24 -3
  324. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  325. package/src/routes/api/collections.ts +49 -6
  326. package/src/routes/api/custom-urls.ts +0 -1
  327. package/src/routes/api/discover.ts +95 -0
  328. package/src/routes/api/export.ts +3 -0
  329. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  330. package/src/routes/api/nav-items.ts +55 -8
  331. package/src/routes/api/posts.ts +172 -9
  332. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  333. package/src/routes/api/public/__tests__/posts.test.ts +38 -0
  334. package/src/routes/api/public/archive.ts +77 -81
  335. package/src/routes/api/public/posts.ts +30 -6
  336. package/src/routes/api/smart-collections.ts +132 -0
  337. package/src/routes/api/telegram.ts +2 -40
  338. package/src/routes/api/uploads.ts +1 -2
  339. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  340. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  341. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  342. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  343. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  344. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  345. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  346. package/src/routes/auth/setup.tsx +934 -186
  347. package/src/routes/auth/signin.tsx +10 -8
  348. package/src/routes/compose.tsx +11 -2
  349. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  350. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  351. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  352. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  353. package/src/routes/dash/custom-urls.tsx +1 -37
  354. package/src/routes/dash/settings.tsx +502 -41
  355. package/src/routes/discover-announce.ts +96 -0
  356. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  357. package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
  358. package/src/routes/feed/feed.ts +69 -40
  359. package/src/routes/feed/sitemap.ts +95 -11
  360. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  361. package/src/routes/pages/__tests__/archive-params.test.ts +657 -1
  362. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  363. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  364. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  365. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  366. package/src/routes/pages/__tests__/featured.test.ts +6 -2
  367. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  368. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  369. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  370. package/src/routes/pages/__tests__/preview.test.ts +3 -1
  371. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  372. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  373. package/src/routes/pages/archive.tsx +507 -357
  374. package/src/routes/pages/collection.tsx +94 -96
  375. package/src/routes/pages/collections.tsx +38 -50
  376. package/src/routes/pages/featured.tsx +32 -13
  377. package/src/routes/pages/home.tsx +25 -6
  378. package/src/routes/pages/language.tsx +113 -0
  379. package/src/routes/pages/latest.tsx +26 -16
  380. package/src/routes/pages/new.tsx +3 -0
  381. package/src/routes/pages/page.tsx +183 -19
  382. package/src/routes/pages/partials.tsx +41 -0
  383. package/src/routes/pages/search.tsx +18 -3
  384. package/src/routes/pages/smart-collection.tsx +340 -0
  385. package/src/routes/pages/subscribe.tsx +182 -0
  386. package/src/runtime/__tests__/index.test.ts +19 -0
  387. package/src/runtime/__tests__/node.test.ts +129 -4
  388. package/src/runtime/cloudflare.ts +21 -2
  389. package/src/runtime/index.ts +27 -1
  390. package/src/runtime/node.ts +18 -1
  391. package/src/runtime/site.ts +95 -24
  392. package/src/services/__tests__/auth.test.ts +116 -7
  393. package/src/services/__tests__/collection.test.ts +139 -4
  394. package/src/services/__tests__/language.test.ts +495 -0
  395. package/src/services/__tests__/navigation.test.ts +213 -10
  396. package/src/services/__tests__/path.test.ts +180 -0
  397. package/src/services/__tests__/post-language.test.ts +830 -0
  398. package/src/services/__tests__/post-timeline.test.ts +142 -11
  399. package/src/services/__tests__/post.test.ts +323 -9
  400. package/src/services/__tests__/settings.test.ts +228 -17
  401. package/src/services/__tests__/site-admin.test.ts +5 -5
  402. package/src/services/__tests__/smart-collection.test.ts +651 -0
  403. package/src/services/about-page.ts +22 -1
  404. package/src/services/auth.ts +51 -49
  405. package/src/services/bootstrap.ts +188 -38
  406. package/src/services/collection-directory-position.ts +76 -0
  407. package/src/services/collection.ts +330 -47
  408. package/src/services/custom-url.ts +27 -22
  409. package/src/services/export-theme/assets/client-site.css +1 -1
  410. package/src/services/export-theme/assets/client-site.js +123 -123
  411. package/src/services/export-theme/layouts/_default/rss.xml +77 -1
  412. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  413. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  414. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  415. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  416. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  417. package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
  418. package/src/services/export-theme/styles/main.css +13 -1
  419. package/src/services/export.ts +281 -24
  420. package/src/services/index.ts +34 -3
  421. package/src/services/language.ts +412 -0
  422. package/src/services/mcp.ts +3 -1
  423. package/src/services/media.ts +14 -6
  424. package/src/services/navigation.ts +429 -67
  425. package/src/services/path.ts +225 -7
  426. package/src/services/post.ts +1318 -252
  427. package/src/services/search.ts +48 -2
  428. package/src/services/settings.ts +238 -43
  429. package/src/services/site-admin.ts +24 -7
  430. package/src/services/site.ts +77 -1
  431. package/src/services/smart-collection.ts +636 -0
  432. package/src/services/telegram.ts +20 -22
  433. package/src/services/upload-session.ts +1 -3
  434. package/src/style-author.css +27 -0
  435. package/src/styles/components-author.css +1622 -0
  436. package/src/styles/components.css +24 -1585
  437. package/src/styles/site-media.css +20 -6
  438. package/src/styles/tokens.css +144 -13
  439. package/src/styles/ui-author.css +7602 -0
  440. package/src/styles/ui.css +1224 -7276
  441. package/src/types/app-context.ts +7 -0
  442. package/src/types/bindings.ts +3 -0
  443. package/src/types/config.ts +94 -15
  444. package/src/types/constants.ts +188 -16
  445. package/src/types/entities.ts +92 -1
  446. package/src/types/operations.ts +64 -2
  447. package/src/types/props.ts +168 -30
  448. package/src/types/views.ts +61 -1
  449. package/src/ui/__tests__/font-themes.test.ts +52 -12
  450. package/src/ui/compose/ComposeDialog.tsx +215 -21
  451. package/src/ui/compose/ComposePrompt.tsx +1 -5
  452. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  453. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  454. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  455. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  456. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  457. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  458. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  459. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  460. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  461. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  462. package/src/ui/dash/settings/GeneralContent.tsx +275 -113
  463. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  464. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  465. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  466. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  467. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  468. package/src/ui/dash/settings/discover-copy.ts +116 -0
  469. package/src/ui/feed/LinkCard.tsx +55 -45
  470. package/src/ui/feed/LinkPreview.tsx +2 -7
  471. package/src/ui/feed/NoteCard.tsx +9 -4
  472. package/src/ui/feed/PostStatusBadges.tsx +103 -17
  473. package/src/ui/feed/QuoteCard.tsx +55 -30
  474. package/src/ui/feed/ThreadPreview.tsx +6 -2
  475. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  476. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  477. package/src/ui/feed/thread-preview-state.ts +20 -6
  478. package/src/ui/font-themes.ts +39 -29
  479. package/src/ui/layouts/BaseLayout.tsx +155 -10
  480. package/src/ui/layouts/SiteLayout.tsx +192 -34
  481. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  482. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  483. package/src/ui/pages/ArchivePage.tsx +401 -301
  484. package/src/ui/pages/CollectionPage.tsx +86 -76
  485. package/src/ui/pages/CollectionsPage.tsx +5 -0
  486. package/src/ui/pages/ComposePage.tsx +8 -0
  487. package/src/ui/pages/PostPage.tsx +47 -0
  488. package/src/ui/pages/SearchPage.tsx +3 -3
  489. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  490. package/src/ui/pages/SubscribePage.tsx +236 -0
  491. package/src/ui/pages/__tests__/ArchivePage.test.tsx +122 -3
  492. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  493. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  494. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  495. package/src/ui/shared/CollectionsManager.tsx +70 -17
  496. package/src/ui/shared/CopyField.tsx +127 -0
  497. package/src/ui/shared/DraftPreviewBar.tsx +25 -9
  498. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  499. package/src/ui/shared/MediaGallery.tsx +125 -46
  500. package/src/ui/shared/Pagination.tsx +20 -5
  501. package/src/ui/shared/PostFooter.tsx +55 -23
  502. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  503. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  504. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  505. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  506. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  507. package/src/ui/shared/archive-labels.ts +98 -0
  508. package/src/ui/shared/collection-management-labels.ts +94 -16
  509. package/src/ui/shared/custom-icons.ts +5 -0
  510. package/src/ui/shared/navigation-labels.ts +45 -71
  511. package/src/ui/shared/post-article-attributes.ts +8 -0
  512. package/src/ui/shared/smart-collection-labels.ts +343 -0
  513. package/dist/app-BxCOR3Uc.js +0 -6
  514. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  515. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  516. package/dist/client/_assets/chunks/url-pLre2DM_.js +0 -1
  517. package/dist/client/_assets/client-B_eGKN5p.css +0 -2
  518. package/dist/client/_assets/client-DxY5BFe8.js +0 -296
  519. package/dist/client/_assets/client-auth-CakPESv9.js +0 -5533
  520. package/dist/env-OHRKGcMj.js +0 -281
  521. package/dist/github-api-BgSiE71w.js +0 -176
  522. package/dist/github-app-BbklkFmU.js +0 -275
  523. package/dist/github-sync-Cq1pzzOI.js +0 -431
  524. package/dist/github-sync-ppWXN3jb.js +0 -4
  525. package/dist/url-BMYO-Zlt.js +0 -395
  526. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  527. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  528. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  529. package/src/client/collection-created-notice.ts +0 -66
  530. package/src/client/collection-form-bridge.ts +0 -191
  531. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  532. package/src/client/components/jant-post-form.ts +0 -422
  533. package/src/client/components/post-form-template.ts +0 -352
  534. package/src/client/components/post-form-types.ts +0 -96
  535. package/src/client/media-metadata.ts +0 -247
  536. package/src/client/post-form-bridge.ts +0 -193
  537. package/src/ui/pages/CollectionEditorPage.tsx +0 -175
@@ -0,0 +1,158 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ vi.mock("../toast.js", () => ({
6
+ showToast: vi.fn(),
7
+ showToastWithAction: vi.fn(),
8
+ }));
9
+
10
+ vi.mock("../confirm.js", () => ({
11
+ showConfirmDialog: vi.fn(),
12
+ }));
13
+
14
+ vi.mock("../smart-collection-dialog-host.js", () => ({
15
+ openSmartCollectionDialog: vi.fn(),
16
+ }));
17
+
18
+ function createMarkup() {
19
+ document.body.innerHTML = `
20
+ <div id="toast-container"></div>
21
+ <div
22
+ data-smart-collection-page-actions
23
+ data-smart-collection-id="smc_1"
24
+ data-collection-page-labels='{"addToNavigation":"Add to Navigation","addingToNavigation":"Adding…","addedToNavigation":"Collection added to navigation.","editNavigation":"Edit Navigation","addToNavigationFailed":"Couldn\\u0027t add this collection to navigation. Try again.","confirmDelete":"Delete this smart collection?","deleteCollection":"Delete","cancel":"Cancel","saveFailed":"Couldn\\u0027t save. Try again in a moment.","deleted":"Smart collection deleted."}'
25
+ data-collection-in-navigation="false"
26
+ data-collection-page-redirect-url="/collections"
27
+ >
28
+ <button
29
+ type="button"
30
+ data-collection-page-action="toggle-menu"
31
+ aria-expanded="false"
32
+ >
33
+ More actions
34
+ </button>
35
+ <div data-collection-page-menu hidden>
36
+ <button type="button" role="menuitem" data-collection-page-action="edit">
37
+ Edit
38
+ </button>
39
+ <button
40
+ type="button"
41
+ role="menuitem"
42
+ data-collection-page-action="add-to-navigation"
43
+ >
44
+ <span class="collections-page-menu-item-label">Add to Navigation</span>
45
+ </button>
46
+ <a
47
+ href="/settings/navigation"
48
+ role="menuitem"
49
+ data-collection-page-edit-navigation
50
+ hidden
51
+ >
52
+ Edit Navigation
53
+ </a>
54
+ <button
55
+ type="button"
56
+ role="menuitem"
57
+ data-collection-page-action="delete"
58
+ >
59
+ Delete
60
+ </button>
61
+ </div>
62
+ </div>
63
+ `;
64
+ }
65
+
66
+ async function flushAsyncWork() {
67
+ await new Promise((resolve) => setTimeout(resolve, 0));
68
+ await new Promise((resolve) => setTimeout(resolve, 0));
69
+ }
70
+
71
+ describe("smart collection page actions", () => {
72
+ beforeEach(() => {
73
+ vi.resetModules();
74
+ vi.clearAllMocks();
75
+ window.location.href = "http://localhost/reading";
76
+ });
77
+
78
+ // The same request a collection page sends, down to the placement and the
79
+ // header-refresh hint — the two kinds are one thing to the reader, so the
80
+ // action cannot quietly put them in different parts of the nav.
81
+ it("adds the smart collection to navigation and reveals the settings action", async () => {
82
+ createMarkup();
83
+ const fetchMock = vi.fn().mockResolvedValue(
84
+ new Response(JSON.stringify({ id: "nav-1" }), {
85
+ status: 201,
86
+ headers: { "Content-Type": "application/json" },
87
+ }),
88
+ );
89
+ vi.stubGlobal("fetch", fetchMock);
90
+
91
+ const { showToastWithAction } = await import("../toast.js");
92
+ await import("../smart-collection-page-actions.js");
93
+
94
+ const addButton = document.querySelector<HTMLButtonElement>(
95
+ "[data-collection-page-action='add-to-navigation']",
96
+ );
97
+ const editNavigationLink = document.querySelector<HTMLAnchorElement>(
98
+ "[data-collection-page-edit-navigation]",
99
+ );
100
+
101
+ addButton?.click();
102
+ await flushAsyncWork();
103
+
104
+ expect(fetchMock).toHaveBeenCalledWith("/api/nav-items", {
105
+ method: "POST",
106
+ headers: {
107
+ "Content-Type": "application/json",
108
+ Accept: "application/json",
109
+ "X-Jant-Site-Header": "include",
110
+ },
111
+ body: JSON.stringify({
112
+ type: "smart_collection",
113
+ smartCollectionId: "smc_1",
114
+ placement: "header",
115
+ }),
116
+ });
117
+ expect(addButton?.hidden).toBe(true);
118
+ expect(editNavigationLink?.hidden).toBe(false);
119
+ expect(
120
+ document.querySelector<HTMLElement>(
121
+ "[data-smart-collection-page-actions]",
122
+ )?.dataset.collectionInNavigation,
123
+ ).toBe("true");
124
+ expect(showToastWithAction).toHaveBeenCalledWith(
125
+ "Collection added to navigation.",
126
+ {
127
+ label: "Edit Navigation",
128
+ href: "/settings/navigation",
129
+ },
130
+ );
131
+ });
132
+
133
+ it("keeps the action available when the request fails", async () => {
134
+ createMarkup();
135
+ const fetchMock = vi
136
+ .fn()
137
+ .mockResolvedValue(new Response(null, { status: 500 }));
138
+ vi.stubGlobal("fetch", fetchMock);
139
+
140
+ const { showToast } = await import("../toast.js");
141
+ await import("../smart-collection-page-actions.js");
142
+
143
+ const addButton = document.querySelector<HTMLButtonElement>(
144
+ "[data-collection-page-action='add-to-navigation']",
145
+ );
146
+
147
+ addButton?.click();
148
+ await flushAsyncWork();
149
+
150
+ expect(showToast).toHaveBeenCalledWith(
151
+ "Couldn't add this collection to navigation. Try again.",
152
+ "error",
153
+ );
154
+ expect(addButton?.hidden).toBe(false);
155
+ expect(addButton?.disabled).toBe(false);
156
+ expect(addButton?.textContent).toContain("Add to Navigation");
157
+ });
158
+ });
@@ -5,6 +5,9 @@ import {
5
5
  QUEUED_TOAST_STORAGE_KEY,
6
6
  consumeQueuedToast,
7
7
  queueToastForNextPage,
8
+ replaceWithAutoClose,
9
+ showPersistentToast,
10
+ showToast,
8
11
  showToastWithAction,
9
12
  } from "../toast.js";
10
13
 
@@ -46,6 +49,90 @@ describe("toast", () => {
46
49
  expect(action?.getAttribute("href")).toBe("/published-post");
47
50
  });
48
51
 
52
+ it("renders error toasts without a copy button — text is selectable instead", () => {
53
+ showToast("Publish failed.", "error");
54
+
55
+ const toast = document.querySelector(".toast-error");
56
+ expect(toast).not.toBeNull();
57
+ expect(toast?.querySelector("button")).toBeNull();
58
+ });
59
+
60
+ it("pauses auto-dismiss while hovered and resumes the remaining time", () => {
61
+ vi.useFakeTimers();
62
+ showToast("Publish failed.", "error");
63
+ const toast = document.querySelector<HTMLElement>(".toast");
64
+ if (!toast) throw new Error("toast not rendered");
65
+
66
+ vi.advanceTimersByTime(2000);
67
+ toast.dispatchEvent(new Event("pointerenter"));
68
+ vi.advanceTimersByTime(60_000);
69
+ expect(toast.classList.contains("toast-out")).toBe(false);
70
+
71
+ toast.dispatchEvent(new Event("pointerleave"));
72
+ vi.advanceTimersByTime(999);
73
+ expect(toast.classList.contains("toast-out")).toBe(false);
74
+ vi.advanceTimersByTime(1);
75
+ expect(toast.classList.contains("toast-out")).toBe(true);
76
+ });
77
+
78
+ it("pauses auto-dismiss while focus is inside the toast", () => {
79
+ vi.useFakeTimers();
80
+ showToastWithAction("Collection created.", {
81
+ label: "Edit Navigation",
82
+ href: "/settings/navigation",
83
+ });
84
+ const toast = document.querySelector<HTMLElement>(".toast");
85
+ if (!toast) throw new Error("toast not rendered");
86
+
87
+ toast.dispatchEvent(new Event("focusin"));
88
+ vi.advanceTimersByTime(60_000);
89
+ expect(toast.classList.contains("toast-out")).toBe(false);
90
+
91
+ toast.dispatchEvent(new Event("focusout"));
92
+ vi.advanceTimersByTime(5000);
93
+ expect(toast.classList.contains("toast-out")).toBe(true);
94
+ });
95
+
96
+ it("mounts toasts inside an open modal dialog and restores them on close", async () => {
97
+ const dialog = document.createElement("dialog");
98
+ document.body.appendChild(dialog);
99
+ dialog.showModal();
100
+
101
+ showToast("Publish failed.", "error");
102
+
103
+ const container = document.getElementById("toast-container");
104
+ expect(container?.parentElement).toBe(dialog);
105
+ expect(container?.querySelector(".toast span")?.textContent).toBe(
106
+ "Publish failed.",
107
+ );
108
+
109
+ dialog.close();
110
+ // the dialog close event is dispatched in a queued task
111
+ await new Promise((resolve) => setTimeout(resolve, 0));
112
+ expect(container?.parentElement).toBe(document.body);
113
+ });
114
+
115
+ it("follows a dialog that opened after the progress toast did", async () => {
116
+ showPersistentToast("compose-deferred", "Publishing...");
117
+
118
+ // A failed publish reopens the composer, which makes everything outside
119
+ // it inert — the result toast has to move in with it.
120
+ const dialog = document.createElement("dialog");
121
+ document.body.appendChild(dialog);
122
+ dialog.showModal();
123
+
124
+ replaceWithAutoClose("compose-deferred", "Couldn't publish.", "error");
125
+
126
+ const container = document.getElementById("toast-container");
127
+ expect(container?.parentElement).toBe(dialog);
128
+ expect(container?.querySelector(".toast span")?.textContent).toBe(
129
+ "Couldn't publish.",
130
+ );
131
+
132
+ dialog.close();
133
+ await new Promise((resolve) => setTimeout(resolve, 0));
134
+ });
135
+
49
136
  it("keeps action toasts available longer than passive notifications", () => {
50
137
  vi.useFakeTimers();
51
138
  showToastWithAction("Collection created.", {
@@ -57,7 +144,7 @@ describe("toast", () => {
57
144
  vi.advanceTimersByTime(3000);
58
145
  expect(toast?.classList.contains("toast-out")).toBe(false);
59
146
 
60
- vi.advanceTimersByTime(5000);
147
+ vi.advanceTimersByTime(2000);
61
148
  expect(toast?.classList.contains("toast-out")).toBe(true);
62
149
  });
63
150
  });
@@ -0,0 +1,175 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ /**
4
+ * The source probe feeds four independent things: the codec that decides
5
+ * whether a re-encode is needed, the bitrate that sizes it, the duration, and
6
+ * the poster/blurhash pair. They used to share one try/catch, so a single
7
+ * failing step returned nothing at all — a mediabunny version without
8
+ * `getCodec()` cost every upload its thumbnail while the video itself went
9
+ * through fine. Each step is now degraded on its own.
10
+ */
11
+
12
+ import { beforeEach, describe, expect, it, vi } from "vitest";
13
+
14
+ const trackStub = {
15
+ displayWidth: 1280,
16
+ displayHeight: 720,
17
+ rotation: 0,
18
+ getCodec: vi.fn(async () => "avc"),
19
+ computePacketStats: vi.fn(async () => ({ averageBitrate: 2_000_000 })),
20
+ };
21
+
22
+ const conversionOptions: { video?: Record<string, unknown> }[] = [];
23
+
24
+ const canvasSinkStub = {
25
+ getCanvas: vi.fn(async () => ({ canvas: makeCanvas(1280, 720) })),
26
+ };
27
+
28
+ vi.mock("mediabunny", () => {
29
+ class Input {
30
+ getPrimaryVideoTrack = async () => trackStub;
31
+ computeDuration = async () => 20;
32
+ dispose = () => {};
33
+ }
34
+ class CanvasSink {
35
+ getCanvas = () => canvasSinkStub.getCanvas();
36
+ }
37
+ class BufferTarget {
38
+ buffer = new ArrayBuffer(64);
39
+ }
40
+ return {
41
+ Input,
42
+ CanvasSink,
43
+ BufferTarget,
44
+ BlobSource: class {},
45
+ Output: class {},
46
+ Mp4OutputFormat: class {},
47
+ Quality: class {
48
+ constructor(readonly options: unknown) {}
49
+ },
50
+ ALL_FORMATS: [],
51
+ Conversion: {
52
+ init: async (options: { video?: Record<string, unknown> }) => {
53
+ conversionOptions.push(options);
54
+ return { onProgress: undefined, execute: async () => {} };
55
+ },
56
+ },
57
+ };
58
+ });
59
+
60
+ vi.mock("../../lib/mp4-track-flags.js", () => ({
61
+ zeroTrackAlternateGroups: () => {},
62
+ }));
63
+
64
+ /** A canvas whose 2D context and WebP encoding both succeed. */
65
+ function makeCanvas(width: number, height: number): HTMLCanvasElement {
66
+ return {
67
+ width,
68
+ height,
69
+ getContext: () => ({
70
+ drawImage: () => {},
71
+ getImageData: (_x: number, _y: number, w: number, h: number) => ({
72
+ data: new Uint8ClampedArray(w * h * 4).fill(120),
73
+ }),
74
+ }),
75
+ toBlob: (cb: (blob: Blob | null) => void) =>
76
+ cb(new Blob(["poster"], { type: "image/webp" })),
77
+ } as unknown as HTMLCanvasElement;
78
+ }
79
+
80
+ const { VideoProcessor } = await import("../video-processor.js");
81
+
82
+ beforeEach(() => {
83
+ conversionOptions.length = 0;
84
+ trackStub.getCodec.mockImplementation(async () => "avc");
85
+ trackStub.computePacketStats.mockImplementation(async () => ({
86
+ averageBitrate: 2_000_000,
87
+ }));
88
+ canvasSinkStub.getCanvas.mockImplementation(async () => ({
89
+ canvas: makeCanvas(1280, 720),
90
+ }));
91
+
92
+ vi.spyOn(document, "createElement").mockImplementation(((tag: string) => {
93
+ if (tag === "canvas") return makeCanvas(1280, 720);
94
+ // `probeVideoDimensions` loads the transcoded output in a <video> element.
95
+ const video = {
96
+ preload: "",
97
+ onloadedmetadata: null as null | (() => void),
98
+ onerror: null,
99
+ videoWidth: 1280,
100
+ videoHeight: 720,
101
+ set src(_value: string) {
102
+ queueMicrotask(() => this.onloadedmetadata?.());
103
+ },
104
+ };
105
+ return video as unknown as HTMLElement;
106
+ }) as typeof document.createElement);
107
+
108
+ vi.stubGlobal("URL", {
109
+ createObjectURL: () => "blob:test",
110
+ revokeObjectURL: () => {},
111
+ });
112
+ vi.stubGlobal("VideoEncoder", class {});
113
+ });
114
+
115
+ function sourceFile() {
116
+ return new File(["source"], "clip.mov", { type: "video/quicktime" });
117
+ }
118
+
119
+ describe("VideoProcessor.processToFile source probing", () => {
120
+ it("still captures a poster when the codec cannot be read", async () => {
121
+ // The exact shape of a stale install: the installed mediabunny has no
122
+ // `getCodec()`, so the call throws before the frame is ever captured.
123
+ trackStub.getCodec.mockImplementation(() => {
124
+ throw new TypeError("videoTrack.getCodec is not a function");
125
+ });
126
+ vi.spyOn(console, "warn").mockImplementation(() => {});
127
+
128
+ const result = await VideoProcessor.processToFile(sourceFile());
129
+
130
+ expect(result.poster).toBeInstanceOf(Blob);
131
+ expect(result.blurhash).toBeTruthy();
132
+ expect(result.durationSeconds).toBe(20);
133
+ // An unreadable codec cannot be vouched for, so the file is re-encoded.
134
+ expect(conversionOptions[0]?.video).toHaveProperty("quality");
135
+ });
136
+
137
+ it("still captures a poster when the bitrate cannot be computed", async () => {
138
+ trackStub.computePacketStats.mockRejectedValue(
139
+ new Error("no sample table"),
140
+ );
141
+ vi.spyOn(console, "warn").mockImplementation(() => {});
142
+
143
+ const result = await VideoProcessor.processToFile(sourceFile());
144
+
145
+ expect(result.poster).toBeInstanceOf(Blob);
146
+ expect(result.blurhash).toBeTruthy();
147
+ });
148
+
149
+ it("still reports the duration when the frame cannot be decoded", async () => {
150
+ canvasSinkStub.getCanvas.mockRejectedValue(
151
+ new Error("unsupported codec configuration"),
152
+ );
153
+ vi.spyOn(console, "warn").mockImplementation(() => {});
154
+
155
+ const result = await VideoProcessor.processToFile(sourceFile());
156
+
157
+ expect(result.poster).toBeUndefined();
158
+ expect(result.blurhash).toBeUndefined();
159
+ // The half that does not need a decoder survives.
160
+ expect(result.durationSeconds).toBe(20);
161
+ expect(conversionOptions[0]?.video).not.toHaveProperty("quality");
162
+ });
163
+
164
+ it("names the failing step on the console rather than failing silently", async () => {
165
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
166
+ trackStub.getCodec.mockRejectedValue(new Error("boom"));
167
+
168
+ await VideoProcessor.processToFile(sourceFile());
169
+
170
+ expect(warn).toHaveBeenCalledWith(
171
+ expect.stringContaining("read codec"),
172
+ expect.any(Error),
173
+ );
174
+ });
175
+ });
@@ -0,0 +1,181 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { planVideoProcessing } from "../video-processor.js";
3
+
4
+ const OPTS = { maxLongEdge: 1920, maxShortEdge: 1080 };
5
+
6
+ const mbps = (n: number) => n * 1_000_000;
7
+
8
+ describe("planVideoProcessing", () => {
9
+ describe("copying", () => {
10
+ it("copies an H.264 video that already fits", () => {
11
+ expect(
12
+ planVideoProcessing(
13
+ {
14
+ width: 1280,
15
+ height: 720,
16
+ videoCodec: "avc",
17
+ videoBitrate: mbps(5),
18
+ },
19
+ OPTS,
20
+ ),
21
+ ).toEqual({
22
+ needsResize: false,
23
+ width: 1280,
24
+ height: 720,
25
+ videoNeedsReencode: false,
26
+ videoBitrate: undefined,
27
+ });
28
+ });
29
+
30
+ it("copies a video sitting exactly on the caps", () => {
31
+ const plan = planVideoProcessing(
32
+ { width: 1920, height: 1080, videoCodec: "avc" },
33
+ OPTS,
34
+ );
35
+ expect(plan.needsResize).toBe(false);
36
+ expect(plan.videoNeedsReencode).toBe(false);
37
+ });
38
+
39
+ it("re-encodes an HEVC video even when it fits", () => {
40
+ const plan = planVideoProcessing(
41
+ { width: 1280, height: 720, videoCodec: "hevc", videoBitrate: mbps(5) },
42
+ OPTS,
43
+ );
44
+ expect(plan.needsResize).toBe(false);
45
+ expect(plan.videoNeedsReencode).toBe(true);
46
+ });
47
+
48
+ it("re-encodes an unknown video codec", () => {
49
+ const plan = planVideoProcessing(
50
+ { width: 1280, height: 720, videoCodec: null },
51
+ OPTS,
52
+ );
53
+ expect(plan.videoNeedsReencode).toBe(true);
54
+ });
55
+ });
56
+
57
+ describe("resizing", () => {
58
+ it("scales 4K down to the long-edge cap", () => {
59
+ const plan = planVideoProcessing(
60
+ { width: 3840, height: 2160, videoCodec: "avc" },
61
+ OPTS,
62
+ );
63
+ expect(plan.needsResize).toBe(true);
64
+ expect(plan.width).toBe(1920);
65
+ expect(plan.height).toBe(1080);
66
+ expect(plan.videoNeedsReencode).toBe(true);
67
+ });
68
+
69
+ it("caps portrait video by its short edge, not its long edge", () => {
70
+ const plan = planVideoProcessing(
71
+ { width: 2160, height: 3840, videoCodec: "avc" },
72
+ OPTS,
73
+ );
74
+ expect(plan.width).toBe(1080);
75
+ expect(plan.height).toBe(1920);
76
+ });
77
+
78
+ it("caps an ultra-wide video by its long edge", () => {
79
+ const plan = planVideoProcessing(
80
+ { width: 3840, height: 1080, videoCodec: "avc" },
81
+ OPTS,
82
+ );
83
+ expect(plan.width).toBe(1920);
84
+ expect(plan.height).toBe(540);
85
+ });
86
+
87
+ it("rounds scaled dimensions up to even numbers for H.264", () => {
88
+ const plan = planVideoProcessing(
89
+ { width: 3840, height: 2158, videoCodec: "avc" },
90
+ OPTS,
91
+ );
92
+ expect(plan.width % 2).toBe(0);
93
+ expect(plan.height % 2).toBe(0);
94
+ });
95
+
96
+ it("falls back to the caps when dimensions are unknown", () => {
97
+ const plan = planVideoProcessing({ videoCodec: "avc" }, OPTS);
98
+ expect(plan.needsResize).toBe(false);
99
+ expect(plan.width).toBe(1920);
100
+ expect(plan.height).toBe(1080);
101
+ });
102
+ });
103
+
104
+ describe("bitrate", () => {
105
+ it("targets roughly 8 Mbps for a 1080p re-encode", () => {
106
+ const plan = planVideoProcessing(
107
+ {
108
+ width: 1920,
109
+ height: 1080,
110
+ videoCodec: "hevc",
111
+ videoBitrate: mbps(50),
112
+ },
113
+ OPTS,
114
+ );
115
+ expect(plan.videoBitrate).toBeGreaterThan(mbps(7));
116
+ expect(plan.videoBitrate).toBeLessThan(mbps(9));
117
+ });
118
+
119
+ it("never exceeds what the source content carries", () => {
120
+ // A 960×448 HEVC clip at 0.72 Mbps: re-encoding at the size-appropriate
121
+ // 1.8 Mbps would spend most of those bits on the source's own artifacts.
122
+ const plan = planVideoProcessing(
123
+ {
124
+ width: 960,
125
+ height: 448,
126
+ videoCodec: "hevc",
127
+ videoBitrate: 717_000,
128
+ },
129
+ OPTS,
130
+ );
131
+ // 0.72 Mbps HEVC ≈ 1.2 Mbps of H.264, well under the ~1.8 Mbps target.
132
+ expect(plan.videoBitrate).toBeGreaterThan(mbps(1));
133
+ expect(plan.videoBitrate).toBeLessThan(mbps(1.4));
134
+ });
135
+
136
+ it("allows H.264 more bits than the HEVC source it replaces", () => {
137
+ const source = { width: 1280, height: 720, videoBitrate: mbps(1) };
138
+ const fromHevc = planVideoProcessing(
139
+ { ...source, videoCodec: "hevc" },
140
+ OPTS,
141
+ );
142
+ const fromAv1 = planVideoProcessing(
143
+ { ...source, videoCodec: "av1" },
144
+ OPTS,
145
+ );
146
+ expect(fromHevc.videoBitrate).toBeGreaterThan(mbps(1));
147
+ expect(fromAv1.videoBitrate).toBeGreaterThan(fromHevc.videoBitrate!);
148
+ });
149
+
150
+ it("scales the source cap down along with a downscale", () => {
151
+ // 4K at 40 Mbps → 1080p. The source cap must account for the quarter
152
+ // pixel count, not wave 40 Mbps of 4K bitrate through unchanged.
153
+ const plan = planVideoProcessing(
154
+ {
155
+ width: 3840,
156
+ height: 2160,
157
+ videoCodec: "hevc",
158
+ videoBitrate: mbps(40),
159
+ },
160
+ OPTS,
161
+ );
162
+ expect(plan.videoBitrate).toBeLessThan(mbps(9));
163
+ });
164
+
165
+ it("falls back to the size target when the source bitrate is unknown", () => {
166
+ const plan = planVideoProcessing(
167
+ { width: 1920, height: 1080, videoCodec: "hevc" },
168
+ OPTS,
169
+ );
170
+ expect(plan.videoBitrate).toBeGreaterThan(mbps(7));
171
+ });
172
+
173
+ it("leaves the bitrate unset when the track is copied", () => {
174
+ const plan = planVideoProcessing(
175
+ { width: 1280, height: 720, videoCodec: "avc", videoBitrate: mbps(3) },
176
+ OPTS,
177
+ );
178
+ expect(plan.videoBitrate).toBeUndefined();
179
+ });
180
+ });
181
+ });
@@ -1,23 +1,17 @@
1
1
  /**
2
2
  * Client-side Audio Processor
3
3
  *
4
- * Transcodes audio files to AAC in an M4A container (MP4 audio-only)
5
- * using mediabunny. Mirrors the video-processor pattern but discards
6
- * any video track and skips poster/blurhash extraction.
4
+ * Remuxes audio files into an M4A container (MP4 audio-only) using
5
+ * mediabunny, transcoding to AAC only when the source is not already AAC.
6
+ * A source that needs nothing has its encoded packets copied across, so it
7
+ * arrives without a generation of re-encoding loss. Mirrors the
8
+ * video-processor pattern but discards any video track and skips
9
+ * poster/blurhash extraction.
7
10
  *
8
11
  * Requires WebCodecs API support — check `isSupported()` before use.
9
12
  */
10
13
 
11
- import {
12
- Input,
13
- Output,
14
- Mp4OutputFormat,
15
- BufferTarget,
16
- BlobSource,
17
- Conversion,
18
- QUALITY_HIGH,
19
- ALL_FORMATS,
20
- } from "mediabunny";
14
+ import { loadMediabunny } from "./mediabunny.js";
21
15
 
22
16
  export interface AudioProcessResult {
23
17
  file: File;
@@ -33,7 +27,8 @@ function isSupported(): boolean {
33
27
  }
34
28
 
35
29
  /**
36
- * Process an audio file: transcode to AAC in an M4A (MP4) container.
30
+ * Process an audio file: remux into an M4A (MP4) container, transcoding to
31
+ * AAC only when the source is not already AAC.
37
32
  *
38
33
  * @param file - Source audio file
39
34
  * @param onProgress - Optional callback receiving progress from 0 to 1
@@ -43,6 +38,15 @@ async function processToFile(
43
38
  file: File,
44
39
  onProgress?: (progress: number) => void,
45
40
  ): Promise<AudioProcessResult> {
41
+ const {
42
+ Input,
43
+ Output,
44
+ Mp4OutputFormat,
45
+ BufferTarget,
46
+ BlobSource,
47
+ Conversion,
48
+ ALL_FORMATS,
49
+ } = await loadMediabunny();
46
50
  const input = new Input({
47
51
  source: new BlobSource(file),
48
52
  formats: ALL_FORMATS,
@@ -54,11 +58,16 @@ async function processToFile(
54
58
  });
55
59
 
56
60
  try {
61
+ // Setting a quality on the track is what disables mediabunny's copy fast
62
+ // path, and a needless decode/encode round trip costs a generation of
63
+ // quality. Leaving it unset lets an AAC source be copied outright;
64
+ // anything else falls back to mediabunny's default, which for AAC lands
65
+ // on 192 kbps — the highest rate it will pick at any quality level.
57
66
  const conversion = await Conversion.init({
58
67
  input,
59
68
  output,
60
69
  video: { discard: true },
61
- audio: { codec: "aac", bitrate: QUALITY_HIGH },
70
+ audio: { codec: "aac" },
62
71
  });
63
72
 
64
73
  if (onProgress) {