@jant/core 0.3.36 → 0.3.38

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
@@ -6,6 +6,18 @@
6
6
 
7
7
  import type { Bindings } from "../types.js";
8
8
 
9
+ /** Tracks an in-progress multipart upload */
10
+ export interface MultipartUploadSession {
11
+ uploadId: string;
12
+ key: string;
13
+ }
14
+
15
+ /** Represents a successfully uploaded part */
16
+ export interface UploadedPart {
17
+ partNumber: number;
18
+ etag: string;
19
+ }
20
+
9
21
  /**
10
22
  * Common interface for storage operations.
11
23
  *
@@ -20,13 +32,68 @@ export interface StorageDriver {
20
32
  opts?: { contentType?: string },
21
33
  ): Promise<void>;
22
34
 
23
- /** Retrieve a file from storage. Returns null if not found. */
35
+ /** Retrieve a file (or byte range) from storage. Returns null if not found. */
24
36
  get(
25
37
  key: string,
26
- ): Promise<{ body: ReadableStream; contentType?: string } | null>;
38
+ opts?: { range?: { offset: number; length: number } },
39
+ ): Promise<{
40
+ body: ReadableStream;
41
+ contentType?: string;
42
+ size?: number;
43
+ } | null>;
27
44
 
28
45
  /** Delete a file from storage */
29
46
  delete(key: string): Promise<void>;
47
+
48
+ /** Start a multipart upload (optional — R2 only) */
49
+ createMultipartUpload?(
50
+ key: string,
51
+ opts?: { contentType?: string },
52
+ ): Promise<MultipartUploadSession>;
53
+
54
+ /** Upload a single part of a multipart upload */
55
+ uploadPart?(
56
+ key: string,
57
+ uploadId: string,
58
+ partNumber: number,
59
+ body: ReadableStream | ArrayBuffer | Uint8Array,
60
+ ): Promise<UploadedPart>;
61
+
62
+ /** Finalize a multipart upload by combining all parts */
63
+ completeMultipartUpload?(
64
+ key: string,
65
+ uploadId: string,
66
+ parts: UploadedPart[],
67
+ ): Promise<void>;
68
+
69
+ /** Cancel a multipart upload and discard uploaded parts */
70
+ abortMultipartUpload?(key: string, uploadId: string): Promise<void>;
71
+ }
72
+
73
+ /**
74
+ * Type guard that checks whether a storage driver supports multipart uploads.
75
+ *
76
+ * @param driver - The storage driver to check
77
+ * @returns true if all multipart methods are present
78
+ */
79
+ export function supportsMultipart(
80
+ driver: StorageDriver,
81
+ ): driver is StorageDriver &
82
+ Required<
83
+ Pick<
84
+ StorageDriver,
85
+ | "createMultipartUpload"
86
+ | "uploadPart"
87
+ | "completeMultipartUpload"
88
+ | "abortMultipartUpload"
89
+ >
90
+ > {
91
+ return (
92
+ typeof driver.createMultipartUpload === "function" &&
93
+ typeof driver.uploadPart === "function" &&
94
+ typeof driver.completeMultipartUpload === "function" &&
95
+ typeof driver.abortMultipartUpload === "function"
96
+ );
30
97
  }
31
98
 
