@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
@@ -95,6 +95,24 @@ describe("generatePostSlug", () => {
95
95
  }),
96
96
  ).rejects.toThrow("Could not generate a unique slug");
97
97
  });
98
+
99
+ it("romanizes Korean titles", async () => {
100
+ const slug = await generatePostSlug({
101
+ title: "개발 일지",
102
+ idLength: 5,
103
+ isAvailable: alwaysAvailable,
104
+ });
105
+ expect(slug).toBe("gaebal-ilji");
106
+ });
107
+
108
+ it("falls back to random IDs for kanji-heavy Japanese titles", async () => {
109
+ const slug = await generatePostSlug({
110
+ title: "日本語のタイトルです",
111
+ idLength: 5,
112
+ isAvailable: alwaysAvailable,
113
+ });
114
+ expect(slug).toMatch(/^[a-z0-9]{5}-[a-z0-9]{5}$/);
115
+ });
98
116
  });
99
117
 
100
118
  describe("random slug (no title, no slug)", () => {
@@ -123,4 +141,74 @@ describe("generatePostSlug", () => {
123
141
  ).rejects.toThrow("Could not generate a unique slug");
124
142
  });
125
143
  });
144
+
145
+ describe("language suffix", () => {
146
+ /** Every slug is taken except the ones listed. */
147
+ const onlyAvailable = (...free: string[]) => {
148
+ const allowed = new Set(free);
149
+ return async (slug: string) => allowed.has(slug);
150
+ };
151
+
152
+ it("is not used when the base slug is free", async () => {
153
+ const slug = await generatePostSlug({
154
+ title: "Book Review",
155
+ idLength: 5,
156
+ languageSuffix: "en",
157
+ isAvailable: alwaysAvailable,
158
+ });
159
+ expect(slug).toBe("book-review");
160
+ });
161
+
162
+ it("is tried before a random suffix when the base collides", async () => {
163
+ const slug = await generatePostSlug({
164
+ title: "Book Review",
165
+ idLength: 5,
166
+ languageSuffix: "en",
167
+ isAvailable: onlyAvailable("book-review-en"),
168
+ });
169
+ expect(slug).toBe("book-review-en");
170
+ });
171
+
172
+ it("keeps the multi-part tag intact", async () => {
173
+ const slug = await generatePostSlug({
174
+ title: "Book Review",
175
+ idLength: 5,
176
+ languageSuffix: "zh-hant",
177
+ isAvailable: onlyAvailable("book-review-zh-hant"),
178
+ });
179
+ expect(slug).toBe("book-review-zh-hant");
180
+ });
181
+
182
+ it("falls back to a random suffix when the language slug is taken too", async () => {
183
+ const slug = await generatePostSlug({
184
+ title: "Book Review",
185
+ idLength: 5,
186
+ languageSuffix: "en",
187
+ isAvailable: async (candidate: string) =>
188
+ candidate !== "book-review" && candidate !== "book-review-en",
189
+ });
190
+ expect(slug).toMatch(/^book-review-[a-z0-9]{5}$/);
191
+ });
192
+
193
+ it("goes straight to a random suffix without one", async () => {
194
+ const slug = await generatePostSlug({
195
+ title: "Book Review",
196
+ idLength: 5,
197
+ isAvailable: async (candidate: string) => candidate !== "book-review",
198
+ });
199
+ expect(slug).toMatch(/^book-review-[a-z0-9]{5}$/);
200
+ });
201
+
202
+ it("skips a language candidate that lands on a reserved path", async () => {
203
+ const slug = await generatePostSlug({
204
+ title: "feed",
205
+ idLength: 5,
206
+ languageSuffix: "en",
207
+ isAvailable: alwaysAvailable,
208
+ });
209
+ // "feed" is reserved, so the base is never offered and the language
210
+ // candidate is never reached — the random fallback owns this case.
211
+ expect(slug).toMatch(/^feed-[a-z0-9]{5}$/);
212
+ });
213
+ });
126
214
  });
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { slugify } from "../slugify.js";
3
+
4
+ describe("slugify", () => {
5
+ it("converts text to lowercase hyphenated slug", () => {
6
+ expect(slugify("Hello World")).toBe("hello-world");
7
+ });
8
+
9
+ it("removes special characters", () => {
10
+ expect(slugify("Hello World! This is a Test.")).toBe(
11
+ "hello-world-this-is-a-test",
12
+ );
13
+ });
14
+
15
+ it("collapses multiple spaces", () => {
16
+ expect(slugify("Multiple Spaces")).toBe("multiple-spaces");
17
+ });
18
+
19
+ it("trims leading/trailing whitespace and hyphens", () => {
20
+ expect(slugify(" Multiple Spaces ")).toBe("multiple-spaces");
21
+ });
22
+
23
+ it("replaces underscores with hyphens", () => {
24
+ expect(slugify("hello_world")).toBe("hello-world");
25
+ });
26
+
27
+ it("handles already-slugified text", () => {
28
+ expect(slugify("already-a-slug")).toBe("already-a-slug");
29
+ });
30
+
31
+ it("handles empty string", () => {
32
+ expect(slugify("")).toBe("");
33
+ });
34
+
35
+ it("transliterates accented characters", () => {
36
+ expect(slugify("café & résumé")).toBe("cafe-and-resume");
37
+ });
38
+
39
+ it("converts Chinese characters to pinyin", () => {
40
+ expect(slugify("书评")).toBe("shu-ping");
41
+ });
42
+
43
+ it("handles mixed Chinese and English text", () => {
44
+ expect(slugify("我的 Blog")).toBe("wo-de-blog");
45
+ });
46
+
47
+ it("handles CJK characters with spaces", () => {
48
+ expect(slugify("电影 评论")).toBe("dian-ying-ping-lun");
49
+ });
50
+
51
+ it("romanizes Korean titles", () => {
52
+ expect(slugify("안녕하세요 세계")).toBe("annyeonghaseyo-segye");
53
+ expect(slugify("개발 일지 3일차")).toBe("gaebal-ilji-3ilcha");
54
+ });
55
+
56
+ it("romanizes kana-only Japanese titles", () => {
57
+ expect(slugify("カタカナタイトル")).toBe("katakanataitoru");
58
+ expect(slugify("ハンバーガー")).toBe("hanbaga");
59
+ });
60
+
61
+ it("keeps Latin words in mostly-kana Japanese titles", () => {
62
+ expect(slugify("React入門ガイド")).toBe("react-gaido");
63
+ });
64
+
65
+ it("returns empty for kanji-heavy Japanese titles instead of garbling them", () => {
66
+ // Kanji carries the meaning; without a reading dictionary any output
67
+ // would be gibberish, so callers should fall back to a random ID.
68
+ expect(slugify("日本語のタイトルです")).toBe("");
69
+ expect(slugify("東京タワーに行った")).toBe("");
70
+ expect(slugify("こんにちは世界")).toBe("");
71
+ });
72
+
73
+ it("returns empty for scripts with no usable transliteration", () => {
74
+ expect(slugify("שלום עולם")).toBe("");
75
+ expect(slugify("สวัสดีชาวโลก")).toBe("");
76
+ });
77
+
78
+ it("returns empty when most of the title is untransliterable", () => {
79
+ expect(slugify("שלום world")).toBe("");
80
+ });
81
+
82
+ it("returns empty for emoji-only titles", () => {
83
+ expect(slugify("🎉🎉")).toBe("");
84
+ });
85
+ });
@@ -210,6 +210,38 @@ describe("createS3Driver", () => {
210
210
  "x-amz-checksum-sha256": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
211
211
  });
