@jant/core 0.6.16 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (508) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/import-site.js +60 -4
  4. package/bin/commands/reset-password.js +21 -8
  5. package/bin/commands/site/snapshot/export.js +1 -1
  6. package/bin/commands/site/snapshot/import.js +1 -1
  7. package/bin/lib/d1-query.js +5 -16
  8. package/bin/lib/node-env.js +40 -4
  9. package/bin/lib/site-selection.js +85 -29
  10. package/bin/lib/wrangler-cli.js +28 -2
  11. package/bin/lib/wrangler-json.js +35 -0
  12. package/dist/{app-C7_4oab9.js → app-K_Aa1MMn.js} +52476 -13330
  13. package/dist/client/.vite/manifest.json +255 -90
  14. package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
  15. package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
  16. package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
  17. package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
  18. package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
  19. package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
  20. package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
  21. package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
  22. package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
  23. package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
  24. package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
  25. package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
  26. package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
  27. package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
  28. package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
  29. package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
  30. package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
  31. package/dist/client/_assets/client-GiYENVw8.css +2 -0
  32. package/dist/client/_assets/client-VnFxJN7G.js +295 -0
  33. package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
  34. package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
  35. package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
  36. package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
  37. package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
  38. package/dist/{export-Dl5KCiEs.js → github-sync-BPAvT999.js} +2254 -107
  39. package/dist/index.js +2 -5
  40. package/dist/node.js +5 -7
  41. package/package.json +3 -3
  42. package/src/__tests__/auth-session-freshness.test.ts +57 -0
  43. package/src/__tests__/basecoat-variants.test.ts +121 -0
  44. package/src/__tests__/bin/assets-upload.test.ts +68 -0
  45. package/src/__tests__/export-hugo-build.test.ts +260 -0
  46. package/src/__tests__/export-service.test.ts +231 -0
  47. package/src/__tests__/helpers/component-css.ts +91 -0
  48. package/src/__tests__/helpers/db.ts +33 -0
  49. package/src/__tests__/helpers/export-fixtures.ts +3 -0
  50. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  51. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  52. package/src/app.tsx +71 -16
  53. package/src/auth.ts +18 -1
  54. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  55. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  56. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  57. package/src/client/__tests__/compose-bridge.test.ts +272 -0
  58. package/src/client/__tests__/compose-launch.test.ts +62 -1
  59. package/src/client/__tests__/compose-shortcuts.test.ts +38 -9
  60. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  61. package/src/client/__tests__/copy-field.test.ts +162 -0
  62. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  63. package/src/client/__tests__/image-processor.test.ts +324 -2
  64. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  65. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  66. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  67. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  68. package/src/client/__tests__/toast.test.ts +23 -0
  69. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  70. package/src/client/__tests__/video-processor.test.ts +181 -0
  71. package/src/client/audio-processor.ts +24 -15
  72. package/src/client/audio-waveform.ts +49 -0
  73. package/src/client/collection-dialog-host.ts +72 -0
  74. package/src/client/collection-navigation.ts +51 -16
  75. package/src/client/collection-page-actions.ts +26 -0
  76. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  77. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  78. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  79. package/src/client/components/__tests__/jant-command-palette.test.ts +28 -0
  80. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  81. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1250 -53
  82. package/src/client/components/__tests__/jant-compose-editor.test.ts +544 -40
  83. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +0 -1
  84. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  85. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  86. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  87. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  88. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  89. package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
  90. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  91. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  92. package/src/client/components/collection-dialog-types.ts +49 -0
  93. package/src/client/components/collection-manager-types.ts +32 -1
  94. package/src/client/components/collection-types.ts +1 -15
  95. package/src/client/components/compose-types.ts +75 -1
  96. package/src/client/components/jant-collection-dialog.ts +722 -0
  97. package/src/client/components/jant-collection-directory.ts +567 -182
  98. package/src/client/components/jant-collection-form.ts +53 -232
  99. package/src/client/components/jant-command-palette.ts +100 -49
  100. package/src/client/components/jant-compose-dialog.ts +1486 -707
  101. package/src/client/components/jant-compose-editor.ts +673 -336
  102. package/src/client/components/jant-compose-fullscreen.ts +51 -43
  103. package/src/client/components/jant-config-editor.ts +65 -53
  104. package/src/client/components/jant-confirm-dialog.ts +13 -11
  105. package/src/client/components/jant-locale-picker.ts +418 -0
  106. package/src/client/components/jant-media-lightbox.ts +146 -136
  107. package/src/client/components/jant-nav-manager.ts +690 -366
  108. package/src/client/components/jant-post-menu.ts +1086 -282
  109. package/src/client/components/jant-post-picker.ts +324 -0
  110. package/src/client/components/jant-repo-picker.ts +138 -106
  111. package/src/client/components/jant-settings-avatar.ts +31 -27
  112. package/src/client/components/jant-settings-general.ts +617 -543
  113. package/src/client/components/jant-settings-language.ts +1087 -0
  114. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  115. package/src/client/components/jant-text-preview.ts +94 -86
  116. package/src/client/components/nav-manager-types.ts +62 -1
  117. package/src/client/components/settings-types.ts +14 -29
  118. package/src/client/components/smart-collection-conditions.ts +285 -0
  119. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  120. package/src/client/compose-bridge.ts +214 -141
  121. package/src/client/compose-launch.ts +76 -2
  122. package/src/client/compose-shortcuts.ts +6 -8
  123. package/src/client/compose-triggers.ts +50 -0
  124. package/src/client/copy-field.ts +108 -0
  125. package/src/client/custom-url-menu.ts +60 -4
  126. package/src/client/icons.ts +14 -0
  127. package/src/client/image-plan.ts +165 -0
  128. package/src/client/image-processor.ts +195 -117
  129. package/src/client/image-worker-client.ts +45 -0
  130. package/src/client/image-worker.ts +135 -0
  131. package/src/client/lazy-entries.ts +95 -0
  132. package/src/client/lazy-slugify.ts +2 -2
  133. package/src/client/media-scroll-hint.ts +43 -2
  134. package/src/client/mediabunny.ts +29 -0
  135. package/src/client/post-picker.ts +41 -0
  136. package/src/client/runtime-paths.ts +74 -1
  137. package/src/client/settings-bridge.ts +4 -10
  138. package/src/client/site-header-nav.js +18 -6
  139. package/src/client/smart-collection-dialog-host.ts +116 -0
  140. package/src/client/smart-collection-page-actions.ts +208 -0
  141. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  142. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  143. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  144. package/src/client/tiptap/bubble-menu.ts +47 -16
  145. package/src/client/tiptap/floating-position.ts +216 -2
  146. package/src/client/tiptap/inline-image-upload.ts +7 -3
  147. package/src/client/tiptap/link-toolbar.ts +22 -9
  148. package/src/client/toast.ts +13 -0
  149. package/src/client/types/vite-worker.d.ts +12 -0
  150. package/src/client/upload-with-metadata.ts +3 -13
  151. package/src/client/video-processor.ts +351 -107
  152. package/src/client-auth.ts +14 -31
  153. package/src/client-compose.ts +19 -0
  154. package/src/client-manage.ts +13 -0
  155. package/src/client-settings.ts +14 -0
  156. package/src/client-site.ts +10 -3
  157. package/src/client.ts +4 -0
  158. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  159. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  160. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  161. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  162. package/src/db/migrations/0032_married_tomas.sql +3 -0
  163. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  164. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  165. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  166. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  167. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  168. package/src/db/migrations/meta/_journal.json +21 -0
  169. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  170. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  171. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  172. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  173. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  174. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  175. package/src/db/migrations/pg/meta/_journal.json +21 -0
  176. package/src/db/pg/schema.ts +195 -27
  177. package/src/db/post-visibility.ts +117 -0
  178. package/src/db/schema.ts +222 -27
  179. package/src/i18n/__tests__/detect.test.ts +26 -81
  180. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  181. package/src/i18n/context.tsx +12 -2
  182. package/src/i18n/detect.ts +7 -66
  183. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  184. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  185. package/src/i18n/locales/public/en.po +490 -158
  186. package/src/i18n/locales/public/en.ts +1 -1
  187. package/src/i18n/locales/public/zh-Hans.po +487 -155
  188. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  189. package/src/i18n/locales/public/zh-Hant.po +487 -155
  190. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  191. package/src/i18n/locales/settings/en.po +551 -69
  192. package/src/i18n/locales/settings/en.ts +1 -1
  193. package/src/i18n/locales/settings/zh-Hans.po +597 -115
  194. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  195. package/src/i18n/locales/settings/zh-Hant.po +610 -128
  196. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  197. package/src/i18n/locales.ts +94 -0
  198. package/src/i18n/middleware.ts +12 -2
  199. package/src/i18n/supported-locales.ts +80 -0
  200. package/src/lib/__tests__/api-settings.test.ts +11 -2
  201. package/src/lib/__tests__/asset-path.test.ts +92 -0
  202. package/src/lib/__tests__/deferred.test.ts +90 -0
  203. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  204. package/src/lib/__tests__/discover.test.ts +410 -0
  205. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  206. package/src/lib/__tests__/feed.test.ts +1526 -15
  207. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  208. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  209. package/src/lib/__tests__/http-cache.test.ts +162 -0
  210. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  211. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  212. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  213. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  214. package/src/lib/__tests__/navigation.test.ts +135 -1
  215. package/src/lib/__tests__/resolve-config.test.ts +64 -3
  216. package/src/lib/__tests__/schemas.test.ts +42 -4
  217. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  218. package/src/lib/__tests__/slug.test.ts +70 -0
  219. package/src/lib/__tests__/slugify.test.ts +85 -0
  220. package/src/lib/__tests__/storage.test.ts +32 -0
  221. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  222. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  223. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  224. package/src/lib/__tests__/url.test.ts +78 -72
  225. package/src/lib/__tests__/view-language.test.ts +413 -0
  226. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  227. package/src/lib/address.ts +57 -0
  228. package/src/lib/api-posts.ts +12 -0
  229. package/src/lib/asset-path.ts +98 -1
  230. package/src/lib/build-env.ts +16 -0
  231. package/src/lib/collection-groups.ts +5 -1
  232. package/src/lib/collection-paths.ts +3 -10
  233. package/src/lib/constants.ts +28 -3
  234. package/src/lib/copy-field.ts +36 -0
  235. package/src/lib/crypto.ts +40 -0
  236. package/src/lib/csp-builder.ts +2 -1
  237. package/src/lib/deferred.ts +79 -0
  238. package/src/lib/discover-ping.ts +141 -0
  239. package/src/lib/discover.ts +437 -0
  240. package/src/lib/env.ts +134 -0
  241. package/src/lib/errors.ts +33 -0
  242. package/src/lib/feed-policy.ts +236 -2
  243. package/src/lib/feed.ts +926 -73
  244. package/src/lib/filter-dimensions.ts +1349 -0
  245. package/src/lib/github-sync-site-config.ts +3 -0
  246. package/src/lib/hosted-signin.ts +1 -1
  247. package/src/lib/http-cache.ts +148 -0
  248. package/src/lib/hugo-markdown.ts +16 -0
  249. package/src/lib/icons.ts +9 -2
  250. package/src/lib/ids.ts +3 -0
  251. package/src/lib/jant-docs.ts +42 -0
  252. package/src/lib/lang-detect.ts +308 -0
  253. package/src/lib/media-helpers.ts +13 -1
  254. package/src/lib/navigation.ts +78 -11
  255. package/src/lib/pagination.ts +28 -24
  256. package/src/lib/per-language-surfaces.ts +67 -0
  257. package/src/lib/post-meta.ts +19 -0
  258. package/src/lib/render.tsx +55 -7
  259. package/src/lib/resolve-config.ts +53 -5
  260. package/src/lib/schemas.ts +201 -10
  261. package/src/lib/site-header-fragment.tsx +2 -1
  262. package/src/lib/site-unavailable-page.ts +47 -0
  263. package/src/lib/slug.ts +51 -7
  264. package/src/lib/slugify.ts +113 -0
  265. package/src/lib/startup-config.ts +4 -6
  266. package/src/lib/storage.ts +7 -0
  267. package/src/lib/summary.ts +50 -0
  268. package/src/lib/thread-fold.ts +161 -0
  269. package/src/lib/time.ts +32 -0
  270. package/src/lib/timeline.ts +42 -1
  271. package/src/lib/tiptap-render.ts +1 -2
  272. package/src/lib/upload.ts +16 -13
  273. package/src/lib/url.ts +139 -86
  274. package/src/lib/version.ts +10 -8
  275. package/src/lib/view-language.ts +436 -0
  276. package/src/lib/view.ts +51 -23
  277. package/src/lib/viewer-context.ts +62 -0
  278. package/src/middleware/__tests__/auth.test.ts +70 -2
  279. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  280. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  281. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  282. package/src/middleware/__tests__/session.test.ts +181 -33
  283. package/src/middleware/auth.ts +50 -31
  284. package/src/middleware/cache-control.ts +75 -10
  285. package/src/middleware/config.ts +6 -6
  286. package/src/middleware/error-handler.ts +12 -0
  287. package/src/middleware/onboarding.ts +32 -10
  288. package/src/middleware/secure-headers.ts +1 -1
  289. package/src/middleware/session.ts +96 -8
  290. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  291. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  292. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  293. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  294. package/src/node/__tests__/runtime.test.ts +37 -0
  295. package/src/node/index.ts +1 -0
  296. package/src/preset.css +76 -22
  297. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  298. package/src/routes/api/__tests__/collections.test.ts +114 -0
  299. package/src/routes/api/__tests__/discover.test.ts +222 -0
  300. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  301. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  302. package/src/routes/api/__tests__/settings.test.ts +24 -3
  303. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  304. package/src/routes/api/collections.ts +49 -6
  305. package/src/routes/api/custom-urls.ts +0 -1
  306. package/src/routes/api/discover.ts +95 -0
  307. package/src/routes/api/export.ts +3 -0
  308. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  309. package/src/routes/api/nav-items.ts +55 -8
  310. package/src/routes/api/posts.ts +159 -0
  311. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  312. package/src/routes/api/public/archive.ts +77 -81
  313. package/src/routes/api/public/posts.ts +23 -6
  314. package/src/routes/api/smart-collections.ts +132 -0
  315. package/src/routes/api/telegram.ts +2 -40
  316. package/src/routes/api/uploads.ts +1 -2
  317. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  318. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  319. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  320. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  321. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  322. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  323. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  324. package/src/routes/auth/setup.tsx +934 -186
  325. package/src/routes/auth/signin.tsx +10 -8
  326. package/src/routes/compose.tsx +5 -0
  327. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  328. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  329. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  330. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  331. package/src/routes/dash/custom-urls.tsx +1 -37
  332. package/src/routes/dash/settings.tsx +502 -41
  333. package/src/routes/discover-announce.ts +96 -0
  334. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  335. package/src/routes/feed/feed.ts +69 -40
  336. package/src/routes/feed/sitemap.ts +95 -11
  337. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  338. package/src/routes/pages/__tests__/archive-params.test.ts +370 -6
  339. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  340. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  341. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  342. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  343. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  344. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  345. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  346. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  347. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  348. package/src/routes/pages/archive.tsx +471 -369
  349. package/src/routes/pages/collection.tsx +94 -96
  350. package/src/routes/pages/collections.tsx +38 -50
  351. package/src/routes/pages/featured.tsx +32 -13
  352. package/src/routes/pages/home.tsx +25 -6
  353. package/src/routes/pages/language.tsx +113 -0
  354. package/src/routes/pages/latest.tsx +26 -16
  355. package/src/routes/pages/new.tsx +3 -0
  356. package/src/routes/pages/page.tsx +178 -17
  357. package/src/routes/pages/partials.tsx +41 -0
  358. package/src/routes/pages/search.tsx +18 -3
  359. package/src/routes/pages/smart-collection.tsx +340 -0
  360. package/src/routes/pages/subscribe.tsx +182 -0
  361. package/src/runtime/__tests__/index.test.ts +19 -0
  362. package/src/runtime/__tests__/node.test.ts +129 -4
  363. package/src/runtime/cloudflare.ts +21 -2
  364. package/src/runtime/index.ts +27 -1
  365. package/src/runtime/node.ts +18 -1
  366. package/src/runtime/site.ts +95 -24
  367. package/src/services/__tests__/auth.test.ts +116 -7
  368. package/src/services/__tests__/collection.test.ts +108 -4
  369. package/src/services/__tests__/language.test.ts +495 -0
  370. package/src/services/__tests__/navigation.test.ts +213 -10
  371. package/src/services/__tests__/path.test.ts +180 -0
  372. package/src/services/__tests__/post-language.test.ts +830 -0
  373. package/src/services/__tests__/post.test.ts +46 -9
  374. package/src/services/__tests__/settings.test.ts +228 -17
  375. package/src/services/__tests__/site-admin.test.ts +5 -5
  376. package/src/services/__tests__/smart-collection.test.ts +651 -0
  377. package/src/services/about-page.ts +22 -1
  378. package/src/services/auth.ts +51 -49
  379. package/src/services/bootstrap.ts +188 -38
  380. package/src/services/collection-directory-position.ts +76 -0
  381. package/src/services/collection.ts +331 -46
  382. package/src/services/custom-url.ts +27 -22
  383. package/src/services/export-theme/assets/client-site.css +1 -1
  384. package/src/services/export-theme/assets/client-site.js +123 -123
  385. package/src/services/export-theme/layouts/_default/rss.xml +77 -1
  386. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  387. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  388. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  389. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  390. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  391. package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
  392. package/src/services/export-theme/styles/main.css +13 -1
  393. package/src/services/export.ts +278 -19
  394. package/src/services/index.ts +34 -3
  395. package/src/services/language.ts +412 -0
  396. package/src/services/mcp.ts +3 -1
  397. package/src/services/media.ts +14 -6
  398. package/src/services/navigation.ts +429 -67
  399. package/src/services/path.ts +221 -6
  400. package/src/services/post.ts +1021 -152
  401. package/src/services/search.ts +39 -2
  402. package/src/services/settings.ts +238 -43
  403. package/src/services/site-admin.ts +24 -7
  404. package/src/services/site.ts +77 -1
  405. package/src/services/smart-collection.ts +636 -0
  406. package/src/services/telegram.ts +20 -22
  407. package/src/services/upload-session.ts +1 -3
  408. package/src/style-author.css +27 -0
  409. package/src/styles/components-author.css +1622 -0
  410. package/src/styles/components.css +24 -1571
  411. package/src/styles/site-media.css +16 -2
  412. package/src/styles/tokens.css +82 -12
  413. package/src/styles/ui-author.css +7602 -0
  414. package/src/styles/ui.css +1087 -7219
  415. package/src/types/app-context.ts +7 -0
  416. package/src/types/bindings.ts +3 -0
  417. package/src/types/config.ts +94 -15
  418. package/src/types/constants.ts +188 -16
  419. package/src/types/entities.ts +87 -1
  420. package/src/types/operations.ts +64 -2
  421. package/src/types/props.ts +157 -31
  422. package/src/types/views.ts +55 -1
  423. package/src/ui/__tests__/font-themes.test.ts +52 -12
  424. package/src/ui/compose/ComposeDialog.tsx +201 -7
  425. package/src/ui/compose/ComposePrompt.tsx +1 -5
  426. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  427. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  428. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  429. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  430. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  431. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  432. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  433. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  434. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  435. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  436. package/src/ui/dash/settings/GeneralContent.tsx +275 -113
  437. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  438. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  439. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  440. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  441. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  442. package/src/ui/dash/settings/discover-copy.ts +116 -0
  443. package/src/ui/feed/LinkCard.tsx +55 -45
  444. package/src/ui/feed/NoteCard.tsx +9 -4
  445. package/src/ui/feed/PostStatusBadges.tsx +90 -43
  446. package/src/ui/feed/QuoteCard.tsx +55 -30
  447. package/src/ui/feed/ThreadPreview.tsx +6 -2
  448. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  449. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  450. package/src/ui/feed/thread-preview-state.ts +20 -6
  451. package/src/ui/font-themes.ts +39 -29
  452. package/src/ui/layouts/BaseLayout.tsx +155 -10
  453. package/src/ui/layouts/SiteLayout.tsx +192 -34
  454. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  455. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  456. package/src/ui/pages/ArchivePage.tsx +282 -289
  457. package/src/ui/pages/CollectionPage.tsx +86 -76
  458. package/src/ui/pages/CollectionsPage.tsx +5 -0
  459. package/src/ui/pages/ComposePage.tsx +8 -0
  460. package/src/ui/pages/PostPage.tsx +47 -0
  461. package/src/ui/pages/SearchPage.tsx +3 -3
  462. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  463. package/src/ui/pages/SubscribePage.tsx +236 -0
  464. package/src/ui/pages/__tests__/ArchivePage.test.tsx +65 -9
  465. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  466. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  467. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  468. package/src/ui/shared/CollectionsManager.tsx +70 -17
  469. package/src/ui/shared/CopyField.tsx +127 -0
  470. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  471. package/src/ui/shared/MediaGallery.tsx +125 -46
  472. package/src/ui/shared/Pagination.tsx +20 -5
  473. package/src/ui/shared/PostFooter.tsx +20 -11
  474. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  475. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  476. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  477. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  478. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  479. package/src/ui/shared/archive-labels.ts +98 -0
  480. package/src/ui/shared/collection-management-labels.ts +94 -16
  481. package/src/ui/shared/navigation-labels.ts +45 -71
  482. package/src/ui/shared/post-article-attributes.ts +2 -0
  483. package/src/ui/shared/smart-collection-labels.ts +343 -0
  484. package/dist/app-C75Zc8dD.js +0 -6
  485. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  486. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  487. package/dist/client/_assets/chunks/url-CU9pEbk5.js +0 -1
  488. package/dist/client/_assets/client-Bq0vre8Y.js +0 -296
  489. package/dist/client/_assets/client-D3VZyG4L.css +0 -2
  490. package/dist/client/_assets/client-auth-F2rb9WfF.js +0 -5571
  491. package/dist/env-BPYViDJJ.js +0 -281
  492. package/dist/github-api-BNF35Lkx.js +0 -176
  493. package/dist/github-app-DdiD-bC4.js +0 -275
  494. package/dist/github-sync-Bqg62IvV.js +0 -4
  495. package/dist/github-sync-HdK2EgvJ.js +0 -431
  496. package/dist/url-CbLAtlZe.js +0 -900
  497. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  498. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  499. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  500. package/src/client/collection-created-notice.ts +0 -66
  501. package/src/client/collection-form-bridge.ts +0 -191
  502. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  503. package/src/client/components/jant-post-form.ts +0 -422
  504. package/src/client/components/post-form-template.ts +0 -352
  505. package/src/client/components/post-form-types.ts +0 -96
  506. package/src/client/media-metadata.ts +0 -247
  507. package/src/client/post-form-bridge.ts +0 -193
  508. package/src/ui/pages/CollectionEditorPage.tsx +0 -175
