@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
package/src/db/schema.ts CHANGED
@@ -15,26 +15,37 @@ import {
15
15
  check,
16
16
  } from "drizzle-orm/sqlite-core";
17
17
  import { sql } from "drizzle-orm";
18
- import { SYSTEM_NAV_KEY_VALUES } from "../types/constants.js";
19
-
20
- const SITE_STATUSES = ["active", "suspended"] as const;
21
- const SITE_DOMAIN_KINDS = ["primary", "alias"] as const;
22
- const SITE_MEMBER_ROLES = ["owner", "admin", "editor"] as const;
23
- const FORMATS = ["note", "link", "quote"] as const;
24
- const STATUSES = ["draft", "published"] as const;
25
- const VISIBILITIES = ["public", "latest_hidden", "private"] as const;
26
- const COLLECTION_SORT_ORDERS = ["newest", "oldest", "rating_desc"] as const;
27
- const NAV_ITEM_TYPES = ["link", "system", "collection", "page"] as const;
28
- const NAV_ITEM_PLACEMENTS = ["header", "more"] as const;
29
- const UPLOAD_SESSION_STATES = [
30
- "pending",
31
- "uploaded",
32
- "completed",
33
- "aborted",
34
- "failed",
35
- ] as const;
36
- const CONTENT_DISPOSITIONS = ["inline", "attachment"] as const;
18
+ import {
19
+ ARCHIVE_LAYOUTS,
20
+ COLLECTION_DIRECTORY_ENTRY_TYPES,
21
+ COLLECTION_SORT_ORDERS,
22
+ CONTENT_DISPOSITIONS,
23
+ EARLIEST_FILTERABLE_YEAR,
24
+ FORMATS,
25
+ GITHUB_APP_ACCOUNT_TYPES,
26
+ LATEST_FILTERABLE_YEAR,
27
+ NAV_ITEM_PLACEMENTS,
28
+ NAV_ITEM_TYPES,
29
+ PATH_KINDS,
30
+ PUBLIC_ARCHIVE_VISIBILITIES,
31
+ SITE_DOMAIN_KINDS,
32
+ SITE_MEMBER_ROLES,
33
+ SITE_STATUSES,
34
+ SMART_COLLECTION_SORT_ORDERS,
35
+ STATUSES,
36
+ SYSTEM_NAV_KEY_VALUES,
37
+ UPLOAD_SESSION_STATES,
38
+ VISIBILITIES,
39
+ } from "../types/constants.js";
37
40
 
41
+ /**
42
+ * Render a value list as the `IN (...)` body of a CHECK constraint.
43
+ *
44
+ * Every list it is handed comes from `types/constants.ts`. Declaring one
45
+ * locally instead is how a CHECK once shipped missing an enum value: the
46
+ * shared list grew, the schema's private copy did not, and the generated
47
+ * constraint rejected rows the application treats as valid.
48
+ */
38
49
  function sqlTextEnum(values: readonly string[]) {
39
50
  return sql.raw(values.map((value) => `'${value}'`).join(", "));
40
51
  }
