@jant/core 0.6.16 → 0.7.1

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 (592) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/export.js +3 -1
  4. package/bin/commands/import-site.js +749 -220
  5. package/bin/commands/reset-password.js +21 -8
  6. package/bin/commands/setup.js +136 -0
  7. package/bin/commands/site/export.js +71 -34
  8. package/bin/commands/site/pull-media.js +2 -6
  9. package/bin/commands/site/snapshot/export.js +19 -61
  10. package/bin/commands/site/snapshot/import.js +23 -24
  11. package/bin/lib/d1-query.js +92 -18
  12. package/bin/lib/hugo-markdown.js +4 -0
  13. package/bin/lib/node-env.js +40 -4
  14. package/bin/lib/site-pull-media.js +21 -28
  15. package/bin/lib/site-selection.js +94 -29
  16. package/bin/lib/site-snapshot.js +338 -3
  17. package/bin/lib/sql-export.js +68 -5
  18. package/bin/lib/wrangler-cli.js +37 -2
  19. package/bin/lib/wrangler-json.js +35 -0
  20. package/bin/lib/zip-archive.js +187 -0
  21. package/dist/{app-C7_4oab9.js → app-ZcaI1kPN.js} +53190 -13529
  22. package/dist/client/.vite/manifest.json +255 -90
  23. package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
  24. package/dist/client/_assets/chunks/create-editor-B-m7X7S5.js +261 -0
  25. package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
  26. package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
  27. package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
  28. package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
  29. package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
  30. package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
  31. package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
  32. package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
  33. package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
  34. package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
  35. package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
  36. package/dist/client/_assets/chunks/sortable-list-BJyd-LXE.js +2 -0
  37. package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
  38. package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
  39. package/dist/client/_assets/chunks/unsafe-svg-BkscJx69.js +2 -0
  40. package/dist/client/_assets/client-DAhoqPdr.css +2 -0
  41. package/dist/client/_assets/client-auth-CAMfTrKW.js +828 -0
  42. package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
  43. package/dist/client/_assets/client-compose-D6rExKVK.js +2004 -0
  44. package/dist/client/_assets/client-knSEyUJO.js +295 -0
  45. package/dist/client/_assets/client-manage-sVOkqVbH.js +2170 -0
  46. package/dist/client/_assets/client-settings-CGR_nZ78.js +1112 -0
  47. package/dist/github-sync-Gw4orAHk.js +8398 -0
  48. package/dist/index.js +2 -5
  49. package/dist/node.js +11 -9
  50. package/package.json +15 -5
  51. package/src/__tests__/auth-session-freshness.test.ts +57 -0
  52. package/src/__tests__/basecoat-variants.test.ts +121 -0
  53. package/src/__tests__/bin/assets-upload.test.ts +68 -0
  54. package/src/__tests__/dev-scripts.test.ts +203 -0
  55. package/src/__tests__/export-collection-order.test.ts +276 -0
  56. package/src/__tests__/export-feed-ids.test.ts +184 -0
  57. package/src/__tests__/export-feed-order.test.ts +294 -0
  58. package/src/__tests__/export-hugo-build.test.ts +390 -0
  59. package/src/__tests__/export-import-roundtrip.test.ts +94 -0
  60. package/src/__tests__/export-service.test.ts +842 -28
  61. package/src/__tests__/export-smart-collection.test.ts +329 -0
  62. package/src/__tests__/helpers/component-css.ts +91 -0
  63. package/src/__tests__/helpers/db.ts +33 -0
  64. package/src/__tests__/helpers/export-fixtures.ts +3 -0
  65. package/src/__tests__/helpers/hugo-site.ts +146 -0
  66. package/src/__tests__/import-site-command.test.ts +487 -1
  67. package/src/__tests__/mise-config.test.ts +75 -4
  68. package/src/__tests__/node-dev-tasks.test.ts +264 -0
  69. package/src/__tests__/site-export-canonical-import.test.ts +149 -0
  70. package/src/__tests__/snapshot-canonical-replay.test.ts +179 -0
  71. package/src/__tests__/snapshot-settings.test.ts +97 -0
  72. package/src/__tests__/snapshot-tables.test.ts +219 -0
  73. package/src/__tests__/sql-export.test.ts +173 -0
  74. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  75. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  76. package/src/__tests__/zip-archive.test.ts +106 -0
  77. package/src/app.tsx +86 -22
  78. package/src/auth.ts +18 -1
  79. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  80. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  81. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  82. package/src/client/__tests__/compose-bridge.test.ts +272 -0
  83. package/src/client/__tests__/compose-launch.test.ts +62 -1
  84. package/src/client/__tests__/compose-shortcuts.test.ts +38 -9
  85. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  86. package/src/client/__tests__/copy-field.test.ts +162 -0
  87. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  88. package/src/client/__tests__/image-processor.test.ts +324 -2
  89. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  90. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  91. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  92. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  93. package/src/client/__tests__/toast.test.ts +23 -0
  94. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  95. package/src/client/__tests__/video-processor.test.ts +181 -0
  96. package/src/client/audio-processor.ts +24 -15
  97. package/src/client/audio-waveform.ts +49 -0
  98. package/src/client/collection-dialog-host.ts +72 -0
  99. package/src/client/collection-navigation.ts +51 -16
  100. package/src/client/collection-page-actions.ts +26 -0
  101. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  102. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  103. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  104. package/src/client/components/__tests__/jant-command-palette.test.ts +28 -0
  105. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  106. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1250 -53
  107. package/src/client/components/__tests__/jant-compose-editor.test.ts +544 -40
  108. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +0 -1
  109. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  110. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  111. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  112. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  113. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  114. package/src/client/components/__tests__/jant-settings-general.test.ts +561 -172
  115. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  116. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  117. package/src/client/components/collection-dialog-types.ts +49 -0
  118. package/src/client/components/collection-manager-types.ts +32 -1
  119. package/src/client/components/collection-types.ts +1 -15
  120. package/src/client/components/compose-types.ts +75 -1
  121. package/src/client/components/jant-collection-dialog.ts +722 -0
  122. package/src/client/components/jant-collection-directory.ts +567 -182
  123. package/src/client/components/jant-collection-form.ts +53 -232
  124. package/src/client/components/jant-command-palette.ts +100 -49
  125. package/src/client/components/jant-compose-dialog.ts +1486 -707
  126. package/src/client/components/jant-compose-editor.ts +673 -336
  127. package/src/client/components/jant-compose-fullscreen.ts +51 -43
  128. package/src/client/components/jant-config-editor.ts +65 -53
  129. package/src/client/components/jant-confirm-dialog.ts +13 -11
  130. package/src/client/components/jant-locale-picker.ts +418 -0
  131. package/src/client/components/jant-media-lightbox.ts +146 -136
  132. package/src/client/components/jant-nav-manager.ts +690 -366
  133. package/src/client/components/jant-post-menu.ts +1086 -282
  134. package/src/client/components/jant-post-picker.ts +324 -0
  135. package/src/client/components/jant-repo-picker-types.ts +6 -1
  136. package/src/client/components/jant-repo-picker.ts +137 -108
  137. package/src/client/components/jant-settings-avatar.ts +31 -27
  138. package/src/client/components/jant-settings-general.ts +622 -543
  139. package/src/client/components/jant-settings-language.ts +1087 -0
  140. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  141. package/src/client/components/jant-text-preview.ts +94 -86
  142. package/src/client/components/nav-manager-types.ts +62 -1
  143. package/src/client/components/settings-types.ts +14 -29
  144. package/src/client/components/smart-collection-conditions.ts +285 -0
  145. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  146. package/src/client/compose-bridge.ts +214 -141
  147. package/src/client/compose-launch.ts +76 -2
  148. package/src/client/compose-shortcuts.ts +6 -8
  149. package/src/client/compose-triggers.ts +50 -0
  150. package/src/client/copy-field.ts +108 -0
  151. package/src/client/custom-url-menu.ts +60 -4
  152. package/src/client/icons.ts +14 -0
  153. package/src/client/image-plan.ts +165 -0
  154. package/src/client/image-processor.ts +195 -117
  155. package/src/client/image-worker-client.ts +45 -0
  156. package/src/client/image-worker.ts +135 -0
  157. package/src/client/lazy-entries.ts +95 -0
  158. package/src/client/lazy-slugify.ts +2 -2
  159. package/src/client/media-scroll-hint.ts +43 -2
  160. package/src/client/mediabunny.ts +29 -0
  161. package/src/client/post-picker.ts +41 -0
  162. package/src/client/runtime-paths.ts +74 -1
  163. package/src/client/settings-bridge.ts +4 -10
  164. package/src/client/site-header-nav.js +18 -6
  165. package/src/client/smart-collection-dialog-host.ts +116 -0
  166. package/src/client/smart-collection-page-actions.ts +208 -0
  167. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  168. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  169. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  170. package/src/client/tiptap/__tests__/list-editing.test.ts +224 -86
  171. package/src/client/tiptap/__tests__/mark-exit.test.ts +1 -2
  172. package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +26 -0
  173. package/src/client/tiptap/bubble-menu.ts +47 -16
  174. package/src/client/tiptap/extensions.ts +0 -3
  175. package/src/client/tiptap/floating-position.ts +216 -2
  176. package/src/client/tiptap/inline-image-upload.ts +7 -3
  177. package/src/client/tiptap/link-toolbar.ts +22 -9
  178. package/src/client/tiptap/structural-keymap.ts +158 -47
  179. package/src/client/toast.ts +13 -0
  180. package/src/client/types/vite-worker.d.ts +12 -0
  181. package/src/client/upload-with-metadata.ts +3 -13
  182. package/src/client/video-processor.ts +351 -107
  183. package/src/client-auth.ts +14 -31
  184. package/src/client-compose.ts +19 -0
  185. package/src/client-manage.ts +13 -0
  186. package/src/client-settings.ts +14 -0
  187. package/src/client-site.ts +10 -3
  188. package/src/client.ts +4 -0
  189. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  190. package/src/db/__tests__/d1-query.test.ts +56 -1
  191. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  192. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  193. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  194. package/src/db/migrations/0032_married_tomas.sql +3 -0
  195. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  196. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  197. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  198. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  199. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  200. package/src/db/migrations/meta/_journal.json +21 -0
  201. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  202. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  203. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  204. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  205. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  206. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  207. package/src/db/migrations/pg/meta/_journal.json +21 -0
  208. package/src/db/pg/schema.ts +195 -27
  209. package/src/db/post-visibility.ts +117 -0
  210. package/src/db/schema.ts +222 -27
  211. package/src/i18n/__tests__/detect.test.ts +26 -81
  212. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  213. package/src/i18n/context.tsx +12 -2
  214. package/src/i18n/detect.ts +7 -66
  215. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  216. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  217. package/src/i18n/locales/public/en.po +490 -158
  218. package/src/i18n/locales/public/en.ts +1 -1
  219. package/src/i18n/locales/public/zh-Hans.po +487 -155
  220. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  221. package/src/i18n/locales/public/zh-Hant.po +487 -155
  222. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  223. package/src/i18n/locales/settings/en.po +555 -73
  224. package/src/i18n/locales/settings/en.ts +1 -1
  225. package/src/i18n/locales/settings/zh-Hans.po +601 -119
  226. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  227. package/src/i18n/locales/settings/zh-Hant.po +614 -132
  228. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  229. package/src/i18n/locales.ts +94 -0
  230. package/src/i18n/middleware.ts +12 -2
  231. package/src/i18n/supported-locales.ts +80 -0
  232. package/src/lib/__tests__/api-settings.test.ts +11 -2
  233. package/src/lib/__tests__/asset-path.test.ts +92 -0
  234. package/src/lib/__tests__/deferred.test.ts +90 -0
  235. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  236. package/src/lib/__tests__/discover.test.ts +410 -0
  237. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  238. package/src/lib/__tests__/feed.test.ts +1526 -15
  239. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  240. package/src/lib/__tests__/github-sync-repo-name.test.ts +40 -0
  241. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  242. package/src/lib/__tests__/http-cache.test.ts +162 -0
  243. package/src/lib/__tests__/image.test.ts +27 -1
  244. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  245. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  246. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  247. package/src/lib/__tests__/markdown-to-tiptap.test.ts +105 -0
  248. package/src/lib/__tests__/markdown.test.ts +10 -0
  249. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  250. package/src/lib/__tests__/navigation.test.ts +135 -1
  251. package/src/lib/__tests__/resolve-config.test.ts +131 -3
  252. package/src/lib/__tests__/schemas.test.ts +69 -5
  253. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  254. package/src/lib/__tests__/slug.test.ts +70 -0
  255. package/src/lib/__tests__/slugify.test.ts +85 -0
  256. package/src/lib/__tests__/storage.test.ts +32 -0
  257. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  258. package/src/lib/__tests__/timeline.test.ts +87 -0
  259. package/src/lib/__tests__/tiptap-to-markdown.test.ts +172 -4
  260. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  261. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  262. package/src/lib/__tests__/url.test.ts +78 -72
  263. package/src/lib/__tests__/view-language.test.ts +413 -0
  264. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  265. package/src/lib/address.ts +57 -0
  266. package/src/lib/api-posts.ts +12 -0
  267. package/src/lib/asset-path.ts +98 -1
  268. package/src/lib/build-env.ts +16 -0
  269. package/src/lib/collection-groups.ts +5 -1
  270. package/src/lib/collection-paths.ts +3 -10
  271. package/src/lib/constants.ts +28 -3
  272. package/src/lib/copy-field.ts +36 -0
  273. package/src/lib/crypto.ts +40 -0
  274. package/src/lib/csp-builder.ts +2 -1
  275. package/src/lib/deferred.ts +79 -0
  276. package/src/lib/discover-ping.ts +141 -0
  277. package/src/lib/discover.ts +439 -0
  278. package/src/lib/env.ts +134 -0
  279. package/src/lib/errors.ts +33 -0
  280. package/src/lib/feed-policy.ts +236 -2
  281. package/src/lib/feed.ts +926 -73
  282. package/src/lib/filter-dimensions.ts +1349 -0
  283. package/src/lib/github-sync-repo-name.ts +45 -0
  284. package/src/lib/github-sync-site-config.ts +3 -0
  285. package/src/lib/hosted-signin.ts +1 -1
  286. package/src/lib/http-cache.ts +148 -0
  287. package/src/lib/hugo-markdown.ts +62 -0
  288. package/src/lib/icons.ts +9 -2
  289. package/src/lib/ids.ts +3 -0
  290. package/src/lib/image.ts +17 -4
  291. package/src/lib/jant-docs.ts +42 -0
  292. package/src/lib/lang-detect.ts +308 -0
  293. package/src/lib/markdown-manager.ts +392 -2
  294. package/src/lib/media-helpers.ts +13 -1
  295. package/src/lib/navigation.ts +78 -11
  296. package/src/lib/pagination.ts +28 -24
  297. package/src/lib/per-language-surfaces.ts +67 -0
  298. package/src/lib/post-body-html.ts +11 -4
  299. package/src/lib/post-meta.ts +19 -0
  300. package/src/lib/render.tsx +55 -7
  301. package/src/lib/resolve-config.ts +111 -6
  302. package/src/lib/schemas.ts +251 -15
  303. package/src/lib/site-header-fragment.tsx +2 -1
  304. package/src/lib/site-unavailable-page.ts +47 -0
  305. package/src/lib/slug.ts +51 -7
  306. package/src/lib/slugify.ts +113 -0
  307. package/src/lib/startup-config.ts +4 -6
  308. package/src/lib/storage.ts +7 -0
  309. package/src/lib/summary.ts +50 -0
  310. package/src/lib/thread-fold.ts +161 -0
  311. package/src/lib/time.ts +32 -0
  312. package/src/lib/timeline.ts +43 -2
  313. package/src/lib/tiptap-render.ts +1 -2
  314. package/src/lib/tiptap-to-markdown.ts +13 -7
  315. package/src/lib/upload.ts +16 -13
  316. package/src/lib/url.ts +159 -86
  317. package/src/lib/version.ts +10 -8
  318. package/src/lib/view-language.ts +436 -0
  319. package/src/lib/view.ts +53 -25
  320. package/src/lib/viewer-context.ts +62 -0
  321. package/src/middleware/__tests__/auth.test.ts +70 -2
  322. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  323. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  324. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  325. package/src/middleware/__tests__/session.test.ts +181 -33
  326. package/src/middleware/auth.ts +50 -31
  327. package/src/middleware/cache-control.ts +75 -10
  328. package/src/middleware/config.ts +6 -6
  329. package/src/middleware/error-handler.ts +12 -0
  330. package/src/middleware/onboarding.ts +32 -10
  331. package/src/middleware/secure-headers.ts +1 -1
  332. package/src/middleware/session.ts +96 -8
  333. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  334. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  335. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  336. package/src/node/__tests__/cli-setup.test.ts +163 -0
  337. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  338. package/src/node/__tests__/cli-site-snapshot.test.ts +67 -0
  339. package/src/node/__tests__/cli-snapshot-meta.test.ts +20 -0
  340. package/src/node/__tests__/runtime.test.ts +75 -0
  341. package/src/node/index.ts +3 -0
  342. package/src/node/request-handler.ts +3 -1
  343. package/src/preset.css +76 -22
  344. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  345. package/src/routes/api/__tests__/collections.test.ts +114 -0
  346. package/src/routes/api/__tests__/discover.test.ts +222 -0
  347. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  348. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  349. package/src/routes/api/__tests__/posts.test.ts +24 -0
  350. package/src/routes/api/__tests__/settings.test.ts +24 -3
  351. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  352. package/src/routes/api/__tests__/upload.test.ts +34 -0
  353. package/src/routes/api/collections.ts +49 -6
  354. package/src/routes/api/custom-urls.ts +0 -1
  355. package/src/routes/api/discover.ts +95 -0
  356. package/src/routes/api/export.ts +6 -3
  357. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  358. package/src/routes/api/internal/sites.ts +0 -1
  359. package/src/routes/api/nav-items.ts +55 -8
  360. package/src/routes/api/posts.ts +161 -0
  361. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  362. package/src/routes/api/public/archive.ts +77 -81
  363. package/src/routes/api/public/posts.ts +44 -7
  364. package/src/routes/api/smart-collections.ts +132 -0
  365. package/src/routes/api/telegram.ts +2 -40
  366. package/src/routes/api/upload.ts +10 -3
  367. package/src/routes/api/uploads.ts +1 -2
  368. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  369. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  370. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  371. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  372. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  373. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  374. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  375. package/src/routes/auth/setup.tsx +934 -186
  376. package/src/routes/auth/signin.tsx +10 -8
  377. package/src/routes/compose.tsx +9 -0
  378. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  379. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  380. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  381. package/src/routes/dash/__tests__/github-sync-app.test.ts +365 -0
  382. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  383. package/src/routes/dash/custom-urls.tsx +1 -37
  384. package/src/routes/dash/settings.tsx +714 -111
  385. package/src/routes/discover-announce.ts +96 -0
  386. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  387. package/src/routes/feed/feed.ts +69 -40
  388. package/src/routes/feed/sitemap.ts +95 -11
  389. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  390. package/src/routes/pages/__tests__/archive-params.test.ts +370 -6
  391. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  392. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  393. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  394. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  395. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  396. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  397. package/src/routes/pages/__tests__/post-page-round-trips.test.ts +211 -0
  398. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  399. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  400. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  401. package/src/routes/pages/__tests__/thread-order.test.ts +179 -0
  402. package/src/routes/pages/archive.tsx +487 -379
  403. package/src/routes/pages/collection.tsx +94 -96
  404. package/src/routes/pages/collections.tsx +38 -50
  405. package/src/routes/pages/featured.tsx +43 -18
  406. package/src/routes/pages/home.tsx +25 -6
  407. package/src/routes/pages/language.tsx +113 -0
  408. package/src/routes/pages/latest.tsx +26 -16
  409. package/src/routes/pages/new.tsx +3 -0
  410. package/src/routes/pages/page.tsx +228 -45
  411. package/src/routes/pages/partials.tsx +41 -0
  412. package/src/routes/pages/search.tsx +22 -5
  413. package/src/routes/pages/smart-collection.tsx +340 -0
  414. package/src/routes/pages/subscribe.tsx +182 -0
  415. package/src/runtime/__tests__/index.test.ts +19 -0
  416. package/src/runtime/__tests__/node.test.ts +129 -4
  417. package/src/runtime/__tests__/readiness.test.ts +23 -0
  418. package/src/runtime/cloudflare.ts +21 -2
  419. package/src/runtime/index.ts +27 -1
  420. package/src/runtime/node.ts +67 -1
  421. package/src/runtime/readiness.ts +15 -0
  422. package/src/runtime/site.ts +95 -24
  423. package/src/services/__tests__/auth.test.ts +116 -7
  424. package/src/services/__tests__/bootstrap-setup-instance.test.ts +230 -0
  425. package/src/services/__tests__/collection.test.ts +108 -4
  426. package/src/services/__tests__/custom-url.test.ts +34 -0
  427. package/src/services/__tests__/github-app-installations.test.ts +153 -0
  428. package/src/services/__tests__/github-sync-push.test.ts +46 -0
  429. package/src/services/__tests__/language.test.ts +495 -0
  430. package/src/services/__tests__/media.test.ts +31 -0
  431. package/src/services/__tests__/navigation.test.ts +213 -10
  432. package/src/services/__tests__/path.test.ts +236 -0
  433. package/src/services/__tests__/post-language.test.ts +830 -0
  434. package/src/services/__tests__/post-timeline.test.ts +127 -0
  435. package/src/services/__tests__/post.test.ts +120 -9
  436. package/src/services/__tests__/settings.test.ts +228 -17
  437. package/src/services/__tests__/site-admin.test.ts +5 -5
  438. package/src/services/__tests__/smart-collection.test.ts +651 -0
  439. package/src/services/about-page.ts +22 -1
  440. package/src/services/auth.ts +51 -49
  441. package/src/services/bootstrap.ts +408 -39
  442. package/src/services/collection-directory-position.ts +76 -0
  443. package/src/services/collection.ts +331 -46
  444. package/src/services/custom-url.ts +29 -24
  445. package/src/services/export-theme/assets/client-site.css +1 -1
  446. package/src/services/export-theme/assets/client-site.js +123 -123
  447. package/src/services/export-theme/layouts/_default/alias.html +27 -1
  448. package/src/services/export-theme/layouts/_default/list.html +2 -63
  449. package/src/services/export-theme/layouts/_default/rss.xml +103 -38
  450. package/src/services/export-theme/layouts/collections/list.html +3 -3
  451. package/src/services/export-theme/layouts/featured/list.html +1 -4
  452. package/src/services/export-theme/layouts/index.html +40 -26
  453. package/src/services/export-theme/layouts/partials/collection-members.html +100 -0
  454. package/src/services/export-theme/layouts/partials/collection-threads.html +33 -0
  455. package/src/services/export-theme/layouts/partials/featured-members.html +35 -0
  456. package/src/services/export-theme/layouts/partials/featured-thread.html +1 -1
  457. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  458. package/src/services/export-theme/layouts/partials/footer.html +1 -1
  459. package/src/services/export-theme/layouts/partials/head.html +1 -1
  460. package/src/services/export-theme/layouts/partials/header.html +5 -3
  461. package/src/services/export-theme/layouts/partials/jant-data.html +23 -0
  462. package/src/services/export-theme/layouts/partials/latest-members.html +48 -0
  463. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  464. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  465. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  466. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  467. package/src/services/export-theme/layouts/partials/smart-collection-members.html +129 -0
  468. package/src/services/export-theme/layouts/partials/thread-preview.html +41 -31
  469. package/src/services/export-theme/layouts/post/list.html +1 -1
  470. package/src/services/export-theme/layouts/smart_collection/list.html +24 -0
  471. package/src/services/export-theme/styles/main.css +13 -2
  472. package/src/services/export-theme/theme.toml +1 -1
  473. package/src/services/export.ts +880 -72
  474. package/src/services/github-app-installations.ts +171 -4
  475. package/src/services/github-sync.ts +69 -15
  476. package/src/services/index.ts +34 -3
  477. package/src/services/language.ts +412 -0
  478. package/src/services/mcp.ts +22 -2
  479. package/src/services/media.ts +22 -7
  480. package/src/services/navigation.ts +429 -67
  481. package/src/services/path.ts +254 -6
  482. package/src/services/post.ts +1234 -190
  483. package/src/services/search.ts +39 -2
  484. package/src/services/settings.ts +238 -43
  485. package/src/services/site-admin.ts +27 -14
  486. package/src/services/site.ts +160 -32
  487. package/src/services/smart-collection.ts +636 -0
  488. package/src/services/telegram.ts +20 -22
  489. package/src/services/upload-session.ts +1 -3
  490. package/src/style-author.css +27 -0
  491. package/src/styles/components-author.css +1622 -0
  492. package/src/styles/components.css +24 -1571
  493. package/src/styles/site-media.css +16 -2
  494. package/src/styles/tokens.css +82 -12
  495. package/src/styles/ui-author.css +7602 -0
  496. package/src/styles/ui.css +1090 -7216
  497. package/src/types/app-context.ts +23 -0
  498. package/src/types/bindings.ts +8 -0
  499. package/src/types/config.ts +94 -15
  500. package/src/types/constants.ts +188 -16
  501. package/src/types/entities.ts +87 -1
  502. package/src/types/operations.ts +74 -2
  503. package/src/types/props.ts +157 -31
  504. package/src/types/views.ts +55 -1
  505. package/src/ui/__tests__/font-themes.test.ts +52 -12
  506. package/src/ui/compose/ComposeDialog.tsx +201 -7
  507. package/src/ui/compose/ComposePrompt.tsx +1 -5
  508. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  509. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  510. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  511. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  512. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  513. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  514. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  515. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  516. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  517. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  518. package/src/ui/dash/settings/GeneralContent.tsx +277 -113
  519. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  520. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  521. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  522. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  523. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  524. package/src/ui/dash/settings/discover-copy.ts +116 -0
  525. package/src/ui/feed/LinkCard.tsx +55 -45
  526. package/src/ui/feed/NoteCard.tsx +9 -4
  527. package/src/ui/feed/PostStatusBadges.tsx +90 -43
  528. package/src/ui/feed/QuoteCard.tsx +55 -30
  529. package/src/ui/feed/ThreadPreview.tsx +6 -2
  530. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  531. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  532. package/src/ui/feed/thread-preview-state.ts +20 -6
  533. package/src/ui/font-themes.ts +39 -29
  534. package/src/ui/layouts/BaseLayout.tsx +155 -10
  535. package/src/ui/layouts/SiteLayout.tsx +192 -34
  536. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  537. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  538. package/src/ui/pages/ArchivePage.tsx +282 -289
  539. package/src/ui/pages/CollectionPage.tsx +86 -76
  540. package/src/ui/pages/CollectionsPage.tsx +5 -0
  541. package/src/ui/pages/ComposePage.tsx +8 -0
  542. package/src/ui/pages/PostPage.tsx +47 -0
  543. package/src/ui/pages/SearchPage.tsx +3 -3
  544. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  545. package/src/ui/pages/SubscribePage.tsx +236 -0
  546. package/src/ui/pages/__tests__/ArchivePage.test.tsx +65 -9
  547. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  548. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  549. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  550. package/src/ui/shared/CollectionsManager.tsx +70 -17
  551. package/src/ui/shared/CopyField.tsx +127 -0
  552. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  553. package/src/ui/shared/MediaGallery.tsx +125 -46
  554. package/src/ui/shared/Pagination.tsx +20 -5
  555. package/src/ui/shared/PostFooter.tsx +20 -11
  556. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  557. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  558. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  559. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  560. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  561. package/src/ui/shared/archive-labels.ts +98 -0
  562. package/src/ui/shared/collection-management-labels.ts +94 -16
  563. package/src/ui/shared/navigation-labels.ts +45 -71
  564. package/src/ui/shared/post-article-attributes.ts +2 -0
  565. package/src/ui/shared/smart-collection-labels.ts +343 -0
  566. package/dist/app-C75Zc8dD.js +0 -6
  567. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  568. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  569. package/dist/client/_assets/chunks/url-CU9pEbk5.js +0 -1
  570. package/dist/client/_assets/client-Bq0vre8Y.js +0 -296
  571. package/dist/client/_assets/client-D3VZyG4L.css +0 -2
  572. package/dist/client/_assets/client-auth-F2rb9WfF.js +0 -5571
  573. package/dist/env-BPYViDJJ.js +0 -281
  574. package/dist/export-Dl5KCiEs.js +0 -5562
  575. package/dist/github-api-BNF35Lkx.js +0 -176
  576. package/dist/github-app-DdiD-bC4.js +0 -275
  577. package/dist/github-sync-Bqg62IvV.js +0 -4
  578. package/dist/github-sync-HdK2EgvJ.js +0 -431
  579. package/dist/url-CbLAtlZe.js +0 -900
  580. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  581. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  582. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  583. package/src/client/collection-created-notice.ts +0 -66
  584. package/src/client/collection-form-bridge.ts +0 -191
  585. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  586. package/src/client/components/jant-post-form.ts +0 -422
  587. package/src/client/components/post-form-template.ts +0 -352
  588. package/src/client/components/post-form-types.ts +0 -96
  589. package/src/client/media-metadata.ts +0 -247
  590. package/src/client/post-form-bridge.ts +0 -193
  591. package/src/client/tiptap/exitable-marks.ts +0 -73
  592. package/src/ui/pages/CollectionEditorPage.tsx +0 -175
