@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
@@ -1,183 +1,836 @@
1
1
  /**
2
2
  * Setup Routes
3
3
  *
4
- * Initial admin account creation during first-time setup.
4
+ * First-run setup, for both ways a Jant site comes into existence.
5
+ *
6
+ * A self-hosted site starts empty and is set up in two screens: the account
7
+ * first, then the site. A hosted site is created by a control plane, which
8
+ * already knows the name and the owner but can only guess at the one thing
9
+ * that shows up in public HTML, feeds and font stacks — the language its
10
+ * author writes in — so it arrives at the second screen and stops there.
11
+ *
12
+ * The split is what makes that true. Between the screens the site stands
13
+ * provisioned with an owner and no answers, which is exactly the state a
14
+ * control plane leaves a hosted site in, so the second screen is one code path
15
+ * asking one site's worth of questions rather than two forms drifting apart.
16
+ * Which step a request is on is never in the URL: it is read from the
17
+ * onboarding status and the session, the only record that survives a closed
18
+ * tab. A `provisioned` site also serves `/signin` normally, so an author who
19
+ * loses the session between the screens signs back in and lands on the second.
20
+ * The first screen is resumable in its own way, for the run that failed partway
21
+ * through it — see `openOwnerSession`, which is the only way out of that one.
22
+ *
23
+ * The cost of standing the site up early is that the public root answers for
24
+ * the width of one screen under the fallback name. On a first run there is
25
+ * nothing there to read, and the alternative was a second "does an owner
26
+ * exist" query wired into the onboarding middleware.
27
+ *
28
+ * Asking little is not the same as saying nothing. A hosted author reaches
29
+ * this page by following a link out of a control plane and lands on a domain
30
+ * they have never seen serve anything, so the screen names the step and the
31
+ * site it belongs to. A self-hosted author has no site name to be told, and
32
+ * gets the count of what is left instead. That is the whole of it: one muted
33
+ * line above the question, no mark and no status summary, because chrome is
34
+ * what made a one-field form look like a gate in the first place. The counter
35
+ * is text and not a stepper on purpose — once the first screen is submitted
36
+ * the account exists and there is no going back, and anything that looked like
37
+ * navigation would say otherwise.
5
38
  */
6
39
 
7
40
  import { Hono } from "hono";
8
- import type { FC } from "hono/jsx";
41
+ import type { Context } from "hono";
42
+ import type { Child, FC, PropsWithChildren } from "hono/jsx";
9
43
  import { msg } from "@lingui/core/macro";
10
44
  import { useLingui } from "../../i18n/context.js";
11
45
  import type { Bindings } from "../../types.js";
12
46
  import type { AppVariables } from "../../types/app-context.js";
13
47
  import { BaseLayout } from "../../ui/layouts/BaseLayout.js";
14
48
  import { dsRedirect, dsToast } from "../../lib/sse.js";
15
- import { SetupSchema } from "../../lib/schemas.js";
49
+ import type { ZodError } from "zod";
50
+ import {
51
+ deriveAccountName,
52
+ SetupAccountSchema,
53
+ SetupLanguageSchema,
54
+ SetupSiteSchema,
55
+ type SetupLanguageAnswers,
56
+ } from "../../lib/schemas.js";
16
57
  import { buildPageTitle } from "../../lib/page-title.js";
17
58
  import { mapIanaToTimezone } from "../../lib/timezones.js";
18
59
  import { getI18n } from "../../i18n/index.js";
60
+ import { isCurrentSiteMember } from "../../middleware/auth.js";
61
+ import type { I18n } from "../../i18n/i18n.js";
19
62
  import {
20
- detectCjkFontFromHeader,
21
- detectLocaleFromHeader,
22
- } from "../../i18n/detect.js";
63
+ getSupportedLocaleEntries,
64
+ resolveSupportedLocaleTag,
65
+ } from "../../i18n/supported-locales.js";
23
66
  import { toPublicPath } from "../../lib/url.js";
67
+ import { ONBOARDING_STATUS } from "../../lib/constants.js";
68
+ import { announceInBackground } from "../discover-announce.js";
69
+ import {
70
+ discoverIntroRuns,
71
+ getDiscoverPageUrls,
72
+ resolveDiscoverMode,
73
+ type DiscoverPageUrls,
74
+ } from "../../lib/discover.js";
75
+ import {
76
+ getDiscoverDefault,
77
+ getDiscoverDirectoryBaseUrl,
78
+ } from "../../lib/env.js";
79
+ import {
80
+ getDiscoverCopy,
81
+ type DiscoverCopy,
82
+ } from "../../ui/dash/settings/discover-copy.js";
24
83
 
25
84
  type Env = { Bindings: Bindings; Variables: AppVariables };
26
85
 
