@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
@@ -38,9 +38,9 @@ describe("Settings API Routes", () => {
38
38
  expect(body.settings.SITE_NAME).toBe("Jant");
39
39
  expect(body.settings.SITE_DESCRIPTION).toBe("");
40
40
  expect(body.settings.SITE_LANGUAGE).toBe("en");
41
- expect(body.settings.PAGE_SIZE).toBe("50");
42
- expect(body.settings.SEARCH_PAGE_SIZE).toBe("50");
43
- expect(body.settings.ARCHIVE_PAGE_SIZE).toBe("50");
41
+ expect(body.settings.PAGE_SIZE).toBe("25");
42
+ expect(body.settings.SEARCH_PAGE_SIZE).toBe("25");
43
+ expect(body.settings.ARCHIVE_PAGE_SIZE).toBe("25");
44
44
  expect(body.settings.RSS_PUBLISH_DELAY_SECONDS).toBe("0");
45
45
  });
46
46
 
@@ -199,6 +199,27 @@ describe("Settings API Routes", () => {
199
199
  expect(body.details.rejectedKeys).toContain("AUTH_SECRET");
200
200
  });
201
201
 
202
+ it("rejects the multilingual keys, which only the language page may write", async () => {
203
+ const { app } = createTestApp({ authenticated: true });
204
+ app.route("/api/settings", settingsApiRoutes);
205
+
206
+ const res = await app.request("/api/settings", {
207
+ method: "PUT",
208
+ headers: { "Content-Type": "application/json" },
209
+ body: JSON.stringify({
210
+ MULTILINGUAL_ENABLED: "true",
211
+ ADDITIONAL_LANGUAGES: "en",
212
+ }),
213
+ });
214
+
215
+ // Writing these directly would skip the confirmation that stamps
216
+ // existing posts and the URL-prefix conflict check.
217
+ expect(res.status).toBe(400);
218
+ const body = await res.json();
219
+ expect(body.details.rejectedKeys).toContain("MULTILINGUAL_ENABLED");
220
+ expect(body.details.rejectedKeys).toContain("ADDITIONAL_LANGUAGES");
221
+ });
222
+
202
223
  it("rejects internal keys", async () => {
203
224
  const { app } = createTestApp({ authenticated: true });
204
225
  app.route("/api/settings", settingsApiRoutes);
@@ -3,6 +3,7 @@ import { createTestApp } from "../../../__tests__/helpers/app.js";
3
3
  import { DEFAULT_TEST_SITE_ID } from "../../../__tests__/helpers/db.js";
4
4
  import { telegramWebhookRoutes } from "../telegram.js";
5
5
  import type { StorageDriver } from "../../../lib/storage.js";
6
+ import type { DeferredPendingEnv } from "../../../lib/deferred.js";
6
7
 
7
8
  const BOT_ID = "111111";
8
9
  const BOT_TOKEN = `${BOT_ID}:AA-test-token`;
@@ -117,13 +118,13 @@ function setup(
117
118
 
118
119
  // The webhook ACKs Telegram immediately and runs the album flush in
119
120
  // detached promises so subsequent webhooks on the same chat aren't
120
- // serialized behind it. Tests have no `executionCtx.waitUntil`, so the
121
- // route falls back to writing each promise into `env.__telegramPending`
122
- // — collect them here so tests can await the flush before asserting.
121
+ // serialized behind it. Tests have no `executionCtx.waitUntil`, so
122
+ // `runDeferred` falls back to writing each promise into
123
+ // `env.__pendingDeferred` — collect them here so tests can await the flush
124
+ // before asserting.
123
125
  const pending: Promise<unknown>[] = [];
124
126
  ctx.app.use("/api/telegram/*", async (c, next) => {
125
- (c.env as { __telegramPending?: Promise<unknown>[] }).__telegramPending =
126
- pending;
127
+ (c.env as DeferredPendingEnv).__pendingDeferred = pending;
127
128
  return next();
128
129
  });
129
130
  ctx.app.route("/api/telegram", telegramWebhookRoutes);
@@ -9,9 +9,11 @@ import type { AppVariables } from "../../types/app-context.js";
9
9
  import { requireAuthApi } from "../../middleware/auth.js";
10
10
  import { requirePublicApiAccess } from "../../middleware/public-content-access.js";
11
11
  import {
12
- CollectionDirectoryItemIdSchema,
12
+ CollectionDirectoryRowIdSchema,
13
13
  CollectionDescriptionValueSchema,
14
+ CollectionIdSchema,
14
15
  CollectionSortOrderSchema,
16
+ ContentLanguageSchema,
15
17
  CreateCollectionDirectoryItemSchema,
16
18
  CreateCollectionSchema,
17
19
  PostIdSchema,
@@ -36,12 +38,24 @@ const ThreadAssignSchema = z.object({
36
38
  });
37
39
 
38
40
  const MoveSchema = z.object({
39
- after: CollectionDirectoryItemIdSchema.nullable().optional(),
40
- before: CollectionDirectoryItemIdSchema.nullable().optional(),
41
+ after: CollectionDirectoryRowIdSchema.nullable().optional(),
42
+ before: CollectionDirectoryRowIdSchema.nullable().optional(),
43
+ });
44
+
45
+ const SlugQuerySchema = z.object({
46
+ mode: z.literal("check"),
47
+ slug: z.string().trim().toLowerCase().min(1).max(200),
48
+ collectionId: CollectionIdSchema.optional(),
41
49
  });
42
50
 
43
51
  const ListCollectionsQuerySchema = z.object({
44
52
  view: z.enum(["compose"]).optional(),
53
+ /**
54
+ * Content language of the calling view. The directory's Thread counts are
55
+ * narrowed by it, so the page and this endpoint have to be asked the same
56
+ * question — see `jant-collection-directory`.
57
+ */
58
+ lang: ContentLanguageSchema.optional(),
45
59
  });
46
60
 
47
61
  // List collections (includes Thread counts and directory items)
@@ -50,6 +64,11 @@ collectionsApiRoutes.get("/", requirePublicApiAccess(), async (c) => {
50
64
 
51
65
  if (query.view === "compose") {
52
66
  c.header("Cache-Control", "no-store");
67
+ // Smart collections are deliberately absent. A post cannot be added to one
68
+ // by hand — its conditions decide, permanently — so offering it here would
69
+ // be a control that does nothing. Every "add this post to a collection"
70
+ // surface leaves them out for the same reason; see the smart collections
71
+ // design notes on the asymmetry, which is not a gap to be filled in later.
53
72
  const collections = await c.var.services.collections.listByRecentActivity();
54
73
  return c.json({
55
74
  collections,
@@ -57,10 +76,14 @@ collectionsApiRoutes.get("/", requirePublicApiAccess(), async (c) => {
57
76
  });
58
77
  }
59
78
 
60
- const directoryData = await c.var.services.collections.listDirectoryData();
79
+ const directoryData = await c.var.services.collections.listDirectoryData({
80
+ isAuthenticated: c.var.isAuthenticated,
81
+ lang: query.lang,
82
+ });
61
83
 
62
84
  return c.json({
63
85
  collections: directoryData.collections,
86
+ smartCollections: directoryData.smartCollections,
64
87
  directoryItems: directoryData.directoryItems,
65
88
  });
66
89
  });
@@ -98,13 +121,17 @@ collectionsApiRoutes.put(
98
121
  );
99
122
 
100
123
  // Move directory item — must be before /:id
124
+ //
125
+ // The moved row and its neighbours may each be named by a collection or smart
126
+ // collection id: those are the rows the directory renders without one of its
127
+ // own. The service places them before it moves anything.
101
128
  collectionsApiRoutes.put(
102
129
  "/directory-items/:id/move",
103
130
  requireAuthApi(),
104
131
  async (c) => {
105
- const id = parseIdParam(
132
+ const id = parseValidated(
133
+ CollectionDirectoryRowIdSchema,
106
134
  c.req.param("id"),
107
- ID_PREFIX.collectionDirectoryItem,
108
135
  );
109
136
  const body = parseValidated(MoveSchema, await c.req.json());
110
137
 
@@ -135,6 +162,22 @@ collectionsApiRoutes.delete(
135
162
  },
136
163
  );
137
164
 
165
+ /**
166
+ * Address availability, in the shape smart collections already use.
167
+ *
168
+ * Declared before `/:id` so the literal path wins. The editing dialog asks
169
+ * while the author types, so a clash is answered where the decision is being
170
+ * made rather than on write.
171
+ */
172
+ collectionsApiRoutes.get("/slug", requireAuthApi(), async (c) => {
173
+ const query = parseValidated(SlugQuerySchema, c.req.query());
174
+ const available = await c.var.services.collections.checkSlugAvailability(
175
+ query.slug,
176
+ query.collectionId,
177
+ );
178
+ return c.json({ slug: query.slug, available });
179
+ });
180
+
138
181
  // Get single collection
139
182
  collectionsApiRoutes.get("/:id", requirePublicApiAccess(), async (c) => {
140
183
  const id = parseIdParam(c.req.param("id"), ID_PREFIX.collection);
@@ -63,7 +63,6 @@ customUrlsApiRoutes.post("/", requireAuthApi(), async (c) => {
63
63
  targetId,
64
64
  toPath: body.toPath,
65
65
  redirectType,
66
- archiveQuery: body.archiveQuery,
67
66
  });
68
67
 
69
68
  return c.json(customUrl, 201);
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Discover API Routes
3
+ *
4
+ * The one address a Discover directory calls besides the feeds it polls.
5
+ * `GET /api/discover/posts` says, for each post ID it names, whether the post
6
+ * is in this site's Latest feed and whether its Thread is in the featured
7
+ * feed. The feed cannot say this about a post pushed past its length, and the
8
+ * permalink cannot say it about a post hidden from Latest or unfeatured, which
9
+ * is still a live page — so the directory asks instead of guessing.
10
+ *
11
+ * Specified in `docs/feeds.md`. A directory never builds the address: every
12
+ * feed names it in `<jant:discover status="…">`.
13
+ */
14
+
15
+ import { Hono } from "hono";
16
+ import { z } from "zod";
17
+ import type { Bindings } from "../../types.js";
18
+ import type { AppVariables } from "../../types/app-context.js";
19
+ import { DISCOVER_STATUS_MAX_IDS } from "../../lib/discover.js";
20
+ import {
21
+ featuredFeedSelection,
22
+ getRssPublishedBefore,
23
+ latestFeedSelection,
24
+ } from "../../lib/feed-policy.js";
25
+ import { createTypeIdSchema, ID_PREFIX } from "../../lib/ids.js";
26
+ import { ContentLanguageSchema, parseValidated } from "../../lib/schemas.js";
27
+
28
+ type Env = { Bindings: Bindings; Variables: AppVariables };
29
+
30
+ export const discoverApiRoutes = new Hono<Env>();
31
+
32
+ const PostStatusQuerySchema = z.object({
33
+ id: z
34
+ .array(createTypeIdSchema(ID_PREFIX.post))
35
+ .min(1, "Name at least one post with id=")
36
+ .max(
37
+ DISCOVER_STATUS_MAX_IDS,
38
+ `Ask about at most ${DISCOVER_STATUS_MAX_IDS} posts at a time`,
39
+ ),
40
+ /** The language view the answer is about, as a language feed is scoped. */
41
+ lang: ContentLanguageSchema.optional(),
42
+ });
43
+
44
+ /** Where one post stands in the feeds a directory reads. */
45
+ export interface DiscoverPostStatus {
46
+ id: string;
47
+ /** In the Latest feed. */
48
+ latest: boolean;
49
+ /** Its Thread is in the featured feed. */
50
+ featured: boolean;
51
+ }
52
+
53
+ discoverApiRoutes.get("/posts", async (c) => {
54
+ // It answers for a site that takes part, and follows the Discover setting
55
+ // rather than `PUBLIC_API_ENABLED`: it says nothing the public feeds do not,
56
+ // and a site that turned its JSON API off has not left Discover.
57
+ if (c.var.appConfig.discover === "none") return c.notFound();
58
+
59
+ const query = parseValidated(PostStatusQuerySchema, {
60
+ id: c.req.queries("id") ?? [],
61
+ lang: c.req.query("lang"),
62
+ });
63
+ const ids = [...new Set(query.id)];
64
+ // The feeds hold a new post back for the RSS delay, and so does this: the
65
+ // answer is about the feeds as a reader would fetch them now.
66
+ const publishedBefore = getRssPublishedBefore(
67
+ c.var.appConfig.rssPublishDelaySeconds,
68
+ );
69
+
70
+ const [latest, featured] = await Promise.all([
71
+ c.var.services.posts.list({
72
+ ...latestFeedSelection({ lang: query.lang, publishedBefore }),
73
+ ids,
74
+ limit: ids.length,
75
+ }),
76
+ c.var.services.posts.listFeaturedThreadRootIds({
77
+ ...featuredFeedSelection({ lang: query.lang, publishedBefore }),
78
+ threadIds: ids,
79
+ limit: ids.length,
80
+ }),
81
+ ]);
82
+ const inLatest = new Set(latest.map((post) => post.id));
83
+ const inFeatured = new Set(featured);
84
+
85
+ // Every ID asked about is answered, in the order asked. A post that is
86
+ // deleted, private, a draft, a reply, or not this site's at all comes back
87
+ // `false` on both: to a directory they all mean the same thing.
88
+ return c.json({
89
+ posts: ids.map((id): DiscoverPostStatus => ({
90
+ id,
91
+ latest: inLatest.has(id),
92
+ featured: inFeatured.has(id),
93
+ })),
94
+ });
95
+ });
@@ -23,10 +23,13 @@ exportApiRoutes.post("/hugo", requireAuthApi(), async (c) => {
23
23
  siteUrl: appConfig.siteUrl,
24
24
  siteDescription: appConfig.siteDescription,
25
25
  siteLanguage: appConfig.siteLanguage,
26
+ multilingualEnabled: appConfig.multilingualEnabled,
27
+ additionalLanguages: appConfig.additionalLanguages,
26
28
  showJantBrandingOnHome: appConfig.showJantBrandingOnHome,
27
29
  publicApiEnabled: appConfig.publicApiEnabled,
28
30
  rssFeedsEnabled: appConfig.rssFeedsEnabled,
29
31
  mainRssFeed: appConfig.mainRssFeed,
32
+ archiveDefaultLayout: appConfig.archiveDefaultLayout,
30
33
  siteFooter: appConfig.siteFooter,
31
34
  showHeaderAvatar: appConfig.showHeaderAvatar,
32
35
  siteAvatarUrl: appConfig.siteAvatarUrl,
@@ -94,8 +94,10 @@ describe("Internal site admin routes", () => {
94
94
 
95
95
  expect(siteRows).toEqual([{ key: "demo-cloud" }]);
96
96
  expect(domainRows).toEqual([{ host: "demo-cloud.example.com" }]);
97
+ // Provisioned, not completed: the site is real and servable, but its
98
+ // owner has not yet confirmed the language they write in.
97
99
  expect(settingRows).toEqual([
98
- { key: "ONBOARDING_STATUS", value: "completed" },
100
+ { key: "ONBOARDING_STATUS", value: "provisioned" },
99
101
  { key: "SITE_LANGUAGE", value: "en" },
100
102
  { key: "SITE_NAME", value: "Demo Cloud" },
101
103
  ]);
@@ -136,7 +138,7 @@ describe("Internal site admin routes", () => {
136
138
  .all(body.siteId) as { key: string; value: string }[];
137
139
 
138
140
  expect(settingRows).toEqual([
139
- { key: "ONBOARDING_STATUS", value: "completed" },
141
+ { key: "ONBOARDING_STATUS", value: "provisioned" },
140
142
  { key: "SITE_LANGUAGE", value: "en" },
141
143
  { key: "SITE_NAME", value: "Locale Demo" },
142
144
  { key: "TIME_ZONE", value: "Etc/GMT-8" },
@@ -405,6 +407,28 @@ describe("Internal site admin routes", () => {
405
407
  VALUES (?, 'SITE_AVATAR', 'sites/${siteId}/avatar.webp', 1774200003)`,
406
408
  )
407
409
  .run(siteId);
410
+ // A smart collection filtering by a collection on the same site. The pair
411
+ // is the shape this endpoint used to die on: the condition column once
412
+ // carried `ON DELETE restrict`, so clearing `collection` threw partway
413
+ // through a delete that runs without a transaction on SQLite and D1.
414
+ sqlite
415
+ .prepare(
416
+ `INSERT INTO "collection" ("id", "site_id", "title", "sort_order", "created_at", "updated_at")
417
+ VALUES ('col_delete_1', ?, 'Notes', 'newest', 1774200004, 1774200004)`,
418
+ )
419
+ .run(siteId);
420
+ sqlite
421
+ .prepare(
422
+ `INSERT INTO "smart_collection" ("id", "site_id", "title", "collection_id", "format", "sort", "created_at", "updated_at")
423
+ VALUES ('smc_delete_1', ?, 'Untitled notes', 'col_delete_1', 'note', 'newest', 1774200005, 1774200005)`,
424
+ )
425
+ .run(siteId);
426
+ sqlite
427
+ .prepare(
428
+ `INSERT INTO "path_registry" ("id", "site_id", "path", "kind", "smart_collection_id", "created_at", "updated_at")
429
+ VALUES ('pth_delete_1', ?, 'untitled-notes', 'slug', 'smc_delete_1', 1774200006, 1774200006)`,
430
+ )
431
+ .run(siteId);
408
432
 
409
433
  const deleteRes = await app.request(`/api/internal/sites/${siteId}`, {
410
434
  method: "DELETE",
@@ -436,6 +460,19 @@ describe("Internal site admin routes", () => {
436
460
  'SELECT COUNT(*) AS count FROM "site_setting" WHERE "site_id" = ?',
437
461
  )
438
462
  .get(siteId) as { count: number };
463
+ const deletedCollectionCount = sqlite
464
+ .prepare('SELECT COUNT(*) AS count FROM "collection" WHERE "site_id" = ?')
465
+ .get(siteId) as { count: number };
466
+ const deletedSmartCollectionCount = sqlite
467
+ .prepare(
468
+ 'SELECT COUNT(*) AS count FROM "smart_collection" WHERE "site_id" = ?',
469
+ )
470
+ .get(siteId) as { count: number };
471
+ const deletedPathCount = sqlite
472
+ .prepare(
473
+ 'SELECT COUNT(*) AS count FROM "path_registry" WHERE "site_id" = ?',
474
+ )
475
+ .get(siteId) as { count: number };
439
476
  const defaultSiteCount = sqlite
440
477
  .prepare('SELECT COUNT(*) AS count FROM "site" WHERE "id" = ?')
441
478
  .get(DEFAULT_TEST_SITE_ID) as { count: number };
@@ -445,6 +482,9 @@ describe("Internal site admin routes", () => {
445
482
  expect(deletedMemberCount.count).toBe(0);
446
483
  expect(deletedPostCount.count).toBe(0);
447
484
  expect(deletedSettingsCount.count).toBe(0);
485
+ expect(deletedCollectionCount.count).toBe(0);
486
+ expect(deletedSmartCollectionCount.count).toBe(0);
487
+ expect(deletedPathCount.count).toBe(0);
448
488
  expect(defaultSiteCount.count).toBe(1);
449
489
  });
450
490
 
@@ -16,8 +16,9 @@ import {
16
16
  parseValidated,
17
17
  } from "../../lib/schemas.js";
18
18
  import { assertFound, parseIdParam, NotFoundError } from "../../lib/errors.js";
19
+ import { AddressQuerySchema, requestInternalPath } from "../../lib/address.js";
20
+ import { toPublicPath } from "../../lib/url.js";
19
21
  import { ID_PREFIX } from "../../lib/ids.js";
20
- import { getCollectionPagePath } from "../../lib/collection-paths.js";
21
22
  import { renderSiteHeaderHtml } from "../../lib/site-header-fragment.js";
22
23
 
23
24
  type Env = { Bindings: Bindings; Variables: AppVariables };
@@ -69,6 +70,51 @@ navItemsApiRoutes.get("/pages", requireAuthApi(), async (c) => {
69
70
  return c.json({ pages });
70
71
  });
71
72
 
73
+ /**
74
+ * What a pasted address could become in navigation.
75
+ *
76
+ * The author is holding a URL, not a title, so searching titles cannot answer
77
+ * them. The rules stay in the service; this only says which address was asked
78
+ * about, so the picker can name it back.
79
+ */
80
+ navItemsApiRoutes.get("/resolve", requireAuthApi(), async (c) => {
81
+ const { url } = parseValidated(AddressQuerySchema, c.req.query());
82
+ const path = requestInternalPath(c, url);
83
+ if (path === null) {
84
+ return c.json({ resolution: { kind: "external", address: url.trim() } });
85
+ }
86
+
87
+ const resolution = await c.var.services.navItems.resolveNavTarget(path);
88
+ const address = toPublicPath(path, c.var.appConfig.sitePathPrefix);
89
+
90
+ if (resolution.status === "page") {
91
+ return c.json({
92
+ resolution: { kind: "page", address, page: resolution.page },
93
+ });
94
+ }
95
+ if (resolution.status === "collection") {
96
+ return c.json({
97
+ resolution: {
98
+ kind: "collection",
99
+ address,
100
+ collection: resolution.collection,
101
+ },
102
+ });
103
+ }
104
+
105
+ if (resolution.status === "smart_collection") {
106
+ return c.json({
107
+ resolution: {
108
+ kind: "smart_collection",
109
+ address,
110
+ smartCollection: resolution.smartCollection,
111
+ },
112
+ });
113
+ }
114
+
115
+ return c.json({ resolution: { kind: resolution.status, address } });
116
+ });
117
+
72
118
  // Move nav item (requires auth) — must be before /:id
73
119
  navItemsApiRoutes.put("/:id/move", requireAuthApi(), async (c) => {
74
120
  const id = parseIdParam(c.req.param("id"), ID_PREFIX.navItem);
@@ -98,16 +144,17 @@ navItemsApiRoutes.post("/", requireAuthApi(), async (c) => {
98
144
  placement: body.placement,
99
145
  });
100
146
  } else if (body.type === "collection") {
101
- const collection = await c.var.services.collections.getById(
102
- body.collectionId,
103
- );
104
- if (!collection) throw new NotFoundError("Collection");
105
-
106
147
  item = await c.var.services.navItems.create({
107
148
  type: "collection",
108
149
  collectionId: body.collectionId,
109
- label: body.label || collection.title,
110
- url: getCollectionPagePath(collection.slug),
150
+ label: body.label,
151
+ placement: body.placement,
152
+ });
153
+ } else if (body.type === "smart_collection") {
154
+ item = await c.var.services.navItems.create({
155
+ type: "smart_collection",
156
+ smartCollectionId: body.smartCollectionId,
157
+ label: body.label,
111
158
  placement: body.placement,
112
159
  });
113
160
  } else if (body.type === "page") {
@@ -7,6 +7,7 @@ import type { Bindings } from "../../types.js";
7
7
  import type { AppVariables } from "../../types/app-context.js";
8
8
  import { z } from "zod";
9
9
  import {
10
+ ContentLanguageSchema,
10
11
  CreatePostApiSchema,
11
12
  UpdatePostApiSchema,
12
13
  FormatSchema,
@@ -16,7 +17,10 @@ import {
16
17
  } from "../../lib/schemas.js";
17
18
  import { requireAuthApi } from "../../middleware/auth.js";
18
19
  import { toApiAttachment, toApiPost } from "../../lib/api-posts.js";
20
+ import { getPostDisplayTitle } from "../../lib/post-meta.js";
19
21
  import { assertFound, NotFoundError, parseIdParam } from "../../lib/errors.js";
22
+ import { AddressQuerySchema, requestInternalPath } from "../../lib/address.js";
23
+ import { toPublicPath } from "../../lib/url.js";
20
24
  import { ID_PREFIX } from "../../lib/ids.js";
21
25
  import { triggerGitHubSyncInline } from "../../lib/github-sync-trigger.js";
22
26
 
@@ -196,6 +200,8 @@ postsApiRoutes.post("/", requireAuthApi(), async (c) => {
196
200
  collectionEntries: body.collectionEntries,
197
201
  replyToId: body.replyToId,
198
202
  quietReply: body.quietReply,
203
+ language: body.language,
204
+ translationOfId: body.translationOfId,
199
205
  publishedAt: body.publishedAt,
200
206
  },
201
207
  body.attachments,
@@ -269,6 +275,7 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
269
275
  collectionIds: body.collectionIds,
270
276
  collectionEntries: body.collectionEntries,
271
277
  publishedAt: body.publishedAt,
278
+ language: body.language,
272
279
  },
273
280
  body.attachments,
274
281
  {
@@ -313,6 +320,158 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
313
320
  );
314
321
  });
315
322
 
323
+ // =============================================================================
324
+ // Language and translations
325
+ // =============================================================================
326
+
327
+ const SetLanguageSchema = z.object({ language: ContentLanguageSchema });
328
+ const TranslationCandidatesQuerySchema = z.object({
329
+ q: z.string().trim().min(1).max(200),
330
+ limit: z.coerce.number().int().min(1).max(20).optional().default(8),
331
+ });
332
+ const LinkTranslationSchema = z.object({ postId: PostIdSchema });
333
+
334
+ /**
335
+ * Set the content language of a whole Thread.
336
+ *
337
+ * Thread-wide because `post.language` is uniform inside a Thread — see the
338
+ * service method for why every language filter depends on that.
339
+ */
340
+ postsApiRoutes.put("/:id/language", requireAuthApi(), async (c) => {
341
+ const id = parseIdParam(c.req.param("id"), ID_PREFIX.post);
342
+ const { language } = parseValidated(SetLanguageSchema, await c.req.json());
343
+
344
+ await c.var.services.posts.setThreadLanguage(id, language);
345
+ await triggerGitHubSyncInline(c);
346
+
347
+ return c.json({ success: true, language });
348
+ });
349
+
350
+ /** List the Thread roots this post is a translation of. */
351
+ postsApiRoutes.get("/:id/translations", requireAuthApi(), async (c) => {
352
+ const id = parseIdParam(c.req.param("id"), ID_PREFIX.post);
353
+ const post = assertFound(await c.var.services.posts.getById(id), "Post");
354
+ const translations = await c.var.services.posts.listTranslations(
355
+ post.threadId,
356
+ );
357
+
358
+ return c.json({
359
+ translations: translations.map((translation) => ({
360
+ id: translation.id,
361
+ slug: translation.slug,
362
+ title: translation.title,
363
+ // What to show in a list: notes are usually untitled, and a slug is not
364
+ // a name.
365
+ label: getPostDisplayTitle(translation) || translation.slug,
366
+ language: translation.language,
367
+ })),
368
+ });
369
+ });
370
+
371
+ /**
372
+ * Posts this one could be linked to as a translation.
373
+ *
374
+ * The eligibility rules live in the service; the menu only renders what comes
375
+ * back, so it never offers a post the link would refuse.
376
+ */
377
+ postsApiRoutes.get(
378
+ "/:id/translations/candidates",
379
+ requireAuthApi(),
380
+ async (c) => {
381
+ const id = parseIdParam(c.req.param("id"), ID_PREFIX.post);
382
+ const { q, limit } = parseValidated(
383
+ TranslationCandidatesQuerySchema,
384
+ c.req.query(),
385
+ );
386
+
387
+ const candidates = await c.var.services.posts.listTranslationCandidates(
388
+ id,
389
+ {
390
+ query: q,
391
+ limit,
392
+ },
393
+ );
394
+
395
+ return c.json({
396
+ candidates: candidates.map((post) => ({
397
+ id: post.id,
398
+ slug: post.slug,
399
+ title: post.title,
400
+ label: getPostDisplayTitle(post) || post.slug,
401
+ language: post.language,
402
+ })),
403
+ });
404
+ },
405
+ );
406
+
407
+ /**
408
+ * The Thread a pasted address names, and whether it can be linked to this one.
409
+ *
410
+ * Separate from the candidate search so a URL never quietly becomes search
411
+ * words: this endpoint only ever answers about one address, and answers with a
412
+ * reason when the Thread it names is not eligible.
413
+ */
414
+ postsApiRoutes.get("/:id/translations/resolve", requireAuthApi(), async (c) => {
415
+ const id = parseIdParam(c.req.param("id"), ID_PREFIX.post);
416
+ const { url } = parseValidated(AddressQuerySchema, c.req.query());
417
+
418
+ const path = requestInternalPath(c, url);
419
+ if (path === null) {
420
+ return c.json({ resolution: { kind: "external", address: url.trim() } });
421
+ }
422
+
423
+ const resolution = await c.var.services.posts.resolveTranslationCandidate(
424
+ id,
425
+ path,
426
+ );
427
+ const address = toPublicPath(path, c.var.appConfig.sitePathPrefix);
428
+
429
+ if (resolution.status === "ok") {
430
+ const post = resolution.post;
431
+ return c.json({
432
+ resolution: {
433
+ kind: "ok",
434
+ address,
435
+ candidate: {
436
+ id: post.id,
437
+ slug: post.slug,
438
+ label: getPostDisplayTitle(post) || post.slug,
439
+ language: post.language,
440
+ },
441
+ },
442
+ });
443
+ }
444
+
445
+ return c.json({
446
+ resolution: {
447
+ kind: resolution.status,
448
+ address,
449
+ ...("language" in resolution ? { language: resolution.language } : {}),
450
+ },
451
+ });
452
+ });
453
+
454
+ /** Link two already-published Threads as translations of each other. */
455
+ postsApiRoutes.post("/:id/translations", requireAuthApi(), async (c) => {
456
+ const id = parseIdParam(c.req.param("id"), ID_PREFIX.post);
457
+ const { postId } = parseValidated(LinkTranslationSchema, await c.req.json());
458
+
459
+ await c.var.services.posts.linkTranslation(id, postId);
460
+ await triggerGitHubSyncInline(c);
461
+
462
+ return c.json({ success: true });
463
+ });
464
+
465
+ /** Take this Thread out of its translation group. */
466
+ postsApiRoutes.delete("/:id/translations", requireAuthApi(), async (c) => {
467
+ const id = parseIdParam(c.req.param("id"), ID_PREFIX.post);
468
+
469
+ await c.var.services.posts.unlinkTranslation(id);
470
+ await triggerGitHubSyncInline(c);
471
+
472
+ return c.json({ success: true });
473
+ });
474
+
316
475
  // Delete post (requires auth)
317
476
  postsApiRoutes.delete("/:id", requireAuthApi(), async (c) => {
318
477
  const id = parseIdParam(c.req.param("id"), ID_PREFIX.post);