@@ -13,19 +13,35 @@ import { z } from "zod";
13
13
  import type { Bindings } from "../../types.js";
14
14
  import type { AppVariables } from "../../types/app-context.js";
15
15
  import { sse, dsRedirect, dsToast } from "../../lib/sse.js";
16
- import { getI18n, isLocale, resolveCatalogLocale } from "../../i18n/index.js";
16
+ import { getI18n, isLocale } from "../../i18n/index.js";
17
17
  import { renderPublicPage } from "../../lib/render.js";
18
18
  import { getNavigationData } from "../../lib/navigation.js";
19
19
  import { buildPageTitle } from "../../lib/page-title.js";
20
20
  import { AdminBreadcrumb } from "../../ui/shared/AdminBreadcrumb.js";
21
21
  import { getTimeZoneOptions } from "../../lib/timezones.js";
22
- import { ValidationError } from "../../lib/errors.js";
22
+ import { getOrBuildEntry } from "../../i18n/supported-locales.js";
23
+ import {
24
+ DomainError,
25
+ LanguageInUseError,
26
+ UnauthorizedError,
27
+ ValidationError,
28
+ } from "../../lib/errors.js";
23
29
  import { SETTINGS_KEYS } from "../../lib/constants.js";
30
+ import { announceInBackground } from "../discover-announce.js";
31
+ import {
32
+ DISCOVER_FIRST_READ_MAX_HOURS,
33
+ DISCOVER_MIN_PUBLIC_POSTS,
34
+ getDiscoverPageUrls,
35
+ getDiscoverSubmitUrl,
36
+ measureDiscoverMaturity,
37
+ parseDiscoverSetting,
38
+ } from "../../lib/discover.js";
24
39
  import { getAvailableThemes } from "../../lib/theme.js";