27
- const SetupContent: FC<{ sitePathPrefix?: string }> = ({
28
- sitePathPrefix = "",
29
- }) => {
86
+ /**
87
+ * The language field, as a plain `<select>` plus the picker that replaces it.
88
+ *
89
+ * The select is the form's real control and stays so; the picker takes over on
90
+ * upgrade and writes back to it. Stylesheet rule `jant-locale-picker:not(:defined)`
91
+ * keeps the picker out of the layout until then, so only one control is ever
92
+ * visible.
93
+ */
94
+ const LocaleField: FC<{
95
+ id: string;
96
+ labelId: string;
97
+ contentLanguage: string;
98
+ searchLabel: string;
99
+ emptyLabel: string;
100
+ }> = ({ id, labelId, contentLanguage, searchLabel, emptyLabel }) => {
101
+ const entries = getSupportedLocaleEntries();
102
+ const locales = JSON.stringify(
103
+ entries.map((entry) => ({
104
+ tag: entry.tag,
105
+ native: entry.native,
106
+ english: entry.english,
107
+ coverage: entry.coverage,
108
+ })),
109
+ ).replace(/</g, "\\u003c");
110
+ const labels = JSON.stringify({
111
+ search: searchLabel,
112
+ empty: emptyLabel,
113
+ }).replace(/</g, "\\u003c");
114
+
115
+ return (
116
+ <>
117
+ {/* The real form field, and the whole control until the picker upgrades.
118
+ Datastar binds to it, so the picker writing here is enough — it never
119
+ needs to know a signal exists. */}
120
+ <select id={id} data-bind="contentLanguage" class="select">
121
+ {entries.map((entry) => (
122
+ <option
123
+ key={entry.tag}
124
+ value={entry.tag}
125
+ selected={entry.tag === contentLanguage}
126
+ >
127
+ {entry.native === entry.english
128
+ ? entry.native
129
+ : `${entry.native} (${entry.english})`}
130
+ </option>
131
+ ))}
132
+ </select>
133
+ <jant-locale-picker
134
+ for={id}
135
+ labelledby={labelId}
136
+ value={contentLanguage}
137
+ locales={locales}
138
+ labels={labels}
139
+ full-width
140
+ />
141
+ </>
142
+ );
143
+ };
144
+
145
+ /**
146
+ * The one question setup asks about the world outside this site.
147
+ *
148
+ * It earns a place on a screen that asks as little as it can because the
149
+ * setting behind it is otherwise three clicks into Settings, and an author who
150
+ * never goes looking never learns a directory exists. The wording is the
151
+ * settings page's own: one control appearing twice should not describe itself
152
+ * two ways.
153
+ *
154
+ * The words come from the same `getDiscoverCopy` the settings page uses,
155
+ * which is what keeps the two surfaces from drifting when either is edited.
156
+ * The label is the settings page's; the help line is the settings page's
157
+ * opening sentence and nothing after it. The delay, the lists and the rules
158
+ * matter to an author with posts to show, and this screen comes before the
159
+ * first one — the directory's name links to the directory for anyone who wants
160
+ * more. Where to find the setting again is left to the screen's footnote, which
161
+ * says it once for every answer on it.
162
+ */
163
+ const DiscoverField: FC<{
164
+ copy: DiscoverCopy;
165
+ /** The directory's pages, or null when this deployment announces to none. */
166
+ pages: DiscoverPageUrls | null;
167
+ }> = ({ copy, pages }) => {
168
+ const runs = discoverIntroRuns(copy.about, copy, pages);
169
+
170
+ return (
171
+ // Held a little further from the field above it than the form's own gap:
172
+ // every other field on this screen is about the site itself, and this one
173
+ // is about the world outside it.
174
+ <div class="field mt-2">
175
+ {/* Classes copied from the settings page's own Discover checkbox
176
+ (`jant-settings-general.ts`), so the control a hosted author meets here
177
+ and the one they find later in Settings are the same object. */}
178
+ <label
179
+ class="flex items-center gap-2 cursor-pointer"
180
+ for="setup-discover"
181
+ >
182
+ <input
183
+ id="setup-discover"
184
+ type="checkbox"
185
+ data-bind="discover"
186
+ class="checkbox"
187
+ />
188
+ <span>{copy.label}</span>
189
+ </label>
190
+ <p class="text-sm text-muted-foreground mt-1">
191
+ {runs.map((run) =>
192
+ run.href ? (
193
+ <a
194
+ href={run.href}
195
+ target="_blank"
196
+ rel="noopener noreferrer"
197
+ class="underline hover:text-foreground transition-colors"
198
+ >
199
+ {run.text}
200
+ </a>
201
+ ) : (
202
+ run.text
203
+ ),
204
+ )}
205
+ </p>
206
+ </div>
207
+ );
208
+ };
209
+
210
+ /**
211
+ * What this screen is called, in the tab title and above the card alike, so
212
+ * the two never drift into naming the same page differently.
213
+ */
214
+ function setupLabel(i18n: I18n): string {
215
+ return i18n._(
216
+ msg({
217
+ message: "Setup",
218
+ comment:
219
+ "@context: Name of the first-run setup screen — shown above the setup card and as the browser tab title",
220
+ }),
221
+ );
222
+ }
223
+
224
+ /** How many screens a self-hosted setup has, and which one this is. */
225
+ const SETUP_STEPS = 2;
226
+
227
+ /**
228
+ * The frame every first-run screen shares.
229
+ *
230
+ * One muted line carries everything these screens were missing: which step
231
+ * this is, and which site it belongs to. A mark, an address, and a summary of
232
+ * what the control plane already answered were all tried above it and all read
233
+ * as chrome stacked around a form with one field in it.
234
+ *
235
+ * The two facts occupy the same line because no screen ever has both: a
236
+ * self-hosted install is counting steps and has no name yet, and a hosted one
237
+ * has a name and only ever sees a single screen. They are joined rather than
238
+ * chosen between anyway, so a future screen that has both says both.
239
+ */
240
+ const SetupShell: FC<
241
+ PropsWithChildren<{
242
+ /** Site being set up. Blank while it has no name yet. */
243
+ siteName?: string;
244
+ /** Which of the self-hosted screens this is. Absent on a hosted site. */
245
+ step?: number;
246
+ heading: string;
247
+ /** What this screen is for, under the heading. Omitted where the heading says it. */
248
+ description?: string;
249
+ /**
250
+ * A note that belongs to the screen but not to the question — set in the
251
+ * card's footer, after the button, where it is read once the answers are
252
+ * given rather than before.
253
+ */
254
+ footnote?: string;
255
+ }>
256
+ > = ({ siteName, step, heading, description, footnote, children }) => {
30
257
  const { i18n } = useLingui();
258
+ const name = siteName?.trim() ?? "";
259
+ const parts = [setupLabel(i18n)];
260
+ if (step) {
261
+ parts.push(
262
+ i18n._(
263
+ msg({
264
+ message: "Step {current} of {total}",
265
+ comment:
266
+ "@context: How far along first-run setup is, shown beside the screen's name on a self-hosted install",
267
+ }),
268
+ { current: step, total: SETUP_STEPS },
269
+ ),
270
+ );
271
+ }
272
+ if (name) parts.push(name);
31
273
 
32
274
  return (
33
- <div class="min-h-screen flex items-center justify-center">
275
+ <div class="min-h-screen flex items-center justify-center p-4">
34
276
  <div class="card max-w-md w-full">
35
277
  <header>
36
- <h2>
278
+ <p class="mb-2 text-sm text-muted-foreground">{parts.join(" · ")}</p>
279
+ <h2>{heading}</h2>
280
+ {description ? <p>{description}</p> : null}
281
+ </header>
282
+ <section>{children}</section>
283
+ {footnote ? (
284
+ <footer>
285
+ <p class="text-sm text-muted-foreground">{footnote}</p>
286
+ </footer>
287
+ ) : null}
288
+ </div>
289
+ </div>
290
+ );
291
+ };
292
+
293
+ /**
294
+ * The first self-hosted screen: the account, and nothing about the site.
295
+ *
296
+ * Credentials alone is not only less to read: an email and a password with
297
+ * nothing between them is the shape a password manager is looking for, which
298
+ * a form that opens with the site's name and language is not.
299
+ */
300
+ const AccountStep: FC<{ action: string; spinner: Child }> = ({
301
+ action,
302
+ spinner,
303
+ }) => {
304
+ const { i18n } = useLingui();
305
+
306
+ return (
307
+ <SetupShell
308
+ step={1}
309
+ heading={i18n._(
310
+ msg({
311
+ message: "Welcome to Jant",
312
+ comment: "@context: Setup page welcome heading",
313
+ }),
314
+ )}
315
+ description={i18n._(
316
+ msg({
317
+ message: "Create the account you write from.",
318
+ comment:
319
+ "@context: Setup page description on the first screen, which asks only for credentials",
320
+ }),
321
+ )}
322
+ >
323
+ <form
324
+ data-signals="{email: '', password: ''}"
325
+ data-on:submit__prevent={action}
326
+ data-indicator="_loading"
327
+ class="flex flex-col gap-4"
328
+ >
329
+ <div class="field">
330
+ <label class="label" for="setup-email">
37
331
  {i18n._(
38
332
  msg({
39
- message: "Welcome to Jant",
40
- comment: "@context: Setup page welcome heading",
333
+ message: "Email",
334
+ comment: "@context: Setup/signin form field - email",
41
335
  }),
42
336
  )}
43
- </h2>
44
- <p>
337
+ </label>
338
+ <input
339
+ id="setup-email"
340
+ type="email"
341
+ data-bind="email"
342
+ class="input"
343
+ required
344
+ autocomplete="username"
345
+ placeholder="you@example.com"
346
+ />
347
+ </div>
348
+ <div class="field">
349
+ <label class="label" for="setup-password">
45
350
  {i18n._(
46
351
  msg({
47
- message: "Create your admin account.",
48
- comment: "@context: Setup page description",
352
+ message: "Password",
353
+ comment: "@context: Setup/signin form field - password",
49
354
  }),
50
355
  )}
51
- </p>
52
- </header>
53
- <section>
54
- <form
55
- data-signals="{siteName: '', email: '', password: '', timezone: '', language: ''}"
56
- data-init="$timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; $language = navigator.language || ''"
57
- data-on:submit__prevent={`@post('${toPublicPath("/setup", sitePathPrefix)}')`}
58
- data-indicator="_loading"
59
- class="flex flex-col gap-4"
60
- >
61
- <div class="field">
62
- <label class="label">
63
- {i18n._(
64
- msg({
65
- message: "Site Name",
66
- comment: "@context: Setup form field - site name",
67
- }),
68
- )}
69
- </label>
70
- <input
71
- type="text"
72
- data-bind="siteName"
73
- class="input"
74
- required
75
- placeholder="My Blog"
76
- />
77
- </div>
78
- <div class="field">
79
- <label class="label">
80
- {i18n._(
81
- msg({
82
- message: "Email",
83
- comment: "@context: Setup/signin form field - email",
84
- }),
85
- )}
86
- </label>
87
- <input
88
- type="email"
89
- data-bind="email"
90
- class="input"
91
- required
92
- placeholder="you@example.com"
93
- />
94
- </div>
95
- <div class="field">
96
- <label class="label">
97
- {i18n._(
98
- msg({
99
- message: "Password",
100
- comment: "@context: Setup/signin form field - password",
101
- }),
102
- )}
103
- </label>
104
- <input
105
- type="password"
106
- data-bind="password"
107
- class="input"
108
- required
109
- minLength={8}
110
- />
111
- </div>
112
- <button type="submit" class="btn" data-attr:disabled="$_loading">
113
- <svg
114
- data-show="$_loading"
115
- style="display:none"
116
- class="animate-spin size-4"
117
- xmlns="http://www.w3.org/2000/svg"
118
- viewBox="0 0 24 24"
119
- fill="none"
120
- stroke="currentColor"
121
- stroke-width="2"
122
- stroke-linecap="round"
123
- stroke-linejoin="round"
124
- role="status"
125
- >
126
- <path d="M21 12a9 9 0 1 1-6.219-8.56" />
127
- </svg>
356
+ </label>
357
+ <input
358
+ id="setup-password"
359
+ type="password"
360
+ data-bind="password"
361
+ class="input"
362
+ required
363
+ autocomplete="new-password"
364
+ minLength={8}
365
+ />
366
+ </div>
367
+ {/* Not "Complete Setup": there is another screen after this one, and a
368
+ button that claims otherwise is the reason the next one reads as a
369
+ failure rather than a step. */}
370
+ <button type="submit" class="btn" data-attr:disabled="$_loading">
371
+ {spinner}
372
+ {i18n._(
373
+ msg({
374
+ message: "Continue",
375
+ comment:
376
+ "@context: Setup submit button on the first screen, which is followed by the site screen",
377
+ }),
378
+ )}
379
+ </button>
380
+ </form>
381
+ </SetupShell>
382
+ );
383
+ };
384
+
385
+ export type SetupContentProps = {
386
+ sitePathPrefix?: string;
387
+ } & (
388
+ | {
389
+ /** The account screen, which a hosted site never sees. */
390
+ mode: "account";
391
+ }
392
+ | {
393
+ /**
394
+ * The site screen: the last question either install kind has left.
395
+ */
396
+ mode: "site";
397
+ /**
398
+ * Whether the site still needs a name — true on a self-hosted install,
399
+ * false on a hosted one the control plane already named.
400
+ *
401
+ * Doubles as which flow this is, and so as whether the step counter
402
+ * shows: only a two-screen setup has a step to count.
403
+ */
404
+ askSiteName: boolean;
405
+ contentLanguage: string;
406
+ /** Shown beside the step name once there is a name, so the site is identified. */
407
+ siteName?: string;
408
+ /**
409
+ * Whether to ask the Discover question at all.
410
+ *
411
+ * False where the answer could not be honoured — a demo site, or feeds
412
+ * switched off — and a control nobody can act on is worse than none.
413
+ */
414
+ discoverAvailable: boolean;
415
+ /**
416
+ * The state the Discover box starts in, derived from the deployment
417
+ * rather than hardcoded per install kind. Hosted Jant sets
418
+ * `DISCOVER=latest`, so a hosted author finds it ticked; a self-hosted
419
+ * install with nothing configured finds it clear.
420
+ */
421
+ discoverDefault: boolean;
422
+ /**
423
+ * The directory's pages, for the links in the help line. Null when this
424
+ * deployment announces to no directory, and the line is then plain text.
425
+ */
426
+ discoverPages: DiscoverPageUrls | null;
427
+ }
428
+ );
429
+
430
+ export const SetupContent: FC<SetupContentProps> = (props) => {
431
+ const { i18n } = useLingui();
432
+ const action = `@post('${toPublicPath("/setup", props.sitePathPrefix ?? "")}')`;
433
+ const spinner = (
434
+ <svg
435
+ data-show="$_loading"
436
+ style="display:none"
437
+ class="animate-spin size-4"
438
+ xmlns="http://www.w3.org/2000/svg"
439
+ viewBox="0 0 24 24"
440
+ fill="none"
441
+ stroke="currentColor"
442
+ stroke-width="2"
443
+ stroke-linecap="round"
444
+ stroke-linejoin="round"
445
+ role="status"
446
+ >
447
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
448
+ </svg>
449
+ );
450
+
451
+ if (props.mode === "account") {
452
+ return <AccountStep action={action} spinner={spinner} />;
453
+ }
454
+
455
+ const {
456
+ askSiteName,
457
+ contentLanguage,
458
+ siteName,
459
+ discoverAvailable,
460
+ discoverDefault,
461
+ discoverPages,
462
+ } = props;
463
+
464
+ const searchLabel = i18n._(
465
+ msg({
466
+ message: "Search…",
467
+ comment: "@context: Placeholder in the language picker search box",
468
+ }),
469
+ );
470
+ const emptyLabel = i18n._(
471
+ msg({
472
+ message: "No matches.",
473
+ comment: "@context: Empty state in the language picker",
474
+ }),
475
+ );
476
+
477
+ // Rendered even when the question is not asked, so the form can name the
478
+ // signal unconditionally; `discoverAvailable` decides whether the control
479
+ // appears, and an absent field simply sends the default back.
480
+ const discoverField = discoverAvailable ? (
481
+ <DiscoverField copy={getDiscoverCopy(i18n)} pages={discoverPages} />
482
+ ) : null;
483
+
484
+ const signals = [
485
+ askSiteName ? "siteName: ''" : null,
486
+ `contentLanguage: ${JSON.stringify(contentLanguage)}`,
487
+ "language: ''",
488
+ // Only the install that is still choosing its own clock reports one. A
489
+ // hosted site's time zone came from the control plane, and this screen can
490
+ // be opened from anywhere.
491
+ askSiteName ? "timezone: ''" : null,
492
+ `discover: ${discoverAvailable && discoverDefault}`,
493
+ ]
494
+ .filter(Boolean)
495
+ .join(", ");
496
+ const init = [
497
+ "$language = navigator.language || ''",
498
+ askSiteName
499
+ ? "$timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || ''"
500
+ : null,
501
+ ]
502
+ .filter(Boolean)
503
+ .join("; ");
504
+
505
+ return (
506
+ <SetupShell
507
+ siteName={siteName}
508
+ step={askSiteName ? 2 : undefined}
509
+ heading={
510
+ askSiteName
511
+ ? i18n._(
512
+ msg({
513
+ message: "Set up your site",
514
+ comment:
515
+ "@context: Setup heading on the second self-hosted screen, which asks for the site's name and language",
516
+ }),
517
+ )
518
+ : i18n._(
519
+ msg({
520
+ message: "What language do you write in?",
521
+ comment:
522
+ "@context: Setup heading on a hosted site, where the language is all that is left to ask",
523
+ }),
524
+ )
525
+ }
526
+ // Not under the heading. Said there, before the questions, it reads as
527
+ // permission to leave them blank; said after the button, it is what it
528
+ // is — a note that nothing here is final.
529
+ footnote={i18n._(
530
+ msg({
531
+ message: "You can change all of this later in Settings.",
532
+ comment:
533
+ "@context: Note at the foot of the last setup screen, after the submit button",
534
+ }),
535
+ )}
536
+ >
537
+ <form
538
+ data-signals={`{${signals}}`}
539
+ data-init={init}
540
+ data-on:submit__prevent={action}
541
+ data-indicator="_loading"
542
+ class="flex flex-col gap-4"
543
+ >
544
+ {askSiteName ? (
545
+ <div class="field">
546
+ <label class="label" for="setup-site-name">
128
547
  {i18n._(
129
548
  msg({
130
- message: "Complete Setup",
131
- comment: "@context: Setup form submit button",
549
+ message: "Site Name",
550
+ comment: "@context: Setup form field - site name",
132
551
  }),
133
552
  )}
134
- </button>
135
- </form>
136
- </section>
137
- </div>
138
- </div>
553
+ </label>
554
+ <input
555
+ id="setup-site-name"
556
+ type="text"
557
+ data-bind="siteName"
558
+ class="input"
559
+ required
560
+ placeholder="My Blog"
561
+ />
562
+ </div>
563
+ ) : null}
564
+ {/* Asked outright rather than inferred from the browser. The inference
565
+ is wrong exactly for the people it matters to — anyone whose browser
566
+ language is not their writing language — and it silently mis-sets
567
+ `<html lang>`, the feed language, and the CJK font stack. */}
568
+ <div class="field">
569
+ {askSiteName ? (
570
+ <label class="label" id="setup-language-label">
571
+ {i18n._(
572
+ msg({
573
+ message: "Content language",
574
+ comment: "@context: Setup form field - site content language",
575
+ }),
576
+ )}
577
+ </label>
578
+ ) : (
579
+ // The heading already asks the question, so a visible label would
580
+ // ask it twice.
581
+ <span id="setup-language-label" class="sr-only">
582
+ {i18n._(
583
+ msg({
584
+ message: "Content language",
585
+ comment: "@context: Setup form field - site content language",
586
+ }),
587
+ )}
588
+ </span>
589
+ )}
590
+ <LocaleField
591
+ id="setup-content-language"
592
+ labelId="setup-language-label"
593
+ contentLanguage={contentLanguage}
594
+ searchLabel={searchLabel}
595
+ emptyLabel={emptyLabel}
596
+ />
597
+ {askSiteName ? (
598
+ <p class="text-sm text-muted-foreground mt-1">
599
+ {i18n._(
600
+ msg({
601
+ message: "The language your readers and search engines see.",
602
+ comment:
603
+ "@context: Setup form help text under the content language field",
604
+ }),
605
+ )}
606
+ </p>
607
+ ) : null}
608
+ </div>
609
+ {discoverField}
610
+ <button type="submit" class="btn" data-attr:disabled="$_loading">
611
+ {spinner}
612
+ {i18n._(
613
+ msg({
614
+ message: "Start writing",
615
+ comment:
616
+ "@context: Setup submit button on the last screen, after which the site is live",
617
+ }),
618
+ )}
619
+ </button>
620
+ </form>
621
+ </SetupShell>
139
622
  );
140
623
  };
141
624
 
625
+ /**
626
+ * Record the Discover answer the setup form carried, and act on it.
627
+ *
628
+ * Stored either way, including a refusal: from here on the author's own answer
629
+ * outranks the deployment's `DISCOVER` default and the `noindex` reading, which
630
+ * is the whole point of asking. An absent field is not a refusal — an older
631
+ * client or a scripted setup sends none — and leaves the default in force.
632
+ *
633
+ * A yes is announced immediately, on the same terms as the settings page's own
634
+ * switch. A directory decides eligibility for itself and re-reads the feed on
635
+ * its own schedule, so a site with nothing published yet loses nothing by
636
+ * saying hello early — it is on the list of addresses to look at, and the
637
+ * looking happens when there is something to see. Waiting instead would mean
638
+ * the answer only takes effect if the author later finds a settings page they
639
+ * have no reason to open, which is not a design, it is a leak.
640
+ *
641
+ * The announcement is not awaited, and so cannot be reported here: a directory
642
+ * takes up to twelve seconds to give up on, and the last screen of setup is not
643
+ * a place to spend them. The outcome is recorded and logged, and the settings
644
+ * page's status block reads it back with a Retry beside it.
645
+ *
646
+ * @param c - The setup request, for its services and its config
647
+ * @param answer - The checkbox, or undefined when the form carried no field
648
+ */
649
+ async function storeDiscoverAnswer(
650
+ c: Context<Env>,
651
+ answer: boolean | undefined,
652
+ ): Promise<void> {
653
+ if (answer === undefined) return;
654
+ const stored = answer ? "latest" : "off";
655
+ const { shouldAnnounce } =
656
+ await c.var.services.settings.updateDiscoverSetting(stored, {
657
+ demoMode: c.var.appConfig.demoMode,
658
+ });
659
+ if (shouldAnnounce) {
660
+ // The value just written, not `allSettings`: that snapshot was taken before
661
+ // this request ran and still holds the deployment default.
662
+ announceInBackground(c, stored);
663
+ }
664
+ }
665
+
666
+ /**
667
+ * Whether this site still owes setup a name of its own.
668
+ *
669
+ * The discriminator between the two ways a site reaches the second screen, and
670
+ * it reads the stored fact rather than a flow flag: a control plane names the
671
+ * site it creates, a self-hosted install does not, and nothing else can put a
672
+ * name there before setup closes.
673
+ *
674
+ * @param c - The setup request, for its settings service
675
+ * @returns True when the second screen must ask for the site's name
676
+ */
677
+ async function needsSiteName(c: Context<Env>): Promise<boolean> {
678
+ const stored = await c.var.services.settings.get("SITE_NAME");
679
+ return !stored?.trim();
680
+ }
681
+
142
682
  export const setupRoutes = new Hono<Env>();
143
683
 
144
684
  setupRoutes.get("/setup", async (c) => {
145
- const isComplete = await c.var.services.settings.isOnboardingComplete();
146
- if (isComplete)
147
- return c.redirect(toPublicPath("/", c.var.appConfig.sitePathPrefix));
685
+ const status = await c.var.services.settings.getOnboardingStatus();
686
+ const home = toPublicPath("/", c.var.appConfig.sitePathPrefix);
687
+ if (status === ONBOARDING_STATUS.COMPLETED) return c.redirect(home);
688
+
689
+ const i18n = getI18n(c);
690
+ const { appConfig } = c.var;
691
+ const title = buildPageTitle(setupLabel(i18n), appConfig.siteName);
692
+
693
+ if (status === ONBOARDING_STATUS.PENDING) {
694
+ return c.html(
695
+ <BaseLayout title={title} c={c}>
696
+ <SetupContent
697
+ mode="account"
698
+ sitePathPrefix={appConfig.sitePathPrefix}
699
+ />
700
+ </BaseLayout>,
701
+ );
702
+ }
703
+
704
+ // The site now exists and is perfectly readable, so the remaining question
705
+ // belongs to its owner alone — a visitor who is not signed in to this site is
706
+ // sent to the site rather than shown a form they cannot submit.
707
+ if (!(await isCurrentSiteMember(c))) return c.redirect(home);
708
+
709
+ const askSiteName = await needsSiteName(c);
710
+
711
+ // Read through the same derivation the feed uses, so the box shows what this
712
+ // site would declare if the author changed nothing. `storedValue` is null by
713
+ // construction: setup runs before anyone has answered.
714
+ const discoverDefault =
715
+ resolveDiscoverMode({
716
+ storedValue: null,
717
+ defaultValue: getDiscoverDefault(c.env),
718
+ demoMode: appConfig.demoMode,
719
+ noindex: appConfig.noindex,
720
+ rssFeedsEnabled: appConfig.rssFeedsEnabled,
721
+ }) !== "none";
148
722
 
149
723
  return c.html(
150
- <BaseLayout title={buildPageTitle("Setup", c.var.appConfig.siteName)} c={c}>
151
- <SetupContent sitePathPrefix={c.var.appConfig.sitePathPrefix} />
724
+ <BaseLayout title={title} c={c}>
725
+ <SetupContent
726
+ sitePathPrefix={appConfig.sitePathPrefix}
727
+ mode="site"
728
+ askSiteName={askSiteName}
729
+ // Not asked where it could not be honoured. Both locks outlive setup —
730
+ // a demo site is never listed, and Discover reads an Atom feed — so a
731
+ // ticked box here would be a promise the next screen breaks.
732
+ discoverAvailable={!appConfig.demoMode && appConfig.rssFeedsEnabled}
733
+ discoverDefault={discoverDefault}
734
+ // The same directory the settings page links to, derived from the same
735
+ // configuration, so this screen can never point at one directory while
736
+ // the site announces to another.
737
+ discoverPages={getDiscoverPageUrls(getDiscoverDirectoryBaseUrl(c.env))}
738
+ contentLanguage={
739
+ // On a named site the control plane's guess is already stored, so
740
+ // offering it back is offering the site's current language. On an
741
+ // unnamed one nothing has been stored yet, so the browser's header is
742
+ // the only prefill there is.
743
+ askSiteName
744
+ ? resolveSupportedLocaleTag(c.req.header("Accept-Language"))
745
+ : appConfig.siteLanguage
746
+ }
747
+ // Only a named site has a name worth showing: until the second screen
748
+ // is submitted, `siteName` is still the built-in default.
749
+ siteName={askSiteName ? undefined : appConfig.siteName}
750
+ />
152
751
  </BaseLayout>,
153
752
  );
154
753
  });
155
754
 
156
- setupRoutes.post("/setup", async (c) => {
157
- const i18n = getI18n(c);
158
- const isComplete = await c.var.services.settings.isOnboardingComplete();
159
- if (isComplete)
160
- return c.redirect(toPublicPath("/", c.var.appConfig.sitePathPrefix));
755
+ /**
756
+ * Open the owner's account, or pick up the one an interrupted run already
757
+ * opened.
758
+ *
759
+ * The resume half exists because the first screen is otherwise a place a site
760
+ * can brick itself: if the account is created and standing the site up then
761
+ * fails, the site is left `pending` with a user row in it, and from there
762
+ * `createAuth`'s registration hook refuses every signup — any email, not just
763
+ * this one — while the onboarding middleware sends `/signin` and `/reset` back
764
+ * to this same screen. Nothing in a browser gets out of that.
765
+ *
766
+ * So a failed signup is followed by a sign-in with the credentials just typed.
767
+ * It is safe because of what `pending` means: registration is closed the moment
768
+ * any user exists, so the only row that can be here is the one the interrupted
769
+ * run left, and claiming it takes that row's password. What follows is
770
+ * idempotent, so resuming is simply running the rest again.
771
+ *
772
+ * @param c - The setup request, for its auth instance
773
+ * @param credentials - The address and password the screen collected
774
+ * @returns The owner and the headers that sign them in, or null when neither
775
+ * opening nor resuming an account worked
776
+ */
777
+ async function openOwnerSession(
778
+ c: Context<Env>,
779
+ credentials: { email: string; password: string },
780
+ ): Promise<{ ownerUserId: string; headers: Headers } | null> {
781
+ const auth = c.var.auth;
782
+ if (!auth) return null;
783
+ const { email, password } = credentials;
161
784
 
162
- const body = await c.req.json<Record<string, string>>();
163
- const parsed = SetupSchema.safeParse(body);
164
- const browserTimezone = body.timezone;
165
- const browserLanguage = body.language;
785
+ try {
786
+ const { headers, response } = await auth.api.signUpEmail({
787
+ returnHeaders: true,
788
+ // A stand-in until the next screen has a site name to put here. See
789
+ // `deriveAccountName`.
790
+ body: { name: deriveAccountName(email), email, password },
791
+ });
792
+ if (response?.user?.id) {
793
+ return { ownerUserId: response.user.id, headers };
794
+ }
795
+ } catch (err) {
796
+ // Not reported yet: the next attempt is the one that decides whether this
797
+ // is a failure or an interrupted run being picked up.
798
+ // eslint-disable-next-line no-console -- Error logging is intentional
799
+ console.error("Setup sign-up failed:", err);
800
+ }
166
801
 
167
- if (!parsed.success) {
168
- const errorMsg =
169
- parsed.error.issues[0]?.message ??
170
- i18n._(
171
- msg({
172
- message:
173
- "Something doesn't look right. Check the form and try again.",
174
- comment: "@context: Fallback validation error for setup form",
175
- }),
176
- );
177
- return dsToast(errorMsg, "error");
802
+ try {
803
+ const { headers, response } = await auth.api.signInEmail({
804
+ returnHeaders: true,
805
+ body: { email, password },
806
+ });
807
+ if (response?.user?.id) {
808
+ return { ownerUserId: response.user.id, headers };
809
+ }
810
+ } catch {
811
+ // Nothing to resume, or the wrong password for what is here. Either way
812
+ // the caller says the same thing: check the details and try again.
178
813
  }
179
814
 
180
- const { siteName, email, password } = parsed.data;
815
+ return null;
816
+ }
817
+
818
+ /**
819
+ * The first screen's submission: open the owner's account and stand the site
820
+ * up around it.
821
+ *
822
+ * Signs the new owner in on the way out rather than sending them to `/signin`
823
+ * to type the password they just chose. The screen after this one is theirs to
824
+ * answer, and it can only be reached with a session.
825
+ *
826
+ * @param c - The setup request
827
+ * @param body - The submitted form
828
+ */
829
+ async function createOwnerAccount(
830
+ c: Context<Env>,
831
+ body: Record<string, string>,
832
+ ): Promise<Response> {
833
+ const i18n = getI18n(c);
181
834
 
182
835
  if (!c.var.auth) {
183
836
  return dsToast(
@@ -192,71 +845,166 @@ setupRoutes.post("/setup", async (c) => {
192
845
  );
193
846
  }
194
847
 
195
- try {
196
- const signUpResponse = await c.var.auth.api.signUpEmail({
197
- body: { name: siteName.trim(), email, password },
198
- });
199
-
200
- if (!signUpResponse || "error" in signUpResponse) {
201
- return dsToast(
202
- i18n._(
203
- msg({
204
- message:
205
- "Couldn't create your account. Check the details and try again.",
206
- comment: "@context: Error toast when account creation fails",
207
- }),
208
- ),
209
- "error",
210
- );
211
- }
848
+ const parsed = SetupAccountSchema.safeParse(body);
849
+ if (!parsed.success) {
850
+ return dsToast(validationMessage(i18n, parsed.error), "error");
851
+ }
212
852
 
213
- const ownerUserId = signUpResponse.user?.id;
214
- if (!ownerUserId) {
215
- return dsToast(
216
- i18n._(
217
- msg({
218
- message:
219
- "Couldn't create your account. Check the details and try again.",
220
- comment: "@context: Error toast when account creation fails",
221
- }),
222
- ),
223
- "error",
224
- );
225
- }
853
+ const owner = await openOwnerSession(c, parsed.data);
854
+ if (!owner) {
855
+ return dsToast(accountCreationFailedMessage(i18n), "error");
856
+ }
226
857
 
227
- const timeZone = mapIanaToTimezone(browserTimezone ?? "");
228
- const cjkSerifFont =
229
- browserLanguage && browserLanguage.trim()
230
- ? detectCjkFontFromHeader(browserLanguage)
231
- : "off";
232
- const siteLanguage =
233
- browserLanguage && browserLanguage.trim()
234
- ? detectLocaleFromHeader(browserLanguage)
235
- : undefined;
236
-
237
- await c.var.services.bootstrap.completeInitialSetup({
238
- ownerUserId,
239
- siteName,
240
- timeZone,
241
- cjkSerifFont,
242
- siteLanguage,
858
+ try {
859
+ await c.var.services.bootstrap.provisionOwnerAccount({
860
+ ownerUserId: owner.ownerUserId,
243
861
  });
244
-
245
- return dsRedirect(
246
- toPublicPath("/signin?setup", c.var.appConfig.sitePathPrefix),
247
- );
248
862
  } catch (err) {
863
+ // The failure that used to be unrecoverable. The account survives it, so
864
+ // the next attempt resumes through `openOwnerSession` rather than trying to
865
+ // create it a second time.
249
866
  // eslint-disable-next-line no-console -- Error logging is intentional
250
867
  console.error("Setup error:", err);
251
- return dsToast(
252
- i18n._(
253
- msg({
254
- message:
255
- "Couldn't create your account. Check the details and try again.",
256
- comment: "@context: Error toast when account creation fails",
257
- }),
258
- ),
259
- "error",
868
+ return dsToast(accountCreationFailedMessage(i18n), "error");
869
+ }
870
+
871
+ // Back to the same URL: the status this just wrote is what decides which
872
+ // screen answers, so the second step needs no address of its own.
873
+ return dsRedirect(toPublicPath("/setup", c.var.appConfig.sitePathPrefix), {
874
+ headers: owner.headers,
875
+ });
876
+ }
877
+
878
+ /**
879
+ * The last screen's submission, for both install kinds: record what the author
880
+ * said about their site and let them in.
881
+ *
882
+ * The two schemas are parsed in separate branches rather than picked between,
883
+ * so a site name can only reach the service when the schema that requires one
884
+ * is the schema that ran.
885
+ *
886
+ * @param c - The setup request
887
+ * @param body - The submitted form
888
+ */
889
+ async function completeSiteSetup(
890
+ c: Context<Env>,
891
+ body: Record<string, string>,
892
+ ): Promise<Response> {
893
+ const i18n = getI18n(c);
894
+
895
+ // Membership is the check that makes "the owner is answering" true: a session
896
+ // alone can belong to someone this site has never heard of, and this branch
897
+ // writes the site's own name and language.
898
+ if (!(await isCurrentSiteMember(c))) {
899
+ return dsRedirect(
900
+ toPublicPath("/signin?redirect=/setup", c.var.appConfig.sitePathPrefix),
260
901
  );
261
902
  }
903
+
904
+ if (await needsSiteName(c)) {
905
+ const parsed = SetupSiteSchema.safeParse(body);
906
+ if (!parsed.success) {
907
+ return dsToast(validationMessage(i18n, parsed.error), "error");
908
+ }
909
+ return storeSiteAnswers(c, body, parsed.data, parsed.data.siteName);
910
+ }
911
+
912
+ const parsed = SetupLanguageSchema.safeParse(body);
913
+ if (!parsed.success) {
914
+ return dsToast(validationMessage(i18n, parsed.error), "error");
915
+ }
916
+ return storeSiteAnswers(c, body, parsed.data, undefined);
917
+ }
918
+
919
+ /**
920
+ * Write the last screen's answers and send the author into their site.
921
+ *
922
+ * @param c - The setup request
923
+ * @param body - The submitted form, for the two values the browser reports
924
+ * rather than the author choosing: its language and its time zone
925
+ * @param answers - The validated answers both install kinds share
926
+ * @param siteName - The name, on an install that was asked for one
927
+ */
928
+ async function storeSiteAnswers(
929
+ c: Context<Env>,
930
+ body: Record<string, string>,
931
+ answers: SetupLanguageAnswers,
932
+ siteName: string | undefined,
933
+ ): Promise<Response> {
934
+ const auth = c.var.auth;
935
+
936
+ await c.var.services.bootstrap.completeSiteSetup(
937
+ {
938
+ siteName,
939
+ siteLanguage: answers.contentLanguage,
940
+ browserLanguage: body.language,
941
+ // Left alone on a site whose clock the control plane already set: this
942
+ // screen can be opened from anywhere, and the browser reporting a
943
+ // different zone is not the author moving their site.
944
+ timeZone: siteName ? mapIanaToTimezone(body.timezone ?? "") : undefined,
945
+ },
946
+ { oldLanguage: c.var.appConfig.siteLanguage },
947
+ {
948
+ // better-auth requires user.name to stay aligned with the active site
949
+ // display name for the current operator, the same way saving the general
950
+ // settings page does.
951
+ updateCurrentUserName: auth
952
+ ? async (displayName) => {
953
+ await auth.api.updateUser({
954
+ body: { name: displayName },
955
+ headers: c.req.raw.headers,
956
+ });
957
+ }
958
+ : null,
959
+ },
960
+ );
961
+
962
+ await storeDiscoverAnswer(c, answers.discover);
963
+
964
+ return dsRedirect(toPublicPath("/", c.var.appConfig.sitePathPrefix));
965
+ }
966
+
967
+ setupRoutes.post("/setup", async (c) => {
968
+ const status = await c.var.services.settings.getOnboardingStatus();
969
+ if (status === ONBOARDING_STATUS.COMPLETED)
970
+ return c.redirect(toPublicPath("/", c.var.appConfig.sitePathPrefix));
971
+
972
+ const body = await c.req.json<Record<string, string>>();
973
+
974
+ return status === ONBOARDING_STATUS.PENDING
975
+ ? createOwnerAccount(c, body)
976
+ : completeSiteSetup(c, body);
262
977
  });
978
+
979
+ /**
980
+ * The first thing a rejected setup form got wrong, in words.
981
+ *
982
+ * @param i18n - The request's catalog
983
+ * @param error - What the schema rejected
984
+ * @returns The first issue's message, or a general one when it carried none
985
+ */
986
+ function validationMessage(
987
+ i18n: ReturnType<typeof getI18n>,
988
+ error: ZodError,
989
+ ): string {
990
+ return (
991
+ error.issues[0]?.message ??
992
+ i18n._(
993
+ msg({
994
+ message: "Something doesn't look right. Check the form and try again.",
995
+ comment: "@context: Fallback validation error for setup form",
996
+ }),
997
+ )
998
+ );
999
+ }
1000
+
1001
+ function accountCreationFailedMessage(
1002
+ i18n: ReturnType<typeof getI18n>,
1003
+ ): string {
1004
+ return i18n._(
1005
+ msg({
1006
+ message: "Couldn't create your account. Check the details and try again.",
1007
+ comment: "@context: Error toast when account creation fails",
1008
+ }),
1009
+ );
1010
+ }