@jant/core 0.6.16 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (508) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/import-site.js +60 -4
  4. package/bin/commands/reset-password.js +21 -8
  5. package/bin/commands/site/snapshot/export.js +1 -1
  6. package/bin/commands/site/snapshot/import.js +1 -1
  7. package/bin/lib/d1-query.js +5 -16
  8. package/bin/lib/node-env.js +40 -4
  9. package/bin/lib/site-selection.js +85 -29
  10. package/bin/lib/wrangler-cli.js +28 -2
  11. package/bin/lib/wrangler-json.js +35 -0
  12. package/dist/{app-C7_4oab9.js → app-K_Aa1MMn.js} +52476 -13330
  13. package/dist/client/.vite/manifest.json +255 -90
  14. package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
  15. package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
  16. package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
  17. package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
  18. package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
  19. package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
  20. package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
  21. package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
  22. package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
  23. package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
  24. package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
  25. package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
  26. package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
  27. package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
  28. package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
  29. package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
  30. package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
  31. package/dist/client/_assets/client-GiYENVw8.css +2 -0
  32. package/dist/client/_assets/client-VnFxJN7G.js +295 -0
  33. package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
  34. package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
  35. package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
  36. package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
  37. package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
  38. package/dist/{export-Dl5KCiEs.js → github-sync-BPAvT999.js} +2254 -107
  39. package/dist/index.js +2 -5
  40. package/dist/node.js +5 -7
  41. package/package.json +3 -3
  42. package/src/__tests__/auth-session-freshness.test.ts +57 -0
  43. package/src/__tests__/basecoat-variants.test.ts +121 -0
  44. package/src/__tests__/bin/assets-upload.test.ts +68 -0
  45. package/src/__tests__/export-hugo-build.test.ts +260 -0
  46. package/src/__tests__/export-service.test.ts +231 -0
  47. package/src/__tests__/helpers/component-css.ts +91 -0
  48. package/src/__tests__/helpers/db.ts +33 -0
  49. package/src/__tests__/helpers/export-fixtures.ts +3 -0
  50. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  51. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  52. package/src/app.tsx +71 -16
  53. package/src/auth.ts +18 -1
  54. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  55. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  56. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  57. package/src/client/__tests__/compose-bridge.test.ts +272 -0
  58. package/src/client/__tests__/compose-launch.test.ts +62 -1
  59. package/src/client/__tests__/compose-shortcuts.test.ts +38 -9
  60. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  61. package/src/client/__tests__/copy-field.test.ts +162 -0
  62. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  63. package/src/client/__tests__/image-processor.test.ts +324 -2
  64. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  65. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  66. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  67. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  68. package/src/client/__tests__/toast.test.ts +23 -0
  69. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  70. package/src/client/__tests__/video-processor.test.ts +181 -0
  71. package/src/client/audio-processor.ts +24 -15
  72. package/src/client/audio-waveform.ts +49 -0
  73. package/src/client/collection-dialog-host.ts +72 -0
  74. package/src/client/collection-navigation.ts +51 -16
  75. package/src/client/collection-page-actions.ts +26 -0
  76. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  77. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  78. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  79. package/src/client/components/__tests__/jant-command-palette.test.ts +28 -0
  80. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  81. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1250 -53
  82. package/src/client/components/__tests__/jant-compose-editor.test.ts +544 -40
  83. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +0 -1
  84. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  85. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  86. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  87. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  88. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  89. package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
  90. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  91. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  92. package/src/client/components/collection-dialog-types.ts +49 -0
  93. package/src/client/components/collection-manager-types.ts +32 -1
  94. package/src/client/components/collection-types.ts +1 -15
  95. package/src/client/components/compose-types.ts +75 -1
  96. package/src/client/components/jant-collection-dialog.ts +722 -0
  97. package/src/client/components/jant-collection-directory.ts +567 -182
  98. package/src/client/components/jant-collection-form.ts +53 -232
  99. package/src/client/components/jant-command-palette.ts +100 -49
  100. package/src/client/components/jant-compose-dialog.ts +1486 -707
  101. package/src/client/components/jant-compose-editor.ts +673 -336
  102. package/src/client/components/jant-compose-fullscreen.ts +51 -43
  103. package/src/client/components/jant-config-editor.ts +65 -53
  104. package/src/client/components/jant-confirm-dialog.ts +13 -11
  105. package/src/client/components/jant-locale-picker.ts +418 -0
  106. package/src/client/components/jant-media-lightbox.ts +146 -136
  107. package/src/client/components/jant-nav-manager.ts +690 -366
  108. package/src/client/components/jant-post-menu.ts +1086 -282
  109. package/src/client/components/jant-post-picker.ts +324 -0
  110. package/src/client/components/jant-repo-picker.ts +138 -106
  111. package/src/client/components/jant-settings-avatar.ts +31 -27
  112. package/src/client/components/jant-settings-general.ts +617 -543
  113. package/src/client/components/jant-settings-language.ts +1087 -0
  114. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  115. package/src/client/components/jant-text-preview.ts +94 -86
  116. package/src/client/components/nav-manager-types.ts +62 -1
  117. package/src/client/components/settings-types.ts +14 -29
  118. package/src/client/components/smart-collection-conditions.ts +285 -0
  119. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  120. package/src/client/compose-bridge.ts +214 -141
  121. package/src/client/compose-launch.ts +76 -2
  122. package/src/client/compose-shortcuts.ts +6 -8
  123. package/src/client/compose-triggers.ts +50 -0
  124. package/src/client/copy-field.ts +108 -0
  125. package/src/client/custom-url-menu.ts +60 -4
  126. package/src/client/icons.ts +14 -0
  127. package/src/client/image-plan.ts +165 -0
  128. package/src/client/image-processor.ts +195 -117
  129. package/src/client/image-worker-client.ts +45 -0
  130. package/src/client/image-worker.ts +135 -0
  131. package/src/client/lazy-entries.ts +95 -0
  132. package/src/client/lazy-slugify.ts +2 -2
  133. package/src/client/media-scroll-hint.ts +43 -2
  134. package/src/client/mediabunny.ts +29 -0
  135. package/src/client/post-picker.ts +41 -0
  136. package/src/client/runtime-paths.ts +74 -1
  137. package/src/client/settings-bridge.ts +4 -10
  138. package/src/client/site-header-nav.js +18 -6
  139. package/src/client/smart-collection-dialog-host.ts +116 -0
  140. package/src/client/smart-collection-page-actions.ts +208 -0
  141. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  142. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  143. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  144. package/src/client/tiptap/bubble-menu.ts +47 -16
  145. package/src/client/tiptap/floating-position.ts +216 -2
  146. package/src/client/tiptap/inline-image-upload.ts +7 -3
  147. package/src/client/tiptap/link-toolbar.ts +22 -9
  148. package/src/client/toast.ts +13 -0
  149. package/src/client/types/vite-worker.d.ts +12 -0
  150. package/src/client/upload-with-metadata.ts +3 -13
  151. package/src/client/video-processor.ts +351 -107
  152. package/src/client-auth.ts +14 -31
  153. package/src/client-compose.ts +19 -0
  154. package/src/client-manage.ts +13 -0
  155. package/src/client-settings.ts +14 -0
  156. package/src/client-site.ts +10 -3
  157. package/src/client.ts +4 -0
  158. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  159. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  160. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  161. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  162. package/src/db/migrations/0032_married_tomas.sql +3 -0
  163. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  164. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  165. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  166. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  167. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  168. package/src/db/migrations/meta/_journal.json +21 -0
  169. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  170. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  171. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  172. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  173. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  174. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  175. package/src/db/migrations/pg/meta/_journal.json +21 -0
  176. package/src/db/pg/schema.ts +195 -27
  177. package/src/db/post-visibility.ts +117 -0
  178. package/src/db/schema.ts +222 -27
  179. package/src/i18n/__tests__/detect.test.ts +26 -81
  180. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  181. package/src/i18n/context.tsx +12 -2
  182. package/src/i18n/detect.ts +7 -66
  183. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  184. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  185. package/src/i18n/locales/public/en.po +490 -158
  186. package/src/i18n/locales/public/en.ts +1 -1
  187. package/src/i18n/locales/public/zh-Hans.po +487 -155
  188. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  189. package/src/i18n/locales/public/zh-Hant.po +487 -155
  190. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  191. package/src/i18n/locales/settings/en.po +551 -69
  192. package/src/i18n/locales/settings/en.ts +1 -1
  193. package/src/i18n/locales/settings/zh-Hans.po +597 -115
  194. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  195. package/src/i18n/locales/settings/zh-Hant.po +610 -128
  196. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  197. package/src/i18n/locales.ts +94 -0
  198. package/src/i18n/middleware.ts +12 -2
  199. package/src/i18n/supported-locales.ts +80 -0
  200. package/src/lib/__tests__/api-settings.test.ts +11 -2
  201. package/src/lib/__tests__/asset-path.test.ts +92 -0
  202. package/src/lib/__tests__/deferred.test.ts +90 -0
  203. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  204. package/src/lib/__tests__/discover.test.ts +410 -0
  205. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  206. package/src/lib/__tests__/feed.test.ts +1526 -15
  207. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  208. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  209. package/src/lib/__tests__/http-cache.test.ts +162 -0
  210. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  211. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  212. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  213. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  214. package/src/lib/__tests__/navigation.test.ts +135 -1
  215. package/src/lib/__tests__/resolve-config.test.ts +64 -3
  216. package/src/lib/__tests__/schemas.test.ts +42 -4
  217. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  218. package/src/lib/__tests__/slug.test.ts +70 -0
  219. package/src/lib/__tests__/slugify.test.ts +85 -0
  220. package/src/lib/__tests__/storage.test.ts +32 -0
  221. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  222. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  223. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  224. package/src/lib/__tests__/url.test.ts +78 -72
  225. package/src/lib/__tests__/view-language.test.ts +413 -0
  226. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  227. package/src/lib/address.ts +57 -0
  228. package/src/lib/api-posts.ts +12 -0
  229. package/src/lib/asset-path.ts +98 -1
  230. package/src/lib/build-env.ts +16 -0
  231. package/src/lib/collection-groups.ts +5 -1
  232. package/src/lib/collection-paths.ts +3 -10
  233. package/src/lib/constants.ts +28 -3
  234. package/src/lib/copy-field.ts +36 -0
  235. package/src/lib/crypto.ts +40 -0
  236. package/src/lib/csp-builder.ts +2 -1
  237. package/src/lib/deferred.ts +79 -0
  238. package/src/lib/discover-ping.ts +141 -0
  239. package/src/lib/discover.ts +437 -0
  240. package/src/lib/env.ts +134 -0
  241. package/src/lib/errors.ts +33 -0
  242. package/src/lib/feed-policy.ts +236 -2
  243. package/src/lib/feed.ts +926 -73
  244. package/src/lib/filter-dimensions.ts +1349 -0
  245. package/src/lib/github-sync-site-config.ts +3 -0
  246. package/src/lib/hosted-signin.ts +1 -1
  247. package/src/lib/http-cache.ts +148 -0
  248. package/src/lib/hugo-markdown.ts +16 -0
  249. package/src/lib/icons.ts +9 -2
  250. package/src/lib/ids.ts +3 -0
  251. package/src/lib/jant-docs.ts +42 -0
  252. package/src/lib/lang-detect.ts +308 -0
  253. package/src/lib/media-helpers.ts +13 -1
  254. package/src/lib/navigation.ts +78 -11
  255. package/src/lib/pagination.ts +28 -24
  256. package/src/lib/per-language-surfaces.ts +67 -0
  257. package/src/lib/post-meta.ts +19 -0
  258. package/src/lib/render.tsx +55 -7
  259. package/src/lib/resolve-config.ts +53 -5
  260. package/src/lib/schemas.ts +201 -10
  261. package/src/lib/site-header-fragment.tsx +2 -1
  262. package/src/lib/site-unavailable-page.ts +47 -0
  263. package/src/lib/slug.ts +51 -7
  264. package/src/lib/slugify.ts +113 -0
  265. package/src/lib/startup-config.ts +4 -6
  266. package/src/lib/storage.ts +7 -0
  267. package/src/lib/summary.ts +50 -0
  268. package/src/lib/thread-fold.ts +161 -0
  269. package/src/lib/time.ts +32 -0
  270. package/src/lib/timeline.ts +42 -1
  271. package/src/lib/tiptap-render.ts +1 -2
  272. package/src/lib/upload.ts +16 -13
  273. package/src/lib/url.ts +139 -86
  274. package/src/lib/version.ts +10 -8
  275. package/src/lib/view-language.ts +436 -0
  276. package/src/lib/view.ts +51 -23
  277. package/src/lib/viewer-context.ts +62 -0
  278. package/src/middleware/__tests__/auth.test.ts +70 -2
  279. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  280. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  281. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  282. package/src/middleware/__tests__/session.test.ts +181 -33
  283. package/src/middleware/auth.ts +50 -31
  284. package/src/middleware/cache-control.ts +75 -10
  285. package/src/middleware/config.ts +6 -6
  286. package/src/middleware/error-handler.ts +12 -0
  287. package/src/middleware/onboarding.ts +32 -10
  288. package/src/middleware/secure-headers.ts +1 -1
  289. package/src/middleware/session.ts +96 -8
  290. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  291. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  292. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  293. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  294. package/src/node/__tests__/runtime.test.ts +37 -0
  295. package/src/node/index.ts +1 -0
  296. package/src/preset.css +76 -22
  297. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  298. package/src/routes/api/__tests__/collections.test.ts +114 -0
  299. package/src/routes/api/__tests__/discover.test.ts +222 -0
  300. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  301. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  302. package/src/routes/api/__tests__/settings.test.ts +24 -3
  303. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  304. package/src/routes/api/collections.ts +49 -6
  305. package/src/routes/api/custom-urls.ts +0 -1
  306. package/src/routes/api/discover.ts +95 -0
  307. package/src/routes/api/export.ts +3 -0
  308. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  309. package/src/routes/api/nav-items.ts +55 -8
  310. package/src/routes/api/posts.ts +159 -0
  311. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  312. package/src/routes/api/public/archive.ts +77 -81
  313. package/src/routes/api/public/posts.ts +23 -6
  314. package/src/routes/api/smart-collections.ts +132 -0
  315. package/src/routes/api/telegram.ts +2 -40
  316. package/src/routes/api/uploads.ts +1 -2
  317. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  318. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  319. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  320. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  321. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  322. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  323. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  324. package/src/routes/auth/setup.tsx +934 -186
  325. package/src/routes/auth/signin.tsx +10 -8
  326. package/src/routes/compose.tsx +5 -0
  327. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  328. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  329. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  330. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  331. package/src/routes/dash/custom-urls.tsx +1 -37
  332. package/src/routes/dash/settings.tsx +502 -41
  333. package/src/routes/discover-announce.ts +96 -0
  334. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  335. package/src/routes/feed/feed.ts +69 -40
  336. package/src/routes/feed/sitemap.ts +95 -11
  337. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  338. package/src/routes/pages/__tests__/archive-params.test.ts +370 -6
  339. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  340. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  341. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  342. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  343. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  344. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  345. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  346. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  347. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  348. package/src/routes/pages/archive.tsx +471 -369
  349. package/src/routes/pages/collection.tsx +94 -96
  350. package/src/routes/pages/collections.tsx +38 -50
  351. package/src/routes/pages/featured.tsx +32 -13
  352. package/src/routes/pages/home.tsx +25 -6
  353. package/src/routes/pages/language.tsx +113 -0
  354. package/src/routes/pages/latest.tsx +26 -16
  355. package/src/routes/pages/new.tsx +3 -0
  356. package/src/routes/pages/page.tsx +178 -17
  357. package/src/routes/pages/partials.tsx +41 -0
  358. package/src/routes/pages/search.tsx +18 -3
  359. package/src/routes/pages/smart-collection.tsx +340 -0
  360. package/src/routes/pages/subscribe.tsx +182 -0
  361. package/src/runtime/__tests__/index.test.ts +19 -0
  362. package/src/runtime/__tests__/node.test.ts +129 -4
  363. package/src/runtime/cloudflare.ts +21 -2
  364. package/src/runtime/index.ts +27 -1
  365. package/src/runtime/node.ts +18 -1
  366. package/src/runtime/site.ts +95 -24
  367. package/src/services/__tests__/auth.test.ts +116 -7
  368. package/src/services/__tests__/collection.test.ts +108 -4
  369. package/src/services/__tests__/language.test.ts +495 -0
  370. package/src/services/__tests__/navigation.test.ts +213 -10
  371. package/src/services/__tests__/path.test.ts +180 -0
  372. package/src/services/__tests__/post-language.test.ts +830 -0
  373. package/src/services/__tests__/post.test.ts +46 -9
  374. package/src/services/__tests__/settings.test.ts +228 -17
  375. package/src/services/__tests__/site-admin.test.ts +5 -5
  376. package/src/services/__tests__/smart-collection.test.ts +651 -0
  377. package/src/services/about-page.ts +22 -1
  378. package/src/services/auth.ts +51 -49
  379. package/src/services/bootstrap.ts +188 -38
  380. package/src/services/collection-directory-position.ts +76 -0
  381. package/src/services/collection.ts +331 -46
  382. package/src/services/custom-url.ts +27 -22
  383. package/src/services/export-theme/assets/client-site.css +1 -1
  384. package/src/services/export-theme/assets/client-site.js +123 -123
  385. package/src/services/export-theme/layouts/_default/rss.xml +77 -1
  386. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  387. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  388. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  389. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  390. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  391. package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
  392. package/src/services/export-theme/styles/main.css +13 -1
  393. package/src/services/export.ts +278 -19
  394. package/src/services/index.ts +34 -3
  395. package/src/services/language.ts +412 -0
  396. package/src/services/mcp.ts +3 -1
  397. package/src/services/media.ts +14 -6
  398. package/src/services/navigation.ts +429 -67
  399. package/src/services/path.ts +221 -6
  400. package/src/services/post.ts +1021 -152
  401. package/src/services/search.ts +39 -2
  402. package/src/services/settings.ts +238 -43
  403. package/src/services/site-admin.ts +24 -7
  404. package/src/services/site.ts +77 -1
  405. package/src/services/smart-collection.ts +636 -0
  406. package/src/services/telegram.ts +20 -22
  407. package/src/services/upload-session.ts +1 -3
  408. package/src/style-author.css +27 -0
  409. package/src/styles/components-author.css +1622 -0
  410. package/src/styles/components.css +24 -1571
  411. package/src/styles/site-media.css +16 -2
  412. package/src/styles/tokens.css +82 -12
  413. package/src/styles/ui-author.css +7602 -0
  414. package/src/styles/ui.css +1087 -7219
  415. package/src/types/app-context.ts +7 -0
  416. package/src/types/bindings.ts +3 -0
  417. package/src/types/config.ts +94 -15
  418. package/src/types/constants.ts +188 -16
  419. package/src/types/entities.ts +87 -1
  420. package/src/types/operations.ts +64 -2
  421. package/src/types/props.ts +157 -31
  422. package/src/types/views.ts +55 -1
  423. package/src/ui/__tests__/font-themes.test.ts +52 -12
  424. package/src/ui/compose/ComposeDialog.tsx +201 -7
  425. package/src/ui/compose/ComposePrompt.tsx +1 -5
  426. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  427. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  428. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  429. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  430. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  431. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  432. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  433. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  434. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  435. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  436. package/src/ui/dash/settings/GeneralContent.tsx +275 -113
  437. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  438. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  439. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  440. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  441. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  442. package/src/ui/dash/settings/discover-copy.ts +116 -0
  443. package/src/ui/feed/LinkCard.tsx +55 -45
  444. package/src/ui/feed/NoteCard.tsx +9 -4
  445. package/src/ui/feed/PostStatusBadges.tsx +90 -43
  446. package/src/ui/feed/QuoteCard.tsx +55 -30
  447. package/src/ui/feed/ThreadPreview.tsx +6 -2
  448. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  449. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  450. package/src/ui/feed/thread-preview-state.ts +20 -6
  451. package/src/ui/font-themes.ts +39 -29
  452. package/src/ui/layouts/BaseLayout.tsx +155 -10
  453. package/src/ui/layouts/SiteLayout.tsx +192 -34
  454. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  455. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  456. package/src/ui/pages/ArchivePage.tsx +282 -289
  457. package/src/ui/pages/CollectionPage.tsx +86 -76
  458. package/src/ui/pages/CollectionsPage.tsx +5 -0
  459. package/src/ui/pages/ComposePage.tsx +8 -0
  460. package/src/ui/pages/PostPage.tsx +47 -0
  461. package/src/ui/pages/SearchPage.tsx +3 -3
  462. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  463. package/src/ui/pages/SubscribePage.tsx +236 -0
  464. package/src/ui/pages/__tests__/ArchivePage.test.tsx +65 -9
  465. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  466. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  467. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  468. package/src/ui/shared/CollectionsManager.tsx +70 -17
  469. package/src/ui/shared/CopyField.tsx +127 -0
  470. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  471. package/src/ui/shared/MediaGallery.tsx +125 -46
  472. package/src/ui/shared/Pagination.tsx +20 -5
  473. package/src/ui/shared/PostFooter.tsx +20 -11
  474. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  475. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  476. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  477. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  478. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  479. package/src/ui/shared/archive-labels.ts +98 -0
  480. package/src/ui/shared/collection-management-labels.ts +94 -16
  481. package/src/ui/shared/navigation-labels.ts +45 -71
  482. package/src/ui/shared/post-article-attributes.ts +2 -0
  483. package/src/ui/shared/smart-collection-labels.ts +343 -0
  484. package/dist/app-C75Zc8dD.js +0 -6
  485. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  486. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  487. package/dist/client/_assets/chunks/url-CU9pEbk5.js +0 -1
  488. package/dist/client/_assets/client-Bq0vre8Y.js +0 -296
  489. package/dist/client/_assets/client-D3VZyG4L.css +0 -2
  490. package/dist/client/_assets/client-auth-F2rb9WfF.js +0 -5571
  491. package/dist/env-BPYViDJJ.js +0 -281
  492. package/dist/github-api-BNF35Lkx.js +0 -176
  493. package/dist/github-app-DdiD-bC4.js +0 -275
  494. package/dist/github-sync-Bqg62IvV.js +0 -4
  495. package/dist/github-sync-HdK2EgvJ.js +0 -431
  496. package/dist/url-CbLAtlZe.js +0 -900
  497. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  498. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  499. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  500. package/src/client/collection-created-notice.ts +0 -66
  501. package/src/client/collection-form-bridge.ts +0 -191
  502. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  503. package/src/client/components/jant-post-form.ts +0 -422
  504. package/src/client/components/post-form-template.ts +0 -352
  505. package/src/client/components/post-form-types.ts +0 -96
  506. package/src/client/media-metadata.ts +0 -247
  507. package/src/client/post-form-bridge.ts +0 -193
  508. package/src/ui/pages/CollectionEditorPage.tsx +0 -175
