@jant/core 0.6.14 → 0.6.16

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 (350) hide show
  1. package/bin/commands/import-site.js +88 -13
  2. package/bin/commands/migrate.js +55 -0
  3. package/bin/commands/posts/rebuild-html.js +184 -0
  4. package/bin/commands/site/snapshot/import.js +14 -10
  5. package/bin/lib/d1-query.js +23 -5
  6. package/bin/lib/migration-runner.js +29 -7
  7. package/bin/lib/node-database.js +32 -0
  8. package/bin/lib/site-snapshot.js +347 -46
  9. package/bin/lib/sql-export.js +1 -1
  10. package/bin/lib/thread-collection-migration.js +326 -0
  11. package/dist/app-C75Zc8dD.js +6 -0
  12. package/dist/{app-2i7-WQHg.js → app-C7_4oab9.js} +7774 -6425
  13. package/dist/client/.vite/manifest.json +8 -8
  14. package/dist/client/_assets/chunks/{url-pLre2DM_.js → url-CU9pEbk5.js} +1 -1
  15. package/dist/client/_assets/{client-BvID5ucz.js → client-Bq0vre8Y.js} +59 -38
  16. package/dist/client/_assets/client-D3VZyG4L.css +2 -0
  17. package/dist/client/_assets/client-auth-F2rb9WfF.js +5571 -0
  18. package/dist/{env-OHRKGcMj.js → env-BPYViDJJ.js} +1 -1
  19. package/dist/{export-DWn149b7.js → export-Dl5KCiEs.js} +1348 -128
  20. package/dist/{github-api-BgSiE71w.js → github-api-BNF35Lkx.js} +1 -1
  21. package/dist/{github-app-BbklkFmU.js → github-app-DdiD-bC4.js} +1 -1
  22. package/dist/{github-sync-C0zyF6XS.js → github-sync-Bqg62IvV.js} +3 -3
  23. package/dist/{github-sync-Ckk0sJxK.js → github-sync-HdK2EgvJ.js} +3 -3
  24. package/dist/index.js +5 -5
  25. package/dist/node.js +7 -7
  26. package/dist/{url-BMYO-Zlt.js → url-CbLAtlZe.js} +517 -12
  27. package/package.json +11 -10
  28. package/src/__tests__/bin/posts-rebuild-html.test.ts +137 -0
  29. package/src/__tests__/dev-navigation-defaults.test.ts +22 -0
  30. package/src/__tests__/export-hugo-build.test.ts +63 -4
  31. package/src/__tests__/export-import-roundtrip.test.ts +6 -4
  32. package/src/__tests__/export-service.test.ts +142 -9
  33. package/src/__tests__/helpers/app.ts +3 -0
  34. package/src/__tests__/helpers/export-fixtures.ts +2 -0
  35. package/src/__tests__/import-site-command.test.ts +65 -0
  36. package/src/__tests__/mise-config.test.ts +22 -0
  37. package/src/__tests__/vite-config.test.ts +45 -0
  38. package/src/app.tsx +16 -3
  39. package/src/client/__tests__/collection-created-notice.test.ts +36 -0
  40. package/src/client/__tests__/collection-form-bridge.test.ts +10 -1
  41. package/src/client/__tests__/collection-page-actions.test.ts +71 -2
  42. package/src/client/__tests__/compose-bridge.test.ts +316 -2
  43. package/src/client/__tests__/compose-shortcuts.test.ts +20 -0
  44. package/src/client/__tests__/feed-video-player.test.ts +16 -1
  45. package/src/client/__tests__/footnote-rail.test.ts +472 -0
  46. package/src/client/__tests__/hydrate-partial.test.ts +3 -0
  47. package/src/client/__tests__/toast.test.ts +82 -1
  48. package/src/client/collection-created-notice.ts +66 -0
  49. package/src/client/collection-form-bridge.ts +17 -0
  50. package/src/client/collection-navigation.ts +44 -0
  51. package/src/client/collection-page-actions.ts +57 -1
  52. package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
  53. package/src/client/components/__tests__/jant-collection-directory.test.ts +155 -6
  54. package/src/client/components/__tests__/jant-command-palette.test.ts +51 -0
  55. package/src/client/components/__tests__/jant-compose-dialog.test.ts +2268 -206
  56. package/src/client/components/__tests__/jant-compose-editor.test.ts +147 -59
  57. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +114 -64
  58. package/src/client/components/__tests__/jant-config-editor.test.ts +405 -0
  59. package/src/client/components/__tests__/jant-media-lightbox.test.ts +121 -0
  60. package/src/client/components/__tests__/jant-nav-manager.test.ts +538 -2
  61. package/src/client/components/__tests__/jant-post-form.test.ts +2 -2
  62. package/src/client/components/__tests__/jant-post-menu.test.ts +47 -5
  63. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
  64. package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
  65. package/src/client/components/collection-manager-types.ts +9 -3
  66. package/src/client/components/compose-format-convert.ts +5 -1
  67. package/src/client/components/compose-types.ts +55 -3
  68. package/src/client/components/jant-collection-directory.ts +165 -30
  69. package/src/client/components/jant-command-palette.ts +14 -7
  70. package/src/client/components/jant-compose-dialog.ts +1542 -694
  71. package/src/client/components/jant-compose-editor.ts +237 -99
  72. package/src/client/components/jant-compose-fullscreen.ts +27 -9
  73. package/src/client/components/jant-config-editor.ts +722 -0
  74. package/src/client/components/jant-media-lightbox.ts +100 -18
  75. package/src/client/components/jant-nav-manager.ts +1283 -54
  76. package/src/client/components/jant-post-form.ts +3 -3
  77. package/src/client/components/jant-post-menu.ts +177 -95
  78. package/src/client/components/nav-manager-types.ts +51 -2
  79. package/src/client/components/post-form-types.ts +1 -1
  80. package/src/client/compose-bridge.ts +147 -123
  81. package/src/client/compose-launch.ts +13 -0
  82. package/src/client/compose-shortcuts.ts +5 -1
  83. package/src/client/feed-video-player.ts +62 -2
  84. package/src/client/footnote-rail.ts +478 -0
  85. package/src/client/hydrate-partial.ts +2 -0
  86. package/src/client/media-lightbox-events.ts +63 -0
  87. package/src/client/post-refresh.ts +135 -0
  88. package/src/client/site-header-fragment.ts +38 -0
  89. package/src/client/tiptap/__tests__/blockquote-editing.test.ts +201 -0
  90. package/src/client/tiptap/__tests__/clear-formatting.test.ts +168 -0
  91. package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
  92. package/src/client/tiptap/__tests__/list-editing.test.ts +447 -0
  93. package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +727 -4
  94. package/src/client/tiptap/__tests__/table-controls.test.ts +263 -0
  95. package/src/client/tiptap/__tests__/table-size-picker.test.ts +179 -0
  96. package/src/client/tiptap/bubble-menu.ts +88 -10
  97. package/src/client/tiptap/continuous-lists.ts +102 -0
  98. package/src/client/tiptap/create-editor.ts +3 -0
  99. package/src/client/tiptap/extensions.ts +14 -5
  100. package/src/client/tiptap/link-input-rules.ts +4 -4
  101. package/src/client/tiptap/markdown-clipboard.ts +608 -11
  102. package/src/client/tiptap/slash-commands.ts +8 -1
  103. package/src/client/tiptap/structural-keymap.ts +211 -0
  104. package/src/client/tiptap/tab-indent.ts +8 -22
  105. package/src/client/tiptap/table-control-labels.ts +30 -0
  106. package/src/client/tiptap/table-controls.ts +537 -0
  107. package/src/client/tiptap/table-size-picker.ts +254 -0
  108. package/src/client/toast.ts +133 -39
  109. package/src/client-auth.ts +1 -0
  110. package/src/client.ts +1 -0
  111. package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
  112. package/src/db/__tests__/d1-query.test.ts +43 -1
  113. package/src/db/__tests__/migration-runner.test.ts +63 -0
  114. package/src/db/__tests__/migrations.test.ts +469 -0
  115. package/src/db/__tests__/thread-activity.test.ts +113 -0
  116. package/src/db/__tests__/thread-collection-migration.test.ts +364 -0
  117. package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
  118. package/src/db/migrations/0027_old_blue_blade.sql +39 -0
  119. package/src/db/migrations/0028_superb_post.sql +2 -0
  120. package/src/db/migrations/0029_boring_magma.sql +1 -0
  121. package/src/db/migrations/0030_typical_vivisector.sql +50 -0
  122. package/src/db/migrations/0031_many_ego.sql +3 -0
  123. package/src/db/migrations/meta/0027_snapshot.json +2511 -0
  124. package/src/db/migrations/meta/0028_snapshot.json +2511 -0
  125. package/src/db/migrations/meta/0029_snapshot.json +2519 -0
  126. package/src/db/migrations/meta/0030_snapshot.json +2541 -0
  127. package/src/db/migrations/meta/0031_snapshot.json +2562 -0
  128. package/src/db/migrations/meta/_journal.json +35 -0
  129. package/src/db/migrations/pg/0025_moaning_epoch.sql +39 -0
  130. package/src/db/migrations/pg/0026_talented_killer_shrike.sql +2 -0
  131. package/src/db/migrations/pg/0027_short_cannonball.sql +1 -0
  132. package/src/db/migrations/pg/0028_perfect_sharon_carter.sql +27 -0
  133. package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
  134. package/src/db/migrations/pg/meta/0025_snapshot.json +3204 -0
  135. package/src/db/migrations/pg/meta/0026_snapshot.json +3204 -0
  136. package/src/db/migrations/pg/meta/0027_snapshot.json +3211 -0
  137. package/src/db/migrations/pg/meta/0028_snapshot.json +3248 -0
  138. package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
  139. package/src/db/migrations/pg/meta/_journal.json +35 -0
  140. package/src/db/pg/schema.ts +46 -20
  141. package/src/db/rehearsal-fixtures/demo-current.json +39 -4
  142. package/src/db/rehearsal-fixtures/demo-current.sql +121 -6
  143. package/src/db/schema.ts +48 -20
  144. package/src/db/thread-activity.ts +101 -0
  145. package/src/i18n/__tests__/detect.test.ts +8 -2
  146. package/src/i18n/detect.ts +14 -5
  147. package/src/i18n/locales/public/en.po +222 -51
  148. package/src/i18n/locales/public/en.ts +1 -1
  149. package/src/i18n/locales/public/zh-Hans.po +216 -45
  150. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  151. package/src/i18n/locales/public/zh-Hant.po +216 -45
  152. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  153. package/src/i18n/locales/settings/en.po +513 -10
  154. package/src/i18n/locales/settings/en.ts +1 -1
  155. package/src/i18n/locales/settings/zh-Hans.po +515 -12
  156. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  157. package/src/i18n/locales/settings/zh-Hant.po +515 -12
  158. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  159. package/src/lib/__tests__/api-settings.test.ts +299 -0
  160. package/src/lib/__tests__/constants.test.ts +5 -0
  161. package/src/lib/__tests__/feed-policy.test.ts +25 -0
  162. package/src/lib/__tests__/feed.test.ts +168 -0
  163. package/src/lib/__tests__/footnotes-upgrade.test.ts +181 -0
  164. package/src/lib/__tests__/markdown-roundtrip-embed.test.ts +61 -0
  165. package/src/lib/__tests__/markdown-to-tiptap.test.ts +32 -4
  166. package/src/lib/__tests__/markdown.test.ts +24 -6
  167. package/src/lib/__tests__/navigation.test.ts +59 -2
  168. package/src/lib/__tests__/post-body-html.test.ts +176 -0
  169. package/src/lib/__tests__/post-display.test.ts +64 -0
  170. package/src/lib/__tests__/resolve-config.test.ts +113 -1
  171. package/src/lib/__tests__/slug.test.ts +18 -0
  172. package/src/lib/__tests__/summary.test.ts +82 -0
  173. package/src/lib/__tests__/timeline.test.ts +109 -70
  174. package/src/lib/__tests__/timezones.test.ts +31 -3
  175. package/src/lib/__tests__/tiptap-render.test.ts +268 -9
  176. package/src/lib/__tests__/tiptap-to-markdown.test.ts +6 -0
  177. package/src/lib/__tests__/translit.test.ts +87 -0
  178. package/src/lib/__tests__/url.test.ts +53 -0
  179. package/src/lib/__tests__/view.test.ts +53 -0
  180. package/src/lib/__tests__/worker-response-cache.test.ts +18 -1
  181. package/src/lib/api-settings.ts +239 -12
  182. package/src/lib/collection-sort.ts +7 -5
  183. package/src/lib/constants.ts +2 -0
  184. package/src/lib/feed-path.ts +25 -0
  185. package/src/lib/feed-policy.ts +64 -0
  186. package/src/lib/feed.ts +113 -11
  187. package/src/lib/footnotes.ts +264 -0
  188. package/src/lib/github-sync-site-config.ts +2 -0
  189. package/src/lib/hugo-markdown.ts +15 -3
  190. package/src/lib/jant-branding.ts +1 -0
  191. package/src/lib/link-preview.ts +25 -0
  192. package/src/lib/markdown-manager.ts +193 -5
  193. package/src/lib/markdown.ts +10 -3
  194. package/src/lib/navigation.ts +16 -3
  195. package/src/lib/post-body-html.ts +131 -0
  196. package/src/lib/post-display.ts +47 -8
  197. package/src/lib/render.tsx +9 -2
  198. package/src/lib/resolve-config.ts +100 -16
  199. package/src/lib/schemas.ts +133 -1
  200. package/src/lib/settings-paths.ts +5 -0
  201. package/src/lib/site-skill.ts +78 -0
  202. package/src/lib/summary.ts +16 -5
  203. package/src/lib/timeline.ts +137 -131
  204. package/src/lib/timezones.ts +54 -14
  205. package/src/lib/tiptap-render.ts +359 -83
  206. package/src/lib/translit.ts +288 -0
  207. package/src/lib/url.ts +123 -12
  208. package/src/lib/view.ts +58 -20
  209. package/src/lib/worker-response-cache.ts +11 -18
  210. package/src/middleware/__tests__/public-content-access.test.ts +91 -0
  211. package/src/middleware/config.ts +2 -2
  212. package/src/middleware/public-content-access.ts +87 -0
  213. package/src/node/__tests__/cli-export.test.ts +14 -0
  214. package/src/node/__tests__/cli-migrate.test.ts +6 -0
  215. package/src/node/__tests__/cli-site-snapshot.test.ts +205 -2
  216. package/src/node/__tests__/cli-snapshot-meta.test.ts +100 -0
  217. package/src/node/index.ts +2 -1
  218. package/src/preset.css +16 -2
  219. package/src/routes/__tests__/compose.test.ts +51 -0
  220. package/src/routes/__tests__/site-skill.test.ts +112 -0
  221. package/src/routes/api/__tests__/collections.test.ts +91 -19
  222. package/src/routes/api/__tests__/mcp.test.ts +65 -1
  223. package/src/routes/api/__tests__/nav-items.test.ts +91 -0
  224. package/src/routes/api/__tests__/palette.test.ts +44 -0
  225. package/src/routes/api/__tests__/public-access.test.ts +81 -0
  226. package/src/routes/api/__tests__/settings.test.ts +217 -0
  227. package/src/routes/api/collections.ts +22 -26
  228. package/src/routes/api/export.ts +2 -0
  229. package/src/routes/api/internal/__tests__/post-body-html.test.ts +163 -0
  230. package/src/routes/api/internal/__tests__/sites.test.ts +35 -2
  231. package/src/routes/api/internal/post-body-html.ts +43 -0
  232. package/src/routes/api/internal/sites.ts +24 -0
  233. package/src/routes/api/nav-items.ts +24 -1
  234. package/src/routes/api/posts.ts +14 -10
  235. package/src/routes/api/public/__tests__/archive.test.ts +35 -0
  236. package/src/routes/api/public/__tests__/posts.test.ts +246 -0
  237. package/src/routes/api/public/archive.ts +4 -0
  238. package/src/routes/api/public/posts.ts +48 -24
  239. package/src/routes/api/search.ts +3 -0
  240. package/src/routes/api/settings.ts +33 -0
  241. package/src/routes/auth/__tests__/setup.test.ts +7 -4
  242. package/src/routes/compose.tsx +8 -3
  243. package/src/routes/dash/settings.tsx +45 -2
  244. package/src/routes/feed/__tests__/feed.test.ts +210 -6
  245. package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
  246. package/src/routes/feed/feed.ts +23 -14
  247. package/src/routes/pages/__tests__/archive-params.test.ts +384 -3
  248. package/src/routes/pages/__tests__/collection-routing.test.ts +63 -5
  249. package/src/routes/pages/__tests__/collections.test.ts +9 -9
  250. package/src/routes/pages/__tests__/featured.test.ts +6 -2
  251. package/src/routes/pages/__tests__/preview.test.ts +176 -0
  252. package/src/routes/pages/__tests__/search.test.ts +69 -0
  253. package/src/routes/pages/archive.tsx +90 -22
  254. package/src/routes/pages/collection.tsx +32 -19
  255. package/src/routes/pages/collections.tsx +6 -1
  256. package/src/routes/pages/page.tsx +86 -11
  257. package/src/routes/site-skill.ts +18 -0
  258. package/src/services/__tests__/auth.test.ts +7 -5
  259. package/src/services/__tests__/collection.test.ts +190 -44
  260. package/src/services/__tests__/navigation.test.ts +229 -21
  261. package/src/services/__tests__/post-timeline.test.ts +249 -39
  262. package/src/services/__tests__/post.test.ts +844 -5
  263. package/src/services/__tests__/search.test.ts +48 -1
  264. package/src/services/__tests__/settings.test.ts +8 -0
  265. package/src/services/__tests__/site-admin.test.ts +121 -1
  266. package/src/services/auth.ts +2 -2
  267. package/src/services/bootstrap.ts +3 -2
  268. package/src/services/collection.ts +224 -167
  269. package/src/services/export-theme/assets/client-site.css +1 -1
  270. package/src/services/export-theme/assets/client-site.js +46 -25
  271. package/src/services/export-theme/layouts/_default/list.html +18 -4
  272. package/src/services/export-theme/layouts/_default/rss.xml +8 -12
  273. package/src/services/export-theme/layouts/featured/list.html +5 -4
  274. package/src/services/export-theme/layouts/partials/featured-thread.html +56 -0
  275. package/src/services/export-theme/layouts/partials/footer.html +1 -1
  276. package/src/services/export-theme/layouts/partials/media-gallery.html +1 -0
  277. package/src/services/export-theme/styles/main.css +30 -2
  278. package/src/services/export.ts +94 -85
  279. package/src/services/mcp.ts +37 -27
  280. package/src/services/media.ts +1 -1
  281. package/src/services/navigation.ts +185 -46
  282. package/src/services/path.ts +4 -1
  283. package/src/services/post.ts +1142 -342
  284. package/src/services/search.ts +17 -2
  285. package/src/services/settings.ts +2 -10
  286. package/src/services/site-admin.ts +63 -43
  287. package/src/style-cjk-jp.css +1 -1
  288. package/src/style-cjk-kr.css +1 -1
  289. package/src/style-cjk-tc.css +1 -1
  290. package/src/style-cjk.css +1 -1
  291. package/src/styles/components.css +540 -14
  292. package/src/styles/site-media.css +111 -19
  293. package/src/styles/tokens.css +76 -10
  294. package/src/styles/ui.css +2227 -1347
  295. package/src/types/bindings.ts +3 -0
  296. package/src/types/config.ts +243 -12
  297. package/src/types/constants.ts +22 -1
  298. package/src/types/entities.ts +14 -8
  299. package/src/types/operations.ts +18 -10
  300. package/src/types/props.ts +20 -2
  301. package/src/types/raw-assets.d.ts +5 -0
  302. package/src/types/views.ts +22 -4
  303. package/src/ui/__tests__/font-themes.test.ts +42 -30
  304. package/src/ui/compose/ComposeDialog.tsx +109 -19
  305. package/src/ui/dash/appearance/NavigationContent.tsx +332 -4
  306. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +23 -0
  307. package/src/ui/dash/settings/ConfigEditorContent.tsx +578 -0
  308. package/src/ui/dash/settings/GeneralContent.tsx +15 -5
  309. package/src/ui/dash/settings/SettingsRootContent.tsx +21 -1
  310. package/src/ui/dash/settings/__tests__/ConfigEditorContent.test.tsx +98 -0
  311. package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +15 -0
  312. package/src/ui/feed/CuratedThreadPreview.tsx +16 -4
  313. package/src/ui/feed/LinkCard.tsx +2 -12
  314. package/src/ui/feed/LinkPreview.tsx +2 -7
  315. package/src/ui/feed/NoteCard.tsx +10 -20
  316. package/src/ui/feed/PostStatusBadges.tsx +42 -3
  317. package/src/ui/feed/QuoteCard.tsx +2 -12
  318. package/src/ui/feed/ThreadPreview.tsx +26 -34
  319. package/src/ui/feed/TimelineFeed.tsx +2 -2
  320. package/src/ui/feed/__tests__/thread-preview.test.ts +197 -56
  321. package/src/ui/feed/__tests__/timeline-cards.test.ts +178 -3
  322. package/src/ui/feed/thread-preview-state.ts +5 -7
  323. package/src/ui/font-themes.ts +74 -26
  324. package/src/ui/layouts/BaseLayout.tsx +22 -8
  325. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +64 -1
  326. package/src/ui/pages/ArchivePage.tsx +131 -24
  327. package/src/ui/pages/CollectionPage.tsx +101 -22
  328. package/src/ui/pages/CollectionsPage.tsx +3 -1
  329. package/src/ui/pages/PostPage.tsx +39 -12
  330. package/src/ui/pages/SearchPage.tsx +6 -14
  331. package/src/ui/pages/__tests__/ArchivePage.test.tsx +70 -0
  332. package/src/ui/pages/__tests__/PostPage.test.tsx +88 -0
  333. package/src/ui/shared/CollectionDirectory.tsx +13 -9
  334. package/src/ui/shared/CollectionsManager.tsx +17 -7
  335. package/src/ui/shared/DraftPreviewBar.tsx +49 -0
  336. package/src/ui/shared/HomePageBranding.tsx +2 -2
  337. package/src/ui/shared/MediaGallery.tsx +1 -0
  338. package/src/ui/shared/PostFooter.tsx +38 -13
  339. package/src/ui/shared/__tests__/home-page-branding.test.tsx +1 -1
  340. package/src/ui/shared/__tests__/media-gallery.test.ts +1 -0
  341. package/src/ui/shared/__tests__/navigation-labels.test.ts +2 -2
  342. package/src/ui/shared/__tests__/post-footer.test.ts +15 -0
  343. package/src/ui/shared/collection-management-labels.ts +33 -2
  344. package/src/ui/shared/custom-icons.ts +5 -0
  345. package/src/ui/shared/navigation-labels.ts +2 -4
  346. package/src/ui/shared/post-article-attributes.ts +52 -0
  347. package/dist/app-DZDlmh7C.js +0 -6
  348. package/dist/client/_assets/client-Cevfpm8H.css +0 -2
  349. package/dist/client/_assets/client-auth-v3yvhbgZ.js +0 -4736
  350. package/src/client/tiptap/wrapping-input-rules.ts +0 -102
