@jant/core 0.3.33 → 0.3.35

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/index.js CHANGED
@@ -2586,7 +2586,7 @@ const SYSTEM_NAV_KEYS = {
2586
2586
  },
2587
2587
  collections: {
2588
2588
  defaultLabel: "Collections",
2589
- url: "/collections"
2589
+ url: "/c"
2590
2590
  },
2591
2591
  archive: {
2592
2592
  defaultLabel: "Archive",
@@ -5152,7 +5152,7 @@ const I18nProvider = ({ c, children })=>{
5152
5152
  }
5153
5153
 
5154
5154
  const IS_VITE_DEV = typeof __JANT_DEV__ !== "undefined" && __JANT_DEV__ === true;
5155
- const CORE_VERSION = "0.3.33";
5155
+ const CORE_VERSION = "0.3.35";
5156
5156
 
5157
5157
  const BaseLayout = ({ title, description, lang, c, toast, faviconUrl, faviconVersion, noindex, isAuthenticated = false, children })=>{
5158
5158
  // Read lang from Hono context if available, otherwise use prop or default
@@ -6223,7 +6223,7 @@ setupRoutes.post("/setup", async (c)=>{
6223
6223
  await c.var.services.navItems.create({
6224
6224
  type: "link",
6225
6225
  label: "Collections",
6226
- url: "/collections"
6226
+ url: "/c"
6227
6227
  });
6228
6228
  // Seed default navigation items
6229
6229
  await c.var.services.navItems.create({
@@ -14925,7 +14925,11 @@ composeRoutes.post("/", async (c)=>{
14925
14925
  const rssRoutes = new Hono();
14926
14926
  /**
14927
14927
  * Build FeedData from the Hono context.
14928
- */ async function buildFeedData(c) {
14928
+ *
14929
+ * @param c - Hono context
14930
+ * @param opts - Filter options for the feed
14931
+ * @returns Feed data ready for rendering
14932
+ */ async function buildFeedData(c, opts) {
14929
14933
  const { appConfig } = c.var;
14930
14934
  const siteName = appConfig.siteName;
14931
14935
  const siteDescription = appConfig.siteDescription;
@@ -14935,6 +14939,8 @@ const rssRoutes = new Hono();
14935
14939
  const posts = await c.var.services.posts.list({
14936
14940
  status: "published",
14937
14941
  excludeReplies: true,
14942
+ featured: opts?.featured,
14943
+ format: opts?.format,
14938
14944
  limit: feedLimit
14939
14945
  });
14940
14946
  // Batch load media for enclosures
@@ -14955,9 +14961,22 @@ const rssRoutes = new Hono();
14955
14961
  posts: postViews
14956
14962
  };
14957
14963
  }
14958
- // RSS 2.0 Feed - main feed at /feed
14964
+ /**
14965
+ * Parse and validate the `format` query parameter.
14966
+ * Returns a valid Format or undefined if missing/invalid.
14967
+ */ function parseFormatQuery(c) {
14968
+ const raw = c.req.query("format");
14969
+ if (raw && FORMATS.includes(raw)) {
14970
+ return raw;
14971
+ }
14972
+ return undefined;
14973
+ }
14974
+ // --- Featured feed (curated) ---
14975
+ // RSS 2.0 — /feed
14959
14976
  rssRoutes.get("/", async (c)=>{
14960
- const feedData = await buildFeedData(c);
14977
+ const feedData = await buildFeedData(c, {
14978
+ featured: true
14979
+ });
14961
14980
  const xml = defaultRssRenderer(feedData);
14962
14981
  return new Response(xml, {
14963
14982
  headers: {
@@ -14965,9 +14984,38 @@ rssRoutes.get("/", async (c)=>{
14965
14984
  }
14966
14985
  });
14967
14986
  });
14968
- // Atom Feed
14987
+ // Atom — /feed/atom.xml
14969
14988
  rssRoutes.get("/atom.xml", async (c)=>{
14970
- const feedData = await buildFeedData(c);
14989
+ const feedData = await buildFeedData(c, {
14990
+ featured: true
14991
+ });
14992
+ const xml = defaultAtomRenderer(feedData);
14993
+ return new Response(xml, {
14994
+ headers: {
14995
+ "Content-Type": "application/atom+xml; charset=utf-8"
14996
+ }
14997
+ });
14998
+ });
14999
+ // --- All posts feed ---
15000
+ // RSS 2.0 — /feed/all
15001
+ rssRoutes.get("/all", async (c)=>{
15002
+ const format = parseFormatQuery(c);
15003
+ const feedData = await buildFeedData(c, {
15004
+ format
15005
+ });
15006
+ const xml = defaultRssRenderer(feedData);
15007
+ return new Response(xml, {
15008
+ headers: {
15009
+ "Content-Type": "application/rss+xml; charset=utf-8"
15010
+ }
15011
+ });
15012
+ });
15013
+ // Atom — /feed/all/atom.xml
15014
+ rssRoutes.get("/all/atom.xml", async (c)=>{
15015
+ const format = parseFormatQuery(c);
15016
+ const feedData = await buildFeedData(c, {
15017
+ format
15018
+ });
14971
15019
  const xml = defaultAtomRenderer(feedData);
14972
15020
  return new Response(xml, {
14973
15021
  headers: {
@@ -15575,7 +15623,7 @@ const errorHandler = (err, c)=>{
15575
15623
  app.route("/archive", archiveRoutes);
15576
15624
  app.route("/featured", featuredRoutes);
15577
15625
  app.route("/latest", latestRoutes);
15578
- app.route("/collections", collectionsPageRoutes);
15626
+ app.route("/c", collectionsPageRoutes);
15579
15627
  app.route("/c", collectionRoutes);
15580
15628
  app.route("/p", postRoutes);
15581
15629
  app.route("/", homeRoutes);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jant/core",
3
- "version": "0.3.33",
3
+ "version": "0.3.35",
4
4
  "description": "A modern, open-source microblogging platform built on Cloudflare Workers",
5
5
  "type": "module",
6
6
  "exports": {
package/src/app.tsx CHANGED
@@ -266,7 +266,7 @@ export function createApp(): App {
266
266
  app.route("/archive", archiveRoutes);
267
267
  app.route("/featured", featuredRoutes);
268
268
  app.route("/latest", latestRoutes);
269
- app.route("/collections", collectionsPageRoutes);
269
+ app.route("/c", collectionsPageRoutes);
270
270
  app.route("/c", collectionRoutes);
271
271
  app.route("/p", postRoutes);
272
272
  app.route("/", homeRoutes);
@@ -23,7 +23,7 @@ async function runSetupSeed(services: {
23
23
  await services.navItems.create({
24
24
  type: "link",
25
25
  label: "Collections",
26
- url: "/collections",
26
+ url: "/c",
27
27
  });
28
28
  await services.navItems.create({
29
29
  type: "link",
@@ -198,7 +198,7 @@ setupRoutes.post("/setup", async (c) => {
198
198
  await c.var.services.navItems.create({
199
199
  type: "link",
200
200
  label: "Collections",
201
- url: "/collections",
201
+ url: "/c",
202
202
  });
203
203
  // Seed default navigation items
204
204
  await c.var.services.navItems.create({
@@ -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
+ featured: true,
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
+ featured: true,
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
+ featured: true,
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
+ featured: true,
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
+ featured: true,
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
+ featured: true,
136
375
  });
137
376
  }
138
377
 
@@ -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,22 @@ type Env = { Bindings: Bindings; Variables: AppVariables };
15
21
 
16
22
  export const rssRoutes = new Hono<Env>();
17
23
 
24
+ interface FeedOptions {
25
+ featured?: boolean;
26
+ format?: Format;
27
+ }
28
+
18
29
  /**
19
30
  * Build FeedData from the Hono context.
31
+ *
32
+ * @param c - Hono context
33
+ * @param opts - Filter options for the feed
34
+ * @returns Feed data ready for rendering
20
35
  */
21
- async function buildFeedData(c: Context<Env>): Promise<FeedData> {
36
+ async function buildFeedData(
37
+ c: Context<Env>,
38
+ opts?: FeedOptions,
39
+ ): Promise<FeedData> {
22
40
  const { appConfig } = c.var;
23
41
  const siteName = appConfig.siteName;
24
42
  const siteDescription = appConfig.siteDescription;
@@ -29,6 +47,8 @@ async function buildFeedData(c: Context<Env>): Promise<FeedData> {
29
47
  const posts = await c.var.services.posts.list({
30
48
  status: "published",
31
49
  excludeReplies: true,
50
+ featured: opts?.featured,
51
+ format: opts?.format,
32
52
  limit: feedLimit,
33
53
  });
34
54
 
@@ -61,9 +81,23 @@ async function buildFeedData(c: Context<Env>): Promise<FeedData> {
61
81
  };
62
82
  }
63
83
 
64
- // RSS 2.0 Feed - main feed at /feed
84
+ /**
85
+ * Parse and validate the `format` query parameter.
86
+ * Returns a valid Format or undefined if missing/invalid.
87
+ */
88
+ function parseFormatQuery(c: Context<Env>): Format | undefined {
89
+ const raw = c.req.query("format");
90
+ if (raw && (FORMATS as readonly string[]).includes(raw)) {
91
+ return raw as Format;
92
+ }
93
+ return undefined;
94
+ }
95
+
96
+ // --- Featured feed (curated) ---
97
+
98
+ // RSS 2.0 — /feed
65
99
  rssRoutes.get("/", async (c) => {
66
- const feedData = await buildFeedData(c);
100
+ const feedData = await buildFeedData(c, { featured: true });
67
101
  const xml = defaultRssRenderer(feedData);
68
102
 
69
103
  return new Response(xml, {
@@ -73,9 +107,37 @@ rssRoutes.get("/", async (c) => {
73
107
  });
74
108
  });
75
109
 
76
- // Atom Feed
110
+ // Atom — /feed/atom.xml
77
111
  rssRoutes.get("/atom.xml", async (c) => {
78
- const feedData = await buildFeedData(c);
112
+ const feedData = await buildFeedData(c, { featured: true });
113
+ const xml = defaultAtomRenderer(feedData);
114
+
115
+ return new Response(xml, {
116
+ headers: {
117
+ "Content-Type": "application/atom+xml; charset=utf-8",
118
+ },
119
+ });
120
+ });
121
+
122
+ // --- All posts feed ---
123
+
124
+ // RSS 2.0 — /feed/all
125
+ rssRoutes.get("/all", async (c) => {
126
+ const format = parseFormatQuery(c);
127
+ const feedData = await buildFeedData(c, { format });
128
+ const xml = defaultRssRenderer(feedData);
129
+
130
+ return new Response(xml, {
131
+ headers: {
132
+ "Content-Type": "application/rss+xml; charset=utf-8",
133
+ },
134
+ });
135
+ });
136
+
137
+ // Atom — /feed/all/atom.xml
138
+ rssRoutes.get("/all/atom.xml", async (c) => {
139
+ const format = parseFormatQuery(c);
140
+ const feedData = await buildFeedData(c, { format });
79
141
  const xml = defaultAtomRenderer(feedData);
80
142
 
81
143
  return new Response(xml, {
@@ -22,7 +22,7 @@ export type NavItemType = (typeof NAV_ITEM_TYPES)[number];
22
22
  export const SYSTEM_NAV_KEYS = {
23
23
  rss: { defaultLabel: "RSS", url: "/feed" },
24
24
  dashboard: { defaultLabel: "Dashboard", url: "/dash" },
25
- collections: { defaultLabel: "Collections", url: "/collections" },
25
+ collections: { defaultLabel: "Collections", url: "/c" },
26
26
  archive: { defaultLabel: "Archive", url: "/archive" },
27
27
  } as const;
28
28
  export type SystemNavKey = keyof typeof SYSTEM_NAV_KEYS;