@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
@@ -0,0 +1,413 @@
1
+ /**
2
+ * View language resolution.
3
+ *
4
+ * These are the rules the language routes are built on: which first path
5
+ * segment counts as a language, what the primary language's own prefix means,
6
+ * and how links stay inside the view they were rendered in.
7
+ */
8
+
9
+ import { describe, expect, it } from "vitest";
10
+ import type { Context } from "hono";
11
+ import type { AppConfig } from "../../types/config.js";
12
+ import {
13
+ buildLanguageSwitcher,
14
+ buildSurfaceAlternates,
15
+ getViewLang,
16
+ getViewLanguages,
17
+ isPerLanguageSurface,
18
+ isPrefixedLanguageView,
19
+ languageScopeBasePath,
20
+ languageUrlPrefixes,
21
+ resolveLanguageView,
22
+ toLanguagePath,
23
+ toViewPath,
24
+ viewBasePath,
25
+ viewRelativePath,
26
+ } from "../view-language.js";
27
+
28
+ interface FakeContextOptions {
29
+ path?: string;
30
+ query?: string;
31
+ viewLang?: string;
32
+ siteLanguage?: string;
33
+ additionalLanguages?: string[];
34
+ multilingualEnabled?: boolean;
35
+ sitePathPrefix?: string;
36
+ siteUrl?: string;
37
+ }
38
+
39
+ /** Minimal stand-in for the request context these helpers actually read. */
40
+ function fakeContext(options: FakeContextOptions = {}) {
41
+ const {
42
+ path = "/",
43
+ query = "",
44
+ viewLang,
45
+ siteLanguage = "zh-Hans",
46
+ additionalLanguages = ["en"],
47
+ multilingualEnabled = true,
48
+ sitePathPrefix = "",
49
+ siteUrl = "https://example.com",
50
+ } = options;
51
+
52
+ const appConfig = {
53
+ siteLanguage,
54
+ additionalLanguages,
55
+ multilingualEnabled,
56
+ sitePathPrefix,
57
+ siteUrl,
58
+ } as unknown as AppConfig;
59
+
60
+ return {
61
+ req: { path, url: `https://example.com${path}${query}` },
62
+ var: { appConfig, viewLang },
63
+ } as unknown as Parameters<typeof resolveLanguageView>[0];
64
+ }
65
+
66
+ describe("resolveLanguageView", () => {
67
+ it("serves a configured language prefix as a view", () => {
68
+ const c = fakeContext({ path: "/en/archive" });
69
+
70
+ expect(resolveLanguageView(c)).toEqual({ kind: "view", lang: "en" });
71
+ });
72
+
73
+ it("matches the prefix case-insensitively", () => {
74
+ const c = fakeContext({
75
+ path: "/zh-hant",
76
+ additionalLanguages: ["zh-Hant"],
77
+ });
78
+
79
+ expect(resolveLanguageView(c)).toEqual({ kind: "view", lang: "zh-Hant" });
80
+ });
81
+
82
+ it("passes a segment that is not a configured language", () => {
83
+ expect(resolveLanguageView(fakeContext({ path: "/fr" }))).toEqual({
84
+ kind: "pass",
85
+ });
86
+ expect(resolveLanguageView(fakeContext({ path: "/hello" }))).toEqual({
87
+ kind: "pass",
88
+ });
89
+ });
90
+
91
+ it("passes everything on a site with no second language", () => {
92
+ // Without this, a post at /en on a single-language site would 404.
93
+ const c = fakeContext({ path: "/en", additionalLanguages: [] });
94
+
95
+ expect(resolveLanguageView(c)).toEqual({ kind: "pass" });
96
+ });
97
+
98
+ it("ignores the primary language repeated in the additional list", () => {
99
+ const c = fakeContext({
100
+ path: "/zh-hans",
101
+ additionalLanguages: ["zh-Hans"],
102
+ });
103
+
104
+ expect(resolveLanguageView(c)).toEqual({ kind: "pass" });
105
+ });
106
+
107
+ describe("the primary language's own prefix", () => {
108
+ it("redirects to the root it aliases", () => {
109
+ const c = fakeContext({ path: "/zh-hans" });
110
+
111
+ expect(resolveLanguageView(c)).toEqual({ kind: "redirect", to: "/" });
112
+ });
113
+
114
+ it("strips the prefix from sub-paths, keeping the query", () => {
115
+ const c = fakeContext({ path: "/zh-hans/archive", query: "?year=2024" });
116
+
117
+ expect(resolveLanguageView(c)).toEqual({
118
+ kind: "redirect",
119
+ to: "/archive?year=2024",
120
+ });
121
+ });
122
+
123
+ it("keeps the deployment path prefix", () => {
124
+ const c = fakeContext({
125
+ path: "/zh-hans/archive",
126
+ sitePathPrefix: "/blog",
127
+ });
128
+
129
+ expect(resolveLanguageView(c)).toEqual({
130
+ kind: "redirect",
131
+ to: "/blog/archive",
132
+ });
133
+ });
134
+ });
135
+
136
+ describe("after multilingual is switched off", () => {
137
+ it("redirects a previously configured prefix", () => {
138
+ // The configuration is retained so old links and feed subscriptions
139
+ // keep working rather than 404ing.
140
+ const c = fakeContext({ path: "/en/feed", multilingualEnabled: false });
141
+
142
+ expect(resolveLanguageView(c)).toEqual({ kind: "redirect", to: "/feed" });
143
+ });
144
+ });
145
+ });
146
+
147
+ describe("languageUrlPrefixes", () => {
148
+ it("lists every prefix a path can arrive under, primary included", () => {
149
+ // The primary language has no view of its own, but `/zh-hans/hello`
150
+ // redirects to `/hello` — so it still names the same page.
151
+ expect(
152
+ languageUrlPrefixes({
153
+ siteLanguage: "zh-Hans",
154
+ additionalLanguages: ["en", "ja"],
155
+ }),
156
+ ).toEqual(["zh-hans", "en", "ja"]);
157
+ });
158
+
159
+ it("is empty on a site with one language", () => {
160
+ // Nothing serves a view there, so `/en/hello` is just a post's address.
161
+ expect(
162
+ languageUrlPrefixes({
163
+ siteLanguage: "en",
164
+ additionalLanguages: [],
165
+ }),
166
+ ).toEqual([]);
167
+ expect(
168
+ languageUrlPrefixes({
169
+ siteLanguage: "en",
170
+ additionalLanguages: ["en"],
171
+ }),
172
+ ).toEqual([]);
173
+ });
174
+ });
175
+
176
+ describe("getViewLang", () => {
177
+ it("returns the prefix language inside a language view", () => {
178
+ expect(getViewLang(fakeContext({ viewLang: "en" }))).toBe("en");
179
+ });
180
+
181
+ it("returns the primary language at the root of a multilingual site", () => {
182
+ // The root is the primary language's view — otherwise `/` would show every
183
+ // language while `/en` showed one.
184
+ expect(getViewLang(fakeContext())).toBe("zh-Hans");
185
+ });
186
+
187
+ it("returns null on a single-language site", () => {
188
+ expect(getViewLang(fakeContext({ multilingualEnabled: false }))).toBeNull();
189
+ expect(getViewLang(fakeContext({ additionalLanguages: [] }))).toBeNull();
190
+ });
191
+ });
192
+
193
+ describe("isPrefixedLanguageView", () => {
194
+ it("is true only under a language prefix", () => {
195
+ expect(isPrefixedLanguageView(fakeContext({ viewLang: "en" }))).toBe(true);
196
+ expect(isPrefixedLanguageView(fakeContext())).toBe(false);
197
+ });
198
+ });
199
+
200
+ describe("path helpers", () => {
201
+ it("prefixes in-view links and leaves root links alone", () => {
202
+ const inView = fakeContext({ viewLang: "en" });
203
+ const atRoot = fakeContext();
204
+
205
+ expect(toViewPath(inView, "/")).toBe("/en");
206
+ expect(toViewPath(inView, "/archive")).toBe("/en/archive");
207
+ expect(toViewPath(atRoot, "/")).toBe("/");
208
+ expect(toViewPath(atRoot, "/archive")).toBe("/archive");
209
+ });
210
+
211
+ it("composes with the deployment path prefix", () => {
212
+ const c = fakeContext({ viewLang: "en", sitePathPrefix: "/blog" });
213
+
214
+ expect(toViewPath(c, "/")).toBe("/blog/en");
215
+ expect(toViewPath(c, "/archive")).toBe("/blog/en/archive");
216
+ expect(viewBasePath(c)).toBe("/en");
217
+ });
218
+
219
+ it("strips the prefix when resolving against site content", () => {
220
+ expect(
221
+ viewRelativePath(fakeContext({ path: "/en/hello", viewLang: "en" })),
222
+ ).toBe("/hello");
223
+ expect(viewRelativePath(fakeContext({ path: "/en", viewLang: "en" }))).toBe(
224
+ "/",
225
+ );
226
+ expect(viewRelativePath(fakeContext({ path: "/hello" }))).toBe("/hello");
227
+ });
228
+
229
+ it("builds paths into another language's view", () => {
230
+ const c = fakeContext({ viewLang: "en" });
231
+
232
+ expect(toLanguagePath(c, "zh-Hans", "/archive")).toBe("/archive");
233
+ expect(toLanguagePath(c, "en", "/archive")).toBe("/en/archive");
234
+ expect(toLanguagePath(c, null, "/")).toBe("/");
235
+ });
236
+ });
237
+
238
+ describe("getViewLanguages", () => {
239
+ it("lists the primary language first", () => {
240
+ expect(
241
+ getViewLanguages(fakeContext({ additionalLanguages: ["en", "ja"] })),
242
+ ).toEqual(["zh-Hans", "en", "ja"]);
243
+ });
244
+
245
+ it("is empty when the site serves one language", () => {
246
+ expect(
247
+ getViewLanguages(fakeContext({ multilingualEnabled: false })),
248
+ ).toEqual([]);
249
+ expect(getViewLanguages(fakeContext({ additionalLanguages: [] }))).toEqual(
250
+ [],
251
+ );
252
+ });
253
+ });
254
+
255
+ describe("languageScopeBasePath", () => {
256
+ it("scopes an active non-primary language to its prefix", () => {
257
+ expect(languageScopeBasePath(fakeContext(), "en")).toBe("/en");
258
+ });
259
+
260
+ it("keeps the primary language at the root", () => {
261
+ expect(languageScopeBasePath(fakeContext(), "zh-Hans")).toBe("");
262
+ });
263
+
264
+ it("stays at the root without a language", () => {
265
+ expect(languageScopeBasePath(fakeContext(), null)).toBe("");
266
+ });
267
+
268
+ it("stays at the root while multilingual is off", () => {
269
+ // A post keeps its language column after the feature is switched off,
270
+ // but there is no /en view to scope its chrome to any more.
271
+ expect(
272
+ languageScopeBasePath(fakeContext({ multilingualEnabled: false }), "en"),
273
+ ).toBe("");
274
+ });
275
+
276
+ it("ignores a language the site no longer publishes", () => {
277
+ expect(languageScopeBasePath(fakeContext(), "ja")).toBe("");
278
+ });
279
+ });
280
+
281
+ describe("buildSurfaceAlternates", () => {
282
+ it("points at the same surface in every language", () => {
283
+ const c = fakeContext({ path: "/en/archive", viewLang: "en" });
284
+
285
+ expect(buildSurfaceAlternates(c)).toEqual([
286
+ { hreflang: "zh-Hans", href: "https://example.com/archive" },
287
+ { hreflang: "en", href: "https://example.com/en/archive" },
288
+ { hreflang: "x-default", href: "https://example.com/archive" },
289
+ ]);
290
+ });
291
+
292
+ it("keeps the query string, so a filtered view alternates correctly", () => {
293
+ const c = fakeContext({ path: "/archive", query: "?year=2024" });
294
+
295
+ expect(buildSurfaceAlternates(c)[1]).toEqual({
296
+ hreflang: "en",
297
+ href: "https://example.com/en/archive?year=2024",
298
+ });
299
+ });
300
+
301
+ it("is empty without an absolute site URL, which hreflang requires", () => {
302
+ expect(buildSurfaceAlternates(fakeContext({ siteUrl: "" }))).toEqual([]);
303
+ });
304
+
305
+ it("is empty on a single-language site", () => {
306
+ expect(
307
+ buildSurfaceAlternates(fakeContext({ multilingualEnabled: false })),
308
+ ).toEqual([]);
309
+ });
310
+ });
311
+
312
+ describe("buildLanguageSwitcher", () => {
313
+ it("offers the current surface in each language", () => {
314
+ const c = fakeContext({ path: "/en/archive", viewLang: "en" });
315
+
316
+ expect(buildLanguageSwitcher(c)).toEqual([
317
+ {
318
+ lang: "zh-Hans",
319
+ label: "简体中文",
320
+ href: "/archive",
321
+ isCurrent: false,
322
+ isPrimary: true,
323
+ },
324
+ {
325
+ lang: "en",
326
+ label: "English",
327
+ href: "/en/archive",
328
+ isCurrent: true,
329
+ isPrimary: false,
330
+ },
331
+ ]);
332
+ });
333
+
334
+ it("takes an explicit destination per language", () => {
335
+ // A post links to its translation, not to its own path in another view.
336
+ const c = fakeContext({ path: "/my-post" });
337
+
338
+ expect(
339
+ buildLanguageSwitcher(c, {
340
+ hrefByLanguage: new Map([["en", "/the-translation"]]),
341
+ fallbackPath: "/",
342
+ currentLang: "zh-Hans",
343
+ }),
344
+ ).toEqual([
345
+ {
346
+ lang: "zh-Hans",
347
+ label: "简体中文",
348
+ href: "/",
349
+ isCurrent: true,
350
+ isPrimary: true,
351
+ },
352
+ {
353
+ lang: "en",
354
+ label: "English",
355
+ href: "/the-translation",
356
+ isCurrent: false,
357
+ isPrimary: false,
358
+ },
359
+ ]);
360
+ });
361
+
362
+ it("falls back to a language's home when there is no translation", () => {
363
+ const c = fakeContext({ path: "/my-post" });
364
+
365
+ const options = buildLanguageSwitcher(c, { fallbackPath: "/" });
366
+ expect(options.map((option) => option.href)).toEqual(["/", "/en"]);
367
+ });
368
+
369
+ it("is empty on a single-language site", () => {
370
+ expect(
371
+ buildLanguageSwitcher(fakeContext({ additionalLanguages: [] })),
372
+ ).toEqual([]);
373
+ });
374
+
375
+ it("aims at each language's home from a page no language view serves", () => {
376
+ // /ja/settings/language would be a 404 — the switcher must never mint one.
377
+ const c = fakeContext({ path: "/settings/language" });
378
+
379
+ expect(buildLanguageSwitcher(c).map((option) => option.href)).toEqual([
380
+ "/",
381
+ "/en",
382
+ ]);
383
+ });
384
+ });
385
+
386
+ describe("isPerLanguageSurface", () => {
387
+ it("recognizes the surfaces the language routes serve", () => {
388
+ expect(isPerLanguageSurface("/")).toBe(true);
389
+ expect(isPerLanguageSurface("/archive")).toBe(true);
390
+ expect(isPerLanguageSurface("/collections/reading")).toBe(true);
391
+ });
392
+
393
+ it("rejects everything without a per-language counterpart", () => {
394
+ expect(isPerLanguageSurface("/settings")).toBe(false);
395
+ expect(isPerLanguageSurface("/settings/language")).toBe(false);
396
+ expect(isPerLanguageSurface("/my-post")).toBe(false);
397
+ });
398
+
399
+ it("counts a collection selection and its feed, and nothing deeper", () => {
400
+ // `langGet()` serves /collections/:selection and its feed, and nothing
401
+ // else under /collections.
402
+ expect(isPerLanguageSurface("/collections/a+b")).toBe(true);
403
+ expect(isPerLanguageSurface("/collections/a+b/feed")).toBe(true);
404
+ expect(isPerLanguageSurface("/collections/a+b/feed/atom")).toBe(false);
405
+ expect(isPerLanguageSurface("/collections/reading/edit")).toBe(false);
406
+ });
407
+
408
+ it("ignores a query string, so a built link can be passed as-is", () => {
409
+ expect(isPerLanguageSurface("/archive?media=any")).toBe(true);
410
+ expect(isPerLanguageSurface("/search?q=hello")).toBe(true);
411
+ expect(isPerLanguageSurface("/settings?tab=general")).toBe(false);
412
+ });
413
+ });
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import type { Bindings } from "../../types.js";
3
3
  import { POST_BODY_HTML_VERSION } from "../post-body-html.js";
