@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,665 @@
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
+ // The real slugify loads the transliteration dictionary through a dynamic
11
+ // import, which fake timers never advance past; the dialog's behaviour does
12
+ // not depend on which slug comes back.
13
+ vi.mock("../../lazy-slugify.js", () => ({
14
+ slugify: (text: string) =>
15
+ Promise.resolve(
16
+ text
17
+ .toLowerCase()
18
+ .trim()
19
+ .replace(/[^\w\s-]/g, "")
20
+ .replace(/[\s_-]+/g, "-")
21
+ .replace(/^-+|-+$/g, ""),
22
+ ),
23
+ preloadSlug: () => {},
24
+ }));
25
+
26
+ import "../jant-smart-collection-dialog.js";
27
+ import type { JantSmartCollectionDialog } from "../jant-smart-collection-dialog.js";
28
+ import { setCollectionVocabulary } from "../smart-collection-conditions.js";
29
+ import type { SmartCollectionDialogLabels } from "../smart-collection-dialog-types.js";
30
+
31
+ /**
32
+ * The dialog is the only place a smart collection is written, so the behaviours
33
+ * it has to hold are the ones the design turns on: one row per dimension,
34
+ * conditions that survive a round trip through the shared vocabulary, a live
35
+ * count, and Escape working from inside the fields.
36
+ */
37
+
38
+ const labels: SmartCollectionDialogLabels = {
39
+ createHeading: "New Smart Collection",
40
+ editHeading: "Edit Smart Collection",
41
+ whatItIs:
42
+ "Conditions choose what belongs here, not you. Posts you write later join on their own.",
43
+ title: "Title",
44
+ link: "Collection link",
45
+ linkHelp: "This is the last part of the collection link.",
46
+ editLink: "Edit link",
47
+ resetLink: "Reset link",
48
+ linkTaken: "This link is taken. Choose another.",
49
+ linkInvalid: "Use lowercase letters, numbers, and hyphens only.",
50
+ linkReserved: "This link is reserved. Choose something else.",
51
+ linkTooLong: "Keep this link under 200 characters.",
52
+ linkMovesWarning: "Changing the link breaks the old one immediately.",
53
+ description: "Description",
54
+ conditionsHeading: "Conditions",
55
+ matchAllHint: "Posts matching all of these",
56
+ noConditions: "No conditions yet. Add one to choose what lands here.",
57
+ addCondition: "Add condition",
58
+ removeCondition: "Remove condition",
59
+ countSummary: "{count} of {total} threads",
60
+ counting: "Counting…",
61
+ displayHeading: "Display",
62
+ orderBy: "Order by",
63
+ layout: "Layout",
64
+ cancel: "Cancel",
65
+ save: "Save",
66
+ saved: "Smart collection saved.",
67
+ saveFailed: "Could not save. Try again.",
68
+ loadFailed: "Could not open this smart collection. Try again.",
69
+ titleAndLinkRequired: "A smart collection needs a title and a link.",
70
+ dimensions: {
71
+ collection: "Collection",
72
+ format: "Format",
73
+ title: "Title",
74
+ year: "Year",
75
+ media: "Media",
76
+ replies: "Replies",
77
+ visibility: "Visibility",
78
+ },
79
+ values: {
80
+ "format.note": "Notes",
81
+ "format.link": "Links",
82
+ "format.quote": "Quotes",
83
+ "title.any": "Titled",
84
+ "title.none": "Untitled",
85
+ "replies.any": "Threads",
86
+ "replies.none": "Single posts",
87
+ "visibility.public": "Public",
88
+ "visibility.featured": "Featured",
89
+ "visibility.hidden": "Hidden from Latest",
90
+ "media.any": "With media",
91
+ "media.none": "Without media",
92
+ "media.image": "Images",
93
+ },
94
+ sortOptions: { newest: "Newest first", oldest: "Oldest first" },
95
+ layoutOptions: { "": "Follow site default", list: "List", grid: "Grid" },
96
+ };
97
+
98
+ /** Responses the dialog's fetches get, keyed by the path they hit. */
99
+ let responses: Record<string, unknown>;
100
+ let requests: Array<{ url: string; method: string; body: unknown }>;
101
+
102
+ function mountDialog(): JantSmartCollectionDialog {
103
+ const element = document.createElement(
104
+ "jant-smart-collection-dialog",
105
+ ) as JantSmartCollectionDialog;
106
+ element.labels = labels;
107
+ document.body.appendChild(element);
108
+ return element;
109
+ }
110
+
111
+ /** Let the debounced preview and slug requests fire and settle. */
112
+ async function settle(element: JantSmartCollectionDialog) {
113
+ await vi.advanceTimersByTimeAsync(400);
114
+ await element.updateComplete;
115
+ }
116
+
117
+ beforeEach(() => {
118
+ document.body.innerHTML = "";
119
+ vi.useFakeTimers();
120
+ setCollectionVocabulary([{ id: "col_books", slug: "books", title: "Books" }]);
121
+
122
+ responses = {
123
+ "/api/smart-collections/preview": { count: 3, baseline: 42 },
124
+ "/api/smart-collections/slug": { slug: "quotes", available: true },
125
+ };
126
+ requests = [];
127
+
128
+ vi.stubGlobal(
129
+ "fetch",
130
+ vi.fn(async (url: string, init?: globalThis.RequestInit) => {
131
+ const path = url.split("?")[0] ?? url;
132
+ requests.push({
133
+ url,
134
+ method: init?.method ?? "GET",
135
+ body: init?.body ? JSON.parse(String(init.body)) : undefined,
136
+ });
137
+ const body = responses[path] ?? { smartCollection: {} };
138
+ return {
139
+ ok: true,
140
+ status: 200,
141
+ json: async () => body,
142
+ } as Response;
143
+ }),
144
+ );
145
+
146
+ // happy-dom's <dialog> has no modal implementation.
147
+ HTMLDialogElement.prototype.showModal = function showModal() {
148
+ this.open = true;
149
+ };
150
+ HTMLDialogElement.prototype.close = function close() {
151
+ this.open = false;
152
+ };
153
+ });
154
+
155
+ describe("JantSmartCollectionDialog", () => {
156
+ it("opens empty, and says so rather than showing a blank condition list", async () => {
157
+ const element = mountDialog();
158
+ void element.open({});
159
+ await settle(element);
160
+
161
+ expect(element.textContent).toContain("New Smart Collection");
162
+ expect(element.textContent).toContain(
163
+ "No conditions yet. Add one to choose what lands here.",
164
+ );
165
+ expect(element.querySelectorAll("[data-condition]")).toHaveLength(0);
166
+ });
167
+
168
+ it("offers each dimension once, and stops offering one that is in use", async () => {
169
+ const element = mountDialog();
170
+ void element.open({ prefill: { selection: { format: "quote" } } });
171
+ await settle(element);
172
+
173
+ expect(element.querySelectorAll("[data-condition]")).toHaveLength(1);
174
+
175
+ const addButton = [...element.querySelectorAll("button")].find(
176
+ (button) => button.textContent?.trim() === "Add condition",
177
+ );
178
+ addButton?.click();
179
+ await element.updateComplete;
180
+
181
+ const offered = [...element.querySelectorAll("[role='menuitem']")].map(
182
+ (item) => item.textContent?.trim(),
183
+ );
184
+
185
+ // Seven dimensions, one already used.
186
+ expect(offered).toHaveLength(6);
187
+ expect(offered).not.toContain("Format");
188
+ expect(offered).toContain("Year");
189
+ });
190
+
191
+ it("dismisses the condition menu on a click elsewhere in the dialog", async () => {
192
+ const element = mountDialog();
193
+ void element.open({});
194
+ await settle(element);
195
+
196
+ const trigger =
197
+ element.querySelector<HTMLButtonElement>("[data-add-trigger]");
198
+ trigger?.click();
199
+ await element.updateComplete;
200
+ expect(element.querySelector("[data-add-menu]")).not.toBeNull();
201
+
202
+ // Not the backdrop — the panel stops that click, so a menu that only
203
+ // listened to the backdrop would stay open over the fields it covers.
204
+ element.querySelector<HTMLElement>(".collection-dialog-body")?.click();
205
+ await element.updateComplete;
206
+
207
+ expect(element.querySelector("[data-add-menu]")).toBeNull();
208
+ });
209
+
210
+ it("closes the condition menu on Escape, and leaves the dialog open", async () => {
211
+ const element = mountDialog();
212
+ const closed = element.open({});
213
+ await settle(element);
214
+
215
+ element.querySelector<HTMLButtonElement>("[data-add-trigger]")?.click();
216
+ await element.updateComplete;
217
+
218
+ element.dispatchEvent(
219
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
220
+ );
221
+ await element.updateComplete;
222
+
223
+ expect(element.querySelector("[data-add-menu]")).toBeNull();
224
+ expect(element.querySelector("dialog")?.open).toBe(true);
225
+
226
+ // A second Escape, now that the menu is gone, closes the dialog itself.
227
+ element.dispatchEvent(
228
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
229
+ );
230
+ await expect(closed).resolves.toBe(false);
231
+ });
232
+
233
+ it("walks the condition menu with the arrow keys", async () => {
234
+ const element = mountDialog();
235
+ void element.open({});
236
+ await settle(element);
237
+
238
+ element.querySelector<HTMLButtonElement>("[data-add-trigger]")?.click();
239
+ await element.updateComplete;
240
+ await Promise.resolve();
241
+
242
+ const items = [
243
+ ...element.querySelectorAll<HTMLButtonElement>(
244
+ "[data-add-menu] [role='menuitem']",
245
+ ),
246
+ ];
247
+ items[0]?.focus();
248
+
249
+ element.dispatchEvent(
250
+ new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }),
251
+ );
252
+ expect(document.activeElement).toBe(items[1]);
253
+
254
+ // Up from the first item wraps to the last rather than falling out.
255
+ items[0]?.focus();
256
+ element.dispatchEvent(
257
+ new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }),
258
+ );
259
+ expect(document.activeElement).toBe(items[items.length - 1]);
260
+ });
261
+
262
+ it("picks a condition on Enter instead of saving", async () => {
263
+ const element = mountDialog();
264
+ void element.open({ prefill: { title: "Quotes" } });
265
+ await settle(element);
266
+
267
+ element.querySelector<HTMLButtonElement>("[data-add-trigger]")?.click();
268
+ await element.updateComplete;
269
+
270
+ const item = element.querySelector<HTMLButtonElement>(
271
+ "[data-add-menu] [role='menuitem']",
272
+ );
273
+ item?.dispatchEvent(
274
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
275
+ );
276
+ await settle(element);
277
+
278
+ expect(
279
+ requests.some(
280
+ (request) =>
281
+ request.method === "POST" && !request.url.includes("/preview"),
282
+ ),
283
+ ).toBe(false);
284
+ });
285
+
286
+ it("saves the conditions it was given, in the shared vocabulary", async () => {
287
+ const element = mountDialog();
288
+ void element.open({
289
+ prefill: {
290
+ title: "Quotes",
291
+ selection: { format: "quote", media: "any", title: false },
292
+ },
293
+ });
294
+ await settle(element);
295
+
296
+ const save = [...element.querySelectorAll("button")].find(
297
+ (button) => button.textContent?.trim() === "Save",
298
+ );
299
+ save?.click();
300
+ await settle(element);
301
+
302
+ const write = requests.find(
303
+ (request) =>
304
+ request.method === "POST" &&
305
+ request.url.includes("/api/smart-collections") &&
306
+ !request.url.includes("/preview"),
307
+ );
308
+ expect(write?.body).toMatchObject({
309
+ title: "Quotes",
310
+ selection: { format: "quote", media: "any", title: false },
311
+ });
312
+ });
313
+
314
+ it("removes a condition, and clears it from what gets saved", async () => {
315
+ const element = mountDialog();
316
+ void element.open({
317
+ prefill: { title: "Quotes", selection: { format: "quote", year: 2024 } },
318
+ });
319
+ await settle(element);
320
+ expect(element.querySelectorAll("[data-condition]")).toHaveLength(2);
321
+
322
+ const remove = element
323
+ .querySelector("[data-condition='year']")
324
+ ?.querySelector("button");
325
+ remove?.click();
326
+ await settle(element);
327
+
328
+ expect(element.querySelectorAll("[data-condition]")).toHaveLength(1);
329
+
330
+ const save = [...element.querySelectorAll("button")].find(
331
+ (button) => button.textContent?.trim() === "Save",
332
+ );
333
+ save?.click();
334
+ await settle(element);
335
+
336
+ const write = requests.find(
337
+ (request) =>
338
+ request.method === "POST" && !request.url.includes("/preview"),
339
+ );
340
+ expect(write?.body).toMatchObject({ selection: { format: "quote" } });
341
+ expect(
342
+ (write?.body as { selection: Record<string, unknown> }).selection,
343
+ ).not.toHaveProperty("year");
344
+ });
345
+
346
+ it("counts what the conditions gather, against the site total", async () => {
347
+ const element = mountDialog();
348
+ void element.open({ prefill: { selection: { format: "quote" } } });
349
+ await settle(element);
350
+
351
+ expect(element.textContent).toContain("3 of 42 threads");
352
+
353
+ const preview = requests.find((request) =>
354
+ request.url.includes("/preview"),
355
+ );
356
+ expect(preview?.method).toBe("POST");
357
+ expect(preview?.body).toEqual({ selection: { format: "quote" } });
358
+ });
359
+
360
+ it("never offers the one visibility a published page cannot name", async () => {
361
+ const element = mountDialog();
362
+ void element.open({ prefill: { selection: { visibility: "public" } } });
363
+ await settle(element);
364
+
365
+ const options = [
366
+ ...element.querySelectorAll("[data-condition='visibility'] option"),
367
+ ].map((option) => (option as globalThis.HTMLOptionElement).value);
368
+
369
+ expect(options).toEqual(["public", "featured", "hidden"]);
370
+ expect(options).not.toContain("private");
371
+ });
372
+
373
+ it("closes on Escape, from inside a field", async () => {
374
+ const element = mountDialog();
375
+ const closed = element.open({ prefill: { title: "Quotes" } });
376
+ await settle(element);
377
+
378
+ const input = element.querySelector<HTMLInputElement>(
379
+ "[data-field='title']",
380
+ );
381
+ // Dispatched on the field, not the dialog: an inner element that swallows
382
+ // keydown is exactly the case the component-level handler exists for.
383
+ input?.dispatchEvent(
384
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
385
+ );
386
+ await element.updateComplete;
387
+
388
+ await expect(closed).resolves.toBe(false);
389
+ });
390
+
391
+ it("saves on Enter, but not from inside the description", async () => {
392
+ const element = mountDialog();
393
+ void element.open({ prefill: { title: "Quotes" } });
394
+ await settle(element);
395
+
396
+ const textarea = element.querySelector<HTMLTextAreaElement>("textarea");
397
+ textarea?.dispatchEvent(
398
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
399
+ );
400
+ await settle(element);
401
+ expect(
402
+ requests.some(
403
+ (request) =>
404
+ request.method === "POST" && !request.url.includes("/preview"),
405
+ ),
406
+ ).toBe(false);
407
+
408
+ const input = element.querySelector<HTMLInputElement>(
409
+ "[data-field='title']",
410
+ );
411
+ input?.dispatchEvent(
412
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
413
+ );
414
+ await settle(element);
415
+ expect(
416
+ requests.some(
417
+ (request) =>
418
+ request.method === "POST" && !request.url.includes("/preview"),
419
+ ),
420
+ ).toBe(true);
421
+ });
422
+
423
+ it("holds Save shut until there is a title and a free address", async () => {
424
+ const element = mountDialog();
425
+ void element.open({});
426
+ await settle(element);
427
+
428
+ const save = () =>
429
+ [...element.querySelectorAll("button")].find(
430
+ (button) => button.textContent?.trim() === "Save",
431
+ );
432
+ expect(save()?.disabled).toBe(true);
433
+
434
+ const input = element.querySelector<HTMLInputElement>(
435
+ "[data-field='title']",
436
+ );
437
+ if (input) {
438
+ input.value = "Quotes";
439
+ input.dispatchEvent(new Event("input", { bubbles: true }));
440
+ }
441
+ await settle(element);
442
+
443
+ expect(save()?.disabled).toBe(false);
444
+ });
445
+
446
+ it("holds Save shut on an address that is taken", async () => {
447
+ responses["/api/smart-collections/slug"] = {
448
+ slug: "books",
449
+ available: false,
450
+ };
451
+ const element = mountDialog();
452
+ void element.open({ prefill: { title: "Books" } });
453
+ await settle(element);
454
+
455
+ const save = [...element.querySelectorAll("button")].find(
456
+ (button) => button.textContent?.trim() === "Save",
457
+ );
458
+ expect(save?.disabled).toBe(true);
459
+
460
+ // Enter must not walk around the button it agrees with.
461
+ element
462
+ .querySelector("[data-field='title']")
463
+ ?.dispatchEvent(
464
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
465
+ );
466
+ await settle(element);
467
+
468
+ expect(
469
+ requests.some(
470
+ (request) =>
471
+ request.method === "POST" && !request.url.includes("/preview"),
472
+ ),
473
+ ).toBe(false);
474
+ });
475
+
476
+ it("says why it cannot save when Enter is pressed on an empty form", async () => {
477
+ const element = mountDialog();
478
+ void element.open({});
479
+ await settle(element);
480
+
481
+ element
482
+ .querySelector("[data-field='title']")
483
+ ?.dispatchEvent(
484
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
485
+ );
486
+ await settle(element);
487
+
488
+ // In one line the alert can lay out, not a bare text node in a grid whose
489
+ // first column is zero wide.
490
+ const alert = element.querySelector("[role='alert'] section p");
491
+ expect(alert?.textContent?.trim()).toBe(
492
+ "A smart collection needs a title and a link.",
493
+ );
494
+ });
495
+
496
+ it("drops a complaint the author has already answered", async () => {
497
+ const element = mountDialog();
498
+ void element.open({});
499
+ await settle(element);
500
+
501
+ element
502
+ .querySelector("[data-field='title']")
503
+ ?.dispatchEvent(
504
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
505
+ );
506
+ await settle(element);
507
+ expect(element.querySelector("[role='alert']")).not.toBeNull();
508
+
509
+ const input = element.querySelector<HTMLInputElement>(
510
+ "[data-field='title']",
511
+ );
512
+ if (input) {
513
+ input.value = "Quotes";
514
+ input.dispatchEvent(new Event("input", { bubbles: true }));
515
+ }
516
+ await settle(element);
517
+
518
+ expect(element.querySelector("[role='alert']")).toBeNull();
519
+ });
520
+
521
+ it("shows the whole link, folded away until asked", async () => {
522
+ const element = mountDialog();
523
+ void element.open({ prefill: { title: "Quotes" } });
524
+ await settle(element);
525
+
526
+ // Collapsed: the URL and a way in, no second labelled field.
527
+ const preview = element.querySelector(".collection-quick-link-preview");
528
+ expect(preview?.textContent?.trim()).toMatch(/\/quotes$/);
529
+ expect(element.querySelector("[data-field='slug']")).toBeNull();
530
+
531
+ const edit = [...element.querySelectorAll("button")].find(
532
+ (button) => button.textContent?.trim() === "Edit link",
533
+ );
534
+ edit?.click();
535
+ await settle(element);
536
+
537
+ const input = element.querySelector<HTMLInputElement>(
538
+ "[data-field='slug']",
539
+ );
540
+ expect(input?.value).toBe("quotes");
541
+ expect(element.textContent).toContain("Collection link");
542
+ });
543
+
544
+ it("says a link is taken rather than failing on save", async () => {
545
+ responses["/api/smart-collections/slug"] = {
546
+ slug: "books",
547
+ available: false,
548
+ };
549
+ const element = mountDialog();
550
+ void element.open({ prefill: { title: "Books" } });
551
+ await settle(element);
552
+
553
+ expect(element.textContent).toContain(
554
+ "This link is taken. Choose another.",
555
+ );
556
+ });
557
+
558
+ it("names the real reason a link is refused, not always a collision", async () => {
559
+ const element = mountDialog();
560
+ void element.open({ prefill: { title: "Quotes" } });
561
+ await settle(element);
562
+
563
+ [...element.querySelectorAll("button")]
564
+ .find((button) => button.textContent?.trim() === "Edit link")
565
+ ?.click();
566
+ await settle(element);
567
+
568
+ const input = element.querySelector<HTMLInputElement>(
569
+ "[data-field='slug']",
570
+ );
571
+ if (input) {
572
+ input.value = "Not A Slug";
573
+ input.dispatchEvent(new Event("input", { bubbles: true }));
574
+ }
575
+ await settle(element);
576
+
577
+ expect(element.textContent).toContain(
578
+ "Use lowercase letters, numbers, and hyphens only.",
579
+ );
580
+ expect(element.textContent).not.toContain("This link is taken");
581
+ });
582
+
583
+ it("warns before an existing link moves", async () => {
584
+ responses["/api/smart-collections/smc_1"] = {
585
+ smartCollection: {
586
+ id: "smc_1",
587
+ slug: "quotes",
588
+ title: "Quotes",
589
+ selection: {},
590
+ sort: "newest",
591
+ layout: null,
592
+ },
593
+ };
594
+ const element = mountDialog();
595
+ void element.open({ smartCollectionId: "smc_1" });
596
+ await settle(element);
597
+
598
+ expect(element.textContent).not.toContain(
599
+ "Changing the link breaks the old one immediately.",
600
+ );
601
+
602
+ [...element.querySelectorAll("button")]
603
+ .find((button) => button.textContent?.trim() === "Edit link")
604
+ ?.click();
605
+ await settle(element);
606
+
607
+ const slugInput = element.querySelector<HTMLInputElement>(
608
+ "[data-field='slug']",
609
+ );
610
+ if (slugInput) {
611
+ slugInput.value = "citations";
612
+ slugInput.dispatchEvent(new Event("input", { bubbles: true }));
613
+ }
614
+ await settle(element);
615
+
616
+ expect(element.textContent).toContain(
617
+ "Changing the link breaks the old one immediately.",
618
+ );
619
+ });
620
+
621
+ it("says what a smart collection is when creating one, and not when editing", async () => {
622
+ const creating = mountDialog();
623
+ void creating.open({});
624
+ await settle(creating);
625
+ expect(creating.textContent).toContain(
626
+ "Conditions choose what belongs here",
627
+ );
628
+
629
+ responses["/api/smart-collections/smc_1"] = {
630
+ smartCollection: {
631
+ id: "smc_1",
632
+ slug: "quotes",
633
+ title: "Quotes",
634
+ selection: {},
635
+ sort: "newest",
636
+ layout: null,
637
+ },
638
+ };
639
+ const editing = mountDialog();
640
+ void editing.open({ smartCollectionId: "smc_1" });
641
+ await settle(editing);
642
+ expect(editing.textContent).not.toContain(
643
+ "Conditions choose what belongs here",
644
+ );
645
+ });
646
+
647
+ it("leaves deleting to the menus that open it", async () => {
648
+ responses["/api/smart-collections/smc_1"] = {
649
+ smartCollection: {
650
+ id: "smc_1",
651
+ slug: "quotes",
652
+ title: "Quotes",
653
+ selection: {},
654
+ sort: "newest",
655
+ layout: null,
656
+ },
657
+ };
658
+ const element = mountDialog();
659
+ void element.open({ smartCollectionId: "smc_1" });
660
+ await settle(element);
661
+
662
+ // Nothing destructive shares a row with Save.
663
+ expect(element.textContent).not.toContain("Delete");
664
+ });
665
+ });