212
212
  });
213
+
214
+ it("omits a request checksum when the caller did not supply one", async () => {
215
+ const driver = createS3Driver({
216
+ endpoint: "https://s3.example.com",
217
+ bucket: "jant-media",
218
+ accessKeyId: "access-key",
219
+ secretAccessKey: "secret-key",
220
+ region: "auto",
221
+ });
222
+
223
+ const presigned = await driver.presignPut?.("media/test.webp", {
224
+ contentType: "image/webp",
225
+ contentDisposition: "inline",
226
+ cacheControl: "public, max-age=31536000, immutable",
227
+ expiresInSeconds: 900,
228
+ });
229
+
230
+ if (!presigned) {
231
+ throw new Error("Expected presigned PUT target");
232
+ }
233
+
234
+ // Presigning runs with an empty body, so a default request checksum would
235
+ // pin CRC32-of-nothing to the URL and every real upload would 400.
236
+ const params = new URL(presigned.url).searchParams;
237
+ expect(params.has("x-amz-checksum-crc32")).toBe(false);
238
+ expect(params.has("x-amz-sdk-checksum-algorithm")).toBe(false);
239
+ expect(presigned.headers).toEqual({
240
+ "Content-Type": "image/webp",
241
+ "Content-Disposition": "inline",
242
+ "Cache-Control": "public, max-age=31536000, immutable",
243
+ });
244
+ });
213
245
  });
