@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
@@ -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
  }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Slug generation from free text.
3
+ *
4
+ * Lives apart from `url.ts` on purpose: `limax` drags the whole `pinyin-pro`
5
+ * dictionary (≈390 KB minified) into any bundle that imports it. Keeping the
6
+ * dictionary behind this single module lets the browser load it lazily via
7
+ * `client/lazy-slugify.ts`, while `url.ts` stays cheap enough for the public
8
+ * bundle. The build guard in `vite.shared.ts` fails the client build if
9
+ * `pinyin-pro` ever lands in an entry chunk again.
10
+ */
11
+
12
+ import limax from "limax";
13
+
14
+ import {
15
+ HAN_RE,
16
+ HANGUL_RE,
17
+ KANA_RE,
18
+ hangulToLatin,
19
+ kanaToRomaji,
20
+ } from "./translit.js";
21
+
22
+ const HAN_GLOBAL_RE = /\p{Script=Han}/gu;
23
+ const CONTENT_CHAR_RE = /[\p{L}\p{N}]/u;
24
+ const ASCII_ALNUM_RE = /[a-zA-Z0-9]/;
25
+
26
+ /**
27
+ * Minimum fraction of a title's letters/digits the transliteration pipeline
28
+ * must handle for the result to be usable. Below this, too much meaning is
29
+ * lost (e.g. mixed kanji+kana Japanese where the semantic core is kanji, or
30
+ * scripts limax drops entirely) and `slugify` returns "" so callers fall back
31
+ * to their random-ID path instead of shipping a garbled slug.
32
+ */
33
+ const SLUG_COVERAGE_THRESHOLD = 0.75;
34
+
35
+ /** Per-character limax probe cache: does this char transliterate to anything? */
36
+ const charCoverageCache = new Map<string, boolean>();
37
+
38
+ function isCharCovered(char: string): boolean {
39
+ if (ASCII_ALNUM_RE.test(char)) return true;
40
+ let covered = charCoverageCache.get(char);
41
+ if (covered === undefined) {
42
+ covered = limax(char, { tone: false }).length > 0;
43
+ charCoverageCache.set(char, covered);
44
+ }
45
+ return covered;
46
+ }
47
+
48
+ function slugCoverage(text: string, japanese: boolean): number {
49
+ let content = 0;
50
+ let covered = 0;
51
+ for (const char of text) {
52
+ if (!CONTENT_CHAR_RE.test(char)) continue;
53
+ content++;
54
+ if (KANA_RE.test(char) || HANGUL_RE.test(char)) {
55
+ covered++;
56
+ } else if (japanese && HAN_RE.test(char)) {
57
+ // Kanji in a kana-containing title is dropped, never pinyin-read.
58
+ } else if (isCharCovered(char)) {
59
+ covered++;
60
+ }
61
+ }
62
+ return content === 0 ? 0 : covered / content;
63
+ }
64
+
65
+ /**
66
+ * Converts text to a URL-friendly slug.
67
+ *
68
+ * Transliterates i18n text to Latin before slugifying: Korean hangul via
69
+ * Revised Romanization, Japanese kana via Hepburn romaji (both in-house, see
70
+ * `translit.ts`), everything else (Han → pinyin, Cyrillic, accents, …) via
71
+ * limax. When the title contains kana it is treated as Japanese and Han
72
+ * characters are dropped rather than misread as Chinese pinyin; Han without
73
+ * kana is treated as Chinese.
74
+ *
75
+ * Returns `""` when transliteration would lose too much of the title (see
76
+ * `SLUG_COVERAGE_THRESHOLD`) — every caller treats an empty base as "no
77
+ * usable title" and falls back to a random ID, which beats publishing a
78
+ * garbled slug.
79
+ *
80
+ * @param text - The text to convert to a slug
81
+ * @returns The slugified string, or `""` when no usable slug can be derived
82
+ *
83
+ * @example
84
+ * ```ts
85
+ * slugify("Hello World! This is a Test.");
86
+ * // Returns: "hello-world-this-is-a-test"
87
+ *
88
+ * slugify("书评");
89
+ * // Returns: "shu-ping"
90
+ *
91
+ * slugify("안녕하세요 세계");
92
+ * // Returns: "annyeonghaseyo-segye"
93
+ *
94
+ * slugify("ハンバーガー");
95
+ * // Returns: "hanbaga"
96
+ *
97
+ * slugify("日本語のタイトルです");
98
+ * // Returns: "" (kanji carries the meaning and can't be transliterated)
99
+ * ```
100
+ */
101
+ export function slugify(text: string): string {
102
+ const japanese = KANA_RE.test(text);
103
+ let pre = text;
104
+ if (japanese) {
105
+ pre = kanaToRomaji(pre.replace(HAN_GLOBAL_RE, " "));
106
+ }
107
+ if (HANGUL_RE.test(pre)) {
108
+ pre = hangulToLatin(pre);
109
+ }
110
+ const base = limax(pre, { tone: false }).replace(/_/g, "-");
111
+ if (!base) return "";
112
+ return slugCoverage(text, japanese) >= SLUG_COVERAGE_THRESHOLD ? base : "";
113
+ }
@@ -1,4 +1,5 @@
1
1
  import { escapeHtml } from "./html.js";
