@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
@@ -18,7 +18,7 @@ import { renderPublicPage } from "../../lib/render.js";
18
18
  import { getNavigationData } from "../../lib/navigation.js";
19
19
  import { buildPageTitle } from "../../lib/page-title.js";
20
20
  import { AdminBreadcrumb } from "../../ui/shared/AdminBreadcrumb.js";
21
- import { TIMEZONES } from "../../lib/timezones.js";
21
+ import { getTimeZoneOptions } from "../../lib/timezones.js";
22
22
  import { ValidationError } from "../../lib/errors.js";
23
23
  import { SETTINGS_KEYS } from "../../lib/constants.js";
24
24
  import { getAvailableThemes } from "../../lib/theme.js";
@@ -39,6 +39,7 @@ import { FontThemeContent } from "../../ui/dash/appearance/FontThemeContent.js";
39
39
  import { AdvancedContent } from "../../ui/dash/appearance/AdvancedContent.js";
40
40
  import { CodeInjectionContent } from "../../ui/dash/appearance/CodeInjectionContent.js";
41
41
  import { ApiTokensContent } from "../../ui/dash/settings/ApiTokensContent.js";
42
+ import { ConfigEditorContent } from "../../ui/dash/settings/ConfigEditorContent.js";
42
43
  import { DeleteAccountContent } from "../../ui/dash/settings/DeleteAccountContent.js";
