@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
@@ -40,6 +40,48 @@ describe("Nav Items API Routes", () => {
40
40
  });
41
41
  });
42
42
 
43
+ describe("GET /api/nav-items/pages", () => {
44
+ it("requires authentication", async () => {
45
+ const { app } = createTestApp({ authenticated: false });
46
+ app.route("/api/nav-items", navItemsApiRoutes);
47
+
48
+ const res = await app.request("/api/nav-items/pages");
49
+
50
+ expect(res.status).toBe(401);
51
+ });
52
+
53
+ it("searches eligible page candidates", async () => {
54
+ const { app, services } = createTestApp({ authenticated: true });
55
+ app.route("/api/nav-items", navItemsApiRoutes);
56
+ const page = await services.posts.create({
57
+ format: "note",
58
+ title: "About this site",
59
+ slug: "about-this-site",
60
+ visibility: "latest_hidden",
61
+ });
62
+ await services.posts.create({
63
+ format: "link",
64
+ title: "About elsewhere",
65
+ slug: "about-elsewhere",
66
+ url: "https://example.com",
67
+ });
68
+
69
+ const res = await app.request("/api/nav-items/pages?q=ABOUT");
70
+
71
+ expect(res.status).toBe(200);
72
+ expect(await res.json()).toEqual({
73
+ pages: [
74
+ {
75
+ id: page.id,
76
+ title: "About this site",
77
+ slug: "about-this-site",
78
+ updatedAt: expect.any(Number),
79
+ },
80
+ ],
81
+ });
82
+ });
83
+ });
84
+
43
85
  describe("POST /api/nav-items", () => {
44
86
  it("returns 401 when not authenticated", async () => {
45
87
  const { app } = createTestApp({ authenticated: false });
@@ -435,6 +477,55 @@ describe("Nav Items API Routes", () => {
435
477
  });
436
478
  });
437
479
 
480
+ describe("POST /api/nav-items (page)", () => {
481
+ it("derives the page label and URL from the selected post", async () => {
482
+ const { app, services } = createTestApp({ authenticated: true });
483
+ app.route("/api/nav-items", navItemsApiRoutes);
484
+ const page = await services.posts.create({
485
+ format: "note",
486
+ title: "About me",
487
+ slug: "about-me",
488
+ visibility: "latest_hidden",
489
+ });
490
+
491
+ const res = await app.request("/api/nav-items", {
492
+ method: "POST",
493
+ headers: {
494
+ "Content-Type": "application/json",
495
+ "X-Jant-Site-Header": "include",
496
+ },
497
+ body: JSON.stringify({ type: "page", postId: page.id }),
498
+ });
499
+
500
+ expect(res.status).toBe(201);
501
+ expect(await res.json()).toMatchObject({
502
+ type: "page",
503
+ postId: page.id,
504
+ label: "About me",
505
+ url: "/about-me",
506
+ headerHtml: expect.stringContaining("About me"),
507
+ });
508
+ });
509
+
510
+ it("rejects a Link post as a page", async () => {
511
+ const { app, services } = createTestApp({ authenticated: true });
512
+ app.route("/api/nav-items", navItemsApiRoutes);
513
+ const link = await services.posts.create({
514
+ format: "link",
515
+ title: "Elsewhere",
516
+ url: "https://example.com",
517
+ });
518
+
519
+ const res = await app.request("/api/nav-items", {
520
+ method: "POST",
521
+ headers: { "Content-Type": "application/json" },
522
+ body: JSON.stringify({ type: "page", postId: link.id }),
523
+ });
524
+
525
+ expect(res.status).toBe(400);
526
+ });
527
+ });
528
+
438
529
  describe("DELETE /api/nav-items/:id", () => {
439
530
  it("returns 401 when not authenticated", async () => {
440
531
  const { app, services } = createTestApp({ authenticated: false });
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createTestApp } from "../../../__tests__/helpers/app.js";
3
+ import { collectionsApiRoutes } from "../collections.js";
4
+ import { navItemsApiRoutes } from "../nav-items.js";
5
+ import { publicArchiveApiRoutes } from "../public/archive.js";
6
+ import { publicPostsApiRoutes } from "../public/posts.js";
7
+ import { searchApiRoutes } from "../search.js";
8
+
9
+ function mountPublicReadRoutes(authenticated = false) {
10
+ const testApp = createTestApp({ authenticated, fts: true });
11
+ testApp.app.route("/api/public/posts", publicPostsApiRoutes);
12
+ testApp.app.route("/api/public/archive", publicArchiveApiRoutes);
13
+ testApp.app.route("/api/search", searchApiRoutes);
14
+ testApp.app.route("/api/collections", collectionsApiRoutes);
15
+ testApp.app.route("/api/nav-items", navItemsApiRoutes);
16
+ return testApp;
17
+ }
18
+
19
+ describe("public API access setting", () => {
20
+ it.each([
21
+ "/api/public/posts",
22
+ "/api/public/posts/missing",
23
+ "/api/public/archive",
24
+ ])("returns 404 for %s when the public API is off", async (path) => {
25
+ const { app, services } = mountPublicReadRoutes();
26
+ await services.settings.set("PUBLIC_API_ENABLED", "false");
27
+
28
+ const response = await app.request(path);
29
+
30
+ expect(response.status).toBe(404);
31
+ });
32
+
33
+ it("does not let an authenticated session bypass the public API switch", async () => {
34
+ const { app, services } = mountPublicReadRoutes(true);
35
+ await services.settings.set("PUBLIC_API_ENABLED", "false");
36
+
37
+ expect((await app.request("/api/public/posts")).status).toBe(404);
38
+ expect((await app.request("/api/public/archive")).status).toBe(404);
39
+ });
40
+
41
+ it("does not let a Bearer token bypass the public API switch", async () => {
42
+ const { app, services } = mountPublicReadRoutes();
43
+ await services.settings.set("PUBLIC_API_ENABLED", "false");
44
+ const { plaintext } = await services.apiTokens.create("Test client");
45
+
46
+ const response = await app.request("/api/public/posts", {
47
+ headers: { Authorization: `Bearer ${plaintext}` },
48
+ });
49
+
50
+ expect(response.status).toBe(404);
51
+ });
52
+
53
+ it.each([
54
+ "/api/search",
55
+ "/api/collections",
56
+ "/api/collections/not-a-typeid",
57
+ "/api/nav-items",
58
+ ])(
59
+ "requires authentication for %s when anonymous reads are off",
60
+ async (path) => {
61
+ const { app, services } = mountPublicReadRoutes();
62
+ await services.settings.set("PUBLIC_API_ENABLED", "false");
63
+
64
+ const response = await app.request(path);
65
+
66
+ expect(response.status).toBe(401);
67
+ },
68
+ );
69
+
70
+ it.each(["/api/search?q=missing", "/api/collections", "/api/nav-items"])(
71
+ "preserves authenticated access to shared read endpoint %s",
72
+ async (path) => {
73
+ const { app, services } = mountPublicReadRoutes(true);
74
+ await services.settings.set("PUBLIC_API_ENABLED", "false");
75
+
76
+ const response = await app.request(path);
77
+
78
+ expect(response.status).toBe(200);
79
+ },
80
+ );
81
+ });
@@ -38,6 +38,10 @@ describe("Settings API Routes", () => {
38
38
  expect(body.settings.SITE_NAME).toBe("Jant");
39
39
  expect(body.settings.SITE_DESCRIPTION).toBe("");
40
40
  expect(body.settings.SITE_LANGUAGE).toBe("en");
41
+ expect(body.settings.PAGE_SIZE).toBe("50");
42
+ expect(body.settings.SEARCH_PAGE_SIZE).toBe("50");
43
+ expect(body.settings.ARCHIVE_PAGE_SIZE).toBe("50");
44
+ expect(body.settings.RSS_PUBLISH_DELAY_SECONDS).toBe("0");
41
45
  });
42
46
 
43
47
  it("returns stored settings overriding defaults", async () => {
@@ -246,6 +250,94 @@ describe("Settings API Routes", () => {
246
250
  expect(res.status).toBe(400);
247
251
  });
248
252
 
253
+ it("rejects invalid boolean and enum values", async () => {
254
+ const { app } = createTestApp({ authenticated: true });
255
+ app.route("/api/settings", settingsApiRoutes);
256
+
257
+ const booleanResponse = await app.request("/api/settings", {
258
+ method: "PUT",
259
+ headers: { "Content-Type": "application/json" },
260
+ body: JSON.stringify({ SHOW_JANT_BRANDING_ON_HOME: "yes" }),
261
+ });
262
+ expect(booleanResponse.status).toBe(400);
263
+ expect((await booleanResponse.json()).error).toContain("true or false");
264
+
265
+ const enumResponse = await app.request("/api/settings", {
266
+ method: "PUT",
267
+ headers: { "Content-Type": "application/json" },
268
+ body: JSON.stringify({ MAIN_RSS_FEED: "popular" }),
269
+ });
270
+ expect(enumResponse.status).toBe(400);
271
+ expect((await enumResponse.json()).error).toContain("available options");
272
+ });
273
+
274
+ it("validates and canonicalizes bounded integer settings", async () => {
275
+ const { app } = createTestApp({ authenticated: true });
276
+ app.route("/api/settings", settingsApiRoutes);
277
+
278
+ const validResponse = await app.request("/api/settings", {
279
+ method: "PUT",
280
+ headers: { "Content-Type": "application/json" },
281
+ body: JSON.stringify({
282
+ PAGE_SIZE: "80",
283
+ SUMMARY_MAX_PARAGRAPHS: "20",
284
+ SUMMARY_MAX_CHARS: "1200",
285
+ RSS_FEED_LIMIT: "150",
286
+ RSS_PUBLISH_DELAY_SECONDS: "0",
287
+ }),
288
+ });
289
+ expect(validResponse.status).toBe(200);
290
+ await expect(validResponse.json()).resolves.toMatchObject({
291
+ settings: {
292
+ PAGE_SIZE: "80",
293
+ SEARCH_PAGE_SIZE: "80",
294
+ ARCHIVE_PAGE_SIZE: "80",
295
+ SUMMARY_MAX_PARAGRAPHS: "20",
296
+ SUMMARY_MAX_CHARS: "1200",
297
+ RSS_FEED_LIMIT: "150",
298
+ RSS_PUBLISH_DELAY_SECONDS: "0",
299
+ },
300
+ });
301
+
302
+ for (const [key, value] of [
303
+ ["PAGE_SIZE", "101"],
304
+ ["SEARCH_PAGE_SIZE", "1.5"],
305
+ ["SUMMARY_MAX_PARAGRAPHS", "51"],
306
+ ["SUMMARY_MAX_CHARS", "1501"],
307
+ ["RSS_FEED_LIMIT", "201"],
308
+ ["RSS_PUBLISH_DELAY_SECONDS", "7201"],
309
+ ]) {
310
+ const response = await app.request("/api/settings", {
311
+ method: "PUT",
312
+ headers: { "Content-Type": "application/json" },
313
+ body: JSON.stringify({ [key]: value }),
314
+ });
315
+ expect(response.status).toBe(400);
316
+ }
317
+ });
318
+
319
+ it("validates and canonicalizes content language tags", async () => {
320
+ const { app } = createTestApp({ authenticated: true });
321
+ app.route("/api/settings", settingsApiRoutes);
322
+
323
+ const validResponse = await app.request("/api/settings", {
324
+ method: "PUT",
325
+ headers: { "Content-Type": "application/json" },
326
+ body: JSON.stringify({ SITE_LANGUAGE: "zh-hans" }),
327
+ });
328
+ expect(validResponse.status).toBe(200);
329
+ expect((await validResponse.json()).settings.SITE_LANGUAGE).toBe(
330
+ "zh-Hans",
331
+ );
332
+
333
+ const invalidResponse = await app.request("/api/settings", {
334
+ method: "PUT",
335
+ headers: { "Content-Type": "application/json" },
336
+ body: JSON.stringify({ SITE_LANGUAGE: "not_a_language" }),
337
+ });
338
+ expect(invalidResponse.status).toBe(400);
339
+ });
340
+
249
341
  it("rejects NOINDEX updates in demo mode", async () => {
250
342
  const { app } = createTestApp({ authenticated: true, demoMode: true });
251
343
  app.route("/api/settings", settingsApiRoutes);
@@ -282,6 +374,131 @@ describe("Settings API Routes", () => {
282
374
  });
283
375
  });
284
376
 
377
+ describe("DELETE /api/settings/:key", () => {
378
+ it("returns 401 when not authenticated", async () => {
379
+ const { app } = createTestApp({ authenticated: false });
380
+ app.route("/api/settings", settingsApiRoutes);
381
+
382
+ const res = await app.request("/api/settings/SITE_NAME", {
383
+ method: "DELETE",
384
+ });
385
+
386
+ expect(res.status).toBe(401);
387
+ });
388
+
389
+ it("removes an override and returns the fallback value", async () => {
390
+ const { app, services } = createTestApp({ authenticated: true });
391
+ app.route("/api/settings", settingsApiRoutes);
392
+ await services.settings.set("SITE_NAME", "Custom name");
393
+
394
+ const res = await app.request("/api/settings/SITE_NAME", {
395
+ method: "DELETE",
396
+ });
397
+
398
+ expect(res.status).toBe(200);
399
+ expect(await services.settings.get("SITE_NAME")).toBeNull();
400
+ const body = await res.json();
401
+ expect(body.settings.SITE_NAME).toBe("Jant");
402
+ expect(body.setting).toMatchObject({
403
+ key: "SITE_NAME",
404
+ value: "Jant",
405
+ modified: false,
406
+ });
407
+ });
408
+
409
+ it("resets search page size to the current page-size setting", async () => {
410
+ const { app, services } = createTestApp({ authenticated: true });
411
+ app.route("/api/settings", settingsApiRoutes);
412
+ await services.settings.set("PAGE_SIZE", "80");
413
+ await services.settings.set("SEARCH_PAGE_SIZE", "25");
414
+
415
+ const res = await app.request("/api/settings/SEARCH_PAGE_SIZE", {
416
+ method: "DELETE",
417
+ });
418
+
419
+ expect(res.status).toBe(200);
420
+ expect(await services.settings.get("SEARCH_PAGE_SIZE")).toBeNull();
421
+ const body = await res.json();
422
+ expect(body.settings.SEARCH_PAGE_SIZE).toBe("80");
423
+ expect(body.setting).toMatchObject({
424
+ key: "SEARCH_PAGE_SIZE",
425
+ value: "80",
426
+ fallbackValue: "80",
427
+ modified: false,
428
+ });
429
+ });
430
+
431
+ it("removes a safe linked scalar override and returns its fallback", async () => {
432
+ const { app, services } = createTestApp({ authenticated: true });
433
+ app.route("/api/settings", settingsApiRoutes);
434
+ await services.settings.set("THEME", "paper");
435
+
436
+ const res = await app.request("/api/settings/THEME", {
437
+ method: "DELETE",
438
+ });
439
+
440
+ expect(res.status).toBe(200);
441
+ expect(await services.settings.get("THEME")).toBeNull();
442
+ const body = await res.json();
443
+ expect(body.settings.THEME).toBeUndefined();
444
+ expect(body.setting).toMatchObject({
445
+ key: "THEME",
446
+ mode: "link",
447
+ value: "tufte",
448
+ modified: false,
449
+ resettable: true,
450
+ });
451
+ });
452
+
453
+ it("resets an API-editable textarea setting through its linked editor state", async () => {
454
+ const { app, services } = createTestApp({ authenticated: true });
455
+ app.route("/api/settings", settingsApiRoutes);
456
+ await services.settings.set("SITE_DESCRIPTION", "A longer introduction");
457
+
458
+ const res = await app.request("/api/settings/SITE_DESCRIPTION", {
459
+ method: "DELETE",
460
+ });
461
+
462
+ expect(res.status).toBe(200);
463
+ expect(await services.settings.get("SITE_DESCRIPTION")).toBeNull();
464
+ const body = await res.json();
465
+ expect(body.settings.SITE_DESCRIPTION).toBe("");
466
+ expect(body.setting).toMatchObject({
467
+ key: "SITE_DESCRIPTION",
468
+ mode: "link",
469
+ value: "false",
470
+ modified: false,
471
+ resettable: true,
472
+ settingsPath: "/settings/general",
473
+ });
474
+ });
475
+
476
+ it("rejects unsafe, specialized, and demo-locked keys", async () => {
477
+ const standard = createTestApp({ authenticated: true });
478
+ standard.app.route("/api/settings", settingsApiRoutes);
479
+
480
+ for (const key of [
481
+ "AUTH_SECRET",
482
+ "GITHUB_SYNC_TOKEN",
483
+ "CUSTOM_CSS",
484
+ "SITE_AVATAR",
485
+ ]) {
486
+ const res = await standard.app.request(`/api/settings/${key}`, {
487
+ method: "DELETE",
488
+ });
489
+ expect(res.status).toBe(400);
490
+ expect((await res.json()).details.rejectedKeys).toContain(key);
491
+ }
492
+
493
+ const demo = createTestApp({ authenticated: true, demoMode: true });
494
+ demo.app.route("/api/settings", settingsApiRoutes);
495
+ const demoResponse = await demo.app.request("/api/settings/NOINDEX", {
496
+ method: "DELETE",
497
+ });
498
+ expect(demoResponse.status).toBe(400);
499
+ });
500
+ });
501
+
285
502
  describe("PUT /api/settings/import", () => {
286
503
  it("returns 401 when not authenticated", async () => {
287
504
  const { app } = createTestApp({ authenticated: false });
@@ -7,6 +7,7 @@ import { z } from "zod";
7
7
  import type { Bindings, CollectionSortOrder } from "../../types.js";
8
8
  import type { AppVariables } from "../../types/app-context.js";
9
9
  import { requireAuthApi } from "../../middleware/auth.js";
10
+ import { requirePublicApiAccess } from "../../middleware/public-content-access.js";
10
11
  import {
11
12
  CollectionDirectoryItemIdSchema,
12
13
  CollectionDescriptionValueSchema,
@@ -30,8 +31,8 @@ const UpdateCollectionSchema = CreateCollectionSchema.partial().extend({
30
31
  sortOrder: CollectionSortOrderSchema.optional(),
31
32
  });
32
33
 
33
- const PostAssignSchema = z.object({
34
- postId: PostIdSchema,
34
+ const ThreadAssignSchema = z.object({
35
+ threadId: PostIdSchema,
35
36
  });
36
37
 
37
38
  const MoveSchema = z.object({
@@ -43,8 +44,8 @@ const ListCollectionsQuerySchema = z.object({
43
44
  view: z.enum(["compose"]).optional(),
44
45
  });
45
46
 
46
- // List collections (includes post counts and directory items)
47
- collectionsApiRoutes.get("/", async (c) => {
47
+ // List collections (includes Thread counts and directory items)
48
+ collectionsApiRoutes.get("/", requirePublicApiAccess(), async (c) => {
48
49
  const query = parseValidated(ListCollectionsQuerySchema, c.req.query());
49
50
 
50
51
  if (query.view === "compose") {
@@ -135,7 +136,7 @@ collectionsApiRoutes.delete(
135
136
  );
136
137
 
137
138
  // Get single collection
138
- collectionsApiRoutes.get("/:id", async (c) => {
139
+ collectionsApiRoutes.get("/:id", requirePublicApiAccess(), async (c) => {
139
140
  const id = parseIdParam(c.req.param("id"), ID_PREFIX.collection);
140
141
  const collection = assertFound(
141
142
  await c.var.services.collections.getById(id),
@@ -181,56 +182,51 @@ collectionsApiRoutes.delete("/:id", requireAuthApi(), async (c) => {
181
182
  return c.json({ success: true });
182
183
  });
183
184
 
184
- // Add a post to a collection (requires auth)
185
- collectionsApiRoutes.post("/:id/posts", requireAuthApi(), async (c) => {
185
+ // Add a thread to a collection (requires auth)
186
+ collectionsApiRoutes.post("/:id/threads", requireAuthApi(), async (c) => {
186
187
  const id = parseIdParam(c.req.param("id"), ID_PREFIX.collection);
187
188
  assertFound(await c.var.services.collections.getById(id), "Collection");
188
189
 
189
- const body = parseValidated(PostAssignSchema, await c.req.json());
190
- assertFound(await c.var.services.posts.getById(body.postId), "Post");
191
-
192
- await c.var.services.collections.addPost(id, body.postId);
190
+ const body = parseValidated(ThreadAssignSchema, await c.req.json());
191
+ await c.var.services.collections.addThread(id, body.threadId);
193
192
 
194
193
  return c.json({ success: true }, 201);
195
194
  });
196
195
 
197
- // Remove a post from a collection (requires auth)
196
+ // Remove a thread from a collection (requires auth)
198
197
  collectionsApiRoutes.delete(
199
- "/:id/posts/:postId",
198
+ "/:id/threads/:threadId",
200
199
  requireAuthApi(),
201
200
  async (c) => {
202
201
  const id = parseIdParam(c.req.param("id"), ID_PREFIX.collection);
203
- const postId = parseIdParam(c.req.param("postId"), ID_PREFIX.post);
204
-
205
- await c.var.services.collections.removePost(id, postId);
202
+ const threadId = parseIdParam(c.req.param("threadId"), ID_PREFIX.post);
203
+ await c.var.services.collections.removeThread(id, threadId);
206
204
 
207
205
  return c.json({ success: true });
208
206
  },
209
207
  );
210
208
 
211
- // Pin a post within a collection (requires auth)
209
+ // Pin a thread within a collection (requires auth)
212
210
  collectionsApiRoutes.put(
213
- "/:id/posts/:postId/pin",
211
+ "/:id/threads/:threadId/pin",
214
212
  requireAuthApi(),
215
213
  async (c) => {
216
214
  const id = parseIdParam(c.req.param("id"), ID_PREFIX.collection);
217
- const postId = parseIdParam(c.req.param("postId"), ID_PREFIX.post);
218
-
219
- await c.var.services.collections.pinPost(id, postId);
215
+ const threadId = parseIdParam(c.req.param("threadId"), ID_PREFIX.post);
216
+ await c.var.services.collections.pinThread(id, threadId);
220
217
 
221
218
  return c.json({ success: true });
222
219
  },
223
220
  );
224
221
 
225
- // Unpin a post within a collection (requires auth)
222
+ // Unpin a thread within a collection (requires auth)
226
223
  collectionsApiRoutes.delete(
227
- "/:id/posts/:postId/pin",
224
+ "/:id/threads/:threadId/pin",
228
225
  requireAuthApi(),
229
226
  async (c) => {
230
227
  const id = parseIdParam(c.req.param("id"), ID_PREFIX.collection);
231
- const postId = parseIdParam(c.req.param("postId"), ID_PREFIX.post);
232
-
233
- await c.var.services.collections.unpinPost(id, postId);
228
+ const threadId = parseIdParam(c.req.param("threadId"), ID_PREFIX.post);
229
+ await c.var.services.collections.unpinThread(id, threadId);
234
230
 
235
231
  return c.json({ success: true });
236
232
  },
@@ -24,6 +24,8 @@ exportApiRoutes.post("/hugo", requireAuthApi(), async (c) => {
24
24
  siteDescription: appConfig.siteDescription,
25
25
  siteLanguage: appConfig.siteLanguage,
26
26
  showJantBrandingOnHome: appConfig.showJantBrandingOnHome,
27
+ publicApiEnabled: appConfig.publicApiEnabled,
28
+ rssFeedsEnabled: appConfig.rssFeedsEnabled,
27
29
  mainRssFeed: appConfig.mainRssFeed,
28
30
  siteFooter: appConfig.siteFooter,
29
31
  showHeaderAvatar: appConfig.showHeaderAvatar,