@jant/core 0.6.14 → 0.6.15

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 (311) hide show
  1. package/bin/commands/import-site.js +81 -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-BxCOR3Uc.js +6 -0
  12. package/dist/{app-2i7-WQHg.js → app-C-oi_t8W.js} +7408 -6343
  13. package/dist/client/.vite/manifest.json +3 -3
  14. package/dist/client/_assets/client-B_eGKN5p.css +2 -0
  15. package/dist/client/_assets/{client-BvID5ucz.js → client-DxY5BFe8.js} +58 -37
  16. package/dist/client/_assets/client-auth-CakPESv9.js +5533 -0
  17. package/dist/{export-DWn149b7.js → export-CsFx6F_M.js} +1259 -125
  18. package/dist/{github-sync-Ckk0sJxK.js → github-sync-Cq1pzzOI.js} +1 -1
  19. package/dist/{github-sync-C0zyF6XS.js → github-sync-ppWXN3jb.js} +2 -2
  20. package/dist/index.js +3 -3
  21. package/dist/node.js +5 -5
  22. package/package.json +11 -10
  23. package/src/__tests__/bin/posts-rebuild-html.test.ts +137 -0
  24. package/src/__tests__/dev-navigation-defaults.test.ts +22 -0
  25. package/src/__tests__/export-hugo-build.test.ts +63 -4
  26. package/src/__tests__/export-import-roundtrip.test.ts +6 -4
  27. package/src/__tests__/export-service.test.ts +142 -9
  28. package/src/__tests__/helpers/app.ts +3 -0
  29. package/src/__tests__/helpers/export-fixtures.ts +2 -0
  30. package/src/__tests__/import-site-command.test.ts +65 -0
  31. package/src/__tests__/vite-config.test.ts +45 -0
  32. package/src/app.tsx +16 -3
  33. package/src/client/__tests__/collection-created-notice.test.ts +36 -0
  34. package/src/client/__tests__/collection-form-bridge.test.ts +10 -1
  35. package/src/client/__tests__/collection-page-actions.test.ts +71 -2
  36. package/src/client/__tests__/compose-bridge.test.ts +64 -0
  37. package/src/client/__tests__/feed-video-player.test.ts +16 -1
  38. package/src/client/__tests__/footnote-rail.test.ts +472 -0
  39. package/src/client/__tests__/hydrate-partial.test.ts +3 -0
  40. package/src/client/__tests__/toast.test.ts +18 -1
  41. package/src/client/collection-created-notice.ts +66 -0
  42. package/src/client/collection-form-bridge.ts +17 -0
  43. package/src/client/collection-navigation.ts +44 -0
  44. package/src/client/collection-page-actions.ts +57 -1
  45. package/src/client/components/__tests__/jant-collection-directory.test.ts +155 -6
  46. package/src/client/components/__tests__/jant-compose-dialog.test.ts +370 -5
  47. package/src/client/components/__tests__/jant-compose-editor.test.ts +97 -0
  48. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +104 -0
  49. package/src/client/components/__tests__/jant-config-editor.test.ts +405 -0
  50. package/src/client/components/__tests__/jant-media-lightbox.test.ts +121 -0
  51. package/src/client/components/__tests__/jant-nav-manager.test.ts +538 -2
  52. package/src/client/components/__tests__/jant-post-form.test.ts +2 -2
  53. package/src/client/components/__tests__/jant-post-menu.test.ts +47 -5
  54. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
  55. package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
  56. package/src/client/components/collection-manager-types.ts +9 -3
  57. package/src/client/components/compose-types.ts +8 -0
  58. package/src/client/components/jant-collection-directory.ts +165 -30
  59. package/src/client/components/jant-command-palette.ts +2 -1
  60. package/src/client/components/jant-compose-dialog.ts +85 -21
  61. package/src/client/components/jant-compose-editor.ts +13 -7
  62. package/src/client/components/jant-compose-fullscreen.ts +24 -4
  63. package/src/client/components/jant-config-editor.ts +722 -0
  64. package/src/client/components/jant-media-lightbox.ts +100 -18
  65. package/src/client/components/jant-nav-manager.ts +1283 -54
  66. package/src/client/components/jant-post-form.ts +3 -3
  67. package/src/client/components/jant-post-menu.ts +86 -74
  68. package/src/client/components/nav-manager-types.ts +51 -2
  69. package/src/client/components/post-form-types.ts +1 -1
  70. package/src/client/compose-bridge.ts +4 -1
  71. package/src/client/feed-video-player.ts +62 -2
  72. package/src/client/footnote-rail.ts +478 -0
  73. package/src/client/hydrate-partial.ts +2 -0
  74. package/src/client/media-lightbox-events.ts +63 -0
  75. package/src/client/site-header-fragment.ts +38 -0
  76. package/src/client/tiptap/__tests__/blockquote-editing.test.ts +201 -0
  77. package/src/client/tiptap/__tests__/clear-formatting.test.ts +168 -0
  78. package/src/client/tiptap/__tests__/list-editing.test.ts +447 -0
  79. package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +727 -4
  80. package/src/client/tiptap/__tests__/table-controls.test.ts +263 -0
  81. package/src/client/tiptap/__tests__/table-size-picker.test.ts +179 -0
  82. package/src/client/tiptap/bubble-menu.ts +88 -10
  83. package/src/client/tiptap/continuous-lists.ts +102 -0
  84. package/src/client/tiptap/create-editor.ts +3 -0
  85. package/src/client/tiptap/extensions.ts +14 -5
  86. package/src/client/tiptap/markdown-clipboard.ts +608 -11
  87. package/src/client/tiptap/slash-commands.ts +8 -1
  88. package/src/client/tiptap/structural-keymap.ts +211 -0
  89. package/src/client/tiptap/tab-indent.ts +8 -22
  90. package/src/client/tiptap/table-control-labels.ts +30 -0
  91. package/src/client/tiptap/table-controls.ts +537 -0
  92. package/src/client/tiptap/table-size-picker.ts +254 -0
  93. package/src/client/toast.ts +8 -6
  94. package/src/client-auth.ts +1 -0
  95. package/src/client.ts +1 -0
  96. package/src/db/__tests__/d1-query.test.ts +43 -1
  97. package/src/db/__tests__/migration-runner.test.ts +63 -0
  98. package/src/db/__tests__/migrations.test.ts +469 -0
  99. package/src/db/__tests__/thread-collection-migration.test.ts +364 -0
  100. package/src/db/migrations/0027_old_blue_blade.sql +39 -0
  101. package/src/db/migrations/0028_superb_post.sql +2 -0
  102. package/src/db/migrations/0029_boring_magma.sql +1 -0
  103. package/src/db/migrations/0030_typical_vivisector.sql +50 -0
  104. package/src/db/migrations/meta/0027_snapshot.json +2511 -0
  105. package/src/db/migrations/meta/0028_snapshot.json +2511 -0
  106. package/src/db/migrations/meta/0029_snapshot.json +2519 -0
  107. package/src/db/migrations/meta/0030_snapshot.json +2541 -0
  108. package/src/db/migrations/meta/_journal.json +28 -0
  109. package/src/db/migrations/pg/0025_moaning_epoch.sql +39 -0
  110. package/src/db/migrations/pg/0026_talented_killer_shrike.sql +2 -0
  111. package/src/db/migrations/pg/0027_short_cannonball.sql +1 -0
  112. package/src/db/migrations/pg/0028_perfect_sharon_carter.sql +27 -0
  113. package/src/db/migrations/pg/meta/0025_snapshot.json +3204 -0
  114. package/src/db/migrations/pg/meta/0026_snapshot.json +3204 -0
  115. package/src/db/migrations/pg/meta/0027_snapshot.json +3211 -0
  116. package/src/db/migrations/pg/meta/0028_snapshot.json +3248 -0
  117. package/src/db/migrations/pg/meta/_journal.json +28 -0
  118. package/src/db/pg/schema.ts +28 -20
  119. package/src/db/rehearsal-fixtures/demo-current.json +39 -4
  120. package/src/db/rehearsal-fixtures/demo-current.sql +121 -6
  121. package/src/db/schema.ts +28 -20
  122. package/src/i18n/__tests__/detect.test.ts +8 -2
  123. package/src/i18n/detect.ts +14 -5
  124. package/src/i18n/locales/public/en.po +133 -30
  125. package/src/i18n/locales/public/en.ts +1 -1
  126. package/src/i18n/locales/public/zh-Hans.po +128 -25
  127. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  128. package/src/i18n/locales/public/zh-Hant.po +128 -25
  129. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  130. package/src/i18n/locales/settings/en.po +513 -10
  131. package/src/i18n/locales/settings/en.ts +1 -1
  132. package/src/i18n/locales/settings/zh-Hans.po +515 -12
  133. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  134. package/src/i18n/locales/settings/zh-Hant.po +515 -12
  135. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  136. package/src/lib/__tests__/api-settings.test.ts +299 -0
  137. package/src/lib/__tests__/constants.test.ts +5 -0
  138. package/src/lib/__tests__/feed-policy.test.ts +25 -0
  139. package/src/lib/__tests__/footnotes-upgrade.test.ts +181 -0
  140. package/src/lib/__tests__/markdown-roundtrip-embed.test.ts +61 -0
  141. package/src/lib/__tests__/markdown-to-tiptap.test.ts +32 -4
  142. package/src/lib/__tests__/markdown.test.ts +8 -6
  143. package/src/lib/__tests__/navigation.test.ts +59 -2
  144. package/src/lib/__tests__/post-body-html.test.ts +176 -0
  145. package/src/lib/__tests__/post-display.test.ts +64 -0
  146. package/src/lib/__tests__/resolve-config.test.ts +113 -1
  147. package/src/lib/__tests__/summary.test.ts +82 -0
  148. package/src/lib/__tests__/timeline.test.ts +109 -70
  149. package/src/lib/__tests__/timezones.test.ts +31 -3
  150. package/src/lib/__tests__/tiptap-render.test.ts +268 -9
  151. package/src/lib/__tests__/tiptap-to-markdown.test.ts +6 -0
  152. package/src/lib/__tests__/view.test.ts +53 -0
  153. package/src/lib/__tests__/worker-response-cache.test.ts +18 -1
  154. package/src/lib/api-settings.ts +239 -12
  155. package/src/lib/collection-sort.ts +7 -5
  156. package/src/lib/constants.ts +2 -0
  157. package/src/lib/feed-path.ts +25 -0
  158. package/src/lib/feed-policy.ts +64 -0
  159. package/src/lib/footnotes.ts +264 -0
  160. package/src/lib/github-sync-site-config.ts +2 -0
  161. package/src/lib/hugo-markdown.ts +10 -3
  162. package/src/lib/jant-branding.ts +1 -0
  163. package/src/lib/markdown-manager.ts +188 -5
  164. package/src/lib/markdown.ts +10 -3
  165. package/src/lib/navigation.ts +16 -3
  166. package/src/lib/post-body-html.ts +131 -0
  167. package/src/lib/post-display.ts +7 -1
  168. package/src/lib/render.tsx +9 -2
  169. package/src/lib/resolve-config.ts +100 -16
  170. package/src/lib/schemas.ts +133 -1
  171. package/src/lib/settings-paths.ts +5 -0
  172. package/src/lib/site-skill.ts +78 -0
  173. package/src/lib/summary.ts +16 -5
  174. package/src/lib/timeline.ts +98 -122
  175. package/src/lib/timezones.ts +54 -14
  176. package/src/lib/tiptap-render.ts +355 -81
  177. package/src/lib/view.ts +30 -20
  178. package/src/lib/worker-response-cache.ts +11 -18
  179. package/src/middleware/__tests__/public-content-access.test.ts +91 -0
  180. package/src/middleware/config.ts +2 -2
  181. package/src/middleware/public-content-access.ts +87 -0
  182. package/src/node/__tests__/cli-export.test.ts +14 -0
  183. package/src/node/__tests__/cli-migrate.test.ts +6 -0
  184. package/src/node/__tests__/cli-site-snapshot.test.ts +205 -2
  185. package/src/node/__tests__/cli-snapshot-meta.test.ts +100 -0
  186. package/src/node/index.ts +2 -1
  187. package/src/preset.css +16 -2
  188. package/src/routes/__tests__/compose.test.ts +51 -0
  189. package/src/routes/__tests__/site-skill.test.ts +112 -0
  190. package/src/routes/api/__tests__/collections.test.ts +91 -19
  191. package/src/routes/api/__tests__/mcp.test.ts +65 -1
  192. package/src/routes/api/__tests__/nav-items.test.ts +91 -0
  193. package/src/routes/api/__tests__/public-access.test.ts +81 -0
  194. package/src/routes/api/__tests__/settings.test.ts +217 -0
  195. package/src/routes/api/collections.ts +22 -26
  196. package/src/routes/api/export.ts +2 -0
  197. package/src/routes/api/internal/__tests__/post-body-html.test.ts +163 -0
  198. package/src/routes/api/internal/__tests__/sites.test.ts +35 -2
  199. package/src/routes/api/internal/post-body-html.ts +43 -0
  200. package/src/routes/api/internal/sites.ts +24 -0
  201. package/src/routes/api/nav-items.ts +24 -1
  202. package/src/routes/api/posts.ts +2 -2
  203. package/src/routes/api/public/__tests__/archive.test.ts +35 -0
  204. package/src/routes/api/public/__tests__/posts.test.ts +208 -0
  205. package/src/routes/api/public/archive.ts +4 -0
  206. package/src/routes/api/public/posts.ts +41 -24
  207. package/src/routes/api/search.ts +3 -0
  208. package/src/routes/api/settings.ts +33 -0
  209. package/src/routes/auth/__tests__/setup.test.ts +7 -4
  210. package/src/routes/compose.tsx +2 -1
  211. package/src/routes/dash/settings.tsx +45 -2
  212. package/src/routes/feed/__tests__/feed.test.ts +210 -6
  213. package/src/routes/feed/feed.ts +23 -14
  214. package/src/routes/pages/__tests__/archive-params.test.ts +92 -3
  215. package/src/routes/pages/__tests__/collection-routing.test.ts +63 -5
  216. package/src/routes/pages/__tests__/collections.test.ts +9 -9
  217. package/src/routes/pages/__tests__/preview.test.ts +174 -0
  218. package/src/routes/pages/__tests__/search.test.ts +69 -0
  219. package/src/routes/pages/archive.tsx +27 -7
  220. package/src/routes/pages/collection.tsx +32 -19
  221. package/src/routes/pages/collections.tsx +6 -1
  222. package/src/routes/pages/page.tsx +83 -11
  223. package/src/routes/site-skill.ts +18 -0
  224. package/src/services/__tests__/auth.test.ts +7 -5
  225. package/src/services/__tests__/collection.test.ts +159 -44
  226. package/src/services/__tests__/navigation.test.ts +229 -21
  227. package/src/services/__tests__/post-timeline.test.ts +109 -30
  228. package/src/services/__tests__/post.test.ts +567 -5
  229. package/src/services/__tests__/search.test.ts +48 -1
  230. package/src/services/__tests__/settings.test.ts +8 -0
  231. package/src/services/__tests__/site-admin.test.ts +121 -1
  232. package/src/services/auth.ts +2 -2
  233. package/src/services/bootstrap.ts +3 -2
  234. package/src/services/collection.ts +226 -167
  235. package/src/services/export-theme/assets/client-site.css +1 -1
  236. package/src/services/export-theme/assets/client-site.js +46 -25
  237. package/src/services/export-theme/layouts/_default/list.html +18 -4
  238. package/src/services/export-theme/layouts/_default/rss.xml +8 -12
  239. package/src/services/export-theme/layouts/featured/list.html +5 -4
  240. package/src/services/export-theme/layouts/partials/featured-thread.html +56 -0
  241. package/src/services/export-theme/layouts/partials/footer.html +1 -1
  242. package/src/services/export-theme/layouts/partials/media-gallery.html +1 -0
  243. package/src/services/export-theme/styles/main.css +30 -2
  244. package/src/services/export.ts +91 -80
  245. package/src/services/mcp.ts +37 -27
  246. package/src/services/media.ts +1 -1
  247. package/src/services/navigation.ts +185 -46
  248. package/src/services/post.ts +874 -271
  249. package/src/services/search.ts +8 -2
  250. package/src/services/settings.ts +2 -10
  251. package/src/services/site-admin.ts +63 -43
  252. package/src/style-cjk-jp.css +1 -1
  253. package/src/style-cjk-kr.css +1 -1
  254. package/src/style-cjk-tc.css +1 -1
  255. package/src/style-cjk.css +1 -1
  256. package/src/styles/components.css +540 -0
  257. package/src/styles/site-media.css +111 -19
  258. package/src/styles/tokens.css +14 -9
  259. package/src/styles/ui.css +829 -29
  260. package/src/types/bindings.ts +3 -0
  261. package/src/types/config.ts +243 -12
  262. package/src/types/constants.ts +22 -1
  263. package/src/types/entities.ts +9 -8
  264. package/src/types/operations.ts +18 -10
  265. package/src/types/props.ts +8 -2
  266. package/src/types/raw-assets.d.ts +5 -0
  267. package/src/types/views.ts +16 -4
  268. package/src/ui/__tests__/font-themes.test.ts +42 -30
  269. package/src/ui/compose/ComposeDialog.tsx +92 -2
  270. package/src/ui/dash/appearance/NavigationContent.tsx +332 -4
  271. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +23 -0
  272. package/src/ui/dash/settings/ConfigEditorContent.tsx +578 -0
  273. package/src/ui/dash/settings/GeneralContent.tsx +15 -5
  274. package/src/ui/dash/settings/SettingsRootContent.tsx +21 -1
  275. package/src/ui/dash/settings/__tests__/ConfigEditorContent.test.tsx +98 -0
  276. package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +15 -0
  277. package/src/ui/feed/CuratedThreadPreview.tsx +16 -4
  278. package/src/ui/feed/LinkCard.tsx +2 -12
  279. package/src/ui/feed/NoteCard.tsx +10 -20
  280. package/src/ui/feed/QuoteCard.tsx +2 -12
  281. package/src/ui/feed/ThreadPreview.tsx +26 -34
  282. package/src/ui/feed/TimelineFeed.tsx +2 -2
  283. package/src/ui/feed/__tests__/thread-preview.test.ts +197 -56
  284. package/src/ui/feed/__tests__/timeline-cards.test.ts +178 -3
  285. package/src/ui/feed/thread-preview-state.ts +5 -7
  286. package/src/ui/font-themes.ts +74 -26
  287. package/src/ui/layouts/BaseLayout.tsx +22 -8
  288. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +64 -1
  289. package/src/ui/pages/CollectionPage.tsx +101 -22
  290. package/src/ui/pages/CollectionsPage.tsx +3 -1
  291. package/src/ui/pages/PostPage.tsx +39 -12
  292. package/src/ui/pages/SearchPage.tsx +6 -14
  293. package/src/ui/pages/__tests__/ArchivePage.test.tsx +7 -0
  294. package/src/ui/pages/__tests__/PostPage.test.tsx +88 -0
  295. package/src/ui/shared/CollectionDirectory.tsx +13 -9
  296. package/src/ui/shared/CollectionsManager.tsx +17 -7
  297. package/src/ui/shared/DraftPreviewBar.tsx +33 -0
  298. package/src/ui/shared/HomePageBranding.tsx +2 -2
  299. package/src/ui/shared/MediaGallery.tsx +1 -0
  300. package/src/ui/shared/PostFooter.tsx +3 -1
  301. package/src/ui/shared/__tests__/home-page-branding.test.tsx +1 -1
  302. package/src/ui/shared/__tests__/media-gallery.test.ts +1 -0
  303. package/src/ui/shared/__tests__/navigation-labels.test.ts +2 -2
  304. package/src/ui/shared/__tests__/post-footer.test.ts +15 -0
  305. package/src/ui/shared/collection-management-labels.ts +33 -2
  306. package/src/ui/shared/navigation-labels.ts +2 -4
  307. package/src/ui/shared/post-article-attributes.ts +46 -0
  308. package/dist/app-DZDlmh7C.js +0 -6
  309. package/dist/client/_assets/client-Cevfpm8H.css +0 -2
  310. package/dist/client/_assets/client-auth-v3yvhbgZ.js +0 -4736
  311. package/src/client/tiptap/wrapping-input-rules.ts +0 -102
