@jant/core 0.3.23 → 0.3.25
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/dist/app.js +50 -26
- package/dist/db/schema.js +72 -47
- package/dist/i18n/locales/en.js +1 -1
- package/dist/i18n/locales/zh-Hans.js +1 -1
- package/dist/i18n/locales/zh-Hant.js +1 -1
- package/dist/index.js +5 -11
- package/dist/lib/constants.js +2 -4
- package/dist/lib/excerpt.js +76 -0
- package/dist/lib/feed.js +18 -7
- package/dist/lib/nav-reorder.js +1 -1
- package/dist/lib/navigation.js +30 -6
- package/dist/lib/pagination.js +44 -0
- package/dist/lib/render.js +7 -11
- package/dist/lib/schemas.js +80 -38
- package/dist/lib/theme.js +4 -4
- package/dist/lib/time.js +56 -1
- package/dist/lib/timeline.js +95 -0
- package/dist/lib/view.js +61 -72
- package/dist/routes/api/collections.js +124 -0
- package/dist/routes/api/nav-items.js +104 -0
- package/dist/routes/api/pages.js +91 -0
- package/dist/routes/api/posts.js +27 -33
- package/dist/routes/api/search.js +4 -5
- package/dist/routes/api/settings.js +68 -0
- package/dist/routes/api/upload.js +13 -13
- package/dist/routes/compose.js +48 -0
- package/dist/routes/dash/collections.js +24 -42
- package/dist/routes/dash/index.js +3 -3
- package/dist/routes/dash/media.js +2 -2
- package/dist/routes/dash/pages.js +440 -106
- package/dist/routes/dash/posts.js +27 -37
- package/dist/routes/dash/redirects.js +2 -2
- package/dist/routes/dash/settings.js +79 -5
- package/dist/routes/feed/rss.js +4 -6
- package/dist/routes/feed/sitemap.js +11 -8
- package/dist/routes/pages/archive.js +13 -15
- package/dist/routes/pages/collection.js +12 -9
- package/dist/routes/pages/collections.js +28 -0
- package/dist/routes/pages/featured.js +32 -0
- package/dist/routes/pages/home.js +19 -68
- package/dist/routes/pages/page.js +57 -29
- package/dist/routes/pages/post.js +7 -17
- package/dist/routes/pages/search.js +5 -9
- package/dist/services/collection.js +52 -64
- package/dist/services/index.js +5 -3
- package/dist/services/navigation.js +29 -53
- package/dist/services/page.js +84 -0
- package/dist/services/post.js +102 -69
- package/dist/services/search.js +24 -18
- package/dist/types.js +24 -40
- package/dist/ui/compose/ComposeDialog.js +452 -0
- package/dist/ui/compose/ComposePrompt.js +55 -0
- package/dist/{theme/components/TypeBadge.js → ui/dash/FormatBadge.js} +3 -15
- package/dist/{theme/components → ui/dash}/PageForm.js +15 -15
- package/dist/{theme/components → ui/dash}/PostForm.js +117 -137
- package/dist/{theme/components → ui/dash}/PostList.js +18 -13
- package/dist/ui/dash/StatusBadge.js +46 -0
- package/dist/{theme/components → ui/dash}/index.js +3 -6
- package/dist/ui/feed/LinkCard.js +72 -0
- package/dist/ui/feed/NoteCard.js +58 -0
- package/dist/{themes/minimal/timeline → ui/feed}/QuoteCard.js +29 -14
- package/dist/{themes/minimal/timeline → ui/feed}/ThreadPreview.js +20 -18
- package/dist/ui/feed/TimelineFeed.js +41 -0
- package/dist/ui/feed/TimelineItem.js +27 -0
- package/dist/{theme → ui}/layouts/BaseLayout.js +10 -0
- package/dist/{theme → ui}/layouts/DashLayout.js +0 -8
- package/dist/ui/layouts/SiteLayout.js +141 -0
- package/dist/{themes/minimal → ui}/pages/ArchivePage.js +37 -50
- package/dist/ui/pages/CollectionPage.js +70 -0
- package/dist/ui/pages/CollectionsPage.js +76 -0
- package/dist/ui/pages/FeaturedPage.js +24 -0
- package/dist/ui/pages/HomePage.js +24 -0
- package/dist/{themes/minimal → ui}/pages/PostPage.js +20 -12
- package/dist/{themes/minimal → ui}/pages/SearchPage.js +19 -18
- package/dist/{themes/minimal → ui}/pages/SinglePage.js +5 -4
- package/dist/ui/shared/MediaGallery.js +35 -0
- package/dist/{theme/components → ui/shared}/Pagination.js +41 -2
- package/dist/{theme/components → ui/shared}/ThreadView.js +3 -3
- package/dist/ui/shared/index.js +5 -0
- package/package.json +2 -9
- package/src/__tests__/helpers/app.ts +4 -0
- package/src/__tests__/helpers/db.ts +53 -73
- package/src/app.tsx +56 -28
- package/src/db/migrations/0005_v2_schema_migration.sql +268 -0
- package/src/db/migrations/0006_rename_slug_to_path.sql +5 -0
- package/src/db/migrations/meta/_journal.json +14 -0
- package/src/db/schema.ts +63 -46
- package/src/i18n/locales/en.po +443 -240
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +443 -240
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +443 -240
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/index.ts +29 -42
- package/src/lib/__tests__/excerpt.test.ts +125 -0
- package/src/lib/__tests__/schemas.test.ts +201 -99
- package/src/lib/__tests__/time.test.ts +62 -0
- package/src/{routes/api → lib}/__tests__/timeline.test.ts +81 -75
- package/src/lib/__tests__/view.test.ts +204 -50
- package/src/lib/constants.ts +2 -4
- package/src/lib/excerpt.ts +87 -0
- package/src/lib/feed.ts +22 -7
- package/src/lib/nav-reorder.ts +1 -1
- package/src/lib/navigation.ts +45 -8
- package/src/lib/pagination.ts +50 -0
- package/src/lib/render.tsx +7 -14
- package/src/lib/schemas.ts +119 -51
- package/src/lib/theme.ts +5 -5
- package/src/lib/time.ts +64 -0
- package/src/lib/timeline.ts +141 -0
- package/src/lib/view.ts +80 -82
- package/src/preset.css +46 -0
- package/src/routes/__tests__/compose.test.ts +199 -0
- package/src/routes/api/__tests__/collections.test.ts +249 -0
- package/src/routes/api/__tests__/nav-items.test.ts +222 -0
- package/src/routes/api/__tests__/pages.test.ts +218 -0
- package/src/routes/api/__tests__/posts.test.ts +50 -108
- package/src/routes/api/__tests__/search.test.ts +2 -3
- package/src/routes/api/__tests__/settings.test.ts +132 -0
- package/src/routes/api/collections.ts +143 -0
- package/src/routes/api/nav-items.ts +115 -0
- package/src/routes/api/pages.ts +101 -0
- package/src/routes/api/posts.ts +28 -28
- package/src/routes/api/search.ts +3 -3
- package/src/routes/api/settings.ts +91 -0
- package/src/routes/api/upload.ts +16 -6
- package/src/routes/compose.ts +63 -0
- package/src/routes/dash/__tests__/pages.test.ts +225 -0
- package/src/routes/dash/collections.tsx +20 -42
- package/src/routes/dash/index.tsx +3 -3
- package/src/routes/dash/media.tsx +2 -2
- package/src/routes/dash/pages.tsx +480 -122
- package/src/routes/dash/posts.tsx +42 -54
- package/src/routes/dash/redirects.tsx +2 -2
- package/src/routes/dash/settings.tsx +83 -5
- package/src/routes/feed/rss.ts +4 -3
- package/src/routes/feed/sitemap.ts +15 -5
- package/src/routes/pages/__tests__/collections.test.ts +94 -0
- package/src/routes/pages/__tests__/featured.test.ts +94 -0
- package/src/routes/pages/archive.tsx +15 -15
- package/src/routes/pages/collection.tsx +16 -9
- package/src/routes/pages/collections.tsx +36 -0
- package/src/routes/pages/featured.tsx +38 -0
- package/src/routes/pages/home.tsx +21 -92
- package/src/routes/pages/page.tsx +62 -27
- package/src/routes/pages/post.tsx +6 -18
- package/src/routes/pages/search.tsx +3 -7
- package/src/services/__tests__/collection.test.ts +257 -158
- package/src/services/__tests__/media.test.ts +18 -18
- package/src/services/__tests__/navigation.test.ts +161 -87
- package/src/services/__tests__/page.test.ts +106 -0
- package/src/services/__tests__/post-timeline.test.ts +92 -88
- package/src/services/__tests__/post.test.ts +432 -197
- package/src/services/__tests__/search.test.ts +19 -25
- package/src/services/collection.ts +71 -113
- package/src/services/index.ts +9 -8
- package/src/services/navigation.ts +38 -71
- package/src/services/page.ts +136 -0
- package/src/services/post.ts +141 -101
- package/src/services/search.ts +38 -27
- package/src/styles/tokens.css +47 -0
- package/src/styles/ui.css +491 -0
- package/src/types.ts +212 -198
- package/src/ui/compose/ComposeDialog.tsx +395 -0
- package/src/ui/compose/ComposePrompt.tsx +55 -0
- package/src/ui/dash/FormatBadge.tsx +28 -0
- package/src/{theme/components → ui/dash}/PageForm.tsx +21 -21
- package/src/{theme/components → ui/dash}/PostForm.tsx +110 -131
- package/src/ui/dash/PostList.tsx +101 -0
- package/src/ui/dash/StatusBadge.tsx +61 -0
- package/src/ui/dash/index.ts +10 -0
- package/src/ui/feed/LinkCard.tsx +72 -0
- package/src/ui/feed/NoteCard.tsx +63 -0
- package/src/ui/feed/QuoteCard.tsx +68 -0
- package/src/ui/feed/ThreadPreview.tsx +48 -0
- package/src/ui/feed/TimelineFeed.tsx +49 -0
- package/src/ui/feed/TimelineItem.tsx +45 -0
- package/src/{theme → ui}/layouts/BaseLayout.tsx +11 -1
- package/src/{theme → ui}/layouts/DashLayout.tsx +0 -10
- package/src/ui/layouts/SiteLayout.tsx +150 -0
- package/src/ui/pages/ArchivePage.tsx +162 -0
- package/src/ui/pages/CollectionPage.tsx +70 -0
- package/src/ui/pages/CollectionsPage.tsx +73 -0
- package/src/ui/pages/FeaturedPage.tsx +31 -0
- package/src/ui/pages/HomePage.tsx +37 -0
- package/src/ui/pages/PostPage.tsx +56 -0
- package/src/{themes/minimal → ui}/pages/SearchPage.tsx +24 -20
- package/src/{themes/minimal → ui}/pages/SinglePage.tsx +5 -5
- package/src/ui/shared/MediaGallery.tsx +59 -0
- package/src/{theme/components → ui/shared}/Pagination.tsx +67 -4
- package/src/{theme/components → ui/shared}/ThreadView.tsx +6 -3
- package/src/ui/shared/__tests__/pagination.test.ts +46 -0
- package/src/ui/shared/index.ts +12 -0
- package/bin/jant.js +0 -185
- package/dist/lib/theme-components.js +0 -49
- package/dist/routes/api/timeline.js +0 -120
- package/dist/routes/dash/navigation.js +0 -288
- package/dist/theme/components/MediaGallery.js +0 -107
- package/dist/theme/components/VisibilityBadge.js +0 -37
- package/dist/theme/index.js +0 -18
- package/dist/theme/layouts/index.js +0 -2
- package/dist/themes/minimal/MinimalSiteLayout.js +0 -83
- package/dist/themes/minimal/index.js +0 -65
- package/dist/themes/minimal/pages/CollectionPage.js +0 -65
- package/dist/themes/minimal/pages/HomePage.js +0 -25
- package/dist/themes/minimal/timeline/ArticleCard.js +0 -36
- package/dist/themes/minimal/timeline/ImageCard.js +0 -67
- package/dist/themes/minimal/timeline/LinkCard.js +0 -47
- package/dist/themes/minimal/timeline/NoteCard.js +0 -34
- package/dist/themes/minimal/timeline/TimelineFeed.js +0 -48
- package/dist/themes/minimal/timeline/TimelineItem.js +0 -44
- package/src/lib/__tests__/theme-components.test.ts +0 -126
- package/src/lib/theme-components.ts +0 -68
- package/src/routes/api/timeline.tsx +0 -159
- package/src/routes/dash/navigation.tsx +0 -316
- package/src/theme/components/MediaGallery.tsx +0 -128
- package/src/theme/components/PostList.tsx +0 -92
- package/src/theme/components/TypeBadge.tsx +0 -37
- package/src/theme/components/VisibilityBadge.tsx +0 -45
- package/src/theme/components/index.ts +0 -23
- package/src/theme/index.ts +0 -22
- package/src/theme/layouts/index.ts +0 -7
- package/src/themes/minimal/MinimalSiteLayout.tsx +0 -100
- package/src/themes/minimal/index.ts +0 -83
- package/src/themes/minimal/pages/ArchivePage.tsx +0 -157
- package/src/themes/minimal/pages/CollectionPage.tsx +0 -60
- package/src/themes/minimal/pages/HomePage.tsx +0 -41
- package/src/themes/minimal/pages/PostPage.tsx +0 -43
- package/src/themes/minimal/timeline/ArticleCard.tsx +0 -37
- package/src/themes/minimal/timeline/ImageCard.tsx +0 -63
- package/src/themes/minimal/timeline/LinkCard.tsx +0 -48
- package/src/themes/minimal/timeline/NoteCard.tsx +0 -35
- package/src/themes/minimal/timeline/QuoteCard.tsx +0 -49
- package/src/themes/minimal/timeline/ThreadPreview.tsx +0 -47
- package/src/themes/minimal/timeline/TimelineFeed.tsx +0 -57
- package/src/themes/minimal/timeline/TimelineItem.tsx +0 -75
- /package/dist/{theme → ui}/color-themes.js +0 -0
- /package/dist/{theme/components → ui/dash}/ActionButtons.js +0 -0
- /package/dist/{theme/components → ui/dash}/CrudPageHeader.js +0 -0
- /package/dist/{theme/components → ui/dash}/DangerZone.js +0 -0
- /package/dist/{theme/components → ui/dash}/ListItemRow.js +0 -0
- /package/dist/{theme/components → ui/shared}/EmptyState.js +0 -0
- /package/src/{theme → ui}/color-themes.ts +0 -0
- /package/src/{theme/components → ui/dash}/ActionButtons.tsx +0 -0
- /package/src/{theme/components → ui/dash}/CrudPageHeader.tsx +0 -0
- /package/src/{theme/components → ui/dash}/DangerZone.tsx +0 -0
- /package/src/{theme/components → ui/dash}/ListItemRow.tsx +0 -0
- /package/src/{theme/components → ui/shared}/EmptyState.tsx +0 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timeline Data Assembly
|
|
3
|
+
*
|
|
4
|
+
* Shared helper for assembling timeline items with media and thread previews.
|
|
5
|
+
* Used by page rendering with page-based pagination.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Context } from "hono";
|
|
9
|
+
import type { Bindings, TimelineItemView } from "../types.js";
|
|
10
|
+
import type { AppVariables } from "../app.js";
|
|
11
|
+
import { buildMediaMap } from "./media-helpers.js";
|
|
12
|
+
import { createMediaContext, toPostView, toPostViews } from "./view.js";
|
|
13
|
+
|
|
14
|
+
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
15
|
+
|
|
16
|
+
const DEFAULT_PAGE_SIZE = 20;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Result from assembling a timeline page.
|
|
20
|
+
*/
|
|
21
|
+
export interface TimelineResult {
|
|
22
|
+
items: TimelineItemView[];
|
|
23
|
+
currentPage: number;
|
|
24
|
+
totalPages: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Assembles a page of timeline items with media attachments and thread previews.
|
|
29
|
+
*
|
|
30
|
+
* Fetches posts using offset-based pagination, batch-loads media, identifies
|
|
31
|
+
* threads, and returns render-ready `TimelineItemView[]` with page info.
|
|
32
|
+
*
|
|
33
|
+
* @param c - Hono context (provides services + env)
|
|
34
|
+
* @param options - Optional page number (1-indexed, defaults to 1)
|
|
35
|
+
* @returns Assembled timeline items with pagination info
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```ts
|
|
39
|
+
* const { items, currentPage, totalPages } = await assembleTimeline(c);
|
|
40
|
+
* const { items, currentPage, totalPages } = await assembleTimeline(c, { page: 2 });
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export async function assembleTimeline(
|
|
44
|
+
c: Context<Env>,
|
|
45
|
+
options?: { page?: number },
|
|
46
|
+
): Promise<TimelineResult> {
|
|
47
|
+
const pageSize =
|
|
48
|
+
parseInt(c.env.PAGE_SIZE ?? String(DEFAULT_PAGE_SIZE), 10) ||
|
|
49
|
+
DEFAULT_PAGE_SIZE;
|
|
50
|
+
|
|
51
|
+
const page = Math.max(1, options?.page ?? 1);
|
|
52
|
+
const offset = (page - 1) * pageSize;
|
|
53
|
+
|
|
54
|
+
// Get total count for pagination
|
|
55
|
+
const totalCount = await c.var.services.posts.count({
|
|
56
|
+
status: "published",
|
|
57
|
+
excludeReplies: true,
|
|
58
|
+
});
|
|
59
|
+
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
|
60
|
+
|
|
61
|
+
// Fetch posts for the current page
|
|
62
|
+
const posts = await c.var.services.posts.list({
|
|
63
|
+
status: "published",
|
|
64
|
+
excludeReplies: true,
|
|
65
|
+
limit: pageSize,
|
|
66
|
+
offset,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
if (posts.length === 0) {
|
|
70
|
+
return { items: [], currentPage: page, totalPages };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Batch load media attachments
|
|
74
|
+
const postIds = posts.map((p) => p.id);
|
|
75
|
+
const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
|
|
76
|
+
const mediaCtx = createMediaContext(c);
|
|
77
|
+
const mediaMap = buildMediaMap(
|
|
78
|
+
rawMediaMap,
|
|
79
|
+
mediaCtx.r2PublicUrl,
|
|
80
|
+
mediaCtx.imageTransformUrl,
|
|
81
|
+
mediaCtx.s3PublicUrl,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// Get reply counts to identify thread roots
|
|
85
|
+
const replyCounts = await c.var.services.posts.getReplyCounts(postIds);
|
|
86
|
+
const threadRootIds = postIds.filter((id) => (replyCounts.get(id) ?? 0) > 0);
|
|
87
|
+
|
|
88
|
+
// Batch load thread previews
|
|
89
|
+
const threadPreviews = await c.var.services.posts.getThreadPreviews(
|
|
90
|
+
threadRootIds,
|
|
91
|
+
3,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// Batch load media for preview replies
|
|
95
|
+
const previewReplyIds: number[] = [];
|
|
96
|
+
for (const replies of threadPreviews.values()) {
|
|
97
|
+
for (const reply of replies) {
|
|
98
|
+
previewReplyIds.push(reply.id);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const previewMediaMap =
|
|
102
|
+
previewReplyIds.length > 0
|
|
103
|
+
? buildMediaMap(
|
|
104
|
+
await c.var.services.media.getByPostIds(previewReplyIds),
|
|
105
|
+
mediaCtx.r2PublicUrl,
|
|
106
|
+
mediaCtx.imageTransformUrl,
|
|
107
|
+
mediaCtx.s3PublicUrl,
|
|
108
|
+
)
|
|
109
|
+
: new Map();
|
|
110
|
+
|
|
111
|
+
// Assemble timeline items with View Models
|
|
112
|
+
const items: TimelineItemView[] = posts.map((post) => {
|
|
113
|
+
const postView = toPostView(
|
|
114
|
+
{ ...post, mediaAttachments: mediaMap.get(post.id) ?? [] },
|
|
115
|
+
mediaCtx,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const replyCount = replyCounts.get(post.id) ?? 0;
|
|
119
|
+
const previewReplies = threadPreviews.get(post.id);
|
|
120
|
+
|
|
121
|
+
if (replyCount > 0 && previewReplies) {
|
|
122
|
+
return {
|
|
123
|
+
post: postView,
|
|
124
|
+
threadPreview: {
|
|
125
|
+
replies: toPostViews(
|
|
126
|
+
previewReplies.map((r) => ({
|
|
127
|
+
...r,
|
|
128
|
+
mediaAttachments: previewMediaMap.get(r.id) ?? [],
|
|
129
|
+
})),
|
|
130
|
+
mediaCtx,
|
|
131
|
+
),
|
|
132
|
+
totalReplyCount: replyCount,
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { post: postView };
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
return { items, currentPage: page, totalPages };
|
|
141
|
+
}
|
package/src/lib/view.ts
CHANGED
|
@@ -1,33 +1,44 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* View Model Conversions
|
|
2
|
+
* View Model Conversions (v2)
|
|
3
3
|
*
|
|
4
4
|
* Transforms raw database models into render-ready View types.
|
|
5
|
-
* Theme components receive only View types
|
|
5
|
+
* Theme components receive only View types -- no lib/ imports needed.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { Context } from "hono";
|
|
9
9
|
import type {
|
|
10
10
|
Post,
|
|
11
11
|
PostWithMedia,
|
|
12
|
+
Page,
|
|
12
13
|
Media,
|
|
13
14
|
MediaView,
|
|
14
15
|
PostView,
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
PageView,
|
|
17
|
+
NavItemView,
|
|
18
|
+
NavItem,
|
|
17
19
|
SearchResult,
|
|
18
20
|
SearchResultView,
|
|
19
21
|
ArchiveGroup,
|
|
22
|
+
Format,
|
|
23
|
+
Status,
|
|
24
|
+
NavItemType,
|
|
20
25
|
} from "../types.js";
|
|
21
26
|
import { encode } from "./sqid.js";
|
|
22
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
toISOString,
|
|
29
|
+
formatDate,
|
|
30
|
+
formatTime,
|
|
31
|
+
formatRelativeTime,
|
|
32
|
+
} from "./time.js";
|
|
23
33
|
import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "./image.js";
|
|
34
|
+
import { getHtmlExcerpt } from "./excerpt.js";
|
|
24
35
|
|
|
25
36
|
// =============================================================================
|
|
26
37
|
// Media Context
|
|
27
38
|
// =============================================================================
|
|
28
39
|
|
|
29
40
|
/**
|
|
30
|
-
* Central media config
|
|
41
|
+
* Central media config -- extracted once per request from env.
|
|
31
42
|
*/
|
|
32
43
|
export interface MediaContext {
|
|
33
44
|
r2PublicUrl?: string;
|
|
@@ -40,12 +51,6 @@ export interface MediaContext {
|
|
|
40
51
|
*
|
|
41
52
|
* @param c - Hono context
|
|
42
53
|
* @returns MediaContext with env values
|
|
43
|
-
*
|
|
44
|
-
* @example
|
|
45
|
-
* ```ts
|
|
46
|
-
* const mediaCtx = createMediaContext(c);
|
|
47
|
-
* const postView = toPostView(post, mediaCtx);
|
|
48
|
-
* ```
|
|
49
54
|
*/
|
|
50
55
|
export function createMediaContext(c: Context): MediaContext {
|
|
51
56
|
return {
|
|
@@ -100,25 +105,26 @@ export function toMediaView(media: Media, ctx: MediaContext): MediaView {
|
|
|
100
105
|
* Converts a PostWithMedia to a render-ready PostView.
|
|
101
106
|
*
|
|
102
107
|
* @param post - Post with media attachments from database
|
|
103
|
-
* @param
|
|
108
|
+
* @param _ctx - Media context with URL configuration
|
|
104
109
|
* @returns Render-ready PostView with pre-computed fields
|
|
105
|
-
*
|
|
106
|
-
* @example
|
|
107
|
-
* ```ts
|
|
108
|
-
* const mediaCtx = createMediaContext(c);
|
|
109
|
-
* const postView = toPostView({ ...post, mediaAttachments: [...] }, mediaCtx);
|
|
110
|
-
* ```
|
|
111
110
|
*/
|
|
112
111
|
export function toPostView(post: PostWithMedia, _ctx: MediaContext): PostView {
|
|
113
|
-
const permalink = `/p/${encode(post.id)}`;
|
|
112
|
+
const permalink = post.path ? `/${post.path}` : `/p/${encode(post.id)}`;
|
|
114
113
|
|
|
115
|
-
// Pre-compute excerpt from raw
|
|
114
|
+
// Pre-compute excerpt from raw body
|
|
116
115
|
let excerpt: string | undefined;
|
|
117
|
-
if (post.
|
|
116
|
+
if (post.body) {
|
|
118
117
|
excerpt =
|
|
119
|
-
post.
|
|
120
|
-
|
|
121
|
-
|
|
118
|
+
post.body.length > 160 ? post.body.slice(0, 160) + "..." : post.body;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Pre-compute HTML summary for article-style posts (with title)
|
|
122
|
+
let summaryHtml: string | undefined;
|
|
123
|
+
let summaryHasMore: boolean | undefined;
|
|
124
|
+
if (post.title && post.bodyHtml) {
|
|
125
|
+
const result = getHtmlExcerpt(post.bodyHtml);
|
|
126
|
+
summaryHtml = result.excerpt;
|
|
127
|
+
summaryHasMore = result.hasMore;
|
|
122
128
|
}
|
|
123
129
|
|
|
124
130
|
// Convert media attachments
|
|
@@ -135,31 +141,34 @@ export function toPostView(post: PostWithMedia, _ctx: MediaContext): PostView {
|
|
|
135
141
|
return {
|
|
136
142
|
id: post.id,
|
|
137
143
|
permalink,
|
|
144
|
+
path: post.path ?? undefined,
|
|
138
145
|
title: post.title ?? undefined,
|
|
139
|
-
|
|
146
|
+
bodyHtml: post.bodyHtml ?? undefined,
|
|
140
147
|
excerpt,
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
148
|
+
summaryHtml,
|
|
149
|
+
summaryHasMore,
|
|
150
|
+
url: post.url ?? undefined,
|
|
151
|
+
quoteText: post.quoteText ?? undefined,
|
|
152
|
+
format: post.format as Format,
|
|
153
|
+
status: post.status as Status,
|
|
154
|
+
featured: post.featured === 1,
|
|
155
|
+
pinned: post.pinned === 1,
|
|
156
|
+
rating: post.rating ?? undefined,
|
|
157
|
+
collectionId: post.collectionId ?? undefined,
|
|
144
158
|
publishedAt: toISOString(post.publishedAt),
|
|
145
159
|
publishedAtFormatted: formatDate(post.publishedAt),
|
|
160
|
+
publishedAtTime: formatTime(post.publishedAt),
|
|
161
|
+
publishedAtRelative: formatRelativeTime(post.publishedAt),
|
|
146
162
|
updatedAt: toISOString(post.updatedAt),
|
|
147
|
-
sourceUrl: post.sourceUrl ?? undefined,
|
|
148
|
-
sourceName: post.sourceName ?? undefined,
|
|
149
|
-
sourceDomain: post.sourceDomain ?? undefined,
|
|
150
163
|
media,
|
|
151
164
|
replyToId: post.replyToId ?? undefined,
|
|
152
165
|
threadRootId: post.threadId ?? undefined,
|
|
153
|
-
|
|
166
|
+
body: post.body ?? undefined,
|
|
154
167
|
};
|
|
155
168
|
}
|
|
156
169
|
|
|
157
170
|
/**
|
|
158
171
|
* Batch converts PostWithMedia[] to PostView[].
|
|
159
|
-
*
|
|
160
|
-
* @param posts - Array of posts with media
|
|
161
|
-
* @param ctx - Media context
|
|
162
|
-
* @returns Array of PostView
|
|
163
172
|
*/
|
|
164
173
|
export function toPostViews(
|
|
165
174
|
posts: PostWithMedia[],
|
|
@@ -170,10 +179,6 @@ export function toPostViews(
|
|
|
170
179
|
|
|
171
180
|
/**
|
|
172
181
|
* Converts a bare Post (no media) to a PostView with empty media array.
|
|
173
|
-
*
|
|
174
|
-
* @param post - Post without media
|
|
175
|
-
* @param ctx - Media context (unused but kept for consistency)
|
|
176
|
-
* @returns PostView with empty media
|
|
177
182
|
*/
|
|
178
183
|
export function toPostViewFromPost(post: Post, ctx: MediaContext): PostView {
|
|
179
184
|
return toPostView({ ...post, mediaAttachments: [] }, ctx);
|
|
@@ -181,10 +186,6 @@ export function toPostViewFromPost(post: Post, ctx: MediaContext): PostView {
|
|
|
181
186
|
|
|
182
187
|
/**
|
|
183
188
|
* Batch converts Post[] (no media) to PostView[].
|
|
184
|
-
*
|
|
185
|
-
* @param posts - Array of posts without media
|
|
186
|
-
* @param ctx - Media context
|
|
187
|
-
* @returns Array of PostView
|
|
188
189
|
*/
|
|
189
190
|
export function toPostViewsFromPosts(
|
|
190
191
|
posts: Post[],
|
|
@@ -193,55 +194,65 @@ export function toPostViewsFromPosts(
|
|
|
193
194
|
return posts.map((p) => toPostViewFromPost(p, ctx));
|
|
194
195
|
}
|
|
195
196
|
|
|
197
|
+
// =============================================================================
|
|
198
|
+
// Page Conversions
|
|
199
|
+
// =============================================================================
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Converts a Page to a render-ready PageView.
|
|
203
|
+
*/
|
|
204
|
+
export function toPageView(page: Page): PageView {
|
|
205
|
+
return {
|
|
206
|
+
id: page.id,
|
|
207
|
+
slug: page.slug,
|
|
208
|
+
title: page.title ?? undefined,
|
|
209
|
+
bodyHtml: page.bodyHtml ?? undefined,
|
|
210
|
+
status: page.status as Status,
|
|
211
|
+
createdAt: toISOString(page.createdAt),
|
|
212
|
+
updatedAt: toISOString(page.updatedAt),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
196
216
|
// =============================================================================
|
|
197
217
|
// Navigation Conversions
|
|
198
218
|
// =============================================================================
|
|
199
219
|
|
|
200
220
|
/**
|
|
201
|
-
* Converts a
|
|
202
|
-
*
|
|
203
|
-
* @param link - Raw navigation link from database
|
|
204
|
-
* @param currentPath - Current page path for active state computation
|
|
205
|
-
* @returns NavLinkView with isActive and isExternal pre-computed
|
|
221
|
+
* Converts a NavItem to a NavItemView with pre-computed state.
|
|
206
222
|
*/
|
|
207
|
-
export function
|
|
208
|
-
link: NavigationLink,
|
|
209
|
-
currentPath: string,
|
|
210
|
-
): NavLinkView {
|
|
223
|
+
export function toNavItemView(item: NavItem, currentPath: string): NavItemView {
|
|
211
224
|
const isExternal =
|
|
212
|
-
|
|
225
|
+
item.url.startsWith("http://") || item.url.startsWith("https://");
|
|
213
226
|
|
|
214
227
|
let isActive = false;
|
|
215
228
|
if (!isExternal) {
|
|
216
|
-
if (
|
|
229
|
+
if (item.url === "/") {
|
|
217
230
|
isActive = currentPath === "/";
|
|
218
231
|
} else {
|
|
219
232
|
isActive =
|
|
220
|
-
currentPath ===
|
|
233
|
+
currentPath === item.url || currentPath.startsWith(item.url + "/");
|
|
221
234
|
}
|
|
222
235
|
}
|
|
223
236
|
|
|
224
237
|
return {
|
|
225
|
-
id:
|
|
226
|
-
|
|
227
|
-
|
|
238
|
+
id: item.id,
|
|
239
|
+
type: item.type as NavItemType,
|
|
240
|
+
label: item.label,
|
|
241
|
+
url: item.url,
|
|
242
|
+
pageId: item.pageId ?? undefined,
|
|
228
243
|
isActive,
|
|
229
244
|
isExternal,
|
|
230
245
|
};
|
|
231
246
|
}
|
|
232
247
|
|
|
233
248
|
/**
|
|
234
|
-
* Batch converts
|
|
235
|
-
*
|
|
236
|
-
* @param links - Raw navigation links
|
|
237
|
-
* @param currentPath - Current page path
|
|
238
|
-
* @returns Array of NavLinkView
|
|
249
|
+
* Batch converts NavItem[] to NavItemView[].
|
|
239
250
|
*/
|
|
240
|
-
export function
|
|
241
|
-
|
|
251
|
+
export function toNavItemViews(
|
|
252
|
+
items: NavItem[],
|
|
242
253
|
currentPath: string,
|
|
243
|
-
):
|
|
244
|
-
return
|
|
254
|
+
): NavItemView[] {
|
|
255
|
+
return items.map((item) => toNavItemView(item, currentPath));
|
|
245
256
|
}
|
|
246
257
|
|
|
247
258
|
// =============================================================================
|
|
@@ -250,10 +261,6 @@ export function toNavLinkViews(
|
|
|
250
261
|
|
|
251
262
|
/**
|
|
252
263
|
* Converts a SearchResult to a SearchResultView with PostView.
|
|
253
|
-
*
|
|
254
|
-
* @param result - Raw search result
|
|
255
|
-
* @param ctx - Media context
|
|
256
|
-
* @returns SearchResultView with PostView
|
|
257
264
|
*/
|
|
258
265
|
export function toSearchResultView(
|
|
259
266
|
result: SearchResult,
|
|
@@ -268,10 +275,6 @@ export function toSearchResultView(
|
|
|
268
275
|
|
|
269
276
|
/**
|
|
270
277
|
* Batch converts SearchResult[] to SearchResultView[].
|
|
271
|
-
*
|
|
272
|
-
* @param results - Raw search results
|
|
273
|
-
* @param ctx - Media context
|
|
274
|
-
* @returns Array of SearchResultView
|
|
275
278
|
*/
|
|
276
279
|
export function toSearchResultViews(
|
|
277
280
|
results: SearchResult[],
|
|
@@ -286,10 +289,6 @@ export function toSearchResultViews(
|
|
|
286
289
|
|
|
287
290
|
/**
|
|
288
291
|
* Converts a grouped post map to typed ArchiveGroup[].
|
|
289
|
-
*
|
|
290
|
-
* @param grouped - Map of "YYYY-MM" keys to Post arrays
|
|
291
|
-
* @param ctx - Media context
|
|
292
|
-
* @returns Array of ArchiveGroup with pre-formatted labels
|
|
293
292
|
*/
|
|
294
293
|
export function toArchiveGroups(
|
|
295
294
|
grouped: Map<string, Post[]>,
|
|
@@ -300,7 +299,6 @@ export function toArchiveGroups(
|
|
|
300
299
|
const [year, month] = yearMonth.split("-");
|
|
301
300
|
if (!year || !month) continue;
|
|
302
301
|
|
|
303
|
-
// Format label like "February 2024"
|
|
304
302
|
const date = new Date(parseInt(year, 10), parseInt(month, 10) - 1);
|
|
305
303
|
const label = date.toLocaleDateString("en-US", {
|
|
306
304
|
year: "numeric",
|
package/src/preset.css
CHANGED
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
@source "./";
|
|
9
9
|
|
|
10
10
|
@import "basecoat-css";
|
|
11
|
+
@plugin "@tailwindcss/typography";
|
|
11
12
|
@import "./styles/components.css";
|
|
13
|
+
@import "./styles/tokens.css";
|
|
14
|
+
@import "./styles/ui.css";
|
|
12
15
|
|
|
13
16
|
@theme {
|
|
14
17
|
--radius-default: 0.5rem;
|
|
@@ -22,3 +25,46 @@
|
|
|
22
25
|
.dark {
|
|
23
26
|
--success: oklch(0.627 0.194 149.214);
|
|
24
27
|
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Typography (prose) — integrate with BaseCoat theme colors.
|
|
31
|
+
*
|
|
32
|
+
* The @tailwindcss/typography plugin ships with hardcoded slate values.
|
|
33
|
+
* We override them to use BaseCoat's CSS variables so that:
|
|
34
|
+
* - Light/dark mode switches automatically
|
|
35
|
+
* - Prose text matches the rest of the theme
|
|
36
|
+
* - Links are understated (same color as text, underline only)
|
|
37
|
+
*
|
|
38
|
+
* Design reference: Medium, Substack, NYT — body links inherit text
|
|
39
|
+
* color and rely on underline as the sole affordance.
|
|
40
|
+
*/
|
|
41
|
+
.prose {
|
|
42
|
+
--tw-prose-body: inherit;
|
|
43
|
+
--tw-prose-headings: var(--color-foreground);
|
|
44
|
+
--tw-prose-lead: var(--color-muted-foreground);
|
|
45
|
+
--tw-prose-links: inherit;
|
|
46
|
+
--tw-prose-bold: inherit;
|
|
47
|
+
--tw-prose-counters: var(--color-muted-foreground);
|
|
48
|
+
--tw-prose-bullets: var(--color-muted-foreground);
|
|
49
|
+
--tw-prose-hr: var(--color-border);
|
|
50
|
+
--tw-prose-quotes: var(--color-foreground);
|
|
51
|
+
--tw-prose-quote-borders: var(--color-border);
|
|
52
|
+
--tw-prose-captions: var(--color-muted-foreground);
|
|
53
|
+
--tw-prose-kbd: var(--color-foreground);
|
|
54
|
+
--tw-prose-code: var(--color-foreground);
|
|
55
|
+
--tw-prose-pre-code: var(--color-muted-foreground);
|
|
56
|
+
--tw-prose-pre-bg: var(--color-muted);
|
|
57
|
+
--tw-prose-th-borders: var(--color-border);
|
|
58
|
+
--tw-prose-td-borders: var(--color-border);
|
|
59
|
+
|
|
60
|
+
/* Links: same color as surrounding text, underline only, no bold */
|
|
61
|
+
a {
|
|
62
|
+
font-weight: inherit;
|
|
63
|
+
text-underline-offset: 2px;
|
|
64
|
+
text-decoration-color: var(--color-border);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
a:hover {
|
|
68
|
+
text-decoration-color: currentColor;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { createTestApp } from "../../__tests__/helpers/app.js";
|
|
3
|
+
import { composeRoutes } from "../compose.js";
|
|
4
|
+
|
|
5
|
+
describe("Compose Routes", () => {
|
|
6
|
+
describe("POST /compose", () => {
|
|
7
|
+
it("redirects to signin when not authenticated", async () => {
|
|
8
|
+
const { app } = createTestApp({ authenticated: false });
|
|
9
|
+
app.route("/compose", composeRoutes);
|
|
10
|
+
|
|
11
|
+
const res = await app.request("/compose", {
|
|
12
|
+
method: "POST",
|
|
13
|
+
headers: { "Content-Type": "application/json" },
|
|
14
|
+
body: JSON.stringify({ format: "note", body: "Hello" }),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
expect(res.status).toBe(302);
|
|
18
|
+
expect(res.headers.get("Location")).toBe("/signin");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("creates a note post and returns redirect", async () => {
|
|
22
|
+
const { app, services } = createTestApp({ authenticated: true });
|
|
23
|
+
app.route("/compose", composeRoutes);
|
|
24
|
+
|
|
25
|
+
const res = await app.request("/compose", {
|
|
26
|
+
method: "POST",
|
|
27
|
+
headers: { "Content-Type": "application/json" },
|
|
28
|
+
body: JSON.stringify({ format: "note", body: "Hello world" }),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
expect(res.status).toBe(200);
|
|
32
|
+
expect(res.headers.get("Content-Type")).toBe("text/html");
|
|
33
|
+
|
|
34
|
+
// Verify post was created
|
|
35
|
+
const posts = await services.posts.list();
|
|
36
|
+
expect(posts).toHaveLength(1);
|
|
37
|
+
expect(posts[0].format).toBe("note");
|
|
38
|
+
expect(posts[0].body).toBe("Hello world");
|
|
39
|
+
expect(posts[0].status).toBe("published");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("creates a link post", async () => {
|
|
43
|
+
const { app, services } = createTestApp({ authenticated: true });
|
|
44
|
+
app.route("/compose", composeRoutes);
|
|
45
|
+
|
|
46
|
+
const res = await app.request("/compose", {
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: { "Content-Type": "application/json" },
|
|
49
|
+
body: JSON.stringify({
|
|
50
|
+
format: "link",
|
|
51
|
+
body: "Check this out",
|
|
52
|
+
url: "https://example.com",
|
|
53
|
+
}),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(res.status).toBe(200);
|
|
57
|
+
|
|
58
|
+
const posts = await services.posts.list();
|
|
59
|
+
expect(posts).toHaveLength(1);
|
|
60
|
+
expect(posts[0].format).toBe("link");
|
|
61
|
+
expect(posts[0].url).toBe("https://example.com");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("creates a quote post", async () => {
|
|
65
|
+
const { app, services } = createTestApp({ authenticated: true });
|
|
66
|
+
app.route("/compose", composeRoutes);
|
|
67
|
+
|
|
68
|
+
const res = await app.request("/compose", {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: { "Content-Type": "application/json" },
|
|
71
|
+
body: JSON.stringify({
|
|
72
|
+
format: "quote",
|
|
73
|
+
body: "Great insight",
|
|
74
|
+
quoteText: "The original quote",
|
|
75
|
+
url: "https://example.com/source",
|
|
76
|
+
}),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(res.status).toBe(200);
|
|
80
|
+
|
|
81
|
+
const posts = await services.posts.list();
|
|
82
|
+
expect(posts).toHaveLength(1);
|
|
83
|
+
expect(posts[0].format).toBe("quote");
|
|
84
|
+
expect(posts[0].quoteText).toBe("The original quote");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("creates a draft when status is draft", async () => {
|
|
88
|
+
const { app, services } = createTestApp({ authenticated: true });
|
|
89
|
+
app.route("/compose", composeRoutes);
|
|
90
|
+
|
|
91
|
+
const res = await app.request("/compose", {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: { "Content-Type": "application/json" },
|
|
94
|
+
body: JSON.stringify({
|
|
95
|
+
format: "note",
|
|
96
|
+
body: "Draft content",
|
|
97
|
+
status: "draft",
|
|
98
|
+
}),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(res.status).toBe(200);
|
|
102
|
+
|
|
103
|
+
const posts = await services.posts.list({ includeDrafts: true });
|
|
104
|
+
expect(posts).toHaveLength(1);
|
|
105
|
+
expect(posts[0].status).toBe("draft");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("returns error for invalid format", async () => {
|
|
109
|
+
const { app } = createTestApp({ authenticated: true });
|
|
110
|
+
app.route("/compose", composeRoutes);
|
|
111
|
+
|
|
112
|
+
const res = await app.request("/compose", {
|
|
113
|
+
method: "POST",
|
|
114
|
+
headers: { "Content-Type": "application/json" },
|
|
115
|
+
body: JSON.stringify({ format: "invalid", body: "Hello" }),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
expect(res.status).toBe(200);
|
|
119
|
+
expect(res.headers.get("Content-Type")).toBe("text/html");
|
|
120
|
+
// Returns a toast error (text/html with error message)
|
|
121
|
+
const text = await res.text();
|
|
122
|
+
expect(text).toContain("toast-error");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("attaches media IDs when provided", async () => {
|
|
126
|
+
const { app, services } = createTestApp({ authenticated: true });
|
|
127
|
+
app.route("/compose", composeRoutes);
|
|
128
|
+
|
|
129
|
+
// Create media first
|
|
130
|
+
const media = await services.media.create({
|
|
131
|
+
filename: "test.jpg",
|
|
132
|
+
originalName: "test.jpg",
|
|
133
|
+
mimeType: "image/jpeg",
|
|
134
|
+
size: 1024,
|
|
135
|
+
storageKey: "media/2025/01/test.jpg",
|
|
136
|
+
width: 800,
|
|
137
|
+
height: 600,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const res = await app.request("/compose", {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: { "Content-Type": "application/json" },
|
|
143
|
+
body: JSON.stringify({
|
|
144
|
+
format: "note",
|
|
145
|
+
body: "Post with media",
|
|
146
|
+
mediaIds: [media.id],
|
|
147
|
+
}),
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
expect(res.status).toBe(200);
|
|
151
|
+
|
|
152
|
+
const posts = await services.posts.list();
|
|
153
|
+
expect(posts).toHaveLength(1);
|
|
154
|
+
|
|
155
|
+
// Verify media is attached
|
|
156
|
+
const attachments = await services.media.getByPostId(posts[0].id);
|
|
157
|
+
expect(attachments).toHaveLength(1);
|
|
158
|
+
expect(attachments[0].id).toBe(media.id);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("sets featured and pinned flags", async () => {
|
|
162
|
+
const { app, services } = createTestApp({ authenticated: true });
|
|
163
|
+
app.route("/compose", composeRoutes);
|
|
164
|
+
|
|
165
|
+
const res = await app.request("/compose", {
|
|
166
|
+
method: "POST",
|
|
167
|
+
headers: { "Content-Type": "application/json" },
|
|
168
|
+
body: JSON.stringify({
|
|
169
|
+
format: "note",
|
|
170
|
+
body: "Featured and pinned",
|
|
171
|
+
featured: true,
|
|
172
|
+
pinned: true,
|
|
173
|
+
}),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
expect(res.status).toBe(200);
|
|
177
|
+
|
|
178
|
+
const posts = await services.posts.list();
|
|
179
|
+
expect(posts).toHaveLength(1);
|
|
180
|
+
expect(posts[0].featured).toBe(1);
|
|
181
|
+
expect(posts[0].pinned).toBe(1);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("returns error when format is missing", async () => {
|
|
185
|
+
const { app } = createTestApp({ authenticated: true });
|
|
186
|
+
app.route("/compose", composeRoutes);
|
|
187
|
+
|
|
188
|
+
const res = await app.request("/compose", {
|
|
189
|
+
method: "POST",
|
|
190
|
+
headers: { "Content-Type": "application/json" },
|
|
191
|
+
body: JSON.stringify({ body: "No format" }),
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
expect(res.status).toBe(200);
|
|
195
|
+
const text = await res.text();
|
|
196
|
+
expect(text).toContain("toast-error");
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
});
|