@jant/core 0.6.15 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (537) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/import-site.js +67 -4
  4. package/bin/commands/reset-password.js +21 -8
  5. package/bin/commands/site/snapshot/export.js +1 -1
  6. package/bin/commands/site/snapshot/import.js +1 -1
  7. package/bin/lib/d1-query.js +5 -16
  8. package/bin/lib/node-env.js +40 -4
  9. package/bin/lib/site-selection.js +85 -29
  10. package/bin/lib/wrangler-cli.js +28 -2
  11. package/bin/lib/wrangler-json.js +35 -0
  12. package/dist/{app-C-oi_t8W.js → app-K_Aa1MMn.js} +52623 -13193
  13. package/dist/client/.vite/manifest.json +255 -90
  14. package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
  15. package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
  16. package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
  17. package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
  18. package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
  19. package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
  20. package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
  21. package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
  22. package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
  23. package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
  24. package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
  25. package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
  26. package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
  27. package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
  28. package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
  29. package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
  30. package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
  31. package/dist/client/_assets/client-GiYENVw8.css +2 -0
  32. package/dist/client/_assets/client-VnFxJN7G.js +295 -0
  33. package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
  34. package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
  35. package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
  36. package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
  37. package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
  38. package/dist/{export-CsFx6F_M.js → github-sync-BPAvT999.js} +2343 -110
  39. package/dist/index.js +2 -5
  40. package/dist/node.js +5 -7
  41. package/package.json +3 -3
  42. package/src/__tests__/auth-session-freshness.test.ts +57 -0
  43. package/src/__tests__/basecoat-variants.test.ts +121 -0
  44. package/src/__tests__/bin/assets-upload.test.ts +68 -0
  45. package/src/__tests__/export-hugo-build.test.ts +260 -0
  46. package/src/__tests__/export-service.test.ts +231 -0
  47. package/src/__tests__/helpers/component-css.ts +91 -0
  48. package/src/__tests__/helpers/db.ts +33 -0
  49. package/src/__tests__/helpers/export-fixtures.ts +3 -0
  50. package/src/__tests__/mise-config.test.ts +22 -0
  51. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  52. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  53. package/src/app.tsx +71 -16
  54. package/src/auth.ts +18 -1
  55. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  56. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  57. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  58. package/src/client/__tests__/compose-bridge.test.ts +524 -2
  59. package/src/client/__tests__/compose-launch.test.ts +62 -1
  60. package/src/client/__tests__/compose-shortcuts.test.ts +57 -8
  61. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  62. package/src/client/__tests__/copy-field.test.ts +162 -0
  63. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  64. package/src/client/__tests__/image-processor.test.ts +324 -2
  65. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  66. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  67. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  68. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  69. package/src/client/__tests__/toast.test.ts +88 -1
  70. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  71. package/src/client/__tests__/video-processor.test.ts +181 -0
  72. package/src/client/audio-processor.ts +24 -15
  73. package/src/client/audio-waveform.ts +49 -0
  74. package/src/client/collection-dialog-host.ts +72 -0
  75. package/src/client/collection-navigation.ts +51 -16
  76. package/src/client/collection-page-actions.ts +26 -0
  77. package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
  78. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  79. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  80. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  81. package/src/client/components/__tests__/jant-command-palette.test.ts +79 -0
  82. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  83. package/src/client/components/__tests__/jant-compose-dialog.test.ts +3283 -389
  84. package/src/client/components/__tests__/jant-compose-editor.test.ts +581 -86
  85. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +34 -89
  86. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  87. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  88. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  89. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  90. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  91. package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
  92. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  93. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  94. package/src/client/components/collection-dialog-types.ts +49 -0
  95. package/src/client/components/collection-manager-types.ts +32 -1
  96. package/src/client/components/collection-types.ts +1 -15
  97. package/src/client/components/compose-format-convert.ts +5 -1
  98. package/src/client/components/compose-types.ts +121 -3
  99. package/src/client/components/jant-collection-dialog.ts +722 -0
  100. package/src/client/components/jant-collection-directory.ts +567 -182
  101. package/src/client/components/jant-collection-form.ts +53 -232
  102. package/src/client/components/jant-command-palette.ts +106 -49
  103. package/src/client/components/jant-compose-dialog.ts +2757 -1194
  104. package/src/client/components/jant-compose-editor.ts +866 -397
  105. package/src/client/components/jant-compose-fullscreen.ts +53 -47
  106. package/src/client/components/jant-config-editor.ts +65 -53
  107. package/src/client/components/jant-confirm-dialog.ts +13 -11
  108. package/src/client/components/jant-locale-picker.ts +418 -0
  109. package/src/client/components/jant-media-lightbox.ts +146 -136
  110. package/src/client/components/jant-nav-manager.ts +690 -366
  111. package/src/client/components/jant-post-menu.ts +1142 -268
  112. package/src/client/components/jant-post-picker.ts +324 -0
  113. package/src/client/components/jant-repo-picker.ts +138 -106
  114. package/src/client/components/jant-settings-avatar.ts +31 -27
  115. package/src/client/components/jant-settings-general.ts +617 -543
  116. package/src/client/components/jant-settings-language.ts +1087 -0
  117. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  118. package/src/client/components/jant-text-preview.ts +94 -86
  119. package/src/client/components/nav-manager-types.ts +62 -1
  120. package/src/client/components/settings-types.ts +14 -29
  121. package/src/client/components/smart-collection-conditions.ts +285 -0
  122. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  123. package/src/client/compose-bridge.ts +336 -242
  124. package/src/client/compose-launch.ts +89 -2
  125. package/src/client/compose-shortcuts.ts +9 -7
  126. package/src/client/compose-triggers.ts +50 -0
  127. package/src/client/copy-field.ts +108 -0
  128. package/src/client/custom-url-menu.ts +60 -4
  129. package/src/client/icons.ts +14 -0
  130. package/src/client/image-plan.ts +165 -0
  131. package/src/client/image-processor.ts +195 -117
  132. package/src/client/image-worker-client.ts +45 -0
  133. package/src/client/image-worker.ts +135 -0
  134. package/src/client/lazy-entries.ts +95 -0
  135. package/src/client/lazy-slugify.ts +2 -2
  136. package/src/client/media-scroll-hint.ts +43 -2
  137. package/src/client/mediabunny.ts +29 -0
  138. package/src/client/post-picker.ts +41 -0
  139. package/src/client/post-refresh.ts +135 -0
  140. package/src/client/runtime-paths.ts +74 -1
  141. package/src/client/settings-bridge.ts +4 -10
  142. package/src/client/site-header-nav.js +18 -6
  143. package/src/client/smart-collection-dialog-host.ts +116 -0
  144. package/src/client/smart-collection-page-actions.ts +208 -0
  145. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  146. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  147. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  148. package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
  149. package/src/client/tiptap/bubble-menu.ts +47 -16
  150. package/src/client/tiptap/floating-position.ts +216 -2
  151. package/src/client/tiptap/inline-image-upload.ts +7 -3
  152. package/src/client/tiptap/link-input-rules.ts +4 -4
  153. package/src/client/tiptap/link-toolbar.ts +22 -9
  154. package/src/client/toast.ts +143 -38
  155. package/src/client/types/vite-worker.d.ts +12 -0
  156. package/src/client/upload-with-metadata.ts +3 -13
  157. package/src/client/video-processor.ts +351 -107
  158. package/src/client-auth.ts +14 -31
  159. package/src/client-compose.ts +19 -0
  160. package/src/client-manage.ts +13 -0
  161. package/src/client-settings.ts +14 -0
  162. package/src/client-site.ts +10 -3
  163. package/src/client.ts +4 -0
  164. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  165. package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
  166. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  167. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  168. package/src/db/__tests__/thread-activity.test.ts +113 -0
  169. package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
  170. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  171. package/src/db/migrations/0031_many_ego.sql +3 -0
  172. package/src/db/migrations/0032_married_tomas.sql +3 -0
  173. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  174. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  175. package/src/db/migrations/meta/0031_snapshot.json +2562 -0
  176. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  177. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  178. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  179. package/src/db/migrations/meta/_journal.json +28 -0
  180. package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
  181. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  182. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  183. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  184. package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
  185. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  186. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  187. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  188. package/src/db/migrations/pg/meta/_journal.json +28 -0
  189. package/src/db/pg/schema.ts +213 -27
  190. package/src/db/post-visibility.ts +117 -0
  191. package/src/db/schema.ts +242 -27
  192. package/src/db/thread-activity.ts +101 -0
  193. package/src/i18n/__tests__/detect.test.ts +26 -81
  194. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  195. package/src/i18n/context.tsx +12 -2
  196. package/src/i18n/detect.ts +7 -66
  197. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  198. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  199. package/src/i18n/locales/public/en.po +568 -168
  200. package/src/i18n/locales/public/en.ts +1 -1
  201. package/src/i18n/locales/public/zh-Hans.po +562 -162
  202. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  203. package/src/i18n/locales/public/zh-Hant.po +562 -162
  204. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  205. package/src/i18n/locales/settings/en.po +551 -69
  206. package/src/i18n/locales/settings/en.ts +1 -1
  207. package/src/i18n/locales/settings/zh-Hans.po +597 -115
  208. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  209. package/src/i18n/locales/settings/zh-Hant.po +610 -128
  210. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  211. package/src/i18n/locales.ts +94 -0
  212. package/src/i18n/middleware.ts +12 -2
  213. package/src/i18n/supported-locales.ts +80 -0
  214. package/src/lib/__tests__/api-settings.test.ts +11 -2
  215. package/src/lib/__tests__/asset-path.test.ts +92 -0
  216. package/src/lib/__tests__/deferred.test.ts +90 -0
  217. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  218. package/src/lib/__tests__/discover.test.ts +410 -0
  219. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  220. package/src/lib/__tests__/feed.test.ts +1691 -12
  221. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  222. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  223. package/src/lib/__tests__/http-cache.test.ts +162 -0
  224. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  225. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  226. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  227. package/src/lib/__tests__/markdown.test.ts +16 -0
  228. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  229. package/src/lib/__tests__/navigation.test.ts +135 -1
  230. package/src/lib/__tests__/resolve-config.test.ts +64 -3
  231. package/src/lib/__tests__/schemas.test.ts +42 -4
  232. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  233. package/src/lib/__tests__/slug.test.ts +88 -0
  234. package/src/lib/__tests__/slugify.test.ts +85 -0
  235. package/src/lib/__tests__/storage.test.ts +32 -0
  236. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  237. package/src/lib/__tests__/translit.test.ts +87 -0
  238. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  239. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  240. package/src/lib/__tests__/url.test.ts +93 -34
  241. package/src/lib/__tests__/view-language.test.ts +413 -0
  242. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  243. package/src/lib/address.ts +57 -0
  244. package/src/lib/api-posts.ts +12 -0
  245. package/src/lib/asset-path.ts +98 -1
  246. package/src/lib/build-env.ts +16 -0
  247. package/src/lib/collection-groups.ts +5 -1
  248. package/src/lib/collection-paths.ts +3 -10
  249. package/src/lib/constants.ts +28 -3
  250. package/src/lib/copy-field.ts +36 -0
  251. package/src/lib/crypto.ts +40 -0
  252. package/src/lib/csp-builder.ts +2 -1
  253. package/src/lib/deferred.ts +79 -0
  254. package/src/lib/discover-ping.ts +141 -0
  255. package/src/lib/discover.ts +437 -0
  256. package/src/lib/env.ts +134 -0
  257. package/src/lib/errors.ts +33 -0
  258. package/src/lib/feed-policy.ts +236 -2
  259. package/src/lib/feed.ts +1021 -66
  260. package/src/lib/filter-dimensions.ts +1349 -0
  261. package/src/lib/github-sync-site-config.ts +3 -0
  262. package/src/lib/hosted-signin.ts +1 -1
  263. package/src/lib/http-cache.ts +148 -0
  264. package/src/lib/hugo-markdown.ts +21 -0
  265. package/src/lib/icons.ts +9 -2
  266. package/src/lib/ids.ts +3 -0
  267. package/src/lib/jant-docs.ts +42 -0
  268. package/src/lib/lang-detect.ts +308 -0
  269. package/src/lib/link-preview.ts +25 -0
  270. package/src/lib/markdown-manager.ts +6 -1
  271. package/src/lib/media-helpers.ts +13 -1
  272. package/src/lib/navigation.ts +78 -11
  273. package/src/lib/pagination.ts +28 -24
  274. package/src/lib/per-language-surfaces.ts +67 -0
  275. package/src/lib/post-display.ts +42 -9
  276. package/src/lib/post-meta.ts +19 -0
  277. package/src/lib/render.tsx +55 -7
  278. package/src/lib/resolve-config.ts +53 -5
  279. package/src/lib/schemas.ts +201 -10
  280. package/src/lib/site-header-fragment.tsx +2 -1
  281. package/src/lib/site-unavailable-page.ts +47 -0
  282. package/src/lib/slug.ts +51 -7
  283. package/src/lib/slugify.ts +113 -0
  284. package/src/lib/startup-config.ts +4 -6
  285. package/src/lib/storage.ts +7 -0
  286. package/src/lib/summary.ts +50 -0
  287. package/src/lib/thread-fold.ts +161 -0
  288. package/src/lib/time.ts +32 -0
  289. package/src/lib/timeline.ts +81 -10
  290. package/src/lib/tiptap-render.ts +5 -4
  291. package/src/lib/translit.ts +288 -0
  292. package/src/lib/upload.ts +16 -13
  293. package/src/lib/url.ts +188 -24
  294. package/src/lib/version.ts +10 -8
  295. package/src/lib/view-language.ts +436 -0
  296. package/src/lib/view.ts +79 -23
  297. package/src/lib/viewer-context.ts +62 -0
  298. package/src/middleware/__tests__/auth.test.ts +70 -2
  299. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  300. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  301. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  302. package/src/middleware/__tests__/session.test.ts +181 -33
  303. package/src/middleware/auth.ts +50 -31
  304. package/src/middleware/cache-control.ts +75 -10
  305. package/src/middleware/config.ts +6 -6
  306. package/src/middleware/error-handler.ts +12 -0
  307. package/src/middleware/onboarding.ts +32 -10
  308. package/src/middleware/secure-headers.ts +1 -1
  309. package/src/middleware/session.ts +96 -8
  310. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  311. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  312. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  313. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  314. package/src/node/__tests__/runtime.test.ts +37 -0
  315. package/src/node/index.ts +1 -0
  316. package/src/preset.css +76 -22
  317. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  318. package/src/routes/api/__tests__/collections.test.ts +114 -0
  319. package/src/routes/api/__tests__/discover.test.ts +222 -0
  320. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  321. package/src/routes/api/__tests__/palette.test.ts +44 -0
  322. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  323. package/src/routes/api/__tests__/settings.test.ts +24 -3
  324. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  325. package/src/routes/api/collections.ts +49 -6
  326. package/src/routes/api/custom-urls.ts +0 -1
  327. package/src/routes/api/discover.ts +95 -0
  328. package/src/routes/api/export.ts +3 -0
  329. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  330. package/src/routes/api/nav-items.ts +55 -8
  331. package/src/routes/api/posts.ts +172 -9
  332. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  333. package/src/routes/api/public/__tests__/posts.test.ts +38 -0
  334. package/src/routes/api/public/archive.ts +77 -81
  335. package/src/routes/api/public/posts.ts +30 -6
  336. package/src/routes/api/smart-collections.ts +132 -0
  337. package/src/routes/api/telegram.ts +2 -40
  338. package/src/routes/api/uploads.ts +1 -2
  339. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  340. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  341. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  342. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  343. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  344. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  345. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  346. package/src/routes/auth/setup.tsx +934 -186
  347. package/src/routes/auth/signin.tsx +10 -8
  348. package/src/routes/compose.tsx +11 -2
  349. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  350. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  351. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  352. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  353. package/src/routes/dash/custom-urls.tsx +1 -37
  354. package/src/routes/dash/settings.tsx +502 -41
  355. package/src/routes/discover-announce.ts +96 -0
  356. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  357. package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
  358. package/src/routes/feed/feed.ts +69 -40
  359. package/src/routes/feed/sitemap.ts +95 -11
  360. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  361. package/src/routes/pages/__tests__/archive-params.test.ts +657 -1
  362. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  363. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  364. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  365. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  366. package/src/routes/pages/__tests__/featured.test.ts +6 -2
  367. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  368. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  369. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  370. package/src/routes/pages/__tests__/preview.test.ts +3 -1
  371. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  372. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  373. package/src/routes/pages/archive.tsx +507 -357
  374. package/src/routes/pages/collection.tsx +94 -96
  375. package/src/routes/pages/collections.tsx +38 -50
  376. package/src/routes/pages/featured.tsx +32 -13
  377. package/src/routes/pages/home.tsx +25 -6
  378. package/src/routes/pages/language.tsx +113 -0
  379. package/src/routes/pages/latest.tsx +26 -16
  380. package/src/routes/pages/new.tsx +3 -0
  381. package/src/routes/pages/page.tsx +183 -19
  382. package/src/routes/pages/partials.tsx +41 -0
  383. package/src/routes/pages/search.tsx +18 -3
  384. package/src/routes/pages/smart-collection.tsx +340 -0
  385. package/src/routes/pages/subscribe.tsx +182 -0
  386. package/src/runtime/__tests__/index.test.ts +19 -0
  387. package/src/runtime/__tests__/node.test.ts +129 -4
  388. package/src/runtime/cloudflare.ts +21 -2
  389. package/src/runtime/index.ts +27 -1
  390. package/src/runtime/node.ts +18 -1
  391. package/src/runtime/site.ts +95 -24
  392. package/src/services/__tests__/auth.test.ts +116 -7
  393. package/src/services/__tests__/collection.test.ts +139 -4
  394. package/src/services/__tests__/language.test.ts +495 -0
  395. package/src/services/__tests__/navigation.test.ts +213 -10
  396. package/src/services/__tests__/path.test.ts +180 -0
  397. package/src/services/__tests__/post-language.test.ts +830 -0
  398. package/src/services/__tests__/post-timeline.test.ts +142 -11
  399. package/src/services/__tests__/post.test.ts +323 -9
  400. package/src/services/__tests__/settings.test.ts +228 -17
  401. package/src/services/__tests__/site-admin.test.ts +5 -5
  402. package/src/services/__tests__/smart-collection.test.ts +651 -0
  403. package/src/services/about-page.ts +22 -1
  404. package/src/services/auth.ts +51 -49
  405. package/src/services/bootstrap.ts +188 -38
  406. package/src/services/collection-directory-position.ts +76 -0
  407. package/src/services/collection.ts +330 -47
  408. package/src/services/custom-url.ts +27 -22
  409. package/src/services/export-theme/assets/client-site.css +1 -1
  410. package/src/services/export-theme/assets/client-site.js +123 -123
  411. package/src/services/export-theme/layouts/_default/rss.xml +77 -1
  412. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  413. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  414. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  415. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  416. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  417. package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
  418. package/src/services/export-theme/styles/main.css +13 -1
  419. package/src/services/export.ts +281 -24
  420. package/src/services/index.ts +34 -3
  421. package/src/services/language.ts +412 -0
  422. package/src/services/mcp.ts +3 -1
  423. package/src/services/media.ts +14 -6
  424. package/src/services/navigation.ts +429 -67
  425. package/src/services/path.ts +225 -7
  426. package/src/services/post.ts +1318 -252
  427. package/src/services/search.ts +48 -2
  428. package/src/services/settings.ts +238 -43
  429. package/src/services/site-admin.ts +24 -7
  430. package/src/services/site.ts +77 -1
  431. package/src/services/smart-collection.ts +636 -0
  432. package/src/services/telegram.ts +20 -22
  433. package/src/services/upload-session.ts +1 -3
  434. package/src/style-author.css +27 -0
  435. package/src/styles/components-author.css +1622 -0
  436. package/src/styles/components.css +24 -1585
  437. package/src/styles/site-media.css +20 -6
  438. package/src/styles/tokens.css +144 -13
  439. package/src/styles/ui-author.css +7602 -0
  440. package/src/styles/ui.css +1224 -7276
  441. package/src/types/app-context.ts +7 -0
  442. package/src/types/bindings.ts +3 -0
  443. package/src/types/config.ts +94 -15
  444. package/src/types/constants.ts +188 -16
  445. package/src/types/entities.ts +92 -1
  446. package/src/types/operations.ts +64 -2
  447. package/src/types/props.ts +168 -30
  448. package/src/types/views.ts +61 -1
  449. package/src/ui/__tests__/font-themes.test.ts +52 -12
  450. package/src/ui/compose/ComposeDialog.tsx +215 -21
  451. package/src/ui/compose/ComposePrompt.tsx +1 -5
  452. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  453. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  454. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  455. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  456. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  457. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  458. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  459. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  460. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  461. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  462. package/src/ui/dash/settings/GeneralContent.tsx +275 -113
  463. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  464. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  465. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  466. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  467. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  468. package/src/ui/dash/settings/discover-copy.ts +116 -0
  469. package/src/ui/feed/LinkCard.tsx +55 -45
  470. package/src/ui/feed/LinkPreview.tsx +2 -7
  471. package/src/ui/feed/NoteCard.tsx +9 -4
  472. package/src/ui/feed/PostStatusBadges.tsx +103 -17
  473. package/src/ui/feed/QuoteCard.tsx +55 -30
  474. package/src/ui/feed/ThreadPreview.tsx +6 -2
  475. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  476. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  477. package/src/ui/feed/thread-preview-state.ts +20 -6
  478. package/src/ui/font-themes.ts +39 -29
  479. package/src/ui/layouts/BaseLayout.tsx +155 -10
  480. package/src/ui/layouts/SiteLayout.tsx +192 -34
  481. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  482. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  483. package/src/ui/pages/ArchivePage.tsx +401 -301
  484. package/src/ui/pages/CollectionPage.tsx +86 -76
  485. package/src/ui/pages/CollectionsPage.tsx +5 -0
  486. package/src/ui/pages/ComposePage.tsx +8 -0
  487. package/src/ui/pages/PostPage.tsx +47 -0
  488. package/src/ui/pages/SearchPage.tsx +3 -3
  489. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  490. package/src/ui/pages/SubscribePage.tsx +236 -0
  491. package/src/ui/pages/__tests__/ArchivePage.test.tsx +122 -3
  492. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  493. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  494. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  495. package/src/ui/shared/CollectionsManager.tsx +70 -17
  496. package/src/ui/shared/CopyField.tsx +127 -0
  497. package/src/ui/shared/DraftPreviewBar.tsx +25 -9
  498. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  499. package/src/ui/shared/MediaGallery.tsx +125 -46
  500. package/src/ui/shared/Pagination.tsx +20 -5
  501. package/src/ui/shared/PostFooter.tsx +55 -23
  502. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  503. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  504. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  505. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  506. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  507. package/src/ui/shared/archive-labels.ts +98 -0
  508. package/src/ui/shared/collection-management-labels.ts +94 -16
  509. package/src/ui/shared/custom-icons.ts +5 -0
  510. package/src/ui/shared/navigation-labels.ts +45 -71
  511. package/src/ui/shared/post-article-attributes.ts +8 -0
  512. package/src/ui/shared/smart-collection-labels.ts +343 -0
  513. package/dist/app-BxCOR3Uc.js +0 -6
  514. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  515. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  516. package/dist/client/_assets/chunks/url-pLre2DM_.js +0 -1
  517. package/dist/client/_assets/client-B_eGKN5p.css +0 -2
  518. package/dist/client/_assets/client-DxY5BFe8.js +0 -296
  519. package/dist/client/_assets/client-auth-CakPESv9.js +0 -5533
  520. package/dist/env-OHRKGcMj.js +0 -281
  521. package/dist/github-api-BgSiE71w.js +0 -176
  522. package/dist/github-app-BbklkFmU.js +0 -275
  523. package/dist/github-sync-Cq1pzzOI.js +0 -431
  524. package/dist/github-sync-ppWXN3jb.js +0 -4
  525. package/dist/url-BMYO-Zlt.js +0 -395
  526. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  527. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  528. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  529. package/src/client/collection-created-notice.ts +0 -66
  530. package/src/client/collection-form-bridge.ts +0 -191
  531. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  532. package/src/client/components/jant-post-form.ts +0 -422
  533. package/src/client/components/post-form-template.ts +0 -352
  534. package/src/client/components/post-form-types.ts +0 -96
  535. package/src/client/media-metadata.ts +0 -247
  536. package/src/client/post-form-bridge.ts +0 -193
  537. package/src/ui/pages/CollectionEditorPage.tsx +0 -175