@@ -21,11 +21,14 @@ import {
21
21
  resolveCollectionSortOrder,
22
22
  supportsCollectionRatingSort,
23
23
  } from "../../../lib/collection-sort.js";
24
+ import { requirePublicApiEnabled } from "../../../middleware/public-content-access.js";
24
25
 
25
26
  type Env = { Bindings: Bindings; Variables: AppVariables };
26
27
 
27
28
  export const publicPostsApiRoutes = new Hono<Env>();
28
29
 
30
+ publicPostsApiRoutes.use("*", requirePublicApiEnabled());
31
+
29
32
  const ListPublicPostsQuerySchema = z.object({
30
33
  format: FormatSchema.optional(),
31
34
  collection: z.string().optional(),
@@ -97,7 +100,7 @@ function isPublicDetailVisible(post: Post | null): post is Post {
97
100
  export function toPublicPost(
98
101
  post: Post,
99
102
  mediaList: Media[],
100
- postCollections: Collection[],
103
+ threadCollections: Collection[],
101
104
  appConfig: AppVariables["appConfig"],
102
105
  options?: { content?: "markdown" },
103
106
  ): PublicPostResponse {
@@ -159,7 +162,7 @@ export function toPublicPost(
159
162
  sitePathPrefix,
160
163
  ),
161
164
  ),
162
- collections: postCollections.map((collection) => ({
165
+ collections: threadCollections.map((collection) => ({
163
166
  id: collection.id,
164
167
  slug: collection.slug,
165
168
  title: collection.title,
@@ -226,16 +229,19 @@ publicPostsApiRoutes.get("/", async (c) => {
226
229
  ? "newest"
227
230
  : primaryCollection.sortOrder;
228
231
 
229
- const ratedPostCount = await c.var.services.posts.countUpTo(
230
- {
232
+ const ratedThreadCount =
233
+ await c.var.services.posts.countCollectionThreadRootsUpToForCollections(
231
234
  collectionIds,
232
- status: "published",
233
- excludePrivate: true,
234
- hasRating: true,
235
- },
236
- 2,
237
- );
238
- const showRatingSort = supportsCollectionRatingSort(ratedPostCount);
235
+ {
236
+ status: "published",
237
+ excludePrivate: true,
238
+ excludeLatestHidden: true,
239
+ rootFormat: format,
240
+ hasRating: true,
241
+ },
242
+ 2,
243
+ );
244
+ const showRatingSort = supportsCollectionRatingSort(ratedThreadCount);
239
245
  const defaultSort = resolveCollectionSortOrder(
240
246
  undefined,
241
247
  requestedDefaultSort,
@@ -244,17 +250,28 @@ publicPostsApiRoutes.get("/", async (c) => {
244
250
  sortOrder = resolveCollectionSortOrder(sort, defaultSort, showRatingSort);
245
251
  }
246
252
 
247
- const posts = await c.var.services.posts.list({
248
- format,
249
- collectionIds,
250
- sortOrder,
251
- status: "published",
252
- cursor: cursor ?? undefined,
253
- limit,
254
- excludePrivate: true,
255
- excludeLatestHidden: true,
256
- excludeReplies: true,
257
- });
253
+ const posts = collectionIds
254
+ ? await c.var.services.posts.listCollectionThreadRootsForCollections(
255
+ collectionIds,
256
+ {
257
+ status: "published",
258
+ excludePrivate: true,
259
+ excludeLatestHidden: true,
260
+ rootFormat: format,
261
+ sortOrder,
262
+ cursor: cursor ?? undefined,
263
+ limit,
264
+ },
265
+ )
266
+ : await c.var.services.posts.list({
267
+ format,
268
+ status: "published",
269
+ cursor: cursor ?? undefined,
270
+ limit,
271
+ excludePrivate: true,
272
+ excludeLatestHidden: true,
273
+ excludeReplies: true,
274
+ });
258
275
 
259
276
  const postIds = posts.map((post) => post.id);
260
277
  const [mediaMap, collectionsMap] = await Promise.all([
@@ -289,13 +306,13 @@ publicPostsApiRoutes.get("/:slug", async (c) => {
289
306
  throw new NotFoundError("Post");
290
307
  }
291
308
 
292
- const [mediaList, postCollections] = await Promise.all([
309
+ const [mediaList, threadCollections] = await Promise.all([
293
310
  c.var.services.media.getByPostId(post.id),
294
311
  c.var.services.collections.getCollectionsByPostId(post.id),
295
312
  ]);
296
313
 
297
314
  return c.json(
298
- toPublicPost(post, mediaList, postCollections, c.var.appConfig, {
315
+ toPublicPost(post, mediaList, threadCollections, c.var.appConfig, {
299
316
  content,
300
317
  }),
301
318
  );
@@ -8,11 +8,14 @@ import type { AppVariables } from "../../types/app-context.js";
8
8
  import { ValidationError, ExternalServiceError } from "../../lib/errors.js";
9
9
  import { toSearchApiResult } from "../../lib/api-search.js";
10
10
  import { rateLimit } from "../../middleware/rate-limit.js";
11
+ import { requirePublicApiAccess } from "../../middleware/public-content-access.js";
11
12
 
12
13
  type Env = { Bindings: Bindings; Variables: AppVariables };
13
14
 
14
15
  export const searchApiRoutes = new Hono<Env>();
15
16
 
17
+ searchApiRoutes.use("*", requirePublicApiAccess());
18
+
16
19
  // Per-IP rate limit. The request-time wrapper is needed because the
17
20
  // per-minute cap is pulled from `appConfig` which is only available on
18
21
  // `c.var`; constructing the middleware once at module load would capture
@@ -13,7 +13,10 @@ import { parseValidated } from "../../lib/schemas.js";
13
13
  import { ValidationError } from "../../lib/errors.js";
14
14
  import { syncHostedControlPlaneSiteAvatar } from "../../lib/hosted-control-plane-sync.js";
15
15
  import {
16
+ buildConfigEditorFields,
16
17
  buildEditableSettingsResponse,
18
+ demoLockedSettingKeys,
19
+ isResettableConfigEditorKey,
17
20
  partitionEditableSettingUpdates,
18
21
  partitionImportableSettingUpdates,
19
22
  } from "../../lib/api-settings.js";
@@ -31,6 +34,7 @@ settingsApiRoutes.get("/", requireAuthApi(), async (c) => {
31
34
  settings: buildEditableSettingsResponse(
32
35
  allSettings,
33
36
  c.var.appConfig.demoMode,
37
+ c.env,
34
38
  ),
35
39
  });
36
40
  });
@@ -61,6 +65,7 @@ settingsApiRoutes.put("/", requireAuthApi(), async (c) => {
61
65
  settings: buildEditableSettingsResponse(
62
66
  allSettings,
63
67
  c.var.appConfig.demoMode,
68
+ c.env,
64
69
  ),
65
70
  ...(rejectedKeys.length > 0 && { rejectedKeys }),
66
71
  });
@@ -216,3 +221,31 @@ settingsApiRoutes.delete("/avatar", requireAuthApi(), async (c) => {
216
221
  }
217
222
  return c.json({ success: true });
218
223
  });
224
+
225
+ // Remove one runtime override so the environment/default fallback applies.
226
+ // Registered after the concrete avatar route so `DELETE /avatar` remains
227
+ // unambiguous.
228
+ settingsApiRoutes.delete("/:key", requireAuthApi(), async (c) => {
229
+ const key = c.req.param("key");
230
+ const demoMode = c.var.appConfig.demoMode;
231
+
232
+ if (
233
+ !isResettableConfigEditorKey(key) ||
234
+ (demoMode && demoLockedSettingKeys.has(key))
235
+ ) {
236
+ throw new ValidationError("This setting can't be reset.", {
237
+ rejectedKeys: [key],
238
+ });
239
+ }
240
+
241
+ await c.var.services.settings.remove(key);
242
+ const allSettings = await c.var.services.settings.getAll();
243
+ const setting = buildConfigEditorFields(allSettings, c.env, demoMode).find(
244
+ (field) => field.key === key,
245
+ );
246
+
247
+ return c.json({
248
+ settings: buildEditableSettingsResponse(allSettings, demoMode, c.env),
249
+ setting,
250
+ });
251
+ });
@@ -38,14 +38,13 @@ describe("Setup bootstrap logic", () => {
38
38
  };
39
39
  });
40
40
 
41
- it("creates six nav items with the built-in default navigation", async () => {
41
+ it("creates the five-item default navigation without Latest", async () => {
42
42
  await runSetupBootstrap(services);
43
43
 
44
44
  const navItemsList = await services.db.select().from(navItems);
45
- expect(navItemsList).toHaveLength(6);
45
+ expect(navItemsList).toHaveLength(5);
46
46
 
47
47
  expect(navItemsList.map((item) => item.systemKey)).toEqual([
48
- "latest",
49
48
  "featured",
50
49
  "collections",
51
50
  "archive",
@@ -125,7 +124,11 @@ describe("Setup bootstrap logic", () => {
125
124
  const navItemsList = await services.db.select().from(navItems);
126
125
  const systemItems = navItemsList.filter((item) => item.type === "system");
127
126
 
128
- expect(systemItems).toHaveLength(6);
127
+ expect(systemItems).toHaveLength(5);
128
+ expect(systemItems.map((item) => item.systemKey)).not.toContain("latest");
129
+ expect(
130
+ systemItems.find((item) => item.systemKey === "collections")?.label,
131
+ ).toBe("Collections");
129
132
  });
130
133
 
131
134
  it("creates a site shell when setup runs after a factory reset", async () => {
@@ -226,8 +226,9 @@ composeRoutes.post("/thread", async (c) => {
226
226
  : data.url || undefined,
227
227
  quoteText: data.quoteText || undefined,
228
228
  rating: data.rating || undefined,
229
- collectionIds: index === 0 ? data.collectionIds : [],
229
+ collectionIds: index === 0 ? data.collectionIds : undefined,
230
230
  replyToId: index === 0 ? data.replyToId : undefined,
231
+ quietReply: data.quietReply,
231
232
  publishedAt: index === 0 ? data.publishedAt : undefined,
232
233
  },
233
234
  attachments: data.attachments,
@@ -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
  });