@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.
- package/README.md +1 -0
- package/dist/{app-BfoG98VD.js → app-CAtsuLLh.js} +306 -72
- package/dist/client/_assets/client-auth.js +77 -68
- package/dist/client/_assets/client.css +1 -1
- package/dist/index.js +1 -1
- package/dist/node.js +2 -2
- package/package.json +1 -1
- package/src/app.tsx +5 -0
- package/src/client/components/__tests__/jant-collection-sidebar.test.ts +56 -0
- package/src/client/components/jant-collection-form.ts +2 -0
- package/src/client/components/jant-collection-sidebar.ts +19 -6
- package/src/i18n/locales/en.po +14 -0
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +14 -0
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +14 -0
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/lib/__tests__/collection-groups.test.ts +63 -0
- package/src/lib/__tests__/constants.test.ts +23 -1
- package/src/lib/__tests__/schemas.test.ts +18 -0
- package/src/lib/__tests__/slug-format.test.ts +8 -0
- package/src/lib/__tests__/timeline.test.ts +84 -2
- package/src/lib/collection-groups.ts +69 -0
- package/src/lib/constants.ts +20 -0
- package/src/lib/schemas.ts +8 -1
- package/src/lib/slug-format.ts +8 -0
- package/src/lib/timeline.ts +30 -23
- package/src/routes/pages/collection.tsx +89 -37
- package/src/runtime/__tests__/readiness.test.ts +89 -0
- package/src/runtime/readiness.ts +129 -0
- package/src/services/__tests__/collection.test.ts +45 -0
- package/src/services/__tests__/post-timeline.test.ts +58 -0
- package/src/services/__tests__/post.test.ts +35 -1
- package/src/services/collection.ts +55 -0
- package/src/services/post.ts +121 -12
- package/src/styles/ui.css +17 -0
- package/src/types/props.ts +2 -1
- package/src/ui/pages/CollectionPage.tsx +84 -17
- 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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/lib/constants.ts
CHANGED
|
@@ -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
|
*
|
package/src/lib/schemas.ts
CHANGED
|
@@ -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(
|
package/src/lib/slug-format.ts
CHANGED
|
@@ -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
|
|
package/src/lib/timeline.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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.
|
|
485
|
-
options.
|
|
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
|
|
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
|
-
|
|
81
|
-
|
|
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 (!
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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 {
|
|
117
|
-
|
|
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(
|
|
127
|
-
: buildPageTitle(
|
|
128
|
-
description:
|
|
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
|
-
|
|
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(
|
|
172
|
+
? toPublicPath(canonicalPagePath, navData.sitePathPrefix)
|
|
140
173
|
: toPublicPath(
|
|
141
|
-
|
|
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
|
|
190
|
+
const slugExpression = c.req.param("slug");
|
|
158
191
|
|
|
159
|
-
const
|
|
160
|
-
|
|
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 =
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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(
|
|
211
|
-
siteDescription:
|
|
259
|
+
siteName: buildPageTitle(selectionTitle, siteName),
|
|
260
|
+
siteDescription:
|
|
261
|
+
selection.collections.length === 1
|
|
262
|
+
? (primaryCollection.description ?? "")
|
|
263
|
+
: "",
|
|
212
264
|
siteUrl,
|
|
213
265
|
selfUrl: toAbsoluteSiteUrl(
|
|
214
|
-
|
|
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
|
+
});
|