@jant/core 0.6.15 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (537) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/import-site.js +67 -4
  4. package/bin/commands/reset-password.js +21 -8
  5. package/bin/commands/site/snapshot/export.js +1 -1
  6. package/bin/commands/site/snapshot/import.js +1 -1
  7. package/bin/lib/d1-query.js +5 -16
  8. package/bin/lib/node-env.js +40 -4
  9. package/bin/lib/site-selection.js +85 -29
  10. package/bin/lib/wrangler-cli.js +28 -2
  11. package/bin/lib/wrangler-json.js +35 -0
  12. package/dist/{app-C-oi_t8W.js → app-K_Aa1MMn.js} +52623 -13193
  13. package/dist/client/.vite/manifest.json +255 -90
  14. package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
  15. package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
  16. package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
  17. package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
  18. package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
  19. package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
  20. package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
  21. package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
  22. package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
  23. package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
  24. package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
  25. package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
  26. package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
  27. package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
  28. package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
  29. package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
  30. package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
  31. package/dist/client/_assets/client-GiYENVw8.css +2 -0
  32. package/dist/client/_assets/client-VnFxJN7G.js +295 -0
  33. package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
  34. package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
  35. package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
  36. package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
  37. package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
  38. package/dist/{export-CsFx6F_M.js → github-sync-BPAvT999.js} +2343 -110
  39. package/dist/index.js +2 -5
  40. package/dist/node.js +5 -7
  41. package/package.json +3 -3
  42. package/src/__tests__/auth-session-freshness.test.ts +57 -0
  43. package/src/__tests__/basecoat-variants.test.ts +121 -0
  44. package/src/__tests__/bin/assets-upload.test.ts +68 -0
  45. package/src/__tests__/export-hugo-build.test.ts +260 -0
  46. package/src/__tests__/export-service.test.ts +231 -0
  47. package/src/__tests__/helpers/component-css.ts +91 -0
  48. package/src/__tests__/helpers/db.ts +33 -0
  49. package/src/__tests__/helpers/export-fixtures.ts +3 -0
  50. package/src/__tests__/mise-config.test.ts +22 -0
  51. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  52. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  53. package/src/app.tsx +71 -16
  54. package/src/auth.ts +18 -1
  55. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  56. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  57. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  58. package/src/client/__tests__/compose-bridge.test.ts +524 -2
  59. package/src/client/__tests__/compose-launch.test.ts +62 -1
  60. package/src/client/__tests__/compose-shortcuts.test.ts +57 -8
  61. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  62. package/src/client/__tests__/copy-field.test.ts +162 -0
  63. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  64. package/src/client/__tests__/image-processor.test.ts +324 -2
  65. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  66. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  67. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  68. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  69. package/src/client/__tests__/toast.test.ts +88 -1
  70. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  71. package/src/client/__tests__/video-processor.test.ts +181 -0
  72. package/src/client/audio-processor.ts +24 -15
  73. package/src/client/audio-waveform.ts +49 -0
  74. package/src/client/collection-dialog-host.ts +72 -0
  75. package/src/client/collection-navigation.ts +51 -16
  76. package/src/client/collection-page-actions.ts +26 -0
  77. package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
  78. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  79. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  80. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  81. package/src/client/components/__tests__/jant-command-palette.test.ts +79 -0
  82. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  83. package/src/client/components/__tests__/jant-compose-dialog.test.ts +3283 -389
  84. package/src/client/components/__tests__/jant-compose-editor.test.ts +581 -86
  85. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +34 -89
  86. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  87. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  88. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  89. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  90. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  91. package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
  92. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  93. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  94. package/src/client/components/collection-dialog-types.ts +49 -0
  95. package/src/client/components/collection-manager-types.ts +32 -1
  96. package/src/client/components/collection-types.ts +1 -15
  97. package/src/client/components/compose-format-convert.ts +5 -1
  98. package/src/client/components/compose-types.ts +121 -3
  99. package/src/client/components/jant-collection-dialog.ts +722 -0
  100. package/src/client/components/jant-collection-directory.ts +567 -182
  101. package/src/client/components/jant-collection-form.ts +53 -232
  102. package/src/client/components/jant-command-palette.ts +106 -49
  103. package/src/client/components/jant-compose-dialog.ts +2757 -1194
  104. package/src/client/components/jant-compose-editor.ts +866 -397
  105. package/src/client/components/jant-compose-fullscreen.ts +53 -47
  106. package/src/client/components/jant-config-editor.ts +65 -53
  107. package/src/client/components/jant-confirm-dialog.ts +13 -11
  108. package/src/client/components/jant-locale-picker.ts +418 -0
  109. package/src/client/components/jant-media-lightbox.ts +146 -136
  110. package/src/client/components/jant-nav-manager.ts +690 -366
  111. package/src/client/components/jant-post-menu.ts +1142 -268
  112. package/src/client/components/jant-post-picker.ts +324 -0
  113. package/src/client/components/jant-repo-picker.ts +138 -106
  114. package/src/client/components/jant-settings-avatar.ts +31 -27
  115. package/src/client/components/jant-settings-general.ts +617 -543
  116. package/src/client/components/jant-settings-language.ts +1087 -0
  117. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  118. package/src/client/components/jant-text-preview.ts +94 -86
  119. package/src/client/components/nav-manager-types.ts +62 -1
  120. package/src/client/components/settings-types.ts +14 -29
  121. package/src/client/components/smart-collection-conditions.ts +285 -0
  122. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  123. package/src/client/compose-bridge.ts +336 -242
  124. package/src/client/compose-launch.ts +89 -2
  125. package/src/client/compose-shortcuts.ts +9 -7
  126. package/src/client/compose-triggers.ts +50 -0
  127. package/src/client/copy-field.ts +108 -0
  128. package/src/client/custom-url-menu.ts +60 -4
  129. package/src/client/icons.ts +14 -0
  130. package/src/client/image-plan.ts +165 -0
  131. package/src/client/image-processor.ts +195 -117
  132. package/src/client/image-worker-client.ts +45 -0
  133. package/src/client/image-worker.ts +135 -0
  134. package/src/client/lazy-entries.ts +95 -0
  135. package/src/client/lazy-slugify.ts +2 -2
  136. package/src/client/media-scroll-hint.ts +43 -2
  137. package/src/client/mediabunny.ts +29 -0
  138. package/src/client/post-picker.ts +41 -0
  139. package/src/client/post-refresh.ts +135 -0
  140. package/src/client/runtime-paths.ts +74 -1
  141. package/src/client/settings-bridge.ts +4 -10
  142. package/src/client/site-header-nav.js +18 -6
  143. package/src/client/smart-collection-dialog-host.ts +116 -0
  144. package/src/client/smart-collection-page-actions.ts +208 -0
  145. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  146. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  147. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  148. package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
  149. package/src/client/tiptap/bubble-menu.ts +47 -16
  150. package/src/client/tiptap/floating-position.ts +216 -2
  151. package/src/client/tiptap/inline-image-upload.ts +7 -3
  152. package/src/client/tiptap/link-input-rules.ts +4 -4
  153. package/src/client/tiptap/link-toolbar.ts +22 -9
  154. package/src/client/toast.ts +143 -38
  155. package/src/client/types/vite-worker.d.ts +12 -0
  156. package/src/client/upload-with-metadata.ts +3 -13
  157. package/src/client/video-processor.ts +351 -107
  158. package/src/client-auth.ts +14 -31
  159. package/src/client-compose.ts +19 -0
  160. package/src/client-manage.ts +13 -0
  161. package/src/client-settings.ts +14 -0
  162. package/src/client-site.ts +10 -3
  163. package/src/client.ts +4 -0
  164. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  165. package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
  166. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  167. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  168. package/src/db/__tests__/thread-activity.test.ts +113 -0
  169. package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
  170. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  171. package/src/db/migrations/0031_many_ego.sql +3 -0
  172. package/src/db/migrations/0032_married_tomas.sql +3 -0
  173. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  174. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  175. package/src/db/migrations/meta/0031_snapshot.json +2562 -0
  176. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  177. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  178. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  179. package/src/db/migrations/meta/_journal.json +28 -0
  180. package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
  181. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  182. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  183. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  184. package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
  185. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  186. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  187. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  188. package/src/db/migrations/pg/meta/_journal.json +28 -0
  189. package/src/db/pg/schema.ts +213 -27
  190. package/src/db/post-visibility.ts +117 -0
  191. package/src/db/schema.ts +242 -27
  192. package/src/db/thread-activity.ts +101 -0
  193. package/src/i18n/__tests__/detect.test.ts +26 -81
  194. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  195. package/src/i18n/context.tsx +12 -2
  196. package/src/i18n/detect.ts +7 -66
  197. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  198. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  199. package/src/i18n/locales/public/en.po +568 -168
  200. package/src/i18n/locales/public/en.ts +1 -1
  201. package/src/i18n/locales/public/zh-Hans.po +562 -162
  202. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  203. package/src/i18n/locales/public/zh-Hant.po +562 -162
  204. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  205. package/src/i18n/locales/settings/en.po +551 -69
  206. package/src/i18n/locales/settings/en.ts +1 -1
  207. package/src/i18n/locales/settings/zh-Hans.po +597 -115
  208. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  209. package/src/i18n/locales/settings/zh-Hant.po +610 -128
  210. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  211. package/src/i18n/locales.ts +94 -0
  212. package/src/i18n/middleware.ts +12 -2
  213. package/src/i18n/supported-locales.ts +80 -0
  214. package/src/lib/__tests__/api-settings.test.ts +11 -2
  215. package/src/lib/__tests__/asset-path.test.ts +92 -0
  216. package/src/lib/__tests__/deferred.test.ts +90 -0
  217. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  218. package/src/lib/__tests__/discover.test.ts +410 -0
  219. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  220. package/src/lib/__tests__/feed.test.ts +1691 -12
  221. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  222. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  223. package/src/lib/__tests__/http-cache.test.ts +162 -0
  224. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  225. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  226. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  227. package/src/lib/__tests__/markdown.test.ts +16 -0
  228. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  229. package/src/lib/__tests__/navigation.test.ts +135 -1
  230. package/src/lib/__tests__/resolve-config.test.ts +64 -3
  231. package/src/lib/__tests__/schemas.test.ts +42 -4
  232. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  233. package/src/lib/__tests__/slug.test.ts +88 -0
  234. package/src/lib/__tests__/slugify.test.ts +85 -0
  235. package/src/lib/__tests__/storage.test.ts +32 -0
  236. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  237. package/src/lib/__tests__/translit.test.ts +87 -0
  238. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  239. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  240. package/src/lib/__tests__/url.test.ts +93 -34
  241. package/src/lib/__tests__/view-language.test.ts +413 -0
  242. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  243. package/src/lib/address.ts +57 -0
  244. package/src/lib/api-posts.ts +12 -0
  245. package/src/lib/asset-path.ts +98 -1
  246. package/src/lib/build-env.ts +16 -0
  247. package/src/lib/collection-groups.ts +5 -1
  248. package/src/lib/collection-paths.ts +3 -10
  249. package/src/lib/constants.ts +28 -3
  250. package/src/lib/copy-field.ts +36 -0
  251. package/src/lib/crypto.ts +40 -0
  252. package/src/lib/csp-builder.ts +2 -1
  253. package/src/lib/deferred.ts +79 -0
  254. package/src/lib/discover-ping.ts +141 -0
  255. package/src/lib/discover.ts +437 -0
  256. package/src/lib/env.ts +134 -0
  257. package/src/lib/errors.ts +33 -0
  258. package/src/lib/feed-policy.ts +236 -2
  259. package/src/lib/feed.ts +1021 -66
  260. package/src/lib/filter-dimensions.ts +1349 -0
  261. package/src/lib/github-sync-site-config.ts +3 -0
  262. package/src/lib/hosted-signin.ts +1 -1
  263. package/src/lib/http-cache.ts +148 -0
  264. package/src/lib/hugo-markdown.ts +21 -0
  265. package/src/lib/icons.ts +9 -2
  266. package/src/lib/ids.ts +3 -0
  267. package/src/lib/jant-docs.ts +42 -0
  268. package/src/lib/lang-detect.ts +308 -0
  269. package/src/lib/link-preview.ts +25 -0
  270. package/src/lib/markdown-manager.ts +6 -1
  271. package/src/lib/media-helpers.ts +13 -1
  272. package/src/lib/navigation.ts +78 -11
  273. package/src/lib/pagination.ts +28 -24
  274. package/src/lib/per-language-surfaces.ts +67 -0
  275. package/src/lib/post-display.ts +42 -9
  276. package/src/lib/post-meta.ts +19 -0
  277. package/src/lib/render.tsx +55 -7
  278. package/src/lib/resolve-config.ts +53 -5
  279. package/src/lib/schemas.ts +201 -10
  280. package/src/lib/site-header-fragment.tsx +2 -1
  281. package/src/lib/site-unavailable-page.ts +47 -0
  282. package/src/lib/slug.ts +51 -7
  283. package/src/lib/slugify.ts +113 -0
  284. package/src/lib/startup-config.ts +4 -6
  285. package/src/lib/storage.ts +7 -0
  286. package/src/lib/summary.ts +50 -0
  287. package/src/lib/thread-fold.ts +161 -0
  288. package/src/lib/time.ts +32 -0
  289. package/src/lib/timeline.ts +81 -10
  290. package/src/lib/tiptap-render.ts +5 -4
  291. package/src/lib/translit.ts +288 -0
  292. package/src/lib/upload.ts +16 -13
  293. package/src/lib/url.ts +188 -24
  294. package/src/lib/version.ts +10 -8
  295. package/src/lib/view-language.ts +436 -0
  296. package/src/lib/view.ts +79 -23
  297. package/src/lib/viewer-context.ts +62 -0
  298. package/src/middleware/__tests__/auth.test.ts +70 -2
  299. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  300. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  301. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  302. package/src/middleware/__tests__/session.test.ts +181 -33
  303. package/src/middleware/auth.ts +50 -31
  304. package/src/middleware/cache-control.ts +75 -10
  305. package/src/middleware/config.ts +6 -6
  306. package/src/middleware/error-handler.ts +12 -0
  307. package/src/middleware/onboarding.ts +32 -10
  308. package/src/middleware/secure-headers.ts +1 -1
  309. package/src/middleware/session.ts +96 -8
  310. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  311. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  312. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  313. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  314. package/src/node/__tests__/runtime.test.ts +37 -0
  315. package/src/node/index.ts +1 -0
  316. package/src/preset.css +76 -22
  317. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  318. package/src/routes/api/__tests__/collections.test.ts +114 -0
  319. package/src/routes/api/__tests__/discover.test.ts +222 -0
  320. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  321. package/src/routes/api/__tests__/palette.test.ts +44 -0
  322. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  323. package/src/routes/api/__tests__/settings.test.ts +24 -3
  324. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  325. package/src/routes/api/collections.ts +49 -6
  326. package/src/routes/api/custom-urls.ts +0 -1
  327. package/src/routes/api/discover.ts +95 -0
  328. package/src/routes/api/export.ts +3 -0
  329. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  330. package/src/routes/api/nav-items.ts +55 -8
  331. package/src/routes/api/posts.ts +172 -9
  332. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  333. package/src/routes/api/public/__tests__/posts.test.ts +38 -0
  334. package/src/routes/api/public/archive.ts +77 -81
  335. package/src/routes/api/public/posts.ts +30 -6
  336. package/src/routes/api/smart-collections.ts +132 -0
  337. package/src/routes/api/telegram.ts +2 -40
  338. package/src/routes/api/uploads.ts +1 -2
  339. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  340. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  341. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  342. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  343. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  344. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  345. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  346. package/src/routes/auth/setup.tsx +934 -186
  347. package/src/routes/auth/signin.tsx +10 -8
  348. package/src/routes/compose.tsx +11 -2
  349. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  350. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  351. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  352. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  353. package/src/routes/dash/custom-urls.tsx +1 -37
  354. package/src/routes/dash/settings.tsx +502 -41
  355. package/src/routes/discover-announce.ts +96 -0
  356. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  357. package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
  358. package/src/routes/feed/feed.ts +69 -40
  359. package/src/routes/feed/sitemap.ts +95 -11
  360. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  361. package/src/routes/pages/__tests__/archive-params.test.ts +657 -1
  362. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  363. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  364. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  365. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  366. package/src/routes/pages/__tests__/featured.test.ts +6 -2
  367. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  368. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  369. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  370. package/src/routes/pages/__tests__/preview.test.ts +3 -1
  371. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  372. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  373. package/src/routes/pages/archive.tsx +507 -357
  374. package/src/routes/pages/collection.tsx +94 -96
  375. package/src/routes/pages/collections.tsx +38 -50
  376. package/src/routes/pages/featured.tsx +32 -13
  377. package/src/routes/pages/home.tsx +25 -6
  378. package/src/routes/pages/language.tsx +113 -0
  379. package/src/routes/pages/latest.tsx +26 -16
  380. package/src/routes/pages/new.tsx +3 -0
  381. package/src/routes/pages/page.tsx +183 -19
  382. package/src/routes/pages/partials.tsx +41 -0
  383. package/src/routes/pages/search.tsx +18 -3
  384. package/src/routes/pages/smart-collection.tsx +340 -0
  385. package/src/routes/pages/subscribe.tsx +182 -0
  386. package/src/runtime/__tests__/index.test.ts +19 -0
  387. package/src/runtime/__tests__/node.test.ts +129 -4
  388. package/src/runtime/cloudflare.ts +21 -2
  389. package/src/runtime/index.ts +27 -1
  390. package/src/runtime/node.ts +18 -1
  391. package/src/runtime/site.ts +95 -24
  392. package/src/services/__tests__/auth.test.ts +116 -7
  393. package/src/services/__tests__/collection.test.ts +139 -4
  394. package/src/services/__tests__/language.test.ts +495 -0
  395. package/src/services/__tests__/navigation.test.ts +213 -10
  396. package/src/services/__tests__/path.test.ts +180 -0
  397. package/src/services/__tests__/post-language.test.ts +830 -0
  398. package/src/services/__tests__/post-timeline.test.ts +142 -11
  399. package/src/services/__tests__/post.test.ts +323 -9
  400. package/src/services/__tests__/settings.test.ts +228 -17
  401. package/src/services/__tests__/site-admin.test.ts +5 -5
  402. package/src/services/__tests__/smart-collection.test.ts +651 -0
  403. package/src/services/about-page.ts +22 -1
  404. package/src/services/auth.ts +51 -49
  405. package/src/services/bootstrap.ts +188 -38
  406. package/src/services/collection-directory-position.ts +76 -0
  407. package/src/services/collection.ts +330 -47
  408. package/src/services/custom-url.ts +27 -22
  409. package/src/services/export-theme/assets/client-site.css +1 -1
  410. package/src/services/export-theme/assets/client-site.js +123 -123
  411. package/src/services/export-theme/layouts/_default/rss.xml +77 -1
  412. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  413. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  414. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  415. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  416. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  417. package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
  418. package/src/services/export-theme/styles/main.css +13 -1
  419. package/src/services/export.ts +281 -24
  420. package/src/services/index.ts +34 -3
  421. package/src/services/language.ts +412 -0
  422. package/src/services/mcp.ts +3 -1
  423. package/src/services/media.ts +14 -6
  424. package/src/services/navigation.ts +429 -67
  425. package/src/services/path.ts +225 -7
  426. package/src/services/post.ts +1318 -252
  427. package/src/services/search.ts +48 -2
  428. package/src/services/settings.ts +238 -43
  429. package/src/services/site-admin.ts +24 -7
  430. package/src/services/site.ts +77 -1
  431. package/src/services/smart-collection.ts +636 -0
  432. package/src/services/telegram.ts +20 -22
  433. package/src/services/upload-session.ts +1 -3
  434. package/src/style-author.css +27 -0
  435. package/src/styles/components-author.css +1622 -0
  436. package/src/styles/components.css +24 -1585
  437. package/src/styles/site-media.css +20 -6
  438. package/src/styles/tokens.css +144 -13
  439. package/src/styles/ui-author.css +7602 -0
  440. package/src/styles/ui.css +1224 -7276
  441. package/src/types/app-context.ts +7 -0
  442. package/src/types/bindings.ts +3 -0
  443. package/src/types/config.ts +94 -15
  444. package/src/types/constants.ts +188 -16
  445. package/src/types/entities.ts +92 -1
  446. package/src/types/operations.ts +64 -2
  447. package/src/types/props.ts +168 -30
  448. package/src/types/views.ts +61 -1
  449. package/src/ui/__tests__/font-themes.test.ts +52 -12
  450. package/src/ui/compose/ComposeDialog.tsx +215 -21
  451. package/src/ui/compose/ComposePrompt.tsx +1 -5
  452. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  453. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  454. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  455. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  456. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  457. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  458. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  459. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  460. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  461. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  462. package/src/ui/dash/settings/GeneralContent.tsx +275 -113
  463. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  464. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  465. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  466. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  467. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  468. package/src/ui/dash/settings/discover-copy.ts +116 -0
  469. package/src/ui/feed/LinkCard.tsx +55 -45
  470. package/src/ui/feed/LinkPreview.tsx +2 -7
  471. package/src/ui/feed/NoteCard.tsx +9 -4
  472. package/src/ui/feed/PostStatusBadges.tsx +103 -17
  473. package/src/ui/feed/QuoteCard.tsx +55 -30
  474. package/src/ui/feed/ThreadPreview.tsx +6 -2
  475. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  476. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  477. package/src/ui/feed/thread-preview-state.ts +20 -6
  478. package/src/ui/font-themes.ts +39 -29
  479. package/src/ui/layouts/BaseLayout.tsx +155 -10
  480. package/src/ui/layouts/SiteLayout.tsx +192 -34
  481. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  482. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  483. package/src/ui/pages/ArchivePage.tsx +401 -301
  484. package/src/ui/pages/CollectionPage.tsx +86 -76
  485. package/src/ui/pages/CollectionsPage.tsx +5 -0
  486. package/src/ui/pages/ComposePage.tsx +8 -0
  487. package/src/ui/pages/PostPage.tsx +47 -0
  488. package/src/ui/pages/SearchPage.tsx +3 -3
  489. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  490. package/src/ui/pages/SubscribePage.tsx +236 -0
  491. package/src/ui/pages/__tests__/ArchivePage.test.tsx +122 -3
  492. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  493. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  494. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  495. package/src/ui/shared/CollectionsManager.tsx +70 -17
  496. package/src/ui/shared/CopyField.tsx +127 -0
  497. package/src/ui/shared/DraftPreviewBar.tsx +25 -9
  498. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  499. package/src/ui/shared/MediaGallery.tsx +125 -46
  500. package/src/ui/shared/Pagination.tsx +20 -5
  501. package/src/ui/shared/PostFooter.tsx +55 -23
  502. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  503. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  504. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  505. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  506. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  507. package/src/ui/shared/archive-labels.ts +98 -0
  508. package/src/ui/shared/collection-management-labels.ts +94 -16
  509. package/src/ui/shared/custom-icons.ts +5 -0
  510. package/src/ui/shared/navigation-labels.ts +45 -71
  511. package/src/ui/shared/post-article-attributes.ts +8 -0
  512. package/src/ui/shared/smart-collection-labels.ts +343 -0
  513. package/dist/app-BxCOR3Uc.js +0 -6
  514. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  515. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  516. package/dist/client/_assets/chunks/url-pLre2DM_.js +0 -1
  517. package/dist/client/_assets/client-B_eGKN5p.css +0 -2
  518. package/dist/client/_assets/client-DxY5BFe8.js +0 -296
  519. package/dist/client/_assets/client-auth-CakPESv9.js +0 -5533
  520. package/dist/env-OHRKGcMj.js +0 -281
  521. package/dist/github-api-BgSiE71w.js +0 -176
  522. package/dist/github-app-BbklkFmU.js +0 -275
  523. package/dist/github-sync-Cq1pzzOI.js +0 -431
  524. package/dist/github-sync-ppWXN3jb.js +0 -4
  525. package/dist/url-BMYO-Zlt.js +0 -395
  526. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  527. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  528. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  529. package/src/client/collection-created-notice.ts +0 -66
  530. package/src/client/collection-form-bridge.ts +0 -191
  531. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  532. package/src/client/components/jant-post-form.ts +0 -422
  533. package/src/client/components/post-form-template.ts +0 -352
  534. package/src/client/components/post-form-types.ts +0 -96
  535. package/src/client/media-metadata.ts +0 -247
  536. package/src/client/post-form-bridge.ts +0 -193
  537. package/src/ui/pages/CollectionEditorPage.tsx +0 -175
@@ -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
  };