@jant/core 0.3.39 → 0.3.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +1 -0
  2. package/dist/{app-BfoG98VD.js → app-CAtsuLLh.js} +306 -72
  3. package/dist/client/_assets/client-auth.js +77 -68
  4. package/dist/client/_assets/client.css +1 -1
  5. package/dist/index.js +1 -1
  6. package/dist/node.js +2 -2
  7. package/package.json +1 -1
  8. package/src/app.tsx +5 -0
  9. package/src/client/components/__tests__/jant-collection-sidebar.test.ts +56 -0
  10. package/src/client/components/jant-collection-form.ts +2 -0
  11. package/src/client/components/jant-collection-sidebar.ts +19 -6
  12. package/src/i18n/locales/en.po +14 -0
  13. package/src/i18n/locales/en.ts +1 -1
  14. package/src/i18n/locales/zh-Hans.po +14 -0
  15. package/src/i18n/locales/zh-Hans.ts +1 -1
  16. package/src/i18n/locales/zh-Hant.po +14 -0
  17. package/src/i18n/locales/zh-Hant.ts +1 -1
  18. package/src/lib/__tests__/collection-groups.test.ts +63 -0
  19. package/src/lib/__tests__/constants.test.ts +23 -1
  20. package/src/lib/__tests__/schemas.test.ts +18 -0
  21. package/src/lib/__tests__/slug-format.test.ts +8 -0
  22. package/src/lib/__tests__/timeline.test.ts +84 -2
  23. package/src/lib/collection-groups.ts +69 -0
  24. package/src/lib/constants.ts +20 -0
  25. package/src/lib/schemas.ts +8 -1
  26. package/src/lib/slug-format.ts +8 -0
  27. package/src/lib/timeline.ts +30 -23
  28. package/src/routes/pages/collection.tsx +89 -37
  29. package/src/runtime/__tests__/readiness.test.ts +89 -0
  30. package/src/runtime/readiness.ts +129 -0
  31. package/src/services/__tests__/collection.test.ts +45 -0
  32. package/src/services/__tests__/post-timeline.test.ts +58 -0
  33. package/src/services/__tests__/post.test.ts +35 -1
  34. package/src/services/collection.ts +55 -0
  35. package/src/services/post.ts +121 -12
  36. package/src/styles/ui.css +17 -0
  37. package/src/types/props.ts +2 -1
  38. package/src/ui/pages/CollectionPage.tsx +84 -17
  39. package/src/ui/shared/CollectionDirectory.tsx +18 -4
@@ -561,7 +561,7 @@ describe("Timeline data assembly", () => {
561
561
  ]);
562
562
 
563
563
  const result = await assembleCollectionTimeline(createTimelineContext(), {
564
- collectionId: collection.id,
564
+ collectionIds: [collection.id],
565
565
  isAuthenticated: true,
566
566
  sortOrder: "newest",
567
567
  });
@@ -611,7 +611,7 @@ describe("Timeline data assembly", () => {
611
611
  });
612
612
 
613
613
  const result = await assembleCollectionTimeline(createTimelineContext(), {
614
- collectionId: collection.id,
614
+ collectionIds: [collection.id],
615
615
  isAuthenticated: true,
616
616
  sortOrder: "newest",
617
617
  });
@@ -635,6 +635,88 @@ describe("Timeline data assembly", () => {
635
635
  );
636
636
  });
637
637
 