@@ -12,15 +12,18 @@
12
12
  import type { Bindings } from "../types/bindings.js";
13
13
  import type { AppConfig } from "../types/config.js";
14
14
  import { CONFIG_FIELDS } from "../types/config.js";
15
- import type { FeedKind } from "../types/constants.js";
15
+ import type { ArchiveLayout, FeedKind } from "../types/constants.js";
16
16
  import { ASSET_BASE_SEGMENT, getPublicAssetBasePath } from "./asset-path.js";
17
+ import { resolveDiscoverMode } from "./discover.js";
17
18
  import {
18
19
  getAuthSecret,
19
20
  getConfiguredSingleSiteUrl,
20
21
  getConfiguredStorageDriver,
22
+ getDiscoverDefault,
21
23
  getEnvString,
22
24
  } from "./env.js";
23
- import { getPublicUrlForProvider, getMediaUrl } from "./image.js";
25
+ import { parseLanguageList } from "../i18n/locales.js";
26
+ import { getPublicUrlForProvider, getMediaUrl, getImageUrl } from "./image.js";
24
27
  import { normalizeTimeZone } from "./timezones.js";
25
28
  import { getSiteOrigin, getSitePathPrefix, normalizeSiteUrl } from "./url.js";
26
29
 
@@ -147,6 +150,13 @@ function parseFeedKind(value: string, fallback: FeedKind): FeedKind {
147
150
  return value === "latest" || value === "featured" ? value : fallback;
148
151
  }
