@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.
- package/bin/commands/export.js +1 -1
- package/bin/commands/import-site.js +529 -0
- package/bin/commands/reset-password.js +3 -2
- package/dist/client/assets/heic-to-DIRPI3VF.js +1 -0
- package/dist/client/assets/url-FWFqPJPb.js +1 -0
- package/dist/client/client.css +1 -1
- package/dist/client/client.js +4012 -3276
- package/dist/index.js +10285 -5809
- package/package.json +11 -3
- package/src/__tests__/helpers/app.ts +9 -9
- package/src/__tests__/helpers/db.ts +91 -93
- package/src/app.tsx +157 -27
- package/src/auth.ts +20 -2
- package/src/client/archive-nav.js +187 -0
- package/src/client/audio-player.ts +478 -0
- package/src/client/audio-processor.ts +84 -0
- package/src/client/avatar-upload.ts +3 -2
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +645 -49
- package/src/client/components/__tests__/jant-compose-editor.test.ts +208 -16
- package/src/client/components/__tests__/jant-post-form.test.ts +19 -9
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
- package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
- package/src/client/components/collection-sidebar-types.ts +7 -9
- package/src/client/components/compose-types.ts +101 -4
- package/src/client/components/jant-collection-form.ts +43 -7
- package/src/client/components/jant-collection-sidebar.ts +88 -84
- package/src/client/components/jant-compose-dialog.ts +1655 -219
- package/src/client/components/jant-compose-editor.ts +732 -168
- package/src/client/components/jant-compose-fullscreen.ts +23 -78
- package/src/client/components/jant-media-lightbox.ts +2 -0
- package/src/client/components/jant-nav-manager.ts +24 -284
- package/src/client/components/jant-post-form.ts +89 -9
- package/src/client/components/jant-post-menu.ts +1019 -0
- package/src/client/components/jant-settings-avatar.ts +3 -3
- package/src/client/components/jant-settings-general.ts +38 -4
- package/src/client/components/jant-text-preview.ts +232 -0
- package/src/client/components/nav-manager-types.ts +4 -19
- package/src/client/components/post-form-template.ts +107 -12
- package/src/client/components/post-form-types.ts +11 -4
- package/src/client/compose-bridge.ts +410 -109
- package/src/client/image-processor.ts +26 -8
- package/src/client/media-metadata.ts +247 -0
- package/src/client/multipart-upload.ts +160 -0
- package/src/client/post-form-bridge.ts +52 -1
- package/src/client/settings-bridge.ts +0 -12
- package/src/client/thread-context.ts +140 -0
- package/src/client/tiptap/create-editor.ts +46 -0
- package/src/client/tiptap/extensions.ts +5 -0
- package/src/client/tiptap/image-node.ts +2 -8
- package/src/client/tiptap/paste-image.ts +2 -13
- package/src/client/tiptap/slash-commands.ts +173 -63
- package/src/client/toast.ts +101 -3
- package/src/client/types/sortablejs.d.ts +15 -0
- package/src/client/upload-with-metadata.ts +54 -0
- package/src/client/video-processor.ts +207 -0
- package/src/client.ts +5 -2
- package/src/db/__tests__/migrations.test.ts +118 -0
- package/src/db/index.ts +52 -0
- package/src/db/migrations/0000_baseline.sql +269 -0
- package/src/db/migrations/0001_fts_setup.sql +31 -0
- package/src/db/migrations/meta/0000_snapshot.json +703 -119
- package/src/db/migrations/meta/0001_snapshot.json +1337 -0
- package/src/db/migrations/meta/_journal.json +4 -39
- package/src/db/schema.ts +409 -145
- package/src/i18n/__tests__/detect.test.ts +115 -0
- package/src/i18n/context.tsx +2 -2
- package/src/i18n/detect.ts +85 -1
- package/src/i18n/i18n.ts +1 -1
- package/src/i18n/index.ts +2 -1
- package/src/i18n/locales/en.po +487 -1217
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +613 -996
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +624 -1007
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/i18n/middleware.ts +6 -0
- package/src/index.ts +5 -7
- package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
- package/src/lib/__tests__/constants.test.ts +0 -1
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
- package/src/lib/__tests__/nanoid.test.ts +26 -0
- package/src/lib/__tests__/schemas.test.ts +181 -63
- package/src/lib/__tests__/slug.test.ts +126 -0
- package/src/lib/__tests__/sse.test.ts +6 -6
- package/src/lib/__tests__/summary.test.ts +264 -0
- package/src/lib/__tests__/theme.test.ts +1 -1
- package/src/lib/__tests__/timeline.test.ts +33 -30
- package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
- package/src/lib/__tests__/view.test.ts +141 -66
- package/src/lib/blurhash-placeholder.ts +102 -0
- package/src/lib/constants.ts +3 -1
- package/src/lib/emoji-catalog.ts +885 -68
- package/src/lib/errors.ts +11 -8
- package/src/lib/feed.ts +78 -32
- package/src/lib/html.ts +2 -1
- package/src/lib/icon-catalog.ts +5033 -1
- package/src/lib/icons.ts +3 -2
- package/src/lib/index.ts +0 -1
- package/src/lib/markdown-to-tiptap.ts +286 -0
- package/src/lib/media-helpers.ts +12 -3
- package/src/lib/nanoid.ts +29 -0
- package/src/lib/navigation.ts +1 -1
- package/src/lib/render.tsx +20 -2
- package/src/lib/resolve-config.ts +6 -2
- package/src/lib/schemas.ts +224 -55
- package/src/lib/search-snippet.ts +34 -0
- package/src/lib/slug.ts +96 -0
- package/src/lib/sse.ts +6 -6
- package/src/lib/storage.ts +115 -7
- package/src/lib/summary.ts +66 -0
- package/src/lib/theme.ts +11 -8
- package/src/lib/timeline.ts +74 -34
- package/src/lib/tiptap-render.ts +5 -10
- package/src/lib/tiptap-to-markdown.ts +305 -0
- package/src/lib/upload.ts +190 -29
- package/src/lib/url.ts +31 -0
- package/src/lib/view.ts +204 -37
- package/src/middleware/__tests__/auth.test.ts +191 -11
- package/src/middleware/__tests__/onboarding.test.ts +12 -10
- package/src/middleware/auth.ts +63 -9
- package/src/middleware/onboarding.ts +1 -1
- package/src/middleware/secure-headers.ts +40 -0
- package/src/preset.css +45 -2
- package/src/routes/__tests__/compose.test.ts +17 -24
- package/src/routes/api/__tests__/collections.test.ts +109 -61
- package/src/routes/api/__tests__/nav-items.test.ts +46 -29
- package/src/routes/api/__tests__/posts.test.ts +132 -68
- package/src/routes/api/__tests__/search.test.ts +15 -2
- package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
- package/src/routes/api/collections.ts +51 -42
- package/src/routes/api/custom-urls.ts +80 -0
- package/src/routes/api/export.ts +31 -0
- package/src/routes/api/nav-items.ts +23 -19
- package/src/routes/api/posts.ts +43 -39
- package/src/routes/api/search.ts +3 -4
- package/src/routes/api/upload-multipart.ts +245 -0
- package/src/routes/api/upload.ts +85 -19
- package/src/routes/auth/__tests__/setup.test.ts +20 -60
- package/src/routes/auth/setup.tsx +26 -33
- package/src/routes/auth/signin.tsx +3 -7
- package/src/routes/compose.tsx +10 -55
- package/src/routes/dash/__tests__/settings-avatar.test.ts +1 -1
- package/src/routes/dash/custom-urls.tsx +414 -0
- package/src/routes/dash/settings.tsx +304 -232
- package/src/routes/feed/__tests__/rss.test.ts +27 -28
- package/src/routes/feed/rss.ts +6 -4
- package/src/routes/feed/sitemap.ts +2 -12
- package/src/routes/pages/__tests__/collections.test.ts +5 -6
- package/src/routes/pages/__tests__/featured.test.ts +41 -22
- package/src/routes/pages/archive.tsx +175 -39
- package/src/routes/pages/collection.tsx +22 -10
- package/src/routes/pages/collections.tsx +3 -3
- package/src/routes/pages/featured.tsx +28 -4
- package/src/routes/pages/home.tsx +16 -15
- package/src/routes/pages/latest.tsx +1 -11
- package/src/routes/pages/new.tsx +39 -0
- package/src/routes/pages/page.tsx +95 -49
- package/src/routes/pages/search.tsx +1 -1
- package/src/services/__tests__/api-token.test.ts +135 -0
- package/src/services/__tests__/collection.test.ts +275 -227
- package/src/services/__tests__/custom-url.test.ts +213 -0
- package/src/services/__tests__/media.test.ts +162 -22
- package/src/services/__tests__/navigation.test.ts +109 -68
- package/src/services/__tests__/post-timeline.test.ts +205 -32
- package/src/services/__tests__/post.test.ts +713 -234
- package/src/services/__tests__/search.test.ts +67 -10
- package/src/services/api-token.ts +166 -0
- package/src/services/auth.ts +17 -2
- package/src/services/collection.ts +397 -131
- package/src/services/custom-url.ts +188 -0
- package/src/services/export.ts +802 -0
- package/src/services/index.ts +26 -19
- package/src/services/media.ts +100 -22
- package/src/services/navigation.ts +158 -47
- package/src/services/path.ts +339 -0
- package/src/services/post.ts +687 -154
- package/src/services/search.ts +160 -75
- package/src/styles/components.css +58 -7
- package/src/styles/tokens.css +84 -6
- package/src/styles/ui.css +2964 -457
- package/src/types/bindings.ts +4 -1
- package/src/types/config.ts +12 -4
- package/src/types/constants.ts +15 -3
- package/src/types/entities.ts +74 -35
- package/src/types/operations.ts +11 -24
- package/src/types/props.ts +51 -16
- package/src/types/views.ts +45 -22
- package/src/ui/color-themes.ts +133 -23
- package/src/ui/compose/ComposeDialog.tsx +239 -17
- package/src/ui/compose/ComposePrompt.tsx +1 -1
- package/src/ui/dash/CrudPageHeader.tsx +1 -1
- package/src/ui/dash/ListItemRow.tsx +1 -1
- package/src/ui/dash/StatusBadge.tsx +3 -1
- package/src/ui/dash/appearance/AdvancedContent.tsx +22 -1
- package/src/ui/dash/appearance/ColorThemeContent.tsx +22 -2
- package/src/ui/dash/appearance/FontThemeContent.tsx +1 -1
- package/src/ui/dash/appearance/NavigationContent.tsx +5 -45
- package/src/ui/dash/index.ts +0 -3
- package/src/ui/dash/settings/AccountContent.tsx +3 -57
- package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
- package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
- package/src/ui/dash/settings/AvatarContent.tsx +8 -0
- package/src/ui/dash/settings/SessionsContent.tsx +159 -0
- package/src/ui/dash/settings/SettingsRootContent.tsx +45 -15
- package/src/ui/feed/LinkCard.tsx +89 -40
- package/src/ui/feed/NoteCard.tsx +39 -25
- package/src/ui/feed/PostStatusBadges.tsx +67 -0
- package/src/ui/feed/QuoteCard.tsx +38 -23
- package/src/ui/feed/ThreadPreview.tsx +90 -26
- package/src/ui/feed/TimelineFeed.tsx +3 -2
- package/src/ui/feed/TimelineItem.tsx +15 -6
- package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
- package/src/ui/feed/thread-preview-state.ts +61 -0
- package/src/ui/font-themes.ts +2 -2
- package/src/ui/layouts/BaseLayout.tsx +2 -2
- package/src/ui/layouts/SiteLayout.tsx +105 -92
- package/src/ui/pages/ArchivePage.tsx +923 -98
- package/src/ui/pages/ComposePage.tsx +54 -0
- package/src/ui/pages/PostPage.tsx +30 -45
- package/src/ui/pages/SearchPage.tsx +181 -37
- package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
- package/src/ui/shared/CollectionsSidebar.tsx +47 -37
- package/src/ui/shared/MediaGallery.tsx +445 -149
- package/src/ui/shared/PostFooter.tsx +204 -0
- package/src/ui/shared/StarRating.tsx +27 -0
- package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
- package/src/ui/shared/index.ts +0 -1
- package/dist/client/assets/url-8Dj-5CLW.js +0 -1
- package/src/client/media-upload.ts +0 -161
- package/src/client/page-slug-bridge.ts +0 -42
- package/src/db/migrations/0000_square_wallflower.sql +0 -118
- package/src/db/migrations/0001_add_search_fts.sql +0 -34
- package/src/db/migrations/0002_add_media_attachments.sql +0 -3
- package/src/db/migrations/0003_add_navigation_links.sql +0 -8
- package/src/db/migrations/0004_add_storage_provider.sql +0 -3
- package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
- package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
- package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
- package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
- package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
- package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
- package/src/db/migrations/0011_add_path_registry.sql +0 -23
- package/src/db/migrations/0012_add_tiptap_columns.sql +0 -2
- package/src/db/migrations/0013_replace_featured_with_visibility.sql +0 -8
- package/src/db/migrations/meta/0003_snapshot.json +0 -821
- package/src/lib/__tests__/sqid.test.ts +0 -65
- package/src/lib/sqid.ts +0 -79
- package/src/routes/api/__tests__/pages.test.ts +0 -218
- package/src/routes/api/pages.ts +0 -73
- package/src/routes/dash/__tests__/pages.test.ts +0 -226
- package/src/routes/dash/index.tsx +0 -109
- package/src/routes/dash/media.tsx +0 -135
- package/src/routes/dash/pages.tsx +0 -245
- package/src/routes/dash/posts.tsx +0 -338
- package/src/routes/dash/redirects.tsx +0 -263
- package/src/routes/pages/post.tsx +0 -59
- package/src/services/__tests__/page.test.ts +0 -298
- package/src/services/__tests__/path-registry.test.ts +0 -165
- package/src/services/__tests__/redirect.test.ts +0 -159
- package/src/services/page.ts +0 -216
- package/src/services/path-registry.ts +0 -160
- package/src/services/redirect.ts +0 -97
- package/src/ui/dash/PageForm.tsx +0 -187
- package/src/ui/dash/PostList.tsx +0 -95
- package/src/ui/dash/media/MediaListContent.tsx +0 -206
- package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
- package/src/ui/dash/pages/PagesContent.tsx +0 -75
- package/src/ui/dash/posts/PostForm.tsx +0 -260
- package/src/ui/layouts/DashLayout.tsx +0 -247
- package/src/ui/pages/SinglePage.tsx +0 -23
- 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:
|
|
40
|
+
id: UUID_1,
|
|
33
41
|
format: "note",
|
|
34
42
|
status: "published",
|
|
35
|
-
visibility: "
|
|
36
|
-
|
|
37
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
100
|
+
id: UUID_NAV_1,
|
|
86
101
|
type: "link",
|
|
87
102
|
label: "Home",
|
|
88
103
|
url: "/",
|
|
89
|
-
|
|
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
|
|
103
|
-
const post = makePostWithMedia({ id:
|
|
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.
|
|
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
|
|
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: "
|
|
245
|
-
|
|
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("
|
|
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
|
|
259
|
+
it("maps default visibility and pinnedAt=null", () => {
|
|
256
260
|
const view = toPostView(
|
|
257
261
|
makePostWithMedia({
|
|
258
|
-
visibility: "
|
|
259
|
-
|
|
262
|
+
visibility: "public",
|
|
263
|
+
pinnedAt: null,
|
|
260
264
|
}),
|
|
261
265
|
EMPTY_CTX,
|
|
262
266
|
);
|
|
263
|
-
expect(view.visibility).toBe("
|
|
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:
|
|
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 = [
|
|
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",
|
|
315
|
-
expect(views[1]).toHaveProperty("id",
|
|
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
|
|
351
|
-
const view = toMediaView(
|
|
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
|
|
360
|
-
const media = makeMedia({
|
|
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
|
|
416
|
-
const view = toNavItemView(makeNavItem({ type: "
|
|
417
|
-
expect(view.type).toBe("
|
|
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:
|
|
431
|
-
makeNavItem({ id:
|
|
432
|
-
makeNavItem({ id:
|
|
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:
|
|
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(
|
|
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:
|
|
537
|
+
id: UUID_POST,
|
|
465
538
|
format: "link",
|
|
466
539
|
status: "published",
|
|
467
|
-
visibility: "
|
|
468
|
-
|
|
540
|
+
visibility: "public",
|
|
541
|
+
pinnedAt: null,
|
|
542
|
+
featuredAt: 1706745600,
|
|
469
543
|
url: "https://example.com",
|
|
470
|
-
|
|
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("
|
|
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:
|
|
493
|
-
makePost({ id:
|
|
567
|
+
makePost({ id: UUID_1, publishedAt: 1706745600 }),
|
|
568
|
+
makePost({ id: UUID_2, publishedAt: 1706832000 }),
|
|
494
569
|
]);
|
|
495
|
-
grouped.set("2024-01", [makePost({ id:
|
|
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:
|
|
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
|
+
}
|
package/src/lib/constants.ts
CHANGED
|
@@ -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",
|