@jant/core 0.3.36 → 0.3.37

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 (271) hide show
  1. package/bin/commands/export.js +1 -1
  2. package/bin/commands/import-site.js +529 -0
  3. package/bin/commands/reset-password.js +3 -2
  4. package/dist/client/assets/heic-to-DIRPI3VF.js +1 -0
  5. package/dist/client/assets/url-FWFqPJPb.js +1 -0
  6. package/dist/client/client.css +1 -1
  7. package/dist/client/client.js +4012 -3276
  8. package/dist/index.js +10285 -5809
  9. package/package.json +11 -3
  10. package/src/__tests__/helpers/app.ts +9 -9
  11. package/src/__tests__/helpers/db.ts +91 -93
  12. package/src/app.tsx +157 -27
  13. package/src/auth.ts +20 -2
  14. package/src/client/archive-nav.js +187 -0
  15. package/src/client/audio-player.ts +478 -0
  16. package/src/client/audio-processor.ts +84 -0
  17. package/src/client/avatar-upload.ts +3 -2
  18. package/src/client/components/__tests__/jant-compose-dialog.test.ts +645 -49
  19. package/src/client/components/__tests__/jant-compose-editor.test.ts +208 -16
  20. package/src/client/components/__tests__/jant-post-form.test.ts +19 -9
  21. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
  22. package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
  23. package/src/client/components/collection-sidebar-types.ts +7 -9
  24. package/src/client/components/compose-types.ts +101 -4
  25. package/src/client/components/jant-collection-form.ts +43 -7
  26. package/src/client/components/jant-collection-sidebar.ts +88 -84
  27. package/src/client/components/jant-compose-dialog.ts +1655 -219
  28. package/src/client/components/jant-compose-editor.ts +732 -168
  29. package/src/client/components/jant-compose-fullscreen.ts +23 -78
  30. package/src/client/components/jant-media-lightbox.ts +2 -0
  31. package/src/client/components/jant-nav-manager.ts +24 -284
  32. package/src/client/components/jant-post-form.ts +89 -9
  33. package/src/client/components/jant-post-menu.ts +1019 -0
  34. package/src/client/components/jant-settings-avatar.ts +3 -3
  35. package/src/client/components/jant-settings-general.ts +38 -4
  36. package/src/client/components/jant-text-preview.ts +232 -0
  37. package/src/client/components/nav-manager-types.ts +4 -19
  38. package/src/client/components/post-form-template.ts +107 -12
  39. package/src/client/components/post-form-types.ts +11 -4
  40. package/src/client/compose-bridge.ts +410 -109
  41. package/src/client/image-processor.ts +26 -8
  42. package/src/client/media-metadata.ts +247 -0
  43. package/src/client/multipart-upload.ts +160 -0
  44. package/src/client/post-form-bridge.ts +52 -1
  45. package/src/client/settings-bridge.ts +0 -12
  46. package/src/client/thread-context.ts +140 -0
  47. package/src/client/tiptap/create-editor.ts +46 -0
  48. package/src/client/tiptap/extensions.ts +5 -0
  49. package/src/client/tiptap/image-node.ts +2 -8
  50. package/src/client/tiptap/paste-image.ts +2 -13
  51. package/src/client/tiptap/slash-commands.ts +173 -63
  52. package/src/client/toast.ts +101 -3
  53. package/src/client/types/sortablejs.d.ts +15 -0
  54. package/src/client/upload-with-metadata.ts +54 -0
  55. package/src/client/video-processor.ts +207 -0
  56. package/src/client.ts +5 -2
  57. package/src/db/__tests__/migrations.test.ts +118 -0
  58. package/src/db/index.ts +52 -0
  59. package/src/db/migrations/0000_baseline.sql +269 -0
  60. package/src/db/migrations/0001_fts_setup.sql +31 -0
  61. package/src/db/migrations/meta/0000_snapshot.json +703 -119
  62. package/src/db/migrations/meta/0001_snapshot.json +1337 -0
  63. package/src/db/migrations/meta/_journal.json +4 -39
  64. package/src/db/schema.ts +409 -145
  65. package/src/i18n/__tests__/detect.test.ts +115 -0
  66. package/src/i18n/context.tsx +2 -2
  67. package/src/i18n/detect.ts +85 -1
  68. package/src/i18n/i18n.ts +1 -1
  69. package/src/i18n/index.ts +2 -1
  70. package/src/i18n/locales/en.po +487 -1217
  71. package/src/i18n/locales/en.ts +1 -1
  72. package/src/i18n/locales/zh-Hans.po +613 -996
  73. package/src/i18n/locales/zh-Hans.ts +1 -1
  74. package/src/i18n/locales/zh-Hant.po +624 -1007
  75. package/src/i18n/locales/zh-Hant.ts +1 -1
  76. package/src/i18n/middleware.ts +6 -0
  77. package/src/index.ts +5 -7
  78. package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
  79. package/src/lib/__tests__/constants.test.ts +0 -1
  80. package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
  81. package/src/lib/__tests__/nanoid.test.ts +26 -0
  82. package/src/lib/__tests__/schemas.test.ts +181 -63
  83. package/src/lib/__tests__/slug.test.ts +126 -0
  84. package/src/lib/__tests__/sse.test.ts +6 -6
  85. package/src/lib/__tests__/summary.test.ts +264 -0
  86. package/src/lib/__tests__/theme.test.ts +1 -1
  87. package/src/lib/__tests__/timeline.test.ts +33 -30
  88. package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
  89. package/src/lib/__tests__/view.test.ts +141 -66
  90. package/src/lib/blurhash-placeholder.ts +102 -0
  91. package/src/lib/constants.ts +3 -1
  92. package/src/lib/emoji-catalog.ts +885 -68
  93. package/src/lib/errors.ts +11 -8
  94. package/src/lib/feed.ts +78 -32
  95. package/src/lib/html.ts +2 -1
  96. package/src/lib/icon-catalog.ts +5033 -1
  97. package/src/lib/icons.ts +3 -2
  98. package/src/lib/index.ts +0 -1
  99. package/src/lib/markdown-to-tiptap.ts +286 -0
  100. package/src/lib/media-helpers.ts +12 -3
  101. package/src/lib/nanoid.ts +29 -0
  102. package/src/lib/navigation.ts +1 -1
  103. package/src/lib/render.tsx +20 -2
  104. package/src/lib/resolve-config.ts +6 -2
  105. package/src/lib/schemas.ts +224 -55
  106. package/src/lib/search-snippet.ts +34 -0
  107. package/src/lib/slug.ts +96 -0
  108. package/src/lib/sse.ts +6 -6
  109. package/src/lib/storage.ts +115 -7
  110. package/src/lib/summary.ts +66 -0
  111. package/src/lib/theme.ts +11 -8
  112. package/src/lib/timeline.ts +74 -34
  113. package/src/lib/tiptap-render.ts +5 -10
  114. package/src/lib/tiptap-to-markdown.ts +305 -0
  115. package/src/lib/upload.ts +190 -29
  116. package/src/lib/url.ts +31 -0
  117. package/src/lib/view.ts +204 -37
  118. package/src/middleware/__tests__/auth.test.ts +191 -11
  119. package/src/middleware/__tests__/onboarding.test.ts +12 -10
  120. package/src/middleware/auth.ts +63 -9
  121. package/src/middleware/onboarding.ts +1 -1
  122. package/src/middleware/secure-headers.ts +40 -0
  123. package/src/preset.css +45 -2
  124. package/src/routes/__tests__/compose.test.ts +17 -24
  125. package/src/routes/api/__tests__/collections.test.ts +109 -61
  126. package/src/routes/api/__tests__/nav-items.test.ts +46 -29
  127. package/src/routes/api/__tests__/posts.test.ts +132 -68
  128. package/src/routes/api/__tests__/search.test.ts +15 -2
  129. package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
  130. package/src/routes/api/collections.ts +51 -42
  131. package/src/routes/api/custom-urls.ts +80 -0
  132. package/src/routes/api/export.ts +31 -0
  133. package/src/routes/api/nav-items.ts +23 -19
  134. package/src/routes/api/posts.ts +43 -39
  135. package/src/routes/api/search.ts +3 -4
  136. package/src/routes/api/upload-multipart.ts +245 -0
  137. package/src/routes/api/upload.ts +85 -19
  138. package/src/routes/auth/__tests__/setup.test.ts +20 -60
  139. package/src/routes/auth/setup.tsx +26 -33
  140. package/src/routes/auth/signin.tsx +3 -7
  141. package/src/routes/compose.tsx +10 -55
  142. package/src/routes/dash/__tests__/settings-avatar.test.ts +1 -1
  143. package/src/routes/dash/custom-urls.tsx +414 -0
  144. package/src/routes/dash/settings.tsx +304 -232
  145. package/src/routes/feed/__tests__/rss.test.ts +27 -28
  146. package/src/routes/feed/rss.ts +6 -4
  147. package/src/routes/feed/sitemap.ts +2 -12
  148. package/src/routes/pages/__tests__/collections.test.ts +5 -6
  149. package/src/routes/pages/__tests__/featured.test.ts +41 -22
  150. package/src/routes/pages/archive.tsx +175 -39
  151. package/src/routes/pages/collection.tsx +22 -10
  152. package/src/routes/pages/collections.tsx +3 -3
  153. package/src/routes/pages/featured.tsx +28 -4
  154. package/src/routes/pages/home.tsx +16 -15
  155. package/src/routes/pages/latest.tsx +1 -11
  156. package/src/routes/pages/new.tsx +39 -0
  157. package/src/routes/pages/page.tsx +95 -49
  158. package/src/routes/pages/search.tsx +1 -1
  159. package/src/services/__tests__/api-token.test.ts +135 -0
  160. package/src/services/__tests__/collection.test.ts +275 -227
  161. package/src/services/__tests__/custom-url.test.ts +213 -0
  162. package/src/services/__tests__/media.test.ts +162 -22
  163. package/src/services/__tests__/navigation.test.ts +109 -68
  164. package/src/services/__tests__/post-timeline.test.ts +205 -32
  165. package/src/services/__tests__/post.test.ts +713 -234
  166. package/src/services/__tests__/search.test.ts +67 -10
  167. package/src/services/api-token.ts +166 -0
  168. package/src/services/auth.ts +17 -2
  169. package/src/services/collection.ts +397 -131
  170. package/src/services/custom-url.ts +188 -0
  171. package/src/services/export.ts +802 -0
  172. package/src/services/index.ts +26 -19
  173. package/src/services/media.ts +100 -22
  174. package/src/services/navigation.ts +158 -47
  175. package/src/services/path.ts +339 -0
  176. package/src/services/post.ts +687 -154
  177. package/src/services/search.ts +160 -75
  178. package/src/styles/components.css +58 -7
  179. package/src/styles/tokens.css +84 -6
  180. package/src/styles/ui.css +2964 -457
  181. package/src/types/bindings.ts +4 -1
  182. package/src/types/config.ts +12 -4
  183. package/src/types/constants.ts +15 -3
  184. package/src/types/entities.ts +74 -35
  185. package/src/types/operations.ts +11 -24
  186. package/src/types/props.ts +51 -16
  187. package/src/types/views.ts +45 -22
  188. package/src/ui/color-themes.ts +133 -23
  189. package/src/ui/compose/ComposeDialog.tsx +239 -17
  190. package/src/ui/compose/ComposePrompt.tsx +1 -1
  191. package/src/ui/dash/CrudPageHeader.tsx +1 -1
  192. package/src/ui/dash/ListItemRow.tsx +1 -1
  193. package/src/ui/dash/StatusBadge.tsx +3 -1
  194. package/src/ui/dash/appearance/AdvancedContent.tsx +22 -1
  195. package/src/ui/dash/appearance/ColorThemeContent.tsx +22 -2
  196. package/src/ui/dash/appearance/FontThemeContent.tsx +1 -1
  197. package/src/ui/dash/appearance/NavigationContent.tsx +5 -45
  198. package/src/ui/dash/index.ts +0 -3
  199. package/src/ui/dash/settings/AccountContent.tsx +3 -57
  200. package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
  201. package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
  202. package/src/ui/dash/settings/AvatarContent.tsx +8 -0
  203. package/src/ui/dash/settings/SessionsContent.tsx +159 -0
  204. package/src/ui/dash/settings/SettingsRootContent.tsx +45 -15
  205. package/src/ui/feed/LinkCard.tsx +89 -40
  206. package/src/ui/feed/NoteCard.tsx +39 -25
  207. package/src/ui/feed/PostStatusBadges.tsx +67 -0
  208. package/src/ui/feed/QuoteCard.tsx +38 -23
  209. package/src/ui/feed/ThreadPreview.tsx +90 -26
  210. package/src/ui/feed/TimelineFeed.tsx +3 -2
  211. package/src/ui/feed/TimelineItem.tsx +15 -6
  212. package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
  213. package/src/ui/feed/thread-preview-state.ts +61 -0
  214. package/src/ui/font-themes.ts +2 -2
  215. package/src/ui/layouts/BaseLayout.tsx +2 -2
  216. package/src/ui/layouts/SiteLayout.tsx +105 -92
  217. package/src/ui/pages/ArchivePage.tsx +923 -98
  218. package/src/ui/pages/ComposePage.tsx +54 -0
  219. package/src/ui/pages/PostPage.tsx +30 -45
  220. package/src/ui/pages/SearchPage.tsx +181 -37
  221. package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
  222. package/src/ui/shared/CollectionsSidebar.tsx +47 -37
  223. package/src/ui/shared/MediaGallery.tsx +445 -149
  224. package/src/ui/shared/PostFooter.tsx +204 -0
  225. package/src/ui/shared/StarRating.tsx +27 -0
  226. package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
  227. package/src/ui/shared/index.ts +0 -1
  228. package/dist/client/assets/url-8Dj-5CLW.js +0 -1
  229. package/src/client/media-upload.ts +0 -161
  230. package/src/client/page-slug-bridge.ts +0 -42
  231. package/src/db/migrations/0000_square_wallflower.sql +0 -118
  232. package/src/db/migrations/0001_add_search_fts.sql +0 -34
  233. package/src/db/migrations/0002_add_media_attachments.sql +0 -3
  234. package/src/db/migrations/0003_add_navigation_links.sql +0 -8
  235. package/src/db/migrations/0004_add_storage_provider.sql +0 -3
  236. package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
  237. package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
  238. package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
  239. package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
  240. package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
  241. package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
  242. package/src/db/migrations/0011_add_path_registry.sql +0 -23
  243. package/src/db/migrations/0012_add_tiptap_columns.sql +0 -2
  244. package/src/db/migrations/0013_replace_featured_with_visibility.sql +0 -8
  245. package/src/db/migrations/meta/0003_snapshot.json +0 -821
  246. package/src/lib/__tests__/sqid.test.ts +0 -65
  247. package/src/lib/sqid.ts +0 -79
  248. package/src/routes/api/__tests__/pages.test.ts +0 -218
  249. package/src/routes/api/pages.ts +0 -73
  250. package/src/routes/dash/__tests__/pages.test.ts +0 -226
  251. package/src/routes/dash/index.tsx +0 -109
  252. package/src/routes/dash/media.tsx +0 -135
  253. package/src/routes/dash/pages.tsx +0 -245
  254. package/src/routes/dash/posts.tsx +0 -338
  255. package/src/routes/dash/redirects.tsx +0 -263
  256. package/src/routes/pages/post.tsx +0 -59
  257. package/src/services/__tests__/page.test.ts +0 -298
  258. package/src/services/__tests__/path-registry.test.ts +0 -165
  259. package/src/services/__tests__/redirect.test.ts +0 -159
  260. package/src/services/page.ts +0 -216
  261. package/src/services/path-registry.ts +0 -160
  262. package/src/services/redirect.ts +0 -97
  263. package/src/ui/dash/PageForm.tsx +0 -187
  264. package/src/ui/dash/PostList.tsx +0 -95
  265. package/src/ui/dash/media/MediaListContent.tsx +0 -206
  266. package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
  267. package/src/ui/dash/pages/PagesContent.tsx +0 -75
  268. package/src/ui/dash/posts/PostForm.tsx +0 -260
  269. package/src/ui/layouts/DashLayout.tsx +0 -247
  270. package/src/ui/pages/SinglePage.tsx +0 -23
  271. package/src/ui/shared/ThreadView.tsx +0 -136