25
40
  import { THEME_MODES, type ThemeMode } from "../../types/config.js";
26
41
  import { BUILTIN_FONT_THEMES } from "../../ui/font-themes.js";
27
42
  import { SettingsRootContent } from "../../ui/dash/settings/SettingsRootContent.js";
28
43
  import { GeneralContent } from "../../ui/dash/settings/GeneralContent.js";
44
+ import { LanguageContent } from "../../ui/dash/settings/LanguageContent.js";
29
45
  import { AvatarContent } from "../../ui/dash/settings/AvatarContent.js";
30
46
  import { AccountMenuContent } from "../../ui/dash/settings/AccountMenuContent.js";
31
47
  import { AccountContent } from "../../ui/dash/settings/AccountContent.js";
@@ -52,16 +68,24 @@ import {
52
68
  } from "../../lib/telegram-settings-status.js";
53
69
  import { TelegramContent } from "../../ui/dash/settings/TelegramContent.js";
54
70
  import { toAbsoluteSiteUrl, toPublicPath } from "../../lib/url.js";
55
- import { parseValidated, UpdateSiteSettingsSchema } from "../../lib/schemas.js";
71
+ import {
72
+ ContentLanguageSchema,
73
+ parseValidated,
74
+ UpdateSiteSettingsSchema,
75
+ } from "../../lib/schemas.js";
56
76
  import {
57
77
  getHostedControlPlaneAccountPasswordUrl,
58
78
  getHostedControlPlaneAccountUrl,
59
79
  getHostedControlPlaneProviderLabel,
60
80
  getHostedControlPlaneSiteDeleteUrl,
61
81
  getHostedControlPlaneSiteSettingsUrl,
82
+ isHostedControlPlaneEnabled,
62
83
  } from "../../lib/hosted-signin.js";