149
152
 
153
+ function parseArchiveLayout(
154
+ value: string,
155
+ fallback: ArchiveLayout,
156
+ ): ArchiveLayout {
157
+ return value === "list" || value === "grid" ? value : fallback;
158
+ }
159
+
150
160
  /**
151
161
  * Build a complete AppConfig from environment bindings and DB settings.
152
162
  *
@@ -198,6 +208,7 @@ export function resolveConfig(
198
208
  // Resolve avatar URL from storage key
199
209
  const siteAvatar = allSettings["SITE_AVATAR"] ?? "";
200
210
  let siteAvatarUrl = "";
211
+ let siteAvatarThumbUrl = "";
201
212
  if (siteAvatar) {
202
213
  const publicUrl = getPublicUrlForProvider(
203
214
  storageDriver,
@@ -206,6 +217,17 @@ export function resolveConfig(
206
217
  localPublicUrl,
207
218
  );
208
219
  siteAvatarUrl = getMediaUrl(siteAvatar, publicUrl, sitePathPrefix);
220
+ // The header and drawer marks render this at 32px and 24px, on every page,
221
+ // in parallel with the render-blocking stylesheet. An untransformed avatar
222
+ // is whatever the author uploaded — commonly a 512px PNG — so it competes
223
+ // for the critical path with tens of times the bytes it can use. 128px
224
+ // still covers a high-DPR header and a theme that enlarges the mark.
225
+ siteAvatarThumbUrl = getImageUrl(siteAvatarUrl, imageTransformUrl, {
226
+ width: 128,
227
+ quality: 80,
228
+ format: "auto",
229
+ fit: "scale-down",
230
+ });
209
231
  }
210
232
 
211
233
  // Description is "explicit" when set in DB or ENV (not just the default)
@@ -216,6 +238,16 @@ export function resolveConfig(
216
238
  ? !!dbDescription
217
239
  : !!envDescription;
218
240
 
241
+ // The owner's stored choice and the deployment's default stay apart, and
242
+ // the registry default must not stand in for either: an absent row is what
243
+ // lets `noindex` decide, and an absent binding is what makes Discover
244
+ // opt-in for a self-hosted site.
245
+ const discoverStoredValue = allSettings["DISCOVER"];
246
+ const discoverDefaultValue = getDiscoverDefault(env);
247
+ const noindex = demoMode || resolve("NOINDEX", allSettings, env) === "true";
248
+ const rssFeedsEnabled =
249
+ resolve("RSS_FEEDS_ENABLED", allSettings, env) === "true";
250
+
219
251
  return {
220
252
  // Site identity (DB > ENV > Default)
221
253
  siteName: resolve("SITE_NAME", allSettings, env),
@@ -223,19 +255,34 @@ export function resolveConfig(
223
255
  siteDescriptionExplicit,
224
256
  siteLanguage: resolve("SITE_LANGUAGE", allSettings, env),
225
257
  dashboardLanguage: resolve("DASHBOARD_LANGUAGE", allSettings, env),
226
- cjkSerifFont: resolve("CJK_SERIF_FONT", allSettings, env),
258
+ multilingualEnabled:
259
+ resolve("MULTILINGUAL_ENABLED", allSettings, env) === "true",
260
+ additionalLanguages: parseLanguageList(
261
+ resolve("ADDITIONAL_LANGUAGES", allSettings, env),
262
+ ),
227
263
  mainRssFeed: parseFeedKind(
228
264
  resolve("MAIN_RSS_FEED", allSettings, env),
229
265
  "featured",
230
266
  ),
267
+ archiveDefaultLayout: parseArchiveLayout(
268
+ resolve("ARCHIVE_DEFAULT_LAYOUT", allSettings, env),
269
+ "list",
270
+ ),
231
271
  timeZone: normalizeTimeZone(resolve("TIME_ZONE", allSettings, env)),
232
272
  siteFooter: resolve("SITE_FOOTER", allSettings, env),
233
273
  showJantBrandingOnHome:
234
274
  resolve("SHOW_JANT_BRANDING_ON_HOME", allSettings, env) === "true",
235
- noindex: demoMode || resolve("NOINDEX", allSettings, env) === "true",
275
+ noindex,
276
+ discover: resolveDiscoverMode({
277
+ storedValue: discoverStoredValue,
278
+ defaultValue: discoverDefaultValue,
279
+ demoMode,
280
+ noindex,
281
+ rssFeedsEnabled,
282
+ }),
236
283
  publicApiEnabled:
237
284
  resolve("PUBLIC_API_ENABLED", allSettings, env) === "true",
238
- rssFeedsEnabled: resolve("RSS_FEEDS_ENABLED", allSettings, env) === "true",
285
+ rssFeedsEnabled,
239
286
 
240
287
  // Infrastructure (ENV only)
241
288
  siteUrl,
@@ -318,6 +365,7 @@ export function resolveConfig(
318
365
  siteAvatar,
319
366
  showHeaderAvatar: allSettings["SHOW_HEADER_AVATAR"] === "true",
320
367
  siteAvatarUrl,
368
+ siteAvatarThumbUrl,
321
369
  faviconVersion: allSettings["SITE_FAVICON_VERSION"] ?? "",
322
370
 
323
371
  // Rate limiting (ENV only). Defaults are conservative enough for a
@@ -10,11 +10,13 @@
10
10
 
11
11
  import { z } from "zod";
12
12
  import {
13
+ ARCHIVE_LAYOUTS,
13
14
  FORMATS,
14
15
  STATUSES,
15
16
  VISIBILITIES,
16
17
  SORT_ORDERS,
17
18
  COLLECTION_SORT_ORDERS,
19
+ SMART_COLLECTION_SORT_ORDERS,
18
20
  NAV_ITEM_TYPES,
19
21
  SYSTEM_NAV_KEY_VALUES,
20
22
  MAX_MEDIA_ATTACHMENTS,
@@ -30,14 +32,20 @@ import {
30
32
  type ConfigEditorDefinition,
31
33
  type ConfigKey,
32
34
  } from "../types.js";
33
- import { isCjkSerifFont } from "../i18n/detect.js";
34
35
  import {
36
+ formatLanguageList,
35
37
  isLocale,
36
38
  isValidContentLanguage,
37
39
  normalizeContentLanguage,
40
+ parseLanguageList,
38
41
  } from "../i18n/locales.js";
39
42
  import { ValidationError } from "./errors.js";
40
43
  import { createTypeIdSchema, ID_PREFIX } from "./ids.js";
44
+ import {
45
+ FILTER_DIMENSION_KEYS,
46
+ FILTER_DIMENSIONS,
47
+ type PostFilterSelection,
48
+ } from "./filter-dimensions.js";
41
49
  import { normalizeSlug } from "./slug-format.js";
42
50
  import { isReservedPath } from "./constants.js";
43
51
  import { sanitizeUrl, normalizePath } from "./url.js";
@@ -69,6 +77,27 @@ export function normalizeEmail(email: string): string {
69
77
  return email.trim().toLowerCase();
70
78
  }
71
79
 
80
+ /**
81
+ * A display name to open an account under, before the author has given one.
82
+ *
83
+ * The first setup screen asks for credentials only, so better-auth needs a
84
+ * name it can create the row with; the second screen overwrites it with the
85
+ * site's name. This stands in only for the gap between the two, and for the
86
+ * setup that is abandoned in it — which is why it is the address the author
87
+ * just typed rather than a placeholder they would not recognise.
88
+ *
89
+ * @param email - The normalized address the account is being opened under
90
+ * @returns The part before the `@`, or the whole address when there is none
91
+ * @example
92
+ * ```ts
93
+ * deriveAccountName("owner@example.com");
94
+ * // Returns: "owner"
95
+ * ```
96
+ */
97
+ export function deriveAccountName(email: string): string {
98
+ return email.split("@")[0]?.trim() || email;
99
+ }
100
+
72
101
  /** Trim, strip control characters, and collapse to undefined when empty. */
