@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
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { resolveConfig } from "../resolve-config.js";
2
+ import { resolveConfig, resolveSummaryConfig } from "../resolve-config.js";
3
3
  import type { Bindings } from "../../types/bindings.js";
4
4
 
5
5
  function makeEnv(overrides: Partial<Bindings> = {}): Bindings {
@@ -28,11 +28,14 @@ describe("resolveConfig", () => {
28
28
  expect(config.timeZone).toBe("UTC");
29
29
  expect(config.showJantBrandingOnHome).toBe(false);
30
30
  expect(config.noindex).toBe(false);
31
+ expect(config.publicApiEnabled).toBe(true);
32
+ expect(config.rssFeedsEnabled).toBe(true);
31
33
  expect(config.demoMode).toBe(false);
32
34
  expect(config.pageSize).toBe(50);
33
35
  expect(config.searchPageSize).toBe(50);
34
36
  expect(config.archivePageSize).toBe(50);
35
37
  expect(config.rssFeedLimit).toBe(50);
38
+ expect(config.rssPublishDelaySeconds).toBe(300);
36
39
  });
37
40
 
38
41
  it("DB settings override ENV and defaults", () => {
@@ -49,6 +52,16 @@ describe("resolveConfig", () => {
49
52
  expect(config.siteName).toBe("FromEnv");
50
53
  });
51
54
 
55
+ it("lets an explicit empty DB value override an environment fallback", () => {
56
+ const config = resolveConfig(
57
+ makeEnv({ SITE_DESCRIPTION: "From environment" }),
58
+ { SITE_DESCRIPTION: "" },
59
+ );
60
+
61
+ expect(config.siteDescription).toBe("");
62
+ expect(config.siteDescriptionExplicit).toBe(false);
63
+ });
64
+
52
65
  it("resolves mainRssFeed from DB, env, and defaults", () => {
53
66
  const defaultConfig = resolveConfig(makeEnv(), {});
54
67
  expect(defaultConfig.mainRssFeed).toBe("featured");
@@ -196,11 +209,15 @@ describe("resolveConfig", () => {
196
209
  it("resolves boolean fields correctly", () => {
197
210
  const config = resolveConfig(makeEnv(), {
198
211
  NOINDEX: "true",
212
+ PUBLIC_API_ENABLED: "false",
213
+ RSS_FEEDS_ENABLED: "false",
199
214
  SHOW_HEADER_AVATAR: "true",
200
215
  SHOW_JANT_BRANDING_ON_HOME: "true",
201
216
  });
202
217
 
203
218
  expect(config.noindex).toBe(true);
219
+ expect(config.publicApiEnabled).toBe(false);
220
+ expect(config.rssFeedsEnabled).toBe(false);
204
221
  expect(config.showHeaderAvatar).toBe(true);
205
222
  expect(config.showJantBrandingOnHome).toBe(true);
206
223
  });
@@ -232,6 +249,7 @@ describe("resolveConfig", () => {
232
249
  SEARCH_PAGE_SIZE: 7,
233
250
  ARCHIVE_PAGE_SIZE: "9",
234
251
  RSS_FEED_LIMIT: 25,
252
+ RSS_PUBLISH_DELAY_SECONDS: "600",
235
253
  }),
236
254
  {},
237
255
  );
@@ -239,6 +257,13 @@ describe("resolveConfig", () => {
239
257
  expect(config1.searchPageSize).toBe(7);
240
258
  expect(config1.archivePageSize).toBe(9);
241
259
  expect(config1.rssFeedLimit).toBe(25);
260
+ expect(config1.rssPublishDelaySeconds).toBe(600);
261
+
262
+ const configWithNoDelay = resolveConfig(
263
+ makeEnv({ RSS_PUBLISH_DELAY_SECONDS: 0 }),
264
+ {},
265
+ );
266
+ expect(configWithNoDelay.rssPublishDelaySeconds).toBe(0);
242
267
 
243
268
  const config2 = resolveConfig(
244
269
  makeEnv({
@@ -246,6 +271,7 @@ describe("resolveConfig", () => {
246
271
  SEARCH_PAGE_SIZE: 0,
247
272
  ARCHIVE_PAGE_SIZE: false,
248
273
  RSS_FEED_LIMIT: "invalid",
274
+ RSS_PUBLISH_DELAY_SECONDS: "-1",
249
275
  }),
250
276
  {},
251
277
  );
@@ -253,6 +279,92 @@ describe("resolveConfig", () => {
253
279
  expect(config2.searchPageSize).toBe(50);
254
280
  expect(config2.archivePageSize).toBe(50);
255
281
  expect(config2.rssFeedLimit).toBe(50);
282
+ expect(config2.rssPublishDelaySeconds).toBe(300);
283
+
284
+ const configWithBlankDelay = resolveConfig(
285
+ makeEnv({ RSS_PUBLISH_DELAY_SECONDS: " " }),
286
+ {},
287
+ );
288
+ expect(configWithBlankDelay.rssPublishDelaySeconds).toBe(300);
289
+ });
290
+
291
+ it("resolves runtime numeric settings from DB before environment values", () => {
292
+ const config = resolveConfig(
293
+ makeEnv({
294
+ PAGE_SIZE: "20",
295
+ SEARCH_PAGE_SIZE: "25",
296
+ ARCHIVE_PAGE_SIZE: "30",
297
+ SUMMARY_MAX_PARAGRAPHS: "6",
298
+ SUMMARY_MAX_CHARS: "600",
299
+ RSS_FEED_LIMIT: "60",
300
+ RSS_PUBLISH_DELAY_SECONDS: "600",
301
+ }),
302
+ {
303
+ PAGE_SIZE: "80",
304
+ SUMMARY_MAX_PARAGRAPHS: "12",
305
+ SUMMARY_MAX_CHARS: "1200",
306
+ RSS_FEED_LIMIT: "120",
307
+ RSS_PUBLISH_DELAY_SECONDS: "0",
308
+ },
309
+ );
310
+
311
+ expect(config.pageSize).toBe(80);
312
+ expect(config.searchPageSize).toBe(25);
313
+ expect(config.archivePageSize).toBe(30);
314
+ expect(config.summaryMaxParagraphs).toBe(12);
315
+ expect(config.summaryMaxChars).toBe(1200);
316
+ expect(config.rssFeedLimit).toBe(120);
317
+ expect(config.rssPublishDelaySeconds).toBe(0);
318
+ });
319
+
320
+ it("inherits runtime page size and rejects out-of-range numeric values", () => {
321
+ const config = resolveConfig(
322
+ makeEnv({
323
+ PAGE_SIZE: "101",
324
+ SEARCH_PAGE_SIZE: "0",
325
+ ARCHIVE_PAGE_SIZE: "1.5",
326
+ SUMMARY_MAX_PARAGRAPHS: "51",
327
+ SUMMARY_MAX_CHARS: "1501",
328
+ RSS_FEED_LIMIT: "201",
329
+ RSS_PUBLISH_DELAY_SECONDS: "7201",
330
+ }),
331
+ { PAGE_SIZE: "75" },
332
+ );
333
+
334
+ expect(config.pageSize).toBe(75);
335
+ expect(config.searchPageSize).toBe(75);
336
+ expect(config.archivePageSize).toBe(75);
337
+ expect(config.summaryMaxParagraphs).toBe(5);
338
+ expect(config.summaryMaxChars).toBe(500);
339
+ expect(config.rssFeedLimit).toBe(50);
340
+ expect(config.rssPublishDelaySeconds).toBe(300);
341
+ });
342
+
343
+ it("resolves summary limits without the full app config", () => {
344
+ expect(
345
+ resolveSummaryConfig(
346
+ makeEnv({
347
+ SUMMARY_MAX_PARAGRAPHS: "3",
348
+ SUMMARY_MAX_CHARS: 240,
349
+ }),
350
+ ),
351
+ ).toEqual({ maxParagraphs: 3, maxChars: 240 });
352
+
353
+ expect(
354
+ resolveSummaryConfig(
355
+ makeEnv({
356
+ SUMMARY_MAX_PARAGRAPHS: 0,
357
+ SUMMARY_MAX_CHARS: "invalid",
358
+ }),
359
+ ),
360
+ ).toEqual({ maxParagraphs: 5, maxChars: 500 });
361
+
362
+ expect(
363
+ resolveSummaryConfig(makeEnv({ SUMMARY_MAX_CHARS: "240" }), {
364
+ SUMMARY_MAX_PARAGRAPHS: "8",
365
+ SUMMARY_MAX_CHARS: "900",
366
+ }),
367
+ ).toEqual({ maxParagraphs: 8, maxChars: 900 });
256
368
  });
257
369
 
258
370
  it("resolves fallbacks without DB values", () => {
@@ -801,6 +801,88 @@ describe("extractSummaryHtml", () => {
801
801
  expect(result!.html).toContain(">Link text</a>");
802
802
  });
803
803
 
804
+ it("includes referenced footnote definitions with the requested namespace", () => {
805
+ const doc = JSON.stringify({
806
+ type: "doc",
807
+ content: [
808
+ {
809
+ type: "paragraph",
810
+ content: [
811
+ { type: "text", text: "Visible" },
812
+ { type: "footnoteReference", attrs: { label: "note" } },
813
+ ],
814
+ },
815
+ {
816
+ type: "paragraph",
817
+ content: [{ type: "text", text: "Hidden tail" }],
818
+ },
819
+ {
820
+ type: "footnoteDefinition",
821
+ attrs: { label: "note" },
822
+ content: [
823
+ {
824
+ type: "paragraph",
825
+ content: [{ type: "text", text: "Definition" }],
826
+ },
827
+ ],
828
+ },
829
+ ],
830
+ });
831
+
832
+ const result = extractSummaryHtml(doc, 1, 500, 0, {
833
+ namespace: "pst_summary",
834
+ });
835
+
836
+ expect(result?.hasMore).toBe(true);
837
+ expect(result?.html).toContain('id="fn-0pba1ne36oeor-1"');
838
+ expect(result?.html).toContain('role="doc-endnotes"');
839
+ expect(result?.html.match(/<ol class="footnote-list">/g)).toHaveLength(1);
840
+ expect(result?.html).not.toMatch(
841
+ /footnote-document|footnote-main|data-footnote-|--footnote-/,
842
+ );
843
+ expect(result?.html).not.toContain("Hidden tail");
844
+ expect(result!.html.indexOf("Definition")).toBeGreaterThan(
845
+ result!.html.indexOf("Visible"),
846
+ );
847
+ });
848
+
849
+ it("omits a footnote when its first reference is beyond the summary boundary", () => {
850
+ const doc = JSON.stringify({
851
+ type: "doc",
852
+ content: [
853
+ {
854
+ type: "paragraph",
855
+ content: [{ type: "text", text: "Visible summary" }],
856
+ },
857
+ {
858
+ type: "paragraph",
859
+ content: [
860
+ { type: "text", text: "Hidden reference" },
861
+ { type: "footnoteReference", attrs: { label: "note" } },
862
+ ],
863
+ },
864
+ {
865
+ type: "footnoteDefinition",
866
+ attrs: { label: "note" },
867
+ content: [
868
+ {
869
+ type: "paragraph",
870
+ content: [{ type: "text", text: "Hidden definition" }],
871
+ },
872
+ ],
873
+ },
874
+ ],
875
+ });
876
+
877
+ const result = extractSummaryHtml(doc, 1, 500);
878
+
879
+ expect(result?.hasMore).toBe(true);
880
+ expect(result?.html).toContain("Visible summary");
881
+ expect(result?.html).not.toContain("Hidden reference");
882
+ expect(result?.html).not.toContain("Hidden definition");
883
+ expect(result?.html).not.toContain('role="doc-endnotes"');
884
+ });
885
+
804
886
  function paragraphsDoc(...texts: string[]): string {
805
887
  return JSON.stringify({
806
888
  type: "doc",
@@ -18,7 +18,7 @@ import { createPostService } from "../../services/post.js";
18
18
  import { createMediaService } from "../../services/media.js";
19
19
  import { createPathService } from "../../services/path.js";
20
20
  import { createCollectionService } from "../../services/collection.js";
21
- import { postCollections, posts as postTable, sites } from "../../db/schema.js";
21
+ import { posts as postTable, sites } from "../../db/schema.js";
22
22
  import { buildMediaMap } from "../media-helpers.js";
23
23
  import {
24
24
  assembleCollectionTimeline,
@@ -157,18 +157,14 @@ describe("Timeline data assembly", () => {
157
157
  return {
158
158
  post: postWithMedia,
159
159
  threadPreview: {
160
- secondReply: threadCtx.secondReply
161
- ? {
162
- ...threadCtx.secondReply,
163
- mediaAttachments: [],
164
- }
165
- : undefined,
166
- penultimateReply: threadCtx.penultimateReply
167
- ? {
168
- ...threadCtx.penultimateReply,
169
- mediaAttachments: [],
170
- }
171
- : undefined,
160
+ leadingReplies: threadCtx.leadingReplies.map((reply) => ({
161
+ ...reply,
162
+ mediaAttachments: [],
163
+ })),
164
+ trailingReplies: threadCtx.trailingReplies.map((reply) => ({
165
+ ...reply,
166
+ mediaAttachments: [],
167
+ })),
172
168
  latestReply: {
173
169
  ...threadCtx.latestReply,
174
170
  mediaAttachments: [],
@@ -183,7 +179,9 @@ describe("Timeline data assembly", () => {
183
179
 
184
180
  expect(items).toHaveLength(1);
185
181
  expect(items[0]?.threadPreview).toBeDefined();
186
- expect(items[0]?.threadPreview?.secondReply?.bodyText).toBe("Reply 1");
182
+ expect(items[0]?.threadPreview?.leadingReplies[0]?.bodyText).toBe(
183
+ "Reply 1",
184
+ );
187
185
  expect(items[0]?.threadPreview?.latestReply.bodyText).toBe("Reply 2");
188
186
  expect(items[0]?.threadPreview?.totalReplyCount).toBe(2);
189
187
  });
@@ -347,6 +345,7 @@ describe("Timeline data assembly", () => {
347
345
 
348
346
  expect(result.items).toHaveLength(1);
349
347
  expect(result.items[0]?.post.id).toBe(root.id);
348
+ expect(result.items[0]?.curatedThread?.showContextRatings).toBe(false);
350
349
  expect(result.items[0]?.curatedThread?.segments).toEqual([
351
350
  expect.objectContaining({
352
351
  post: expect.objectContaining({ id: root.id }),
@@ -526,22 +525,26 @@ describe("Timeline data assembly", () => {
526
525
  ]);
527
526
  });
528
527
 
529
- it("orders featured threads by latest featured time instead of later non-featured activity", async () => {
528
+ it("orders Featured Threads by the latest selected Post publication time", async () => {
530
529
  const olderFeaturedRoot = await postService.create({
531
530
  format: "note",
532
531
  bodyMarkdown: "Older featured root",
532
+ publishedAt: 1000,
533
533
  });
534
534
  const olderFeaturedReply = await postService.create({
535
535
  format: "note",
536
536
  bodyMarkdown: "Older featured reply",
537
537
  replyToId: olderFeaturedRoot.id,
538
+ publishedAt: 3000,
538
539
  });
539
540
  const newerFeaturedRoot = await postService.create({
540
541
  format: "note",
541
542
  bodyMarkdown: "Newer featured root",
542
543
  featured: true,
544
+ publishedAt: 2000,
543
545
  });
544
546
 
547
+ // Featured timestamps deliberately disagree with publication order.
545
548
  await db
546
549
  .update(postTable)
547
550
  .set({ featuredAt: 100 })
@@ -555,6 +558,7 @@ describe("Timeline data assembly", () => {
555
558
  format: "note",
556
559
  bodyMarkdown: "Later non-featured reply",
557
560
  replyToId: olderFeaturedReply.id,
561
+ publishedAt: 4000,
558
562
  });
559
563
 
560
564
  const result = await assembleFeaturedTimeline(createTimelineContext(), {
@@ -562,11 +566,63 @@ describe("Timeline data assembly", () => {
562
566
  });
563
567
 
564
568
  expect(result.items).toHaveLength(2);
565
- expect(result.items[0]?.post.id).toBe(newerFeaturedRoot.id);
566
- expect(result.items[1]?.post.id).toBe(olderFeaturedRoot.id);
569
+ expect(result.items[0]?.post.id).toBe(olderFeaturedRoot.id);
570
+ expect(result.items[1]?.post.id).toBe(newerFeaturedRoot.id);
571
+ });
572
+
573
+ it("loads only the Root, Featured Posts, and final Post for curated display", async () => {
574
+ const root = await postService.create({
575
+ format: "note",
576
+ bodyMarkdown: "Root",
577
+ publishedAt: 1000,
578
+ });
579
+ const hiddenReplyA = await postService.create({
580
+ format: "note",
581
+ bodyMarkdown: "Hidden reply A",
582
+ replyToId: root.id,
583
+ publishedAt: 2000,
584
+ });
585
+ const featuredReply = await postService.create({
586
+ format: "note",
587
+ bodyMarkdown: "Featured reply",
588
+ replyToId: hiddenReplyA.id,
589
+ featured: true,
590
+ publishedAt: 3000,
591
+ });
592
+ const hiddenReplyB = await postService.create({
593
+ format: "note",
594
+ bodyMarkdown: "Hidden reply B",
595
+ replyToId: featuredReply.id,
596
+ publishedAt: 4000,
597
+ });
598
+ const finalReply = await postService.create({
599
+ format: "note",
600
+ bodyMarkdown: "Final reply",
601
+ replyToId: hiddenReplyB.id,
602
+ publishedAt: 5000,
603
+ });
604
+
605
+ const result = await postService.getFeaturedThreadTimelineData([root.id]);
606
+ const thread = result.get(root.id);
607
+
608
+ expect(thread?.posts).toEqual([
609
+ expect.objectContaining({
610
+ post: expect.objectContaining({ id: root.id }),
611
+ position: 0,
612
+ }),
613
+ expect.objectContaining({
614
+ post: expect.objectContaining({ id: featuredReply.id }),
615
+ position: 2,
616
+ }),
617
+ expect.objectContaining({
618
+ post: expect.objectContaining({ id: finalReply.id }),
619
+ position: 4,
620
+ }),
621
+ ]);
622
+ expect(thread?.featuredPostIds).toEqual([featuredReply.id]);
567
623
  });
568
624
 
569
- it("groups collection posts by root thread and sorts threads by collected-at", async () => {
625
+ it("renders every post in each collected Thread and sorts by Thread activity", async () => {
570
626
  const collection = await collectionService.create({
571
627
  slug: "reading",
572
628
  title: "Reading",
@@ -595,26 +651,12 @@ describe("Timeline data assembly", () => {
595
651
  bodyMarkdown: "Second thread root",
596
652
  });
597
653
 
598
- await db.insert(postCollections).values([
599
- {
600
- siteId: DEFAULT_TEST_SITE_ID,
601
- postId: collectedReplyA.id,
602
- collectionId: collection.id,
603
- createdAt: 100,
604
- },
605
- {
606
- siteId: DEFAULT_TEST_SITE_ID,
607
- postId: collectedReplyB.id,
608
- collectionId: collection.id,
609
- createdAt: 200,
610
- },
611
- {
612
- siteId: DEFAULT_TEST_SITE_ID,
613
- postId: secondRoot.id,
614
- collectionId: collection.id,
615
- createdAt: 300,
616
- },
617
- ]);
654
+ await collectionService.addThread(collection.id, firstRoot.id, {
655
+ createdAt: 200,
656
+ });
657
+ await collectionService.addThread(collection.id, secondRoot.id, {
658
+ createdAt: 300,
659
+ });
618
660
 
619
661
  const result = await assembleCollectionTimeline(createTimelineContext(), {
620
662
  collectionIds: [collection.id],
@@ -625,6 +667,7 @@ describe("Timeline data assembly", () => {
625
667
  expect(result.items).toHaveLength(2);
626
668
  expect(result.items[0]?.post.id).toBe(secondRoot.id);
627
669
  expect(result.items[1]?.post.id).toBe(firstRoot.id);
670
+ expect(result.items[1]?.curatedThread?.showContextRatings).toBe(true);
628
671
  expect(result.items[1]?.curatedThread?.segments).toEqual([
629
672
  expect.objectContaining({
630
673
  post: expect.objectContaining({ id: firstRoot.id }),
@@ -634,17 +677,22 @@ describe("Timeline data assembly", () => {
634
677
  expect.objectContaining({
635
678
  post: expect.objectContaining({ id: collectedReplyA.id }),
636
679
  hiddenBeforeCount: 0,
637
- highlighted: true,
680
+ highlighted: false,
681
+ }),
682
+ expect.objectContaining({
683
+ post: expect.objectContaining({ id: hiddenMiddleReply.id }),
684
+ hiddenBeforeCount: 0,
685
+ highlighted: false,
638
686
  }),
639
687
  expect.objectContaining({
640
688
  post: expect.objectContaining({ id: collectedReplyB.id }),
641
- hiddenBeforeCount: 1,
689
+ hiddenBeforeCount: 0,
642
690
  highlighted: true,
643
691
  }),
644
692
  ]);
645
693
  });
646
694
 
647
- it("keeps the last post visible when a collected root is the only selected post", async () => {
695
+ it("renders the complete Thread and highlights its latest Post", async () => {
648
696
  const collection = await collectionService.create({
649
697
  slug: "root-only",
650
698
  title: "Root only",
@@ -659,10 +707,7 @@ describe("Timeline data assembly", () => {
659
707
  replyToId: root.id,
660
708
  });
661
709
 
662
- await db.insert(postCollections).values({
663
- siteId: DEFAULT_TEST_SITE_ID,
664
- postId: root.id,
665
- collectionId: collection.id,
710
+ await collectionService.addThread(collection.id, root.id, {
666
711
  createdAt: 100,
667
712
  });
668
713
 
@@ -678,17 +723,17 @@ describe("Timeline data assembly", () => {
678
723
  expect.objectContaining({
679
724
  post: expect.objectContaining({ id: root.id }),
680
725
  hiddenBeforeCount: 0,
681
- highlighted: true,
726
+ highlighted: false,
682
727
  }),
683
728
  expect.objectContaining({
684
729
  post: expect.objectContaining({ id: uncollectedReply.id }),
685
730
  hiddenBeforeCount: 0,
686
- highlighted: false,
731
+ highlighted: true,
687
732
  }),
688
733
  ]);
689
734
  });
690
735
 
691
- it("highlights the union of collected posts across multiple collections", async () => {
736
+ it("deduplicates Threads selected through multiple collections", async () => {
692
737
  const smart = await collectionService.create({
693
738
  slug: "smart",
694
739
  title: "Smart",
@@ -721,26 +766,15 @@ describe("Timeline data assembly", () => {
721
766
  bodyMarkdown: "Second thread root",
722
767
  });
723
768
 
724
- await db.insert(postCollections).values([
725
- {
726
- siteId: DEFAULT_TEST_SITE_ID,
727
- postId: smartReply.id,
728
- collectionId: smart.id,
729
- createdAt: 100,
730
- },
731
- {
732
- siteId: DEFAULT_TEST_SITE_ID,
733
- postId: movieReply.id,
734
- collectionId: movies.id,
735
- createdAt: 200,
736
- },
737
- {
738
- siteId: DEFAULT_TEST_SITE_ID,
739
- postId: secondRoot.id,
740
- collectionId: movies.id,
741
- createdAt: 300,
742
- },
743
- ]);
769
+ await collectionService.addThread(smart.id, firstRoot.id, {
770
+ createdAt: 100,
771
+ });
772
+ await collectionService.addThread(movies.id, firstRoot.id, {
773
+ createdAt: 200,
774
+ });
775
+ await collectionService.addThread(movies.id, secondRoot.id, {
776
+ createdAt: 300,
777
+ });
744
778
 
745
779
  const result = await assembleCollectionTimeline(createTimelineContext(), {
746
780
  collectionIds: [smart.id, movies.id],
@@ -760,11 +794,16 @@ describe("Timeline data assembly", () => {
760
794
  expect.objectContaining({
761
795
  post: expect.objectContaining({ id: smartReply.id }),
762
796
  hiddenBeforeCount: 0,
763
- highlighted: true,
797
+ highlighted: false,
798
+ }),
799
+ expect.objectContaining({
800
+ post: expect.objectContaining({ id: hiddenMiddleReply.id }),
801
+ hiddenBeforeCount: 0,
802
+ highlighted: false,
764
803
  }),
765
804
  expect.objectContaining({
766
805
  post: expect.objectContaining({ id: movieReply.id }),
767
- hiddenBeforeCount: 1,
806
+ hiddenBeforeCount: 0,
768
807
  highlighted: true,
769
808
  }),
770
809
  ]);
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import {
3
3
  TIMEZONES,
4
+ getTimeZoneOptions,
4
5
  isSupportedTimeZone,
5
6
  mapIanaToTimezone,
6
7
  normalizeTimeZone,
@@ -69,8 +70,15 @@ describe("normalizeTimeZone", () => {
69
70
  expect(normalizeTimeZone("London")).toBe("Europe/London");
70
71
  });
71
72
 
72
- it("normalizes accepted IANA aliases to the curated canonical value", () => {
73
- expect(normalizeTimeZone("Asia/Calcutta")).toBe("Asia/Kolkata");
73
+ it("preserves runtime-supported zones instead of collapsing their rules", () => {
74
+ expect(normalizeTimeZone("America/Phoenix")).toBe("America/Phoenix");
75
+ expect(normalizeTimeZone("Australia/Darwin")).toBe("Australia/Darwin");
76
+ expect(normalizeTimeZone("Etc/GMT-8")).toBe("Etc/GMT-8");
77
+ });
78
+
79
+ it("normalizes fixed offsets and UTC aliases through Intl", () => {
80
+ expect(normalizeTimeZone("+08")).toBe("+08:00");
81
+ expect(normalizeTimeZone("+08:00")).toBe("+08:00");
74
82
  expect(normalizeTimeZone("Etc/UTC")).toBe("UTC");
75
83
  });
76
84
 
@@ -81,9 +89,13 @@ describe("normalizeTimeZone", () => {
81
89
  });
82
90
 
83
91
  describe("isSupportedTimeZone", () => {
84
- it("accepts canonical, aliased, and legacy values", () => {
92
+ it("accepts runtime zones, fixed offsets, and legacy values", () => {
85
93
  expect(isSupportedTimeZone("Asia/Shanghai")).toBe(true);
86
94
  expect(isSupportedTimeZone("Asia/Calcutta")).toBe(true);
95
+ expect(isSupportedTimeZone("America/Phoenix")).toBe(true);
96
+ expect(isSupportedTimeZone("Australia/Darwin")).toBe(true);
97
+ expect(isSupportedTimeZone("Etc/GMT-8")).toBe(true);
98
+ expect(isSupportedTimeZone("+08:00")).toBe(true);
87
99
  expect(isSupportedTimeZone("Beijing")).toBe(true);
88
100
  });
89
101
 
@@ -92,3 +104,19 @@ describe("isSupportedTimeZone", () => {
92
104
  expect(isSupportedTimeZone("Unknown/Zone")).toBe(false);
93
105
  });
94
106
  });
107
+
108
+ describe("getTimeZoneOptions", () => {
109
+ it("adds an unlisted current timezone without expanding the curated list", () => {
110
+ const options = getTimeZoneOptions("America/Phoenix");
111
+
112
+ expect(options).toHaveLength(TIMEZONES.length + 1);
113
+ expect(options.at(-1)).toMatchObject({
114
+ label: "America/Phoenix",
115
+ value: "America/Phoenix",
116
+ });
117
+ });
118
+
119
+ it("does not duplicate a curated timezone", () => {
120
+ expect(getTimeZoneOptions("Asia/Shanghai")).toBe(TIMEZONES);
121
+ });
122
+ });