4
+ import { withConditionalResponse } from "../http-cache.js";
4
5
  import {
5
6
  normalizeWorkerCacheKeyUrl,
6
7
  withWorkerResponseCache,
@@ -326,3 +327,92 @@ describe("withWorkerResponseCache", () => {
326
327
  expect(nextCallCount).toBe(1);
327
328
  });
328
329
  });
330
+
331
+ /**
332
+ * Revalidation composed with the cache, the way `createApp()` composes them:
333
+ * conditional handling on the outside, so it sees a cache hit and a cache miss
334
+ * alike.
335
+ */
336
+ describe("withWorkerResponseCache under conditional requests", () => {
337
+ const FEED_URL = "https://example.com/feed";
338
+ const ETAG = '"feed-v1"';
339
+
340
+ function feedResponse() {
341
+ return new Response("<feed/>", {
342
+ headers: {
343
+ "Cache-Control": "public, max-age=60",
344
+ "Content-Type": "application/atom+xml; charset=utf-8",
345
+ ETag: ETAG,
346
+ },
347
+ });
348
+ }
349
+
350
+ async function poll(
351
+ cache: ReturnType<typeof createMemoryCache>,
352
+ ifNoneMatch: string | undefined,
353
+ next: () => Promise<Response>,
354
+ ) {
355
+ const request = new Request(
356
+ FEED_URL,
357
+ ifNoneMatch ? { headers: { "If-None-Match": ifNoneMatch } } : undefined,
358
+ );
359
+
360
+ return withConditionalResponse(
361
+ request,
362
+ await withWorkerResponseCache({
363
+ bindings: createCloudflareBindings(),
364
+ cache,
365
+ request,
366
+ next,
367
+ }),
368
+ );
369
+ }
370
+
371
+ it("stores the full document even when the poll that filled the cache got a 304", async () => {
372
+ const cache = createMemoryCache();
373
+ let renders = 0;
374
+ const render = async () => {
375
+ renders += 1;
376
+ return feedResponse();
377
+ };
378
+
379
+ // A reader that already holds this version, arriving on a cache miss.
380
+ const conditional = await poll(cache, ETAG, render);
381
+ expect(conditional.status).toBe(304);
382
+ expect(await conditional.text()).toBe("");
383
+ expect(renders).toBe(1);
384
+
385
+ // The next reader gets the document from the cache, not another render.
386
+ const unconditional = await poll(cache, undefined, render);
387
+ expect(unconditional.status).toBe(200);
388
+ expect(await unconditional.text()).toBe("<feed/>");
389
+ expect(renders).toBe(1);
390
+ });
391
+
392
+ it("revalidates a cache hit without reaching a route", async () => {
393
+ const cache = createMemoryCache();
394
+ let renders = 0;
395
+ const render = async () => {
396
+ renders += 1;
397
+ return feedResponse();
398
+ };
399
+
400
+ await poll(cache, undefined, render);
401
+ const revalidated = await poll(cache, ETAG, render);
402
+
403
+ expect(revalidated.status).toBe(304);
404
+ expect(revalidated.headers.get("ETag")).toBe(ETAG);
405
+ expect(revalidated.headers.get("Cache-Control")).toBe("public, max-age=60");
406
+ expect(renders).toBe(1);
407
+ });
408
+
409
+ it("serves the document again once the cached version no longer matches", async () => {
410
+ const cache = createMemoryCache();
411
+
412
+ await poll(cache, undefined, async () => feedResponse());
413
+ const stale = await poll(cache, '"feed-v0"', async () => feedResponse());
414
+
415
+ expect(stale.status).toBe(200);
416
+ expect(await stale.text()).toBe("<feed/>");
417
+ });
418
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Addresses the author pasted.
3
+ *
4
+ * Pickers ask "which page?" and the author often answers with a URL rather
5
+ * than a title — they have the page open in another tab. Turning that answer
6
+ * into something the path registry can resolve is the same job everywhere, and
7
+ * it is fiddly enough (site prefix, language prefix, which hosts are ours) to
8
+ * be worth doing in one place.
9
+ */
10
+
11
+ import type { Context } from "hono";
12
+ import { z } from "zod";
13
+ import type { Bindings } from "../types/bindings.js";
14
+ import type { AppVariables } from "../types/app-context.js";
15
+ import { toInternalPath } from "./url.js";
16
+ import { languageUrlPrefixes } from "./view-language.js";
17
+
18
+ type AddressContext = Context<{ Bindings: Bindings; Variables: AppVariables }>;
19
+
20
+ /** Query shape of every "what is at this address?" endpoint. */
21
+ export const AddressQuerySchema = z.object({
22
+ url: z.string().trim().min(1).max(2048),
23
+ });
24
+
25
+ /**
26
+ * Convert an address into the internal path this site resolves it against.
27
+ *
28
+ * Accepts a path or a full URL on any host the site answers on — the
29
+ * configured one *and* the host of the current request, because a hosted site
30
+ * is routinely reached on a domain its canonical URL knows nothing about.
31
+ *
32
+ * @param c - Request context, after `withConfig()`
33
+ * @param input - What the author typed or pasted
34
+ * @returns Internal path rooted at `/`, or `null` when the address is not ours
35
+ * @example
36
+ * requestInternalPath(c, "https://example.com/en/about"); // "/about"
37
+ * requestInternalPath(c, "https://elsewhere.com/about"); // null
38
+ */
39
+ export function requestInternalPath(
40
+ c: AddressContext,
41
+ input: string,
42
+ ): string | null {
43
+ const { siteOrigin, sitePathPrefix } = c.var.appConfig;
44
+
45
+ let requestOrigin = "";
46
+ try {
47
+ requestOrigin = new URL(c.req.url).origin;
48
+ } catch {
49
+ // A request URL that will not parse is not one we can match against.
50
+ }
51
+
52
+ return toInternalPath(input, {
53
+ siteOrigins: [siteOrigin, requestOrigin].filter(Boolean),
54
+ sitePathPrefix,
55
+ languagePrefixes: languageUrlPrefixes(c.var.appConfig),
56
+ });
57
+ }
@@ -1,5 +1,6 @@
1
1
  import { isTextAttachment } from "../services/media.js";
2
2
  import type { Media, Post } from "../types.js";
3
+ import { getPostDisplayTitle } from "./post-meta.js";
3
4
  import { getImageUrl, getMediaUrl, getPublicUrlForProvider } from "./image.js";
4
5
  import { toPublicPath } from "./url.js";
5
6
 
@@ -7,6 +8,12 @@ export type ApiPostResponse = Omit<Post, "title" | "url"> & {
7
8
  attachments?: ReturnType<typeof toApiAttachment>[];
8
9
  collectionIds?: string[];
9
10
  title?: string | null;
11
+ /**
12
+ * Short plain-text name for this Post, derived when it has no title of its
13
+ * own. Use it wherever the Post is referenced from somewhere else; a slug is
14
+ * a URL, not a name.
15
+ */
16
+ displayTitle: string;
10
17
  url?: string | null;
11
18
  sourceName?: string | null;
12
19
  sourceUrl?: string | null;
@@ -82,11 +89,15 @@ export function toApiPost(
82
89
  } = {},
83
90
  ): ApiPostResponse {
84
91
  const { title, url, ...rest } = post;
92
+ // A short name for this Post wherever it is referenced from somewhere else —
93
+ // notes are usually untitled, so the client cannot just read `title`.
94
+ const displayTitle = getPostDisplayTitle(post);
85
95
 
86
96
  if (post.format === "quote") {
87
97
  return {
88
98
  ...rest,
89
99
  ...extras,
100
+ displayTitle,
90
101
  sourceName: title ?? null,
91
102
  sourceUrl: url ?? null,
92
103
  };
@@ -95,6 +106,7 @@ export function toApiPost(
95
106
  return {
96
107
  ...rest,
97
108
  ...extras,
109
+ displayTitle,
98
110
  title: title ?? null,
99
111
  url: url ?? null,
100
112
  };