@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
@@ -20,29 +20,40 @@ import type {
20
20
  SearchResult,
21
21
  Post,
22
22
  } from "../../types.js";
23
-
24
23
  const EMPTY_CTX: MediaContext = {};
25
24
  const CTX_WITH_URLS: MediaContext = {
26
25
  r2PublicUrl: "https://cdn.example.com",
27
26
  imageTransformUrl: "https://example.com/cdn-cgi/image",
28
27
  };
29
28
 
29
+ // UUIDv7 constants for test fixtures
30
+ const UUID_1 = "019cb943-b2c0-76e3-ade2-209415e74da5";
31
+ const UUID_2 = "019cb943-b2c0-76e3-ade2-209415e74da6";
32
+ const UUID_3 = "019cb943-b2c0-76e3-ade2-209415e74da7";
33
+ const UUID_POST = "019cb943-c000-7000-8000-000000000001";
34
+ const UUID_NAV_1 = "019cb943-d000-7000-8000-000000000001";
35
+ const UUID_NAV_2 = "019cb943-d000-7000-8000-000000000002";
36
+ const UUID_NAV_3 = "019cb943-d000-7000-8000-000000000003";
37
+
30
38
  function makePost(overrides: Partial<Post> = {}): Post {
31
39
  return {
32
- id: 1,
40
+ id: UUID_1,
33
41
  format: "note",
34
42
  status: "published",
35
- visibility: "listed" as const,
36
- pinned: 0,
37
- path: null,
43
+ visibility: "public" as const,
44
+ pinnedAt: null,
45
+ featuredAt: null,
46
+ slug: "test-post",
38
47
  title: null,
39
48
  url: null,
40
49
  body: "Hello world",
41
50
  bodyHtml: "<p>Hello world</p>",
51
+ bodyText: null,
42
52
  quoteText: null,
53
+ summary: null,
43
54
  rating: null,
44
55
  replyToId: null,
45
- threadId: null,
56
+ threadId: UUID_1,
46
57
  deletedAt: null,
47
58
  publishedAt: 1706745600, // 2024-02-01T00:00:00Z
48
59
  createdAt: 1706745600,
@@ -63,7 +74,7 @@ function makePostWithMedia(
63
74
  function makeMedia(overrides: Partial<Media> = {}): Media {
64
75
  return {
65
76
  id: "01902a9f-1a2b-7c3d",
66
- postId: 1,
77
+ postId: UUID_1,
67
78
  filename: "image.webp",
68
79
  originalName: "photo.jpg",
69
80
  mimeType: "image/webp",
@@ -73,21 +84,24 @@ function makeMedia(overrides: Partial<Media> = {}): Media {
73
84
  width: 1920,
74
85
  height: 1080,
75
86
  alt: "A photo",
76
- position: 0,
87
+ position: "a0",
77
88
  blurhash: null,
89
+ posterKey: null,
90
+ summary: null,
91
+ chars: null,
78
92
  createdAt: 1706745600,
93
+ updatedAt: 1706745600,
79
94
  ...overrides,
80
95
  };
81
96
  }
82
97
 
83
98
  function makeNavItem(overrides: Partial<NavItem> = {}): NavItem {
84
99
  return {
85
- id: 1,
100
+ id: UUID_NAV_1,
86
101
  type: "link",
87
102
  label: "Home",
88
103
  url: "/",
89
- pageId: null,
90
- position: 0,
104
+ position: "a0",
91
105
  createdAt: 1706745600,
92
106
  updatedAt: 1706745600,
93
107
  ...overrides,
@@ -99,23 +113,11 @@ function makeNavItem(overrides: Partial<NavItem> = {}): NavItem {
99
113
  // =============================================================================
100
114
 
101
115
  describe("toPostView", () => {
102
- it("generates permalink from post id when no path", () => {
103
- const post = makePostWithMedia({ id: 123, path: null });
104
- const view = toPostView(post, EMPTY_CTX);
105
- expect(view.permalink).toMatch(/^\/p\/.+$/);
106
- expect(view.permalink.length).toBeGreaterThan(3);
107
- });
108
-
109
- it("generates permalink from path when path is set", () => {
110
- const post = makePostWithMedia({ id: 123, path: "my-post" });
116
+ it("generates permalink from slug", () => {
117
+ const post = makePostWithMedia({ id: UUID_POST, slug: "my-post" });
111
118
  const view = toPostView(post, EMPTY_CTX);
112
119
  expect(view.permalink).toBe("/my-post");
113
- });
114
-
115
- it("generates permalink from multi-level path", () => {
116
- const post = makePostWithMedia({ id: 123, path: "2024/01/my-post" });
117
- const view = toPostView(post, EMPTY_CTX);
118
- expect(view.permalink).toBe("/2024/01/my-post");
120
+ expect(view.slug).toBe("my-post");
119
121
  });
120
122
 
121
123
  it("formats dates correctly", () => {
@@ -207,7 +209,7 @@ describe("toPostView", () => {
207
209
  it("converts null fields to undefined", () => {
208
210
  const view = toPostView(makePostWithMedia(), EMPTY_CTX);
209
211
  expect(view.title).toBeUndefined();
210
- expect(view.path).toBeUndefined();
212
+ expect(view.slug).toBe("test-post");
211
213
  expect(view.url).toBeUndefined();
212
214
  expect(view.quoteText).toBeUndefined();
213
215
  expect(view.rating).toBeUndefined();
@@ -236,31 +238,33 @@ describe("toPostView", () => {
236
238
  expect(view.quoteText).toBe("Something wise");
237
239
  });
238
240
 
239
- it("maps format, status, visibility, and pinned correctly", () => {
241
+ it("maps format, status, visibility, pinned, and featured correctly", () => {
240
242
  const view = toPostView(
241
243
  makePostWithMedia({
242
244
  format: "link",
243
245
  status: "draft",
244
- visibility: "featured",
245
- pinned: 1,
246
+ visibility: "public",
247
+ pinnedAt: 1706745600,
248
+ featuredAt: 1706745600,
246
249
  }),
247
250
  EMPTY_CTX,
248
251
  );
249
252
  expect(view.format).toBe("link");
250
253
  expect(view.status).toBe("draft");
251
- expect(view.visibility).toBe("featured");
254
+ expect(view.visibility).toBe("public");
252
255
  expect(view.pinned).toBe(true);
256
+ expect(view.featured).toBe(true);
253
257
  });
254
258
 
255
- it("maps default visibility and pinned=0", () => {
259
+ it("maps default visibility and pinnedAt=null", () => {
256
260
  const view = toPostView(
257
261
  makePostWithMedia({
258
- visibility: "listed",
259
- pinned: 0,
262
+ visibility: "public",
263
+ pinnedAt: null,
260
264
  }),
261
265
  EMPTY_CTX,
262
266
  );
263
- expect(view.visibility).toBe("listed");
267
+ expect(view.visibility).toBe("public");
264
268
  expect(view.pinned).toBe(false);
265
269
  });
266
270
 
@@ -284,10 +288,15 @@ describe("toPostView", () => {
284
288
  previewUrl: "/media/abc-thumb.webp",
285
289
  alt: "Photo",
286
290
  blurhash: null,
291
+ posterUrl: null,
287
292
  width: 800,
288
293
  height: 600,
289
- position: 0,
294
+ position: "a0",
290
295
  mimeType: "image/webp",
296
+ originalName: "photo.jpg",
297
+ size: 5000,
298
+ summary: null,
299
+ chars: null,
291
300
  },
292
301
  ],
293
302
  }),
@@ -302,17 +311,53 @@ describe("toPostView", () => {
302
311
  altText: "Photo",
303
312
  width: 800,
304
313
  height: 600,
314
+ blurhash: undefined,
315
+ posterUrl: undefined,
316
+ originalName: "photo.jpg",
317
+ size: 5000,
318
+ summary: undefined,
319
+ chars: undefined,
305
320
  });
306
321
  });
322
+
323
+ it("passes blurhash from media attachments to MediaView", () => {
324
+ const view = toPostView(
325
+ makePostWithMedia({
326
+ mediaAttachments: [
327
+ {
328
+ id: "abc",
329
+ url: "/media/abc.webp",
330
+ previewUrl: "/media/abc-thumb.webp",
331
+ alt: null,
332
+ blurhash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj",
333
+ posterUrl: null,
334
+ width: 800,
335
+ height: 600,
336
+ position: "a0",
337
+ mimeType: "image/webp",
338
+ originalName: "photo.jpg",
339
+ size: 5000,
340
+ summary: null,
341
+ chars: null,
342
+ },
343
+ ],
344
+ }),
345
+ EMPTY_CTX,
346
+ );
347
+ expect(view.media[0]?.blurhash).toBe("LEHV6nWB2yk8pyo0adR*.7kCMdnj");
348
+ });
307
349
  });
308
350
 
309
351
  describe("toPostViews", () => {
310
352
  it("converts multiple posts", () => {
311
- const posts = [makePostWithMedia({ id: 1 }), makePostWithMedia({ id: 2 })];
353
+ const posts = [
354
+ makePostWithMedia({ id: UUID_1 }),
355
+ makePostWithMedia({ id: UUID_2 }),
356
+ ];
312
357
  const views = toPostViews(posts, EMPTY_CTX);
313
358
  expect(views).toHaveLength(2);
314
- expect(views[0]).toHaveProperty("id", 1);
315
- expect(views[1]).toHaveProperty("id", 2);
359
+ expect(views[0]).toHaveProperty("id", UUID_1);
360
+ expect(views[1]).toHaveProperty("id", UUID_2);
316
361
  });
317
362
  });
318
363
 
@@ -347,21 +392,55 @@ describe("toMediaView", () => {
347
392
  expect(view.url).toContain("s3.example.com");
348
393
  });
349
394
 
350
- it("maps alt text and dimensions", () => {
351
- const view = toMediaView(makeMedia(), EMPTY_CTX);
395
+ it("maps alt text, dimensions, and blurhash", () => {
396
+ const view = toMediaView(
397
+ makeMedia({ blurhash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj" }),
398
+ EMPTY_CTX,
399
+ );
352
400
  expect(view.altText).toBe("A photo");
353
401
  expect(view.width).toBe(1920);
354
402
  expect(view.height).toBe(1080);
355
403
  expect(view.mimeType).toBe("image/webp");
356
404
  expect(view.size).toBe(12345);
405
+ expect(view.blurhash).toBe("LEHV6nWB2yk8pyo0adR*.7kCMdnj");
357
406
  });
358
407
 
359
- it("handles null alt and dimensions", () => {
360
- const media = makeMedia({ alt: null, width: null, height: null });
408
+ it("handles null alt, dimensions, and blurhash", () => {
409
+ const media = makeMedia({
410
+ alt: null,
411
+ width: null,
412
+ height: null,
413
+ blurhash: null,
414
+ });
361
415
  const view = toMediaView(media, EMPTY_CTX);
362
416
  expect(view.altText).toBeUndefined();
363
417
  expect(view.width).toBeUndefined();
364
418
  expect(view.height).toBeUndefined();
419
+ expect(view.blurhash).toBeUndefined();
420
+ });
421
+
422
+ it("computes posterUrl from posterKey", () => {
423
+ const media = makeMedia({
424
+ posterKey: "media/2025/01/abc-poster.webp",
425
+ });
426
+ const view = toMediaView(media, EMPTY_CTX);
427
+ expect(view.posterUrl).toBe("/media/2025/01/abc-poster.webp");
428
+ });
429
+
430
+ it("computes posterUrl with CDN public URL and image transform", () => {
431
+ const media = makeMedia({
432
+ posterKey: "media/2025/01/abc-poster.webp",
433
+ });
434
+ const view = toMediaView(media, CTX_WITH_URLS);
435
+ expect(view.posterUrl).toBe(
436
+ "https://example.com/cdn-cgi/image/width=640,quality=80,format=auto,fit=scale-down/https://cdn.example.com/media/2025/01/abc-poster.webp",
437
+ );
438
+ });
439
+
440
+ it("returns undefined posterUrl when posterKey is null", () => {
441
+ const media = makeMedia({ posterKey: null });
442
+ const view = toMediaView(media, EMPTY_CTX);
443
+ expect(view.posterUrl).toBeUndefined();
365
444
  });
366
445
  });
367
446
 
@@ -412,24 +491,18 @@ describe("toNavItemView", () => {
412
491
  expect(view.isActive).toBe(false);
413
492
  });
414
493
 
415
- it("includes type and pageId in view", () => {
416
- const view = toNavItemView(makeNavItem({ type: "page", pageId: 5 }), "/");
417
- expect(view.type).toBe("page");
418
- expect(view.pageId).toBe(5);
419
- });
420
-
421
- it("converts null pageId to undefined", () => {
422
- const view = toNavItemView(makeNavItem({ pageId: null }), "/");
423
- expect(view.pageId).toBeUndefined();
494
+ it("includes type in view", () => {
495
+ const view = toNavItemView(makeNavItem({ type: "system" }), "/");
496
+ expect(view.type).toBe("system");
424
497
  });
425
498
  });
426
499
 
427
500
  describe("toNavItemViews", () => {
428
501
  it("converts multiple items", () => {
429
502
  const items = [
430
- makeNavItem({ id: 1, url: "/" }),
431
- makeNavItem({ id: 2, url: "/archive" }),
432
- makeNavItem({ id: 3, url: "https://github.com" }),
503
+ makeNavItem({ id: UUID_NAV_1, url: "/" }),
504
+ makeNavItem({ id: UUID_NAV_2, url: "/archive" }),
505
+ makeNavItem({ id: UUID_NAV_3, url: "https://github.com" }),
433
506
  ];
434
507
  const views = toNavItemViews(items, "/archive");
435
508
  expect(views).toHaveLength(3);
@@ -446,12 +519,12 @@ describe("toNavItemViews", () => {
446
519
  describe("toSearchResultView", () => {
447
520
  it("wraps post in PostView", () => {
448
521
  const result: SearchResult = {
449
- post: makePost({ id: 42, title: "Test" }),
522
+ post: makePost({ id: UUID_POST, title: "Test" }),
450
523
  rank: 1.5,
451
524
  snippet: "...matching <b>text</b>...",
452
525
  };
453
526
  const view = toSearchResultView(result, EMPTY_CTX);
454
- expect(view.post.id).toBe(42);
527
+ expect(view.post.id).toBe(UUID_POST);
455
528
  expect(view.post.title).toBe("Test");
456
529
  expect(view.post.permalink).toBeDefined();
457
530
  expect(view.rank).toBe(1.5);
@@ -461,20 +534,22 @@ describe("toSearchResultView", () => {
461
534
  it("uses new post fields in search result view", () => {
462
535
  const result: SearchResult = {
463
536
  post: makePost({
464
- id: 10,
537
+ id: UUID_POST,
465
538
  format: "link",
466
539
  status: "published",
467
- visibility: "featured",
468
- pinned: 0,
540
+ visibility: "public",
541
+ pinnedAt: null,
542
+ featuredAt: 1706745600,
469
543
  url: "https://example.com",
470
- path: "my-link",
544
+ slug: "my-link",
471
545
  }),
472
546
  rank: 0.8,
473
547
  };
474
548
  const view = toSearchResultView(result, EMPTY_CTX);
475
549
  expect(view.post.format).toBe("link");
476
550
  expect(view.post.status).toBe("published");
477
- expect(view.post.visibility).toBe("featured");
551
+ expect(view.post.visibility).toBe("public");
552
+ expect(view.post.featured).toBe(true);
478
553
  expect(view.post.pinned).toBe(false);
479
554
  expect(view.post.url).toBe("https://example.com");
480
555
  expect(view.post.permalink).toBe("/my-link");
@@ -489,10 +564,10 @@ describe("toArchiveGroups", () => {
489
564
  it("converts grouped map to ArchiveGroup array", () => {
490
565
  const grouped = new Map<string, Post[]>();
491
566
  grouped.set("2024-02", [
492
- makePost({ id: 1, publishedAt: 1706745600 }),
493
- makePost({ id: 2, publishedAt: 1706832000 }),
567
+ makePost({ id: UUID_1, publishedAt: 1706745600 }),
568
+ makePost({ id: UUID_2, publishedAt: 1706832000 }),
494
569
  ]);
495
- grouped.set("2024-01", [makePost({ id: 3, publishedAt: 1704067200 })]);
570
+ grouped.set("2024-01", [makePost({ id: UUID_3, publishedAt: 1704067200 })]);
496
571
 
497
572
  const groups = toArchiveGroups(grouped, EMPTY_CTX);
498
573
  expect(groups).toHaveLength(2);
@@ -511,7 +586,7 @@ describe("toArchiveGroups", () => {
511
586
 
512
587
  it("converts posts to PostView within groups", () => {
513
588
  const grouped = new Map<string, Post[]>();
514
- grouped.set("2024-02", [makePost({ id: 1 })]);
589
+ grouped.set("2024-02", [makePost({ id: UUID_1 })]);
515
590
 
516
591
  const groups = toArchiveGroups(grouped, EMPTY_CTX);
517
592
  const post = groups[0]?.posts[0];
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Blurhash to Data URL (Workers-compatible)
3
+ *
4
+ * Decodes a blurhash to a tiny BMP image encoded as a base64 data URL.
5
+ * Uses raw BMP encoding (no Canvas/DOM needed) so it works in
6
+ * Cloudflare Workers and Node.js alike.
7
+ *
8
+ * The resulting 4×3 image is stretched by the browser via CSS
9
+ * `background-size: cover` with `image-rendering: auto` (default),
10
+ * which applies bilinear interpolation for a natural blur effect.
11
+ */
12
+
13
+ import { decode } from "blurhash";
14
+
15
+ /**
16
+ * Convert a blurhash string to a base64-encoded BMP data URL.
17
+ *
18
+ * @param hash - Blurhash string
19
+ * @param width - Decode width in pixels (default 4)
20
+ * @param height - Decode height in pixels (default 3)
21
+ * @returns data:image/bmp;base64,... string
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * const url = blurhashToDataUrl("LEHV6nWB2yk8pyo0adR*.7kCMdnj");
26
+ * // "data:image/bmp;base64,Qk2..."
27
+ * ```
28
+ */
29
+ export function blurhashToDataUrl(hash: string, width = 4, height = 3): string {
30
+ const pixels = decode(hash, width, height);
31
+ const bmp = encodeBMP(pixels, width, height);
32
+ return "data:image/bmp;base64," + uint8ToBase64(bmp);
33
+ }
34
+
35
+ /**
36
+ * Encode RGBA pixel data into a BMP file (24-bit, bottom-up).
37
+ */
38
+ function encodeBMP(
39
+ pixels: Uint8ClampedArray,
40
+ w: number,
41
+ h: number,
42
+ ): Uint8Array {
43
+ // BMP row stride must be a multiple of 4 bytes
44
+ const rowSize = Math.ceil((w * 3) / 4) * 4;
45
+ const pixelDataSize = rowSize * h;
46
+ const fileSize = 54 + pixelDataSize; // 14 (file header) + 40 (DIB header) + pixels
47
+
48
+ const buf = new Uint8Array(fileSize);
49
+ const view = new DataView(buf.buffer);
50
+
51
+ // -- BMP File Header (14 bytes) --
52
+ buf[0] = 0x42; // 'B'
53
+ buf[1] = 0x4d; // 'M'
54
+ view.setUint32(2, fileSize, true);
55
+ view.setUint32(10, 54, true); // pixel data offset
56
+
57
+ // -- DIB Header (BITMAPINFOHEADER, 40 bytes) --
58
+ view.setUint32(14, 40, true); // header size
59
+ view.setInt32(18, w, true); // width
60
+ view.setInt32(22, h, true); // height (positive = bottom-up)
61
+ view.setUint16(26, 1, true); // color planes
62
+ view.setUint16(28, 24, true); // bits per pixel
63
+ // compression (0), image size (0), resolution, colors — all zeros (default)
64
+
65
+ // -- Pixel data (bottom-up, BGR) --
66
+ for (let y = 0; y < h; y++) {
67
+ const srcRow = (h - 1 - y) * w; // BMP is bottom-up
68
+ const dstRow = 54 + y * rowSize;
69
+ for (let x = 0; x < w; x++) {
70
+ const srcIdx = (srcRow + x) * 4;
71
+ const dstIdx = dstRow + x * 3;
72
+ buf[dstIdx] = pixels[srcIdx + 2] ?? 0; // B
73
+ buf[dstIdx + 1] = pixels[srcIdx + 1] ?? 0; // G
74
+ buf[dstIdx + 2] = pixels[srcIdx] ?? 0; // R
75
+ }
76
+ }
77
+
78
+ return buf;
79
+ }
80
+
81
+ /**
82
+ * Base64-encode a Uint8Array without relying on btoa or Buffer.
83
+ */
84
+ function uint8ToBase64(bytes: Uint8Array): string {
85
+ const chars =
86
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
87
+ let result = "";
88
+ const len = bytes.length;
89
+
90
+ for (let i = 0; i < len; i += 3) {
91
+ const b0 = bytes[i] as number;
92
+ const b1 = i + 1 < len ? (bytes[i + 1] as number) : 0;
93
+ const b2 = i + 2 < len ? (bytes[i + 2] as number) : 0;
94
+
95
+ result += chars[b0 >> 2];
96
+ result += chars[((b0 & 3) << 4) | (b1 >> 4)];
97
+ result += i + 1 < len ? chars[((b1 & 15) << 2) | (b2 >> 6)] : "=";
98
+ result += i + 2 < len ? chars[b2 & 63] : "=";
99
+ }
100
+
101
+ return result;
102
+ }
@@ -12,6 +12,8 @@ export const RESERVED_PATHS = [
12
12
  "signin",
13
13
  "signout",
14
14
  "setup",
15
+ "settings",
16
+ "posts",
15
17
  "dash",
16
18
  "api",
17
19
  "feed",
@@ -20,8 +22,8 @@ export const RESERVED_PATHS = [
20
22
  "media",
21
23
  "pages",
22
24
  "reset",
23
- "p",
24
25
  "c",
26
+ "compose",
25
27
  "static",
26
28
  "assets",
27
29
  "health",