63
84
  import { syncHostedControlPlaneSiteAvatar } from "../../lib/hosted-control-plane-sync.js";
64
85
  import {
86
+ getDiscoverDefault,
87
+ getDiscoverDirectoryBaseUrl,
88
+ getDiscoverPingUrl,
65
89
  getGitHubAppConfig,
66
90
  getHostedControlPlaneSsoSecret,
67
91
  getTelegramBotPool,
@@ -71,6 +95,7 @@ import {
71
95
  getInstallation,
72
96
  listInstallationReposPage,
73
97
  searchInstallationRepos,
98
+ type InstallationAccount,
74
99
  } from "../../lib/github-app.js";
75
100
  import {
76
101
  isSyncPending,
@@ -79,6 +104,7 @@ import {
79
104
  triggerGitHubSyncInline,
80
105
  } from "../../lib/github-sync-trigger.js";
81
106
  import { buildSyncSiteConfig } from "../../lib/github-sync-site-config.js";
107
+ import { suggestSyncRepoName } from "../../lib/github-sync-repo-name.js";
82
108
  import { buildConfigEditorFields } from "../../lib/api-settings.js";
83
109
  import {
84
110
  readGitHubSyncStatus,
@@ -95,15 +121,26 @@ type Env = { Bindings: Bindings; Variables: AppVariables };
95
121
 
96
122
  export const settingsRoutes = new Hono<Env>();
97
123
 
98
- const UpdateLocaleSettingsSchema = z.object({
99
- siteLanguage: z.string(),
100
- // Optional for back-compat: absent = leave the dashboard language untouched,
101
- // "" = clear it (follow content language).
102
- dashboardLanguage: z.string().optional(),
103
- cjkSerifFont: z.string(),
124
+ const UpdateTimeSettingsSchema = z.object({
104
125
  timeZone: z.string(),
105
126
  });
106
127
 
128
+ // Both fields are optional: the language page saves one control at a time, and
129
+ // an absent field means "leave it alone" rather than "clear it".
130
+ const UpdateLanguageSettingsSchema = z.object({
131
+ contentLanguage: ContentLanguageSchema.optional(),
132
+ dashboardLanguage: z.string().optional(),
133
+ });
134
+
135
+ const LanguageTagSchema = z.object({
136
+ language: ContentLanguageSchema,
137
+ });
138
+
139
+ const EnableMultilingualSchema = z.object({
140
+ primary: ContentLanguageSchema,
141
+ additional: z.array(ContentLanguageSchema),
142
+ });
143
+
107
144
  const UpdateFeedSettingsSchema = z.object({
108
145
  mainRssFeed: z.enum(["featured", "latest"]),
109
146
  });
@@ -116,6 +153,13 @@ const UpdateSearchSettingsSchema = z.object({
116
153
  allowIndexing: z.boolean(),
117
154
  });
118
155
 
156
+ // Only the two values the control writes. `featured` is still a stored value
157
+ // older releases wrote and the feed still declares, but nothing writes it any
158
+ // more: which of a blog's posts reach which list is the directory's rule.
159
+ const UpdateDiscoverSettingsSchema = z.object({
160
+ discover: z.enum(["latest", "off"]),
161
+ });
162
+
119
163
  function publicPath(c: Context<Env>, path: string): string {
120
164
  return toPublicPath(path, c.var.appConfig.sitePathPrefix);
121
165
  }
@@ -124,6 +168,39 @@ function aboutEditPath(c: Context<Env>): string {
124
168
  return `${publicPath(c, "/about")}?edit=1`;
125
169
  }
126
170
 
171
+ /**
172
+ * The session id behind the current request.
173
+ *
174
+ * Every `/settings` route sits behind `requireAuth`, so a missing session here
175
+ * is a wiring mistake rather than a signed-out visitor — hence a throw instead
176
+ * of a redirect.
177
+ */
178
+ function requireSessionId(c: Context<Env>): string {
179
+ const sessionId = c.var.session?.session?.id;
180
+ if (!sessionId) {
181
+ throw new UnauthorizedError();
182
+ }
183
+
184
+ return sessionId;
185
+ }
186
+
187
+ /**
188
+ * The user behind the current request.
189
+ *
190
+ * Same contract as `requireSessionId`: `requireAuth` has already proven the
191
+ * session belongs to a member of this site, so an absent user is a wiring
192
+ * mistake. Used wherever a decision is scoped to the person rather than to
193
+ * the site — the GitHub App installations they may reuse, most of all.
194
+ */
195
+ function requireSessionUserId(c: Context<Env>): string {
196
+ const userId = c.var.session?.user?.id;
197
+ if (!userId) {
198
+ throw new UnauthorizedError();
199
+ }
200
+
201
+ return userId;
202
+ }
203
+
127
204
  /**
128
205
  * Breadcrumb labels for admin settings pages.
129
206
  *
@@ -138,6 +215,7 @@ function breadcrumbLabel(
138
215
  key:
139
216
  | "settings"
140
217
  | "general"
218
+ | "language"
141
219
  | "avatar"
142
220
  | "navigation"
143
221
  | "colorTheme"
@@ -163,6 +241,10 @@ function breadcrumbLabel(
163
241
  return i18n._(
164
242
  msg({ message: "General", comment: "@context: Breadcrumb label" }),
165
243
  );
244
+ case "language":
245
+ return i18n._(
246
+ msg({ message: "Language", comment: "@context: Breadcrumb label" }),
247
+ );
166
248
  case "avatar":
167
249
  return i18n._(
168
250
  msg({ message: "Avatar", comment: "@context: Breadcrumb label" }),
@@ -318,11 +400,44 @@ settingsRoutes.get("/general", async (c) => {
318
400
 
319
401
  const dbSiteName = allSettings["SITE_NAME"] ?? "";
320
402
  const dbSiteDescription = allSettings["SITE_DESCRIPTION"] ?? "";
403
+ // The stored choice, not the effective mode: the control has to be able to
404
+ // show "never chosen", which is what lets `noindex` and then the deployment
405
+ // default decide instead.
406
+ const discoverSetting = parseDiscoverSetting(allSettings["DISCOVER"]);
407
+ // The deployment's own answer, and nothing else folded into it: `latest` on
408
+ // a deployment that lists its fleet, absent on an ordinary self-hosted site.
409
+ // The gates that sit above it — `noindex` most of all — are applied in the
410
+ // browser instead, because they are controls on this same page and a value
411
+ // resolved here would freeze at page load. The component runs the same
412
+ // `resolveDiscoverMode` this route would have.
413
+ const discoverDefault = parseDiscoverSetting(getDiscoverDefault(c.env)) ?? "";
321
414
 
322
415
  const saved = c.req.query("saved") !== undefined;
323
- const [navData, aboutPage] = await Promise.all([
416
+ // What the site can answer about its own standing in the directory, without
417
+ // asking one. The directory deliberately does not take status queries, so
418
+ // everything shown here is local evidence.
419
+ const publicPostFilters = {
420
+ status: "published" as const,
421
+ excludeReplies: true,
422
+ excludeLatestHidden: true,
423
+ excludePrivate: true,
424
+ };
425
+ const [
426
+ navData,
427
+ aboutPage,
428
+ announceState,
429
+ publicPostCount,
430
+ featuredPostCount,
431
+ ] = await Promise.all([
324
432
  getNavigationData(c),
325
433
  c.var.services.aboutPage.getStatus(),
434
+ c.var.services.settings.getDiscoverAnnounceState(),
435
+ c.var.services.posts.count(publicPostFilters),
436
+ c.var.services.posts.countFeaturedThreadRoots({
437
+ status: "published",
438
+ excludePrivate: true,
439
+ excludeLatestHidden: true,
440
+ }),
326
441
  ]);
327
442
  const siteUrlForDisplay =
328
443
  appConfig.siteUrl || new URL(publicPath(c, "/"), c.req.url).toString();
@@ -341,13 +456,6 @@ settingsRoutes.get("/general", async (c) => {
341
456
  <GeneralContent
342
457
  siteName={dbSiteName || ""}
343
458
  siteDescription={dbSiteDescription || ""}
344
- siteLanguage={appConfig.siteLanguage}
345
- dashboardLanguage={
346
- isLocale(appConfig.dashboardLanguage)
347
- ? appConfig.dashboardLanguage
348
- : resolveCatalogLocale(appConfig.siteLanguage)
349
- }
350
- cjkSerifFont={appConfig.cjkSerifFont}
351
459
  siteNameFallback={appConfig.fallbacks.siteName}
352
460
  siteDescriptionFallback={appConfig.fallbacks.siteDescription}
353
461
  mainRssFeed={appConfig.mainRssFeed}
@@ -375,6 +483,32 @@ settingsRoutes.get("/general", async (c) => {
375
483
  siteFooter={appConfig.siteFooter}
376
484
  showJantBrandingOnHome={appConfig.showJantBrandingOnHome}
377
485
  noindex={appConfig.noindex}
486
+ discover={discoverSetting ?? ""}
487
+ discoverDefault={discoverDefault}
488
+ discoverPages={getDiscoverPageUrls(
489
+ getDiscoverDirectoryBaseUrl(c.env),
490
+ )}
491
+ discoverStatus={{
492
+ announced: announceState?.ok ?? null,
493
+ announceError: announceState?.error ?? null,
494
+ announceAt: announceState?.at ?? null,
495
+ // No directory configured means nothing to announce to, and the
496
+ // whole announcement block is beside the point.
497
+ hasDirectory: getDiscoverPingUrl(c.env) !== undefined,
498
+ // A hosted fleet is enrolled by its control plane, so there is no
499
+ // announcement for the owner to make, chase, or retry.
500
+ managedByHost: isHostedControlPlaneEnabled(c.env),
501
+ submitUrl: getDiscoverSubmitUrl(getDiscoverDirectoryBaseUrl(c.env)),
502
+ // The mode the feeds actually declare, already derived once on
503
+ // `appConfig` — re-deriving it here is how this block used to
504
+ // miss the deployment default.
505
+ declaredMode: appConfig.discover,
506
+ ...measureDiscoverMaturity({ publicPostCount }),
507
+ featuredPostCount,
508
+ minPublicPosts: DISCOVER_MIN_PUBLIC_POSTS,
509
+ firstReadMaxHours: DISCOVER_FIRST_READ_MAX_HOURS,
510
+ }}
511
+ rssFeedsEnabled={appConfig.rssFeedsEnabled}
378
512
  demoMode={appConfig.demoMode}
379
513
  timezones={getTimeZoneOptions(appConfig.timeZone)}
380
514
  aboutPage={aboutPage}
@@ -451,42 +585,286 @@ settingsRoutes.post("/general", async (c) => {
451
585
  }
452
586
  });
453
587
 
454
- settingsRoutes.post("/general/language-time", async (c) => {
588
+ // Time zone only. Language moved to its own page, so this no longer sends —
589
+ // and must not clear — the language fields.
590
+ settingsRoutes.post("/general/time", async (c) => {
455
591
  const i18n = getI18n(c);
456
- const body = parseValidated(UpdateLocaleSettingsSchema, await c.req.json());
592
+ const body = parseValidated(UpdateTimeSettingsSchema, await c.req.json());
457
593
  const toast = i18n._(
458
594
  msg({
459
- message: "Language and time updated.",
460
- comment: "@context: Toast after saving language and time settings",
595
+ message: "Time zone updated.",
596
+ comment: "@context: Toast after saving the time zone",
461
597
  }),
462
598
  );
463
- const { languageChanged } =
464
- await c.var.services.settings.updateLocaleSettings(body, {
465
- oldLanguage: c.var.appConfig.siteLanguage,
466
- oldCjkSerifFont: c.var.appConfig.cjkSerifFont,
467
- oldDashboardLanguage: c.var.appConfig.dashboardLanguage,
468
- });
599
+ await c.var.services.settings.updateLocaleSettings(body, {
600
+ oldLanguage: c.var.appConfig.siteLanguage,
601
+ });
469
602
 
470
603
  const wantsJson = c.req.header("accept")?.includes("application/json");
471
604
  if (wantsJson) {
472
- if (languageChanged) {
473
- return c.json({
474
- status: "redirect" as const,
475
- url: publicPath(c, "/settings/general?saved"),
476
- });
477
- }
605
+ return c.json({ status: "ok" as const, toast });
606
+ }
607
+
608
+ return dsToast(toast);
609
+ });
610
+
611
+ // ===========================================================================
612
+ // Language
613
+ //
614
+ // Every language setting lives on one page, and every write goes through the
615
+ // language service — the multilingual keys are DB-only precisely so this is the
616
+ // only door.
617
+ // ===========================================================================
478
618
 
619
+ settingsRoutes.get("/language", async (c) => {
620
+ const { appConfig } = c.var;
621
+ const navData = await getNavigationData(c);
622
+ const [state, preview] = await Promise.all([
623
+ c.var.services.language.getState(),
624
+ c.var.services.language.getEnablePreview(),
625
+ ]);
626
+
627
+ return renderPublicPage(c, {
628
+ title: buildPageTitle("Language", navData.siteName),
629
+ navData,
630
+ content: (
631
+ <>
632
+ <AdminBreadcrumb
633
+ parent={breadcrumbLabel(c, "settings")}
634
+ parentHref={publicPath(c, "/settings")}
635
+ current={breadcrumbLabel(c, "language")}
636
+ />
637
+ <LanguageContent
638
+ contentLanguage={state.primary}
639
+ // Unset stays unset rather than being shown as the locale it
640
+ // currently resolves to: "Follow content language" is a real choice
641
+ // on this page, and it is the one most sites are on.
642
+ dashboardLanguage={
643
+ isLocale(appConfig.dashboardLanguage)
644
+ ? appConfig.dashboardLanguage
645
+ : ""
646
+ }
647
+ multilingualEnabled={state.enabled}
648
+ additionalLanguages={state.additional}
649
+ unmarkedPostCount={preview.pendingCount}
650
+ sitePathPrefix={appConfig.sitePathPrefix}
651
+ />
652
+ </>
653
+ ),
654
+ });
655
+ });
656
+
657
+ /**
658
+ * Run a language mutation and report it as JSON.
659
+ *
660
+ * Every language endpoint has the same shape — do the thing, or explain in one
661
+ * sentence why it could not be done — so they share one wrapper rather than
662
+ * repeating the try/catch. The toast may be built from what the action
663
+ * returned, for the one operation whose outcome is not known until it runs.
664
+ */
665
+ async function respondToLanguageAction<T>(
666
+ c: Context<Env>,
667
+ toast: string | ((result: T) => string),
668
+ action: () => Promise<T>,
669
+ ): Promise<Response> {
670
+ try {
671
+ const result = await action();
479
672
  return c.json({
480
673
  status: "ok" as const,
481
- toast,
674
+ toast: typeof toast === "function" ? toast(result) : toast,
482
675
  });
676
+ } catch (error) {
677
+ // The one refusal an author will actually meet — removing or dropping a
678
+ // language that posts still use — gets a localized sentence naming the
679
+ // language; the settings page pairs it with a link to those posts.
680
+ if (error instanceof LanguageInUseError) {
681
+ const i18n = getI18n(c);
682
+ return c.json(
683
+ {
684
+ error: i18n._(
685
+ msg({
686
+ message:
687
+ "{count, plural, one {# post is} other {# posts are}} still written in {language}. Change their language, or keep the language.",
688
+ comment:
689
+ "@context: Error when a content language that posts still use would be dropped",
690
+ }),
691
+ {
692
+ count: error.postCount,
693
+ language: getOrBuildEntry(error.language).native,
694
+ },
695
+ ),
696
+ code: error.code,
697
+ language: error.language,
698
+ },
699
+ 400,
700
+ );
701
+ }
702
+ if (error instanceof DomainError) {
703
+ return c.json({ error: error.message, code: error.code }, 400);
704
+ }
705
+ throw error;
483
706
  }
707
+ }
708
+
709
+ settingsRoutes.post("/language", async (c) => {
710
+ const i18n = getI18n(c);
711
+ const body = parseValidated(UpdateLanguageSettingsSchema, await c.req.json());
712
+
713
+ return respondToLanguageAction(
714
+ c,
715
+ i18n._(
716
+ msg({
717
+ message: "Language updated.",
718
+ comment: "@context: Toast after saving a language setting",
719
+ }),
720
+ ),
721
+ async () => {
722
+ await c.var.services.settings.updateLocaleSettings(
723
+ {
724
+ siteLanguage: body.contentLanguage,
725
+ dashboardLanguage: body.dashboardLanguage,
726
+ },
727
+ {
728
+ oldLanguage: c.var.appConfig.siteLanguage,
729
+ oldDashboardLanguage: c.var.appConfig.dashboardLanguage,
730
+ },
731
+ );
732
+ },
733
+ );
734
+ });
735
+
736
+ settingsRoutes.post("/language/enable", async (c) => {
737
+ const i18n = getI18n(c);
738
+ const body = parseValidated(EnableMultilingualSchema, await c.req.json());
484
739
 
485
- if (languageChanged) {
486
- return dsRedirect(publicPath(c, "/settings/general?saved"));
740
+ try {
741
+ const { markedCount } = await c.var.services.language.enable(body);
742
+ return c.json({
743
+ status: "ok" as const,
744
+ toast:
745
+ markedCount > 0
746
+ ? i18n._(
747
+ msg({
748
+ message:
749
+ "Multilingual content is on. {count, plural, one {# post was marked} other {# posts were marked}} as {language}.",
750
+ comment:
751
+ "@context: Toast after turning multilingual content on with existing posts",
752
+ }),
753
+ // The language's own name, not its tag — the author picked
754
+ // "繁體中文" in the dialog, not "zh-Hant".
755
+ {
756
+ count: markedCount,
757
+ language: getOrBuildEntry(body.primary).native,
758
+ },
759
+ )
760
+ : i18n._(
761
+ msg({
762
+ message: "Multilingual content is on.",
763
+ comment:
764
+ "@context: Toast after turning multilingual content on with no existing posts",
765
+ }),
766
+ ),
767
+ });
768
+ } catch (error) {
769
+ if (error instanceof LanguageInUseError) {
770
+ // Worded for the dialog it lands in: the fix is putting the language
771
+ // back on the list right there, and the response carries the tag so
772
+ // the dialog can offer that as one click.
773
+ return c.json(
774
+ {
775
+ error: i18n._(
776
+ msg({
777
+ message:
778
+ "{count, plural, one {# post is} other {# posts are}} written in {language}, which is not on this list. Add it back, or change their language first.",
779
+ comment:
780
+ "@context: Refusal in the multilingual dialog when the list drops a language that posts still use",
781
+ }),
782
+ {
783
+ count: error.postCount,
784
+ language: getOrBuildEntry(error.language).native,
785
+ },
786
+ ),
787
+ code: error.code,
788
+ language: error.language,
789
+ },
790
+ 400,
791
+ );
792
+ }
793
+ if (error instanceof DomainError) {
794
+ return c.json({ error: error.message, code: error.code }, 400);
795
+ }
796
+ throw error;
487
797
  }
798
+ });
488
799
 
489
- return dsToast(toast);
800
+ settingsRoutes.post("/language/disable", async (c) => {
801
+ const i18n = getI18n(c);
802
+ return respondToLanguageAction(
803
+ c,
804
+ i18n._(
805
+ msg({
806
+ message: "Multilingual content is off. Your languages are still saved.",
807
+ comment: "@context: Toast after turning multilingual content off",
808
+ }),
809
+ ),
810
+ () => c.var.services.language.disable(),
811
+ );
812
+ });
813
+
814
+ settingsRoutes.post("/language/primary", async (c) => {
815
+ const i18n = getI18n(c);
816
+ const body = parseValidated(LanguageTagSchema, await c.req.json());
817
+ return respondToLanguageAction(
818
+ c,
819
+ i18n._(
820
+ msg({
821
+ message: "Primary language changed.",
822
+ comment: "@context: Toast after changing the primary language",
823
+ }),
824
+ ),
825
+ () => c.var.services.language.setPrimary(body.language),
826
+ );
827
+ });
828
+
829
+ settingsRoutes.post("/language/add", async (c) => {
830
+ const i18n = getI18n(c);
831
+ const body = parseValidated(LanguageTagSchema, await c.req.json());
832
+ return respondToLanguageAction(
833
+ c,
834
+ i18n._(
835
+ msg({
836
+ message: "Language added.",
837
+ comment: "@context: Toast after adding a content language",
838
+ }),
839
+ ),
840
+ () => c.var.services.language.addLanguage(body.language),
841
+ );
842
+ });
843
+
844
+ settingsRoutes.post("/language/remove", async (c) => {
845
+ const i18n = getI18n(c);
846
+ const body = parseValidated(LanguageTagSchema, await c.req.json());
847
+ return respondToLanguageAction(
848
+ c,
849
+ // The last language takes the feature with it, and that is the larger of
850
+ // the two facts — the author leaves this page single-language.
851
+ ({ multilingualDisabled }) =>
852
+ multilingualDisabled
853
+ ? i18n._(
854
+ msg({
855
+ message: "Language removed. Multilingual content is off.",
856
+ comment:
857
+ "@context: Toast after removing the last additional content language",
858
+ }),
859
+ )
860
+ : i18n._(
861
+ msg({
862
+ message: "Language removed.",
863
+ comment: "@context: Toast after removing a content language",
864
+ }),
865
+ ),
866
+ () => c.var.services.language.removeLanguage(body.language),
867
+ );
490
868
  });
491
869
 
492
870
  settingsRoutes.post("/general/feeds", async (c) => {
@@ -527,6 +905,88 @@ settingsRoutes.post("/general/home", async (c) => {
527
905
  return dsToast(toast);
528
906
  });
529
907
 
908
+ /**
909
+ * Announce, because the owner asked.
910
+ *
911
+ * The route the settings page offers whenever the directory has not heard from
912
+ * this site: an announcement that failed, and one that was never made at all.
913
+ * Sending it is safe at any time — the receiving end treats a repeat as a
914
+ * no-op for a site it already knows, and it carries nothing but the feed
915
+ * address.
916
+ */
917
+ settingsRoutes.post("/general/discover/announce", async (c) => {
918
+ const i18n = getI18n(c);
919
+ const { appConfig } = c.var;
920
+
921
+ const started =
922
+ !appConfig.demoMode &&
923
+ announceInBackground(c, c.var.allSettings["DISCOVER"]);
924
+
925
+ const toast = started
926
+ ? i18n._(
927
+ msg({
928
+ message: "Announcing your site. Reload to see the result.",
929
+ comment:
930
+ "@context: Toast after sending the Discover announcement. It runs in the background, so the page does not yet know how it went.",
931
+ }),
932
+ )
933
+ : i18n._(
934
+ msg({
935
+ message: "This site has no directory to announce to.",
936
+ comment:
937
+ "@context: Toast when the Discover announcement cannot be sent because no directory is configured, or the site cannot be listed at all",
938
+ }),
939
+ );
940
+
941
+ // The settings bridge is the only caller, and it always asks for JSON — a
942
+ // Datastar toast reaches it as a body it cannot parse, which it reports as a
943
+ // failed save whatever the server actually did. Every other settings route
944
+ // answers both shapes; this one has to as well.
945
+ const wantsJson = c.req.header("accept")?.includes("application/json");
946
+ if (wantsJson) {
947
+ return c.json({ status: "ok" as const, toast });
948
+ }
949
+
950
+ return dsToast(toast);
951
+ });
952
+
953
+ settingsRoutes.post("/general/discover", async (c) => {
954
+ const i18n = getI18n(c);
955
+ const body = parseValidated(UpdateDiscoverSettingsSchema, await c.req.json());
956
+ const { appConfig } = c.var;
957
+
958
+ const { shouldAnnounce } =
959
+ await c.var.services.settings.updateDiscoverSetting(body.discover, {
960
+ demoMode: appConfig.demoMode,
961
+ });
962
+
963
+ if (shouldAnnounce) {
964
+ announceInBackground(c, body.discover);
965
+ }
966
+
967
+ const wantsJson = c.req.header("accept")?.includes("application/json");
968
+ if (wantsJson) {
969
+ return c.json({
970
+ status: "ok" as const,
971
+ toast: i18n._(
972
+ msg({
973
+ message: "Site visibility updated.",
974
+ comment: "@context: Toast after saving the site visibility settings",
975
+ }),
976
+ ),
977
+ });
978
+ }
979
+
980
+ return dsToast(
981
+ i18n._(
982
+ msg({
983
+ message: "Site visibility updated.",
984
+ comment: "@context: Toast after saving the site visibility settings",
985
+ }),
986
+ ),
987
+ );
988
+ });
989
+
530
990
  settingsRoutes.post("/general/search", async (c) => {
531
991
  const i18n = getI18n(c);
532
992
  const body = parseValidated(UpdateSearchSettingsSchema, await c.req.json());
@@ -795,7 +1255,7 @@ settingsRoutes.post("/avatar/display", async (c) => {
795
1255
  settingsRoutes.get("/navigation", async (c) => {
796
1256
  const [navItems, directoryData, suggestedLinks] = await Promise.all([
797
1257
  c.var.services.navItems.list(),
798
- c.var.services.collections.listDirectoryData(),
1258
+ c.var.services.collections.listDirectoryData({ isAuthenticated: true }),
799
1259
  c.var.services.navItems.listSuggestedLinks({
800
1260
  siteOrigin: c.var.appConfig.siteOrigin,
801
1261
  sitePathPrefix: c.var.appConfig.sitePathPrefix,
@@ -1332,7 +1792,9 @@ settingsRoutes.get("/account/delete-account", async (c) => {
1332
1792
  }
1333
1793
 
1334
1794
  const navData = await getNavigationData(c);
1335
- const csrfToken = await c.var.services.auth.generateDeleteCsrfToken();
1795
+ const csrfToken = await c.var.services.auth.generateDeleteCsrfToken(
1796
+ requireSessionId(c),
1797
+ );
1336
1798
 
1337
1799
  return renderPublicPage(c, {
1338
1800
  title: buildPageTitle("Delete Account", navData.siteName),
@@ -1390,7 +1852,10 @@ settingsRoutes.post("/account/delete-account", async (c) => {
1390
1852
  );
1391
1853
  }
1392
1854
 
1393
- const isValid = await c.var.services.auth.validateDeleteCsrfToken(csrfToken);
1855
+ const isValid = await c.var.services.auth.validateDeleteCsrfToken(
1856
+ csrfToken,
1857
+ requireSessionId(c),
1858
+ );
1394
1859
  if (!isValid) {
1395
1860
  return dsToast(
1396
1861
  i18n._(
@@ -1404,11 +1869,26 @@ settingsRoutes.post("/account/delete-account", async (c) => {
1404
1869
  );
1405
1870
  }
1406
1871
 
1872
+ // Sign the browser out first. Every row behind this session is about to
1873
+ // disappear, and better-auth's cookie cache would keep answering "signed in"
1874
+ // from the browser's own copy for minutes afterwards — a ticket to a site
1875
+ // that no longer exists, handed to whoever completes the next setup.
1876
+ let signOutHeaders: Headers | undefined;
1877
+ try {
1878
+ const signedOut = await c.var.auth.api.signOut({
1879
+ headers: c.req.raw.headers,
1880
+ asResponse: true,
1881
+ });
1882
+ signOutHeaders = signedOut.headers;
1883
+ } catch {
1884
+ // The wipe below removes the session either way.
1885
+ }
1886
+
1407
1887
  await c.var.services.auth.deleteAllData({
1408
1888
  storage: c.var.storage,
1409
1889
  });
1410
1890
 
1411
- return dsRedirect(publicPath(c, "/setup"));
1891
+ return dsRedirect(publicPath(c, "/setup"), { headers: signOutHeaders });
1412
1892
  });
1413
1893
 
1414
1894
  // ===========================================================================
@@ -1619,6 +2099,49 @@ settingsRoutes.post("/github-sync/disconnect", async (c) => {
1619
2099
  // GitHub App install flow
1620
2100
  // ---------------------------------------------------------------------------
1621
2101
 
2102
+ /**
2103
+ * Recreate installation bindings for sites that sync through the App
2104
+ * but have no row to prove it.
2105
+ *
2106
+ * GitHub is the only source for the account behind an installation id,
2107
+ * so the repair costs one call per distinct id — bounded by how many
2108
+ * sites the author has, and only ever reached when they would otherwise
2109
+ * be shown a GitHub page that cannot lead anywhere. Ids GitHub no longer
2110
+ * recognizes are skipped: an uninstalled App has nothing to rebuild.
2111
+ */
2112
+ async function rebuildInstallationBindings(
2113
+ c: Context<Env>,
2114
+ app: import("../../lib/env.js").GitHubAppEnvConfig,
2115
+ userId: string,
2116
+ ): Promise<void> {
2117
+ const syncing =
2118
+ await c.var.services.githubAppInstallations.listSyncingInstallationsForUser(
2119
+ userId,
2120
+ );
2121
+ if (syncing.length === 0) return;
2122
+
2123
+ const accounts = new Map<string, InstallationAccount>();
2124
+ for (const { installationId } of syncing) {
2125
+ if (accounts.has(installationId)) continue;
2126
+ try {
2127
+ const installation = await getInstallation(app, installationId);
2128
+ accounts.set(installationId, installation.account);
2129
+ } catch {
2130
+ continue;
2131
+ }
2132
+ }
2133
+
2134
+ for (const { siteId, installationId } of syncing) {
2135
+ const account = accounts.get(installationId);
2136
+ if (!account) continue;
2137
+ await c.var.services.githubAppInstallations.upsertInstallation(
2138
+ installationId,
2139
+ siteId,
2140
+ account,
2141
+ );
2142
+ }
2143
+ }
2144
+
1622
2145
  /**
1623
2146
  * Redirect the user to GitHub to install the App on their account/org.
1624
2147
  *
@@ -1631,26 +2154,46 @@ settingsRoutes.get("/github-sync/app/install", async (c) => {
1631
2154
  return c.text("GitHub App is not configured on this deployment.", 404);
1632
2155
  }
1633
2156
 
1634
- // Reinstall-same-account predicate (§5): when this site already has at
1635
- // least one authorized installation, skip the GitHub round-trip and
1636
- // render the picker directly. The GitHub redirect is a dead end when
1637
- // the App is already installed on the chosen account — GitHub shows
1638
- // its Configure page and never comes back with installation_id.
2157
+ // Reinstall-same-account predicate: when the signed-in author has
2158
+ // already authorized an installation — on this site or on any other
2159
+ // site they belong to — skip the GitHub round-trip and render the
2160
+ // picker directly. The GitHub redirect is a dead end once the App is
2161
+ // installed on the chosen account: GitHub shows its Configure page
2162
+ // and never comes back with installation_id.
2163
+ //
2164
+ // The scope is the user's sites rather than this one because a GitHub
2165
+ // App installs once per GitHub account. An author connecting their
2166
+ // second blog to the same account has no binding here yet and cannot
2167
+ // create one through GitHub, so site-scoping this check would leave
2168
+ // them with no way in at all.
1639
2169
  //
1640
2170
  // `?force=new` bypasses the predicate so the picker's "Install on
1641
2171
  // another account" action can still reach GitHub to add a fresh
1642
2172
  // install.
1643
2173
  const forceNew = c.req.query("force") === "new";
1644
2174
  if (!forceNew) {
1645
- const existing =
1646
- await c.var.services.githubAppInstallations.listInstallationsForSite(
1647
- c.var.currentSite.id,
2175
+ const userId = requireSessionUserId(c);
2176
+ let existing =
2177
+ await c.var.services.githubAppInstallations.listInstallationsForUser(
2178
+ userId,
1648
2179
  );
2180
+ // An author with nothing to show but a site already syncing through
2181
+ // the App is in the one state the redirect cannot rescue: GitHub
2182
+ // will not install the App a second time on that account. Rebuild
2183
+ // the binding from the sync settings instead of sending them into
2184
+ // the dead end.
2185
+ if (existing.length === 0) {
2186
+ await rebuildInstallationBindings(c, app, userId);
2187
+ existing =
2188
+ await c.var.services.githubAppInstallations.listInstallationsForUser(
2189
+ userId,
2190
+ );
2191
+ }
1649
2192
  if (existing.length > 0) {
1650
2193
  const navData = await getNavigationData(c);
1651
2194
  const base = publicPath(c, "/settings/github-sync");
1652
- const labels = buildRepoPickerLabels(c);
1653
- const suggestedRepoName = buildSuggestedRepoName(c);
2195
+ const suggestedRepoName = suggestSyncRepoName(c.var.appConfig.siteUrl);
2196
+ const labels = buildRepoPickerLabels(c, suggestedRepoName);
1654
2197
  return renderPublicPage(c, {
1655
2198
  title: buildPageTitle(
1656
2199
  "GitHub Sync — Pick Repository",
@@ -1748,19 +2291,17 @@ settingsRoutes.get("/github-sync/app/callback", async (c) => {
1748
2291
  }
1749
2292
  }
1750
2293
 
1751
- // One-shot: clear the cookie so it can't be replayed.
1752
- setCookie(c, "jant_gh_app_state", "", {
1753
- httpOnly: true,
1754
- sameSite: "Lax",
1755
- path: "/",
1756
- maxAge: 0,
1757
- });
1758
-
1759
2294
  // Fetch the account info for this installation and remember it. The
1760
- // picker's owner dropdown reads the stored list, so subsequent visits
1761
- // can switch between accounts without re-running the install flow.
1762
- // Failures are non-fatal: this callback must still render the picker
1763
- // so the user isn't locked out right after a successful install.
2295
+ // binding is what every later step reads: the picker's account
2296
+ // dropdown, the endpoints that authorize an installation id, and the
2297
+ // App webhook's fan-out. Rendering the picker without it would show an
2298
+ // empty account list and nothing to pick.
2299
+ //
2300
+ // So a failure here has to stay recoverable, and the only thing that
2301
+ // makes this page replayable is the state cookie — GitHub will not
2302
+ // hand out a second callback for an App that is now installed. Hence
2303
+ // the clearing below sits *after* the binding is written: until then,
2304
+ // reloading this URL is the whole retry.
1764
2305
  try {
1765
2306
  const installation = await getInstallation(app, installationId);
1766
2307
  await c.var.services.githubAppInstallations.upsertInstallation(
@@ -1769,14 +2310,24 @@ settingsRoutes.get("/github-sync/app/callback", async (c) => {
1769
2310
  installation.account,
1770
2311
  );
1771
2312
  } catch {
1772
- // Swallow — the legacy inline picker still works with just the
1773
- // installation_id from the URL, just without the owner list.
2313
+ return c.text(
2314
+ "The App was installed, but GitHub did not answer with the account details. Reload this page to try again.",
2315
+ 502,
2316
+ );
1774
2317
  }
1775
2318
 
2319
+ // One-shot: clear the cookie so it can't be replayed.
2320
+ setCookie(c, "jant_gh_app_state", "", {
2321
+ httpOnly: true,
2322
+ sameSite: "Lax",
2323
+ path: "/",
2324
+ maxAge: 0,
2325
+ });
2326
+
1776
2327
  const navData = await getNavigationData(c);
1777
2328
  const base = publicPath(c, "/settings/github-sync");
1778
- const labels = buildRepoPickerLabels(c);
1779
- const suggestedRepoName = buildSuggestedRepoName(c);
2329
+ const suggestedRepoName = suggestSyncRepoName(c.var.appConfig.siteUrl);
2330
+ const labels = buildRepoPickerLabels(c, suggestedRepoName);
1780
2331
 
1781
2332
  return renderPublicPage(c, {
1782
2333
  title: buildPageTitle("GitHub Sync — Pick Repository", navData.siteName),
@@ -1792,7 +2343,7 @@ settingsRoutes.get("/github-sync/app/callback", async (c) => {
1792
2343
  labels={labels}
1793
2344
  api-base={`${base}/app`}
1794
2345
  connect-url={`${base}/app/connect`}
1795
- install-url={`${base}/app/install`}
2346
+ install-url={`${base}/app/install?force=new`}
1796
2347
  cancel-url={publicPath(c, "/settings")}
1797
2348
  create-repo-name-hint={suggestedRepoName}
1798
2349
  >
@@ -1810,29 +2361,36 @@ settingsRoutes.get("/github-sync/app/callback", async (c) => {
1810
2361
  });
1811
2362
 
1812
2363
  /**
1813
- * Derive a default repository name to prefill on github.com/new.
2364
+ * Values that keep a placeholder literal for the client to fill in.
1814
2365
  *
1815
- * Uses the site's host — the first DNS label is a stable, URL-safe
1816
- * identifier tied to this specific Jant instance. Fallback to
1817
- * "jant-site-sync" when the host parse fails so we never hand GitHub an
1818
- * empty `name=`. The `-jant-sync` suffix disambiguates the sync mirror
1819
- * from a user's own `{slug}-jant` source repo.
2366
+ * `i18n._()` renders a placeholder it has no value for as an empty string, so
2367
+ * a label whose value only exists in the browser — a repo count, the picked
2368
+ * repo — must be formatted with the placeholder's own token as its value.
2369
+ * `{repo}` then survives into the serialized labels and the component's
2370
+ * `.replace("{repo}", …)` finds it.
2371
+ *
2372
+ * @param names - Placeholder names the client interpolates.
2373
+ * @returns A values object mapping each name to its own `{name}` token.
2374
+ * @example
2375
+ * i18n._(msg({ message: "Type {repo} to confirm" }), keepForClient("repo"));
2376
+ * // "Type {repo} to confirm"
1820
2377
  */
1821
- function buildSuggestedRepoName(c: Context<Env>): string {
1822
- let firstLabel = "";
1823
- try {
1824
- firstLabel = new URL(c.var.appConfig.siteUrl).host.split(".")[0] ?? "";
1825
- } catch {
1826
- /* fall through */
1827
- }
1828
- const slug = firstLabel
1829
- .toLowerCase()
1830
- .replace(/[^a-z0-9-_]+/g, "-")
1831
- .replace(/^-+|-+$/g, "");
1832
- return slug ? `${slug}-jant-sync` : "jant-site-sync";
2378
+ function keepForClient(...names: string[]): Record<string, string> {
2379
+ return Object.fromEntries(names.map((name) => [name, `{${name}}`]));
1833
2380
  }
1834
2381
 
1835
- function buildRepoPickerLabels(c: Context<Env>): string {
2382
+ /**
2383
+ * Serialize the picker's strings for the client component.
2384
+ *
2385
+ * @param c - Request context, for the active locale.
2386
+ * @param suggestedRepoName - Repository name prefilled on github.com/new;
2387
+ * interpolated here because the client cannot format ICU messages.
2388
+ * @returns The labels as a JSON string for the `labels` attribute.
2389
+ */
2390
+ function buildRepoPickerLabels(
2391
+ c: Context<Env>,
2392
+ suggestedRepoName: string,
2393
+ ): string {
1836
2394
  const i18n = getI18n(c);
1837
2395
  return JSON.stringify({
1838
2396
  pageTitle: i18n._(
@@ -1919,6 +2477,7 @@ function buildRepoPickerLabels(c: Context<Env>): string {
1919
2477
  comment:
1920
2478
  "@context: GitHub sync picker — paginated repo count hint. Placeholders {shown} and {total} are integers.",
1921
2479
  }),
2480
+ keepForClient("shown", "total"),
1922
2481
  ),
1923
2482
  repoSearchHint: i18n._(
1924
2483
  msg({
@@ -1943,10 +2502,12 @@ function buildRepoPickerLabels(c: Context<Env>): string {
1943
2502
  ),
1944
2503
  createOnGitHubHint: i18n._(
1945
2504
  msg({
1946
- message: "We'll prefill the name {name}. The list refreshes on return.",
2505
+ message:
2506
+ "Name prefilled as {name}. The list refreshes when you return.",
1947
2507
  comment:
1948
2508
  "@context: GitHub sync picker — hint under the create-on-github entry. Placeholder {name} is the suggested repo name.",
1949
2509
  }),
2510
+ { name: suggestedRepoName },
1950
2511
  ),
1951
2512
  classifyLoading: i18n._(
1952
2513
  msg({
@@ -1976,6 +2537,7 @@ function buildRepoPickerLabels(c: Context<Env>): string {
1976
2537
  comment:
1977
2538
  "@context: GitHub sync picker — blocking message when marker belongs to another site. Placeholder {host} is the other site's host.",
1978
2539
  }),
2540
+ keepForClient("host"),
1979
2541
  ),
1980
2542
  classificationForeign: i18n._(
1981
2543
  msg({
@@ -1998,6 +2560,7 @@ function buildRepoPickerLabels(c: Context<Env>): string {
1998
2560
  comment:
1999
2561
  "@context: GitHub sync picker — explanatory body for foreign-repo confirmation. Placeholder {repo} is the owner/repo slug.",
2000
2562
  }),
2563
+ keepForClient("repo"),
2001
2564
  ),
2002
2565
  confirmInputLabel: i18n._(
2003
2566
  msg({
@@ -2005,6 +2568,7 @@ function buildRepoPickerLabels(c: Context<Env>): string {
2005
2568
  comment:
2006
2569
  "@context: GitHub sync picker — label above the typed-confirmation input",
2007
2570
  }),
2571
+ keepForClient("repo"),
2008
2572
  ),
2009
2573
  confirmInputPlaceholder: i18n._(
2010
2574
  msg({
@@ -2083,6 +2647,16 @@ settingsRoutes.post("/github-sync/app/connect", async (c) => {
2083
2647
  : c.text("Missing installationId or repo.", 400);
2084
2648
  }
2085
2649
 
2650
+ // The installation has to be one the author already authorized, here
2651
+ // or on another of their sites. The account snapshot also spares us a
2652
+ // GitHub round-trip when recording the binding further down.
2653
+ const installation = await findVisibleInstallation(c, installationId);
2654
+ if (!installation) {
2655
+ return wantsJson
2656
+ ? c.json({ error: "Unknown installation." }, 404)
2657
+ : c.text("Unknown installation.", 404);
2658
+ }
2659
+
2086
2660
  const { parseRepoSlug, createGitHubClient } =
2087
2661
  await import("../../lib/github-api.js");
2088
2662
  const parsed = parseRepoSlug(repo);
@@ -2143,6 +2717,18 @@ settingsRoutes.post("/github-sync/app/connect", async (c) => {
2143
2717
  : c.text(msg, 409);
2144
2718
  }
2145
2719
 
2720
+ // Bind the installation to this site. The callback does this too, but
2721
+ // only for the site that ran the install; connecting a second site to
2722
+ // an installation the author already has is exactly the case that
2723
+ // never passes through there. Without the row, the App-level webhook's
2724
+ // fan-out (uninstall, suspend, repository removed) skips this site
2725
+ // while it happily keeps syncing.
2726
+ await c.var.services.githubAppInstallations.upsertInstallation(
2727
+ installationId,
2728
+ c.var.currentSite.id,
2729
+ installation.account,
2730
+ );
2731
+
2146
2732
  // Persist config before creating webhook so the sync service can load it.
2147
2733
  await c.var.services.settings.set("GITHUB_SYNC_AUTH_MODE", "app");
2148
2734
  await c.var.services.settings.set(
@@ -2227,21 +2813,38 @@ async function getInstallationTokenFromApp(
2227
2813
  return getInstallationToken(app, installationId);
2228
2814
  }
2229
2815
 
2230
- /** List GitHub App installations authorized for this site. */
2816
+ /**
2817
+ * List the GitHub App installations the signed-in author may connect
2818
+ * this site to: everything authorized on any site they belong to, this
2819
+ * one included.
2820
+ */
2231
2821
  settingsRoutes.get("/github-sync/app/installations", async (c) => {
2232
2822
  const installations =
2233
- await c.var.services.githubAppInstallations.listInstallationsForSite(
2234
- c.var.currentSite.id,
2823
+ await c.var.services.githubAppInstallations.listInstallationsForUser(
2824
+ requireSessionUserId(c),
2235
2825
  );
2236
- return c.json({
2237
- installations: installations.map((entry) => ({
2238
- installationId: entry.installationId,
2239
- account: entry.account,
2240
- addedAt: entry.addedAt,
2241
- })),
2242
- });
2826
+ return c.json({ installations });
2243
2827
  });
2244
2828
 
2829
+ /**
2830
+ * Resolve an `installationId` that arrived in a request into an
2831
+ * installation the caller is allowed to act on.
2832
+ *
2833
+ * Every endpoint below takes the id from the client, and an installation
2834
+ * id is a small integer — without this check a signed-in author on one
2835
+ * site could name another tenant's installation and read their private
2836
+ * repository names, or push this site's content into one of their repos.
2837
+ */
2838
+ async function findVisibleInstallation(
2839
+ c: Context<Env>,
2840
+ installationId: string,
2841
+ ) {
2842
+ return c.var.services.githubAppInstallations.findInstallationForUser(
2843
+ installationId,
2844
+ requireSessionUserId(c),
2845
+ );
2846
+ }
2847
+
2245
2848
  /**
2246
2849
  * List (or search) repositories accessible via an installation.
2247
2850
  *
@@ -2266,18 +2869,13 @@ settingsRoutes.get("/github-sync/app/repos", async (c) => {
2266
2869
  const page =
2267
2870
  Number.isFinite(pageParam) && pageParam > 0 ? Math.floor(pageParam) : 1;
2268
2871
 
2872
+ const installation = await findVisibleInstallation(c, installationId);
2873
+ if (!installation) {
2874
+ return c.json({ error: "Unknown installation." }, 404);
2875
+ }
2876
+
2269
2877
  try {
2270
2878
  if (q) {
2271
- const installations =
2272
- await c.var.services.githubAppInstallations.listInstallationsForSite(
2273
- c.var.currentSite.id,
2274
- );
2275
- const installation = installations.find(
2276
- (i) => i.installationId === installationId,
2277
- );
2278
- if (!installation) {
2279
- return c.json({ error: "Unknown installation." }, 404);
2280
- }
2281
2879
  const result = await searchInstallationRepos(
2282
2880
  app,
2283
2881
  installationId,
@@ -2304,11 +2902,13 @@ settingsRoutes.get("/github-sync/app/repos", async (c) => {
2304
2902
  } catch (err) {
2305
2903
  const detail = err instanceof Error ? err.message : String(err);
2306
2904
  // GitHub returns 401 when an installation was uninstalled on their
2307
- // side. Clean up our cached entry so the UI stops showing a dead owner.
2905
+ // side. Clean up our cached entries so the UI stops showing a dead
2906
+ // owner — across the user's sites, since the binding that surfaced
2907
+ // this account may well belong to another one of them.
2308
2908
  if (/\b401\b/.test(detail) || /\b404\b/.test(detail)) {
2309
- await c.var.services.githubAppInstallations.removeInstallation(
2909
+ await c.var.services.githubAppInstallations.removeInstallationForUser(
2310
2910
  installationId,
2311
- c.var.currentSite.id,
2911
+ requireSessionUserId(c),
2312
2912
  );
2313
2913
  return c.json(
2314
2914
  { error: "Installation is no longer accessible.", removed: true },
@@ -2338,6 +2938,9 @@ settingsRoutes.post("/github-sync/app/classify", async (c) => {
2338
2938
  if (!installationId || !repo) {
2339
2939
  return c.json({ error: "Missing installationId or repo." }, 400);
2340
2940
  }
2941
+ if (!(await findVisibleInstallation(c, installationId))) {
2942
+ return c.json({ error: "Unknown installation." }, 404);
2943
+ }
2341
2944
 
2342
2945
  const { parseRepoSlug, createGitHubClient } =
2343
2946
  await import("../../lib/github-api.js");