@@ -164,6 +175,27 @@ export const posts = sqliteTable(
164
175
  previewProvider: text("preview_provider"),
165
176
  replyToId: text("reply_to_id"),
166
177
  threadId: text("thread_id").notNull(),
178
+ /**
179
+ * BCP 47 content language in canonical form (`en`, `zh-Hans`).
180
+ *
181
+ * Uniform across a Thread: replies inherit the Root's value on creation and
182
+ * a language change rewrites every row in the Thread. Language filters can
183
+ * therefore be plain column predicates even on member-grained queries.
184
+ *
185
+ * NULL only exists before multilingual content is first enabled; enabling
186
+ * stamps every NULL row with the primary language.
187
+ */
188
+ language: text("language"),
189
+ /**
190
+ * Shared translation-group key (TypeID, `tgr_` prefix). Rows sharing a value
191
+ * are translations of one another — one row per language, no direction.
192
+ *
193
+ * Only ever set on Thread Roots (`thread_id = id`); enforced in the post
194
+ * service rather than by a table CHECK, because adding a CHECK to `post`
195
+ * would make drizzle-kit rebuild the table and silently drop the FTS
196
+ * triggers (`post_ai` / `post_ad` / `post_au`) that keep search current.
197
+ */
198
+ translationGroupId: text("translation_group_id"),
167
199
  /**
168
200
  * The author added this reply without announcing it. Persisted so both
169
201
  * activity timestamps below stay recomputable from the rows alone.
@@ -246,6 +278,12 @@ export const posts = sqliteTable(
246
278
  .where(
247
279
  sql`${table.status} = 'published' AND ${table.featuredAt} IS NOT NULL`,
248
280
  ),
281
+ // One Post per language inside a translation group. The `site_id` prefix
282
+ // matches the indexing convention here and lets the same index serve
283
+ // "list the members of this group" without a second index.
284
+ uniqueIndex("uq_post_site_translation_group_language")
285
+ .on(table.siteId, table.translationGroupId, table.language)
286
+ .where(sql`${table.translationGroupId} IS NOT NULL`),
249
287
  ],
250
288
  );
251
289
 
@@ -441,6 +479,108 @@ export const collections = sqliteTable(
441
479
  ],
442
480
  );
443
481
 
482
+ // =============================================================================
483
+ // Smart Collections
484
+ // =============================================================================
485
+
486
+ /**
487
+ * A collection whose members are decided by conditions rather than by tagging.
488
+ *
489
+ * Kept apart from `collection` rather than folded into it. The two share an
490
+ * identity shape — a path registry row, a title, a description — but half the
491
+ * columns of a merged table would be meaningless for half its rows, every query
492
+ * would branch, and a smart collection can filter *by* a collection, which in
493
+ * one table becomes a self-reference and a cycle to defend against. The
494
+ * resemblance belongs in a read model, not in the storage.
495
+ *
496
+ * The condition columns are declared once in `lib/filter-dimensions.ts`; this
497
+ * table is where they land. `NULL` always means "this dimension is not part of
498
+ * the conditions".
499
+ *
500
+ * Never private: `visibility` is constrained to the public archive values, so
501
+ * there is no such thing as a smart collection page that answers 404 to a
502
+ * reader. Per-post visibility still applies exactly as it does on a manual
503
+ * collection page.
504
+ *
505
+ * `collection_id` carries no foreign key, which makes it the one id column here
506
+ * that does not. None of the three actions is the right one: CASCADE would
507
+ * delete a smart collection — a page, with an address — because a collection it
508
+ * filters by went away, SET NULL would silently widen it from "Rust notes" to
509
+ * every post, and RESTRICT can only say `FOREIGN KEY constraint failed`, which
510
+ * no one should ever be shown. The refusal that belongs here is
511
+ * `smartCollections.assertCollectionUnused()`, which names the smart
512
+ * collections standing in the way; a constraint underneath it would add no
513
+ * answer, only turn every bulk delete into an ordering rule nothing enforces.
514
+ *
515
+ * What a dangling id costs is bounded: the condition compiles to an `IN` over a
516
+ * collection no `thread_collection` row carries, so the smart collection matches
517
+ * nothing. Visible and repairable by editing the conditions — not a page that
518
+ * quietly grew to the whole archive.
519
+ */
520
+ export const smartCollections = sqliteTable(
521
+ "smart_collection",
522
+ {
523
+ id: text("id").primaryKey(),
524
+ siteId: text("site_id")
525
+ .notNull()
526
+ .references(() => sites.id, { onDelete: "cascade" }),
527
+ title: text("title").notNull(),
528
+ description: text("description"),
529
+
530
+ // --- Conditions (see lib/filter-dimensions.ts) --------------------------
531
+ format: text("format", { enum: FORMATS }),
532
+ year: integer("year"),
533
+ // No foreign key, alone among this schema's id columns — see the note under
534
+ // the table on why this one is held in the service instead.
535
+ collectionId: text("collection_id"),
536
+ /** `any` | `none` | comma-joined MEDIA_KINDS. One folded vocabulary. */
537
+ media: text("media"),
538
+ hasTitle: integer("has_title", { mode: "boolean" }),
539
+ hasReplies: integer("has_replies", { mode: "boolean" }),
540
+ visibility: text("visibility", { enum: PUBLIC_ARCHIVE_VISIBILITIES }),
541
+
542
+ // --- Presentation (not conditions) --------------------------------------
543
+ sort: text("sort", { enum: SMART_COLLECTION_SORT_ORDERS })
544
+ .notNull()
545
+ .default("newest"),
546
+ /** NULL follows the site's `ARCHIVE_DEFAULT_LAYOUT`. */
547
+ layout: text("layout", { enum: ARCHIVE_LAYOUTS }),
548
+
549
+ createdAt: integer("created_at").notNull(),
550
+ updatedAt: integer("updated_at").notNull(),
551
+ },
552
+ (table) => [
553
+ check(
554
+ "chk_smart_collection_format",
555
+ sql`${table.format} IS NULL OR ${table.format} IN (${sqlTextEnum(FORMATS)})`,
556
+ ),
557
+ check(
558
+ "chk_smart_collection_visibility",
559
+ sql`${table.visibility} IS NULL OR ${table.visibility} IN (${sqlTextEnum(PUBLIC_ARCHIVE_VISIBILITIES)})`,
560
+ ),
561
+ check(
562
+ "chk_smart_collection_sort",
563
+ sql`${table.sort} IN (${sqlTextEnum(SMART_COLLECTION_SORT_ORDERS)})`,
564
+ ),
565
+ check(
566
+ "chk_smart_collection_layout",
567
+ sql`${table.layout} IS NULL OR ${table.layout} IN (${sqlTextEnum(ARCHIVE_LAYOUTS)})`,
568
+ ),
569
+ check(
570
+ "chk_smart_collection_year",
571
+ sql`${table.year} IS NULL OR ${table.year} BETWEEN ${sql.raw(String(EARLIEST_FILTERABLE_YEAR))} AND ${sql.raw(String(LATEST_FILTERABLE_YEAR))}`,
572
+ ),
573
+ index("idx_smart_collection_site_created_at").on(
574
+ table.siteId,
575
+ table.createdAt,
576
+ ),
577
+ index("idx_smart_collection_site_collection_id").on(
578
+ table.siteId,
579
+ table.collectionId,
580
+ ),
581
+ ],
582
+ );
583
+
444
584
  // =============================================================================
445
585
  // Path Registry (slug + alias + redirect)
446
586
  // =============================================================================
@@ -462,6 +602,10 @@ export const pathRegistry = sqliteTable(
462
602
  collectionId: text("collection_id").references(() => collections.id, {
463
603
  onDelete: "cascade",
464
604
  }),
605
+ smartCollectionId: text("smart_collection_id").references(
606
+ () => smartCollections.id,
607
+ { onDelete: "cascade" },
608
+ ),
465
609
  redirectToPath: text("redirect_to_path"),
466
610
  redirectType: integer("redirect_type"),
467
611
  archiveQuery: text("archive_query"),
@@ -471,7 +615,7 @@ export const pathRegistry = sqliteTable(
471
615
  (table) => [
472
616
  check(
473
617
  "chk_path_registry_kind",
474
- sql`${table.kind} IN ('slug', 'alias', 'redirect', 'archive')`,
618
+ sql`${table.kind} IN (${sqlTextEnum(PATH_KINDS)})`,
475
619
  ),
476
620
  uniqueIndex("uq_path_registry_site_path").on(table.siteId, table.path),
477
621
  uniqueIndex("uq_path_registry_site_post_slug")
@@ -480,18 +624,28 @@ export const pathRegistry = sqliteTable(
480
624
  uniqueIndex("uq_path_registry_site_collection_slug")
481
625
  .on(table.siteId, table.collectionId)
482
626
  .where(sql`${table.kind} = 'slug' AND ${table.collectionId} IS NOT NULL`),
627
+ uniqueIndex("uq_path_registry_site_smart_collection_slug")
628
+ .on(table.siteId, table.smartCollectionId)
629
+ .where(
630
+ sql`${table.kind} = 'slug' AND ${table.smartCollectionId} IS NOT NULL`,
631
+ ),
483
632
  index("idx_path_registry_site_post_id").on(table.siteId, table.postId),
484
633
  index("idx_path_registry_site_collection_id").on(
485
634
  table.siteId,
486
635
  table.collectionId,
487
636
  ),
637
+ index("idx_path_registry_site_smart_collection_id").on(
638
+ table.siteId,
639
+ table.smartCollectionId,
640
+ ),
488
641
  check(
489
642
  "chk_path_registry_shape",
490
643
  sql`(
491
644
  ${table.kind} IN ('slug', 'alias')
492
645
  AND (
493
- (${table.postId} IS NOT NULL AND ${table.collectionId} IS NULL)
494
- OR (${table.postId} IS NULL AND ${table.collectionId} IS NOT NULL)
646
+ (${table.postId} IS NOT NULL AND ${table.collectionId} IS NULL AND ${table.smartCollectionId} IS NULL)
647
+ OR (${table.postId} IS NULL AND ${table.collectionId} IS NOT NULL AND ${table.smartCollectionId} IS NULL)
648
+ OR (${table.postId} IS NULL AND ${table.collectionId} IS NULL AND ${table.smartCollectionId} IS NOT NULL)
495
649
  )
496
650
  AND ${table.redirectToPath} IS NULL
497
651
  AND ${table.redirectType} IS NULL
@@ -500,6 +654,7 @@ export const pathRegistry = sqliteTable(
500
654
  ${table.kind} = 'redirect'
501
655
  AND ${table.postId} IS NULL
502
656
  AND ${table.collectionId} IS NULL
657
+ AND ${table.smartCollectionId} IS NULL
503
658
  AND ${table.redirectToPath} IS NOT NULL
504
659
  AND ${table.redirectType} IN (301, 302)
505
660
  AND ${table.archiveQuery} IS NULL
@@ -507,6 +662,7 @@ export const pathRegistry = sqliteTable(
507
662
  ${table.kind} = 'archive'
508
663
  AND ${table.postId} IS NULL
509
664
  AND ${table.collectionId} IS NULL
665
+ AND ${table.smartCollectionId} IS NULL
510
666
  AND ${table.redirectToPath} IS NULL
511
667
  AND ${table.redirectType} IS NULL
512
668
  AND ${table.archiveQuery} IS NOT NULL
@@ -526,10 +682,16 @@ export const collectionDirectoryItems = sqliteTable(
526
682
  siteId: text("site_id")
527
683
  .notNull()
528
684
  .references(() => sites.id, { onDelete: "cascade" }),
529
- type: text("type", { enum: ["collection", "divider", "link"] }).notNull(),
685
+ type: text("type", {
686
+ enum: COLLECTION_DIRECTORY_ENTRY_TYPES,
687
+ }).notNull(),
530
688
  collectionId: text("collection_id").references(() => collections.id, {
531
689
  onDelete: "cascade",
532
690
  }),
691
+ smartCollectionId: text("smart_collection_id").references(
692
+ () => smartCollections.id,
693
+ { onDelete: "cascade" },
694
+ ),
533
695
  label: text("label"),
534
696
  url: text("url"),
535
697
  description: text("description"),
@@ -540,12 +702,16 @@ export const collectionDirectoryItems = sqliteTable(
540
702
  (table) => [
541
703
  check(
542
704
  "chk_collection_directory_item_type",
543
- sql`${table.type} IN ('collection', 'divider', 'link')`,
705
+ sql`${table.type} IN (${sqlTextEnum(COLLECTION_DIRECTORY_ENTRY_TYPES)})`,
544
706
  ),
545
707
  index("idx_collection_directory_item_site_collection_id").on(
546
708
  table.siteId,
547
709
  table.collectionId,
548
710
  ),
711
+ index("idx_collection_directory_item_site_smart_collection_id").on(
712
+ table.siteId,
713
+ table.smartCollectionId,
714
+ ),
549
715
  uniqueIndex("uq_collection_directory_item_site_position").on(
550
716
  table.siteId,
551
717
  table.position,
@@ -555,27 +721,41 @@ export const collectionDirectoryItems = sqliteTable(
555
721
  .where(
556
722
  sql`${table.type} = 'collection' AND ${table.collectionId} IS NOT NULL`,
557
723
  ),
724
+ uniqueIndex("uq_collection_directory_item_site_smart_collection_once")
725
+ .on(table.siteId, table.smartCollectionId)
726
+ .where(
727
+ sql`${table.type} = 'smart_collection' AND ${table.smartCollectionId} IS NOT NULL`,
728
+ ),
558
729
  check(
559
730
  "chk_collection_directory_item_shape",
560
731
  sql`(
561
732
  ${table.type} = 'collection'
562
733
  AND ${table.collectionId} IS NOT NULL
734
+ AND ${table.smartCollectionId} IS NULL
735
+ AND ${table.label} IS NULL
736
+ AND ${table.url} IS NULL
737
+ ) OR (
738
+ ${table.type} = 'smart_collection'
739
+ AND ${table.collectionId} IS NULL
740
+ AND ${table.smartCollectionId} IS NOT NULL
563
741
  AND ${table.label} IS NULL
564
742
  AND ${table.url} IS NULL
565
743
  ) OR (
566
744
  ${table.type} = 'divider'
567
745
  AND ${table.collectionId} IS NULL
746
+ AND ${table.smartCollectionId} IS NULL
568
747
  AND ${table.url} IS NULL
569
748
  ) OR (
570
749
  ${table.type} = 'link'
571
750
  AND ${table.collectionId} IS NULL
751
+ AND ${table.smartCollectionId} IS NULL
572
752
  AND ${table.label} IS NOT NULL
573
753
  AND ${table.url} IS NOT NULL
574
754
  )`,
575
755
  ),
576
756
  check(
577
757
  "chk_collection_directory_item_label",
578
- sql`${table.type} <> 'collection' OR ${table.label} IS NULL`,
758
+ sql`${table.type} NOT IN ('collection', 'smart_collection') OR ${table.label} IS NULL`,
579
759
  ),
580
760
  check(
581
761
  "chk_collection_directory_item_description",
@@ -641,6 +821,10 @@ export const navItems = sqliteTable(
641
821
  collectionId: text("collection_id").references(() => collections.id, {
642
822
  onDelete: "cascade",
643
823
  }),
824
+ smartCollectionId: text("smart_collection_id").references(
825
+ () => smartCollections.id,
826
+ { onDelete: "cascade" },
827
+ ),
644
828
  postId: text("post_id").references(() => posts.id, {
645
829
  onDelete: "cascade",
646
830
  }),
@@ -670,21 +854,31 @@ export const navItems = sqliteTable(
670
854
  ${table.type} = 'link'
671
855
  AND ${table.systemKey} IS NULL
672
856
  AND ${table.collectionId} IS NULL
857
+ AND ${table.smartCollectionId} IS NULL
673
858
  AND ${table.postId} IS NULL
674
859
  ) OR (
675
860
  ${table.type} = 'system'
676
861
  AND ${table.systemKey} IS NOT NULL
677
862
  AND ${table.collectionId} IS NULL
863
+ AND ${table.smartCollectionId} IS NULL
678
864
  AND ${table.postId} IS NULL
679
865
  ) OR (
680
866
  ${table.type} = 'collection'
681
867
  AND ${table.systemKey} IS NULL
682
868
  AND ${table.collectionId} IS NOT NULL
869
+ AND ${table.smartCollectionId} IS NULL
870
+ AND ${table.postId} IS NULL
871
+ ) OR (
872
+ ${table.type} = 'smart_collection'
873
+ AND ${table.systemKey} IS NULL
874
+ AND ${table.collectionId} IS NULL
875
+ AND ${table.smartCollectionId} IS NOT NULL
683
876
  AND ${table.postId} IS NULL
684
877
  ) OR (
685
878
  ${table.type} = 'page'
686
879
  AND ${table.systemKey} IS NULL
687
880
  AND ${table.collectionId} IS NULL
881
+ AND ${table.smartCollectionId} IS NULL
688
882
  AND ${table.postId} IS NOT NULL
689
883
  )`,
690
884
  ),
@@ -695,6 +889,9 @@ export const navItems = sqliteTable(
695
889
  uniqueIndex("uq_nav_item_site_collection_id")
696
890
  .on(table.siteId, table.collectionId)
697
891
  .where(sql`${table.collectionId} IS NOT NULL`),
892
+ uniqueIndex("uq_nav_item_site_smart_collection_id")
893
+ .on(table.siteId, table.smartCollectionId)
894
+ .where(sql`${table.smartCollectionId} IS NOT NULL`),
698
895
  uniqueIndex("uq_nav_item_site_post_id")
699
896
  .on(table.siteId, table.postId)
700
897
  .where(sql`${table.postId} IS NOT NULL`),
@@ -812,8 +1009,6 @@ export const verification = sqliteTable("verification", {
812
1009
  // GitHub App Installations (junction table: installation ↔ site, many-to-many)
813
1010
  // ---------------------------------------------------------------------------
814
1011
 
815
- const GITHUB_APP_ACCOUNT_TYPES = ["User", "Organization"] as const;
816
-
817
1012
  export const githubAppInstallation = sqliteTable(
818
1013
  "github_app_installation",
819
1014
  {
@@ -1,5 +1,8 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { detectLocaleFromHeader, detectCjkFontFromHeader } from "../detect.js";
2
+ import {
3
+ detectLocaleFromHeader,
4
+ getCjkFontFromLanguageTag,
5
+ } from "../detect.js";
3
6
 
4
7
  describe("detectLocaleFromHeader", () => {
5
8
  it("returns en for en", () => {
@@ -45,94 +48,36 @@ describe("detectLocaleFromHeader", () => {
45
48
  });
46
49
  });
47
50
 
48
- describe("detectCjkFontFromHeader", () => {
49
- // Simplified Chinese
50
- it("maps zh-CN to zh-Hans", () => {
51
- expect(detectCjkFontFromHeader("zh-CN")).toBe("zh-Hans");
51
+ describe("getCjkFontFromLanguageTag", () => {
52
+ it("maps Simplified Chinese tags to the zh-Hans profile", () => {
53
+ expect(getCjkFontFromLanguageTag("zh-CN")).toBe("zh-Hans");
54
+ expect(getCjkFontFromLanguageTag("zh-SG")).toBe("zh-Hans");
55
+ expect(getCjkFontFromLanguageTag("zh-Hans")).toBe("zh-Hans");
56
+ // Bare `zh` is ambiguous in principle; Simplified is the majority reading.
57
+ expect(getCjkFontFromLanguageTag("zh")).toBe("zh-Hans");
52
58
  });
53
59
 
54
- it("maps zh-SG to zh-Hans", () => {
55
- expect(detectCjkFontFromHeader("zh-SG")).toBe("zh-Hans");
60
+ it("maps Traditional Chinese tags to the zh-Hant profile", () => {
61
+ expect(getCjkFontFromLanguageTag("zh-TW")).toBe("zh-Hant");
62
+ expect(getCjkFontFromLanguageTag("zh-HK")).toBe("zh-Hant");
63
+ expect(getCjkFontFromLanguageTag("zh-MO")).toBe("zh-Hant");
64
+ expect(getCjkFontFromLanguageTag("zh-Hant")).toBe("zh-Hant");
56
65
  });
57
66
 
58
- it("maps zh-Hans to zh-Hans", () => {
59
- expect(detectCjkFontFromHeader("zh-Hans")).toBe("zh-Hans");
60
- });
61
-
62
- it("maps bare zh to zh-Hans", () => {
63
- expect(detectCjkFontFromHeader("zh")).toBe("zh-Hans");
64
- });
65
-
66
- // Traditional Chinese
67
- it("maps zh-TW to zh-Hant", () => {
68
- expect(detectCjkFontFromHeader("zh-TW")).toBe("zh-Hant");
69
- });
70
-
71
- it("maps zh-HK to zh-Hant", () => {
72
- expect(detectCjkFontFromHeader("zh-HK")).toBe("zh-Hant");
73
- });
74
-
75
- it("maps zh-MO to zh-Hant", () => {
76
- expect(detectCjkFontFromHeader("zh-MO")).toBe("zh-Hant");
77
- });
78
-
79
- it("maps zh-Hant to zh-Hant", () => {
80
- expect(detectCjkFontFromHeader("zh-Hant")).toBe("zh-Hant");
81
- });
82
-
83
- // Japanese
84
- it("maps ja to ja", () => {
85
- expect(detectCjkFontFromHeader("ja")).toBe("ja");
86
- });
87
-
88
- it("maps ja-JP to ja", () => {
89
- expect(detectCjkFontFromHeader("ja-JP")).toBe("ja");
90
- });
91
-
92
- // Korean
93
- it("maps ko to ko", () => {
94
- expect(detectCjkFontFromHeader("ko")).toBe("ko");
95
- });
96
-
97
- it("maps ko-KR to ko", () => {
98
- expect(detectCjkFontFromHeader("ko-KR")).toBe("ko");
99
- });
100
-
101
- // q-value priority
102
- it("picks highest q-value CJK language", () => {
103
- expect(detectCjkFontFromHeader("fr;q=0.5,zh-CN;q=0.9,en;q=0.8")).toBe(
104
- "zh-Hans",
105
- );
106
- });
107
-
108
- it("picks first CJK when q-values are equal", () => {
109
- expect(detectCjkFontFromHeader("zh-TW,en")).toBe("zh-Hant");
110
- });
111
-
112
- // Non-CJK fallback
113
- it("returns off for non-CJK language", () => {
114
- expect(detectCjkFontFromHeader("en-US")).toBe("off");
115
- });
116
-
117
- it("returns off for undefined", () => {
118
- expect(detectCjkFontFromHeader(undefined)).toBe("off");
119
- });
120
-
121
- it("returns off for empty string", () => {
122
- expect(detectCjkFontFromHeader("")).toBe("off");
123
- });
124
-
125
- it("skips entries with q=0", () => {
126
- expect(detectCjkFontFromHeader("zh-CN;q=0,en")).toBe("off");
67
+ it("maps Japanese and Korean tags", () => {
68
+ expect(getCjkFontFromLanguageTag("ja")).toBe("ja");
69
+ expect(getCjkFontFromLanguageTag("ja-JP")).toBe("ja");
70
+ expect(getCjkFontFromLanguageTag("ko")).toBe("ko");
71
+ expect(getCjkFontFromLanguageTag("ko-KR")).toBe("ko");
127
72
  });
128
73
 
129
74
  it("is case-insensitive", () => {
130
- expect(detectCjkFontFromHeader("ZH-CN")).toBe("zh-Hans");
75
+ expect(getCjkFontFromLanguageTag("ZH-CN")).toBe("zh-Hans");
131
76
  });
132
77
 
133
- it("handles realistic browser header", () => {
134
- expect(detectCjkFontFromHeader("zh-TW,zh;q=0.9,en-US;q=0.8,en;q=0.7")).toBe(
135
- "zh-Hant",
136
- );
78
+ it("returns nothing for languages with no CJK profile", () => {
79
+ expect(getCjkFontFromLanguageTag("en-US")).toBeUndefined();
80
+ expect(getCjkFontFromLanguageTag("")).toBeUndefined();
81
+ expect(getCjkFontFromLanguageTag(" ")).toBeUndefined();
137
82
  });
138
83
  });
@@ -0,0 +1,83 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ formatLanguageList,
4
+ parseLanguageList,
5
+ toLanguagePrefix,
6
+ } from "../locales.js";
7
+ import { resolveSupportedLocaleTag } from "../supported-locales.js";
8
+
9
+ describe("resolveSupportedLocaleTag", () => {
10
+ it("matches a curated tag exactly", () => {
11
+ expect(resolveSupportedLocaleTag("ja")).toBe("ja");
12
+ expect(resolveSupportedLocaleTag("pt-BR")).toBe("pt-BR");
13
+ });
14
+
15
+ it("is case-insensitive", () => {
16
+ expect(resolveSupportedLocaleTag("PT-br")).toBe("pt-BR");
17
+ });
18
+
19
+ it("resolves a region tag to the right Chinese script", () => {
20
+ // The curated list offers zh by script, not by region, so this has to work
21
+ // or a Chinese browser lands on English.
22
+ expect(resolveSupportedLocaleTag("zh-CN")).toBe("zh-Hans");
23
+ expect(resolveSupportedLocaleTag("zh-SG")).toBe("zh-Hans");
24
+ expect(resolveSupportedLocaleTag("zh-TW")).toBe("zh-Hant");
25
+ expect(resolveSupportedLocaleTag("zh-HK")).toBe("zh-Hant");
26
+ });
27
+
28
+ it("falls back to the bare language when the region is unknown", () => {
29
+ expect(resolveSupportedLocaleTag("de-AT")).toBe("de");
30
+ });
31
+
32
+ it("respects q-values", () => {
33
+ expect(resolveSupportedLocaleTag("fr;q=0.5,ja;q=0.9,en;q=0.8")).toBe("ja");
34
+ });
35
+
36
+ it("skips entries the client excluded", () => {
37
+ expect(resolveSupportedLocaleTag("ja;q=0,fr")).toBe("fr");
38
+ });
39
+
40
+ it("falls back to English for anything unrecognized", () => {
41
+ expect(resolveSupportedLocaleTag("*")).toBe("en");
42
+ expect(resolveSupportedLocaleTag("")).toBe("en");
43
+ expect(resolveSupportedLocaleTag(undefined)).toBe("en");
44
+ expect(resolveSupportedLocaleTag("not a locale!!!")).toBe("en");
45
+ });
46
+ });
47
+
48
+ describe("language list settings value", () => {
49
+ it("round-trips canonical tags in order", () => {
50
+ expect(formatLanguageList(parseLanguageList("en,ja"))).toBe("en,ja");
51
+ });
52
+
53
+ it("canonicalizes case and trims whitespace", () => {
54
+ expect(parseLanguageList(" en , ZH-hant ")).toEqual(["en", "zh-Hant"]);
55
+ });
56
+
57
+ it("drops duplicates while keeping the first position", () => {
58
+ expect(parseLanguageList("ja,en,JA")).toEqual(["ja", "en"]);
59
+ });
60
+
61
+ it("drops blank and unparseable entries", () => {
62
+ expect(parseLanguageList("en,,not a locale!!!,ja")).toEqual(["en", "ja"]);
63
+ });
64
+
65
+ it("treats missing values as an empty list", () => {
66
+ expect(parseLanguageList(null)).toEqual([]);
67
+ expect(parseLanguageList(undefined)).toEqual([]);
68
+ expect(parseLanguageList("")).toEqual([]);
69
+ });
70
+ });
71
+
72
+ describe("toLanguagePrefix", () => {
73
+ it("lowercases the canonical tag", () => {
74
+ expect(toLanguagePrefix("zh-Hant")).toBe("zh-hant");
75
+ expect(toLanguagePrefix("en")).toBe("en");
76
+ });
77
+
78
+ it("maps distinct languages to distinct prefixes", () => {
79
+ const tags = ["en", "zh-Hans", "zh-Hant", "ja", "pt-BR"];
80
+ const prefixes = tags.map(toLanguagePrefix);
81
+ expect(new Set(prefixes).size).toBe(tags.length);
82
+ });
83
+ });
@@ -1,9 +1,15 @@
1
1
  /**
2
- * Request-scoped i18n access for Hono JSX renders.
2
+ * Request-scoped render state for Hono JSX renders.
3
+ *
4
+ * Primarily the i18n instance, which is what components reach for. It also
5
+ * binds the viewer (`lib/viewer-context.ts`), because this provider is the one
6
+ * wrapper every server render already passes through — pages via `BaseLayout`,
7
+ * fragments via the partial routes — and both facts come from the same request.
3
8
  */
4
9
 
5
10
  import type { Context } from "hono";
6
11
  import type { FC, PropsWithChildren } from "hono/jsx";
12
+ import { bindViewer } from "../lib/viewer-context.js";
7
13
  import type { I18n } from "./i18n.js";
8
14
  import { getI18n as getI18nFromContext } from "./i18n.js";
9
15
 
@@ -11,7 +17,8 @@ import { getI18n as getI18nFromContext } from "./i18n.js";
11
17
  let currentI18n: I18n | null = null;
12
18
 
13
19
  /**
14
- * I18nProvider - binds the current request's i18n instance for this render.
20
+ * I18nProvider - binds this render's request-scoped state: the i18n instance
21
+ * and the viewer.
15
22
  *
16
23
  * @example
17
24
  * ```tsx
@@ -33,6 +40,9 @@ export const I18nProvider: FC<I18nProviderProps> = ({ c, children }) => {
33
40
  // Note: In Hono JSX, rendering is synchronous and single-threaded per request
34
41
  // so we can safely set global context without cleanup
35
42
  currentI18n = getI18nFromContext(c);
43
+ // The same flag `BaseLayout` writes into `data-authenticated`, so what a card
44
+ // renders and what the CSS reveals can never disagree.
45
+ bindViewer(c.get("isAuthenticated") === true);
36
46
  return <>{children}</>;
37
47
  };
38
48