638
+ it("highlights the union of collected posts across multiple collections", async () => {
639
+ const smart = await collectionService.create({
640
+ slug: "smart",
641
+ title: "Smart",
642
+ });
643
+ const movies = await collectionService.create({
644
+ slug: "movies",
645
+ title: "Movies",
646
+ });
647
+ const firstRoot = await postService.create({
648
+ format: "note",
649
+ bodyMarkdown: "Thread root",
650
+ });
651
+ const smartReply = await postService.create({
652
+ format: "note",
653
+ bodyMarkdown: "Smart reply",
654
+ replyToId: firstRoot.id,
655
+ });
656
+ await postService.create({
657
+ format: "note",
658
+ bodyMarkdown: "Hidden middle reply",
659
+ replyToId: firstRoot.id,
660
+ });
661
+ const movieReply = await postService.create({
662
+ format: "note",
663
+ bodyMarkdown: "Movie reply",
664
+ replyToId: firstRoot.id,
665
+ });
666
+ const secondRoot = await postService.create({
667
+ format: "note",
668
+ bodyMarkdown: "Second thread root",
669
+ });
670
+
671
+ await db.insert(postCollections).values([
672
+ {
673
+ siteId: DEFAULT_TEST_SITE_ID,
674
+ postId: smartReply.id,
675
+ collectionId: smart.id,
676
+ createdAt: 100,
677
+ },
678
+ {
679
+ siteId: DEFAULT_TEST_SITE_ID,
680
+ postId: movieReply.id,
681
+ collectionId: movies.id,
682
+ createdAt: 200,
683
+ },
684
+ {
685
+ siteId: DEFAULT_TEST_SITE_ID,
686
+ postId: secondRoot.id,
687
+ collectionId: movies.id,
688
+ createdAt: 300,
689
+ },
690
+ ]);
691
+
692
+ const result = await assembleCollectionTimeline(createTimelineContext(), {
693
+ collectionIds: [smart.id, movies.id],
694
+ isAuthenticated: true,
695
+ sortOrder: "newest",
696
+ });
697
+
698
+ expect(result.items).toHaveLength(2);
699
+ expect(result.items[0]?.post.id).toBe(secondRoot.id);
700
+ expect(result.items[1]?.post.id).toBe(firstRoot.id);
701
+ expect(result.items[1]?.curatedThread?.segments).toEqual([
702
+ expect.objectContaining({
703
+ post: expect.objectContaining({ id: firstRoot.id }),
704
+ hiddenBeforeCount: 0,
705
+ highlighted: false,
706
+ }),
707
+ expect.objectContaining({
708
+ post: expect.objectContaining({ id: smartReply.id }),
709
+ hiddenBeforeCount: 0,
710
+ highlighted: true,
711
+ }),
712
+ expect.objectContaining({
713
+ post: expect.objectContaining({ id: movieReply.id }),
714
+ hiddenBeforeCount: 1,
715
+ highlighted: true,
716
+ }),
717
+ ]);
718
+ });
719
+
638
720
  it("omits private timeline items from unauthenticated partial refreshes", async () => {
639
721
  const root = await postService.create({
640
722
  format: "note",
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Helpers for deriving aggregate collection groups from divider sections.
3
+ */
4
+
5
+ export interface GroupableCollectionItem {
6
+ type: "collection" | "divider";
7
+ label?: string | null;
8
+ collection?: {
9
+ slug: string;
10
+ };
11
+ }
12
+
13
+ export interface DividerCollectionGroup {
14
+ slugExpression: string;
15
+ collectionCount: number;
16
+ }
17
+
18
+ /**
19
+ * Returns the aggregate collection selection that belongs to a divider.
20
+ *
21
+ * A divider maps to the consecutive collection items that follow it until the
22
+ * next divider. Groups with fewer than two collections do not produce an
23
+ * aggregate selection.
24
+ *
25
+ * @param items - Ordered collection directory items
26
+ * @param dividerIndex - Index of the divider item to inspect
27
+ * @returns Aggregate slug expression and count, or `null`
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * getDividerCollectionGroup(
32
+ * [
33
+ * { type: "divider", label: "Reading" },
34
+ * { type: "collection", collection: { slug: "books" } },
35
+ * { type: "collection", collection: { slug: "essays" } },
36
+ * ],
37
+ * 0,
38
+ * );
39
+ * ```
40
+ */
41
+ export function getDividerCollectionGroup(
42
+ items: readonly GroupableCollectionItem[],
43
+ dividerIndex: number,
44
+ ): DividerCollectionGroup | null {
45
+ const divider = items[dividerIndex];
46
+ if (!divider || divider.type !== "divider" || !divider.label) {
47
+ return null;
48
+ }
49
+
50
+ const slugs: string[] = [];
51
+
52
+ for (let index = dividerIndex + 1; index < items.length; index += 1) {
53
+ const item = items[index];
54
+ if (!item) break;
55
+ if (item.type === "divider") break;
56
+ const slug = item.collection?.slug;
57
+ if (!slug) continue;
58
+ slugs.push(slug);
59
+ }
60
+
61
+ if (slugs.length < 2) {
62
+ return null;
63
+ }
64
+
65
+ return {
66
+ slugExpression: slugs.join("+"),
67
+ collectionCount: slugs.length,
68
+ };
69
+ }
@@ -32,6 +32,16 @@ export const RESERVED_PATHS = [
32
32
 
33
33
  export type ReservedPath = (typeof RESERVED_PATHS)[number];
34
34
 
35
+ /**
36
+ * Reserved collection slugs within the `/c/*` namespace.
37
+ *
38
+ * These values are valid top-level paths elsewhere but are unavailable as
39
+ * collection slugs because they collide with dedicated collection routes.
40
+ */
41
+ export const RESERVED_COLLECTION_SLUGS = ["new"] as const;
42
+
43
+ export type ReservedCollectionSlug = (typeof RESERVED_COLLECTION_SLUGS)[number];
44
+
35
45
  /**
36
46
  * Check if a path is reserved
37
47
  */
@@ -40,6 +50,16 @@ export function isReservedPath(path: string): boolean {
40
50
  return RESERVED_PATHS.includes(firstSegment as ReservedPath);
41
51
  }
42
52
 
53
+ /**
54
+ * Check if a collection slug is reserved within the collection namespace.
55
+ */
56
+ export function isReservedCollectionSlug(slug: string): boolean {
57
+ const normalized = slug.trim().toLowerCase();
58
+ return RESERVED_COLLECTION_SLUGS.includes(
59
+ normalized as ReservedCollectionSlug,
60
+ );
61
+ }
62
+
43
63
  /**
44
64
  * Settings keys - derived from CONFIG_FIELDS (Single Source of Truth)
45
65
  *
@@ -30,6 +30,7 @@ import {
30
30
  import { ValidationError } from "./errors.js";
31
31
  import { createTypeIdSchema, ID_PREFIX } from "./ids.js";
32
32
  import { normalizeSlug } from "./slug-format.js";
33
+ import { isReservedCollectionSlug } from "./constants.js";
33
34
  import { sanitizeUrl, normalizePath } from "./url.js";
34
35
 
35
36
  // =============================================================================
@@ -459,6 +460,9 @@ export const CollectionSlugSchema = z
459
460
  .max(MAX_COLLECTION_SLUG_LENGTH, {
460
461
  message: `Keep this link under ${MAX_COLLECTION_SLUG_LENGTH} characters.`,
461
462
  })
463
+ .refine((value) => !value.includes("+"), {
464
+ message: "Use lowercase letters, numbers, and hyphens only.",
465
+ })
462
466
  .transform(normalizeSlug)
463
467
  .pipe(
464
468
  z
@@ -467,7 +471,10 @@ export const CollectionSlugSchema = z
467
471
  .max(MAX_COLLECTION_SLUG_LENGTH, {
468
472
  message: `Keep this link under ${MAX_COLLECTION_SLUG_LENGTH} characters.`,
469
473
  })
470
- .regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/),
474
+ .regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/)
475
+ .refine((value) => !isReservedCollectionSlug(value), {
476
+ message: "This link is reserved. Choose something else.",
477
+ }),
471
478
  );
472
479
 
473
480
  export const CollectionTitleSchema = sanitizeText(
@@ -15,6 +15,7 @@ export type SlugValidationIssue = "invalid" | "reserved" | "too_long";
15
15
 
16
16
  interface SlugValidationOptions {
17
17
  maxLength?: number;
18
+ additionalReservedValues?: readonly string[];
18
19
  }
19
20
 
20
21
  /**
@@ -67,6 +68,13 @@ export function getSlugValidationIssue(
67
68
  if (options.maxLength && slug.length > options.maxLength) return "too_long";
68
69
  if (!SLUG_PATTERN.test(slug)) return "invalid";
69
70
  if (isReservedPath(slug)) return "reserved";
71
+ if (
72
+ options.additionalReservedValues?.some(
73
+ (value) => value.toLowerCase() === slug.toLowerCase(),
74
+ )
75
+ ) {
76
+ return "reserved";
77
+ }
70
78
  return null;
71
79
  }
72
80
 
@@ -26,6 +26,7 @@ export interface TimelineResult {
26
26
  items: TimelineItemView[];
27
27
  currentPage: number;
28
28
  totalPages: number;
29
+ totalCount: number;
29
30
  }
30
31
 
31
32
  type CuratedThreadSelectionMap = Map<string, Set<string>>;
@@ -299,8 +300,8 @@ async function buildCuratedThreadItems(
299
300
  *
300
301
  * @example
301
302
  * ```ts
302
- * const { items, currentPage, totalPages } = await assembleTimeline(c);
303
- * const { items, currentPage, totalPages } = await assembleTimeline(c, { page: 2 });
303
+ * const { items, currentPage, totalPages, totalCount } = await assembleTimeline(c);
304
+ * const { items, currentPage, totalPages, totalCount } = await assembleTimeline(c, { page: 2 });
304
305
  * ```
305
306
  */
306
307
  export async function assembleTimeline(
@@ -334,12 +335,12 @@ export async function assembleTimeline(
334
335
  const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
335
336
 
336
337
  if (posts.length === 0) {
337
- return { items: [], currentPage: page, totalPages };
338
+ return { items: [], currentPage: page, totalPages, totalCount };
338
339
  }
339
340
 
340
341
  const items = await buildTimelineItems(c, posts);
341
342
 
342
- return { items, currentPage: page, totalPages };
343
+ return { items, currentPage: page, totalPages, totalCount };
343
344
  }
344
345
 
345
346
  /**
@@ -407,7 +408,7 @@ export async function assembleFeaturedTimeline(
407
408
  const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
408
409
 
409
410
  if (rootIds.length === 0) {
410
- return { items: [], currentPage: page, totalPages };
411
+ return { items: [], currentPage: page, totalPages, totalCount };
411
412
  }
412
413
 
413
414
  const threadsByRootId =
@@ -431,7 +432,7 @@ export async function assembleFeaturedTimeline(
431
432
  selectedPostIdsByThread,
432
433
  );
433
434
 
434
- return { items, currentPage: page, totalPages };
435
+ return { items, currentPage: page, totalPages, totalCount };
435
436
  }
436
437
 
437
438
  /**
@@ -442,13 +443,13 @@ export async function assembleFeaturedTimeline(
442
443
  * expanded and intervening non-collected posts collapse into hidden-count gaps.
443
444
  *
444
445
  * @param c - Hono context (provides services + appConfig)
445
- * @param options - Collection ID, optional page number, auth state, and sort
446
+ * @param options - Collection IDs, optional page number, auth state, and sort
446
447
  * @returns Collection timeline items with pagination info
447
448
  */
448
449
  export async function assembleCollectionTimeline(
449
450
  c: Context<Env>,
450
451
  options: {
451
- collectionId: string;
452
+ collectionIds: string[];
452
453
  page?: number;
453
454
  isAuthenticated?: boolean;
454
455
  sortOrder?: CollectionSortOrder;
@@ -460,29 +461,35 @@ export async function assembleCollectionTimeline(
460
461
  const excludePrivate = !(options.isAuthenticated ?? false);
461
462
 
462
463
  const [totalCount, rootIds] = await Promise.all([
463
- c.var.services.posts.countCollectionThreadRoots(options.collectionId, {
464
- status: "published",
465
- excludePrivate,
466
- }),
467
- c.var.services.posts.listCollectionThreadRootIds(options.collectionId, {
468
- status: "published",
469
- excludePrivate,
470
- sortOrder: options.sortOrder,
471
- limit: pageSize,
472
- offset,
473
- }),
464
+ c.var.services.posts.countCollectionThreadRootsForCollections(
465
+ options.collectionIds,
466
+ {
467
+ status: "published",
468
+ excludePrivate,
469
+ },
470
+ ),
471
+ c.var.services.posts.listCollectionThreadRootIdsForCollections(
472
+ options.collectionIds,
473
+ {
474
+ status: "published",
475
+ excludePrivate,
476
+ sortOrder: options.sortOrder,
477
+ limit: pageSize,
478
+ offset,
479
+ },
480
+ ),
474
481
  ]);
475
482
 
476
483
  const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
477
484
 
478
485
  if (rootIds.length === 0) {
479
- return { items: [], currentPage: page, totalPages };
486
+ return { items: [], currentPage: page, totalPages, totalCount };
480
487
  }
481
488
 
482
489
  const [threadsByRootId, collectedPostIdsByThread] = await Promise.all([
483
490
  c.var.services.posts.getPublishedThreads(rootIds),
484
- c.var.services.posts.getCollectionPostIdsByThread(
485
- options.collectionId,
491
+ c.var.services.posts.getCollectionPostIdsByThreadForCollections(
492
+ options.collectionIds,
486
493
  rootIds,
487
494
  ),
488
495
  ]);
@@ -499,5 +506,5 @@ export async function assembleCollectionTimeline(
499
506
  selectedPostIdsByThread,
500
507
  );
501
508
 
502
- return { items, currentPage: page, totalPages };
509
+ return { items, currentPage: page, totalPages, totalCount };
503
510
  }
@@ -28,6 +28,16 @@ type Env = { Bindings: Bindings; Variables: AppVariables };
28
28
 
29
29
  export const collectionRoutes = new Hono<Env>();
30
30
 
31
+ function buildCollectionSelectionTitle(
32
+ collections: { title: string }[],
33
+ ): string {
34
+ return collections.map((collection) => collection.title).join(" + ");
35
+ }
36
+
37
+ function getCanonicalSelectionPath(slugExpression: string): string {
38
+ return `/c/${slugExpression}`;
39
+ }
40
+
31
41
  function resolveReturnHref(
32
42
  value: string | undefined,
33
43
  fallback: string,
@@ -73,38 +83,53 @@ collectionRoutes.get("/:slug/edit", async (c) => {
73
83
  });
74
84
 
75
85
  collectionRoutes.get("/:slug", async (c) => {
76
- const slug = c.req.param("slug");
86
+ const slugExpression = c.req.param("slug");
77
87
  const page = parsePageNumber(c.req.query("page"));
78
88
  const paginatedPageTitle = formatPageLabel(page);
79
89
 
80
- // Start navData + collection fetch in parallel
81
- const [collection, navData] = await Promise.all([
82
- c.var.services.collections.getBySlug(slug),
90
+ const [selection, navData] = await Promise.all([
91
+ c.var.services.collections.resolveSelection(slugExpression),
83
92
  getNavigationData(c),
84
93
  ]);
85
- if (!collection) return c.notFound();
94
+ if (!selection) return c.notFound();
95
+
96
+ const canonicalPagePath = getCanonicalSelectionPath(selection.slugExpression);
97
+ if (slugExpression !== selection.slugExpression) {
98
+ const search = new URL(c.req.url).search;
99
+ return c.redirect(
100
+ toPublicPath(`${canonicalPagePath}${search}`, navData.sitePathPrefix),
101
+ 301,
102
+ );
103
+ }
104
+
86
105
  const sortQuery = c.req.query("sort");
87
106
  const requestedSort =
88
107
  sortQuery && CollectionSortOrderSchema.safeParse(sortQuery).success
89
108
  ? CollectionSortOrderSchema.parse(sortQuery)
90
109
  : undefined;
110
+ const primaryCollection = selection.collections[0];
111
+ if (!primaryCollection) return c.notFound();
112
+ const collectionIds = selection.collections.map(
113
+ (collection) => collection.id,
114
+ );
115
+ const isAggregate = selection.collections.length > 1;
91
116
 
92
- const [totalThreadCount, ratedPostCount] = await Promise.all([
93
- c.var.services.posts.countCollectionThreadRoots(collection.id, {
94
- status: "published",
95
- excludePrivate: !navData.isAuthenticated,
96
- }),
97
- c.var.services.posts.count({
98
- collectionId: collection.id,
117
+ const ratedPostCount = await c.var.services.posts.countUpTo(
118
+ {
119
+ collectionIds,
99
120
  status: "published",
100
121
  excludePrivate: !navData.isAuthenticated,
101
122
  hasRating: true,
102
- }),
103
- ]);
123
+ },
124
+ 2,
125
+ );
104
126
  const showRatingSort = supportsCollectionRatingSort(ratedPostCount);
127
+ const requestedDefaultSort = isAggregate
128
+ ? "newest"
129
+ : primaryCollection.sortOrder;
105
130
  const defaultSort = resolveCollectionSortOrder(
106
131
  undefined,
107
- collection.sortOrder,
132
+ requestedDefaultSort,
108
133
  showRatingSort,
109
134
  );
110
135
  const currentSort = resolveCollectionSortOrder(
@@ -113,32 +138,40 @@ collectionRoutes.get("/:slug", async (c) => {
113
138
  showRatingSort,
114
139
  );
115
140
 
116
- const { items, totalPages } = await assembleCollectionTimeline(c, {
117
- collectionId: collection.id,
141
+ const {
142
+ items,
143
+ totalCount: totalThreadCount,
144
+ totalPages,
145
+ } = await assembleCollectionTimeline(c, {
146
+ collectionIds,
118
147
  page,
119
148
  isAuthenticated: navData.isAuthenticated,
120
149
  sortOrder: currentSort,
121
150
  });
151
+ const selectionTitle = buildCollectionSelectionTitle(selection.collections);
122
152
 
123
153
  return renderPublicPage(c, {
124
154
  title:
125
155
  page > 1
126
- ? buildPageTitle(collection.title, paginatedPageTitle, navData.siteName)
127
- : buildPageTitle(collection.title, navData.siteName),
128
- description: collection.description ?? undefined,
156
+ ? buildPageTitle(selectionTitle, paginatedPageTitle, navData.siteName)
157
+ : buildPageTitle(selectionTitle, navData.siteName),
158
+ description: isAggregate
159
+ ? undefined
160
+ : (primaryCollection.description ?? undefined),
129
161
  navData,
130
162
  content: (
131
163
  <CollectionPage
132
- collection={collection}
164
+ collections={selection.collections}
133
165
  items={items}
134
166
  totalThreadCount={totalThreadCount}
135
167
  currentPage={page}
136
168
  totalPages={totalPages}
169
+ pagePath={canonicalPagePath}
137
170
  baseUrl={
138
171
  currentSort === defaultSort
139
- ? toPublicPath(`/c/${collection.slug}`, navData.sitePathPrefix)
172
+ ? toPublicPath(canonicalPagePath, navData.sitePathPrefix)
140
173
  : toPublicPath(
141
- `/c/${collection.slug}?sort=${currentSort}`,
174
+ `${canonicalPagePath}?sort=${currentSort}`,
142
175
  navData.sitePathPrefix,
143
176
  )
144
177
  }
@@ -154,25 +187,40 @@ collectionRoutes.get("/:slug", async (c) => {
154
187
 
155
188
  // Collection RSS feed
156
189
  collectionRoutes.get("/:slug/feed", async (c) => {
157
- const slug = c.req.param("slug");
190
+ const slugExpression = c.req.param("slug");
158
191
 
159
- const collection = await c.var.services.collections.getBySlug(slug);
160
- if (!collection) return c.notFound();
192
+ const selection =
193
+ await c.var.services.collections.resolveSelection(slugExpression);
194
+ if (!selection) return c.notFound();
195
+
196
+ if (slugExpression !== selection.slugExpression) {
197
+ const search = new URL(c.req.url).search;
198
+ return c.redirect(
199
+ toPublicPath(
200
+ `${getCanonicalSelectionPath(selection.slugExpression)}/feed${search}`,
201
+ c.var.appConfig.sitePathPrefix,
202
+ ),
203
+ 301,
204
+ );
205
+ }
161
206
 
162
207
  const { appConfig } = c.var;
163
208
  const siteName = appConfig.siteName;
164
209
  const siteUrl = appConfig.siteUrl;
165
210
  const siteLanguage = appConfig.siteLanguage;
166
211
  const feedLimit = appConfig.rssFeedLimit;
212
+ const primaryCollection = selection.collections[0];
213
+ if (!primaryCollection) return c.notFound();
167
214
 
168
- const entries = await c.var.services.posts.listCollectionFeedEntries(
169
- collection.id,
170
- {
171
- status: "published",
172
- excludePrivate: true,
173
- limit: feedLimit,
174
- },
175
- );
215
+ const entries =
216
+ await c.var.services.posts.listCollectionFeedEntriesForCollections(
217
+ selection.collections.map((collection) => collection.id),
218
+ {
219
+ status: "published",
220
+ excludePrivate: true,
221
+ limit: feedLimit,
222
+ },
223
+ );
176
224
  const posts = entries.map((entry) => entry.post);
177
225
 
178
226
  // Batch load media for enclosures
@@ -205,13 +253,17 @@ collectionRoutes.get("/:slug/feed", async (c) => {
205
253
  feedUpdatedAt: feedTimestamp,
206
254
  };
207
255
  });
256
+ const selectionTitle = buildCollectionSelectionTitle(selection.collections);
208
257
 
209
258
  const xml = defaultRssRenderer({
210
- siteName: buildPageTitle(collection.title, siteName),
211
- siteDescription: collection.description ?? "",
259
+ siteName: buildPageTitle(selectionTitle, siteName),
260
+ siteDescription:
261
+ selection.collections.length === 1
262
+ ? (primaryCollection.description ?? "")
263
+ : "",
212
264
  siteUrl,
213
265
  selfUrl: toAbsoluteSiteUrl(
214
- `/c/${collection.slug}/feed`,
266
+ `${getCanonicalSelectionPath(selection.slugExpression)}/feed`,
215
267
  siteUrl,
216
268
  appConfig.sitePathPrefix,
217
269
  ),
@@ -0,0 +1,89 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createTestDatabase } from "../../__tests__/helpers/db.js";
3
+ import { getInstanceReadiness } from "../readiness.js";
4
+
5
+ const HOSTED_SHARED_ENV = {
6
+ HOSTED_CONTROL_PLANE_BASE_URL: "https://cloud-jant.localtest.me",
7
+ HOSTED_CONTROL_PLANE_DOMAIN_CHECK_SECRET:
8
+ "cloud-domain-check-secret-cloud-domain-check-secret",
9
+ HOSTED_CONTROL_PLANE_INTERNAL_TOKEN: "internal-token-123456",
10
+ HOSTED_CONTROL_PLANE_SSO_SECRET: "cloud-sso-secret-cloud-sso-secret",
11
+ INTERNAL_ADMIN_TOKEN: "internal-admin-token-123456",
12
+ SITE_RESOLUTION_MODE: "host-based" as const,
13
+ };
14
+
15
+ describe("getInstanceReadiness", () => {
16
+ it("reports ready when startup config and database checks pass", async () => {
17
+ const { sqlite } = createTestDatabase();
18
+
19
+ await expect(
20
+ getInstanceReadiness({
21
+ ...HOSTED_SHARED_ENV,
22
+ AUTH_SECRET: "test-secret-with-enough-entropy-for-readiness",
23
+ NODE_SQLITE: sqlite,
24
+ }),
25
+ ).resolves.toEqual({
26
+ status: "ok",
27
+ checks: {
28
+ startupConfig: { ok: true },
29
+ database: { ok: true },
30
+ },
31
+ });
32
+ });
33
+
34
+ it("reports startup configuration failures", async () => {
35
+ const { sqlite } = createTestDatabase();
36
+
37
+ await expect(
38
+ getInstanceReadiness({
39
+ NODE_SQLITE: sqlite,
40
+ }),
41
+ ).resolves.toEqual({
42
+ status: "error",
43
+ checks: {
44
+ startupConfig: {
45
+ ok: false,
46
+ error: "AUTH_SECRET must be set before Jant can accept traffic.",
47
+ },
48
+ database: { ok: true },
49
+ },
50
+ });
51
+ });
52
+
53
+ it("reports host-based startup issues when required env is missing", async () => {
54
+ const { sqlite } = createTestDatabase();
55
+
56
+ const result = await getInstanceReadiness({
57
+ AUTH_SECRET: "test-secret-with-enough-entropy-for-readiness",
58
+ NODE_SQLITE: sqlite,
59
+ SITE_RESOLUTION_MODE: "host-based",
60
+ });
61
+
62
+ expect(result.status).toBe("error");
63
+ expect(result.checks.database).toEqual({ ok: true });
64
+ expect(result.checks.startupConfig.ok).toBe(false);
65
+ expect(result.checks.startupConfig.error).toContain(
66
+ "HOSTED_CONTROL_PLANE_BASE_URL",
67
+ );
68
+ expect(result.checks.startupConfig.error).toContain(
69
+ "HOSTED_CONTROL_PLANE_INTERNAL_TOKEN",
70
+ );
71
+ });
72
+
73
+ it("reports a missing database binding", async () => {
74
+ await expect(
75
+ getInstanceReadiness({
76
+ AUTH_SECRET: "test-secret-with-enough-entropy-for-readiness",
77
+ }),
78
+ ).resolves.toEqual({
79
+ status: "error",
80
+ checks: {
81
+ startupConfig: { ok: true },
82
+ database: {
83
+ ok: false,
84
+ error: "No database binding is configured for this runtime.",
85
+ },
86
+ },
87
+ });
88
+ });
89
+ });