@@ -4,12 +4,11 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
4
4
  import type {
5
5
  SettingsLabels,
6
6
  SettingsTimezone,
7
- SettingsCjkFont,
8
- SettingsDashboardLanguage,
9
7
  SettingsSaveDetail,
10
8
  SettingsAboutPageStatus,
11
9
  } from "../settings-types.js";
12
10
  import { MAX_SITE_NAME_LENGTH } from "../../../types.js";
11
+ import type { DiscoverPageUrls } from "../../../lib/discover.js";
13
12
  import "../jant-settings-general.js";
14
13
  import type { JantSettingsGeneral } from "../jant-settings-general.js";
15
14
 
@@ -43,13 +42,18 @@ function findRadioByValue(
43
42
  );
44
43
  }
45
44
 
45
+ /**
46
+ * Matched exactly: "Site" is a prefix of "Site visibility", so a substring
47
+ * match would find whichever of the two comes first on the page.
48
+ */
46
49
  function findSectionByHeading(
47
50
  el: HTMLElement,
48
51
  headingText: string,
49
52
  ): HTMLElement | null {
50
53
  return (
51
- Array.from(el.querySelectorAll<HTMLElement>("section")).find((section) =>
52
- section.querySelector("h3")?.textContent?.includes(headingText),
54
+ Array.from(el.querySelectorAll<HTMLElement>("section")).find(
55
+ (section) =>
56
+ section.querySelector("h3")?.textContent?.trim() === headingText,
53
57
  ) ?? null
54
58
  );
55
59
  }
@@ -83,22 +87,12 @@ const labels: SettingsLabels = {
83
87
  "/about is already used. Rename that item before creating an About page.",
84
88
  createAboutPage: "Create About page",
85
89
  editAboutPage: "Edit About page",
86
- languageAndTime: "Language & Time",
90
+ timeSection: "Time",
87
91
  home: "Home",
88
- search: "Search",
92
+ siteVisibility: "Site visibility",
89
93
  siteName: "Site Name",
90
94
  aboutBlog: "About this blog",
91
95
  aboutBlogHelp: "Displayed above your blog posts.",
92
- siteLanguage: "Content language",
93
- siteLanguageHelp: "The language your posts are written in.",
94
- siteLanguageSearchPlaceholder: "Search…",
95
- siteLanguageNoMatches: "No matches.",
96
- contentLanguagePreview: "Readers and search engines see",
97
- dashboardLanguage: "Dashboard language",
98
- dashboardLanguageHelp: "The language this admin dashboard shows in.",
99
- cjkFont: "CJK fallback",
100
- cjkFontHelp:
101
- "Used when the content language has no built-in font profile. Your font theme still controls serif and sans styling.",
102
96
  timeZone: "Time Zone",
103
97
  feeds: "Feeds",
104
98
  mainRssFeed: "Main RSS feed",
@@ -124,11 +118,24 @@ const labels: SettingsLabels = {
124
118
  markdownSupported: "Markdown supported",
125
119
  allowIndexing: "Allow search engines to index my site",
126
120
  demoSeoLocked: "Demo sites always stay hidden from search engines.",
121
+ discoverName: "Jant Discover",
122
+ discoverRules: "Discover community rules",
123
+ discoverEnabled: "Allow Jant Discover to list my site",
124
+ discoverIntro:
125
+ "Jant Discover is a directory of Jant blogs, curated by hand by the Jant community to help people find new Jant blogs and posts. A post you mark Featured appears on the Discover home page 24 hours later, and link and quote posts appear on the Links and Quotes lists 24 hours after they are published. You can keep editing them in the meantime. See the Discover community rules.",
126
+ discoverDemoLocked: "Demo sites are never listed in Discover.",
127
+ discoverFeedsOffLocked:
128
+ "Discover reads your Atom feed, so it needs feeds turned on.",
129
+ discoverSearchOff:
130
+ "Search engine indexing is off, so this site is not listed by default. Ticking the box above lists it anyway.",
131
+ discoverAnnounce: "Announce my site",
132
+ discoverAnnounceManual: "Or submit your address by hand",
127
133
  save: "Save",
128
134
  cancel: "Cancel",
129
135
  copy: "Copy",
130
136
  copyFailed: "Could not copy. Try again.",
131
137
  feedUrlCopied: "Feed URL copied.",
138
+ feedsDocs: "All feed addresses",
132
139
  };
133
140
 
134
141
  const timezones: SettingsTimezone[] = [
@@ -136,28 +143,22 @@ const timezones: SettingsTimezone[] = [
136
143
  { value: "America/New_York", label: "(UTC-05:00) Eastern Time" },
137
144
  ];
138
145
 
139
- const cjkFonts: SettingsCjkFont[] = [
140
- { value: "off", label: "Follow content language" },
141
- { value: "zh-Hans", label: "简体中文 (Simplified Chinese)" },
142
- ];
143
-
144
- const dashboardLanguages: SettingsDashboardLanguage[] = [
145
- { value: "en", label: "English" },
146
- { value: "zh-Hans", label: "简体中文" },
147
- { value: "zh-Hant", label: "繁體中文" },
148
- ];
149
-
150
146
  const initialData = {
151
147
  siteName: "My Blog",
152
148
  siteDescription: "A test blog",
153
- siteLanguage: "en",
154
- dashboardLanguage: "en",
155
- cjkSerifFont: "off",
156
149
  timeZone: "UTC",
157
150
  mainRssFeed: "featured",
158
151
  siteFooter: "Footer text",
159
152
  showJantBrandingOnHome: false,
160
153
  noindex: false,
154
+ discover: "",
155
+ };
156
+
157
+ const discoverPages: DiscoverPageUrls = {
158
+ home: "https://jant.me/discover",
159
+ links: "https://jant.me/links",
160
+ quotes: "https://jant.me/quotes",
161
+ rules: "https://jant.me/discover/about",
161
162
  };
162
163
 
163
164
  function findCheckboxByLabel(
@@ -171,10 +172,27 @@ function findCheckboxByLabel(
171
172
  );
172
173
  }
173
174
 
175
+ /**
176
+ * The Discover help line, found by its text: it is one of several muted
177
+ * paragraphs in the section, and only this one carries the directory's links.
178
+ */
179
+ function findIntroParagraph(el: HTMLElement): HTMLElement | null {
180
+ return (
181
+ Array.from(el.querySelectorAll<HTMLElement>("p")).find(
182
+ (paragraph) => paragraph.textContent?.trim() === labels.discoverIntro,
183
+ ) ?? null
184
+ );
185
+ }
186
+
174
187
  async function createElement(
175
188
  opts: {
176
189
  demoMode?: boolean;
190
+ feedsEnabled?: boolean;
177
191
  aboutPage?: SettingsAboutPageStatus;
192
+ /** The deployment's own answer: "" when it has none. */
193
+ discoverDefault?: string;
194
+ /** Serialized status view, as the server hands it to the component. */
195
+ discoverStatus?: string;
178
196
  } = {},
179
197
  ): Promise<JantSettingsGeneral> {
180
198
  const el = document.createElement(
@@ -182,8 +200,6 @@ async function createElement(
182
200
  ) as JantSettingsGeneral;
183
201
  el.labels = labels;
184
202
  el.timezones = timezones;
185
- el.cjkFonts = cjkFonts;
186
- el.dashboardLanguages = dashboardLanguages;
187
203
  el.siteNameFallback = "Fallback Name";
188
204
  el.siteDescriptionFallback = "Fallback Description";
189
205
  el.mainFeedUrl = "/feed";
@@ -199,6 +215,10 @@ async function createElement(
199
215
  el.aboutEditUrl = "/about?edit=1";
200
216
  el.aboutCreateUrl = "/settings/general/about-page";
201
217
  el.demoMode = opts.demoMode ?? false;
218
+ el.discoverPages = discoverPages;
219
+ el.discoverDefault = opts.discoverDefault ?? "";
220
+ el.discoverStatus = opts.discoverStatus ?? "";
221
+ el.feedsEnabled = opts.feedsEnabled ?? true;
202
222
  document.body.appendChild(el);
203
223
  await el.updateComplete;
204
224
  el.initData(initialData);
@@ -220,10 +240,10 @@ describe("JantSettingsGeneral", () => {
220
240
  expect(el.querySelector("h2")?.textContent).toBe("General");
221
241
  expect(groupTitles).toEqual([
222
242
  labels.site,
223
- labels.languageAndTime,
243
+ labels.siteVisibility,
224
244
  labels.feeds,
245
+ labels.timeSection,
225
246
  labels.home,
226
- labels.search,
227
247
  ]);
228
248
 
229
249
  const siteSection = requireElement(
@@ -309,121 +329,6 @@ describe("JantSettingsGeneral", () => {
309
329
  expect(options?.[0]?.value).toBe("UTC");
310
330
  });
311
331
 
312
- it("opens the locale combobox and filters options as the user searches", async () => {
313
- const el = await createElement();
314
-
315
- const trigger = requireElement(
316
- el.querySelector<HTMLButtonElement>(
317
- 'button[aria-haspopup="listbox"][aria-labelledby="site-language-label"]',
318
- ),
319
- "expected locale picker trigger",
320
- );
321
- expect(trigger.getAttribute("aria-expanded")).toBe("false");
322
-
323
- trigger.click();
324
- await el.updateComplete;
325
-
326
- expect(trigger.getAttribute("aria-expanded")).toBe("true");
327
-
328
- const options = el.querySelectorAll<HTMLButtonElement>('[role="option"]');
329
- // The content-language picker lists the full BCP 47 catalog so any public
330
- // content language is reachable. Coverage / raw tags are not shown here.
331
- expect(options.length).toBeGreaterThanOrEqual(20);
332
- for (const option of options) {
333
- expect(option.textContent).not.toMatch(/% translated/);
334
- }
335
-
336
- const search = requireElement(
337
- el.querySelector<HTMLInputElement>("[data-locale-search]"),
338
- "expected search input",
339
- );
340
- search.value = "fin";
341
- search.dispatchEvent(new Event("input", { bubbles: true }));
342
- await el.updateComplete;
343
-
344
- const filtered = el.querySelectorAll<HTMLButtonElement>('[role="option"]');
345
- expect(filtered.length).toBe(1);
346
- expect(filtered[0]?.textContent).toMatch(/Suomi|Finnish/);
347
- });
348
-
349
- it("selects a non-catalog content language and shows its native name", async () => {
350
- const el = await createElement();
351
- const trigger = requireElement(
352
- el.querySelector<HTMLButtonElement>(
353
- 'button[aria-labelledby="site-language-label"]',
354
- ),
355
- "expected trigger",
356
- );
357
- trigger.click();
358
- await el.updateComplete;
359
-
360
- const search = requireElement(
361
- el.querySelector<HTMLInputElement>("[data-locale-search]"),
362
- "expected search input",
363
- );
364
- search.value = "fi";
365
- search.dispatchEvent(new Event("input", { bubbles: true }));
366
- await el.updateComplete;
367
-
368
- const finnishOption = Array.from(
369
- el.querySelectorAll<HTMLButtonElement>('[role="option"]'),
370
- ).find((opt) => /Suomi|Finnish/.test(opt.textContent ?? ""));
371
- finnishOption?.click();
372
- await el.updateComplete;
373
-
374
- // Picker closes after selection.
375
- expect(trigger.getAttribute("aria-expanded")).toBe("false");
376
- // Trigger shows the selected language's native name only — no raw BCP 47
377
- // tag, no coverage metric.
378
- expect(trigger.textContent).toMatch(/suomi|finnish/i);
379
- expect(trigger.textContent).not.toMatch(/% translated/);
380
- expect(trigger.textContent).not.toMatch(/\bfi\b/);
381
- });
382
-
383
- it("renders dashboard language options and saves the selection", async () => {
384
- const el = await createElement();
385
- const select = requireElement(
386
- el.querySelector(
387
- 'select[aria-labelledby="dashboard-language-label"]',
388
- ) as globalThis.HTMLSelectElement | null,
389
- "expected dashboard language select",
390
- );
391
- const values = Array.from(select.options).map((o) => o.value);
392
- expect(values).toEqual(["en", "zh-Hans", "zh-Hant"]);
393
- expect(select.value).toBe("en");
394
-
395
- const saves: SettingsSaveDetail[] = [];
396
- el.addEventListener("jant:settings-save", (e) => {
397
- saves.push((e as CustomEvent<SettingsSaveDetail>).detail);
398
- });
399
-
400
- select.value = "zh-Hant";
401
- select.dispatchEvent(new Event("change", { bubbles: true }));
402
- await el.updateComplete;
403
-
404
- const saveButton = requireElement(
405
- findSaveButtonByHeading(el, labels.languageAndTime),
406
- "expected language & time save button",
407
- );
408
- saveButton.click();
409
-
410
- expect(saves).toHaveLength(1);
411
- expect(saves[0]?.endpoint).toBe("/settings/general/language-time");
412
- expect(saves[0]?.data.dashboardLanguage).toBe("zh-Hant");
413
- });
414
-
415
- it("renders CJK font options", async () => {
416
- const el = await createElement();
417
- const cjkSelect = requireElement(
418
- findSelectByLabel(el, labels.cjkFont),
419
- "expected CJK font select",
420
- );
421
- const options = cjkSelect?.querySelectorAll("option");
422
- expect(options?.length).toBe(2);
423
- expect(options?.[0]?.value).toBe("off");
424
- expect(options?.[1]?.value).toBe("zh-Hans");
425
- });
426
-
427
332
  it("renders main RSS feed controls and fixed feed URLs", async () => {
428
333
  const el = await createElement();
429
334
  const featuredRadio = requireElement(
@@ -451,26 +356,37 @@ describe("JantSettingsGeneral", () => {
451
356
  ]);
452
357
  });
453
358
 
454
- it("copies a feed URL from the info block", async () => {
455
- const writeText = vi.fn().mockResolvedValue(undefined);
456
- Object.defineProperty(globalThis.navigator, "clipboard", {
457
- value: { writeText },
458
- configurable: true,
459
- });
460
-
359
+ it("marks up each feed URL for the copy-field enhancer", async () => {
461
360
  const el = await createElement();
462
361
  const feedSection = requireElement(
463
362
  findSectionByHeading(el, labels.feeds),
464
363
  "expected feeds section",
465
364
  );
466
- const copyButtons = feedSection.querySelectorAll<HTMLButtonElement>(
467
- "button[data-copy-feed-url]",
468
- );
365
+ const fields = feedSection.querySelectorAll("[data-copy-field-root]");
469
366
 
470
- copyButtons[0]?.click();
471
- await Promise.resolve();
367
+ expect(fields).toHaveLength(4);
368
+
369
+ const firstField = requireElement(
370
+ fields[0],
371
+ "expected the main feed field",
372
+ );
373
+ const input = requireElement(
374
+ firstField.querySelector<HTMLInputElement>(
375
+ "input[data-copy-field-value]",
376
+ ),
377
+ "expected the address input",
378
+ );
379
+ const button = requireElement(
380
+ firstField.querySelector<HTMLButtonElement>("button[data-copy-field]"),
381
+ "expected the copy button",
382
+ );
472
383
 
473
- expect(writeText).toHaveBeenCalledWith("/feed");
384
+ expect(input.value).toBe("/feed");
385
+ expect(button.getAttribute("data-copy-field")).toBe(labels.feedUrlCopied);
386
+ expect(button.getAttribute("data-copy-field-failed")).toBe(
387
+ labels.copyFailed,
388
+ );
389
+ expect(button.textContent).toContain(labels.copy);
474
390
  });
475
391
 
476
392
  it("tracks site group dirty state on input", async () => {
@@ -534,48 +450,69 @@ describe("JantSettingsGeneral", () => {
534
450
  detail = (event as CustomEvent<SettingsSaveDetail>).detail;
535
451
  });
536
452
 
537
- const saveBtn = findSaveButtonByHeading(el, labels.languageAndTime);
453
+ const saveBtn = findSaveButtonByHeading(el, labels.timeSection);
538
454
  saveBtn?.click();
539
455
  await el.updateComplete;
540
456
 
541
457
  expect(detail).not.toBeNull();
542
458
  expect((detail as unknown as SettingsSaveDetail).endpoint).toBe(
543
- "/settings/general/language-time",
544
- );
545
- expect((detail as unknown as SettingsSaveDetail).section).toBe(
546
- "language-time",
459
+ "/settings/general/time",
547
460
  );
461
+ expect((detail as unknown as SettingsSaveDetail).section).toBe("time");
548
462
  expect((detail as unknown as SettingsSaveDetail).data.timeZone).toBe(
549
463
  "America/New_York",
550
464
  );
551
465
  });
552
466
 
553
- it("includes mainRssFeed in feed section save", async () => {
467
+ it("auto-saves the main RSS feed choice without a save button", async () => {
554
468
  const el = await createElement();
555
469
  const latestRadio = requireElement(
556
470
  findRadioByValue(el, "main-rss-feed", "latest"),
557
471
  "expected latest radio option",
558
472
  );
559
473
 
560
- latestRadio.click();
561
- await el.updateComplete;
474
+ expect(findSaveButtonByHeading(el, labels.feeds)).toBeNull();
562
475
 
563
476
  let detail: SettingsSaveDetail | null = null;
564
477
  el.addEventListener("jant:settings-save", (event) => {
565
478
  detail = (event as CustomEvent<SettingsSaveDetail>).detail;
566
479
  });
567
480
 
568
- const saveBtn = findSaveButtonByHeading(el, labels.feeds);
569
- saveBtn?.click();
481
+ latestRadio.click();
570
482
  await el.updateComplete;
571
483
 
572
484
  expect(detail).not.toBeNull();
573
485
  expect((detail as unknown as SettingsSaveDetail).endpoint).toBe(
574
486
  "/settings/general/feeds",
575
487
  );
488
+ expect((detail as unknown as SettingsSaveDetail).section).toBe("feeds");
576
489
  expect((detail as unknown as SettingsSaveDetail).data.mainRssFeed).toBe(
577
490
  "latest",
578
491
  );
492
+ expect(latestRadio.checked).toBe(true);
493
+ });
494
+
495
+ it("sectionError for the feed choice restores the saved value", async () => {
496
+ const el = await createElement();
497
+ const latestRadio = requireElement(
498
+ findRadioByValue(el, "main-rss-feed", "latest"),
499
+ "expected latest radio option",
500
+ );
501
+
502
+ latestRadio.click();
503
+ await el.updateComplete;
504
+ expect(latestRadio.checked).toBe(true);
505
+
506
+ el.sectionError("feeds");
507
+ await el.updateComplete;
508
+
509
+ expect(latestRadio.checked).toBe(false);
510
+ expect(
511
+ requireElement(
512
+ findRadioByValue(el, "main-rss-feed", "featured"),
513
+ "expected featured radio option",
514
+ ).checked,
515
+ ).toBe(true);
579
516
  });
580
517
 
581
518
  it("sectionSaved resets site dirty state and updates originals", async () => {
@@ -735,4 +672,436 @@ describe("JantSettingsGeneral", () => {
735
672
  const spinner = saveBtn?.querySelector("svg.animate-spin");
736
673
  expect(spinner).not.toBeNull();
737
674
  });
675
+
676
+ /**
677
+ * Every control here saves on change, like the indexing checkbox beside it:
678
+ * ticking the box stores the default mode, and a mode is stored the moment
679
+ * it is picked. Ticking the box is also how a self-hosted site opts in, and
680
+ * that first save is what announces it to the directory.
681
+ */
682
+ describe("Discover", () => {
683
+ it("renders the section under Site visibility", async () => {
684
+ const el = await createElement();
685
+
686
+ expect(findSectionByHeading(el, labels.siteVisibility)).not.toBeNull();
687
+ expect(el.textContent).toContain(labels.discoverEnabled);
688
+ expect(el.textContent).toContain(labels.discoverIntro);
689
+ });
690
+
691
+ // Opt-in: a self-hosted site nobody configured shows the box unticked.
692
+ it("starts off for a site with no deployment default", async () => {
693
+ const el = await createElement();
694
+ const toggle = requireElement(
695
+ findCheckboxByLabel(el, labels.discoverEnabled) ?? null,
696
+ "expected the Discover checkbox",
697
+ );
698
+
699
+ expect(toggle.checked).toBe(false);
700
+
701
+ toggle.click();
702
+ await el.updateComplete;
703
+
704
+ expect(toggle.checked).toBe(true);
705
+ });
706
+
707
+ // Hosted Jant sets `DISCOVER=latest`, and an owner who has never opened
708
+ // this page must not be shown "off" while their feeds say otherwise.
709
+ it("starts on when the deployment lists its blogs", async () => {
710
+ const el = await createElement({ discoverDefault: "latest" });
711
+
712
+ expect(
713
+ requireElement(
714
+ findCheckboxByLabel(el, labels.discoverEnabled) ?? null,
715
+ "expected the Discover checkbox",
716
+ ).checked,
717
+ ).toBe(true);
718
+ });
719
+
720
+ // The section carries no Save button of its own: the only button it can
721
+ // render is the announcement retry, which needs a failed announcement.
722
+ it("offers no Save button", async () => {
723
+ const el = await createElement();
724
+ const section = requireElement(
725
+ findSectionByHeading(el, labels.siteVisibility),
726
+ "expected the Site visibility section",
727
+ );
728
+
729
+ expect(section.querySelectorAll("button").length).toBe(0);
730
+ });
731
+
732
+ it("sends latest when the box is ticked", async () => {
733
+ const el = await createElement();
734
+ let detail: SettingsSaveDetail | null = null;
735
+ el.addEventListener("jant:settings-save", (event) => {
736
+ detail = (event as CustomEvent<SettingsSaveDetail>).detail;
737
+ });
738
+
739
+ requireElement(
740
+ findCheckboxByLabel(el, labels.discoverEnabled) ?? null,
741
+ "expected the Discover checkbox",
742
+ ).click();
743
+ await el.updateComplete;
744
+
745
+ const d = detail as unknown as SettingsSaveDetail;
746
+ expect(d.endpoint).toBe("/settings/general/discover");
747
+ expect(d.section).toBe("discover");
748
+ expect(d.data.discover).toBe("latest");
749
+ });
750
+
751
+ it("sends off when the box is unticked", async () => {
752
+ const el = await createElement({ discoverDefault: "latest" });
753
+ let detail: SettingsSaveDetail | null = null;
754
+ el.addEventListener("jant:settings-save", (event) => {
755
+ detail = (event as CustomEvent<SettingsSaveDetail>).detail;
756
+ });
757
+
758
+ requireElement(
759
+ findCheckboxByLabel(el, labels.discoverEnabled) ?? null,
760
+ "expected the Discover checkbox",
761
+ ).click();
762
+ await el.updateComplete;
763
+
764
+ expect((detail as unknown as SettingsSaveDetail).data.discover).toBe(
765
+ "off",
766
+ );
767
+ });
768
+
769
+ // A site that chose the old featured-only mode reads as on, and the box
770
+ // widens it only when the owner ticks it again — never on its own.
771
+ it("keeps a stored featured choice ticked, and writes latest on re-tick", async () => {
772
+ const el = await createElement();
773
+ el.initData({ ...initialData, discover: "featured" });
774
+ await el.updateComplete;
775
+ const details: SettingsSaveDetail[] = [];
776
+ el.addEventListener("jant:settings-save", (event) => {
777
+ details.push((event as CustomEvent<SettingsSaveDetail>).detail);
778
+ });
779
+
780
+ const toggle = requireElement(
781
+ findCheckboxByLabel(el, labels.discoverEnabled) ?? null,
782
+ "expected the Discover checkbox",
783
+ );
784
+ expect(toggle.checked).toBe(true);
785
+ expect(details).toHaveLength(0);
786
+
787
+ toggle.click();
788
+ await el.updateComplete;
789
+ expect(details[0]?.data.discover).toBe("off");
790
+
791
+ el.sectionSaved("discover");
792
+ await el.updateComplete;
793
+ requireElement(
794
+ findCheckboxByLabel(el, labels.discoverEnabled) ?? null,
795
+ "expected the Discover checkbox",
796
+ ).click();
797
+ await el.updateComplete;
798
+ expect(details[1]?.data.discover).toBe("latest");
799
+ });
800
+
801
+ // The controls stay disabled until the save answers, so a second click
802
+ // cannot race the first.
803
+ it("disables the controls while a save is in flight", async () => {
804
+ const el = await createElement({ discoverDefault: "latest" });
805
+ const toggle = requireElement(
806
+ findCheckboxByLabel(el, labels.discoverEnabled) ?? null,
807
+ "expected the Discover checkbox",
808
+ );
809
+
810
+ toggle.click();
811
+ await el.updateComplete;
812
+
813
+ expect(toggle.disabled).toBe(true);
814
+
815
+ el.sectionSaved("discover");
816
+ await el.updateComplete;
817
+
818
+ expect(
819
+ requireElement(
820
+ findCheckboxByLabel(el, labels.discoverEnabled) ?? null,
821
+ "expected the Discover checkbox",
822
+ ).disabled,
823
+ ).toBe(false);
824
+ });
825
+
826
+ // A failed save leaves the page describing the site as it still is.
827
+ it("restores the stored choice when the save fails", async () => {
828
+ const el = await createElement({ discoverDefault: "latest" });
829
+ const toggle = requireElement(
830
+ findCheckboxByLabel(el, labels.discoverEnabled) ?? null,
831
+ "expected the Discover checkbox",
832
+ );
833
+
834
+ toggle.click();
835
+ await el.updateComplete;
836
+ el.sectionError("discover");
837
+ await el.updateComplete;
838
+
839
+ expect(
840
+ requireElement(
841
+ findCheckboxByLabel(el, labels.discoverEnabled) ?? null,
842
+ "expected the Discover checkbox",
843
+ ).checked,
844
+ ).toBe(true);
845
+ });
846
+
847
+ it("locks the control off for a demo site", async () => {
848
+ const el = await createElement({ demoMode: true });
849
+ const toggle = requireElement(
850
+ findCheckboxByLabel(el, labels.discoverEnabled) ?? null,
851
+ "expected the Discover checkbox",
852
+ );
853
+
854
+ expect(toggle.disabled).toBe(true);
855
+ expect(toggle.checked).toBe(false);
856
+ expect(el.textContent).toContain(labels.discoverDemoLocked);
857
+ });
858
+
859
+ // Discover reads the Atom feed, so with feeds off there is nothing to read.
860
+ it("locks the control off when the site publishes no feeds", async () => {
861
+ const el = await createElement({ feedsEnabled: false });
862
+ const toggle = requireElement(
863
+ findCheckboxByLabel(el, labels.discoverEnabled) ?? null,
864
+ "expected the Discover checkbox",
865
+ );
866
+
867
+ expect(toggle.disabled).toBe(true);
868
+ expect(el.textContent).toContain(labels.discoverFeedsOffLocked);
869
+ });
870
+
871
+ // What Discover is, is best answered by the list itself, so the sentence
872
+ // explaining the list is the way into it: the name goes to the home, the
873
+ // list names to the lists, and the rules to the page of rules.
874
+ it("links each page the help line names to that page", async () => {
875
+ const el = await createElement();
876
+ const intro = requireElement(
877
+ findIntroParagraph(el),
878
+ "expected the Discover help line",
879
+ );
880
+ const links = Array.from(intro.querySelectorAll<HTMLAnchorElement>("a"));
881
+
882
+ expect(
883
+ links.map((link) => [link.textContent, link.getAttribute("href")]),
884
+ ).toEqual([
885
+ [labels.discoverName, discoverPages.home],
886
+ ["Links", discoverPages.links],
887
+ ["Quotes", discoverPages.quotes],
888
+ [labels.discoverRules, discoverPages.rules],
889
+ ]);
890
+ for (const link of links) {
891
+ expect(link.getAttribute("target")).toBe("_blank");
892
+ expect(link.getAttribute("rel")).toBe("noopener noreferrer");
893
+ expect(link.className).toContain("underline");
894
+ }
895
+ // The whole line still reads as one sentence.
896
+ expect(intro.textContent?.trim()).toBe(labels.discoverIntro);
897
+ });
898
+
899
+ // A `<label>` forwards a click on any descendant to its control, so a link
900
+ // inside it would open the directory and flip the setting on the way out.
901
+ it("keeps the directory link out of the checkbox label", async () => {
902
+ const el = await createElement();
903
+ const label = requireElement(
904
+ findCheckboxByLabel(el, labels.discoverEnabled)?.closest("label"),
905
+ "expected the Discover checkbox label",
906
+ );
907
+
908
+ expect(label.querySelector("a")).toBeNull();
909
+ expect(label.textContent).toContain(labels.discoverEnabled);
910
+ });
911
+
912
+ // A directory this deployment does not announce to has no address to
913
+ // link, and a sentence with a dead link in it is worse than a plain one.
914
+ it("renders the help line as plain text with no directory configured", async () => {
915
+ const el = await createElement();
916
+ el.discoverPages = null;
917
+ await el.updateComplete;
918
+
919
+ const intro = requireElement(
920
+ findIntroParagraph(el),
921
+ "expected the Discover help line",
922
+ );
923
+
924
+ expect(intro.querySelector("a")).toBeNull();
925
+ expect(intro.textContent?.trim()).toBe(labels.discoverIntro);
926
+ });
927
+
928
+ // The two checkboxes are one rule read twice. Search indexing gates the
929
+ // deployment default, so a page that answers the second from a value the
930
+ // server resolved at load time goes on claiming a listing the feed has
931
+ // already stopped declaring — right until the owner reloads and finds out.
932
+ it("unticks Discover when search indexing is turned off", async () => {
933
+ const el = await createElement({ discoverDefault: "latest" });
934
+ const discover = requireElement(
935
+ findCheckboxByLabel(el, labels.discoverEnabled) ?? null,
936
+ "expected the Discover checkbox",
937
+ );
938
+ const indexing = requireElement(
939
+ findCheckboxByLabel(el, labels.allowIndexing) ?? null,
940
+ "expected the indexing checkbox",
941
+ );
942
+ expect(discover.checked).toBe(true);
943
+
944
+ indexing.click();
945
+ await el.updateComplete;
946
+
947
+ expect(discover.checked).toBe(false);
948
+ });
949
+
950
+ // A control that moves on its own has to say why it moved.
951
+ it("says why the box unticked itself", async () => {
952
+ const el = await createElement({ discoverDefault: "latest" });
953
+ expect(el.textContent).not.toContain(labels.discoverSearchOff);
954
+
955
+ requireElement(
956
+ findCheckboxByLabel(el, labels.allowIndexing) ?? null,
957
+ "expected the indexing checkbox",
958
+ ).click();
959
+ await el.updateComplete;
960
+
961
+ expect(el.textContent).toContain(labels.discoverSearchOff);
962
+ });
963
+
964
+ // Nothing was holding this site back: the deployment lists nothing by
965
+ // default, so an unticked box is the opt-in it has always been.
966
+ it("says nothing about search on a site no default would list", async () => {
967
+ const el = await createElement();
968
+
969
+ requireElement(
970
+ findCheckboxByLabel(el, labels.allowIndexing) ?? null,
971
+ "expected the indexing checkbox",
972
+ ).click();
973
+ await el.updateComplete;
974
+
975
+ expect(el.textContent).not.toContain(labels.discoverSearchOff);
976
+ });
977
+
978
+ // An owner who answered Discover is not being held back by anything: the
979
+ // stored choice is read first, whichever way it went.
980
+ it("says nothing about search once the owner has answered", async () => {
981
+ const el = await createElement({ discoverDefault: "latest" });
982
+ const discover = requireElement(
983
+ findCheckboxByLabel(el, labels.discoverEnabled) ?? null,
984
+ "expected the Discover checkbox",
985
+ );
986
+
987
+ discover.click();
988
+ await el.updateComplete;
989
+ el.sectionSaved("discover");
990
+ await el.updateComplete;
991
+
992
+ requireElement(
993
+ findCheckboxByLabel(el, labels.allowIndexing) ?? null,
994
+ "expected the indexing checkbox",
995
+ ).click();
996
+ await el.updateComplete;
997
+
998
+ expect(discover.checked).toBe(false);
999
+ expect(el.textContent).not.toContain(labels.discoverSearchOff);
1000
+ });
1001
+
1002
+ it("ticks Discover again when search indexing comes back", async () => {
1003
+ const el = await createElement({ discoverDefault: "latest" });
1004
+ const indexing = requireElement(
1005
+ findCheckboxByLabel(el, labels.allowIndexing) ?? null,
1006
+ "expected the indexing checkbox",
1007
+ );
1008
+
1009
+ indexing.click();
1010
+ await el.updateComplete;
1011
+ el.sectionSaved("search");
1012
+ await el.updateComplete;
1013
+ indexing.click();
1014
+ await el.updateComplete;
1015
+ el.sectionSaved("search");
1016
+ await el.updateComplete;
1017
+
1018
+ expect(
1019
+ requireElement(
1020
+ findCheckboxByLabel(el, labels.discoverEnabled) ?? null,
1021
+ "expected the Discover checkbox",
1022
+ ).checked,
1023
+ ).toBe(true);
1024
+ });
1025
+
1026
+ // An owner who ticked the box meant it, and turning off search indexing
1027
+ // does not quietly undo it — the server reads it the same way.
1028
+ it("leaves a stored choice alone when search indexing is turned off", async () => {
1029
+ const el = await createElement({ discoverDefault: "latest" });
1030
+ const discover = requireElement(
1031
+ findCheckboxByLabel(el, labels.discoverEnabled) ?? null,
1032
+ "expected the Discover checkbox",
1033
+ );
1034
+
1035
+ discover.click();
1036
+ await el.updateComplete;
1037
+ el.sectionSaved("discover");
1038
+ await el.updateComplete;
1039
+ discover.click();
1040
+ await el.updateComplete;
1041
+ el.sectionSaved("discover");
1042
+ await el.updateComplete;
1043
+ expect(discover.checked).toBe(true);
1044
+
1045
+ requireElement(
1046
+ findCheckboxByLabel(el, labels.allowIndexing) ?? null,
1047
+ "expected the indexing checkbox",
1048
+ ).click();
1049
+ await el.updateComplete;
1050
+
1051
+ expect(discover.checked).toBe(true);
1052
+ });
1053
+
1054
+ // The status is what the server sent with the page, and it sends a line
1055
+ // only when there is something to say. A site whose setting is doing what
1056
+ // it says is handed nothing, and no heading is left behind to announce an
1057
+ // empty block. Ticking the box does not conjure one either: the lines are
1058
+ // recomputed on the next page load.
1059
+ it("drops the status block when the server sends no lines", async () => {
1060
+ const el = await createElement({
1061
+ discoverStatus: JSON.stringify({
1062
+ lines: [],
1063
+ showAnnounce: false,
1064
+ submitUrl: null,
1065
+ }),
1066
+ });
1067
+
1068
+ expect(el.querySelector(".border-t.pt-3")).toBeNull();
1069
+
1070
+ requireElement(
1071
+ findCheckboxByLabel(el, labels.discoverEnabled) ?? null,
1072
+ "expected the Discover checkbox",
1073
+ ).click();
1074
+ await el.updateComplete;
1075
+
1076
+ expect(el.querySelector(".border-t.pt-3")).toBeNull();
1077
+ });
1078
+
1079
+ it("shows the status the server sent for a listed site", async () => {
1080
+ const el = await createElement({
1081
+ discoverDefault: "latest",
1082
+ discoverStatus: JSON.stringify({
1083
+ lines: ["Not announced yet."],
1084
+ showAnnounce: false,
1085
+ submitUrl: null,
1086
+ }),
1087
+ });
1088
+
1089
+ expect(el.textContent).toContain("Not announced yet.");
1090
+ });
1091
+
1092
+ // The lines and the button are separate halves of the same block, so the
1093
+ // block cannot key its existence on the lines alone.
1094
+ it("keeps the block for an announce button with no lines", async () => {
1095
+ const el = await createElement({
1096
+ discoverDefault: "latest",
1097
+ discoverStatus: JSON.stringify({
1098
+ lines: [],
1099
+ showAnnounce: true,
1100
+ submitUrl: null,
1101
+ }),
1102
+ });
1103
+
1104
+ expect(el.textContent).toContain(labels.discoverAnnounce);
1105
+ });
1106
+ });
738
1107
  });