@@ -0,0 +1,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,8 @@ import {
5
5
  QUEUED_TOAST_STORAGE_KEY,
6
6
  consumeQueuedToast,
7
7
  queueToastForNextPage,
8
+ replaceWithAutoClose,
9
+ showPersistentToast,
8
10
  showToast,
9
11
  showToastWithAction,
10
12
  } from "../toast.js";
@@ -110,6 +112,27 @@ describe("toast", () => {
110
112
  expect(container?.parentElement).toBe(document.body);
111
113
  });
112
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
+
113
136
  it("keeps action toasts available longer than passive notifications", () => {
114
137
  vi.useFakeTimers();
115
138
  showToastWithAction("Collection created.", {
@@ -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) {
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Client-side Audio Waveform Extraction
3
+ *
4
+ * Decodes an audio file with the Web Audio API and samples the peaks the
5
+ * player draws. Image dimensions and blurhash come from `ImageProcessor`;
6
+ * video dimensions, poster, and blurhash come from `VideoProcessor`.
7
+ */
8
+
9
+ /**
10
+ * Extract waveform peak amplitudes from an audio file.
11
+ * Decodes via Web Audio API and returns a JSON string of ~100 normalized peak values (0–1).
12
+ *
13
+ * @param file - Audio file to extract peaks from
14
+ * @returns JSON string of peak values, e.g. "[0.2,0.8,0.5,...]"
15
+ */
16
+ export async function extractAudioWaveform(file: File): Promise<string> {
17
+ const buffer = await file.arrayBuffer();
18
+ const audioCtx = new AudioContext();
19
+
20
+ try {
21
+ const decoded = await audioCtx.decodeAudioData(buffer);
22
+ const raw = decoded.getChannelData(0);
23
+ const count = 100;
24
+ const step = Math.max(1, Math.floor(raw.length / count));
25
+ const peaks: number[] = new Array(count);
26
+
27
+ for (let i = 0; i < count; i++) {
28
+ let max = 0;
29
+ const start = i * step;
30
+ const end = Math.min(start + step, raw.length);
31
+ for (let j = start; j < end; j++) {
32
+ const v = Math.abs(raw[j]);
33
+ if (v > max) max = v;
34
+ }
35
+ peaks[i] = max;
36
+ }
37
+
38
+ let maxPeak = 0;
39
+ for (const p of peaks) if (p > maxPeak) maxPeak = p;
40
+ if (maxPeak > 0) {
41
+ for (let i = 0; i < count; i++)
42
+ peaks[i] = Math.round((peaks[i] / maxPeak) * 100) / 100;
43
+ }
44
+
45
+ return JSON.stringify(peaks);
46
+ } finally {
47
+ await audioCtx.close();
48
+ }
49
+ }