@@ -402,6 +402,12 @@ describe("tiptapJsonToMarkdown", () => {
402
402
  const md = "Body copy[^1]\n\n[^1]: Footnote body";
403
403
  expect(roundtrip(md)).toBe(md);
404
404
  });
405
+
406
+ it("canonicalizes inline footnotes as references plus definitions", () => {
407
+ expect(roundtrip("Body^[Inline **bold** note.]")).toBe(
408
+ "Body[^1]\n\n[^1]: Inline **bold** note.",
409
+ );
410
+ });
405
411
  });
406
412
 
407
413
  describe("edge cases", () => {
@@ -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
+ });
@@ -5,6 +5,8 @@ import {
5
5
  isFullUrl,
6
6
  isSafeAbsoluteUrl,
7
7
  normalizePath,
8
+ sanitizeRichTextHref,
9
+ sanitizeUrl,
8
10
  stripSitePathPrefix,
9
11
  slugify,
10
12
  toSameSitePath,
@@ -211,6 +213,22 @@ describe("isSafeAbsoluteUrl", () => {
211
213
  });
212
214
  });
213
215
 
216
+ describe("URL sanitizers", () => {
217
+ it("keeps action protocols scoped to rich-text links", () => {
218
+ expect(sanitizeRichTextHref("tel:+15551234567")).toBe("tel:+15551234567");
219
+ expect(sanitizeRichTextHref("sms:xxxxx@xx.com")).toBe("sms:xxxxx@xx.com");
220
+
221
+ expect(sanitizeUrl("tel:+15551234567")).toBe("");
222
+ expect(sanitizeUrl("sms:xxxxx@xx.com")).toBe("");
223
+ });
224
+
225
+ it("rejects executable protocols in every context", () => {
226
+ expect(sanitizeRichTextHref("javascript:alert(1)")).toBe("");
227
+ expect(sanitizeRichTextHref("data:text/html,<h1>Hi</h1>")).toBe("");
228
+ expect(sanitizeUrl("javascript:alert(1)")).toBe("");
229
+ });
230
+ });
231
+
214
232
  describe("slugify", () => {
215
233
  it("converts text to lowercase hyphenated slug", () => {
216
234
  expect(slugify("Hello World")).toBe("hello-world");
@@ -257,6 +275,41 @@ describe("slugify", () => {
257
275
  it("handles CJK characters with spaces", () => {
258
276
  expect(slugify("电影 评论")).toBe("dian-ying-ping-lun");
259
277
  });
278
+
279
+ it("romanizes Korean titles", () => {
280
+ expect(slugify("안녕하세요 세계")).toBe("annyeonghaseyo-segye");
281
+ expect(slugify("개발 일지 3일차")).toBe("gaebal-ilji-3ilcha");
282
+ });
283
+
284
+ it("romanizes kana-only Japanese titles", () => {
285
+ expect(slugify("カタカナタイトル")).toBe("katakanataitoru");
286
+ expect(slugify("ハンバーガー")).toBe("hanbaga");
287
+ });
288
+
289
+ it("keeps Latin words in mostly-kana Japanese titles", () => {
290
+ expect(slugify("React入門ガイド")).toBe("react-gaido");
291
+ });
292
+
293
+ it("returns empty for kanji-heavy Japanese titles instead of garbling them", () => {
294
+ // Kanji carries the meaning; without a reading dictionary any output
295
+ // would be gibberish, so callers should fall back to a random ID.
296
+ expect(slugify("日本語のタイトルです")).toBe("");
297
+ expect(slugify("東京タワーに行った")).toBe("");
298
+ expect(slugify("こんにちは世界")).toBe("");
299
+ });
300
+
301
+ it("returns empty for scripts with no usable transliteration", () => {
302
+ expect(slugify("שלום עולם")).toBe("");
303
+ expect(slugify("สวัสดีชาวโลก")).toBe("");
304
+ });
305
+
306
+ it("returns empty when most of the title is untransliterable", () => {
307
+ expect(slugify("שלום world")).toBe("");
308
+ });
309
+
310
+ it("returns empty for emoji-only titles", () => {
311
+ expect(slugify("🎉🎉")).toBe("");
312
+ });
260
313
  });
261
314
 
262
315
  describe("stripSitePathPrefix", () => {
@@ -20,6 +20,7 @@ import type {
20
20
  SearchResult,
21
21
  Post,
22
22
  } from "../../types.js";
23
+ import { renderTiptapJson } from "../tiptap-render.js";
23
24
  const EMPTY_CTX: MediaContext = {};
24
25
  const CTX_WITH_URLS: MediaContext = {
25
26
  r2PublicUrl: "https://cdn.example.com",
@@ -271,6 +272,58 @@ describe("toPostView", () => {
271
272
  );
272
273
  });
273
274
 
275
+ it("places the summary boundary after a footnote repeated in the hidden tail", () => {
276
+ const firstText = "A".repeat(300);
277
+ const secondText = "B".repeat(300);
278
+ const body = JSON.stringify({
279
+ type: "doc",
280
+ content: [
281
+ {
282
+ type: "paragraph",
283
+ content: [
284
+ { type: "text", text: firstText },
285
+ { type: "footnoteReference", attrs: { label: "shared" } },
286
+ ],
287
+ },
288
+ {
289
+ type: "paragraph",
290
+ content: [
291
+ { type: "text", text: secondText },
292
+ { type: "footnoteReference", attrs: { label: "shared" } },
293
+ ],
294
+ },
295
+ {
296
+ type: "footnoteDefinition",
297
+ attrs: { label: "shared" },
298
+ content: [
299
+ {
300
+ type: "paragraph",
301
+ content: [{ type: "text", text: "Shared definition" }],
302
+ },
303
+ ],
304
+ },
305
+ ],
306
+ });
307
+ const bodyHtml = renderTiptapJson(body, { namespace: UUID_1 });
308
+ const view = toPostView(
309
+ makePostWithMedia({ title: "Article", body, bodyHtml }),
310
+ EMPTY_CTX,
311
+ );
312
+
313
+ expect(view.summaryHasMore).toBe(true);
314
+ const markerIndex = view.bodyHtml?.indexOf('<span id="continue"></span>');
315
+ expect(markerIndex).toBeGreaterThan(
316
+ view.bodyHtml?.indexOf(firstText) ?? -1,
317
+ );
318
+ expect(markerIndex).toBeLessThan(view.bodyHtml?.indexOf(secondText) ?? -1);
319
+ expect(view.bodyHtml).toContain('href="#fnref-1jp3895hp1ag7-1-2"');
320
+ expect(view.bodyHtml).not.toContain("footnote-document");
321
+ expect(view.bodyHtml?.match(/<ol class="footnote-list">/g)).toHaveLength(1);
322
+ expect(view.bodyHtml?.indexOf("Shared definition")).toBeGreaterThan(
323
+ view.bodyHtml?.indexOf(secondText) ?? -1,
324
+ );
325
+ });
326
+
274
327
  it("does not attach summaryHtml for short untitled notes", () => {
275
328
  const body = JSON.stringify({
276
329
  type: "doc",
@@ -1,6 +1,10 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import type { Bindings } from "../../types.js";
3
- import { withWorkerResponseCache } from "../worker-response-cache.js";
3
+ import { POST_BODY_HTML_VERSION } from "../post-body-html.js";
4
+ import {
5
+ normalizeWorkerCacheKeyUrl,
6
+ withWorkerResponseCache,
7
+ } from "../worker-response-cache.js";
4
8
 
5
9
  interface StoredResponse {
6
10
  body: Uint8Array;
@@ -59,6 +63,19 @@ function createCloudflareBindings(overrides: Partial<Bindings> = {}): Bindings {
59
63
  }
60
64
 
61
65
  describe("withWorkerResponseCache", () => {
66
+ it("versions cache keys with the current body HTML contract", () => {
67
+ const url = new URL(
68
+ normalizeWorkerCacheKeyUrl(
69
+ "https://example.com/feed?__jant_body_html=old&utm_source=test",
70
+ ),
71
+ );
72
+
73
+ expect(url.searchParams.get("__jant_body_html")).toBe(
74
+ String(POST_BODY_HTML_VERSION),
75
+ );
76
+ expect(url.searchParams.has("utm_source")).toBe(false);
77
+ });
78
+
62
79
  it("caches anonymous phase-one feed responses and normalizes tracking params", async () => {
63
80
  const cache = createMemoryCache();
64
81
  const bindings = createCloudflareBindings();
@@ -1,31 +1,258 @@
1
- import { CONFIG_FIELDS, type ConfigKey } from "../types.js";
1
+ import {
2
+ CONFIG_FIELDS,
3
+ type Bindings,
4
+ type ConfigEditorDefinition,
5
+ type ConfigEditorFieldState,
6
+ type ConfigEditorKey,
7
+ type ConfigEditorResettableKey,
8
+ type ConfigEditorVisibleKey,
9
+ type ConfigKey,
10
+ } from "../types.js";
11
+ import { getSupportedLocaleEntries } from "../i18n/supported-locales.js";
2
12
  import { SETTINGS_KEYS } from "./constants.js";
3
- import { normalizeTimeZone } from "./timezones.js";
13
+ import { getEnvString } from "./env.js";
14
+ import { normalizeEditableSettingValue } from "./schemas.js";
15
+ import { getTimeZoneOptions } from "./timezones.js";
4
16
 
5
17
  export const demoLockedSettingKeys = new Set<ConfigKey>(["NOINDEX"]);
6
18
 
7
- /** Config keys that can be modified via the settings API */
19
+ /** Config keys explicitly approved for runtime editing. */
8
20
  export const editableSettingKeys = Object.entries(CONFIG_FIELDS)
21
+ .filter(([, field]) => "editor" in field)
22
+ .map(([key]) => key as ConfigEditorKey);
23
+
24
+ /** Safe settings shown in Config Editor, including dedicated-page links. */
25
+ export const configEditorVisibleKeys = Object.entries(CONFIG_FIELDS)
26
+ .filter(([, field]) => "editor" in field || "configEditorLink" in field)
27
+ .map(([key]) => key as ConfigEditorVisibleKey);
28
+
29
+ /** Config Editor keys whose DB override can be safely removed directly. */
30
+ export const resettableConfigEditorKeys = Object.entries(CONFIG_FIELDS)
9
31
  .filter(
10
- ([, field]) => !field.envOnly && !("internal" in field && field.internal),
32
+ ([, field]) =>
33
+ "editor" in field ||
34
+ ("configEditorLink" in field &&
35
+ "resettable" in field.configEditorLink &&
36
+ field.configEditorLink.resettable === true),
11
37
  )
12
- .map(([key]) => key as ConfigKey);
38
+ .map(([key]) => key as ConfigEditorResettableKey);
39
+
40
+ export function isEditableSettingKey(key: string): key is ConfigEditorKey {
41
+ return editableSettingKeys.includes(key as ConfigEditorKey);
42
+ }
43
+
44
+ export function isResettableConfigEditorKey(
45
+ key: string,
46
+ ): key is ConfigEditorResettableKey {
47
+ return resettableConfigEditorKeys.includes(key as ConfigEditorResettableKey);
48
+ }
49
+
50
+ function tryNormalizeResolvedSettingValue(
51
+ key: ConfigEditorKey,
52
+ value: string,
53
+ ): string | undefined {
54
+ try {
55
+ return normalizeEditableSettingValue(key, value);
56
+ } catch {
57
+ return undefined;
58
+ }
59
+ }
60
+
61
+ export function getEditableSettingFallbackValue(
62
+ key: ConfigEditorKey,
63
+ env?: Bindings,
64
+ allSettings: Record<string, string> = {},
65
+ ): string {
66
+ const field = CONFIG_FIELDS[key];
67
+ const envKeys = "envKeys" in field ? field.envKeys : undefined;
68
+ const envValue = env ? getEnvString(env, ...(envKeys ?? [])) : undefined;
69
+ if (envValue) {
70
+ const normalizedEnv = tryNormalizeResolvedSettingValue(key, envValue);
71
+ if (normalizedEnv !== undefined) return normalizedEnv;
72
+ }
73
+
74
+ const normalizedDefault = tryNormalizeResolvedSettingValue(
75
+ key,
76
+ field.defaultValue,
77
+ );
78
+ if (normalizedDefault !== undefined) return normalizedDefault;
79
+
80
+ if ("fallbackKey" in field && field.fallbackKey) {
81
+ return getEditableSettingValue(allSettings, field.fallbackKey, env);
82
+ }
83
+
84
+ throw new Error(`Missing valid Config Editor fallback for ${key}`);
85
+ }
13
86
 
14
87
  export function getEditableSettingValue(
15
88
  allSettings: Record<string, string>,
16
- key: ConfigKey,
89
+ key: ConfigEditorKey,
90
+ env?: Bindings,
17
91
  ): string {
18
- const value = allSettings[key] ?? CONFIG_FIELDS[key].defaultValue;
19
- return key === SETTINGS_KEYS.TIME_ZONE ? normalizeTimeZone(value) : value;
92
+ const fallbackValue = getEditableSettingFallbackValue(key, env, allSettings);
93
+ const value = Object.hasOwn(allSettings, key)
94
+ ? allSettings[key]
95
+ : fallbackValue;
96
+ return (
97
+ tryNormalizeResolvedSettingValue(key, value ?? fallbackValue) ??
98
+ fallbackValue
99
+ );
100
+ }
101
+
102
+ function getEditorConstraints(
103
+ key: ConfigEditorKey,
104
+ definition: ConfigEditorDefinition,
105
+ value: string,
106
+ fallbackValue: string,
107
+ ): Pick<
108
+ ConfigEditorFieldState,
109
+ "maxLength" | "min" | "max" | "step" | "options"
110
+ > {
111
+ switch (definition.type) {
112
+ case "boolean":
113
+ return {};
114
+ case "string":
115
+ return { maxLength: definition.maxLength };
116
+ case "number":
117
+ return {
118
+ min: definition.min,
119
+ max: definition.max,
120
+ step: definition.step,
121
+ };
122
+ case "enum":
123
+ if (definition.options) return { options: definition.options };
124
+ if (definition.optionsSource === "contentLanguage") {
125
+ const options = getSupportedLocaleEntries().map((entry) => entry.tag);
126
+ for (const candidate of [fallbackValue, value]) {
127
+ if (candidate && !options.includes(candidate))
128
+ options.push(candidate);
129
+ }
130
+ return { options };
131
+ }
132
+ if (definition.optionsSource === "timeZone") {
133
+ const options = getTimeZoneOptions(value).map((entry) => entry.value);
134
+ for (const candidate of [fallbackValue, value]) {
135
+ if (candidate && !options.includes(candidate))
136
+ options.push(candidate);
137
+ }
138
+ return { options };
139
+ }
140
+ return { options: [value] };
141
+ }
142
+ }
143
+
144
+ function buildLinkedConfigEditorField(
145
+ allSettings: Record<string, string>,
146
+ env: Bindings,
147
+ key: ConfigEditorVisibleKey,
148
+ ): ConfigEditorFieldState {
149
+ const field = CONFIG_FIELDS[key];
150
+ if (!("configEditorLink" in field)) {
151
+ throw new Error(`Missing Config Editor link metadata for ${key}`);
152
+ }
153
+ const definition = field.configEditorLink;
154
+ const resettable =
155
+ "resettable" in definition && definition.resettable === true;
156
+ const modified = Object.hasOwn(allSettings, key);
157
+ const storedValue = allSettings[key] ?? "";
158
+
159
+ if (definition.display === "configured") {
160
+ const envKeys = "envKeys" in field ? field.envKeys : [];
161
+ const fallbackRawValue =
162
+ getEnvString(env, ...envKeys) || field.defaultValue;
163
+ const effectiveValue = modified ? storedValue : fallbackRawValue;
164
+ return {
165
+ key,
166
+ mode: "link",
167
+ type: definition.type,
168
+ value: effectiveValue.trim() ? "true" : "false",
169
+ fallbackValue: fallbackRawValue.trim() ? "true" : "false",
170
+ modified,
171
+ locked: false,
172
+ settingsPath: definition.settingsPath,
173
+ display: definition.display,
174
+ ...(resettable && { resettable: true }),
175
+ };
176
+ }
177
+
178
+ const fieldEnvKeys = "envKeys" in field ? field.envKeys : [];
179
+ let fallbackValue: string =
180
+ ("fallbackValue" in definition ? definition.fallbackValue : undefined) ??
181
+ getEnvString(env, ...fieldEnvKeys) ??
182
+ field.defaultValue;
183
+ if ("fallbackKey" in definition && definition.fallbackKey) {
184
+ const fallbackField = CONFIG_FIELDS[definition.fallbackKey];
185
+ const envKeys = "envKeys" in fallbackField ? fallbackField.envKeys : [];
186
+ fallbackValue = getEnvString(env, ...envKeys) || fallbackField.defaultValue;
187
+ }
188
+ const value = storedValue.trim() || fallbackValue;
189
+
190
+ return {
191
+ key,
192
+ mode: "link",
193
+ type: definition.type,
194
+ value: definition.type === "boolean" ? String(value === "true") : value,
195
+ fallbackValue,
196
+ modified,
197
+ locked: false,
198
+ settingsPath: definition.settingsPath,
199
+ display: definition.display,
200
+ ...(resettable && { resettable: true }),
201
+ };
202
+ }
203
+
204
+ export function buildConfigEditorFields(
205
+ allSettings: Record<string, string>,
206
+ env: Bindings,
207
+ demoMode: boolean,
208
+ ): ConfigEditorFieldState[] {
209
+ return configEditorVisibleKeys.map((key) => {
210
+ if ("configEditorLink" in CONFIG_FIELDS[key]) {
211
+ return buildLinkedConfigEditorField(allSettings, env, key);
212
+ }
213
+
214
+ if (!isEditableSettingKey(key)) {
215
+ throw new Error(`Missing Config Editor metadata for ${key}`);
216
+ }
217
+
218
+ const definition: ConfigEditorDefinition = CONFIG_FIELDS[key].editor;
219
+ const fallbackValue = getEditableSettingFallbackValue(
220
+ key,
221
+ env,
222
+ allSettings,
223
+ );
224
+ const value =
225
+ demoMode && key === SETTINGS_KEYS.NOINDEX
226
+ ? "true"
227
+ : getEditableSettingValue(allSettings, key, env);
228
+ const fallbackKey = (CONFIG_FIELDS[key] as { fallbackKey?: "PAGE_SIZE" })
229
+ .fallbackKey;
230
+ const base = {
231
+ key,
232
+ mode: "edit" as const,
233
+ type: definition.type,
234
+ value,
235
+ fallbackValue,
236
+ modified: Object.hasOwn(allSettings, key),
237
+ locked: demoMode && demoLockedSettingKeys.has(key),
238
+ ...(fallbackKey ? { fallbackKey } : {}),
239
+ };
240
+
241
+ return {
242
+ ...base,
243
+ ...getEditorConstraints(key, definition, value, fallbackValue),
244
+ };
245
+ });
20
246
  }
21
247
 
22
248
  export function buildEditableSettingsResponse(
23
249
  allSettings: Record<string, string>,
24
250
  demoMode: boolean,
251
+ env?: Bindings,
25
252
  ): Record<string, string> {
26
253
  const result: Record<string, string> = {};
27
254
  for (const key of editableSettingKeys) {
28
- result[key] = getEditableSettingValue(allSettings, key);
255
+ result[key] = getEditableSettingValue(allSettings, key, env);
29
256
  }
30
257
  if (demoMode) {
31
258
  result.NOINDEX = "true";
@@ -38,14 +265,14 @@ export function partitionEditableSettingUpdates(
38
265
  updates: Record<string, string>,
39
266
  demoMode: boolean,
40
267
  ): {
41
- filteredUpdates: Partial<Record<ConfigKey, string>>;
268
+ filteredUpdates: Partial<Record<ConfigEditorKey, string>>;
42
269
  rejectedKeys: string[];
43
270
  } {
44
- const filteredUpdates: Partial<Record<ConfigKey, string>> = {};
271
+ const filteredUpdates: Partial<Record<ConfigEditorKey, string>> = {};
45
272
  const rejectedKeys: string[] = [];
46
273
 
47
274
  for (const [key, value] of Object.entries(updates)) {
48
- const configKey = key as ConfigKey;
275
+ const configKey = key as ConfigEditorKey;
49
276
 
50
277
  if (
51
278
  demoMode &&
@@ -18,9 +18,9 @@ export function isRatingSortOrder(
18
18
  }
19
19
 
20
20
  /**
21
- * Returns true when a collection has enough rated posts to make rating sort useful.
21
+ * Returns true when a collection has enough rated Threads to make rating sort useful.
22
22
  *
23
- * @param ratedPostCount - Number of posts in the collection with a rating
23
+ * @param ratedThreadCount - Number of Threads with at least one rating
24
24
  * @returns Whether rating sort should be shown to readers
25
25
  *
26
26
  * @example
@@ -28,13 +28,15 @@ export function isRatingSortOrder(
28
28
  * supportsCollectionRatingSort(2);
29
29
  * ```
30
30
  */
31
- export function supportsCollectionRatingSort(ratedPostCount: number): boolean {
32
- return ratedPostCount > 1;
31
+ export function supportsCollectionRatingSort(
32
+ ratedThreadCount: number,
33
+ ): boolean {
34
+ return ratedThreadCount > 1;
33
35
  }
34
36
 
35
37
  /**
36
38
  * Resolves the sort order for a collection page, falling back when rating
37
- * sorting is requested but the collection does not have enough rated posts.
39
+ * sorting is requested but the collection does not have enough rated Threads.
38
40
  *
39
41
  * @param requestedSort - Sort order from the request query
40
42
  * @param defaultSort - Collection default sort order
@@ -22,12 +22,14 @@ export const RESERVED_PATHS = [
22
22
  "reset",
23
23
  "collections",
24
24
  "compose",
25
+ "preview",
25
26
  "new",
26
27
  "static",
27
28
  "assets",
28
29
  "_assets",
29
30
  "healthz",
30
31
  "readyz",
32
+ "skill.md",
31
33
  ] as const;
32
34
 
33
35
  export type ReservedPath = (typeof RESERVED_PATHS)[number];
@@ -0,0 +1,25 @@
1
+ const NON_FEED_PATH_PREFIXES = ["/api", "/settings", "/compose", "/_"];
2
+
3
+ function hasPathPrefix(path: string, prefix: string): boolean {
4
+ return path === prefix || path.startsWith(`${prefix}/`);
5
+ }
6
+
7
+ /**
8
+ * Check whether an internal application path is an Atom feed endpoint.
9
+ *
10
+ * @param path - Request pathname after any configured site prefix is removed
11
+ * @returns Whether the path is a canonical or legacy feed URL
12
+ * @example
13
+ * ```ts
14
+ * isRssFeedPath("/reading/feed"); // true
15
+ * isRssFeedPath("/api/posts"); // false
16
+ * ```
17
+ */
18
+ export function isRssFeedPath(path: string): boolean {
19
+ if (path === "/feed" || path.startsWith("/feed/")) return true;
20
+ if (NON_FEED_PATH_PREFIXES.some((prefix) => hasPathPrefix(path, prefix))) {
21
+ return false;
22
+ }
23
+
24
+ return path.endsWith("/feed") || path.endsWith("/feed/atom.xml");
25
+ }