@jant/core 0.6.16 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (508) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/import-site.js +60 -4
  4. package/bin/commands/reset-password.js +21 -8
  5. package/bin/commands/site/snapshot/export.js +1 -1
  6. package/bin/commands/site/snapshot/import.js +1 -1
  7. package/bin/lib/d1-query.js +5 -16
  8. package/bin/lib/node-env.js +40 -4
  9. package/bin/lib/site-selection.js +85 -29
  10. package/bin/lib/wrangler-cli.js +28 -2
  11. package/bin/lib/wrangler-json.js +35 -0
  12. package/dist/{app-C7_4oab9.js → app-K_Aa1MMn.js} +52476 -13330
  13. package/dist/client/.vite/manifest.json +255 -90
  14. package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
  15. package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
  16. package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
  17. package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
  18. package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
  19. package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
  20. package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
  21. package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
  22. package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
  23. package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
  24. package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
  25. package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
  26. package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
  27. package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
  28. package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
  29. package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
  30. package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
  31. package/dist/client/_assets/client-GiYENVw8.css +2 -0
  32. package/dist/client/_assets/client-VnFxJN7G.js +295 -0
  33. package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
  34. package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
  35. package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
  36. package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
  37. package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
  38. package/dist/{export-Dl5KCiEs.js → github-sync-BPAvT999.js} +2254 -107
  39. package/dist/index.js +2 -5
  40. package/dist/node.js +5 -7
  41. package/package.json +3 -3
  42. package/src/__tests__/auth-session-freshness.test.ts +57 -0
  43. package/src/__tests__/basecoat-variants.test.ts +121 -0
  44. package/src/__tests__/bin/assets-upload.test.ts +68 -0
  45. package/src/__tests__/export-hugo-build.test.ts +260 -0
  46. package/src/__tests__/export-service.test.ts +231 -0
  47. package/src/__tests__/helpers/component-css.ts +91 -0
  48. package/src/__tests__/helpers/db.ts +33 -0
  49. package/src/__tests__/helpers/export-fixtures.ts +3 -0
  50. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  51. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  52. package/src/app.tsx +71 -16
  53. package/src/auth.ts +18 -1
  54. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  55. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  56. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  57. package/src/client/__tests__/compose-bridge.test.ts +272 -0
  58. package/src/client/__tests__/compose-launch.test.ts +62 -1
  59. package/src/client/__tests__/compose-shortcuts.test.ts +38 -9
  60. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  61. package/src/client/__tests__/copy-field.test.ts +162 -0
  62. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  63. package/src/client/__tests__/image-processor.test.ts +324 -2
  64. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  65. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  66. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  67. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  68. package/src/client/__tests__/toast.test.ts +23 -0
  69. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  70. package/src/client/__tests__/video-processor.test.ts +181 -0
  71. package/src/client/audio-processor.ts +24 -15
  72. package/src/client/audio-waveform.ts +49 -0
  73. package/src/client/collection-dialog-host.ts +72 -0
  74. package/src/client/collection-navigation.ts +51 -16
  75. package/src/client/collection-page-actions.ts +26 -0
  76. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  77. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  78. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  79. package/src/client/components/__tests__/jant-command-palette.test.ts +28 -0
  80. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  81. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1250 -53
  82. package/src/client/components/__tests__/jant-compose-editor.test.ts +544 -40
  83. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +0 -1
  84. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  85. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  86. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  87. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  88. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  89. package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
  90. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  91. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  92. package/src/client/components/collection-dialog-types.ts +49 -0
  93. package/src/client/components/collection-manager-types.ts +32 -1
  94. package/src/client/components/collection-types.ts +1 -15
  95. package/src/client/components/compose-types.ts +75 -1
  96. package/src/client/components/jant-collection-dialog.ts +722 -0
  97. package/src/client/components/jant-collection-directory.ts +567 -182
  98. package/src/client/components/jant-collection-form.ts +53 -232
  99. package/src/client/components/jant-command-palette.ts +100 -49
  100. package/src/client/components/jant-compose-dialog.ts +1486 -707
  101. package/src/client/components/jant-compose-editor.ts +673 -336
  102. package/src/client/components/jant-compose-fullscreen.ts +51 -43
  103. package/src/client/components/jant-config-editor.ts +65 -53
  104. package/src/client/components/jant-confirm-dialog.ts +13 -11
  105. package/src/client/components/jant-locale-picker.ts +418 -0
  106. package/src/client/components/jant-media-lightbox.ts +146 -136
  107. package/src/client/components/jant-nav-manager.ts +690 -366
  108. package/src/client/components/jant-post-menu.ts +1086 -282
  109. package/src/client/components/jant-post-picker.ts +324 -0
  110. package/src/client/components/jant-repo-picker.ts +138 -106
  111. package/src/client/components/jant-settings-avatar.ts +31 -27
  112. package/src/client/components/jant-settings-general.ts +617 -543
  113. package/src/client/components/jant-settings-language.ts +1087 -0
  114. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  115. package/src/client/components/jant-text-preview.ts +94 -86
  116. package/src/client/components/nav-manager-types.ts +62 -1
  117. package/src/client/components/settings-types.ts +14 -29
  118. package/src/client/components/smart-collection-conditions.ts +285 -0
  119. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  120. package/src/client/compose-bridge.ts +214 -141
  121. package/src/client/compose-launch.ts +76 -2
  122. package/src/client/compose-shortcuts.ts +6 -8
  123. package/src/client/compose-triggers.ts +50 -0
  124. package/src/client/copy-field.ts +108 -0
  125. package/src/client/custom-url-menu.ts +60 -4
  126. package/src/client/icons.ts +14 -0
  127. package/src/client/image-plan.ts +165 -0
  128. package/src/client/image-processor.ts +195 -117
  129. package/src/client/image-worker-client.ts +45 -0
  130. package/src/client/image-worker.ts +135 -0
  131. package/src/client/lazy-entries.ts +95 -0
  132. package/src/client/lazy-slugify.ts +2 -2
  133. package/src/client/media-scroll-hint.ts +43 -2
  134. package/src/client/mediabunny.ts +29 -0
  135. package/src/client/post-picker.ts +41 -0
  136. package/src/client/runtime-paths.ts +74 -1
  137. package/src/client/settings-bridge.ts +4 -10
  138. package/src/client/site-header-nav.js +18 -6
  139. package/src/client/smart-collection-dialog-host.ts +116 -0
  140. package/src/client/smart-collection-page-actions.ts +208 -0
  141. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  142. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  143. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  144. package/src/client/tiptap/bubble-menu.ts +47 -16
  145. package/src/client/tiptap/floating-position.ts +216 -2
  146. package/src/client/tiptap/inline-image-upload.ts +7 -3
  147. package/src/client/tiptap/link-toolbar.ts +22 -9
  148. package/src/client/toast.ts +13 -0
  149. package/src/client/types/vite-worker.d.ts +12 -0
  150. package/src/client/upload-with-metadata.ts +3 -13
  151. package/src/client/video-processor.ts +351 -107
  152. package/src/client-auth.ts +14 -31
  153. package/src/client-compose.ts +19 -0
  154. package/src/client-manage.ts +13 -0
  155. package/src/client-settings.ts +14 -0
  156. package/src/client-site.ts +10 -3
  157. package/src/client.ts +4 -0
  158. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  159. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  160. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  161. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  162. package/src/db/migrations/0032_married_tomas.sql +3 -0
  163. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  164. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  165. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  166. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  167. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  168. package/src/db/migrations/meta/_journal.json +21 -0
  169. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  170. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  171. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  172. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  173. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  174. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  175. package/src/db/migrations/pg/meta/_journal.json +21 -0
  176. package/src/db/pg/schema.ts +195 -27
  177. package/src/db/post-visibility.ts +117 -0
  178. package/src/db/schema.ts +222 -27
  179. package/src/i18n/__tests__/detect.test.ts +26 -81
  180. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  181. package/src/i18n/context.tsx +12 -2
  182. package/src/i18n/detect.ts +7 -66
  183. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  184. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  185. package/src/i18n/locales/public/en.po +490 -158
  186. package/src/i18n/locales/public/en.ts +1 -1
  187. package/src/i18n/locales/public/zh-Hans.po +487 -155
  188. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  189. package/src/i18n/locales/public/zh-Hant.po +487 -155
  190. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  191. package/src/i18n/locales/settings/en.po +551 -69
  192. package/src/i18n/locales/settings/en.ts +1 -1
  193. package/src/i18n/locales/settings/zh-Hans.po +597 -115
  194. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  195. package/src/i18n/locales/settings/zh-Hant.po +610 -128
  196. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  197. package/src/i18n/locales.ts +94 -0
  198. package/src/i18n/middleware.ts +12 -2
  199. package/src/i18n/supported-locales.ts +80 -0
  200. package/src/lib/__tests__/api-settings.test.ts +11 -2
  201. package/src/lib/__tests__/asset-path.test.ts +92 -0
  202. package/src/lib/__tests__/deferred.test.ts +90 -0
  203. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  204. package/src/lib/__tests__/discover.test.ts +410 -0
  205. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  206. package/src/lib/__tests__/feed.test.ts +1526 -15
  207. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  208. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  209. package/src/lib/__tests__/http-cache.test.ts +162 -0
  210. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  211. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  212. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  213. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  214. package/src/lib/__tests__/navigation.test.ts +135 -1
  215. package/src/lib/__tests__/resolve-config.test.ts +64 -3
  216. package/src/lib/__tests__/schemas.test.ts +42 -4
  217. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  218. package/src/lib/__tests__/slug.test.ts +70 -0
  219. package/src/lib/__tests__/slugify.test.ts +85 -0
  220. package/src/lib/__tests__/storage.test.ts +32 -0
  221. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  222. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  223. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  224. package/src/lib/__tests__/url.test.ts +78 -72
  225. package/src/lib/__tests__/view-language.test.ts +413 -0
  226. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  227. package/src/lib/address.ts +57 -0
  228. package/src/lib/api-posts.ts +12 -0
  229. package/src/lib/asset-path.ts +98 -1
  230. package/src/lib/build-env.ts +16 -0
  231. package/src/lib/collection-groups.ts +5 -1
  232. package/src/lib/collection-paths.ts +3 -10
  233. package/src/lib/constants.ts +28 -3
  234. package/src/lib/copy-field.ts +36 -0
  235. package/src/lib/crypto.ts +40 -0
  236. package/src/lib/csp-builder.ts +2 -1
  237. package/src/lib/deferred.ts +79 -0
  238. package/src/lib/discover-ping.ts +141 -0
  239. package/src/lib/discover.ts +437 -0
  240. package/src/lib/env.ts +134 -0
  241. package/src/lib/errors.ts +33 -0
  242. package/src/lib/feed-policy.ts +236 -2
  243. package/src/lib/feed.ts +926 -73
  244. package/src/lib/filter-dimensions.ts +1349 -0
  245. package/src/lib/github-sync-site-config.ts +3 -0
  246. package/src/lib/hosted-signin.ts +1 -1
  247. package/src/lib/http-cache.ts +148 -0
  248. package/src/lib/hugo-markdown.ts +16 -0
  249. package/src/lib/icons.ts +9 -2
  250. package/src/lib/ids.ts +3 -0
  251. package/src/lib/jant-docs.ts +42 -0
  252. package/src/lib/lang-detect.ts +308 -0
  253. package/src/lib/media-helpers.ts +13 -1
  254. package/src/lib/navigation.ts +78 -11
  255. package/src/lib/pagination.ts +28 -24
  256. package/src/lib/per-language-surfaces.ts +67 -0
  257. package/src/lib/post-meta.ts +19 -0
  258. package/src/lib/render.tsx +55 -7
  259. package/src/lib/resolve-config.ts +53 -5
  260. package/src/lib/schemas.ts +201 -10
  261. package/src/lib/site-header-fragment.tsx +2 -1
  262. package/src/lib/site-unavailable-page.ts +47 -0
  263. package/src/lib/slug.ts +51 -7
  264. package/src/lib/slugify.ts +113 -0
  265. package/src/lib/startup-config.ts +4 -6
  266. package/src/lib/storage.ts +7 -0
  267. package/src/lib/summary.ts +50 -0
  268. package/src/lib/thread-fold.ts +161 -0
  269. package/src/lib/time.ts +32 -0
  270. package/src/lib/timeline.ts +42 -1
  271. package/src/lib/tiptap-render.ts +1 -2
  272. package/src/lib/upload.ts +16 -13
  273. package/src/lib/url.ts +139 -86
  274. package/src/lib/version.ts +10 -8
  275. package/src/lib/view-language.ts +436 -0
  276. package/src/lib/view.ts +51 -23
  277. package/src/lib/viewer-context.ts +62 -0
  278. package/src/middleware/__tests__/auth.test.ts +70 -2
  279. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  280. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  281. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  282. package/src/middleware/__tests__/session.test.ts +181 -33
  283. package/src/middleware/auth.ts +50 -31
  284. package/src/middleware/cache-control.ts +75 -10
  285. package/src/middleware/config.ts +6 -6
  286. package/src/middleware/error-handler.ts +12 -0
  287. package/src/middleware/onboarding.ts +32 -10
  288. package/src/middleware/secure-headers.ts +1 -1
  289. package/src/middleware/session.ts +96 -8
  290. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  291. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  292. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  293. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  294. package/src/node/__tests__/runtime.test.ts +37 -0
  295. package/src/node/index.ts +1 -0
  296. package/src/preset.css +76 -22
  297. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  298. package/src/routes/api/__tests__/collections.test.ts +114 -0
  299. package/src/routes/api/__tests__/discover.test.ts +222 -0
  300. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  301. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  302. package/src/routes/api/__tests__/settings.test.ts +24 -3
  303. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  304. package/src/routes/api/collections.ts +49 -6
  305. package/src/routes/api/custom-urls.ts +0 -1
  306. package/src/routes/api/discover.ts +95 -0
  307. package/src/routes/api/export.ts +3 -0
  308. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  309. package/src/routes/api/nav-items.ts +55 -8
  310. package/src/routes/api/posts.ts +159 -0
  311. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  312. package/src/routes/api/public/archive.ts +77 -81
  313. package/src/routes/api/public/posts.ts +23 -6
  314. package/src/routes/api/smart-collections.ts +132 -0
  315. package/src/routes/api/telegram.ts +2 -40
  316. package/src/routes/api/uploads.ts +1 -2
  317. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  318. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  319. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  320. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  321. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  322. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  323. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  324. package/src/routes/auth/setup.tsx +934 -186
  325. package/src/routes/auth/signin.tsx +10 -8
  326. package/src/routes/compose.tsx +5 -0
  327. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  328. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  329. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  330. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  331. package/src/routes/dash/custom-urls.tsx +1 -37
  332. package/src/routes/dash/settings.tsx +502 -41
  333. package/src/routes/discover-announce.ts +96 -0
  334. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  335. package/src/routes/feed/feed.ts +69 -40
  336. package/src/routes/feed/sitemap.ts +95 -11
  337. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  338. package/src/routes/pages/__tests__/archive-params.test.ts +370 -6
  339. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  340. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  341. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  342. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  343. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  344. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  345. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  346. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  347. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  348. package/src/routes/pages/archive.tsx +471 -369
  349. package/src/routes/pages/collection.tsx +94 -96
  350. package/src/routes/pages/collections.tsx +38 -50
  351. package/src/routes/pages/featured.tsx +32 -13
  352. package/src/routes/pages/home.tsx +25 -6
  353. package/src/routes/pages/language.tsx +113 -0
  354. package/src/routes/pages/latest.tsx +26 -16
  355. package/src/routes/pages/new.tsx +3 -0
  356. package/src/routes/pages/page.tsx +178 -17
  357. package/src/routes/pages/partials.tsx +41 -0
  358. package/src/routes/pages/search.tsx +18 -3
  359. package/src/routes/pages/smart-collection.tsx +340 -0
  360. package/src/routes/pages/subscribe.tsx +182 -0
  361. package/src/runtime/__tests__/index.test.ts +19 -0
  362. package/src/runtime/__tests__/node.test.ts +129 -4
  363. package/src/runtime/cloudflare.ts +21 -2
  364. package/src/runtime/index.ts +27 -1
  365. package/src/runtime/node.ts +18 -1
  366. package/src/runtime/site.ts +95 -24
  367. package/src/services/__tests__/auth.test.ts +116 -7
  368. package/src/services/__tests__/collection.test.ts +108 -4
  369. package/src/services/__tests__/language.test.ts +495 -0
  370. package/src/services/__tests__/navigation.test.ts +213 -10
  371. package/src/services/__tests__/path.test.ts +180 -0
  372. package/src/services/__tests__/post-language.test.ts +830 -0
  373. package/src/services/__tests__/post.test.ts +46 -9
  374. package/src/services/__tests__/settings.test.ts +228 -17
  375. package/src/services/__tests__/site-admin.test.ts +5 -5
  376. package/src/services/__tests__/smart-collection.test.ts +651 -0
  377. package/src/services/about-page.ts +22 -1
  378. package/src/services/auth.ts +51 -49
  379. package/src/services/bootstrap.ts +188 -38
  380. package/src/services/collection-directory-position.ts +76 -0
  381. package/src/services/collection.ts +331 -46
  382. package/src/services/custom-url.ts +27 -22
  383. package/src/services/export-theme/assets/client-site.css +1 -1
  384. package/src/services/export-theme/assets/client-site.js +123 -123
  385. package/src/services/export-theme/layouts/_default/rss.xml +77 -1
  386. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  387. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  388. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  389. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  390. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  391. package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
  392. package/src/services/export-theme/styles/main.css +13 -1
  393. package/src/services/export.ts +278 -19
  394. package/src/services/index.ts +34 -3
  395. package/src/services/language.ts +412 -0
  396. package/src/services/mcp.ts +3 -1
  397. package/src/services/media.ts +14 -6
  398. package/src/services/navigation.ts +429 -67
  399. package/src/services/path.ts +221 -6
  400. package/src/services/post.ts +1021 -152
  401. package/src/services/search.ts +39 -2
  402. package/src/services/settings.ts +238 -43
  403. package/src/services/site-admin.ts +24 -7
  404. package/src/services/site.ts +77 -1
  405. package/src/services/smart-collection.ts +636 -0
  406. package/src/services/telegram.ts +20 -22
  407. package/src/services/upload-session.ts +1 -3
  408. package/src/style-author.css +27 -0
  409. package/src/styles/components-author.css +1622 -0
  410. package/src/styles/components.css +24 -1571
  411. package/src/styles/site-media.css +16 -2
  412. package/src/styles/tokens.css +82 -12
  413. package/src/styles/ui-author.css +7602 -0
  414. package/src/styles/ui.css +1087 -7219
  415. package/src/types/app-context.ts +7 -0
  416. package/src/types/bindings.ts +3 -0
  417. package/src/types/config.ts +94 -15
  418. package/src/types/constants.ts +188 -16
  419. package/src/types/entities.ts +87 -1
  420. package/src/types/operations.ts +64 -2
  421. package/src/types/props.ts +157 -31
  422. package/src/types/views.ts +55 -1
  423. package/src/ui/__tests__/font-themes.test.ts +52 -12
  424. package/src/ui/compose/ComposeDialog.tsx +201 -7
  425. package/src/ui/compose/ComposePrompt.tsx +1 -5
  426. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  427. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  428. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  429. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  430. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  431. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  432. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  433. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  434. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  435. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  436. package/src/ui/dash/settings/GeneralContent.tsx +275 -113
  437. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  438. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  439. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  440. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  441. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  442. package/src/ui/dash/settings/discover-copy.ts +116 -0
  443. package/src/ui/feed/LinkCard.tsx +55 -45
  444. package/src/ui/feed/NoteCard.tsx +9 -4
  445. package/src/ui/feed/PostStatusBadges.tsx +90 -43
  446. package/src/ui/feed/QuoteCard.tsx +55 -30
  447. package/src/ui/feed/ThreadPreview.tsx +6 -2
  448. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  449. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  450. package/src/ui/feed/thread-preview-state.ts +20 -6
  451. package/src/ui/font-themes.ts +39 -29
  452. package/src/ui/layouts/BaseLayout.tsx +155 -10
  453. package/src/ui/layouts/SiteLayout.tsx +192 -34
  454. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  455. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  456. package/src/ui/pages/ArchivePage.tsx +282 -289
  457. package/src/ui/pages/CollectionPage.tsx +86 -76
  458. package/src/ui/pages/CollectionsPage.tsx +5 -0
  459. package/src/ui/pages/ComposePage.tsx +8 -0
  460. package/src/ui/pages/PostPage.tsx +47 -0
  461. package/src/ui/pages/SearchPage.tsx +3 -3
  462. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  463. package/src/ui/pages/SubscribePage.tsx +236 -0
  464. package/src/ui/pages/__tests__/ArchivePage.test.tsx +65 -9
  465. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  466. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  467. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  468. package/src/ui/shared/CollectionsManager.tsx +70 -17
  469. package/src/ui/shared/CopyField.tsx +127 -0
  470. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  471. package/src/ui/shared/MediaGallery.tsx +125 -46
  472. package/src/ui/shared/Pagination.tsx +20 -5
  473. package/src/ui/shared/PostFooter.tsx +20 -11
  474. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  475. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  476. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  477. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  478. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  479. package/src/ui/shared/archive-labels.ts +98 -0
  480. package/src/ui/shared/collection-management-labels.ts +94 -16
  481. package/src/ui/shared/navigation-labels.ts +45 -71
  482. package/src/ui/shared/post-article-attributes.ts +2 -0
  483. package/src/ui/shared/smart-collection-labels.ts +343 -0
  484. package/dist/app-C75Zc8dD.js +0 -6
  485. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  486. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  487. package/dist/client/_assets/chunks/url-CU9pEbk5.js +0 -1
  488. package/dist/client/_assets/client-Bq0vre8Y.js +0 -296
  489. package/dist/client/_assets/client-D3VZyG4L.css +0 -2
  490. package/dist/client/_assets/client-auth-F2rb9WfF.js +0 -5571
  491. package/dist/env-BPYViDJJ.js +0 -281
  492. package/dist/github-api-BNF35Lkx.js +0 -176
  493. package/dist/github-app-DdiD-bC4.js +0 -275
  494. package/dist/github-sync-Bqg62IvV.js +0 -4
  495. package/dist/github-sync-HdK2EgvJ.js +0 -431
  496. package/dist/url-CbLAtlZe.js +0 -900
  497. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  498. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  499. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  500. package/src/client/collection-created-notice.ts +0 -66
  501. package/src/client/collection-form-bridge.ts +0 -191
  502. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  503. package/src/client/components/jant-post-form.ts +0 -422
  504. package/src/client/components/post-form-template.ts +0 -352
  505. package/src/client/components/post-form-types.ts +0 -96
  506. package/src/client/media-metadata.ts +0 -247
  507. package/src/client/post-form-bridge.ts +0 -193
  508. package/src/ui/pages/CollectionEditorPage.tsx +0 -175