package/src/lib/view.ts CHANGED
@@ -8,22 +8,21 @@
8
8
  import type {
9
9
  Post,
10
10
  PostWithMedia,
11
- Page,
12
11
  Media,
13
12
  MediaView,
14
13
  PostView,
15
- PageView,
14
+ CollectionTagView,
16
15
  NavItemView,
17
16
  NavItem,
18
17
  SearchResult,
19
18
  SearchResultView,
20
19
  ArchiveGroup,
20
+ Collection,
21
21
  Format,
22
22
  Status,
23
23
  NavItemType,
24
24
  AppConfig,
25
25
  } from "../types.js";
26
- import { encode } from "./sqid.js";
27
26
  import {
28
27
  toISOString,
29
28
  formatDate,
@@ -32,6 +31,9 @@ import {
32
31
  } from "./time.js";
33
32
  import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "./image.js";
34
33
  import { getHtmlExcerpt } from "./excerpt.js";
34
+ import { highlightText } from "./search-snippet.js";
35
+ import { renderCollectionIcon } from "./icons.js";
36
+ import { escapeHtml } from "./html.js";
35
37
 
36
38
  // =============================================================================
37
39
  // Media Context
@@ -90,6 +92,18 @@ export function toMediaView(media: Media, ctx: MediaContext): MediaView {
90
92
  })
