@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
@@ -88,6 +88,8 @@ export function makeSiteConfig(over: Partial<SiteConfig> = {}): SiteConfig {
88
88
  siteDescription: "Export fixture",
89
89
  siteLanguage: "en",
90
90
  showJantBrandingOnHome: true,
91
+ publicApiEnabled: true,
92
+ rssFeedsEnabled: true,
91
93
  mainRssFeed: "latest",
92
94
  siteFooter: "",
93
95
  showHeaderAvatar: false,
@@ -17,8 +17,10 @@ import { __test__ } from "../../bin/commands/import-site.js";
17
17
  const {
18
18
  walkHugoContent,
19
19
  loadSiteConfig,
20
+ buildSettingsUpdatesFromConfig,
20
21
  mediaSpecFromJantMedia,
21
22
  resolveCollectionMemberships,
23
+ resolveThreadCollectionMemberships,
22
24
  buildPostPayloadFromBundle,
23
25
  getRootAliasPathsForImport,
24
26
  uploadMediaList,
@@ -101,6 +103,8 @@ describe("Hugo import CLI helpers", () => {
101
103
  "show_jant_branding_on_home = true",
102
104
  "show_header_avatar = false",
103
105
  "noindex = false",
106
+ "public_api_enabled = false",
107
+ "rss_feeds_enabled = false",
104
108
  'site_avatar_mode = "none"',
105
109
  'favicon_mode = "default"',
106
110
  'apple_touch_mode = "default"',
@@ -135,6 +139,12 @@ describe("Hugo import CLI helpers", () => {
135
139
  expect(siteConfig.title).toBe("Example Site");
136
140
  expect(siteConfig.base_url).toBe("https://example.com/");
137
141
  expect(siteConfig.extra.jant.theme_id).toBe("paper");
142
+ expect(siteConfig.extra.jant.public_api_enabled).toBe(false);
143
+ expect(siteConfig.extra.jant.rss_feeds_enabled).toBe(false);
144
+ expect(buildSettingsUpdatesFromConfig(siteConfig)).toMatchObject({
145
+ PUBLIC_API_ENABLED: "false",
146
+ RSS_FEEDS_ENABLED: "false",
147
+ });
138
148
  expect(siteConfig.extra.jant).not.toHaveProperty("home_default_view");
139
149
  expect(siteConfig.extra.jant.nav_exported).toBe(true);
140
150
  expect(siteConfig.extra.jant.nav).toHaveLength(1);
@@ -297,6 +307,61 @@ describe("Hugo import CLI helpers", () => {
297
307
  ]);
298
308
  });
299
309
 
310
+ it("unions legacy per-post Collection metadata across a Thread", () => {
311
+ const slugToId = new Map([
312
+ ["ideas", "col_ideas"],
313
+ ["walks", "col_walks"],
314
+ ]);
315
+ const rootBundle = {
316
+ frontMatter: {
317
+ collections: [
318
+ {
319
+ slug: "ideas",
320
+ collected_at: "2026-03-01T00:00:00Z",
321
+ position: 5,
322
+ pinned_at: "2026-03-05T00:00:00Z",
323
+ },
324
+ ],
325
+ },
326
+ children: [
327
+ {
328
+ frontMatter: {
329
+ collections: [
330
+ {
331
+ slug: "ideas",
332
+ collected_at: "2026-03-03T00:00:00Z",
333
+ position: 2,
334
+ pinned_at: "2026-03-04T00:00:00Z",
335
+ },
336
+ {
337
+ slug: "walks",
338
+ collected_at: "2026-03-02T00:00:00Z",
339
+ position: 7,
340
+ },
341
+ ],
342
+ },
343
+ },
344
+ ],
345
+ };
346
+
347
+ expect(resolveThreadCollectionMemberships(rootBundle, slugToId)).toEqual({
348
+ entries: [
349
+ {
350
+ collectionId: "col_ideas",
351
+ createdAt: Date.parse("2026-03-03T00:00:00Z") / 1000,
352
+ position: 2,
353
+ pinnedAt: Date.parse("2026-03-05T00:00:00Z") / 1000,
354
+ },
355
+ {
356
+ collectionId: "col_walks",
357
+ createdAt: Date.parse("2026-03-02T00:00:00Z") / 1000,
358
+ position: 7,
359
+ },
360
+ ],
361
+ ids: ["col_ideas", "col_walks"],
362
+ });
363
+ });
364
+
300
365
  it("buildPostPayloadFromBundle translates front matter into createPost input", () => {
301
366
  const bundle = {
302
367
  slug: "hello",
@@ -0,0 +1,45 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { resolveWorkerDevProcessOptions } from "../../vite.config.js";
6
+
7
+ describe("worker Vite config", () => {
8
+ const tempDirs: string[] = [];
9
+
10
+ afterEach(() => {
11
+ vi.unstubAllEnvs();
12
+ for (const dir of tempDirs.splice(0)) {
13
+ rmSync(dir, { recursive: true, force: true });
14
+ }
15
+ });
16
+
17
+ function createEnvDir(files: Record<string, string>): string {
18
+ const dir = mkdtempSync(join(tmpdir(), "jant-vite-env-"));
19
+ tempDirs.push(dir);
20
+
21
+ for (const [name, contents] of Object.entries(files)) {
22
+ writeFileSync(join(dir, name), contents);
23
+ }
24
+
25
+ return dir;
26
+ }
27
+
28
+ it("loads the worker dev port from .env", () => {
29
+ vi.stubEnv("PORT", undefined);
30
+ const envDir = createEnvDir({ ".env": "PORT=3012\n" });
31
+
32
+ expect(resolveWorkerDevProcessOptions("development", envDir).port).toBe(
33
+ 3012,
34
+ );
35
+ });
36
+
37
+ it("lets the shell environment override .env", () => {
38
+ const envDir = createEnvDir({ ".env": "PORT=3012\n" });
39
+ vi.stubEnv("PORT", "4012");
40
+
41
+ expect(resolveWorkerDevProcessOptions("development", envDir).port).toBe(
42
+ 4012,
43
+ );
44
+ });
45
+ });
package/src/app.tsx CHANGED
@@ -55,6 +55,7 @@ import {
55
55
  import { telegramWebhookRoutes } from "./routes/api/telegram.js";
56
56
  import { internalTextAttachmentsRoutes } from "./routes/api/internal/text-attachments.js";
57
57
  import { internalSearchReindexRoutes } from "./routes/api/internal/search-reindex.js";
58
+ import { internalPostBodyHtmlRoutes } from "./routes/api/internal/post-body-html.js";
58
59
  import { internalUploadsRoutes } from "./routes/api/internal/uploads.js";
59
60
  import { publicArchiveApiRoutes } from "./routes/api/public/archive.js";
60
61
  import { publicPostsApiRoutes } from "./routes/api/public/posts.js";
@@ -65,11 +66,13 @@ import { composeRoutes } from "./routes/compose.js";
65
66
  import { feedRoutes } from "./routes/feed/feed.js";
66
67
  import { sitemapRoutes } from "./routes/feed/sitemap.js";
67
68
  import { manifestRoutes } from "./routes/feed/manifest.js";
69
+ import { siteSkillRoutes } from "./routes/site-skill.js";
68
70
 
69
71
  // Middleware
70
72
  import { requireAuth } from "./middleware/auth.js";
71
73
  import { attachSession } from "./middleware/session.js";
72
74
  import { defaultCacheControl } from "./middleware/cache-control.js";
75
+ import { requireRssFeedsEnabled } from "./middleware/public-content-access.js";
73
76
  import { requireOnboarding } from "./middleware/onboarding.js";
74
77
  import { errorHandler } from "./middleware/error-handler.js";
75
78
  import { withConfig } from "./middleware/config.js";
@@ -355,6 +358,7 @@ export function createApp(): App {
355
358
  app.route("/api/internal/sites", internalSitesRoutes);
356
359
  app.route("/api/internal/text-attachments", internalTextAttachmentsRoutes);
357
360
  app.route("/api/internal/search/reindex", internalSearchReindexRoutes);
361
+ app.route("/api/internal/posts/body-html", internalPostBodyHtmlRoutes);
358
362
  app.route("/api/internal/uploads", internalUploadsRoutes);
359
363
  app.route("/api/github-sync", githubSyncWebhookRoutes);
360
364
  app.route("/api/telegram", telegramWebhookRoutes);
@@ -382,7 +386,9 @@ export function createApp(): App {
382
386
  if (!object) return c.notFound();
383
387
 
384
388
  const markdown = await new Response(object.body).text();
385
- const html = renderTiptapJson(markdownToTiptapJson(markdown));
389
+ const html = renderTiptapJson(markdownToTiptapJson(markdown), {
390
+ namespace: media.id,
391
+ });
386
392
 
387
393
  return c.json({ html, markdown }, 200, {
388
394
  "Cache-Control": "public, no-cache",
@@ -485,8 +491,8 @@ export function createApp(): App {
485
491
  // Redirect middleware — only handles redirect-type custom URLs
486
492
  app.use("*", async (c, next) => {
487
493
  const path = new URL(c.req.url).pathname;
488
- // Skip redirect check for API routes and static assets
489
- if (path.startsWith("/api/") || isAssetPath(path)) {
494
+ // Skip redirect lookup for fixed machine routes and static assets.
495
+ if (path.startsWith("/api/") || path === "/skill.md" || isAssetPath(path)) {
490
496
  return next();
491
497
  }
492
498
 
@@ -511,8 +517,15 @@ export function createApp(): App {
511
517
  app.use("*", withConfig());
512
518
  app.use("*", i18nMiddleware());
513
519
 
520
+ // Feed URLs are spread across page route groups (including dynamic
521
+ // collection aliases), so one config-aware guard owns the URL policy.
522
+ app.use("*", requireRssFeedsEnabled());
523
+
514
524
  // --- Routes that need config ---
515
525
 
526
+ // Public machine-readable site guide
527
+ app.route("/", siteSkillRoutes);
528
+
516
529
  // API Routes
517
530
  app.route("/api/public/posts", publicPostsApiRoutes);
518
531
  app.route("/api/public/archive", publicArchiveApiRoutes);
@@ -0,0 +1,36 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { beforeEach, describe, expect, it } from "vitest";
4
+ import {
5
+ consumeCollectionCreatedNotice,
6
+ PENDING_COLLECTION_CREATED_STORAGE_KEY,
7
+ queueCollectionCreatedNotice,
8
+ } from "../collection-created-notice.js";
9
+
10
+ describe("collection created notice", () => {
11
+ beforeEach(() => {
12
+ globalThis.sessionStorage.clear();
13
+ });
14
+
15
+ it("queues and consumes the newly created Collection ID once", () => {
16
+ queueCollectionCreatedNotice("col_books");
17
+
18
+ expect(
19
+ globalThis.sessionStorage.getItem(PENDING_COLLECTION_CREATED_STORAGE_KEY),
20
+ ).toBe('{"collectionId":"col_books"}');
21
+ expect(consumeCollectionCreatedNotice()).toBe("col_books");
22
+ expect(consumeCollectionCreatedNotice()).toBeNull();
23
+ });
24
+
25
+ it("clears malformed pending state", () => {
26
+ globalThis.sessionStorage.setItem(
27
+ PENDING_COLLECTION_CREATED_STORAGE_KEY,
28
+ '{"collectionId":false}',
29
+ );
30
+
31
+ expect(consumeCollectionCreatedNotice()).toBeNull();
32
+ expect(
33
+ globalThis.sessionStorage.getItem(PENDING_COLLECTION_CREATED_STORAGE_KEY),
34
+ ).toBeNull();
35
+ });
36
+ });
@@ -6,6 +6,10 @@ vi.mock("../toast.js", () => ({
6
6
  showToast: vi.fn(),
7
7
  }));
8
8
 
9
+ vi.mock("../collection-created-notice.js", () => ({
10
+ queueCollectionCreatedNotice: vi.fn(),
11
+ }));
12
+
9
13
  import "../collection-form-bridge.js";
10
14
 
11
15
  type CollectionFormHarness = HTMLElement & {
@@ -50,6 +54,7 @@ async function flushAsyncWork() {
50
54
  describe("collection form bridge", () => {
51
55
  beforeEach(() => {
52
56
  document.body.innerHTML = "";
57
+ vi.clearAllMocks();
53
58
  vi.restoreAllMocks();
54
59
  window.location.href = "http://localhost/collections/new";
55
60
  });
@@ -57,7 +62,7 @@ describe("collection form bridge", () => {
57
62
  it("redirects new collections back to the configured return URL", async () => {
58
63
  const formEl = createPageForm("/collections");
59
64
  const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
60
- new Response(JSON.stringify({ slug: "books" }), {
65
+ new Response(JSON.stringify({ id: "col_books", slug: "books" }), {
61
66
  status: 201,
62
67
  headers: { "Content-Type": "application/json" },
63
68
  }),
@@ -79,6 +84,9 @@ describe("collection form bridge", () => {
79
84
 
80
85
  await flushAsyncWork();
81
86
 
87
+ const { queueCollectionCreatedNotice } =
88
+ await import("../collection-created-notice.js");
89
+
82
90
  expect(fetchSpy).toHaveBeenCalledWith("/api/collections", {
83
91
  method: "POST",
84
92
  headers: {
@@ -91,6 +99,7 @@ describe("collection form bridge", () => {
91
99
  }),
92
100
  });
93
101
  expect(window.location.pathname).toBe("/collections");
102
+ expect(queueCollectionCreatedNotice).toHaveBeenCalledWith("col_books");
94
103
  });
95
104
 
96
105
  it("redirects edited collections back to the collections page when launched from the list", async () => {
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
4
4
 
5
5
  vi.mock("../toast.js", () => ({
6
6
  showToast: vi.fn(),
7
+ showToastWithAction: vi.fn(),
7
8
  }));
8
9
 
9
10
  vi.mock("../confirm.js", () => ({
@@ -16,7 +17,8 @@ function createMarkup() {
16
17
  <div
17
18
  data-collection-page-actions
18
19
  data-collection-id="collection-1"
19
- data-collection-page-labels='{"edit":"Edit","moreActions":"More actions","deleteCollection":"Delete","confirmDelete":"Delete this collection permanently? Posts inside won\\u0027t be removed.","cancel":"Cancel","saveFailed":"Couldn\\u0027t save. Try again in a moment.","deleted":"Deleted"}'
20
+ data-collection-page-labels='{"edit":"Edit","addToNavigation":"Add to Navigation","addingToNavigation":"Adding…","addedToNavigation":"Collection added to navigation.","editNavigation":"Edit Navigation","addToNavigationFailed":"Couldn\\u0027t add this collection to navigation. Try again.","moreActions":"More actions","deleteCollection":"Delete","confirmDelete":"Delete this collection permanently? Threads inside won\\u0027t be removed.","cancel":"Cancel","saveFailed":"Couldn\\u0027t save. Try again in a moment.","deleted":"Deleted"}'
21
+ data-collection-in-navigation="false"
20
22
  data-collection-page-redirect-url="/collections"
21
23
  >
22
24
  <button
@@ -30,6 +32,21 @@ function createMarkup() {
30
32
  <a href="/collections/original-slug/edit?returnTo=%2Foriginal-slug" role="menuitem">
31
33
  Edit
32
34
  </a>
35
+ <button
36
+ type="button"
37
+ role="menuitem"
38
+ data-collection-page-action="add-to-navigation"
39
+ >
40
+ <span class="collections-page-menu-item-label">Add to Navigation</span>
41
+ </button>
42
+ <a
43
+ href="/settings/navigation"
44
+ role="menuitem"
45
+ data-collection-page-edit-navigation
46
+ hidden
47
+ >
48
+ Edit Navigation
49
+ </a>
33
50
  <button
34
51
  type="button"
35
52
  role="menuitem"
@@ -42,6 +59,11 @@ function createMarkup() {
42
59
  `;
43
60
  }
44
61
 
62
+ async function flushAsyncWork() {
63
+ await new Promise((resolve) => setTimeout(resolve, 0));
64
+ await new Promise((resolve) => setTimeout(resolve, 0));
65
+ }
66
+
45
67
  describe("collection detail page actions", () => {
46
68
  beforeEach(() => {
47
69
  vi.resetModules();
@@ -98,7 +120,7 @@ describe("collection detail page actions", () => {
98
120
 
99
121
  expect(showConfirmDialog).toHaveBeenCalledWith({
100
122
  message:
101
- "Delete this collection permanently? Posts inside won't be removed.",
123
+ "Delete this collection permanently? Threads inside won't be removed.",
102
124
  confirmLabel: "Delete",
103
125
  cancelLabel: "Cancel",
104
126
  tone: "danger",
@@ -109,4 +131,51 @@ describe("collection detail page actions", () => {
109
131
  expect(showToast).toHaveBeenCalledWith("Deleted");
110
132
  expect(window.location.pathname).toBe("/collections");
111
133
  });
134
+
135
+ it("adds the collection to navigation and reveals the settings action", async () => {
136
+ createMarkup();
137
+ const fetchMock = vi.fn().mockResolvedValue(
138
+ new Response(JSON.stringify({ id: "nav-1" }), {
139
+ status: 201,
140
+ headers: { "Content-Type": "application/json" },
141
+ }),
142
+ );
143
+ vi.stubGlobal("fetch", fetchMock);
144
+
145
+ const { showToastWithAction } = await import("../toast.js");
146
+ await import("../collection-page-actions.js");
147
+
148
+ const addButton = document.querySelector<HTMLButtonElement>(
149
+ "[data-collection-page-action='add-to-navigation']",
150
+ );
151
+ const editNavigationLink = document.querySelector<HTMLAnchorElement>(
152
+ "[data-collection-page-edit-navigation]",
153
+ );
154
+
155
+ addButton?.click();
156
+ await flushAsyncWork();
157
+
158
+ expect(fetchMock).toHaveBeenCalledWith("/api/nav-items", {
159
+ method: "POST",
160
+ headers: {
161
+ "Content-Type": "application/json",
162
+ Accept: "application/json",
163
+ "X-Jant-Site-Header": "include",
164
+ },
165
+ body: JSON.stringify({
166
+ type: "collection",
167
+ collectionId: "collection-1",
168
+ placement: "header",
169
+ }),
170
+ });
171
+ expect(addButton?.hidden).toBe(true);
172
+ expect(editNavigationLink?.hidden).toBe(false);
173
+ expect(showToastWithAction).toHaveBeenCalledWith(
174
+ "Collection added to navigation.",
175
+ {
176
+ label: "Edit Navigation",
177
+ href: "/settings/navigation",
178
+ },
179
+ );
180
+ });
112
181
  });
@@ -118,6 +118,70 @@ describe("compose bridge", () => {
118
118
  expect(refreshCollections).toHaveBeenCalledTimes(1);
119
119
  });
120
120
 
121
+ it("omits stale Collection state from Reply requests", async () => {
122
+ const composeEl = document.createElement(
123
+ "jant-compose-dialog",
124
+ ) as ComposeHarness;
125
+ composeEl.refreshCollections = vi.fn(async () => true);
126
+ composeEl.pageMode = false;
127
+ document.body.appendChild(composeEl);
128
+
129
+ const fetchSpy = vi
130
+ .spyOn(globalThis, "fetch")
131
+ .mockImplementation(async (input, init) => {
132
+ const raw =
133
+ typeof input === "string"
134
+ ? input
135
+ : input instanceof URL
136
+ ? input.toString()
137
+ : input.url;
138
+ const url = new URL(raw, "http://localhost");
139
+
140
+ if (url.pathname === "/compose") {
141
+ const body = JSON.parse(String(init?.body)) as Record<
142
+ string,
143
+ unknown
144
+ >;
145
+ expect(body.replyToId).toBe("pst_parent");
146
+ expect(body).not.toHaveProperty("collectionIds");
147
+
148
+ return new Response(
149
+ JSON.stringify({ status: "draft", toast: "Draft saved." }),
150
+ {
151
+ status: 200,
152
+ headers: { "Content-Type": "application/json" },
153
+ },
154
+ );
155
+ }
156
+
157
+ throw new Error(`Unexpected fetch: ${url.pathname}`);
158
+ });
159
+
160
+ composeEl.dispatchEvent(
161
+ new CustomEvent("jant:compose-submit-deferred", {
162
+ bubbles: true,
163
+ detail: {
164
+ format: "note",
165
+ title: "",
166
+ body: "Reply draft",
167
+ url: "",
168
+ quoteText: "",
169
+ quoteAuthor: "",
170
+ status: "draft",
171
+ rating: 0,
172
+ collectionIds: ["col-stale"],
173
+ attachments: [],
174
+ pendingAttachments: [],
175
+ replyToId: "pst_parent",
176
+ },
177
+ }),
178
+ );
179
+
180
+ await flushBridgeWork();
181
+
182
+ expect(fetchSpy).toHaveBeenCalledOnce();
183
+ });
184
+
121
185
  it("stays on page and resets form after page-mode publish", async () => {
122
186
  const composeEl = document.createElement(
123
187
  "jant-compose-dialog",
@@ -1,7 +1,11 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
3
  import { describe, expect, it } from "vitest";
4
- import { chooseAutoplayVideo } from "../feed-video-player.js";
4
+ import {
5
+ chooseAutoplayVideo,
6
+ shouldSuppressFeedVideoAutoplay,
7
+ } from "../feed-video-player.js";
8
+ import { setMediaVideoPlaybackPaused } from "../media-lightbox-events.js";
5
9
 
6
10
  describe("chooseAutoplayVideo", () => {
7
11
  it("prefers the candidate with the largest visible area", () => {
@@ -41,4 +45,15 @@ describe("chooseAutoplayVideo", () => {
41
45
 
42
46
  expect(winner?.video).toBe("second");
43
47
  });
48
+
49
+ it("suppresses only media the user explicitly paused", () => {
50
+ setMediaVideoPlaybackPaused("media-paused", true);
51
+
52
+ expect(shouldSuppressFeedVideoAutoplay("media-paused")).toBe(true);
53
+ expect(shouldSuppressFeedVideoAutoplay("media-playing")).toBe(false);
54
+ expect(shouldSuppressFeedVideoAutoplay(undefined)).toBe(false);
55
+
56
+ setMediaVideoPlaybackPaused("media-paused", false);
57
+ expect(shouldSuppressFeedVideoAutoplay("media-paused")).toBe(false);
58
+ });
44
59
  });