@@ -29,10 +29,13 @@ export async function buildSyncSiteConfig(c: {
29
29
  siteUrl: appConfig.siteUrl,
30
30
  siteDescription: appConfig.siteDescription,
31
31
  siteLanguage: appConfig.siteLanguage,
32
+ multilingualEnabled: appConfig.multilingualEnabled,
33
+ additionalLanguages: appConfig.additionalLanguages,
32
34
  showJantBrandingOnHome: appConfig.showJantBrandingOnHome,
33
35
  publicApiEnabled: appConfig.publicApiEnabled,
34
36
  rssFeedsEnabled: appConfig.rssFeedsEnabled,
35
37
  mainRssFeed: appConfig.mainRssFeed,
38
+ archiveDefaultLayout: appConfig.archiveDefaultLayout,
36
39
  siteFooter: appConfig.siteFooter,
37
40
  showHeaderAvatar: appConfig.showHeaderAvatar,
38
41
  siteAvatarUrl: appConfig.siteAvatarUrl,
@@ -60,7 +60,7 @@ export function getHostedControlPlaneResetUrl(
60
60
  export function getHostedControlPlaneDashboardUrl(
61
61
  env: object | undefined | null,
62
62
  ): string | null {
63
- return buildHostedControlPlaneUrl(env, "/sites");
63
+ return buildHostedControlPlaneUrl(env, "/app");
64
64
  }
65
65
 
66
66
  export function getHostedControlPlaneAccountUrl(
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Conditional-request helpers.
3
+ *
4
+ * Transport-level only: deriving an entity tag for a response body, and
5
+ * deciding whether the request already holds it. Nothing here knows what the
6
+ * body represents.
7
+ */
8
+
9
+ const textEncoder = new TextEncoder();
10
+
11
+ /**
12
+ * One entity tag inside an `If-None-Match` list, weak prefix included.
13
+ *
14
+ * Matching quoted runs rather than splitting on commas is deliberate: RFC 9110
15
+ * allows a comma inside an entity tag's opaque part, so a naive split can tear
16
+ * one tag into two that match nothing.
17
+ */
18
+ const ENTITY_TAG = /(?:W\/)?"[^"]*"/g;
19
+
20
+ /** Drop the weak prefix, which the weak comparison in RFC 9110 ignores. */
21
+ function opaqueTag(tag: string): string {
22
+ return tag.startsWith("W/") ? tag.slice(2) : tag;
23
+ }
24
+
25
+ /**
26
+ * Derive a strong entity tag from a response body.
27
+ *
28
+ * The tag is a truncated SHA-256 of the exact bytes that would be sent, so
29
+ * two equal tags do mean two identical responses — which is what makes the
30
+ * tag strong rather than `W/`-weak. 128 bits is far past the point where a
31
+ * collision could plausibly pin a stale body in a cache.
32
+ *
33
+ * @param body - Serialized response body
34
+ * @returns Quoted entity tag, ready for an `ETag` header
35
+ * @example
36
+ * ```ts
37
+ * const etag = await strongETag(xml); // '"9f86d081884c7d65..."'
38
+ * ```
39
+ */
40
+ export async function strongETag(body: string): Promise<string> {
41
+ const digest = await crypto.subtle.digest(
42
+ "SHA-256",
43
+ textEncoder.encode(body),
44
+ );
45
+
46
+ let hex = "";
47
+ for (const byte of new Uint8Array(digest, 0, 16)) {
48
+ hex += byte.toString(16).padStart(2, "0");
49
+ }
50
+
51
+ return `"${hex}"`;
52
+ }
53
+
54
+ /**
55
+ * Whether a request's `If-None-Match` already holds `etag`.
56
+ *
57
+ * Comparison is the weak one RFC 9110 requires for `If-None-Match`: the weak
58
+ * prefix is ignored on both sides, and any tag in the list can match. `*`
59
+ * matches whenever the resource exists, which it does by the time a response
60
+ * body has been rendered.
61
+ *
62
+ * @param header - Raw `If-None-Match` header value, absent when not sent
63
+ * @param etag - Entity tag of the response about to be sent
64
+ * @returns `true` when the response can be answered with `304`
65
+ * @example
66
+ * ```ts
67
+ * matchesIfNoneMatch('W/"abc", "def"', '"def"'); // true
68
+ * ```
69
+ */
70
+ export function matchesIfNoneMatch(
71
+ header: string | null | undefined,
72
+ etag: string,
73
+ ): boolean {
74
+ if (!header) return false;
75
+ if (header.trim() === "*") return true;
76
+
77
+ const target = opaqueTag(etag);
78
+ for (const candidate of header.match(ENTITY_TAG) ?? []) {
79
+ if (opaqueTag(candidate) === target) return true;
80
+ }
81
+
82
+ return false;
83
+ }
84
+
85
+ /**
86
+ * Headers a `304` carries over from the response it stands in for.
87
+ *
88
+ * RFC 9110 asks for the fields a cache would otherwise have to invent or
89
+ * discard: the validator itself, the freshness the entry is stored under, and
90
+ * the identity of what was validated.
91
+ */
92
+ const NOT_MODIFIED_HEADERS = [
93
+ "Cache-Control",
94
+ "Content-Location",
95
+ "Date",
96
+ "ETag",
97
+ "Expires",
98
+ "Vary",
99
+ ] as const;
100
+
101
+ /**
102
+ * Answer a conditional request with `304` when the response is one the client
103
+ * already holds.
104
+ *
105
+ * This is the only place in the app that turns a validator into a `304`, and
106
+ * it runs at the outer edge of the request — outside the Worker response
107
+ * cache. Both halves of that matter: a cache hit is revalidated without
108
+ * reaching a route, and a cache miss still renders and stores the whole
109
+ * document before the body is dropped, so the next reader gets a cache entry
110
+ * rather than a `304` that cannot be stored.
111
+ *
112
+ * Anything without an entity tag passes through untouched, so a route opts in
113
+ * simply by emitting one.
114
+ *
115
+ * @param request - Incoming request, read for `If-None-Match`
116
+ * @param response - Response the app produced
117
+ * @returns A bare `304`, or the response unchanged
118
+ * @example
119
+ * ```ts
120
+ * return withConditionalResponse(request, await handle(request));
121
+ * ```
122
+ */
123
+ export function withConditionalResponse(
124
+ request: Request,
125
+ response: Response,
126
+ ): Response {
127
+ if (request.method !== "GET" && request.method !== "HEAD") return response;
128
+ if (response.status !== 200) return response;
129
+
130
+ // A 304 drops the body, and with it any header that only makes sense
131
+ // attached to one. A response setting a cookie is left alone rather than
132
+ // stripped down to a validator.
133
+ if (response.headers.has("Set-Cookie")) return response;
134
+
135
+ const etag = response.headers.get("ETag");
136
+ if (!etag) return response;
137
+ if (!matchesIfNoneMatch(request.headers.get("If-None-Match"), etag)) {
138
+ return response;
139
+ }
140
+
141
+ const headers = new Headers();
142
+ for (const name of NOT_MODIFIED_HEADERS) {
143
+ const value = response.headers.get(name);
144
+ if (value !== null) headers.set(name, value);
145
+ }
146
+
147
+ return new Response(null, { status: 304, headers });
148
+ }
@@ -104,6 +104,11 @@ export interface HugoFrontMatter {
104
104
  status?: string;
105
105
  visibility?: string;
106
106
  summary_text?: string;
107
+ /**
108
+ * The timeline cuts this post's text short. Written only when true, so a
109
+ * consumer reads its presence rather than its value.
110
+ */
111
+ truncated?: boolean;
107
112
  link_url?: string;
108
113
  source_name?: string;
109
114
  source_url?: string;
@@ -111,6 +116,16 @@ export interface HugoFrontMatter {
111
116
  rating?: number | null;
112
117
  featured_at?: string | null;
113
118
  pinned_at?: string | null;
119
+ /**
120
+ * BCP 47 content language. Root bundles only — replies take the root's.
121
+ */
122
+ language?: string;
123
+ /**
124
+ * Opaque shared key linking posts that are translations of one another. Root
125
+ * bundles only. Meaningful within one export; carried across so a full-site
126
+ * restore keeps translated posts linked.
127
+ */
128
+ translation_group?: string;
114
129
  /**
115
130
  * Reply bundles only: the reply was published without announcing the Thread
116
131
  * on Latest. Round-tripped so a restore rebuilds the same ordering.
@@ -205,6 +220,7 @@ const FRONT_MATTER_KEY_ORDER: readonly string[] = [
205
220
  "status",
206
221
  "visibility",
207
222
  "summary_text",
223
+ "truncated",
208
224
  "link_url",
209
225
  "source_name",
210
226
  "source_url",
package/src/lib/icons.ts CHANGED
@@ -24,17 +24,24 @@ function toPascalCase(name: string): string {
24
24
  * Get SVG markup for a Lucide icon by kebab-case name.
25
25
  *
26
26
  * @param name - Kebab-case icon name
27
+ * @param className - Extra classes for the `<svg>`, appended to Lucide's own.
28
+ * `icon-fine` is the project's lighter stroke.
27
29
  * @returns SVG string or null when the icon is unknown
28
30
  *
29
31
  * @example
30
32
  * ```ts
31
33
  * getIconSvg("book-open");
34
+ * getIconSvg("funnel", "icon-fine");
32
35
  * ```
33
36
  */
34
- export function getIconSvg(name: string): string | null {
37
+ export function getIconSvg(name: string, className?: string): string | null {
35
38
  const pascalName = toPascalCase(name);
36
39
  const svg = (lucideIcons as Record<string, string>)[pascalName];
37
- return typeof svg === "string" ? svg : null;
40
+ if (typeof svg !== "string") return null;
41
+ if (!className) return svg;
42
+ // Lucide always ships a `class="lucide lucide-…"`, so extend it rather than
43
+ // adding a second class attribute the parser would drop.
44
+ return svg.replace(/class="([^"]*)"/, `class="$1 ${className}"`);
38
45
  }
39
46
 
40
47
  /**
package/src/lib/ids.ts CHANGED
@@ -5,9 +5,12 @@ export const ID_PREFIX = {
5
5
  site: "sit",
6
6
  siteDomain: "sdm",
7
7
  post: "pst",
8
+ /** Shared key linking Posts that are translations of one another. */
9
+ translationGroup: "tgr",
8
10
  media: "med",
9
11
  uploadSession: "upl",
10
12
  collection: "col",
13
+ smartCollection: "smc",
11
14
  path: "pth",
12
15
  collectionDirectoryItem: "cdi",
13
16
  navItem: "nav",
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Links into the published documentation
3
+ *
4
+ * Everything under `docs/` is published on the Jant site: `docs/multilingual.md`
5
+ * is served at `/docs/multilingual`, lowercased and without the extension.
6
+ *
7
+ * The host is resolved once, here, so no call site writes a domain. Development
8
+ * builds point at the control plane running locally, which serves the docs
9
+ * being edited rather than the released ones.
10
+ */
11
+
12
+ import { IS_VITE_DEV } from "./build-env.js";
13
+
14
+ /** Docs host for released builds. */
15
+ const DOCS_ORIGIN = "https://jant.me";
16
+
17
+ /** Docs host during development. */
18
+ const DEV_DOCS_ORIGIN = "https://jant-cloud.localtest.me";
19
+
20
+ /** Documentation index for the current environment. */
21
+ export const JANT_DOCS_BASE_URL = `${
22
+ IS_VITE_DEV ? DEV_DOCS_ORIGIN : DOCS_ORIGIN
23
+ }/docs`;
24
+
25
+ /**
26
+ * Build a link to one documentation page.
27
+ *
28
+ * @param page - Published page slug, optionally with a `#section` anchor. It is
29
+ * the file name under `docs/`, lowercased and without `.md`. Omit it to link
30
+ * the documentation index.
31
+ * @returns Absolute URL to that page on the current environment's docs site
32
+ * @example
33
+ * ```ts
34
+ * getJantDocsUrl("multilingual"); // "https://jant.me/docs/multilingual"
35
+ * getJantDocsUrl("configuration#required");
36
+ * // "https://jant.me/docs/configuration#required"
37
+ * getJantDocsUrl(); // "https://jant.me/docs"
38
+ * ```
39
+ */
40
+ export function getJantDocsUrl(page = ""): string {
41
+ return page ? `${JANT_DOCS_BASE_URL}/${page}` : JANT_DOCS_BASE_URL;
42
+ }
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Content language detection.
3
+ *
4
+ * A hand-written character-set voter, not a statistical model. It runs in the
5
+ * browser (in compose, for logged-in authors only — public pages load none of
6
+ * it) and on the server (for posts that arrive through the API, the Telegram
7
+ * bot, or MCP, where nobody picked a language).
8
+ *
9
+ * What it is good at, and what it is not:
10
+ *
11
+ * - Hangul and kana are near-unambiguous, so Korean and Japanese are reliable.
12
+ * - Simplified vs Traditional Chinese is decided by voting on characters that
13
+ * exist in only one of the two. One sentence is usually plenty. Text written
14
+ * the same way in both — `我今天很好` — is reported as Chinese of unsettled
15
+ * variant, which is enough on a site publishing only one of them and
16
+ * deliberately not enough on a site publishing both.
17
+ * - Latin script says "not CJK", nothing more. It resolves to a language only
18
+ * when the site publishes exactly one non-CJK language.
19
+ * - Nothing at all is read out of a fragment. See `MIN_SIGNAL`: under a couple
20
+ * of words the honest answer is the caller's default, not a coin toss that
21
+ * changes with the next keystroke.
22
+ *
23
+ * The detector **never** returns an empty value or a language the site does
24
+ * not publish: it always answers with a member of `languages`, falling back to
25
+ * the caller's default. Its output is a *suggestion* the author can change —
26
+ * nothing here decides anything irreversibly.
27
+ */
28
+
29
+ import { getCjkFontFromLanguageTag } from "../i18n/detect.js";
30
+
31
+ /**
32
+ * Characters written only in Simplified Chinese.
33
+ *
34
+ * Every entry is checked to be absent from Traditional writing — a character
35
+ * that serves both (`后` as "empress", `台` in `台北`) would vote for the wrong
36
+ * side and has been left out, while its Traditional counterpart still votes.
37
+ */
38
+ const SIMPLIFIED_ONLY =
39
+ "们这会说对时国学个来过现发经应关点无义与从门问间东车长马鸟鱼见贝页风飞头语话认识讲论议记让试该请读谁调谈变边达运还进远连" +
40
+ "适选递邮释钟银错锁闭开闻阅队阶阳阴陆陈险隐难静韩须顾频颜题类飘饭饮饰馆驻驾验骑骗体处备够夺奋妇妈宁宝实宽宾寻导尔尘尝尽层" +
41
+ "属岁岂岛币帅帮归当录彻忆态怀恋恶悬惊惧惨愤愿战户扩扫扬担拟挂挤挥换据摄击敌断旧显术机杀杂权条极构柜标树桥检楼欢欧残毁气汉" +
42
+ "汤沟泪洁济浏浅浆涛润涨渐渔渗湾湿满滨滤灭灯灵灾炉烦烧热营爱状犹狮独狱猎猪献猫环电画畅疗疯皱监盘矿码确础礼祸种积称稳穷窃竞" +
43
+ "笔筹简签粮紧纪纯纲纳纵纷纸线组细织终绍结绕绘给络绝统继绩绪续维综绿缓编缘缩缴网罗罚罢习联声职聪肃肠胀脑脱腊舰艰节苏药荐荡" +
44
+ "获萧蓝虏虚虽蚀蜡补装观规觉计订讨训讯词译诉诊证评诚误诸课谅谓谢谱贡财责贤败货质贩贪购贯贱贴贵贷贸费贺资赏赐赔赖赚赛赢赠赶" +
45
+ "趋践轨转轮软载较辅辈辉输辐辑辞迁迈违迟逊遗邓邻郑针钉钓钙钝钞钢钥钦钩钱钻铁铃铅铜铝铺链销锋锐锦键镇镜闪闯闲闷闹阀阁阵际隶" +
46
+ "随雾项顶预领颇颈颗颠饥饱饼馈驰驱驶骂骄鲁鲜鸡鸣鸦鸭鸽鹅鹰麦黄齐齿龄龙龟儿优传伤侦侧俭债倾偿储兰兴军农决净减凤凭则刘创剑剧" +
47
+ "劝办务动励劳势勋医华单卖卫厅历压厌厨参叶号叹吗启呐员呜响哑唤啸喷嘱团园围图圆场坏块坚坛垒垦堕墙壮壳复";
48
+
49
+ /** Characters written only in Traditional Chinese. */
50
+ const TRADITIONAL_ONLY =
51
+ "們這會說對時國學後個來過現發經應關點無義與從門問間東車長馬鳥魚見貝頁風飛頭語話認識講論議記讓試該請讀誰調談變邊達運還進遠" +
52
+ "連適選遞郵釋鐘銀錯鎖閉開聞閱隊階陽陰陸陳險隱難靜韓須顧頻顏題類飄飯飲飾館駐駕驗騎騙體處備夠奪奮婦媽寧寶實寬賓尋導爾塵嘗盡" +
53
+ "層屬歲豈島幣帥幫歸當錄徹憶態懷戀惡懸驚懼慘憤願戰戶擴掃揚擔擬掛擠揮換據攝擊敵斷舊顯術機殺雜權條極構櫃標樹橋檢樓歡歐殘毀氣" +
54
+ "漢湯溝淚潔濟瀏淺漿濤潤漲漸漁滲灣濕滿濱濾滅燈靈災爐煩燒熱營愛狀猶獅獨獄獵豬獻貓環電畫暢療瘋皺監盤礦碼確礎禮禍種積稱穩窮竊" +
55
+ "競筆籌簡簽糧緊紀純綱納縱紛紙線組細織終紹結繞繪給絡絕統繼績緒續維綜綠緩編緣縮繳網羅罰罷習聯聲職聰肅腸脹腦脫臘艦艱節蘇藥薦" +
56
+ "蕩獲蕭藍虜虛雖蝕蠟補裝觀規覺計訂討訓訊詞譯訴診證評誠誤諸課諒謂謝譜貢財責賢敗貨質販貪購貫賤貼貴貸貿費賀資賞賜賠賴賺賽贏贈" +
57
+ "趕趨踐軌轉輪軟載較輔輩輝輸輻輯辭遷邁違遲遜遺鄧鄰鄭針釘釣鈣鈍鈔鋼鑰欽鉤錢鑽鐵鈴鉛銅鋁鋪鏈銷鋒銳錦鍵鎮鏡閃闖閒悶鬧閥閣陣際" +
58
+ "隸隨霧項頂預領頗頸顆顛飢飽餅饋馳驅駛罵驕魯鮮雞鳴鴉鴨鴿鵝鷹麥黃齊齒齡龍龜兒優傳傷偵側儉債傾償儲蘭興軍農衝決淨減鳳憑劃則劉" +
59
+ "創劍劇勸辦務動勵勞勢勳醫華單賣衛廳歷壓厭廚參疊隻臺葉號嘆嗎啟吶員嗚響啞喚嘯噴囑團園圍圖圓場壞塊堅壇壘墾墮牆壯殼複";
60
+
61
+ const SIMPLIFIED_SET = new Set(SIMPLIFIED_ONLY);
62
+ const TRADITIONAL_SET = new Set(TRADITIONAL_ONLY);
63
+
64
+ /**
65
+ * Script family a run of text is written in, before it meets the site's
66
+ * languages.
67
+ *
68
+ * `han` is Chinese whose variant the text does not settle: `我今天很好` and
69
+ * `哈哈哈哈` are written identically in Simplified and Traditional. It is a
70
+ * real answer, not a refusal — the text is certainly Chinese — and on a site
71
+ * that publishes only one of the two variants it is all the answer needed.
72
+ */
73
+ export type DetectedScript =
74
+ "ko" | "ja" | "zh-Hans" | "zh-Hant" | "han" | "latin";
75
+
76
+ interface ScriptCounts {
77
+ hangul: number;
78
+ kana: number;
79
+ han: number;
80
+ latin: number;
81
+ simplified: number;
82
+ traditional: number;
83
+ }
84
+
85
+ function countScripts(text: string): ScriptCounts {
86
+ const counts: ScriptCounts = {
87
+ hangul: 0,
88
+ kana: 0,
89
+ han: 0,
90
+ latin: 0,
91
+ simplified: 0,
92
+ traditional: 0,
93
+ };
94
+
95
+ for (const char of text) {
96
+ const code = char.codePointAt(0);
97
+ if (code === undefined) continue;
98
+
99
+ // Hangul syllables, Jamo, and compatibility Jamo.
100
+ if (
101
+ (code >= 0xac00 && code <= 0xd7a3) ||
102
+ (code >= 0x1100 && code <= 0x11ff) ||
103
+ (code >= 0x3130 && code <= 0x318f)
104
+ ) {
105
+ counts.hangul++;
106
+ continue;
107
+ }
108
+
109
+ // Hiragana, katakana, and katakana phonetic extensions.
110
+ if (
111
+ (code >= 0x3040 && code <= 0x309f) ||
112
+ (code >= 0x30a0 && code <= 0x30ff) ||
113
+ (code >= 0x31f0 && code <= 0x31ff)
114
+ ) {
115
+ counts.kana++;
116
+ continue;
117
+ }
118
+
119
+ // CJK Unified Ideographs, Extension A, and compatibility ideographs.
120
+ if (
121
+ (code >= 0x4e00 && code <= 0x9fff) ||
122
+ (code >= 0x3400 && code <= 0x4dbf) ||
123
+ (code >= 0xf900 && code <= 0xfaff)
124
+ ) {
125
+ counts.han++;
126
+ if (SIMPLIFIED_SET.has(char)) counts.simplified++;
127
+ else if (TRADITIONAL_SET.has(char)) counts.traditional++;
128
+ continue;
129
+ }
130
+
131
+ // Basic Latin letters plus the Latin-1 and Extended-A accented ranges.
132
+ if (
133
+ (code >= 0x41 && code <= 0x5a) ||
134
+ (code >= 0x61 && code <= 0x7a) ||
135
+ (code >= 0xc0 && code <= 0x24f)
136
+ ) {
137
+ counts.latin++;
138
+ }
139
+ }
140
+
141
+ return counts;
142
+ }
143
+
144
+ /**
145
+ * What a CJK character is worth against a Latin letter.
146
+ *
147
+ * A Han character is closer to a word than to a letter, and kana and Hangul
148
+ * sit in between — so counting characters straight would let a two-character
149
+ * quote outvote a paragraph. Three is the rough density ratio between CJK and
150
+ * Latin text, and it is the only number here that has to be approximate: it
151
+ * decides ties between scripts, not what any one script means.
152
+ */
153
+ const CJK_WEIGHT = 3;
154
+
155
+ /**
156
+ * How much evidence it takes before the detector will name a script at all.
157
+ *
158
+ * Ten points is four Han characters, or two English words. Below that the
159
+ * answer would swing on every keystroke — a composer that reads "H" as English
160
+ * and "He 说" as Chinese is not reading, it is twitching. Under the threshold
161
+ * the detector refuses and the caller keeps its own default, which is the
162
+ * language of the page the author is writing from.
163
+ */
164
+ const MIN_SIGNAL = 10;
165
+
166
+ /**
167
+ * Identify the script family a piece of text is written in.
168
+ *
169
+ * Exported for its own sake — the language-set resolution below is a separate
170
+ * concern, and testing the two apart keeps both honest.
171
+ *
172
+ * @param text - Plain text, typically a post body
173
+ * @returns The script family, or null when the text carries too little signal
174
+ * @example
175
+ * detectScript("これはテスト"); // "ja"
176
+ * detectScript("國學說這時"); // "zh-Hant"
177
+ * detectScript("我今天很好"); // "han" — Chinese, variant not settled
178
+ * detectScript("Hi"); // null — too short to be worth an answer
179
+ * detectScript("123 !!!"); // null
180
+ */
181
+ export function detectScript(text: string): DetectedScript | null {
182
+ const counts = countScripts(text);
183
+ const cjk = (counts.hangul + counts.kana + counts.han) * CJK_WEIGHT;
184
+
185
+ // Which script the text is *mostly* in, not merely which one appears in it:
186
+ // an English paragraph that quotes a Chinese phrase is still English, and
187
+ // before this comparison a single Han character spoke for the whole post.
188
+ if (cjk >= counts.latin && cjk >= MIN_SIGNAL) {
189
+ // Korean mixes Hangul with Han; Japanese mixes kana with Han. Neither
190
+ // borrows the other's syllabary, so either one present settles it.
191
+ if (counts.hangul > 0) return "ko";
192
+ if (counts.kana > 0) return "ja";
193
+ if (counts.simplified > counts.traditional) return "zh-Hans";
194
+ if (counts.traditional > counts.simplified) return "zh-Hant";
195
+ // Han with no distinctive character either way, or the same number on each
196
+ // side. Which variant this is, the text does not say; that it is Chinese,
197
+ // it says clearly. Report that much and let the caller weigh it against the
198
+ // languages the site actually publishes.
199
+ return "han";
200
+ }
201
+
202
+ return counts.latin >= MIN_SIGNAL ? "latin" : null;
203
+ }
204
+
205
+ /**
206
+ * Read one of the site's languages out of a piece of text, or admit that the
207
+ * text does not say.
208
+ *
209
+ * The distinction is the whole point of this function existing next to
210
+ * `detectContentLanguage`: a caller that substitutes a default cannot tell
211
+ * "this is English" from "I could not tell, here is your default", and a
212
+ * composer that reports the second as the first is lying to the author.
213
+ *
214
+ * @param text - Plain text, typically a post body
215
+ * @param options - The languages the site publishes
216
+ * @returns A tag from `options.languages`, or null when the text does not
217
+ * settle on one of them
218
+ * @example
219
+ * readContentLanguage("國學說這時會對後", {
220
+ * languages: ["zh-Hans", "zh-Hant", "en"],
221
+ * }); // "zh-Hant"
222
+ * readContentLanguage("Hi", { languages: ["zh-Hans", "en"] }); // null
223
+ */
224
+ export function readContentLanguage(
225
+ text: string,
226
+ options: { languages: readonly string[] },
227
+ ): string | null {
228
+ const { languages } = options;
229
+ if (languages.length === 0) return null;
230
+
231
+ const script = detectScript(text);
232
+ if (!script) return null;
233
+
234
+ if (script === "latin") {
235
+ // Latin script only says "not CJK". That names a language only when there
236
+ // is exactly one candidate; with English and French configured, guessing
237
+ // between them from the alphabet alone would be a coin toss.
238
+ const nonCjk = languages.filter((tag) => !getCjkFontFromLanguageTag(tag));
239
+ return nonCjk.length === 1 ? (nonCjk[0] as string) : null;
240
+ }
241
+
242
+ if (script === "han") {
243
+ // Same shape as the Latin case: the text narrowed it to Chinese but not to
244
+ // a variant, so it names a language only when the site publishes one of
245
+ // them. A site running both Simplified and Traditional gets no answer,
246
+ // which is the honest one — the text genuinely does not say.
247
+ const chinese = languages.filter((tag) => {
248
+ const profile = getCjkFontFromLanguageTag(tag);
249
+ return profile === "zh-Hans" || profile === "zh-Hant";
250
+ });
251
+ return chinese.length === 1 ? (chinese[0] as string) : null;
252
+ }
253
+
254
+ // No match means the site does not publish the detected script — say nothing
255
+ // rather than invent a language the author never configured.
256
+ return (
257
+ languages.find((tag) => getCjkFontFromLanguageTag(tag) === script) ?? null
258
+ );
259
+ }
260
+
261
+ /**
262
+ * Suggest which of the site's languages a piece of text is written in.
263
+ *
264
+ * @param text - Plain text, typically a post body
265
+ * @param options - The languages the site publishes, and what to answer when
266
+ * the text gives no usable signal
267
+ * @returns A tag from `options.languages`, always
268
+ * @example
269
+ * detectContentLanguage("國學說這時會對後", {
270
+ * languages: ["zh-Hans", "zh-Hant", "en"],
271
+ * fallback: "zh-Hans",
272
+ * }); // "zh-Hant"
273
+ */
274
+ export function detectContentLanguage(
275
+ text: string,
276
+ options: { languages: readonly string[]; fallback: string },
277
+ ): string {
278
+ return readContentLanguage(text, options) ?? options.fallback;
279
+ }
280
+
281
+ /**
282
+ * Suggest the language a new Post should be stored with, when nobody said.
283
+ *
284
+ * Only ever fills a blank: an explicit choice is the author's and is never
285
+ * second-guessed here. Returns null on a site that does not publish more than
286
+ * one language, because nothing is stamped before the feature is turned on.
287
+ *
288
+ * @param options - Text to read, the site's languages, and its primary one
289
+ * @returns Canonical tag from `languages`, or null when the site is single-language
290
+ * @example
291
+ * suggestPostLanguage({
292
+ * text: "Hello there",
293
+ * languages: ["zh-Hans", "en"],
294
+ * primary: "zh-Hans",
295
+ * }); // "en"
296
+ */
297
+ export function suggestPostLanguage(options: {
298
+ text?: string | null;
299
+ languages: readonly string[];
300
+ primary: string;
301
+ }): string | null {
302
+ const { text, languages, primary } = options;
303
+
304
+ if (languages.length === 0) return null;
305
+ if (!text?.trim()) return primary;
306
+
307
+ return detectContentLanguage(text, { languages, fallback: primary });
308
+ }
@@ -58,9 +58,21 @@ export function buildMediaMap(
58
58
  fit: "scale-down",
59
59
  })
60
60
  : mediaUrl;
61
- const posterUrl = m.posterKey
61
+ // A poster is a still frame at whatever resolution the source video
62
+ // had, so it needs the same transform as any other image — without it
63
+ // a 4K clip puts a multi-megabyte PNG in the feed. Matches the limits
64
+ // `toMediaView` uses for the same field.
65
+ const posterRawUrl = m.posterKey
62
66
  ? getMediaUrl(m.posterKey, publicUrl, sitePathPrefix)
63
67
  : null;
68
+ const posterUrl = posterRawUrl
69
+ ? getImageUrl(posterRawUrl, imageTransformUrl, {
70
+ width: 640,
71
+ quality: 80,
72
+ format: "auto",
73
+ fit: "scale-down",
74
+ })
75
+ : null;
64
76
  return {
65
77
  id: m.id,
66
78
  url: mediaUrl,