91
93
  : url;
92
94
 
95
+ const posterRawUrl = media.posterKey
96
+ ? getMediaUrl(media.posterKey, publicUrl)
97
+ : undefined;
98
+ const posterUrl = posterRawUrl
99
+ ? getImageUrl(posterRawUrl, ctx.imageTransformUrl, {
100
+ width: 640,
101
+ quality: 80,
102
+ format: "auto",
103
+ fit: "scale-down",
104
+ })
105
+ : undefined;
106
+
93
107
  return {
94
108
  id: media.id,
95
109
  url,
@@ -99,6 +113,10 @@ export function toMediaView(media: Media, ctx: MediaContext): MediaView {
99
113
  width: media.width ?? undefined,
100
114
  height: media.height ?? undefined,
101
115
  size: media.size,
116
+ blurhash: media.blurhash ?? undefined,
117
+ waveform: media.waveform ?? undefined,
118
+ posterUrl,
119
+ chars: media.chars ?? undefined,
102
120
  };
103
121
  }
104
122
 
@@ -111,10 +129,19 @@ export function toMediaView(media: Media, ctx: MediaContext): MediaView {
111
129
  *
112
130
  * @param post - Post with media attachments from database
113
131
  * @param _ctx - Media context with URL configuration
132
+ * @param postCollections - Optional collections this post belongs to
114
133
  * @returns Render-ready PostView with pre-computed fields
115
134
  */
116
- export function toPostView(post: PostWithMedia, _ctx: MediaContext): PostView {
117
- const permalink = post.path ? `/${post.path}` : `/p/${encode(post.id)}`;
135
+ export function toPostView(
136
+ post: PostWithMedia,
137
+ _ctx: MediaContext,
138
+ postCollections?: Collection[],
139
+ threadRootPermalink?: string,
140
+ isLastInThread?: boolean,
141
+ ): PostView {
142
+ const id = post.id;
143
+ const permalink = `/${post.slug}`;
144
+ const publishedAt = post.publishedAt ?? post.updatedAt;
118
145
 
119
146
  // Pre-compute excerpt from raw body
120
147
  let excerpt: string | undefined;
@@ -132,7 +159,7 @@ export function toPostView(post: PostWithMedia, _ctx: MediaContext): PostView {
132
159
  // Use stored summary (generated from Tiptap JSON)
133
160
  summaryHtml = post.summary
134
161
  .split("\n\n")
135
- .map((p) => `<p>${p}</p>`)
162
+ .map((p) => `<p>${escapeHtml(p)}</p>`)
136
163
  .join("");
137
164
  summaryHasMore = true;
138
165
  } else {
@@ -152,6 +179,12 @@ export function toPostView(post: PostWithMedia, _ctx: MediaContext): PostView {
152
179
  }
153
180
  }
154
181
 
182
+ // Convert collection tags
183
+ const collections: CollectionTagView[] = (postCollections ?? []).map((c) => {
184
+ const iconHtml = renderCollectionIcon(c.icon, { size: 12 }) || undefined;
185
+ return { slug: c.slug, title: c.title, iconHtml };
186
+ });
187
+
155
188
  // Convert media attachments
156
189
  const media: MediaView[] = post.mediaAttachments.map((m) => ({
157
190
  id: m.id,
@@ -161,12 +194,19 @@ export function toPostView(post: PostWithMedia, _ctx: MediaContext): PostView {
161
194
  altText: m.alt ?? undefined,
162
195
  width: m.width ?? undefined,
163
196
  height: m.height ?? undefined,
197
+ size: m.size ?? undefined,
198
+ blurhash: m.blurhash ?? undefined,
199
+ waveform: m.waveform ?? undefined,
200
+ posterUrl: m.posterUrl ?? undefined,
201
+ originalName: m.originalName ?? undefined,
202
+ summary: m.summary ?? undefined,
203
+ chars: m.chars ?? undefined,
164
204
  }));
165
205
 
166
206
  return {
167
- id: post.id,
207
+ id,
168
208
  permalink,
169
- path: post.path ?? undefined,
209
+ slug: post.slug,
170
210
  title: post.title ?? undefined,
171
211
  bodyHtml: bodyHtmlWithAnchor ?? undefined,
172
212
  excerpt,
@@ -177,35 +217,68 @@ export function toPostView(post: PostWithMedia, _ctx: MediaContext): PostView {
177
217
  format: post.format as Format,
178
218
  status: post.status as Status,
179
219
  visibility: post.visibility,
180
- pinned: post.pinned === 1,
220
+ pinned: post.pinnedAt !== null,
221
+ featured: post.featuredAt !== null,
181
222
  rating: post.rating ?? undefined,
182
- publishedAt: toISOString(post.publishedAt),
183
- publishedAtFormatted: formatDate(post.publishedAt),
184
- publishedAtTime: formatTime(post.publishedAt),
185
- publishedAtRelative: formatRelativeTime(post.publishedAt),
223
+ publishedAt: toISOString(publishedAt),
224
+ publishedAtFormatted: formatDate(publishedAt),
225
+ publishedAtTime: formatTime(publishedAt),
226
+ publishedAtRelative: formatRelativeTime(publishedAt),
186
227
  updatedAt: toISOString(post.updatedAt),
187
228
  media,
229
+ collections,
188
230
  replyToId: post.replyToId ?? undefined,
189
- threadRootId: post.threadId ?? undefined,
231
+ threadRootId: post.replyToId ? post.threadId : undefined,
232
+ threadRootPermalink,
233
+ isLastInThread: isLastInThread ?? true,
190
234
  body: post.body ?? undefined,
191
235
  };
192
236
  }
193
237
 
194
238
  /**
195
239
  * Batch converts PostWithMedia[] to PostView[].
240
+ *
241
+ * @param posts - Posts with media attachments
242
+ * @param ctx - Media context with URL configuration
243
+ * @param threadRootPermalinkMap - Optional map of thread root ID → permalink
244
+ * @returns Render-ready PostView[]
196
245
  */
197
246
  export function toPostViews(
198
247
  posts: PostWithMedia[],
199
248
  ctx: MediaContext,
249
+ threadRootPermalinkMap?: Map<string, string>,
250
+ isLastInThreadMap?: Map<string, boolean>,
200
251
  ): PostView[] {
201
- return posts.map((p) => toPostView(p, ctx));
252
+ return posts.map((p) => {
253
+ const rootPermalink = p.replyToId
254
+ ? threadRootPermalinkMap?.get(p.threadId)
255
+ : undefined;
256
+ return toPostView(
257
+ p,
258
+ ctx,
259
+ undefined,
260
+ rootPermalink,
261
+ isLastInThreadMap?.get(p.id),
262
+ );
263
+ });
202
264
  }
203
265
 
204
266
  /**
205
267
  * Converts a bare Post (no media) to a PostView with empty media array.
206
268
  */
207
- export function toPostViewFromPost(post: Post, ctx: MediaContext): PostView {
208
- return toPostView({ ...post, mediaAttachments: [] }, ctx);
269
+ export function toPostViewFromPost(
270
+ post: Post,
271
+ ctx: MediaContext,
272
+ threadRootPermalink?: string,
273
+ isLastInThread?: boolean,
274
+ ): PostView {
275
+ return toPostView(
276
+ { ...post, mediaAttachments: [] },
277
+ ctx,
278
+ undefined,
279
+ threadRootPermalink,
280
+ isLastInThread,
281
+ );
209
282
  }
210
283
 
211
284
  /**
@@ -214,27 +287,54 @@ export function toPostViewFromPost(post: Post, ctx: MediaContext): PostView {
214
287
  export function toPostViewsFromPosts(
215
288
  posts: Post[],
216
289
  ctx: MediaContext,
290
+ threadRootPermalinkMap?: Map<string, string>,
291
+ isLastInThreadMap?: Map<string, boolean>,
217
292
  ): PostView[] {
218
- return posts.map((p) => toPostViewFromPost(p, ctx));
293
+ return posts.map((p) => {
294
+ const rootPermalink = p.replyToId
295
+ ? threadRootPermalinkMap?.get(p.threadId)
296
+ : undefined;
297
+ return toPostViewFromPost(
298
+ p,
299
+ ctx,
300
+ rootPermalink,
301
+ isLastInThreadMap?.get(p.id),
302
+ );
303
+ });
219
304
  }
220
305
 
221
306
  // =============================================================================
222
- // Page Conversions
307
+ // Thread Helpers
223
308
  // =============================================================================
224
309
 
225
310
  /**
226
- * Converts a Page to a render-ready PageView.
311
+ * Builds a map of thread root ID → permalink for posts that are thread replies.
312
+ *
313
+ * @param posts - Posts to inspect for thread membership
314
+ * @param getById - Lookup function to fetch a post by ID
315
+ * @returns Map of thread root ID → permalink string (e.g. `/{slug}`)
316
+ *
317
+ * @example
318
+ * ```ts
319
+ * const map = await loadThreadRootPermalinks(posts, services.posts.getById);
320
+ * const views = toPostViews(postsWithMedia, mediaCtx, map);
321
+ * ```
227
322
  */
228
- export function toPageView(page: Page): PageView {
229
- return {
230
- id: page.id,
231
- slug: page.slug,
232
- title: page.title ?? undefined,
233
- bodyHtml: page.bodyHtml ?? undefined,
234
- status: page.status as Status,
235
- createdAt: toISOString(page.createdAt),
236
- updatedAt: toISOString(page.updatedAt),
237
- };
323
+ export async function loadThreadRootPermalinks(
324
+ posts: Post[],
325
+ getById: (id: string) => Promise<Post | null>,
326
+ ): Promise<Map<string, string>> {
327
+ const threadRootIds = [
328
+ ...new Set(posts.filter((p) => p.replyToId).map((p) => p.threadId)),
329
+ ];
330
+ const map = new Map<string, string>();
331
+ if (threadRootIds.length > 0) {
332
+ const roots = await Promise.all(threadRootIds.map(getById));
333
+ for (const root of roots) {
334
+ if (root) map.set(root.id, `/${root.slug}`);
335
+ }
336
+ }
337
+ return map;
238
338
  }
239
339
 
240
340
  // =============================================================================
@@ -246,7 +346,7 @@ export function toPageView(page: Page): PageView {
246
346
  *
247
347
  * @param item - Raw nav item from database
248
348
  * @param currentPath - Current URL path for active state
249
- * @param isAuthenticated - Whether the user is logged in (affects system dashboard item)
349
+ * @param isAuthenticated - Whether the user is logged in (affects system settings item)
250
350
  */
251
351
  export function toNavItemView(
252
352
  item: NavItem,
@@ -256,9 +356,13 @@ export function toNavItemView(
256
356
  let url = item.url;
257
357
  let label = item.label;
258
358
 
259
- // System dashboard item: resolve URL and label based on auth
260
- if (item.type === "system" && item.url === "/dash") {
261
- url = isAuthenticated ? "/dash" : "/signin";
359
+ // System settings item: resolve URL and label based on auth
360
+ // Also handles legacy "/dash" URLs from existing DB data
361
+ if (
362
+ item.type === "system" &&
363
+ (item.url === "/settings" || item.url === "/dash")
364
+ ) {
365
+ url = isAuthenticated ? "/settings" : "/signin";
262
366
  if (!isAuthenticated) {
263
367
  label = "Sign in";
264
368
  }
@@ -280,7 +384,6 @@ export function toNavItemView(
280
384
  type: item.type as NavItemType,
281
385
  label,
282
386
  url,
283
- pageId: item.pageId ?? undefined,
284
387
  isActive,
285
388
  isExternal,
286
389
  };
@@ -307,26 +410,57 @@ export function toNavItemViews(
307
410
 
308
411
  /**
309
412
  * Converts a SearchResult to a SearchResultView with PostView.
413
+ *
414
+ * @param result - Raw search result with post and FTS metadata
415
+ * @param ctx - Media context for URL computation
416
+ * @param query - Original search query for client-side title/quote highlighting
310
417
  */
311
418
  export function toSearchResultView(
312
419
  result: SearchResult,
313
420
  ctx: MediaContext,
421
+ query?: string,
314
422
  ): SearchResultView {
423
+ const post = toPostViewFromPost(result.post, ctx);
424
+
425
+ let titleHighlighted: string | undefined;
426
+ let quoteHighlighted: string | undefined;
427
+
428
+ if (query) {
429
+ if (post.title) {
430
+ titleHighlighted = highlightText(post.title, query);
431
+ }
432
+ if (post.quoteText) {
433
+ // Truncate before highlighting to avoid splitting inside <mark> tags
434
+ const truncated =
435
+ post.quoteText.length > 120
436
+ ? post.quoteText.slice(0, 120) + "..."
437
+ : post.quoteText;
438
+ quoteHighlighted = highlightText(truncated, query);
439
+ }
440
+ }
441
+
315
442
  return {
316
- post: toPostViewFromPost(result.post, ctx),
443
+ post,
317
444
  rank: result.rank,
318
445
  snippet: result.snippet,
446
+ titleHighlighted,
447
+ quoteHighlighted,
319
448
  };
320
449
  }
321
450
 
322
451
  /**
323
452
  * Batch converts SearchResult[] to SearchResultView[].
453
+ *
454
+ * @param results - Raw search results
455
+ * @param ctx - Media context for URL computation
456
+ * @param query - Original search query for title/quote highlighting
324
457
  */
325
458
  export function toSearchResultViews(
326
459
  results: SearchResult[],
327
460
  ctx: MediaContext,
461
+ query?: string,
328
462
  ): SearchResultView[] {
329
- return results.map((r) => toSearchResultView(r, ctx));
463
+ return results.map((r) => toSearchResultView(r, ctx, query));
330
464
  }
331
465
 
332
466
  // =============================================================================
@@ -360,3 +494,36 @@ export function toArchiveGroups(
360
494
  }
361
495
  return groups;
362
496
  }
497
+
498
+ /**
499
+ * Converts a grouped PostWithMedia map to typed ArchiveGroup[].
500
+ * Unlike toArchiveGroups, this preserves media attachments on each post.
501
+ *
502
+ * @param grouped - Map of "YYYY-MM" keys to PostWithMedia arrays
503
+ * @param ctx - Media context for URL computation
504
+ * @returns ArchiveGroup[] with full media data on each PostView
505
+ */
506
+ export function toArchiveGroupsWithMedia(
507
+ grouped: Map<string, PostWithMedia[]>,
508
+ ctx: MediaContext,
509
+ ): ArchiveGroup[] {
510
+ const groups: ArchiveGroup[] = [];
511
+ for (const [yearMonth, posts] of grouped) {
512
+ const [year, month] = yearMonth.split("-");
513
+ if (!year || !month) continue;
514
+
515
+ const date = new Date(parseInt(year, 10), parseInt(month, 10) - 1);
516
+ const label = date.toLocaleDateString("en-US", {
517
+ year: "numeric",
518
+ month: "long",
519
+ });
520
+
521
+ groups.push({
522
+ year,
523
+ month,
524
+ label,
525
+ posts: toPostViews(posts, ctx),
526
+ });
527
+ }
528
+ return groups;
529
+ }
@@ -1,6 +1,6 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect, vi } from "vitest";
2
2
  import { Hono } from "hono";
3
- import { requireAuth, requireAuthApi } from "../auth.js";
3
+ import { requireAuth, requireAuthApi, isLocalHostname } from "../auth.js";
4
4
  import { errorHandler } from "../error-handler.js";
5
5
  import type { Bindings } from "../../types.js";
6
6
  import type { AppVariables } from "../../types/app-context.js";
@@ -21,6 +21,32 @@ function createMockAuth(authenticated: boolean) {
21
21
  } as AppVariables["auth"];
22
22
  }
23
23
 
24
+ function createMockApiTokenService(validToken?: string) {
25
+ const tokenId = "token-id-1";
26
+ return {
27
+ verify: vi.fn(async (raw: string) => (raw === validToken ? tokenId : null)),
28
+ updateLastUsed: vi.fn(async () => {}),
29
+ create: vi.fn(),
30
+ list: vi.fn(),
31
+ delete: vi.fn(),
32
+ };
33
+ }
34
+
35
+ describe("isLocalHostname", () => {
36
+ it.each([
37
+ ["localhost", true],
38
+ ["127.0.0.1", true],
39
+ ["::1", true],
40
+ ["jant.localtest.me", true],
41
+ ["sub.localtest.me", true],
42
+ ["myblog.com", false],
43
+ ["demo.jant.me", false],
44
+ ["localtest.me.evil.com", false],
45
+ ])("isLocalHostname(%s) → %s", (hostname, expected) => {
46
+ expect(isLocalHostname(hostname)).toBe(expected);
47
+ });
48
+ });
49
+
24
50
  describe("requireAuth", () => {
25
51
  it("allows authenticated requests", async () => {
26
52
  const app = new Hono<Env>();
@@ -28,11 +54,11 @@ describe("requireAuth", () => {
28
54
  c.set("auth", createMockAuth(true));
29
55
  await next();
30
56
  });
31
- app.get("/dash", requireAuth(), (c) => c.text("Dashboard"));
57
+ app.get("/settings", requireAuth(), (c) => c.text("Settings"));
32
58
 
33
- const res = await app.request("/dash");
59
+ const res = await app.request("/settings");
34
60
  expect(res.status).toBe(200);
35
- expect(await res.text()).toBe("Dashboard");
61
+ expect(await res.text()).toBe("Settings");
36
62
  });
37
63
 
38
64
  it("redirects unauthenticated requests to /signin", async () => {
@@ -41,9 +67,9 @@ describe("requireAuth", () => {
41
67
  c.set("auth", createMockAuth(false));
42
68
  await next();
43
69
  });
44
- app.get("/dash", requireAuth(), (c) => c.text("Dashboard"));
70
+ app.get("/settings", requireAuth(), (c) => c.text("Settings"));
45
71
 
46
- const res = await app.request("/dash", { redirect: "manual" });
72
+ const res = await app.request("/settings", { redirect: "manual" });
47
73
  expect(res.status).toBe(302);
48
74
  expect(res.headers.get("Location")).toBe("/signin");
49
75
  });
@@ -54,20 +80,23 @@ describe("requireAuth", () => {
54
80
  c.set("auth", createMockAuth(false));
55
81
  await next();
56
82
  });
57
- app.get("/dash", requireAuth("/login"), (c) => c.text("Dashboard"));
83
+ app.get("/settings", requireAuth("/login"), (c) => c.text("Settings"));
58
84
 
59
- const res = await app.request("/dash", { redirect: "manual" });
85
+ const res = await app.request("/settings", { redirect: "manual" });
60
86
  expect(res.status).toBe(302);
61
87
  expect(res.headers.get("Location")).toBe("/login");
62
88
  });
63
89
  });
64
90
 
65
91
  describe("requireAuthApi", () => {
66
- it("allows authenticated requests", async () => {
92
+ it("allows authenticated requests via session", async () => {
67
93
  const app = new Hono<Env>();
68
94
  app.onError(errorHandler);
69
95
  app.use("*", async (c, next) => {
70
96
  c.set("auth", createMockAuth(true));
97
+ c.set("services", {
98
+ apiTokens: createMockApiTokenService(),
99
+ } as AppVariables["services"]);
71
100
  await next();
72
101
  });
73
102
  app.get("/api/data", requireAuthApi(), (c) => c.json({ data: "secret" }));
@@ -79,11 +108,14 @@ describe("requireAuthApi", () => {
79
108
  expect(body.data).toBe("secret");
80
109
  });
81
110
 
82
- it("returns 401 for unauthenticated requests", async () => {
111
+ it("returns 401 for unauthenticated requests without Bearer token", async () => {
83
112
  const app = new Hono<Env>();
84
113
  app.onError(errorHandler);
85
114
  app.use("*", async (c, next) => {
86
115
  c.set("auth", createMockAuth(false));
116
+ c.set("services", {
117
+ apiTokens: createMockApiTokenService(),
118
+ } as AppVariables["services"]);
87
119
  await next();
88
120
  });
89
121
  app.get("/api/data", requireAuthApi(), (c) => c.json({ data: "secret" }));
@@ -107,6 +139,9 @@ describe("requireAuthApi", () => {
107
139
  },
108
140
  },
109
141
  } as AppVariables["auth"]);
142
+ c.set("services", {
143
+ apiTokens: createMockApiTokenService(),
144
+ } as AppVariables["services"]);
110
145
  await next();
111
146
  });
112
147
  app.get("/api/data", requireAuthApi(), (c) => c.json({ data: "secret" }));
@@ -114,4 +149,149 @@ describe("requireAuthApi", () => {
114
149
  const res = await app.request("/api/data");
115
150
  expect(res.status).toBe(401);
116
151
  });
152
+
153
+ it("allows requests with valid Bearer token when session auth fails", async () => {
154
+ const validToken = "jnt_abc123";
155
+ const mockApiTokens = createMockApiTokenService(validToken);
156
+
157
+ const app = new Hono<Env>();
158
+ app.onError(errorHandler);
159
+ app.use("*", async (c, next) => {
160
+ c.set("auth", createMockAuth(false));
161
+ c.set("services", {
162
+ apiTokens: mockApiTokens,
163
+ } as AppVariables["services"]);
164
+ await next();
165
+ });
166
+ app.get("/api/data", requireAuthApi(), (c) => c.json({ data: "secret" }));
167
+
168
+ const res = await app.request("/api/data", {
169
+ headers: { Authorization: `Bearer ${validToken}` },
170
+ });
171
+ expect(res.status).toBe(200);
172
+
173
+ const body = await res.json();
174
+ expect(body.data).toBe("secret");
175
+
176
+ expect(mockApiTokens.verify).toHaveBeenCalledWith(validToken);
177
+ expect(mockApiTokens.updateLastUsed).toHaveBeenCalledWith("token-id-1");
178
+ });
179
+
180
+ it("returns 401 for invalid Bearer token", async () => {
181
+ const mockApiTokens = createMockApiTokenService("jnt_valid");
182
+
183
+ const app = new Hono<Env>();
184
+ app.onError(errorHandler);
185
+ app.use("*", async (c, next) => {
186
+ c.set("auth", createMockAuth(false));
187
+ c.set("services", {
188
+ apiTokens: mockApiTokens,
189
+ } as AppVariables["services"]);
190
+ await next();
191
+ });
192
+ app.get("/api/data", requireAuthApi(), (c) => c.json({ data: "secret" }));
193
+
194
+ const res = await app.request("/api/data", {
195
+ headers: { Authorization: "Bearer jnt_invalid" },
196
+ });
197
+ expect(res.status).toBe(401);
198
+
199
+ expect(mockApiTokens.verify).toHaveBeenCalledWith("jnt_invalid");
200
+ });
201
+
202
+ it("prefers session auth over Bearer token", async () => {
203
+ const mockApiTokens = createMockApiTokenService("jnt_valid");
204
+
205
+ const app = new Hono<Env>();
206
+ app.onError(errorHandler);
207
+ app.use("*", async (c, next) => {
208
+ c.set("auth", createMockAuth(true));
209
+ c.set("services", {
210
+ apiTokens: mockApiTokens,
211
+ } as AppVariables["services"]);
212
+ await next();
213
+ });
214
+ app.get("/api/data", requireAuthApi(), (c) => c.json({ data: "secret" }));
215
+
216
+ const res = await app.request("/api/data", {
217
+ headers: { Authorization: "Bearer jnt_valid" },
218
+ });
219
+ expect(res.status).toBe(200);
220
+
221
+ // Should not check the token since session auth succeeded
222
+ expect(mockApiTokens.verify).not.toHaveBeenCalled();
223
+ });
224
+
225
+ it("allows DEV_API_TOKEN on localhost", async () => {
226
+ const devToken = "jnt_dev_test123";
227
+ const mockApiTokens = createMockApiTokenService();
228
+
229
+ const app = new Hono<Env>();
230
+ app.onError(errorHandler);
231
+ app.use("*", async (c, next) => {
232
+ c.env = { ...c.env, DEV_API_TOKEN: devToken } as Bindings;
233
+ c.set("auth", createMockAuth(false));
234
+ c.set("services", {
235
+ apiTokens: mockApiTokens,
236
+ } as AppVariables["services"]);
237
+ await next();
238
+ });
239
+ app.get("/api/data", requireAuthApi(), (c) => c.json({ data: "secret" }));
240
+
241
+ const res = await app.request("http://localhost:9020/api/data", {
242
+ headers: { Authorization: `Bearer ${devToken}` },
243
+ });
244
+ expect(res.status).toBe(200);
245
+
246
+ // Should NOT hit DB verification
247
+ expect(mockApiTokens.verify).not.toHaveBeenCalled();
248
+ });
249
+
250
+ it("rejects DEV_API_TOKEN on non-local hostname", async () => {
251
+ const devToken = "jnt_dev_test123";
252
+ const mockApiTokens = createMockApiTokenService();
253
+
254
+ const app = new Hono<Env>();
255
+ app.onError(errorHandler);
256
+ app.use("*", async (c, next) => {
257
+ c.env = { ...c.env, DEV_API_TOKEN: devToken } as Bindings;
258
+ c.set("auth", createMockAuth(false));
259
+ c.set("services", {
260
+ apiTokens: mockApiTokens,
261
+ } as AppVariables["services"]);
262
+ await next();
263
+ });
264
+ app.get("/api/data", requireAuthApi(), (c) => c.json({ data: "secret" }));
265
+
266
+ const res = await app.request("https://myblog.com/api/data", {
267
+ headers: { Authorization: `Bearer ${devToken}` },
268
+ });
269
+ expect(res.status).toBe(401);
270
+
271
+ // Falls through to normal DB verification (which also fails)
272
+ expect(mockApiTokens.verify).toHaveBeenCalledWith(devToken);
273
+ });
274
+
275
+ it("allows DEV_API_TOKEN on *.localtest.me", async () => {
276
+ const devToken = "jnt_dev_test123";
277
+ const mockApiTokens = createMockApiTokenService();
278
+
279
+ const app = new Hono<Env>();
280
+ app.onError(errorHandler);
281
+ app.use("*", async (c, next) => {
282
+ c.env = { ...c.env, DEV_API_TOKEN: devToken } as Bindings;
283
+ c.set("auth", createMockAuth(false));
284
+ c.set("services", {
285
+ apiTokens: mockApiTokens,
286
+ } as AppVariables["services"]);
287
+ await next();
288
+ });
289
+ app.get("/api/data", requireAuthApi(), (c) => c.json({ data: "secret" }));
290
+
291
+ const res = await app.request("https://jant.localtest.me/api/data", {
292
+ headers: { Authorization: `Bearer ${devToken}` },
293
+ });
294
+ expect(res.status).toBe(200);
295
+ expect(mockApiTokens.verify).not.toHaveBeenCalled();
296
+ });
117
297
  });