73
102
  function sanitizeText(maxLength: number) {
74
103
  return z
@@ -172,7 +201,10 @@ export const SystemNavKeySchema = z.enum(SYSTEM_NAV_KEY_VALUES);
172
201
  export const RedirectTypeSchema = z.enum(["301", "302"]);
173
202
 
174
203
  /**
175
- * Custom URL target type enum schema
204
+ * Custom URL target type enum schema.
205
+ *
206
+ * Every kind a stored path can be, including `archive` — which is read and
207
+ * listed but no longer created. See {@link CreatableCustomUrlTargetTypeSchema}.
176
208
  */
177
209
  export const CustomUrlTargetTypeSchema = z.enum([
178
210
  "post",
@@ -181,6 +213,20 @@ export const CustomUrlTargetTypeSchema = z.enum([
181
213
  "archive",
182
214
  ]);
183
215
 
216
+ /**
217
+ * The target kinds a new custom URL may take.
218
+ *
219
+ * `archive` is absent: hand-typing `format=note&title=none` into a text field
220
+ * is the problem smart collections exist to replace, and a form that stopped
221
+ * offering it while the endpoint still accepted it would not have stopped
222
+ * offering it. Existing archive paths keep working indefinitely.
223
+ */
224
+ export const CreatableCustomUrlTargetTypeSchema = z.enum([
225
+ "post",
226
+ "collection",
227
+ "redirect",
228
+ ]);
229
+
184
230
  /**
185
231
  * Rating schema (1-5 integer)
186
232
  */
@@ -193,9 +239,24 @@ export const RatingSchema = z.coerce
193
239
  .or(z.literal("").transform(() => undefined))
194
240
  .transform((v) => (v === 0 ? undefined : v));
195
241
 
242
+ /**
243
+ * Any syntactically valid BCP 47 tag, normalized to canonical case.
244
+ *
245
+ * Deliberately open: Jant ships dashboard catalogs for a handful of locales but
246
+ * content can be written in any language.
247
+ */
248
+ export const ContentLanguageSchema = z
249
+ .string()
250
+ .trim()
251
+ .refine(isValidContentLanguage, {
252
+ message: "Enter a valid BCP 47 language tag (e.g. en, zh-Hans, ja).",
253
+ })
254
+ .transform(normalizeContentLanguage);
255
+
196
256
  const PostIdSchema = createTypeIdSchema(ID_PREFIX.post);
197
257
  const MediaIdSchema = createTypeIdSchema(ID_PREFIX.media);
198
258
  const CollectionIdSchema = createTypeIdSchema(ID_PREFIX.collection);
259
+ const SmartCollectionIdSchema = createTypeIdSchema(ID_PREFIX.smartCollection);
199
260
  const CollectionDirectoryItemIdSchema = createTypeIdSchema(
200
261
  ID_PREFIX.collectionDirectoryItem,
201
262
  );
@@ -294,6 +355,12 @@ const PostFieldsSchema = z.object({
294
355
  .optional(),
295
356
  replyToId: PostIdSchema.optional(),
296
357
  quietReply: z.boolean().optional(),
358
+ // Content language. The post service normalizes and enforces the
359
+ // thread-uniform rule; this only rejects tags that are not BCP 47 at all.
360
+ language: ContentLanguageSchema.optional().or(
361
+ z.literal("").transform(() => undefined),
362
+ ),
363
+ translationOfId: PostIdSchema.optional(),
297
364
  publishedAt: z.number().int().positive().optional(),
298
365
  mediaIds: z.array(MediaIdSchema).max(MAX_MEDIA_ATTACHMENTS).optional(),
299
366
  mediaAlts: z.record(MediaIdSchema, z.string()).optional(),
@@ -566,6 +633,14 @@ export const CreateNavItemSchema = z.discriminatedUnion("type", [
566
633
  label: sanitizeText(100).pipe(z.string().min(1)).optional(),
567
634
  placement: z.enum(["header", "more"]).optional(),
568
635
  }),
636
+ // Placed exactly like a collection — the label follows the target's title
637
+ // and the URL follows its address; only which column holds the key differs.
638
+ z.object({
639
+ type: z.literal("smart_collection"),
640
+ smartCollectionId: SmartCollectionIdSchema,
641
+ label: sanitizeText(100).pipe(z.string().min(1)).optional(),
642
+ placement: z.enum(["header", "more"]).optional(),
643
+ }),
569
644
  z.object({
570
645
  type: z.literal("page"),
571
646
  postId: PostIdSchema,
@@ -620,6 +695,19 @@ export const CreateCollectionDirectoryItemSchema = z.discriminatedUnion(
620
695
  ],
621
696
  );
622
697
 
698
+ /**
699
+ * A row in the collections directory, as the drag surface names it.
700
+ *
701
+ * A placed row is named by its directory row id. A collection or smart
702
+ * collection that has never been placed is named by its own id, because that is
703
+ * all the directory has to render — moving one is what gives it a row.
704
+ */
705
+ export const CollectionDirectoryRowIdSchema = z.union([
706
+ CollectionDirectoryItemIdSchema,
707
+ CollectionIdSchema,
708
+ SmartCollectionIdSchema,
709
+ ]);
710
+
623
711
  export const UpdateCollectionDirectoryItemSchema = z.object({
624
712
  label: z.union([CollectionDirectoryLabelSchema, z.null()]).optional(),
625
713
  url: CollectionDirectoryLinkUrlSchema.optional(),
@@ -633,6 +721,7 @@ export {
633
721
  NavItemIdSchema,
634
722
  PathIdSchema,
635
723
  PostIdSchema,
724
+ SmartCollectionIdSchema,
636
725
  };
637
726
 
638
727
  /**
@@ -677,6 +766,61 @@ export const CreateCollectionSchema = z.object({
677
766
  */
678
767
  export const UpdateCollectionSchema = CreateCollectionSchema.partial();
679
768
 
769
+ /**
770
+ * What a stored selection may hold — the validator a create or update request
771
+ * runs its conditions through.
772
+ *
773
+ * Assembled from the dimension registry rather than restated, so a new
774
+ * dimension is accepted by every endpoint the moment it is declared, and a
775
+ * dimension whose stored vocabulary is narrower than its URL one (`visibility`)
776
+ * is narrow everywhere at once.
777
+ *
778
+ * The registry itself carries plain predicates, not schemas, because the smart
779
+ * collection dialog compiles it into the browser bundle. The Zod wrapper lives
780
+ * here, on the server side of that line, where request bodies are validated and
781
+ * a rejected condition still has to say which one it was.
782
+ */
783
+ export const PostFilterSelectionSchema = z
784
+ .object(
785
+ Object.fromEntries(
786
+ FILTER_DIMENSION_KEYS.map((key) => [
787
+ key,
788
+ z
789
+ .custom((value) => FILTER_DIMENSIONS[key].storage.isStorable(value), {
790
+ message: `Invalid value for the ${key} condition`,
791
+ })
792
+ .optional(),
793
+ ]),
794
+ ),
795
+ )
796
+ .strict() as unknown as z.ZodType<PostFilterSelection>;
797
+
798
+ /**
799
+ * API request body schema for creating a smart collection.
800
+ *
801
+ * The conditions come from the dimension registry rather than being restated
802
+ * here, so a new dimension is accepted the moment it is declared and a stored
803
+ * vocabulary narrower than the URL one (`visibility` never being `private`)
804
+ * is narrow at every entry point at once.
805
+ */
806
+ export const CreateSmartCollectionSchema = z.object({
807
+ slug: CollectionSlugSchema,
808
+ title: CollectionTitleSchema,
809
+ description: CollectionDescriptionValueSchema.nullable().optional(),
810
+ // Omitted means no conditions, which is the honest spelling of "every post".
811
+ selection: PostFilterSelectionSchema.optional(),
812
+ sort: z.enum(SMART_COLLECTION_SORT_ORDERS).optional(),
813
+ layout: z.enum(ARCHIVE_LAYOUTS).nullable().optional(),
814
+ });
815
+
816
+ export const UpdateSmartCollectionSchema =
817
+ CreateSmartCollectionSchema.partial();
818
+
819
+ /** The body of a preview request: the conditions, and nothing else. */
820
+ export const SmartCollectionPreviewSchema = z.object({
821
+ selection: PostFilterSelectionSchema.optional(),
822
+ });
823
+
680
824
  export const SiteNameSettingSchema = sanitizeSettingText(MAX_SITE_NAME_LENGTH);
681
825
  export const SiteDescriptionSettingSchema = sanitizeSettingText(
682
826
  MAX_SITE_DESCRIPTION_LENGTH,
@@ -808,11 +952,16 @@ export function normalizeEditableSettingValue(
808
952
  );
809
953
  }
810
954
  break;
811
- case "CJK_SERIF_FONT":
812
- if (!isCjkSerifFont(normalized)) {
813
- throw new ValidationError("Choose an available CJK font fallback.");
955
+ case "MULTILINGUAL_ENABLED":
956
+ if (normalized !== "true" && normalized !== "false") {
957
+ throw new ValidationError("Multilingual content is on or off.");
814
958
  }
815
959
  break;
960
+ case "ADDITIONAL_LANGUAGES":
961
+ // Re-serialized from the parsed form so a hand-written value cannot
962
+ // store blanks, duplicates, or non-canonical casing.
963
+ normalized = formatLanguageList(parseLanguageList(normalized));
964
+ break;
816
965
  case "TIME_ZONE":
817
966
  if (!isSupportedTimeZone(normalized)) {
818
967
  throw new ValidationError("Choose a valid time zone.");
@@ -837,11 +986,10 @@ export const CreateCustomUrlSchema = z.object({
837
986
  "Path must contain only lowercase alphanumeric characters, hyphens, slashes, and dots",
838
987
  )
839
988
  .transform((p) => (p.startsWith("/") ? p : `/${p}`)),
840
- targetType: CustomUrlTargetTypeSchema,
989
+ targetType: CreatableCustomUrlTargetTypeSchema,
841
990
  targetId: z.string().optional(),
842
991
  toPath: z.string().optional(),
843
992
  redirectType: RedirectTypeSchema.optional(),
844
- archiveQuery: z.string().optional(),
845
993
  });
846
994
 
847
995
  // =============================================================================
@@ -849,10 +997,13 @@ export const CreateCustomUrlSchema = z.object({
849
997
  // =============================================================================
850
998
 
851
999
  /**
852
- * Setup form validation schema
1000
+ * The first of the two screens a self-hosted site is set up through.
1001
+ *
1002
+ * Credentials and nothing else. Everything about the site itself waits for the
1003
+ * second screen, which is the same screen a hosted site shows — so the two
1004
+ * install kinds converge instead of keeping two forms that drift.
853
1005
  */
854
- export const SetupSchema = z.object({
855
- siteName: z.string().min(1, "Site name is required"),
1006
+ export const SetupAccountSchema = z.object({
856
1007
  email: z
857
1008
  .string()
858
1009
  .transform(normalizeEmail)
@@ -863,6 +1014,46 @@ export const SetupSchema = z.object({
863
1014
  .max(128),
864
1015
  });
865
1016
 
1017
+ /**
1018
+ * Setup on a site whose shell already exists and is already named.
1019
+ *
1020
+ * A control plane created the site and its owner, so the only thing left to
1021
+ * settle is the language its author writes in — the one fact no header, host,
1022
+ * or billing record can supply.
1023
+ */
1024
+ export const SetupLanguageSchema = z.object({
1025
+ contentLanguage: ContentLanguageSchema,
1026
+ /**
1027
+ * The Jant Discover answer, from the checkbox on the setup screen.
1028
+ *
1029
+ * Optional, and absence is not `false`: an older client or a scripted setup
1030
+ * sends nothing, and that has to keep meaning "leave the deployment default
1031
+ * in force" rather than "the author said no".
1032
+ */
1033
+ discover: z.boolean().optional(),
1034
+ });
1035
+
1036
+ /**
1037
+ * The second of the two self-hosted screens: the same questions the hosted
1038
+ * screen asks, plus the name only a self-hosted site is still missing.
1039
+ *
1040
+ * Which of the two applies is not a property of the form but of the site — a
1041
+ * site that already has a name is never asked for one — so the route picks the
1042
+ * schema by reading the stored name rather than trusting a field.
1043
+ */
1044
+ export const SetupSiteSchema = SetupLanguageSchema.extend({
1045
+ // The message is set twice over on purpose: `.min(1)` catches a field left
1046
+ // blank, and the type-level `error` catches a form that carried no field at
1047
+ // all. Without the second, a scripted setup is told "expected string,
1048
+ // received undefined" — which names nothing the author can act on.
1049
+ siteName: z
1050
+ .string({ error: "Site name is required" })
1051
+ .min(1, "Site name is required"),
1052
+ });
1053
+
1054
+ /** The answers the last setup screen collects from either install kind. */
1055
+ export type SetupLanguageAnswers = z.infer<typeof SetupLanguageSchema>;
1056
+
866
1057
  /**
867
1058
  * Sign-in form validation schema
868
1059
  */
@@ -29,7 +29,8 @@ export async function renderSiteHeaderHtml(c: Context<Env>): Promise<string> {
29
29
  links={navData.links}
30
30
  currentPath={navData.currentPath}
31
31
  sitePathPrefix={navData.sitePathPrefix}
32
- siteAvatarUrl={navData.siteAvatarUrl}
32
+ basePath={navData.basePath}
33
+ siteAvatarThumbUrl={navData.siteAvatarThumbUrl}
33
34
  showHeaderAvatar={navData.showHeaderAvatar}
34
35
  />
35
36
  </I18nProvider>,
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Standalone page served when a site resolves but is suspended.
3
+ *
4
+ * Kept deliberately self-contained (no theme, no site services, no i18n
5
+ * catalog): it renders before any site context exists, and the site whose
6
+ * theme we would otherwise use is exactly the one that is unavailable.
7
+ *
8
+ * The copy stays provider-neutral. Hosted deployments pass their brand across
9
+ * the boundary through `HOSTED_CONTROL_PLANE_PROVIDER_NAME` /
10
+ * `HOSTED_CONTROL_PLANE_BASE_URL`, which turn the last line into a real link
11
+ * back to the control plane; a self-hosted instance renders the same page
12
+ * without one.
13
+ */
14
+
15
+ import { getHostedControlPlaneProviderLabel } from "./env.js";
16
+ import { getHostedControlPlaneDashboardUrl } from "./hosted-signin.js";
17
+ import { escapeHtml } from "./html.js";
18
+
19
+ export function renderSiteUnavailablePage(
20
+ env: object | undefined | null,
21
+ ): string {
22
+ const providerLabel = getHostedControlPlaneProviderLabel(env);
23
+ const dashboardUrl = getHostedControlPlaneDashboardUrl(env);
24
+
25
+ const ownerLine =
26
+ dashboardUrl && providerLabel
27
+ ? `<p>If it's your site, sign in to <a href="${escapeHtml(dashboardUrl)}">${escapeHtml(providerLabel)}</a> to bring it back online.</p>`
28
+ : `<p>If it's your site, sign in to your hosting provider to bring it back online.</p>`;
29
+
30
+ return `<!DOCTYPE html>
31
+ <html lang="en">
32
+ <head>
33
+ <meta charset="utf-8">
34
+ <meta name="viewport" content="width=device-width, initial-scale=1">
35
+ <meta name="robots" content="noindex">
36
+ <title>This site is offline</title>
37
+ <style>body{font-family:system-ui,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#fafafa;color:#111;padding:24px}div{max-width:460px;text-align:left}h1{font-size:1.25rem;font-weight:600;margin:0 0 12px}p{color:#4a4a4a;line-height:1.6;margin:12px 0}a{color:#0f766e}@media(prefers-color-scheme:dark){body{background:#111;color:#fafafa}p{color:#a1a1a1}a{color:#2dd4bf}}</style>
38
+ </head>
39
+ <body>
40
+ <div>
41
+ <h1>This site is offline</h1>
42
+ <p>There's nothing to read here right now &mdash; the site's hosting has stopped.</p>
43
+ ${ownerLine}
44
+ </div>
45
+ </body>
46
+ </html>`;
47
+ }
package/src/lib/slug.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * Handles three cases: user-provided slug, title-based slug, and random-only slug.
6
6
  */
7
7
 
8
- import { slugify } from "./url.js";
8
+ import { slugify } from "./slugify.js";
9
9
  import { generateRandomId } from "./nanoid.js";
10
10
  import { isReservedPath } from "./constants.js";
11
11
  import { ValidationError, ConflictError } from "./errors.js";
@@ -19,6 +19,21 @@ export interface SlugOptions {
19
19
  title?: string;
20
20
  /** Length of random IDs */
21
21
  idLength: number;
22
+ /**
23
+ * URL form of the post's language (lowercased BCP 47 tag, e.g. `zh-hant`),
24
+ * set only for posts written in a non-primary language. Used as the first
25
+ * fallback when a title-based slug collides, so the transliterations of a
26
+ * translated pair land on `shu-ping` and `shu-ping-en` rather than a random
27
+ * suffix. Omitted for primary-language posts: the bare namespace belongs to
28
+ * them, and a language suffix among same-language posts distinguishes nothing.
29
+ */
30
+ languageSuffix?: string;
31
+ /**
32
+ * URL prefixes currently served by language views. A slug matching one would
33
+ * be shadowed by that view and never resolve, so it is treated as reserved
34
+ * for as long as the language is configured.
35
+ */
36
+ reservedPrefixes?: readonly string[];
22
37
  /** Callback to check if a slug is available (checks post slugs + path_registry paths) */
23
38
  isAvailable: (slug: string) => Promise<boolean>;
24
39
  }
@@ -28,7 +43,7 @@ export interface SlugOptions {
28
43
  *
29
44
  * Resolution order:
30
45
  * 1. User-provided slug → validate format, check reserved, check availability
31
- * 2. Title exists → slugify(title), append -{randomId} if conflict
46
+ * 2. Title exists → slugify(title), then -{languageSuffix}, then -{randomId}
32
47
  * 3. No title → pure random ID
33
48
  *
34
49
  * @param opts - Slug generation options
@@ -42,16 +57,33 @@ export interface SlugOptions {
42
57
  * // Title-based
43
58
  * await generatePostSlug({ title: "Hello World", idLength: 5, isAvailable: check });
44
59
  *
60
+ * // Title-based in a secondary language, colliding with an existing slug
61
+ * await generatePostSlug({
62
+ * title: "書評",
63
+ * languageSuffix: "en",
64
+ * idLength: 5,
65
+ * isAvailable: check,
66
+ * }); // => "shu-ping-en"
67
+ *
45
68
  * // Random
46
69
  * await generatePostSlug({ idLength: 5, isAvailable: check });
47
70
  * ```
48
71
  */
49
72
  export async function generatePostSlug(opts: SlugOptions): Promise<string> {
50
- const { slug, title, idLength, isAvailable } = opts;
73
+ const {
74
+ slug,
75
+ title,
76
+ idLength,
77
+ languageSuffix,
78
+ reservedPrefixes = [],
79
+ isAvailable,
80
+ } = opts;
81
+ const isReserved = (candidate: string) =>
82
+ isReservedPath(candidate, reservedPrefixes);
51
83
 
52
84
  // Case 1: User-provided slug
53
85
  if (slug) {
54
- if (isReservedPath(slug)) {
86
+ if (isReserved(slug)) {
55
87
  throw new ValidationError(
56
88
  `Slug "${slug}" is reserved and cannot be used`,
57
89
  );
@@ -66,15 +98,27 @@ export async function generatePostSlug(opts: SlugOptions): Promise<string> {
66
98
  // Case 2: Title-based slug
67
99
  if (title) {
68
100
  const base = slugify(title);
69
- if (base && !isReservedPath(base)) {
101
+ if (base && !isReserved(base)) {
70
102
  const available = await isAvailable(base);
71
103
  if (available) return base;
104
+
105
+ // A readable language suffix beats a random one, and it is exactly the
106
+ // collision a translated pair produces.
107
+ if (languageSuffix) {
108
+ const languageCandidate = `${base}-${languageSuffix}`;
109
+ if (
110
+ !isReserved(languageCandidate) &&
111
+ (await isAvailable(languageCandidate))
112
+ ) {
113
+ return languageCandidate;
114
+ }
115
+ }
72
116
  }
73
117
 
74
118
  // Append random suffix on conflict or reserved base
75
119
  for (let i = 0; i < MAX_RETRIES; i++) {
76
120
  const candidate = `${base || generateRandomId(idLength)}-${generateRandomId(idLength)}`;
77
- if (!isReservedPath(candidate) && (await isAvailable(candidate))) {
121
+ if (!isReserved(candidate) && (await isAvailable(candidate))) {
78
122
  return candidate;
79
123
  }
80
124
  }
@@ -86,7 +130,7 @@ export async function generatePostSlug(opts: SlugOptions): Promise<string> {
86
130
  // Case 3: Pure random
87
131
  for (let i = 0; i < MAX_RETRIES; i++) {
88
132
  const candidate = generateRandomId(idLength);
89
- if (!isReservedPath(candidate) && (await isAvailable(candidate))) {
133
+ if (!isReserved(candidate) && (await isAvailable(candidate))) {
90
134
  return candidate;
91
135
  }
92
136
  }