32
99
  /**
@@ -45,18 +112,47 @@ export function createR2Driver(r2: R2Bucket): StorageDriver {
45
112
  });
46
113
  },
47
114
 
48
- async get(key) {
49
- const object = await r2.get(key);
115
+ async get(key, opts) {
116
+ const object = await r2.get(
117
+ key,
118
+ opts?.range ? { range: opts.range } : undefined,
119
+ );
50
120
  if (!object) return null;
51
121
  return {
52
122
  body: object.body,
53
123
  contentType: object.httpMetadata?.contentType ?? undefined,
124
+ size: object.size,
54
125
  };
55
126
  },
56
127
 
57
128
  async delete(key) {
58
129
  await r2.delete(key);
59
130
  },
131
+
132
+ async createMultipartUpload(key, opts) {
133
+ const upload = await r2.createMultipartUpload(key, {
134
+ httpMetadata: opts?.contentType
135
+ ? { contentType: opts.contentType }
136
+ : undefined,
137
+ });
138
+ return { uploadId: upload.uploadId, key: upload.key };
139
+ },
140
+
141
+ async uploadPart(key, uploadId, partNumber, body) {
142
+ const upload = r2.resumeMultipartUpload(key, uploadId);
143
+ const part = await upload.uploadPart(partNumber, body);
144
+ return { partNumber: part.partNumber, etag: part.etag };
145
+ },
146
+
147
+ async completeMultipartUpload(key, uploadId, parts) {
148
+ const upload = r2.resumeMultipartUpload(key, uploadId);
149
+ await upload.complete(parts);
150
+ },
151
+
152
+ async abortMultipartUpload(key, uploadId) {
153
+ const upload = r2.resumeMultipartUpload(key, uploadId);
154
+ await upload.abort();
155
+ },
60
156
  };
61
157
  }
62
158
 
@@ -84,7 +180,14 @@ interface PutObjectInput {
84
180
  ContentType?: string;
85
181
  }
86
182
 
87
- /** Input for GetObject / DeleteObject */
183
+ /** Input for GetObject */
184
+ interface GetObjectInput {
185
+ Bucket: string;
186
+ Key: string;
187
+ Range?: string;
188
+ }
189
+
190
+ /** Input for DeleteObject */
88
191
  interface ObjectKeyInput {
89
192
  Bucket: string;
90
193
  Key: string;
@@ -94,13 +197,14 @@ interface ObjectKeyInput {
94
197
  interface S3GetObjectOutput {
95
198
  Body?: { transformToWebStream(): ReadableStream };
96
199
  ContentType?: string;
200
+ ContentLength?: number;
97
201
  }
98
202
 
99
203
  /** Lazy-loaded S3 client bundle */
100
204
  interface S3ClientBundle {
101
205
  send: (command: unknown) => Promise<unknown>;
102
206
  PutObjectCommand: S3CommandCtor<PutObjectInput>;
103
- GetObjectCommand: S3CommandCtor<ObjectKeyInput>;
207
+ GetObjectCommand: S3CommandCtor<GetObjectInput>;
104
208
  DeleteObjectCommand: S3CommandCtor<ObjectKeyInput>;
105
209
  bucket: string;
106
210
  }
@@ -178,18 +282,22 @@ export function createS3Driver(config: S3DriverConfig): StorageDriver {
178
282
  await s3.send(command);
179
283
  },
180
284
 
181
- async get(key) {
285
+ async get(key, opts) {
182
286
  const s3 = await getClient();
183
287
  try {
184
288
  const command = new s3.GetObjectCommand({
185
289
  Bucket: s3.bucket,
186
290
  Key: key,
291
+ Range: opts?.range
292
+ ? `bytes=${opts.range.offset}-${opts.range.offset + opts.range.length - 1}`
293
+ : undefined,
187
294
  });
188
295
  const response = (await s3.send(command)) as S3GetObjectOutput;
189
296
  if (!response.Body) return null;
190
297
  return {
191
298
  body: response.Body.transformToWebStream(),
192
299
  contentType: response.ContentType ?? undefined,
300
+ size: response.ContentLength ?? undefined,
193
301
  };
194
302
  } catch (err: unknown) {
195
303
  // NoSuchKey → return null instead of throwing
@@ -41,6 +41,72 @@ function extractPlainText(node: TiptapNode): string {
41
41
  * const summary = extractSummary(body, 5, 500);
42
42
  * ```
43
43
  */
44
+ /**
45
+ * Content-bearing TipTap node types whose text should be indexed for search.
46
+ * Block-level containers (bulletList, orderedList, table, etc.) are included
47
+ * because they recurse into child nodes that carry text.
48
+ */
49
+ const SEARCHABLE_TYPES = new Set([
50
+ "doc",
51
+ "paragraph",
52
+ "heading",
53
+ "codeBlock",
54
+ "bulletList",
55
+ "orderedList",
56
+ "listItem",
57
+ "blockquote",
58
+ "table",
59
+ "tableRow",
60
+ "tableCell",
61
+ "tableHeader",
62
+ "text",
63
+ "hardBreak",
64
+ ]);
65
+
66
+ /**
67
+ * Recursively extracts all searchable plain text from a TipTap JSON body string.
68
+ *
69
+ * Used for FTS indexing — includes text from paragraphs, headings, code blocks,
70
+ * lists, blockquotes, and tables. Skips non-textual nodes (image, moreBreak,
71
+ * horizontalRule). Block-level nodes are joined with spaces for better trigram
72
+ * matching.
73
+ *
74
+ * @param bodyJson - TipTap JSON string (the `body` column)
75
+ * @returns Plain text for FTS indexing, or null if parsing fails or doc is empty
76
+ *
77
+ * @example
78
+ * ```ts
79
+ * const text = extractBodyText(body);
80
+ * // "Hello world Some code here"
81
+ * ```
82
+ */
83
+ export function extractBodyText(bodyJson: string): string | null {
84
+ let doc: TiptapNode;
85
+ try {
86
+ doc = JSON.parse(bodyJson) as TiptapNode;
87
+ } catch {
88
+ return null;
89
+ }
90
+
91
+ if (doc.type !== "doc" || !doc.content) return null;
92
+
93
+ function collectText(node: TiptapNode): string {
94
+ if (!SEARCHABLE_TYPES.has(node.type)) return "";
95
+ if (node.type === "text") return node.text ?? "";
96
+ if (node.type === "hardBreak") return " ";
97
+ if (!node.content) return "";
98
+ return node.content.map(collectText).join(" ");
99
+ }
100
+
101
+ const parts: string[] = [];
102
+ for (const child of doc.content) {
103
+ const text = collectText(child).trim();
104
+ if (text) parts.push(text);
105
+ }
106
+
107
+ return parts.length > 0 ? parts.join(" ") : null;
108
+ }
109
+
44
110
  export function extractSummary(
45
111
  bodyJson: string,
46
112
  maxParagraphs: number,
package/src/lib/theme.ts CHANGED
@@ -33,15 +33,16 @@ export function getAvailableThemes(): ColorTheme[] {
33
33
  * @param cssVariables - Extra CSS variable overrides
34
34
  * @returns CSS string to inject in `<head>`, or empty string if nothing to inject
35
35
  *
36
- * Uses `:root:root` and `:root.dark` selectors for higher specificity than
37
- * BaseCoat defaults (`:root` and `.dark`). This ensures theme overrides win
38
- * regardless of source order important because Vite dev mode injects CSS
39
- * as `<style>` tags after the theme `<style>`.
36
+ * Uses `:root:root` for light mode and `@media (prefers-color-scheme: dark)`
37
+ * with `:root:root` for dark mode, giving higher specificity than BaseCoat
38
+ * defaults (`:root`). This ensures theme overrides win regardless of source
39
+ * order important because Vite dev mode injects CSS as `<style>` tags
40
+ * after the theme `<style>`.
40
41
  *
41
42
  * @example
42
43
  * ```typescript
43
44
  * const css = buildThemeStyle(blueTheme, { "--radius": "0.5rem" });
44
- * // => ":root:root { --primary: oklch(...); ... }\n:root.dark { ... }"
45
+ * // => ":root:root { ... }\n@media (prefers-color-scheme: dark) { :root:root { ... } }"
45
46
  * ```
46
47
  */
47
48
  export function buildThemeStyle(
@@ -74,10 +75,12 @@ export function buildThemeStyle(
74
75
 
75
76
  if (hasDark) {
76
77
  const declarations = Object.entries(darkVars)
77
- .map(([k, v]) => ` ${k}: ${v};`)
78
+ .map(([k, v]) => ` ${k}: ${v};`)
78
79
  .join("\n");
79
- // :root.dark has specificity (0,1,1) > BaseCoat's .dark (0,1,0)
80
- parts.push(`:root.dark {\n${declarations}\n}`);
80
+ // :root:root inside @media has specificity (0,0,2) > preset fallback :root (0,0,1)
81
+ parts.push(
82
+ `@media (prefers-color-scheme: dark) {\n :root:root {\n${declarations}\n }\n}`,
83
+ );
81
84
  }
82
85
 
83
86
  return parts.join("\n");
@@ -9,7 +9,7 @@ import type { Context } from "hono";
9
9
  import type { Bindings, TimelineItemView } from "../types.js";
10
10
  import type { AppVariables } from "../types/app-context.js";
11
11
  import { buildMediaMap } from "./media-helpers.js";
12
- import { createMediaContext, toPostView, toPostViews } from "./view.js";
12
+ import { createMediaContext, toPostView } from "./view.js";
13
13
 
14
14
  type Env = { Bindings: Bindings; Variables: AppVariables };
15
15
 
@@ -40,18 +40,21 @@ export interface TimelineResult {
40
40
  */
41
41
  export async function assembleTimeline(
42
42
  c: Context<Env>,
43
- options?: { page?: number },
43
+ options?: { page?: number; isAuthenticated?: boolean },
44
44
  ): Promise<TimelineResult> {
45
45
  const pageSize = c.var.appConfig.pageSize;
46
46
 
47
47
  const page = Math.max(1, options?.page ?? 1);
48
48
  const offset = (page - 1) * pageSize;
49
49
 
50
+ const excludePrivate = !(options?.isAuthenticated ?? false);
51
+
50
52
  // Get total count for pagination
51
53
  const totalCount = await c.var.services.posts.count({
52
54
  status: "published",
53
55
  excludeReplies: true,
54
56
  excludeUnlisted: true,
57
+ excludePrivate,
55
58
  });
56
59
  const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
57
60
 
@@ -60,6 +63,7 @@ export async function assembleTimeline(
60
63
  status: "published",
61
64
  excludeReplies: true,
62
65
  excludeUnlisted: true,
66
+ excludePrivate,
63
67
  limit: pageSize,
64
68
  offset,
65
69
  });
@@ -68,7 +72,7 @@ export async function assembleTimeline(
68
72
  return { items: [], currentPage: page, totalPages };
69
73
  }
70
74
 
71
- // Batch load media attachments
75
+ // Batch load media
72
76
  const postIds = posts.map((p) => p.id);
73
77
  const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
74
78
  const mediaCtx = createMediaContext(c.var.appConfig);
@@ -79,55 +83,91 @@ export async function assembleTimeline(
79
83
  mediaCtx.s3PublicUrl,
80
84
  );
81
85
 
86
+ // Batch load collections for main posts
87
+ const collectionsMap =
88
+ await c.var.services.collections.getCollectionsByPostIds(postIds);
89
+
82
90
  // Get reply counts to identify thread roots
83
91
  const replyCounts = await c.var.services.posts.getReplyCounts(postIds);
84
92
  const threadRootIds = postIds.filter((id) => (replyCounts.get(id) ?? 0) > 0);
85
93
 
86
- // Batch load thread previews
87
- const threadPreviews = await c.var.services.posts.getThreadPreviews(
88
- threadRootIds,
89
- 3,
90
- );
94
+ // Batch load thread timeline context (latest reply + parent)
95
+ const threadContexts =
96
+ await c.var.services.posts.getThreadTimelineContext(threadRootIds);
91
97
 
92
- // Batch load media for preview replies
93
- const previewReplyIds: number[] = [];
94
- for (const replies of threadPreviews.values()) {
95
- for (const reply of replies) {
96
- previewReplyIds.push(reply.id);
98
+ // Batch load media for context posts (latestReply + parentReply)
99
+ const contextPostIds: string[] = [];
100
+ for (const ctx of threadContexts.values()) {
101
+ contextPostIds.push(ctx.latestReply.id);
102
+ if (ctx.parentReply) {
103
+ contextPostIds.push(ctx.parentReply.id);
97
104
  }
98
105
  }
99
- const previewMediaMap =
100
- previewReplyIds.length > 0
101
- ? buildMediaMap(
102
- await c.var.services.media.getByPostIds(previewReplyIds),
103
- mediaCtx.r2PublicUrl,
104
- mediaCtx.imageTransformUrl,
105
- mediaCtx.s3PublicUrl,
106
- )
107
- : new Map();
106
+ const [contextMediaMap, contextCollectionsMap] =
107
+ contextPostIds.length > 0
108
+ ? await Promise.all([
109
+ c.var.services.media
110
+ .getByPostIds(contextPostIds)
111
+ .then((raw) =>
112
+ buildMediaMap(
113
+ raw,
114
+ mediaCtx.r2PublicUrl,
115
+ mediaCtx.imageTransformUrl,
116
+ mediaCtx.s3PublicUrl,
117
+ ),
118
+ ),
119
+ c.var.services.collections.getCollectionsByPostIds(contextPostIds),
120
+ ])
121
+ : [new Map(), new Map()];
108
122
 
109
123
  // Assemble timeline items with View Models
110
124
  const items: TimelineItemView[] = posts.map((post) => {
111
125
  const postView = toPostView(
112
- { ...post, mediaAttachments: mediaMap.get(post.id) ?? [] },
126
+ {
127
+ ...post,
128
+ mediaAttachments: mediaMap.get(post.id) ?? [],
129
+ },
113
130
  mediaCtx,
131
+ collectionsMap.get(post.id),
114
132
  );
115
133
 
116
- const replyCount = replyCounts.get(post.id) ?? 0;
117
- const previewReplies = threadPreviews.get(post.id);
134
+ const threadCtx = threadContexts.get(post.id);
135
+
136
+ if (threadCtx) {
137
+ // Thread root is not the last post — hide reply button on it
138
+ postView.isLastInThread = false;
139
+
140
+ const latestReplyView = toPostView(
141
+ {
142
+ ...threadCtx.latestReply,
143
+ mediaAttachments: contextMediaMap.get(threadCtx.latestReply.id) ?? [],
144
+ },
145
+ mediaCtx,
146
+ contextCollectionsMap.get(threadCtx.latestReply.id),
147
+ undefined,
148
+ true, // latestReply is the last post in the thread
149
+ );
150
+
151
+ const parentReplyView = threadCtx.parentReply
152
+ ? toPostView(
153
+ {
154
+ ...threadCtx.parentReply,
155
+ mediaAttachments:
156
+ contextMediaMap.get(threadCtx.parentReply.id) ?? [],
157
+ },
158
+ mediaCtx,
159
+ contextCollectionsMap.get(threadCtx.parentReply.id),
160
+ undefined,
161
+ false, // parentReply is not the last post
162
+ )
163
+ : undefined;
118
164
 
119
- if (replyCount > 0 && previewReplies) {
120
165
  return {
121
166
  post: postView,
122
167
  threadPreview: {
123
- replies: toPostViews(
124
- previewReplies.map((r) => ({
125
- ...r,
126
- mediaAttachments: previewMediaMap.get(r.id) ?? [],
127
- })),
128
- mediaCtx,
129
- ),
130
- totalReplyCount: replyCount,
168
+ latestReply: latestReplyView,
169
+ parentReply: parentReplyView,
170
+ totalReplyCount: threadCtx.totalReplyCount,
131
171
  },
132
172
  };
133
173
  }
@@ -6,6 +6,9 @@
6
6
  * Works on Cloudflare Workers and any JS runtime.
7
7
  */
8
8
 
9
+ import { escapeHtml } from "./html.js";
10
+ import { sanitizeUrl } from "./url.js";
11
+
9
12
  interface TiptapMark {
10
13
  type: string;
11
14
  attrs?: Record<string, unknown>;
@@ -19,14 +22,6 @@ interface TiptapNode {
19
22
  attrs?: Record<string, unknown>;
20
23
  }
21
24
 
22
- function escapeHtml(str: string): string {
23
- return str
24
- .replace(/&/g, "&amp;")
25
- .replace(/</g, "&lt;")
26
- .replace(/>/g, "&gt;")
27
- .replace(/"/g, "&quot;");
28
- }
29
-
30
25
  function renderMarks(text: string, marks: TiptapMark[]): string {
31
26
  let result = escapeHtml(text);
32
27
 
@@ -45,7 +40,7 @@ function renderMarks(text: string, marks: TiptapMark[]): string {
45
40
  result = `<code>${result}</code>`;
46
41
  break;
47
42
  case "link": {
48
- const href = escapeHtml(String(mark.attrs?.href ?? ""));
43
+ const href = escapeHtml(sanitizeUrl(String(mark.attrs?.href ?? "")));
49
44
  const target = mark.attrs?.target
50
45
  ? ` target="${escapeHtml(String(mark.attrs.target))}"`
51
46
  : "";
@@ -145,7 +140,7 @@ function renderNode(node: TiptapNode): string {
145
140
  : "";
146
141
  const caption = node.attrs?.caption ? String(node.attrs.caption) : "";
147
142
  const layout = node.attrs?.layout ?? "regular";
148
- const href = node.attrs?.href ? String(node.attrs.href) : "";
143
+ const href = node.attrs?.href ? sanitizeUrl(String(node.attrs.href)) : "";
149
144
  const layoutAttr =
150
145
  layout !== "regular"
151
146
  ? ` data-layout="${escapeHtml(String(layout))}"`