2
+ import { getJantDocsUrl } from "./jant-docs.js";
2
3
  import {
3
4
  getAuthSecret,
4
5
  getHostedControlPlaneBaseUrl,
@@ -94,8 +95,7 @@ function getAuthSecretErrorHtml(kind: AuthSecretIssueKind): string {
94
95
  return renderConfigurationErrorPage({
95
96
  title: titleByKind[kind],
96
97
  bodyHtml: `${leadByKind[kind]}${runtimeInstructions}`,
97
- docsHref:
98
- "https://github.com/jant-me/jant/blob/main/docs/configuration.md#required",
98
+ docsHref: getJantDocsUrl("configuration#required"),
99
99
  });
100
100
  }
101
101
 
@@ -219,8 +219,7 @@ function getHostBasedConfigurationErrorHtml(
219
219
  return renderConfigurationErrorPage({
220
220
  title: "Hosted configuration is incomplete",
221
221
  bodyHtml: `<p>Jant is running with <code>SITE_RESOLUTION_MODE=host-based</code>, so hosted control-plane integration must be configured before the instance can serve requests.</p><ul>${itemsHtml}</ul>`,
222
- docsHref:
223
- "https://github.com/jant-me/jant/blob/main/docs/configuration.md#hosted-control-plane-integration-variables",
222
+ docsHref: getJantDocsUrl("configuration"),
224
223
  });
225
224
  }
226
225
 
@@ -243,8 +242,7 @@ export function getRuntimeConfigurationErrorPage(message: string): string {
243
242
  return renderConfigurationErrorPage({
244
243
  title: "Configuration Error",
245
244
  bodyHtml: `<p>${escapeHtml(message)}</p><p>Update your environment or instance data, then restart Jant.</p>`,
246
- docsHref:
247
- "https://github.com/jant-me/jant/blob/main/docs/configuration.md#site-resolution",
245
+ docsHref: getJantDocsUrl("configuration"),
248
246
  });
249
247
  }
250
248
 
@@ -432,6 +432,13 @@ export function createS3Driver(config: S3DriverConfig): StorageDriver {
432
432
  // uploaded object checksums during finalize, so only enable response
433
433
  // checksum verification when a request explicitly asks for it.
434
434
  responseChecksumValidation: "WHEN_REQUIRED",
435
+ // Presigning runs with an empty body, so the SDK's default
436
+ // "WHEN_SUPPORTED" request checksums bake CRC32-of-nothing
437
+ // (x-amz-checksum-crc32=AAAAAA==) into every presigned PUT URL. The
438
+ // browser then uploads real bytes and the storage provider rejects
439
+ // the mismatch. Only send a request checksum when the caller asked
440
+ // for one (presignPut passes ChecksumSHA256 explicitly).
441
+ requestChecksumCalculation: "WHEN_REQUIRED",
435
442
  });
436
443
  return {
437
444
  send: (cmd: unknown) => client.send(cmd as never),