@jant/core 0.3.34 → 0.3.36
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/client/assets/module-RjUF93sV.js +716 -0
- package/dist/client/assets/native-48B9X9Wg.js +1 -0
- package/dist/client/assets/url-8Dj-5CLW.js +1 -0
- package/dist/client/client.css +1 -1
- package/dist/client/client.js +3109 -2294
- package/dist/index.js +3327 -3031
- package/package.json +13 -4
- package/src/__tests__/helpers/app.ts +1 -1
- package/src/__tests__/helpers/db.ts +6 -0
- package/src/app.tsx +1 -5
- package/src/{lib → client}/avatar-upload.ts +1 -1
- package/src/{lib → client}/collection-form-bridge.ts +2 -2
- package/src/{ui → client}/components/__tests__/jant-collection-form.test.ts +26 -9
- package/src/{ui → client}/components/__tests__/jant-compose-dialog.test.ts +46 -14
- package/src/{ui → client}/components/__tests__/jant-compose-editor.test.ts +64 -24
- package/src/{ui → client}/components/__tests__/jant-post-form.test.ts +24 -14
- package/src/{ui → client}/components/__tests__/jant-settings-general.test.ts +3 -3
- package/src/client/components/collection-sidebar-types.ts +45 -0
- package/src/{ui → client}/components/collection-types.ts +3 -4
- package/src/{ui → client}/components/compose-types.ts +3 -1
- package/src/{ui → client}/components/jant-collection-form.ts +301 -182
- package/src/client/components/jant-collection-sidebar.ts +801 -0
- package/src/{ui → client}/components/jant-compose-dialog.ts +231 -1
- package/src/client/components/jant-compose-editor.ts +1249 -0
- package/src/client/components/jant-compose-fullscreen.ts +338 -0
- package/src/client/components/jant-media-lightbox.ts +257 -0
- package/src/{ui → client}/components/jant-nav-manager.ts +143 -84
- package/src/{ui → client}/components/jant-post-form.ts +57 -8
- package/src/{ui → client}/components/jant-settings-general.ts +2 -2
- package/src/{ui → client}/components/nav-manager-types.ts +3 -0
- package/src/{ui → client}/components/post-form-template.ts +35 -31
- package/src/{ui → client}/components/post-form-types.ts +7 -3
- package/src/{lib → client}/compose-bridge.ts +9 -7
- package/src/client/lazy-slugify.ts +51 -0
- package/src/{lib → client}/media-upload.ts +16 -3
- package/src/{lib → client}/nav-manager-bridge.ts +1 -1
- package/src/client/page-slug-bridge.ts +42 -0
- package/src/{lib → client}/post-form-bridge.ts +2 -2
- package/src/{lib → client}/settings-bridge.ts +3 -3
- package/src/client/tiptap/bubble-menu.ts +205 -0
- package/src/client/tiptap/create-editor.ts +40 -0
- package/src/client/tiptap/exitable-marks.ts +73 -0
- package/src/client/tiptap/extensions.ts +60 -0
- package/src/client/tiptap/image-node.ts +488 -0
- package/src/client/tiptap/link-toolbar.ts +371 -0
- package/src/client/tiptap/more-break.ts +50 -0
- package/src/client/tiptap/paste-image.ts +140 -0
- package/src/client/tiptap/slash-commands.ts +328 -0
- package/src/{types → client/types}/sortablejs.d.ts +1 -1
- package/src/client.ts +24 -17
- package/src/db/migrations/0012_add_tiptap_columns.sql +2 -0
- package/src/db/migrations/0013_replace_featured_with_visibility.sql +8 -0
- package/src/db/schema.ts +6 -1
- package/src/i18n/locales/en.po +641 -215
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +642 -204
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +642 -204
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/lib/__tests__/resolve-config.test.ts +2 -2
- package/src/lib/__tests__/schemas.test.ts +9 -6
- package/src/lib/__tests__/url.test.ts +2 -2
- package/src/lib/__tests__/view.test.ts +9 -9
- package/src/lib/emoji-catalog.ts +146 -0
- package/src/lib/feed.ts +1 -1
- package/src/lib/media-helpers.ts +10 -9
- package/src/lib/render.tsx +4 -3
- package/src/lib/resolve-config.ts +8 -1
- package/src/lib/schemas.ts +2 -3
- package/src/lib/summary.ts +92 -0
- package/src/lib/timeline.ts +2 -0
- package/src/lib/tiptap-render.ts +196 -0
- package/src/lib/upload.ts +97 -9
- package/src/lib/url.ts +7 -23
- package/src/lib/view.ts +33 -19
- package/src/middleware/error-handler.ts +3 -3
- package/src/preset.css +38 -0
- package/src/routes/api/collections.ts +20 -3
- package/src/routes/api/posts.ts +48 -33
- package/src/routes/api/upload.ts +7 -5
- package/src/routes/auth/reset.tsx +5 -4
- package/src/routes/auth/setup.tsx +26 -11
- package/src/routes/auth/signin.tsx +10 -7
- package/src/routes/compose.tsx +20 -11
- package/src/routes/dash/__tests__/settings-avatar.test.ts +43 -8
- package/src/routes/dash/index.tsx +7 -1
- package/src/routes/dash/media.tsx +3 -0
- package/src/routes/dash/pages.tsx +8 -2
- package/src/routes/dash/posts.tsx +6 -2
- package/src/routes/dash/redirects.tsx +15 -9
- package/src/routes/dash/settings.tsx +336 -32
- package/src/routes/feed/__tests__/rss.test.ts +245 -6
- package/src/routes/feed/rss.ts +70 -6
- package/src/routes/pages/__tests__/featured.test.ts +6 -7
- package/src/routes/pages/archive.tsx +11 -7
- package/src/routes/pages/collection.tsx +32 -15
- package/src/routes/pages/collections.tsx +11 -2
- package/src/routes/pages/featured.tsx +1 -1
- package/src/routes/pages/home.tsx +1 -1
- package/src/services/__tests__/post.test.ts +124 -33
- package/src/services/__tests__/settings.test.ts +3 -3
- package/src/services/page.ts +16 -3
- package/src/services/post.ts +96 -37
- package/src/services/search.ts +4 -2
- package/src/services/settings.ts +6 -2
- package/src/styles/components.css +240 -60
- package/src/styles/tokens.css +10 -0
- package/src/styles/ui.css +1157 -81
- package/src/types/bindings.ts +5 -0
- package/src/types/config.ts +23 -1
- package/src/types/constants.ts +3 -0
- package/src/types/entities.ts +9 -2
- package/src/types/operations.ts +9 -3
- package/src/types/props.ts +3 -3
- package/src/types/views.ts +3 -2
- package/src/ui/compose/ComposeDialog.tsx +24 -7
- package/src/ui/dash/PageForm.tsx +2 -0
- package/src/ui/dash/PostList.tsx +5 -5
- package/src/ui/dash/StatusBadge.tsx +13 -5
- package/src/ui/dash/appearance/AdvancedContent.tsx +52 -61
- package/src/ui/dash/appearance/ColorThemeContent.tsx +30 -35
- package/src/ui/dash/appearance/FontThemeContent.tsx +65 -73
- package/src/ui/dash/appearance/NavigationContent.tsx +107 -96
- package/src/ui/dash/media/MediaListContent.tsx +9 -4
- package/src/ui/dash/media/ViewMediaContent.tsx +2 -2
- package/src/ui/dash/pages/PagesContent.tsx +2 -1
- package/src/ui/dash/posts/PostForm.tsx +19 -7
- package/src/ui/dash/settings/AccountContent.tsx +133 -138
- package/src/ui/dash/settings/AvatarContent.tsx +70 -0
- package/src/ui/dash/settings/GeneralContent.tsx +3 -62
- package/src/ui/dash/settings/SettingsRootContent.tsx +236 -0
- package/src/ui/layouts/DashLayout.tsx +157 -75
- package/src/ui/layouts/SiteLayout.tsx +13 -13
- package/src/ui/pages/ArchivePage.tsx +10 -7
- package/src/ui/pages/CollectionPage.tsx +6 -35
- package/src/ui/pages/CollectionsPage.tsx +2 -1
- package/src/ui/pages/FeaturedPage.tsx +2 -1
- package/src/ui/pages/HomePage.tsx +1 -1
- package/src/ui/pages/SearchPage.tsx +1 -1
- package/src/ui/shared/CollectionsSidebar.tsx +228 -3
- package/src/ui/shared/MediaGallery.tsx +179 -41
- package/src/lib/collections-reorder.ts +0 -28
- package/src/routes/dash/appearance.tsx +0 -240
- package/src/routes/dash/collections.tsx +0 -211
- package/src/ui/components/jant-compose-editor.ts +0 -814
- package/src/ui/dash/appearance/AppearanceNav.tsx +0 -60
- package/src/ui/dash/collections/CollectionForm.tsx +0 -166
- package/src/ui/dash/collections/CollectionsListContent.tsx +0 -146
- package/src/ui/dash/collections/IconPickerGrid.tsx +0 -50
- package/src/ui/dash/collections/ViewCollectionContent.tsx +0 -103
- package/src/ui/dash/settings/SettingsNav.tsx +0 -52
- /package/src/{ui → client}/components/__tests__/jant-settings-avatar.test.ts +0 -0
- /package/src/{ui → client}/components/jant-settings-avatar.ts +0 -0
- /package/src/{ui → client}/components/settings-types.ts +0 -0
- /package/src/{lib → client}/image-processor.ts +0 -0
- /package/src/{lib → client}/toast.ts +0 -0
|
@@ -28,7 +28,7 @@ function createFeedTestApp(envOverrides: Partial<Bindings> = {}) {
|
|
|
28
28
|
|
|
29
29
|
app.use("*", async (c, next) => {
|
|
30
30
|
const env = {
|
|
31
|
-
SITE_URL: "http://localhost:
|
|
31
|
+
SITE_URL: "http://localhost:9020",
|
|
32
32
|
...envOverrides,
|
|
33
33
|
} as Bindings;
|
|
34
34
|
c.env = env;
|
|
@@ -46,17 +46,255 @@ function createFeedTestApp(envOverrides: Partial<Bindings> = {}) {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
describe("RSS Feed Routes", () => {
|
|
49
|
+
describe("/feed — featured only", () => {
|
|
50
|
+
it("returns only featured posts", async () => {
|
|
51
|
+
const { app, services } = createFeedTestApp();
|
|
52
|
+
|
|
53
|
+
// Create a mix of featured and non-featured posts
|
|
54
|
+
await services.posts.create({
|
|
55
|
+
format: "note",
|
|
56
|
+
title: "Regular Post",
|
|
57
|
+
body: "Not featured",
|
|
58
|
+
status: "published",
|
|
59
|
+
});
|
|
60
|
+
await services.posts.create({
|
|
61
|
+
format: "note",
|
|
62
|
+
title: "Featured Post",
|
|
63
|
+
body: "This is featured",
|
|
64
|
+
status: "published",
|
|
65
|
+
visibility: "featured",
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const res = await app.request("/feed");
|
|
69
|
+
expect(res.status).toBe(200);
|
|
70
|
+
|
|
71
|
+
const xml = await res.text();
|
|
72
|
+
expect(xml).toContain("Featured Post");
|
|
73
|
+
expect(xml).not.toContain("Regular Post");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("returns empty feed when no featured posts exist", async () => {
|
|
77
|
+
const { app, services } = createFeedTestApp();
|
|
78
|
+
|
|
79
|
+
await services.posts.create({
|
|
80
|
+
format: "note",
|
|
81
|
+
title: "Regular Post",
|
|
82
|
+
body: "Not featured",
|
|
83
|
+
status: "published",
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const res = await app.request("/feed");
|
|
87
|
+
expect(res.status).toBe(200);
|
|
88
|
+
|
|
89
|
+
const xml = await res.text();
|
|
90
|
+
expect(xml).not.toContain("Regular Post");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("returns RSS content type", async () => {
|
|
94
|
+
const { app } = createFeedTestApp();
|
|
95
|
+
|
|
96
|
+
const res = await app.request("/feed");
|
|
97
|
+
expect(res.headers.get("Content-Type")).toBe(
|
|
98
|
+
"application/rss+xml; charset=utf-8",
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("/feed/atom.xml — featured only (Atom)", () => {
|
|
104
|
+
it("returns only featured posts in Atom format", async () => {
|
|
105
|
+
const { app, services } = createFeedTestApp();
|
|
106
|
+
|
|
107
|
+
await services.posts.create({
|
|
108
|
+
format: "note",
|
|
109
|
+
title: "Regular Post",
|
|
110
|
+
body: "Not featured",
|
|
111
|
+
status: "published",
|
|
112
|
+
});
|
|
113
|
+
await services.posts.create({
|
|
114
|
+
format: "note",
|
|
115
|
+
title: "Featured Post",
|
|
116
|
+
body: "This is featured",
|
|
117
|
+
status: "published",
|
|
118
|
+
visibility: "featured",
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const res = await app.request("/feed/atom.xml");
|
|
122
|
+
expect(res.status).toBe(200);
|
|
123
|
+
expect(res.headers.get("Content-Type")).toBe(
|
|
124
|
+
"application/atom+xml; charset=utf-8",
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const xml = await res.text();
|
|
128
|
+
expect(xml).toContain("Featured Post");
|
|
129
|
+
expect(xml).not.toContain("Regular Post");
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("/feed/all — all published posts", () => {
|
|
134
|
+
it("returns all published posts", async () => {
|
|
135
|
+
const { app, services } = createFeedTestApp();
|
|
136
|
+
|
|
137
|
+
await services.posts.create({
|
|
138
|
+
format: "note",
|
|
139
|
+
title: "Regular Post",
|
|
140
|
+
body: "Not featured",
|
|
141
|
+
status: "published",
|
|
142
|
+
});
|
|
143
|
+
await services.posts.create({
|
|
144
|
+
format: "note",
|
|
145
|
+
title: "Featured Post",
|
|
146
|
+
body: "This is featured",
|
|
147
|
+
status: "published",
|
|
148
|
+
visibility: "featured",
|
|
149
|
+
});
|
|
150
|
+
await services.posts.create({
|
|
151
|
+
format: "note",
|
|
152
|
+
title: "Draft Post",
|
|
153
|
+
body: "Draft",
|
|
154
|
+
status: "draft",
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const res = await app.request("/feed/all");
|
|
158
|
+
expect(res.status).toBe(200);
|
|
159
|
+
|
|
160
|
+
const xml = await res.text();
|
|
161
|
+
expect(xml).toContain("Regular Post");
|
|
162
|
+
expect(xml).toContain("Featured Post");
|
|
163
|
+
expect(xml).not.toContain("Draft Post");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("filters by format query parameter", async () => {
|
|
167
|
+
const { app, services } = createFeedTestApp();
|
|
168
|
+
|
|
169
|
+
await services.posts.create({
|
|
170
|
+
format: "note",
|
|
171
|
+
title: "My Note",
|
|
172
|
+
body: "A note",
|
|
173
|
+
status: "published",
|
|
174
|
+
});
|
|
175
|
+
await services.posts.create({
|
|
176
|
+
format: "link",
|
|
177
|
+
title: "My Link",
|
|
178
|
+
url: "https://example.com",
|
|
179
|
+
status: "published",
|
|
180
|
+
});
|
|
181
|
+
await services.posts.create({
|
|
182
|
+
format: "quote",
|
|
183
|
+
title: "My Quote",
|
|
184
|
+
quoteText: "Something wise",
|
|
185
|
+
status: "published",
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const res = await app.request("/feed/all?format=note");
|
|
189
|
+
expect(res.status).toBe(200);
|
|
190
|
+
|
|
191
|
+
const xml = await res.text();
|
|
192
|
+
expect(xml).toContain("My Note");
|
|
193
|
+
expect(xml).not.toContain("My Link");
|
|
194
|
+
expect(xml).not.toContain("My Quote");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("ignores invalid format query parameter", async () => {
|
|
198
|
+
const { app, services } = createFeedTestApp();
|
|
199
|
+
|
|
200
|
+
await services.posts.create({
|
|
201
|
+
format: "note",
|
|
202
|
+
title: "My Note",
|
|
203
|
+
body: "A note",
|
|
204
|
+
status: "published",
|
|
205
|
+
});
|
|
206
|
+
await services.posts.create({
|
|
207
|
+
format: "link",
|
|
208
|
+
title: "My Link",
|
|
209
|
+
url: "https://example.com",
|
|
210
|
+
status: "published",
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const res = await app.request("/feed/all?format=invalid");
|
|
214
|
+
expect(res.status).toBe(200);
|
|
215
|
+
|
|
216
|
+
const xml = await res.text();
|
|
217
|
+
// Invalid format is ignored — all posts returned
|
|
218
|
+
expect(xml).toContain("My Note");
|
|
219
|
+
expect(xml).toContain("My Link");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("returns RSS content type", async () => {
|
|
223
|
+
const { app } = createFeedTestApp();
|
|
224
|
+
|
|
225
|
+
const res = await app.request("/feed/all");
|
|
226
|
+
expect(res.headers.get("Content-Type")).toBe(
|
|
227
|
+
"application/rss+xml; charset=utf-8",
|
|
228
|
+
);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe("/feed/all/atom.xml — all published posts (Atom)", () => {
|
|
233
|
+
it("returns all published posts in Atom format", async () => {
|
|
234
|
+
const { app, services } = createFeedTestApp();
|
|
235
|
+
|
|
236
|
+
await services.posts.create({
|
|
237
|
+
format: "note",
|
|
238
|
+
title: "Regular Post",
|
|
239
|
+
body: "Not featured",
|
|
240
|
+
status: "published",
|
|
241
|
+
});
|
|
242
|
+
await services.posts.create({
|
|
243
|
+
format: "note",
|
|
244
|
+
title: "Featured Post",
|
|
245
|
+
body: "This is featured",
|
|
246
|
+
status: "published",
|
|
247
|
+
visibility: "featured",
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const res = await app.request("/feed/all/atom.xml");
|
|
251
|
+
expect(res.status).toBe(200);
|
|
252
|
+
expect(res.headers.get("Content-Type")).toBe(
|
|
253
|
+
"application/atom+xml; charset=utf-8",
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const xml = await res.text();
|
|
257
|
+
expect(xml).toContain("Regular Post");
|
|
258
|
+
expect(xml).toContain("Featured Post");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("supports format filtering", async () => {
|
|
262
|
+
const { app, services } = createFeedTestApp();
|
|
263
|
+
|
|
264
|
+
await services.posts.create({
|
|
265
|
+
format: "note",
|
|
266
|
+
title: "My Note",
|
|
267
|
+
body: "A note",
|
|
268
|
+
status: "published",
|
|
269
|
+
});
|
|
270
|
+
await services.posts.create({
|
|
271
|
+
format: "link",
|
|
272
|
+
title: "My Link",
|
|
273
|
+
url: "https://example.com",
|
|
274
|
+
status: "published",
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const res = await app.request("/feed/all/atom.xml?format=link");
|
|
278
|
+
expect(res.status).toBe(200);
|
|
279
|
+
|
|
280
|
+
const xml = await res.text();
|
|
281
|
+
expect(xml).not.toContain("My Note");
|
|
282
|
+
expect(xml).toContain("My Link");
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
49
286
|
describe("RSS_FEED_LIMIT env var", () => {
|
|
50
287
|
it("defaults to 50 when RSS_FEED_LIMIT is not set", async () => {
|
|
51
288
|
const { app, services } = createFeedTestApp();
|
|
52
289
|
|
|
53
|
-
// Create 3 posts
|
|
290
|
+
// Create 3 featured posts
|
|
54
291
|
for (let i = 0; i < 3; i++) {
|
|
55
292
|
await services.posts.create({
|
|
56
293
|
format: "note",
|
|
57
294
|
title: `Post ${i}`,
|
|
58
295
|
body: `Body ${i}`,
|
|
59
296
|
status: "published",
|
|
297
|
+
visibility: "featured",
|
|
60
298
|
});
|
|
61
299
|
}
|
|
62
300
|
|
|
@@ -75,7 +313,7 @@ describe("RSS Feed Routes", () => {
|
|
|
75
313
|
RSS_FEED_LIMIT: "2",
|
|
76
314
|
});
|
|
77
315
|
|
|
78
|
-
// Create 5 posts
|
|
316
|
+
// Create 5 posts on /feed/all
|
|
79
317
|
for (let i = 0; i < 5; i++) {
|
|
80
318
|
await services.posts.create({
|
|
81
319
|
format: "note",
|
|
@@ -85,7 +323,7 @@ describe("RSS Feed Routes", () => {
|
|
|
85
323
|
});
|
|
86
324
|
}
|
|
87
325
|
|
|
88
|
-
const res = await app.request("/feed");
|
|
326
|
+
const res = await app.request("/feed/all");
|
|
89
327
|
expect(res.status).toBe(200);
|
|
90
328
|
|
|
91
329
|
const xml = await res.text();
|
|
@@ -103,7 +341,7 @@ describe("RSS Feed Routes", () => {
|
|
|
103
341
|
RSS_FEED_LIMIT: "not-a-number",
|
|
104
342
|
});
|
|
105
343
|
|
|
106
|
-
// Create 2 posts
|
|
344
|
+
// Create 2 posts on /feed/all
|
|
107
345
|
for (let i = 0; i < 2; i++) {
|
|
108
346
|
await services.posts.create({
|
|
109
347
|
format: "note",
|
|
@@ -113,7 +351,7 @@ describe("RSS Feed Routes", () => {
|
|
|
113
351
|
});
|
|
114
352
|
}
|
|
115
353
|
|
|
116
|
-
const res = await app.request("/feed");
|
|
354
|
+
const res = await app.request("/feed/all");
|
|
117
355
|
expect(res.status).toBe(200);
|
|
118
356
|
|
|
119
357
|
const xml = await res.text();
|
|
@@ -133,6 +371,7 @@ describe("RSS Feed Routes", () => {
|
|
|
133
371
|
title: `Post ${i}`,
|
|
134
372
|
body: `Body ${i}`,
|
|
135
373
|
status: "published",
|
|
374
|
+
visibility: "featured",
|
|
136
375
|
});
|
|
137
376
|
}
|
|
138
377
|
|
package/src/routes/feed/rss.ts
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* RSS Feed Routes
|
|
3
|
+
*
|
|
4
|
+
* Three-level hierarchy:
|
|
5
|
+
* - /feed — featured posts only (curated feed for subscribers)
|
|
6
|
+
* - /feed/all — all published posts (with optional ?format= filter)
|
|
7
|
+
* - /c/{slug}/feed — per-collection feed (handled in collection routes)
|
|
3
8
|
*/
|
|
4
9
|
|
|
5
10
|
import { Hono } from "hono";
|
|
6
11
|
import type { Context } from "hono";
|
|
7
|
-
import type { Bindings, FeedData } from "../../types.js";
|
|
12
|
+
import type { Bindings, FeedData, Format } from "../../types.js";
|
|
8
13
|
import type { AppVariables } from "../../types/app-context.js";
|
|
9
14
|
import { defaultRssRenderer, defaultAtomRenderer } from "../../lib/feed.js";
|
|
10
15
|
import { buildMediaMap } from "../../lib/media-helpers.js";
|
|
16
|
+
import { FORMATS } from "../../types/constants.js";
|
|
11
17
|
|
|
12
18
|
import { createMediaContext, toPostViews } from "../../lib/view.js";
|
|
13
19
|
|
|
@@ -15,10 +21,23 @@ type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
|
15
21
|
|
|
16
22
|
export const rssRoutes = new Hono<Env>();
|
|
17
23
|
|
|
24
|
+
interface FeedOptions {
|
|
25
|
+
visibility?: "featured";
|
|
26
|
+
excludeUnlisted?: boolean;
|
|
27
|
+
format?: Format;
|
|
28
|
+
}
|
|
29
|
+
|
|
18
30
|
/**
|
|
19
31
|
* Build FeedData from the Hono context.
|
|
32
|
+
*
|
|
33
|
+
* @param c - Hono context
|
|
34
|
+
* @param opts - Filter options for the feed
|
|
35
|
+
* @returns Feed data ready for rendering
|
|
20
36
|
*/
|
|
21
|
-
async function buildFeedData(
|
|
37
|
+
async function buildFeedData(
|
|
38
|
+
c: Context<Env>,
|
|
39
|
+
opts?: FeedOptions,
|
|
40
|
+
): Promise<FeedData> {
|
|
22
41
|
const { appConfig } = c.var;
|
|
23
42
|
const siteName = appConfig.siteName;
|
|
24
43
|
const siteDescription = appConfig.siteDescription;
|
|
@@ -29,6 +48,9 @@ async function buildFeedData(c: Context<Env>): Promise<FeedData> {
|
|
|
29
48
|
const posts = await c.var.services.posts.list({
|
|
30
49
|
status: "published",
|
|
31
50
|
excludeReplies: true,
|
|
51
|
+
visibility: opts?.visibility,
|
|
52
|
+
excludeUnlisted: opts?.excludeUnlisted,
|
|
53
|
+
format: opts?.format,
|
|
32
54
|
limit: feedLimit,
|
|
33
55
|
});
|
|
34
56
|
|
|
@@ -61,9 +83,23 @@ async function buildFeedData(c: Context<Env>): Promise<FeedData> {
|
|
|
61
83
|
};
|
|
62
84
|
}
|
|
63
85
|
|
|
64
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Parse and validate the `format` query parameter.
|
|
88
|
+
* Returns a valid Format or undefined if missing/invalid.
|
|
89
|
+
*/
|
|
90
|
+
function parseFormatQuery(c: Context<Env>): Format | undefined {
|
|
91
|
+
const raw = c.req.query("format");
|
|
92
|
+
if (raw && (FORMATS as readonly string[]).includes(raw)) {
|
|
93
|
+
return raw as Format;
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --- Featured feed (curated) ---
|
|
99
|
+
|
|
100
|
+
// RSS 2.0 — /feed
|
|
65
101
|
rssRoutes.get("/", async (c) => {
|
|
66
|
-
const feedData = await buildFeedData(c);
|
|
102
|
+
const feedData = await buildFeedData(c, { visibility: "featured" });
|
|
67
103
|
const xml = defaultRssRenderer(feedData);
|
|
68
104
|
|
|
69
105
|
return new Response(xml, {
|
|
@@ -73,9 +109,37 @@ rssRoutes.get("/", async (c) => {
|
|
|
73
109
|
});
|
|
74
110
|
});
|
|
75
111
|
|
|
76
|
-
// Atom
|
|
112
|
+
// Atom — /feed/atom.xml
|
|
77
113
|
rssRoutes.get("/atom.xml", async (c) => {
|
|
78
|
-
const feedData = await buildFeedData(c);
|
|
114
|
+
const feedData = await buildFeedData(c, { visibility: "featured" });
|
|
115
|
+
const xml = defaultAtomRenderer(feedData);
|
|
116
|
+
|
|
117
|
+
return new Response(xml, {
|
|
118
|
+
headers: {
|
|
119
|
+
"Content-Type": "application/atom+xml; charset=utf-8",
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// --- All posts feed ---
|
|
125
|
+
|
|
126
|
+
// RSS 2.0 — /feed/all
|
|
127
|
+
rssRoutes.get("/all", async (c) => {
|
|
128
|
+
const format = parseFormatQuery(c);
|
|
129
|
+
const feedData = await buildFeedData(c, { excludeUnlisted: true, format });
|
|
130
|
+
const xml = defaultRssRenderer(feedData);
|
|
131
|
+
|
|
132
|
+
return new Response(xml, {
|
|
133
|
+
headers: {
|
|
134
|
+
"Content-Type": "application/rss+xml; charset=utf-8",
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Atom — /feed/all/atom.xml
|
|
140
|
+
rssRoutes.get("/all/atom.xml", async (c) => {
|
|
141
|
+
const format = parseFormatQuery(c);
|
|
142
|
+
const feedData = await buildFeedData(c, { excludeUnlisted: true, format });
|
|
79
143
|
const xml = defaultAtomRenderer(feedData);
|
|
80
144
|
|
|
81
145
|
return new Response(xml, {
|
|
@@ -26,24 +26,23 @@ describe("Featured Page - Data Logic", () => {
|
|
|
26
26
|
await postService.create({
|
|
27
27
|
format: "note",
|
|
28
28
|
body: "Featured post",
|
|
29
|
-
|
|
29
|
+
visibility: "featured",
|
|
30
30
|
status: "published",
|
|
31
31
|
});
|
|
32
32
|
await postService.create({
|
|
33
33
|
format: "note",
|
|
34
34
|
body: "Normal post",
|
|
35
|
-
featured: false,
|
|
36
35
|
status: "published",
|
|
37
36
|
});
|
|
38
37
|
await postService.create({
|
|
39
38
|
format: "note",
|
|
40
39
|
body: "Draft featured",
|
|
41
|
-
|
|
40
|
+
visibility: "featured",
|
|
42
41
|
status: "draft",
|
|
43
42
|
});
|
|
44
43
|
|
|
45
44
|
const posts = await postService.list({
|
|
46
|
-
|
|
45
|
+
visibility: "featured",
|
|
47
46
|
status: "published",
|
|
48
47
|
excludeReplies: true,
|
|
49
48
|
});
|
|
@@ -60,7 +59,7 @@ describe("Featured Page - Data Logic", () => {
|
|
|
60
59
|
});
|
|
61
60
|
|
|
62
61
|
const posts = await postService.list({
|
|
63
|
-
|
|
62
|
+
visibility: "featured",
|
|
64
63
|
status: "published",
|
|
65
64
|
excludeReplies: true,
|
|
66
65
|
});
|
|
@@ -72,7 +71,7 @@ describe("Featured Page - Data Logic", () => {
|
|
|
72
71
|
const root = await postService.create({
|
|
73
72
|
format: "note",
|
|
74
73
|
body: "Featured root",
|
|
75
|
-
|
|
74
|
+
visibility: "featured",
|
|
76
75
|
status: "published",
|
|
77
76
|
});
|
|
78
77
|
|
|
@@ -84,7 +83,7 @@ describe("Featured Page - Data Logic", () => {
|
|
|
84
83
|
});
|
|
85
84
|
|
|
86
85
|
const posts = await postService.list({
|
|
87
|
-
|
|
86
|
+
visibility: "featured",
|
|
88
87
|
status: "published",
|
|
89
88
|
excludeReplies: true,
|
|
90
89
|
});
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Archive Page Route
|
|
3
3
|
*
|
|
4
|
-
* Shows all posts, optionally filtered by format or
|
|
4
|
+
* Shows all posts, optionally filtered by format or visibility
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { Hono } from "hono";
|
|
8
|
-
import type { Bindings, Format } from "../../types.js";
|
|
8
|
+
import type { Bindings, Format, Visibility } from "../../types.js";
|
|
9
9
|
import type { AppVariables } from "../../types/app-context.js";
|
|
10
|
-
import { FORMATS } from "../../types.js";
|
|
10
|
+
import { FORMATS, VISIBILITIES } from "../../types.js";
|
|
11
11
|
import { ArchivePage } from "../../ui/pages/ArchivePage.js";
|
|
12
12
|
import { getNavigationData } from "../../lib/navigation.js";
|
|
13
13
|
import { renderPublicPage } from "../../lib/render.js";
|
|
@@ -24,8 +24,12 @@ archiveRoutes.get("/", async (c) => {
|
|
|
24
24
|
const formatParam = c.req.query("format") as Format | undefined;
|
|
25
25
|
const format =
|
|
26
26
|
formatParam && FORMATS.includes(formatParam) ? formatParam : undefined;
|
|
27
|
-
const
|
|
28
|
-
const
|
|
27
|
+
const visibilityParam = c.req.query("visibility") as Visibility | undefined;
|
|
28
|
+
const visibility =
|
|
29
|
+
visibilityParam &&
|
|
30
|
+
(VISIBILITIES as readonly string[]).includes(visibilityParam)
|
|
31
|
+
? visibilityParam
|
|
32
|
+
: undefined;
|
|
29
33
|
|
|
30
34
|
// Parse cursor
|
|
31
35
|
const cursorParam = c.req.query("cursor");
|
|
@@ -37,7 +41,7 @@ archiveRoutes.get("/", async (c) => {
|
|
|
37
41
|
const posts = await c.var.services.posts.list({
|
|
38
42
|
format,
|
|
39
43
|
status: "published",
|
|
40
|
-
|
|
44
|
+
visibility,
|
|
41
45
|
excludeReplies: true,
|
|
42
46
|
cursor,
|
|
43
47
|
limit: PAGE_SIZE + 1,
|
|
@@ -78,7 +82,7 @@ archiveRoutes.get("/", async (c) => {
|
|
|
78
82
|
hasMore={hasMore}
|
|
79
83
|
nextCursor={nextCursor}
|
|
80
84
|
format={format}
|
|
81
|
-
|
|
85
|
+
visibility={visibility}
|
|
82
86
|
/>
|
|
83
87
|
),
|
|
84
88
|
});
|
|
@@ -8,11 +8,7 @@ import type { AppVariables } from "../../types/app-context.js";
|
|
|
8
8
|
import { CollectionPage } from "../../ui/pages/CollectionPage.js";
|
|
9
9
|
import { getNavigationData } from "../../lib/navigation.js";
|
|
10
10
|
import { renderPublicPage } from "../../lib/render.js";
|
|
11
|
-
import {
|
|
12
|
-
createMediaContext,
|
|
13
|
-
toPostViewsFromPosts,
|
|
14
|
-
toPostViews,
|
|
15
|
-
} from "../../lib/view.js";
|
|
11
|
+
import { createMediaContext, toPostViews } from "../../lib/view.js";
|
|
16
12
|
import { defaultRssRenderer } from "../../lib/feed.js";
|
|
17
13
|
import { buildMediaMap } from "../../lib/media-helpers.js";
|
|
18
14
|
import { CollectionsSidebar } from "../../ui/shared/CollectionsSidebar.js";
|
|
@@ -27,35 +23,56 @@ collectionRoutes.get("/:slug", async (c) => {
|
|
|
27
23
|
const collection = await c.var.services.collections.getBySlug(slug);
|
|
28
24
|
if (!collection) return c.notFound();
|
|
29
25
|
|
|
30
|
-
// Fetch posts
|
|
31
|
-
const [posts, allCollections] = await Promise.all([
|
|
26
|
+
// Fetch posts, all collections, dividers, and post counts in parallel
|
|
27
|
+
const [posts, allCollections, dividers, postCounts] = await Promise.all([
|
|
32
28
|
c.var.services.posts.list({
|
|
33
29
|
collectionId: collection.id,
|
|
34
30
|
status: "published",
|
|
35
31
|
excludeReplies: true,
|
|
36
32
|
}),
|
|
37
33
|
c.var.services.collections.list(),
|
|
34
|
+
c.var.services.collections.listDividers(),
|
|
35
|
+
c.var.services.collections.getPostCounts(),
|
|
38
36
|
]);
|
|
39
37
|
|
|
40
38
|
const navData = await getNavigationData(c);
|
|
41
39
|
|
|
42
|
-
//
|
|
40
|
+
// Batch-load media for posts
|
|
41
|
+
const postIds = posts.map((p) => p.id);
|
|
42
|
+
const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
|
|
43
43
|
const mediaCtx = createMediaContext(c.var.appConfig);
|
|
44
|
-
const
|
|
44
|
+
const mediaMap = buildMediaMap(
|
|
45
|
+
rawMediaMap,
|
|
46
|
+
mediaCtx.r2PublicUrl,
|
|
47
|
+
mediaCtx.imageTransformUrl,
|
|
48
|
+
mediaCtx.s3PublicUrl,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const postViews = toPostViews(
|
|
52
|
+
posts.map((p) => ({
|
|
53
|
+
...p,
|
|
54
|
+
mediaAttachments: mediaMap.get(p.id) ?? [],
|
|
55
|
+
})),
|
|
56
|
+
mediaCtx,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const items = postViews.map((post) => ({ post }));
|
|
45
60
|
|
|
46
61
|
return renderPublicPage(c, {
|
|
47
62
|
title: `${collection.title} - ${navData.siteName}`,
|
|
48
63
|
description: collection.description ?? undefined,
|
|
49
64
|
navData,
|
|
50
65
|
sidebar: (
|
|
51
|
-
<CollectionsSidebar
|
|
66
|
+
<CollectionsSidebar
|
|
67
|
+
collections={allCollections}
|
|
68
|
+
dividers={dividers}
|
|
69
|
+
activeSlug={slug}
|
|
70
|
+
isAuthenticated={navData.isAuthenticated}
|
|
71
|
+
postCounts={postCounts}
|
|
72
|
+
/>
|
|
52
73
|
),
|
|
53
74
|
content: (
|
|
54
|
-
<CollectionPage
|
|
55
|
-
collection={collection}
|
|
56
|
-
posts={postViews}
|
|
57
|
-
hasMore={false}
|
|
58
|
-
/>
|
|
75
|
+
<CollectionPage collection={collection} items={items} hasMore={false} />
|
|
59
76
|
),
|
|
60
77
|
});
|
|
61
78
|
});
|
|
@@ -17,8 +17,9 @@ type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
|
17
17
|
export const collectionsPageRoutes = new Hono<Env>();
|
|
18
18
|
|
|
19
19
|
collectionsPageRoutes.get("/", async (c) => {
|
|
20
|
-
const [allCollections, postCounts] = await Promise.all([
|
|
20
|
+
const [allCollections, dividers, postCounts] = await Promise.all([
|
|
21
21
|
c.var.services.collections.list(),
|
|
22
|
+
c.var.services.collections.listDividers(),
|
|
22
23
|
c.var.services.collections.getPostCounts(),
|
|
23
24
|
]);
|
|
24
25
|
|
|
@@ -32,7 +33,15 @@ collectionsPageRoutes.get("/", async (c) => {
|
|
|
32
33
|
return renderPublicPage(c, {
|
|
33
34
|
title: `Collections - ${navData.siteName}`,
|
|
34
35
|
navData,
|
|
35
|
-
sidebar:
|
|
36
|
+
sidebar: (
|
|
37
|
+
<CollectionsSidebar
|
|
38
|
+
collections={allCollections}
|
|
39
|
+
dividers={dividers}
|
|
40
|
+
activeSlug={undefined}
|
|
41
|
+
isAuthenticated={navData.isAuthenticated}
|
|
42
|
+
postCounts={postCounts}
|
|
43
|
+
/>
|
|
44
|
+
),
|
|
36
45
|
content: <CollectionsPage collections={collections} />,
|
|
37
46
|
});
|
|
38
47
|
});
|
|
@@ -28,7 +28,7 @@ homeRoutes.get("/", async (c) => {
|
|
|
28
28
|
if (navData.homeDefaultView === "featured") {
|
|
29
29
|
// Show featured posts on homepage
|
|
30
30
|
const posts = await c.var.services.posts.list({
|
|
31
|
-
|
|
31
|
+
visibility: "featured",
|
|
32
32
|
status: "published",
|
|
33
33
|
excludeReplies: true,
|
|
34
34
|
});
|