@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,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
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Collection dialog host.
3
+ *
4
+ * One dialog element for the whole page, mounted lazily and reused. Four
5
+ * places open it — the collections page toolbar, a directory row's menu, a
6
+ * collection page's own menu, and the navigation manager — and they all go
7
+ * through {@link openCollectionDialog}, because creating and editing are one
8
+ * surface.
9
+ */
10
+
11
+ import "./components/jant-collection-dialog.js";
12
+ import type { JantCollectionDialog } from "./components/jant-collection-dialog.js";
13
+ import type {
14
+ CollectionDialogLabels,
15
+ CollectionDialogResult,
16
+ } from "./components/collection-dialog-types.js";
17
+
18
+ const LABELS_ATTRIBUTE = "data-collection-dialog-labels";
19
+
20
+ let labels: CollectionDialogLabels | null = null;
21
+ let element: JantCollectionDialog | null = null;
22
+
23
+ /**
24
+ * The dialog's strings, rendered into the page by whichever surface can open
25
+ * it. Read once and cached: they are the same on every surface of a page.
26
+ */
27
+ function readLabels(): CollectionDialogLabels | null {
28
+ if (labels) return labels;
29
+ const holder = document.querySelector<HTMLElement>(`[${LABELS_ATTRIBUTE}]`);
30
+ const raw = holder?.getAttribute(LABELS_ATTRIBUTE);
31
+ if (!raw) return null;
32
+ try {
33
+ labels = JSON.parse(raw) as CollectionDialogLabels;
34
+ return labels;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ function ensureElement(
41
+ dialogLabels: CollectionDialogLabels,
42
+ ): JantCollectionDialog {
43
+ if (element?.isConnected) {
44
+ element.labels = dialogLabels;
45
+ return element;
46
+ }
47
+ element = document.createElement(
48
+ "jant-collection-dialog",
49
+ ) as JantCollectionDialog;
50
+ element.labels = dialogLabels;
51
+ document.body.appendChild(element);
52
+ return element;
53
+ }
54
+
55
+ /**
56
+ * Open the collection dialog.
57
+ *
58
+ * @param options.collectionId - Edit this one; omit to create a new one
59
+ * @returns What happened, and the collection when one was saved
60
+ * @example
61
+ * const { changed } = await openCollectionDialog({ collectionId: "col_…" });
62
+ */
63
+ export async function openCollectionDialog(
64
+ options: {
65
+ collectionId?: string;
66
+ } = {},
67
+ ): Promise<CollectionDialogResult> {
68
+ const dialogLabels = readLabels();
69
+ if (!dialogLabels) return { changed: false };
70
+
71
+ return ensureElement(dialogLabels).open(options);
72
+ }
@@ -9,16 +9,14 @@ const SITE_HEADER_REQUEST_HEADER = "X-Jant-Site-Header";
9
9
  const INCLUDE_SITE_HEADER_RESPONSE = "include";
10
10
 
11
11
  /**
12
- * Add a Collection to the end of the primary navigation and refresh the
13
- * rendered site header when the server returns a fragment.
12
+ * Create a navigation item and refresh the rendered site header when the
13
+ * server returns a fragment.
14
14
  *
15
- * @param collectionId - TypeID of the Collection to add
15
+ * @param body - Request body for `POST /api/nav-items`
16
16
  * @returns The created navigation item ID when present
17
- * @example
18
- * const navItemId = await addCollectionToNavigation("col_01abc");
19
17
  */
20
- export async function addCollectionToNavigation(
21
- collectionId: string,
18
+ async function createNavItem(
19
+ body: Record<string, unknown>,
22
20
  ): Promise<string | undefined> {
23
21
  const response = await fetch("/api/nav-items", {
24
22
  method: "POST",
@@ -27,18 +25,55 @@ export async function addCollectionToNavigation(
27
25
  Accept: "application/json",
28
26
  [SITE_HEADER_REQUEST_HEADER]: INCLUDE_SITE_HEADER_RESPONSE,
29
27
  },
30
- body: JSON.stringify({
31
- type: "collection",
32
- collectionId,
33
- placement: "header",
34
- }),
28
+ body: JSON.stringify(body),
35
29
  });
36
- const body = await readJsonObject(response);
30
+ const responseBody = await readJsonObject(response);
37
31
 
38
32
  if (!response.ok) {
39
- throw new Error(getJsonString(body, "error") ?? `HTTP ${response.status}`);
33
+ throw new Error(
34
+ getJsonString(responseBody, "error") ?? `HTTP ${response.status}`,
35
+ );
40
36
  }
41
37
 
42
- applySiteHeaderHtml(getJsonString(body, "headerHtml"));
43
- return getJsonString(body, "id");
38
+ applySiteHeaderHtml(getJsonString(responseBody, "headerHtml"));
39
+ return getJsonString(responseBody, "id");
40
+ }
41
+
42
+ /**
43
+ * Add a Collection to the end of the primary navigation and refresh the
44
+ * rendered site header when the server returns a fragment.
45
+ *
46
+ * @param collectionId - TypeID of the Collection to add
47
+ * @returns The created navigation item ID when present
48
+ * @example
49
+ * const navItemId = await addCollectionToNavigation("col_01abc");
50
+ */
51
+ export async function addCollectionToNavigation(
52
+ collectionId: string,
53
+ ): Promise<string | undefined> {
54
+ return createNavItem({
55
+ type: "collection",
56
+ collectionId,
57
+ placement: "header",
58
+ });
59
+ }
60
+
61
+ /**
62
+ * Add a smart collection to the end of the primary navigation. Placement
63
+ * matches {@link addCollectionToNavigation} — the two kinds are one thing to
64
+ * the reader, so the same action puts them in the same place.
65
+ *
66
+ * @param smartCollectionId - TypeID of the smart collection to add
67
+ * @returns The created navigation item ID when present
68
+ * @example
69
+ * const navItemId = await addSmartCollectionToNavigation("smc_01abc");
70
+ */
71
+ export async function addSmartCollectionToNavigation(
72
+ smartCollectionId: string,
73
+ ): Promise<string | undefined> {
74
+ return createNavItem({
75
+ type: "smart_collection",
76
+ smartCollectionId,
77
+ placement: "header",
78
+ });
44
79
  }
@@ -1,5 +1,6 @@
1
1
  import { getCollectionsDirectoryPath } from "../lib/collection-paths.js";
2
2
  import { NAVIGATION_SETTINGS_PATH } from "../lib/settings-paths.js";
3
+ import { openCollectionDialog } from "./collection-dialog-host.js";
3
4
  import { addCollectionToNavigation } from "./collection-navigation.js";
4
5
  import { showConfirmDialog } from "./confirm.js";
5
6
  import { showToast, showToastWithAction } from "./toast.js";
@@ -95,6 +96,27 @@ document
95
96
  }
96
97
  };
97
98
 
99
+ const handleEdit = async () => {
100
+ closeMenu(false);
101
+ const { changed, collection } = await openCollectionDialog({
102
+ collectionId,
103
+ });
104
+ if (!changed) return;
105
+
106
+ // The address can have moved, and the old one stops resolving the moment
107
+ // it does — so the page follows it rather than reloading into a 404.
108
+ // Only the last segment changes: whatever site or language prefix this
109
+ // reader arrived under is theirs to keep.
110
+ const nextPath = collection?.slug
111
+ ? window.location.pathname.replace(/[^/]*$/, collection.slug)
112
+ : null;
113
+ if (nextPath && nextPath !== window.location.pathname) {
114
+ window.location.href = `${nextPath}${window.location.search}`;
115
+ return;
116
+ }
117
+ window.location.reload();
118
+ };
119
+
98
120
  const handleDelete = async () => {
99
121
  closeMenu(false);
100
122
 
@@ -197,6 +219,10 @@ document
197
219
  if (!actionEl || !root.contains(actionEl)) return;
198
220
 
199
221
  const action = actionEl.dataset.collectionPageAction;
222
+ if (action === "edit") {
223
+ event.preventDefault();
224
+ void handleEdit();
225
+ }
200
226
  if (action === "delete") {
201
227
  event.preventDefault();
202
228
  void handleDelete();
@@ -55,7 +55,6 @@ describe("convertComposeFormat", () => {
55
55
  "quote",
56
56
  fields({
57
57
  title: "My thoughts",
58
- showTitle: true,
59
58
  bodyJson: doc(paragraph("body")),
60
59
  }),
61
60
  );
@@ -216,7 +215,6 @@ describe("convertComposeFormat", () => {
216
215
  "link",
217
216
  fields({
218
217
  title: "Keep me",
219
- showTitle: true,
220
218
  bodyJson: doc(paragraph("body")),
221
219
  }),
222
220
  );
@@ -236,7 +234,6 @@ describe("convertComposeFormat", () => {
236
234
  }),
237
235
  );
238
236
  expect(result.title).toBe("Keep me");
239
- expect(result.showTitle).toBe(true);
240
237
  expect(result.url).toBe("");
241
238
  expect(firstBlock(result)).toEqual({
242
239
  type: "paragraph",
@@ -317,22 +314,6 @@ describe("convertComposeFormat", () => {
317
314
  expect(result.bodyJson).toBeNull();
318
315
  });
319
316
 
320
- it("sets showTitle only for a note that ends up with a title", () => {
321
- const toNote = convertComposeFormat(
322
- "link",
323
- "note",
324
- fields({ title: "T", url: "https://example.com" }),
325
- );
326
- expect(toNote.showTitle).toBe(true);
327
-
328
- const toQuote = convertComposeFormat(
329
- "note",
330
- "quote",
331
- fields({ title: "T" }),
332
- );
333
- expect(toQuote.showTitle).toBe(false);
334
- });
335
-
336
317
  it("treats a non-attribution last line as part of the quote text", () => {
337
318
  const result = convertComposeFormat(
338
319
  "note",
@@ -0,0 +1,329 @@
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("../../lazy-slugify.js", () => ({
11
+ slugify: (text: string) =>
12
+ Promise.resolve(
13
+ text
14
+ .toLowerCase()
15
+ .trim()
16
+ .replace(/[^\w\s-]/g, "")
17
+ .replace(/[\s_-]+/g, "-")
18
+ .replace(/^-+|-+$/g, ""),
19
+ ),
20
+ preloadSlug: () => {},
21
+ }));
22
+
23
+ // happy-dom has no contenteditable, so the description editor is stood in for.
24
+ let editorContent: string | undefined;
25
+ vi.mock("../../tiptap/create-editor.js", () => ({
26
+ createSettingsEditor: (opts: { element: HTMLElement; content?: string }) => {
27
+ editorContent = opts.content;
28
+ opts.element.innerHTML =
29
+ '<div class="ProseMirror" contenteditable="true"></div>';
30
+ return {
31
+ getJSON: () => ({ type: "doc", content: [] }),
32
+ destroy: () => {},
33
+ };
34
+ },
35
+ jsonToMarkdown: () => "",
36
+ }));
37
+
38
+ import "../jant-collection-dialog.js";
39
+ import type { JantCollectionDialog } from "../jant-collection-dialog.js";
40
+ import type { CollectionDialogLabels } from "../collection-dialog-types.js";
41
+
42
+ /**
43
+ * The dialog is now the only place a collection is written, so what it has to
44
+ * hold is what the editor page used to: an address derived from the title and
45
+ * checked against the server, a warning before an existing link moves, and a
46
+ * save that reports where the collection ended up.
47
+ */
48
+
49
+ const labels: CollectionDialogLabels = {
50
+ createHeading: "New Collection",
51
+ editHeading: "Edit Collection",
52
+ title: "Title",
53
+ titlePlaceholder: "My Collection",
54
+ link: "Collection link",
55
+ linkHelp: "This is the last part of the collection link.",
56
+ editLink: "Edit link",
57
+ resetLink: "Reset link",
58
+ linkTaken: "This link is taken. Choose another.",
59
+ linkInvalid: "Use lowercase letters, numbers, and hyphens only.",
60
+ linkReserved: "This link is reserved. Choose something else.",
61
+ linkTooLong: "Keep this link under 200 characters.",
62
+ linkMovesWarning: "Changing the link breaks the old one immediately.",
63
+ description: "Description (optional)",
64
+ descriptionPlaceholder: "What's this collection about?",
65
+ orderBy: "Order by",
66
+ sortOptions: {
67
+ newest: "Newest first",
68
+ oldest: "Oldest first",
69
+ rating_desc: "Highest rated",
70
+ },
71
+ cancel: "Cancel",
72
+ save: "Save",
73
+ saved: "Collection saved.",
74
+ saveFailed: "Couldn't save. Try again in a moment.",
75
+ loadFailed: "Could not open this collection. Try again.",
76
+ titleAndLinkRequired: "A collection needs a title and a link.",
77
+ };
78
+
79
+ /** Responses the dialog's fetches get, keyed by the path they hit. */
80
+ let responses: Record<string, { ok?: boolean; body: unknown }>;
81
+ let requests: Array<{ url: string; method: string; body: unknown }>;
82
+
83
+ function mountDialog(): JantCollectionDialog {
84
+ const element = document.createElement(
85
+ "jant-collection-dialog",
86
+ ) as JantCollectionDialog;
87
+ element.labels = labels;
88
+ document.body.appendChild(element);
89
+ return element;
90
+ }
91
+
92
+ /** Let the debounced address check and the slugify round trip settle. */
93
+ async function settle(element: JantCollectionDialog) {
94
+ await vi.advanceTimersByTimeAsync(400);
95
+ await element.updateComplete;
96
+ }
97
+
98
+ function findButton(
99
+ element: JantCollectionDialog,
100
+ text: string,
101
+ ): HTMLButtonElement | undefined {
102
+ return [...element.querySelectorAll("button")].find(
103
+ (button) => button.textContent?.trim() === text,
104
+ );
105
+ }
106
+
107
+ async function typeTitle(element: JantCollectionDialog, value: string) {
108
+ const input = element.querySelector<HTMLInputElement>("[data-field='title']");
109
+ if (!input) throw new Error("Expected the title field");
110
+ input.value = value;
111
+ input.dispatchEvent(new Event("input", { bubbles: true }));
112
+ await settle(element);
113
+ }
114
+
115
+ beforeEach(() => {
116
+ document.body.innerHTML = "";
117
+ vi.useFakeTimers();
118
+ editorContent = undefined;
119
+
120
+ responses = {
121
+ "/api/collections/slug": { body: { slug: "reading", available: true } },
122
+ "/api/collections": { body: { id: "col_new", slug: "reading" } },
123
+ "/api/collections/col_books": {
124
+ body: {
125
+ id: "col_books",
126
+ slug: "books",
127
+ title: "Books",
128
+ description: "Notes from books",
129
+ sortOrder: "oldest",
130
+ },
131
+ },
132
+ };
133
+ requests = [];
134
+
135
+ vi.stubGlobal(
136
+ "fetch",
137
+ vi.fn(async (url: string, init?: globalThis.RequestInit) => {
138
+ const path = url.split("?")[0] ?? url;
139
+ requests.push({
140
+ url,
141
+ method: init?.method ?? "GET",
142
+ body: init?.body ? JSON.parse(String(init.body)) : undefined,
143
+ });
144
+ const response = responses[path] ?? { body: {} };
145
+ return {
146
+ ok: response.ok ?? true,
147
+ status: response.ok === false ? 400 : 200,
148
+ json: async () => response.body,
149
+ } as Response;
150
+ }),
151
+ );
152
+
153
+ // happy-dom's <dialog> has no modal implementation.
154
+ HTMLDialogElement.prototype.showModal = function showModal() {
155
+ this.open = true;
156
+ };
157
+ HTMLDialogElement.prototype.close = function close() {
158
+ this.open = false;
159
+ };
160
+ });
161
+
162
+ describe("JantCollectionDialog", () => {
163
+ it("opens on the create heading with nothing filled in", async () => {
164
+ const element = mountDialog();
165
+ void element.open({});
166
+ await settle(element);
167
+
168
+ expect(element.textContent).toContain("New Collection");
169
+ expect(
170
+ element.querySelector<HTMLInputElement>("[data-field='title']")?.value,
171
+ ).toBe("");
172
+ // The link only appears once there is one to show.
173
+ expect(element.querySelector(".collection-quick-link-box")).toBeNull();
174
+ });
175
+
176
+ it("derives the address from the title and asks whether it is free", async () => {
177
+ const element = mountDialog();
178
+ void element.open({});
179
+ await settle(element);
180
+ await typeTitle(element, "Reading");
181
+
182
+ expect(
183
+ element.querySelector<HTMLElement>(".collection-quick-link-preview")
184
+ ?.textContent,
185
+ ).toContain("/reading");
186
+ expect(
187
+ requests.some(
188
+ (request) =>
189
+ request.url.includes("/api/collections/slug") &&
190
+ request.url.includes("slug=reading"),
191
+ ),
192
+ ).toBe(true);
193
+ });
194
+
195
+ it("refuses to save onto an address something else already holds", async () => {
196
+ responses["/api/collections/slug"] = {
197
+ body: { slug: "reading", available: false },
198
+ };
199
+ const element = mountDialog();
200
+ void element.open({});
201
+ await settle(element);
202
+ await typeTitle(element, "Reading");
203
+
204
+ expect(element.textContent).toContain(
205
+ "This link is taken. Choose another.",
206
+ );
207
+ expect(findButton(element, "Save")?.disabled).toBe(true);
208
+ });
209
+
210
+ it("loads an existing collection, and warns before its link moves", async () => {
211
+ const element = mountDialog();
212
+ void element.open({ collectionId: "col_books" });
213
+ await settle(element);
214
+
215
+ expect(element.textContent).toContain("Edit Collection");
216
+ expect(
217
+ element.querySelector<HTMLInputElement>("[data-field='title']")?.value,
218
+ ).toBe("Books");
219
+ const sortSelect = element.querySelector(
220
+ "[data-field='sort']",
221
+ ) as HTMLSelectElement | null;
222
+ expect(sortSelect?.value).toBe("oldest");
223
+ expect(editorContent).toBe("Notes from books");
224
+ expect(element.textContent).not.toContain(
225
+ "Changing the link breaks the old one immediately.",
226
+ );
227
+
228
+ findButton(element, "Edit link")?.click();
229
+ await element.updateComplete;
230
+ const slugInput = element.querySelector<HTMLInputElement>(
231
+ "[data-field='slug']",
232
+ );
233
+ if (!slugInput) throw new Error("Expected the link field");
234
+ slugInput.value = "reading";
235
+ slugInput.dispatchEvent(new Event("input", { bubbles: true }));
236
+ await settle(element);
237
+
238
+ expect(element.textContent).toContain(
239
+ "Changing the link breaks the old one immediately.",
240
+ );
241
+ });
242
+
243
+ it("creates a collection and reports where it landed", async () => {
244
+ const element = mountDialog();
245
+ const result = element.open({});
246
+ await settle(element);
247
+ await typeTitle(element, "Reading");
248
+
249
+ findButton(element, "Save")?.click();
250
+ await settle(element);
251
+
252
+ const save = requests.find((request) => request.method === "POST");
253
+ expect(save?.url).toContain("/api/collections");
254
+ expect(save?.body).toEqual({
255
+ slug: "reading",
256
+ title: "Reading",
257
+ sortOrder: "newest",
258
+ });
259
+ await expect(result).resolves.toEqual({
260
+ changed: true,
261
+ collection: { id: "col_new", slug: "reading", title: "Reading" },
262
+ });
263
+ });
264
+
265
+ it("saves an edit with PUT, and clears a description that was emptied", async () => {
266
+ responses["/api/collections/col_books"] = {
267
+ body: {
268
+ id: "col_books",
269
+ slug: "books",
270
+ title: "Books",
271
+ description: "Notes from books",
272
+ sortOrder: "newest",
273
+ },
274
+ };
275
+ const element = mountDialog();
276
+ void element.open({ collectionId: "col_books" });
277
+ await settle(element);
278
+
279
+ findButton(element, "Save")?.click();
280
+ await settle(element);
281
+
282
+ const save = requests.find((request) => request.method === "PUT");
283
+ expect(save?.url).toContain("/api/collections/col_books");
284
+ // The stubbed editor round-trips to an empty document, so the edit is
285
+ // clearing the description — which only an explicit null can do.
286
+ expect(save?.body).toEqual({
287
+ slug: "books",
288
+ title: "Books",
289
+ sortOrder: "newest",
290
+ description: null,
291
+ });
292
+ });
293
+
294
+ it("keeps a failed save open, with the reason the server gave", async () => {
295
+ responses["/api/collections"] = {
296
+ ok: false,
297
+ body: { error: "That link is reserved." },
298
+ };
299
+ const element = mountDialog();
300
+ void element.open({});
301
+ await settle(element);
302
+ await typeTitle(element, "Reading");
303
+
304
+ findButton(element, "Save")?.click();
305
+ await settle(element);
306
+
307
+ expect(element.textContent).toContain("That link is reserved.");
308
+ expect(
309
+ element.querySelector<HTMLDialogElement>(".collection-dialog")?.open,
310
+ ).toBe(true);
311
+ });
312
+
313
+ it("closes on Escape without saving", async () => {
314
+ const element = mountDialog();
315
+ const result = element.open({});
316
+ await settle(element);
317
+ await typeTitle(element, "Reading");
318
+
319
+ element
320
+ .querySelector<HTMLInputElement>("[data-field='title']")
321
+ ?.dispatchEvent(
322
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
323
+ );
324
+ await element.updateComplete;
325
+
326
+ await expect(result).resolves.toEqual({ changed: false });
327
+ expect(requests.some((request) => request.method === "POST")).toBe(false);
328
+ });
329
+ });