43
44
  import {
44
45
  GitHubSyncContent,
@@ -78,6 +79,7 @@ import {
78
79
  triggerGitHubSyncInline,
79
80
  } from "../../lib/github-sync-trigger.js";
80
81
  import { buildSyncSiteConfig } from "../../lib/github-sync-site-config.js";
82
+ import { buildConfigEditorFields } from "../../lib/api-settings.js";
81
83
  import {
82
84
  readGitHubSyncStatus,
83
85
  renderStatusCardHtml,
@@ -142,6 +144,7 @@ function breadcrumbLabel(
142
144
  | "fontTheme"
143
145
  | "customCss"
144
146
  | "codeInjection"
147
+ | "config"
145
148
  | "account"
146
149
  | "sessions"
147
150
  | "password"
@@ -187,6 +190,13 @@ function breadcrumbLabel(
187
190
  comment: "@context: Breadcrumb label",
188
191
  }),
189
192
  );
193
+ case "config":
194
+ return i18n._(
195
+ msg({
196
+ message: "Config Editor",
197
+ comment: "@context: Breadcrumb label",
198
+ }),
199
+ );
190
200
  case "account":
191
201
  return i18n._(
192
202
  msg({
@@ -366,7 +376,7 @@ settingsRoutes.get("/general", async (c) => {
366
376
  showJantBrandingOnHome={appConfig.showJantBrandingOnHome}
367
377
  noindex={appConfig.noindex}
368
378
  demoMode={appConfig.demoMode}
369
- timezones={TIMEZONES}
379
+ timezones={getTimeZoneOptions(appConfig.timeZone)}
370
380
  aboutPage={aboutPage}
371
381
  aboutEditUrl={aboutEditPath(c)}
372
382
  aboutCreateUrl={publicPath(c, "/settings/general/about-page")}
@@ -808,6 +818,7 @@ settingsRoutes.get("/navigation", async (c) => {
808
818
  directoryData={directoryData}
809
819
  suggestedLinks={suggestedLinks}
810
820
  mainRssFeed={c.var.appConfig.mainRssFeed}
821
+ rssFeedsEnabled={c.var.appConfig.rssFeedsEnabled}
811
822
  siteName={navData.siteName}
812
823
  sitePathPrefix={c.var.appConfig.sitePathPrefix}
813
824
  />
@@ -1051,6 +1062,38 @@ settingsRoutes.post("/code-injection", async (c) => {
1051
1062
  );
1052
1063
  });
1053
1064
 
1065
+ // ===========================================================================
1066
+ // Config Editor — explicitly approved runtime settings only.
1067
+ // ===========================================================================
1068
+
1069
+ settingsRoutes.get("/config", async (c) => {
1070
+ const navData = await getNavigationData(c);
1071
+ const fields = buildConfigEditorFields(
1072
+ c.var.allSettings,
1073
+ c.env,
1074
+ c.var.appConfig.demoMode,
1075
+ );
1076
+
1077
+ return renderPublicPage(c, {
1078
+ title: buildPageTitle("Config Editor", navData.siteName),
1079
+ navData,
1080
+ content: (
1081
+ <>
1082
+ <AdminBreadcrumb
1083
+ parent={breadcrumbLabel(c, "settings")}
1084
+ parentHref={publicPath(c, "/settings")}
1085
+ current={breadcrumbLabel(c, "config")}
1086
+ />
1087
+ <ConfigEditorContent
1088
+ fields={fields}
1089
+ endpoint={publicPath(c, "/api/settings")}
1090
+ sitePathPrefix={c.var.appConfig.sitePathPrefix}
1091
+ />
1092
+ </>
1093
+ ),
1094
+ });
1095
+ });
1096
+
1054
1097
  // ===========================================================================
1055
1098
  // Account sub-menu
1056
1099
  // ===========================================================================
@@ -1,4 +1,4 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { eq } from "drizzle-orm";
3
3
  import { Hono } from "hono";
4
4
  import type { Bindings } from "../../../types.js";
@@ -43,6 +43,7 @@ function createFeedTestApp(envOverrides: Partial<Bindings> = {}) {
43
43
  const env = {
44
44
  SITE_ORIGIN: `http://localhost:${DEFAULT_APP_PORT}`,
45
45
  SITE_PATH_PREFIX: "",
46
+ RSS_PUBLISH_DELAY_SECONDS: 0,
46
47
  ...envOverrides,
47
48
  } as Bindings;
48
49
  c.env = env;
@@ -67,6 +68,10 @@ function createFeedTestApp(envOverrides: Partial<Bindings> = {}) {
67
68
  return { app, services, db: db as unknown as Database };
68
69
  }
69
70
 
71
+ afterEach(() => {
72
+ vi.restoreAllMocks();
73
+ });
74
+
70
75
  describe("Atom Feed Routes", () => {
71
76
  describe("/feed — site main feed", () => {
72
77
  it("defaults to featured posts", async () => {
@@ -170,9 +175,10 @@ describe("Atom Feed Routes", () => {
170
175
  expect(res.headers.get("Content-Type")).toBe(
171
176
  "application/atom+xml; charset=utf-8",
172
177
  );
178
+ expect(res.headers.get("Cache-Control")).toBe("public, max-age=60");
173
179
  });
174
180
 
175
- it("orders featured items by featuredAt and preserves original publishedAt", async () => {
181
+ it("orders Featured Threads by selected Post publication time", async () => {
176
182
  const { app, services, db } = createFeedTestApp();
177
183
 
178
184
  const olderPublished = await services.posts.create({
@@ -203,11 +209,9 @@ describe("Atom Feed Routes", () => {
203
209
  expect(res.status).toBe(200);
204
210
 
205
211
  const xml = await res.text();
206
- // Older published should appear first because it has a later featuredAt
207
- expect(xml.indexOf("Older published")).toBeLessThan(
208
- xml.indexOf("Newer published"),
212
+ expect(xml.indexOf("Newer published")).toBeLessThan(
213
+ xml.indexOf("Older published"),
209
214
  );
210
- // <published> should reflect original publishedAt, not featuredAt
211
215
  expect(xml).toContain("<published>1970-01-01T00:16:40.000Z</published>");
212
216
  expect(xml).toContain("<published>1970-01-01T00:33:20.000Z</published>");
213
217
  });
@@ -352,6 +356,45 @@ describe("Atom Feed Routes", () => {
352
356
  expect(xml).toContain("Featured Post");
353
357
  expect(xml).not.toContain("Regular Post");
354
358
  });
359
+
360
+ it("deduplicates by Thread and orders from a Featured Child publication", async () => {
361
+ const { app, services } = createFeedTestApp();
362
+
363
+ const olderRoot = await services.posts.create({
364
+ format: "note",
365
+ title: "Older Thread Root",
366
+ bodyMarkdown: "Older root body",
367
+ status: "published",
368
+ publishedAt: 1000,
369
+ });
370
+ await services.posts.create({
371
+ format: "note",
372
+ title: "Featured Child",
373
+ bodyMarkdown: "Selected child body",
374
+ status: "published",
375
+ replyToId: olderRoot.id,
376
+ featured: true,
377
+ publishedAt: 3000,
378
+ });
379
+ await services.posts.create({
380
+ format: "note",
381
+ title: "Newer Root",
382
+ bodyMarkdown: "Newer root body",
383
+ status: "published",
384
+ featured: true,
385
+ publishedAt: 2000,
386
+ });
387
+
388
+ const res = await app.request("/featured/feed");
389
+ expect(res.status).toBe(200);
390
+
391
+ const xml = await res.text();
392
+ expect(xml.match(/<entry>/g)).toHaveLength(2);
393
+ expect(xml).toContain("Selected child body");
394
+ expect(xml.indexOf("Older Thread Root")).toBeLessThan(
395
+ xml.indexOf("Newer Root"),
396
+ );
397
+ });
355
398
  });
356
399
 
357
400
  describe("legacy feed path redirects", () => {
@@ -432,6 +475,33 @@ describe("Atom Feed Routes", () => {
432
475
  });
433
476
  });
434
477
 
478
+ it("emits canonical v3 footnotes from a stale stored projection", async () => {
479
+ const { app, services, db } = createFeedTestApp();
480
+ const post = await services.posts.create({
481
+ format: "note",
482
+ title: "Footnote feed",
483
+ bodyMarkdown: "Feed body[^1]\n\n[^1]: Feed definition",
484
+ status: "published",
485
+ });
486
+ await db
487
+ .update(postTable)
488
+ .set({
489
+ bodyHtml: '<span class="sidenote">legacy</span>',
490
+ bodyHtmlVersion: 1,
491
+ })
492
+ .where(eq(postTable.id, post.id));
493
+
494
+ const response = await app.request("/latest/feed");
495
+ const xml = await response.text();
496
+
497
+ expect(response.status).toBe(200);
498
+ expect(xml).toContain('role="doc-noteref"');
499
+ expect(xml).toContain('role="doc-endnotes"');
500
+ expect(xml).toMatch(/id="fn-[a-z0-9]{13}-1"/);
501
+ expect(xml).not.toContain(`fn-${post.id}`);
502
+ expect(xml).not.toContain("legacy");
503
+ });
504
+
435
505
  describe("RSS_FEED_LIMIT env var", () => {
436
506
  it("defaults to 50 when RSS_FEED_LIMIT is not set", async () => {
437
507
  const { app, services } = createFeedTestApp();
@@ -527,6 +597,85 @@ describe("Atom Feed Routes", () => {
527
597
  });
528
598
  });
529
599
 
600
+ describe("RSS_PUBLISH_DELAY_SECONDS env var", () => {
601
+ it("includes the exact 300-second boundary and excludes newer Posts", async () => {
602
+ const currentTime = 2_000_000;
603
+ vi.spyOn(Date, "now").mockReturnValue(currentTime * 1000);
604
+ const { app, services } = createFeedTestApp({
605
+ RSS_PUBLISH_DELAY_SECONDS: 300,
606
+ });
607
+
608
+ await services.posts.create({
609
+ format: "note",
610
+ title: "Boundary Post",
611
+ bodyMarkdown: "Eligible at exactly five minutes",
612
+ status: "published",
613
+ publishedAt: currentTime - 300,
614
+ });
615
+ await services.posts.create({
616
+ format: "note",
617
+ title: "Recent Post",
618
+ bodyMarkdown: "Still inside the edit window",
619
+ status: "published",
620
+ publishedAt: currentTime - 299,
621
+ });
622
+
623
+ const xml = await (await app.request("/latest/feed")).text();
624
+
625
+ expect(xml).toContain("Boundary Post");
626
+ expect(xml).not.toContain("Recent Post");
627
+ });
628
+
629
+ it("applies the delay to Featured Posts", async () => {
630
+ const currentTime = 2_000_000;
631
+ vi.spyOn(Date, "now").mockReturnValue(currentTime * 1000);
632
+ const { app, services } = createFeedTestApp({
633
+ RSS_PUBLISH_DELAY_SECONDS: 300,
634
+ });
635
+
636
+ await services.posts.create({
637
+ format: "note",
638
+ title: "Eligible Featured Post",
639
+ bodyMarkdown: "Old enough",
640
+ status: "published",
641
+ featured: true,
642
+ publishedAt: currentTime - 300,
643
+ });
644
+ await services.posts.create({
645
+ format: "note",
646
+ title: "Recent Featured Post",
647
+ bodyMarkdown: "Too new",
648
+ status: "published",
649
+ featured: true,
650
+ publishedAt: currentTime,
651
+ });
652
+
653
+ const xml = await (await app.request("/featured/feed")).text();
654
+
655
+ expect(xml).toContain("Eligible Featured Post");
656
+ expect(xml).not.toContain("Recent Featured Post");
657
+ });
658
+
659
+ it("lets zero disable the delay", async () => {
660
+ const currentTime = 2_000_000;
661
+ vi.spyOn(Date, "now").mockReturnValue(currentTime * 1000);
662
+ const { app, services } = createFeedTestApp({
663
+ RSS_PUBLISH_DELAY_SECONDS: 0,
664
+ });
665
+ await services.posts.create({
666
+ format: "note",
667
+ title: "Immediate Post",
668
+ bodyMarkdown: "No edit window",
669
+ status: "published",
670
+ publishedAt: currentTime,
671
+ });
672
+
673
+ const xml = await (await app.request("/latest/feed")).text();
674
+
675
+ expect(xml).toContain("Immediate Post");
676
+ });
677
+ });
678
+
530
679
  describe("thread content in feed", () => {
531
680
  it("includes thread replies inline with hr separators", async () => {
532
681
  const { app, services } = createFeedTestApp();
@@ -552,5 +701,60 @@ describe("Atom Feed Routes", () => {
552
701
  expect(xml).toContain("Reply content");
553
702
  expect(xml).toContain("<hr/>");
554
703
  });
704
+
705
+ it("delays new replies inside an already eligible Thread", async () => {
706
+ const currentTime = 2_000_000;
707
+ const dateNow = vi.spyOn(Date, "now").mockReturnValue(currentTime * 1000);
708
+ const { app, services } = createFeedTestApp({
709
+ RSS_PUBLISH_DELAY_SECONDS: 300,
710
+ });
711
+ const root = await services.posts.create({
712
+ format: "note",
713
+ title: "Eligible Thread Root",
714
+ bodyMarkdown: "Root content",
715
+ status: "published",
716
+ publishedAt: currentTime - 600,
717
+ });
718
+ await services.posts.create({
719
+ format: "note",
720
+ bodyMarkdown: "Recent reply content",
721
+ status: "published",
722
+ replyToId: root.id,
723
+ publishedAt: currentTime,
724
+ });
725
+
726
+ const beforeDelay = await (await app.request("/latest/feed")).text();
727
+ expect(beforeDelay).toContain("Root content");
728
+ expect(beforeDelay).not.toContain("Recent reply content");
729
+
730
+ dateNow.mockReturnValue((currentTime + 300) * 1000);
731
+ const afterDelay = await (await app.request("/latest/feed")).text();
732
+ expect(afterDelay).toContain("Recent reply content");
733
+ });
734
+
735
+ it("reflects eligible content edits in the Atom entry updated timestamp", async () => {
736
+ const currentTime = 2_000_000;
737
+ vi.spyOn(Date, "now").mockReturnValue(currentTime * 1000);
738
+ const { app, services } = createFeedTestApp({
739
+ RSS_PUBLISH_DELAY_SECONDS: 300,
740
+ });
741
+ const root = await services.posts.create({
742
+ format: "note",
743
+ title: "Edited Post",
744
+ bodyMarkdown: "Original copy",
745
+ status: "published",
746
+ publishedAt: currentTime - 300,
747
+ });
748
+ await services.posts.update(root.id, {
749
+ bodyMarkdown: "Corrected copy",
750
+ });
751
+
752
+ const xml = await (await app.request("/latest/feed")).text();
753
+ const entry = xml.match(/<entry>[\s\S]*?<\/entry>/)?.[0];
754
+
755
+ expect(entry).toContain("Corrected copy");
756
+ expect(entry).not.toContain("Original copy");
757
+ expect(entry).toContain("<updated>1970-01-24T03:33:20.000Z</updated>");
758
+ });
555
759
  });
556
760
  });
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { describe, expect, it, vi } from "vitest";
2
2
  import { Hono } from "hono";
3
3
  import type { Bindings } from "../../../types.js";
4
4
  import type { AppVariables } from "../../../types/app-context.js";
@@ -154,6 +154,73 @@ describe("Sitemap Routes", () => {
154
154
  expect(indices).toEqual([...indices].sort((a, b) => a - b));
155
155
  });
156
156
 
157
+ // A Thread root's page renders its replies, so anything that changes a
158
+ // reply changes the page. Unlike ordering, edits count for <lastmod> —
159
+ // the page really did change.
160
+ it("moves <lastmod> when a reply is added", async () => {
161
+ const { app, services } = createSitemapTestApp();
162
+ const readLastmod = async () => {
163
+ const xml = await (await app.request("/sitemap-posts-1.xml")).text();
164
+ return /<lastmod>([^<]+)<\/lastmod>/.exec(xml)?.[1];
165
+ };
166
+
167
+ vi.useFakeTimers();
168
+ try {
169
+ // lastmod is a date, so the two writes need to land on different days.
170
+ vi.setSystemTime(new Date("2026-03-01T00:00:00Z"));
171
+ const root = await services.posts.create({
172
+ format: "note",
173
+ bodyMarkdown: "root",
174
+ title: "Threaded",
175
+ status: "published",
176
+ });
177
+ expect(await readLastmod()).toBe("2026-03-01");
178
+
179
+ vi.setSystemTime(new Date("2026-03-05T00:00:00Z"));
180
+ await services.posts.create({
181
+ format: "note",
182
+ bodyMarkdown: "reply",
183
+ replyToId: root.id,
184
+ status: "published",
185
+ });
186
+ expect(await readLastmod()).toBe("2026-03-05");
187
+ } finally {
188
+ vi.useRealTimers();
189
+ }
190
+ });
191
+
192
+ it("ignores a draft reply in <lastmod>", async () => {
193
+ const { app, services } = createSitemapTestApp();
194
+ const readLastmod = async () => {
195
+ const xml = await (await app.request("/sitemap-posts-1.xml")).text();
196
+ return /<lastmod>([^<]+)<\/lastmod>/.exec(xml)?.[1];
197
+ };
198
+
199
+ vi.useFakeTimers();
200
+ try {
201
+ vi.setSystemTime(new Date("2026-03-01T00:00:00Z"));
202
+ const root = await services.posts.create({
203
+ format: "note",
204
+ bodyMarkdown: "root",
205
+ title: "Threaded",
206
+ status: "published",
207
+ });
208
+
209
+ vi.setSystemTime(new Date("2026-03-05T00:00:00Z"));
210
+ await services.posts.create({
211
+ format: "note",
212
+ bodyMarkdown: "unpublished reply",
213
+ replyToId: root.id,
214
+ status: "draft",
215
+ });
216
+
217
+ // Not on the page, so it must not claim the page changed.
218
+ expect(await readLastmod()).toBe("2026-03-01");
219
+ } finally {
220
+ vi.useRealTimers();
221
+ }
222
+ });
223
+
157
224
  it("excludes private posts, replies, and drafts", async () => {
158
225
  const { app, services } = createSitemapTestApp();
159
226
  const root = await services.posts.create({
@@ -27,9 +27,13 @@ import type {
27
27
  } from "../../types.js";
28
28
  import type { AppVariables } from "../../types/app-context.js";
29
29
  import { defaultFeedRenderer } from "../../lib/feed.js";
30
+ import {
31
+ getFeedEntryUpdatedAt,
32
+ getRssPublishedBefore,
33
+ RSS_FEED_CACHE_CONTROL,
34
+ } from "../../lib/feed-policy.js";
30
35
  import { buildMediaMap } from "../../lib/media-helpers.js";
31
36
  import { getI18n } from "../../i18n/index.js";
32
- import { toISOString } from "../../lib/time.js";
33
37
  import { FORMATS } from "../../types/constants.js";
34
38
 
35
39
  import { createMediaContext, toPostViews } from "../../lib/view.js";
@@ -118,6 +122,7 @@ function collectReplyIds(threadMap: Map<string, Post[]>): string[] {
118
122
  async function buildLatestFeedData(
119
123
  c: Context<Env>,
120
124
  feedLimit: number,
125
+ publishedBefore: number,
121
126
  format?: Format,
122
127
  ): Promise<{ posts: Post[]; postViews: FeedPostView[] }> {
123
128
  const posts = await c.var.services.posts.list({
@@ -127,6 +132,7 @@ async function buildLatestFeedData(
127
132
  excludePrivate: true,
128
133
  format,
129
134
  ignorePinnedSort: true,
135
+ publishedBefore,
130
136
  limit: feedLimit,
131
137
  });
132
138
 
@@ -135,7 +141,7 @@ async function buildLatestFeedData(
135
141
 
136
142
  const mediaCtx = createMediaContext(c.var.appConfig);
137
143
  const [threadMap, mediaMap, aliasesMap] = await Promise.all([
138
- c.var.services.posts.getPublishedThreads(rootIds),
144
+ c.var.services.posts.getPublishedThreads(rootIds, { publishedBefore }),
139
145
  loadMediaMap(c, postIds, mediaCtx),
140
146
  c.var.services.paths.getPostAliases(postIds),
141
147
  ]);
@@ -162,9 +168,10 @@ async function buildLatestFeedData(
162
168
  aliasMap,
163
169
  ).map((postView, index) => {
164
170
  const post = posts[index] as (typeof posts)[number];
171
+ const thread = threadMap.get(post.id);
165
172
  return {
166
173
  ...postView,
167
- feedUpdatedAt: toISOString(post.lastActivityAt),
174
+ feedUpdatedAt: getFeedEntryUpdatedAt(post, thread),
168
175
  threadReplies: buildThreadReplies(
169
176
  post.id,
170
177
  threadMap,
@@ -187,10 +194,12 @@ async function buildLatestFeedData(
187
194
  async function buildFeaturedFeedData(
188
195
  c: Context<Env>,
189
196
  feedLimit: number,
197
+ publishedBefore: number,
190
198
  ): Promise<{ posts: Post[]; postViews: FeedPostView[] }> {
191
199
  const rootIds = await c.var.services.posts.listFeaturedThreadRootIds({
192
200
  status: "published",
193
201
  excludePrivate: true,
202
+ publishedBefore,
194
203
  limit: feedLimit,
195
204
  });
196
205
 
@@ -198,7 +207,9 @@ async function buildFeaturedFeedData(
198
207
  return { posts: [], postViews: [] };
199
208
  }
200
209
 
201
- const threadMap = await c.var.services.posts.getPublishedThreads(rootIds);
210
+ const threadMap = await c.var.services.posts.getPublishedThreads(rootIds, {
211
+ publishedBefore,
212
+ });
202
213
 
203
214
  // Extract root posts in the same order as rootIds
204
215
  const posts: Post[] = [];
@@ -241,16 +252,11 @@ async function buildFeaturedFeedData(
241
252
  aliasMap,
242
253
  ).map((postView, index) => {
243
254
  const post = posts[index] as (typeof posts)[number];
244
-
245
- // feedUpdatedAt = max(lastActivityAt, featuredAt)
246
- const lastActivity = toISOString(post.lastActivityAt);
247
- const featuredIso = post.featuredAt ? toISOString(post.featuredAt) : null;
248
- const feedUpdatedAt =
249
- featuredIso && featuredIso > lastActivity ? featuredIso : lastActivity;
255
+ const thread = threadMap.get(post.id);
250
256
 
251
257
  return {
252
258
  ...postView,
253
- feedUpdatedAt,
259
+ feedUpdatedAt: getFeedEntryUpdatedAt(post, thread),
254
260
  threadReplies: buildThreadReplies(post.id, threadMap, mediaMap, mediaCtx),
255
261
  };
256
262
  });
@@ -280,12 +286,15 @@ export async function buildFeedData(
280
286
  const siteUrl = appConfig.siteUrl;
281
287
  const siteLanguage = appConfig.siteLanguage;
282
288
  const feedLimit = appConfig.rssFeedLimit;
289
+ const publishedBefore = getRssPublishedBefore(
290
+ appConfig.rssPublishDelaySeconds,
291
+ );
283
292
  const kind = opts.kind;
284
293
 
285
294
  const { postViews } =
286
295
  kind === "featured"
287
- ? await buildFeaturedFeedData(c, feedLimit)
288
- : await buildLatestFeedData(c, feedLimit, opts.format);
296
+ ? await buildFeaturedFeedData(c, feedLimit, publishedBefore)
297
+ : await buildLatestFeedData(c, feedLimit, publishedBefore, opts.format);
289
298
 
290
299
  return {
291
300
  siteName,
@@ -333,7 +342,7 @@ export function renderFeed(xml: string) {
333
342
  return new Response(xml, {
334
343
  headers: {
335
344
  "Content-Type": "application/atom+xml; charset=utf-8",
336
- "Cache-Control": "public, max-age=180",
345
+ "Cache-Control": RSS_FEED_CACHE_CONTROL,
337
346
  },
338
347
  });
339
348
  }