214
246
 
215
247
  describe("createR2Driver", () => {
@@ -0,0 +1,113 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ foldThreadReplies,
4
+ getThreadHiddenCount,
5
+ THREAD_LEADING_REPLIES,
6
+ THREAD_TRAILING_REPLIES,
7
+ } from "../thread-fold.js";
8
+
9
+ const reply = (n: number) => ({ id: `reply-${n}` });
10
+ const replies = (count: number) =>
11
+ Array.from({ length: count }, (_unused, index) => reply(index + 1));
12
+
13
+ const ids = (list: { id: string }[]) => list.map((item) => item.id);
14
+
15
+ describe("foldThreadReplies", () => {
16
+ it("has no fold for a thread with no replies", () => {
17
+ expect(foldThreadReplies([])).toBeNull();
18
+ });
19
+
20
+ // The window is the first two plus the last three, so anything up to six
21
+ // posts arrives whole and there is nothing for a gap link to stand for.
22
+ it.each([1, 2, 3, 4, 5])("hides nothing with %i replies", (count) => {
23
+ const fold = foldThreadReplies(replies(count));
24
+
25
+ expect(fold?.hiddenCount).toBe(0);
26
+ expect(fold?.firstHiddenReply).toBeNull();
27
+ // Every reply is shown exactly once, the hero included.
28
+ const shown = [
29
+ ...ids(fold?.leadingReplies ?? []),
30
+ ...ids(fold?.trailingReplies ?? []),
31
+ fold?.latestReply.id,
32
+ ];
33
+ expect(new Set(shown).size).toBe(shown.length);
34
+ expect(shown.length).toBe(count);
35
+ });
36
+
37
+ // The shortest thread puts one reply in every role at once. It must still
38
+ // be rendered once, not three times.
39
+ it("shows a lone reply once, as the hero", () => {
40
+ const fold = foldThreadReplies(replies(1));
41
+
42
+ expect(fold?.latestReply.id).toBe("reply-1");
43
+ expect(fold?.leadingReplies).toEqual([]);
44
+ expect(fold?.trailingReplies).toEqual([]);
45
+ });
46
+
47
+ it("folds the middle out of a long thread", () => {
48
+ const fold = foldThreadReplies(replies(7));
49
+
50
+ expect(ids(fold?.leadingReplies ?? [])).toEqual(["reply-1", "reply-2"]);
51
+ expect(ids(fold?.trailingReplies ?? [])).toEqual(["reply-5", "reply-6"]);
52
+ expect(fold?.latestReply.id).toBe("reply-7");
53
+ expect(fold?.hiddenCount).toBe(2);
54
+ });
55
+
56
+ // The gap stands for a run of posts and opens the first of them, so a reader
57
+ // following it lands where the missing stretch starts.
58
+ it("points the gap at the earliest post it hides", () => {
59
+ expect(foldThreadReplies(replies(20))?.firstHiddenReply?.id).toBe(
60
+ "reply-3",
61
+ );
62
+ });
63
+
64
+ it("keeps the window fixed however long the thread runs", () => {
65
+ const fold = foldThreadReplies(replies(50));
66
+ const shown =
67
+ (fold?.leadingReplies.length ?? 0) +
68
+ (fold?.trailingReplies.length ?? 0) +
69
+ 1;
70
+
71
+ expect(shown).toBe(THREAD_LEADING_REPLIES + THREAD_TRAILING_REPLIES);
72
+ expect(fold?.hiddenCount).toBe(50 - shown);
73
+ });
74
+ });
75
+
76
+ describe("getThreadHiddenCount", () => {
77
+ // The site selects its buckets in SQL and they overlap on short threads, so
78
+ // the count has to dedupe rather than subtract two lengths.
79
+ it("counts a reply in two buckets once", () => {
80
+ expect(
81
+ getThreadHiddenCount({
82
+ leadingReplies: [reply(1), reply(2)],
83
+ trailingReplies: [reply(1)],
84
+ latestReply: reply(2),
85
+ totalReplyCount: 2,
86
+ }),
87
+ ).toBe(0);
88
+ });
89
+
90
+ it("counts what a long thread leaves out", () => {
91
+ expect(
92
+ getThreadHiddenCount({
93
+ leadingReplies: [reply(1), reply(2)],
94
+ trailingReplies: [reply(6), reply(7)],
95
+ latestReply: reply(8),
96
+ totalReplyCount: 8,
97
+ }),
98
+ ).toBe(3);
99
+ });
100
+
101
+ // A reply published between the count and the selection would otherwise
102
+ // report a negative number of hidden posts.
103
+ it("never goes below zero", () => {
104
+ expect(
105
+ getThreadHiddenCount({
106
+ leadingReplies: [reply(1), reply(2)],
107
+ trailingReplies: [],
108
+ latestReply: reply(3),
109
+ totalReplyCount: 1,
110
+ }),
111
+ ).toBe(0);
112
+ });
113
+ });
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { hangulToLatin, kanaToRomaji } from "../translit.js";
3
+
4
+ describe("hangulToLatin", () => {
5
+ it("romanizes basic syllables", () => {
6
+ expect(hangulToLatin("안녕하세요")).toBe("annyeonghaseyo");
7
+ });
8
+
9
+ it("applies liaison before silent-ㅇ syllables", () => {
10
+ expect(hangulToLatin("한국어")).toBe("hangugeo");
11
+ expect(hangulToLatin("일지")).toBe("ilji");
12
+ });
13
+
14
+ it("drops carried ㅎ in liaison", () => {
15
+ expect(hangulToLatin("좋아")).toBe("joa");
16
+ });
17
+
18
+ it("splits compound finals in liaison", () => {
19
+ expect(hangulToLatin("읽어")).toBe("ilgeo");
20
+ expect(hangulToLatin("값이")).toBe("gapsi");
21
+ });
22
+
23
+ it("keeps final ㅇ as ng before vowels", () => {
24
+ expect(hangulToLatin("강아지")).toBe("gangaji");
25
+ });
26
+
27
+ it("uses phonetic finals", () => {
28
+ expect(hangulToLatin("옷")).toBe("ot");
29
+ expect(hangulToLatin("부엌")).toBe("bueok");
30
+ });
31
+
32
+ it("preserves spaces and non-hangul characters", () => {
33
+ expect(hangulToLatin("개발 일지 3일차")).toBe("gaebal ilji 3ilcha");
34
+ expect(hangulToLatin("React 공부")).toBe("React gongbu");
35
+ });
36
+
37
+ it("romanizes standalone compatibility jamo", () => {
38
+ expect(hangulToLatin("ㅋㅋㅋ")).toBe("kkk");
39
+ expect(hangulToLatin("ㅠㅠ")).toBe("yuyu");
40
+ });
41
+ });
42
+
43
+ describe("kanaToRomaji", () => {
44
+ it("romanizes hiragana", () => {
45
+ expect(kanaToRomaji("こんにちは")).toBe("konnichiha");
46
+ });
47
+
48
+ it("romanizes katakana", () => {
49
+ expect(kanaToRomaji("カタカナタイトル")).toBe("katakanataitoru");
50
+ });
51
+
52
+ it("drops the chōonpu long-vowel mark", () => {
53
+ expect(kanaToRomaji("ハンバーガー")).toBe("hanbaga");
54
+ expect(kanaToRomaji("ラーメン")).toBe("ramen");
55
+ });
56
+
57
+ it("geminates consonants after sokuon", () => {
58
+ expect(kanaToRomaji("がっこう")).toBe("gakkou");
59
+ expect(kanaToRomaji("きっぷ")).toBe("kippu");
60
+ });
61
+
62
+ it("uses tch for sokuon before chi", () => {
63
+ expect(kanaToRomaji("まっちゃ")).toBe("matcha");
64
+ });
65
+
66
+ it("handles digraphs", () => {
67
+ expect(kanaToRomaji("きょうと")).toBe("kyouto");
68
+ expect(kanaToRomaji("しゃしん")).toBe("shashin");
69
+ expect(kanaToRomaji("じゅんび")).toBe("junbi");
70
+ });
71
+
72
+ it("handles extended katakana combos", () => {
73
+ expect(kanaToRomaji("ファン")).toBe("fan");
74
+ expect(kanaToRomaji("パーティー")).toBe("pati");
75
+ expect(kanaToRomaji("チェック")).toBe("chekku");
76
+ expect(kanaToRomaji("ヴァイオリン")).toBe("vaiorin");
77
+ });
78
+
79
+ it("passes non-kana characters through unchanged", () => {
80
+ expect(kanaToRomaji("東京タワー")).toBe("東京tawa");
81
+ expect(kanaToRomaji("abc カフェ")).toBe("abc kafe");
82
+ });
83
+
84
+ it("ignores a dangling sokuon", () => {
85
+ expect(kanaToRomaji("あっ")).toBe("a");
86
+ });
87
+ });
@@ -64,6 +64,20 @@ describe("sniffImageMimeType", () => {
64
64
  expect(sniffImageMimeType(bytes)).toBe("image/avif");
65
65
  });
66
66
 
67
+ it.each(["heic", "heix", "hevc", "hevx", "mif1", "msf1"])(
68
+ "detects HEIC/HEIF via the %s ftyp brand",
69
+ (brand) => {
70
+ const bytes = new Uint8Array(16);
71
+ bytes.set([...new TextEncoder().encode("ftyp")], 4);
72
+ bytes.set([...new TextEncoder().encode(brand)], 8);
73
+ expect(sniffImageMimeType(bytes)).toBe("image/heic");
74
+ },
75
+ );
76
+
77
+ it("keeps HEIC out of the sideload formats", () => {
78
+ expect(isAllowedSideloadImageType("image/heic")).toBe(false);
79
+ });
80
+
67
81
  it("detects SVG from an <svg> root", () => {
68
82
  const svg = new TextEncoder().encode(
69
83
  '<?xml version="1.0"?>\n<svg xmlns="http://www.w3.org/2000/svg"></svg>',
@@ -83,8 +83,7 @@ describe("fetchImageBytes", () => {
83
83
  );
84
84
 
85
85
  const headers = fetchMock.mock.calls[0]?.[1]?.headers as
86
- | Record<string, string>
87
- | undefined;
86
+ Record<string, string> | undefined;
88
87
  expect(headers?.Referer).toBe("https://img9.doubanio.com/");
89
88
  });
90
89
 
@@ -4,9 +4,12 @@ import {
4
4
  extractDisplayDomain,
5
5
  isFullUrl,
6
6
  isSafeAbsoluteUrl,
7
+ looksLikeAddress,
7
8
  normalizePath,
9
+ sanitizeRichTextHref,
10
+ sanitizeUrl,
8
11
  stripSitePathPrefix,
9
- slugify,
12
+ toInternalPath,
10
13
  toSameSitePath,
11
14
  } from "../url.js";
12
15
 
@@ -211,62 +214,118 @@ describe("isSafeAbsoluteUrl", () => {
211
214
  });
212
215
  });
213
216
 
214
- describe("slugify", () => {
215
- it("converts text to lowercase hyphenated slug", () => {
216
- expect(slugify("Hello World")).toBe("hello-world");
217
+ describe("URL sanitizers", () => {
218
+ it("keeps action protocols scoped to rich-text links", () => {
219
+ expect(sanitizeRichTextHref("tel:+15551234567")).toBe("tel:+15551234567");
220
+ expect(sanitizeRichTextHref("sms:xxxxx@xx.com")).toBe("sms:xxxxx@xx.com");
221
+
222
+ expect(sanitizeUrl("tel:+15551234567")).toBe("");
223
+ expect(sanitizeUrl("sms:xxxxx@xx.com")).toBe("");
217
224
  });
218
225
 
219
- it("removes special characters", () => {
220
- expect(slugify("Hello World! This is a Test.")).toBe(
221
- "hello-world-this-is-a-test",
222
- );
226
+ it("rejects executable protocols in every context", () => {
227
+ expect(sanitizeRichTextHref("javascript:alert(1)")).toBe("");
228
+ expect(sanitizeRichTextHref("data:text/html,<h1>Hi</h1>")).toBe("");
229
+ expect(sanitizeUrl("javascript:alert(1)")).toBe("");
223
230
  });
231
+ });
224
232
 
225
- it("collapses multiple spaces", () => {
226
- expect(slugify("Multiple Spaces")).toBe("multiple-spaces");
233
+ describe("stripSitePathPrefix", () => {
234
+ it("rewrites prefixed asset paths into the internal asset namespace", () => {
235
+ expect(stripSitePathPrefix("/blog/_assets/client.css", "/blog")).toBe(
236
+ "/_assets/client.css",
237
+ );
227
238
  });
228
239
 
229
- it("trims leading/trailing whitespace and hyphens", () => {
230
- expect(slugify(" Multiple Spaces ")).toBe("multiple-spaces");
240
+ it("rejects paths outside the configured site prefix", () => {
241
+ expect(stripSitePathPrefix("/_assets/client.css", "/blog")).toBe(null);
231
242
  });
243
+ });
232
244
 
233
- it("replaces underscores with hyphens", () => {
234
- expect(slugify("hello_world")).toBe("hello-world");
245
+ describe("looksLikeAddress", () => {
246
+ it("recognizes what a browser would have produced", () => {
247
+ expect(looksLikeAddress("/about")).toBe(true);
248
+ expect(looksLikeAddress(" https://example.com/about ")).toBe(true);
235
249
  });
236
250
 
237
- it("handles already-slugified text", () => {
238
- expect(slugify("already-a-slug")).toBe("already-a-slug");
251
+ it("leaves search words alone", () => {
252
+ // The line that keeps a picker's search box a search box: "about" is a
253
+ // word someone is looking for, not the page /about.
254
+ expect(looksLikeAddress("about")).toBe(false);
255
+ expect(looksLikeAddress("coffee notes")).toBe(false);
256
+ expect(looksLikeAddress("example.com")).toBe(false);
239
257
  });
258
+ });
240
259
 
241
- it("handles empty string", () => {
242
- expect(slugify("")).toBe("");
260
+ describe("toInternalPath", () => {
261
+ const SITE = {
262
+ siteOrigins: ["https://example.com"],
263
+ sitePathPrefix: "/blog",
264
+ languagePrefixes: ["zh-hans", "en"],
265
+ };
266
+
267
+ it("reads a path, a full URL, and a prefixed URL as the same page", () => {
268
+ expect(toInternalPath("/about", SITE)).toBe("/about");
269
+ expect(toInternalPath("about", SITE)).toBe("/about");
270
+ expect(toInternalPath("https://example.com/blog/about", SITE)).toBe(
271
+ "/about",
272
+ );
273
+ expect(toInternalPath("/blog/about", SITE)).toBe("/about");
274
+ // A typed path without the deployment prefix still means this site.
275
+ expect(toInternalPath("/about", SITE)).toBe("/about");
243
276
  });
244
277
 
245
- it("transliterates accented characters", () => {
246
- expect(slugify("café & résumé")).toBe("cafe-and-resume");
278
+ it("strips a language prefix, because a view is not another page", () => {
279
+ expect(toInternalPath("/en/about", SITE)).toBe("/about");
280
+ expect(toInternalPath("https://example.com/blog/zh-hans/about", SITE)).toBe(
281
+ "/about",
282
+ );
283
+ // The prefix alone is that view's home.
284
+ expect(toInternalPath("/en", SITE)).toBe("/");
247
285
  });
248
286
 
249
- it("converts Chinese characters to pinyin", () => {
250
- expect(slugify("书评")).toBe("shu-ping");
287
+ it("keeps a first segment that only looks like a language", () => {
288
+ // A single-language site is free to have a post at /en — nothing is
289
+ // serving a view there.
290
+ expect(toInternalPath("/en/about", { sitePathPrefix: "" })).toBe(
291
+ "/en/about",
292
+ );
251
293
  });
252
294
 
253
- it("handles mixed Chinese and English text", () => {
254
- expect(slugify("我的 Blog")).toBe("wo-de-blog");
295
+ it("matches any host the site answers on", () => {
296
+ const origins = {
297
+ siteOrigins: ["https://example.com", "https://alias.dev"],
298
+ };
299
+ expect(toInternalPath("https://alias.dev/about", origins)).toBe("/about");
300
+ // Scheme and port differ in dev; the host is what decides.
301
+ expect(toInternalPath("http://example.com:8787/about", origins)).toBe(
302
+ "/about",
303
+ );
255
304
  });
256
305
 
257
- it("handles CJK characters with spaces", () => {
258
- expect(slugify("电影 评论")).toBe("dian-ying-ping-lun");
306
+ it("refuses everything that is not a page on this site", () => {
307
+ expect(toInternalPath("https://other.com/about", SITE)).toBeNull();
308
+ expect(toInternalPath("mailto:hi@example.com", SITE)).toBeNull();
309
+ expect(toInternalPath("tel:+15551234567", SITE)).toBeNull();
310
+ expect(toInternalPath("//other.com/about", SITE)).toBeNull();
311
+ expect(toInternalPath("#section", SITE)).toBeNull();
312
+ expect(toInternalPath(" ", SITE)).toBeNull();
313
+ // A full URL inside our host but outside the deployment.
314
+ expect(
315
+ toInternalPath("https://example.com/other-app/about", SITE),
316
+ ).toBeNull();
259
317
  });
260
- });
261
318
 
262
- describe("stripSitePathPrefix", () => {
263
- it("rewrites prefixed asset paths into the internal asset namespace", () => {
264
- expect(stripSitePathPrefix("/blog/_assets/client.css", "/blog")).toBe(
265
- "/_assets/client.css",
266
- );
319
+ it("drops the query unless the caller is navigating", () => {
320
+ expect(toInternalPath("/about?ref=x#top", SITE)).toBe("/about");
321
+ expect(
322
+ toInternalPath("/about?ref=x#top", { ...SITE, keepQuery: true }),
323
+ ).toBe("/about?ref=x#top");
267
324
  });
268
325
 
269
- it("rejects paths outside the configured site prefix", () => {
270
- expect(stripSitePathPrefix("/_assets/client.css", "/blog")).toBe(null);
326
+ it("normalizes trailing slashes and relative steps", () => {
327
+ expect(toInternalPath("/about/", SITE)).toBe("/about");
328
+ expect(toInternalPath("/collections/../about", SITE)).toBe("/about");
329
+ expect(toInternalPath("/", SITE)).toBe("/");
271
330
  });
272
331
  });