@grove-dev/astro 0.5.0-next.2 → 0.5.0
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 +2 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/index.d.ts +0 -2
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +0 -2
- package/dist/lib/index.js.map +1 -1
- package/dist/ui/button.d.ts +45 -0
- package/dist/ui/button.d.ts.map +1 -0
- package/dist/ui/button.js +82 -0
- package/dist/ui/button.js.map +1 -0
- package/package.json +8 -5
- package/src/components/CardGrid.astro +41 -0
- package/src/components/CardIcon.astro +67 -0
- package/src/components/CategoryGrid.astro +20 -5
- package/src/components/CollectionCard.astro +56 -0
- package/src/components/CollectionIndex.astro +21 -26
- package/src/components/CollectionPage.astro +36 -13
- package/src/components/CollectionRow.astro +62 -192
- package/src/components/CollectionTeaser.astro +4 -0
- package/src/components/ContributorsGrid.astro +4 -1
- package/src/components/DirectoryIndexClient.astro +127 -30
- package/src/components/EditorialSummary.astro +6 -5
- package/src/components/FilterGroupMenu.astro +24 -10
- package/src/components/FilterOptions.astro +6 -1
- package/src/components/FinalCta.astro +5 -3
- package/src/components/Hero.astro +85 -139
- package/src/components/IndexRow.astro +36 -69
- package/src/components/LanguageBreakdown.astro +5 -1
- package/src/components/OriginalCollection.astro +3 -2
- package/src/components/Pagination.astro +7 -20
- package/src/components/ProjectCard.astro +345 -0
- package/src/components/RecordHeader.astro +111 -82
- package/src/components/RecordSection.astro +14 -10
- package/src/components/RecordSidebar.astro +68 -20
- package/src/components/RefinePanel.astro +41 -62
- package/src/components/SmartLensTabs.astro +3 -7
- package/src/components/StackGrid.astro +26 -6
- package/src/components/SubmissionClient.astro +140 -63
- package/src/components/TableOfContents.astro +31 -5
- package/src/components/WhyThisExists.astro +1 -1
- package/src/index.ts +2 -2
- package/src/layouts/BaseLayout.astro +79 -9
- package/src/layouts/Header.astro +5 -4
- package/src/layouts/SectionHeader.astro +3 -2
- package/src/layouts/Seo.astro +28 -17
- package/src/layouts/ThemeToggle.astro +23 -6
- package/src/lib/index.ts +0 -2
- package/src/server/collections.ts +11 -0
- package/src/server/contrast.test.ts +82 -0
- package/src/server/contrast.ts +117 -0
- package/src/server/directory.test.ts +177 -0
- package/src/server/directory.ts +328 -206
- package/src/server/github-repo.test.ts +88 -0
- package/src/server/github-repo.ts +104 -0
- package/src/server/index.ts +4 -3
- package/src/server/models-home.test.ts +101 -0
- package/src/server/models.test.ts +135 -0
- package/src/server/models.ts +170 -42
- package/src/styles.css +128 -16
- package/dist/lib/scores.d.ts +0 -2
- package/dist/lib/scores.d.ts.map +0 -1
- package/dist/lib/scores.js +0 -2
- package/dist/lib/scores.js.map +0 -1
- package/src/components/CurationGrid.astro +0 -41
- package/src/components/DecisionRow.astro +0 -89
- package/src/components/ExploreByCategory.astro +0 -67
- package/src/components/ExploreByStack.astro +0 -78
- package/src/components/GroveDocumentHead.astro +0 -28
- package/src/components/ItemCard.astro +0 -324
- package/src/components/MinimalAbout.astro +0 -82
- package/src/components/ScoreBars.astro +0 -102
- package/src/lib/scores.ts +0 -1
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { fetchRepoMetadata } from './github-repo.js';
|
|
3
|
+
|
|
4
|
+
const originalFetch = globalThis.fetch;
|
|
5
|
+
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
globalThis.fetch = originalFetch;
|
|
8
|
+
vi.restoreAllMocks();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe('fetchRepoMetadata', () => {
|
|
12
|
+
it('returns 400 for invalid owner names', async () => {
|
|
13
|
+
const result = await fetchRepoMetadata({ owner: '../etc', repo: 'passwd' });
|
|
14
|
+
expect(result.ok).toBe(false);
|
|
15
|
+
if (!result.ok) expect(result.status).toBe(400);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('returns 400 for invalid repo names', async () => {
|
|
19
|
+
const result = await fetchRepoMetadata({ owner: 'owner', repo: 'bad name' });
|
|
20
|
+
expect(result.ok).toBe(false);
|
|
21
|
+
if (!result.ok) expect(result.status).toBe(400);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('returns the parsed repo on a 200 response', async () => {
|
|
25
|
+
globalThis.fetch = vi.fn(
|
|
26
|
+
async () =>
|
|
27
|
+
new Response(
|
|
28
|
+
JSON.stringify({
|
|
29
|
+
name: 'demo',
|
|
30
|
+
description: 'desc',
|
|
31
|
+
html_url: 'https://github.com/owner/demo',
|
|
32
|
+
homepage: 'https://demo.example',
|
|
33
|
+
language: 'TypeScript',
|
|
34
|
+
private: false,
|
|
35
|
+
topics: ['x', 'y'],
|
|
36
|
+
}),
|
|
37
|
+
{ status: 200 },
|
|
38
|
+
),
|
|
39
|
+
);
|
|
40
|
+
const result = await fetchRepoMetadata({ owner: 'owner', repo: 'demo' });
|
|
41
|
+
expect(result.ok).toBe(true);
|
|
42
|
+
if (result.ok) {
|
|
43
|
+
expect(result.data.name).toBe('demo');
|
|
44
|
+
expect(result.data.html_url).toBe('https://github.com/owner/demo');
|
|
45
|
+
expect(result.data.topics).toEqual(['x', 'y']);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('forwards an Authorization header when a token is provided', async () => {
|
|
50
|
+
const spy = vi.fn(async () => new Response('{}', { status: 200 }));
|
|
51
|
+
globalThis.fetch = spy;
|
|
52
|
+
await fetchRepoMetadata({ owner: 'owner', repo: 'demo', token: 'gh_test_123' });
|
|
53
|
+
const headers = spy.mock.calls[0]?.[1]?.headers as Record<string, string>;
|
|
54
|
+
expect(headers.Authorization).toBe('Bearer gh_test_123');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('maps a 404 to a structured error', async () => {
|
|
58
|
+
globalThis.fetch = vi.fn(async () => new Response('not found', { status: 404 }));
|
|
59
|
+
const result = await fetchRepoMetadata({ owner: 'owner', repo: 'missing' });
|
|
60
|
+
expect(result.ok).toBe(false);
|
|
61
|
+
if (!result.ok) {
|
|
62
|
+
expect(result.status).toBe(404);
|
|
63
|
+
expect(result.message).toMatch(/not found/i);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('maps a 403 to a rate-limit error', async () => {
|
|
68
|
+
globalThis.fetch = vi.fn(async () => new Response('forbidden', { status: 403 }));
|
|
69
|
+
const result = await fetchRepoMetadata({ owner: 'owner', repo: 'demo' });
|
|
70
|
+
expect(result.ok).toBe(false);
|
|
71
|
+
if (!result.ok) {
|
|
72
|
+
expect(result.status).toBe(403);
|
|
73
|
+
expect(result.message).toMatch(/rate limit/i);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('maps network errors to a 502', async () => {
|
|
78
|
+
globalThis.fetch = vi.fn(async () => {
|
|
79
|
+
throw new Error('ECONNRESET');
|
|
80
|
+
});
|
|
81
|
+
const result = await fetchRepoMetadata({ owner: 'owner', repo: 'demo' });
|
|
82
|
+
expect(result.ok).toBe(false);
|
|
83
|
+
if (!result.ok) {
|
|
84
|
+
expect(result.status).toBe(502);
|
|
85
|
+
expect(result.message).toBe('ECONNRESET');
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side helper for fetching GitHub repository metadata.
|
|
3
|
+
*
|
|
4
|
+
* This exists so consumer pages can wire a thin `/api/github-repo`
|
|
5
|
+
* endpoint that proxies `api.github.com` requests through their own
|
|
6
|
+
* server (rather than from the visitor's browser). The visitor's
|
|
7
|
+
* browser hitting `api.github.com` directly leaks their IP and burns
|
|
8
|
+
* the per-IP unauthenticated rate limit (60/hour).
|
|
9
|
+
*
|
|
10
|
+
* Token handling:
|
|
11
|
+
* - If `token` is provided, it's sent as `Authorization: Bearer <token>`.
|
|
12
|
+
* - If not, the request goes unauthenticated (5000/hour if the server
|
|
13
|
+
* has a shared egress IP, 60/hour per-IP otherwise — much better
|
|
14
|
+
* than the per-visitor case).
|
|
15
|
+
* - Consumers are expected to source the token from a server-only
|
|
16
|
+
* env var (e.g. `process.env.GITHUB_TOKEN`) and never expose it.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface FetchRepoMetadataInput {
|
|
20
|
+
owner: string;
|
|
21
|
+
repo: string;
|
|
22
|
+
token?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface FetchRepoMetadataResult {
|
|
26
|
+
ok: true;
|
|
27
|
+
data: GitHubRepoMetadata;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface FetchRepoMetadataError {
|
|
31
|
+
ok: false;
|
|
32
|
+
status: number;
|
|
33
|
+
message: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface GitHubRepoMetadata {
|
|
37
|
+
name: string;
|
|
38
|
+
full_name: string;
|
|
39
|
+
description: string | null;
|
|
40
|
+
html_url: string;
|
|
41
|
+
homepage: string | null;
|
|
42
|
+
language: string | null;
|
|
43
|
+
private: boolean;
|
|
44
|
+
topics: string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const GITHUB_API = 'https://api.github.com';
|
|
48
|
+
|
|
49
|
+
export async function fetchRepoMetadata(
|
|
50
|
+
input: FetchRepoMetadataInput,
|
|
51
|
+
): Promise<FetchRepoMetadataResult | FetchRepoMetadataError> {
|
|
52
|
+
const { owner, repo, token } = input;
|
|
53
|
+
if (!/^[A-Za-z0-9._-]+$/.test(owner) || !/^[A-Za-z0-9._-]+$/.test(repo)) {
|
|
54
|
+
return { ok: false, status: 400, message: 'Invalid owner or repo name.' };
|
|
55
|
+
}
|
|
56
|
+
const headers: Record<string, string> = {
|
|
57
|
+
Accept: 'application/vnd.github+json',
|
|
58
|
+
'User-Agent': 'grove-submission-proxy',
|
|
59
|
+
};
|
|
60
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
61
|
+
|
|
62
|
+
let response: Response;
|
|
63
|
+
try {
|
|
64
|
+
response = await fetch(`${GITHUB_API}/repos/${owner}/${repo}`, { headers });
|
|
65
|
+
} catch (error) {
|
|
66
|
+
const message = error instanceof Error ? error.message : 'Network error';
|
|
67
|
+
return { ok: false, status: 502, message };
|
|
68
|
+
}
|
|
69
|
+
if (response.status === 404) {
|
|
70
|
+
return { ok: false, status: 404, message: 'Repository not found or not public.' };
|
|
71
|
+
}
|
|
72
|
+
if (response.status === 403) {
|
|
73
|
+
return { ok: false, status: 403, message: 'GitHub rate limit reached. Try again shortly.' };
|
|
74
|
+
}
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
status: response.status,
|
|
79
|
+
message: `GitHub returned ${response.status}.`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const json = (await response.json()) as {
|
|
83
|
+
name: string;
|
|
84
|
+
description: string | null;
|
|
85
|
+
html_url: string;
|
|
86
|
+
homepage: string | null;
|
|
87
|
+
language: string | null;
|
|
88
|
+
private: boolean;
|
|
89
|
+
topics?: string[];
|
|
90
|
+
};
|
|
91
|
+
return {
|
|
92
|
+
ok: true,
|
|
93
|
+
data: {
|
|
94
|
+
name: json.name,
|
|
95
|
+
full_name: `${owner}/${repo}`,
|
|
96
|
+
description: json.description,
|
|
97
|
+
html_url: json.html_url,
|
|
98
|
+
homepage: json.homepage,
|
|
99
|
+
language: json.language,
|
|
100
|
+
private: json.private,
|
|
101
|
+
topics: json.topics ?? [],
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
export * from
|
|
2
|
-
export * from
|
|
3
|
-
export * from
|
|
1
|
+
export * from './directory.js';
|
|
2
|
+
export * from './models.js';
|
|
3
|
+
export * from './collections.js';
|
|
4
|
+
export * from './github-repo.js';
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real-module tests for `getHomePageModel` — the homepage stack/category
|
|
3
|
+
* count block.
|
|
4
|
+
*
|
|
5
|
+
* `models.ts` transitively imports `@grove/generated/*.json` at module
|
|
6
|
+
* load, so (like `directory.test.ts`) we mock those build artifacts to
|
|
7
|
+
* import the actual implementation instead of re-deriving it.
|
|
8
|
+
*
|
|
9
|
+
* Regression coverage for the audit's "Python 3 vs 4" drift: the count
|
|
10
|
+
* block used to iterate `fullItems` (every record, hidden included)
|
|
11
|
+
* and count only the singular `record.stack`, while browse filtered the
|
|
12
|
+
* visible index by the primary+supporting union. The fix counts the
|
|
13
|
+
* visible `items` via `projectStackIds`.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, it, vi } from "vitest";
|
|
16
|
+
|
|
17
|
+
function indexRecord(
|
|
18
|
+
slug: string,
|
|
19
|
+
options: { stack?: string; stacks?: string[]; category?: string } = {},
|
|
20
|
+
) {
|
|
21
|
+
return {
|
|
22
|
+
kind: "project",
|
|
23
|
+
slug,
|
|
24
|
+
name: slug,
|
|
25
|
+
description: `${slug} description`,
|
|
26
|
+
category: options.category ?? "tools",
|
|
27
|
+
tags: [],
|
|
28
|
+
stack: options.stack ?? options.stacks?.[0],
|
|
29
|
+
stacks: options.stacks ?? [],
|
|
30
|
+
platforms: ["linux"],
|
|
31
|
+
projectType: "real-app",
|
|
32
|
+
bestFor: [],
|
|
33
|
+
whyListed: [],
|
|
34
|
+
caveats: [],
|
|
35
|
+
links: {},
|
|
36
|
+
distribution: { channels: [] },
|
|
37
|
+
source: { type: "manual" },
|
|
38
|
+
curation: { reviewed: true, labels: [], lenses: [] },
|
|
39
|
+
visibility: "keep",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The generated index payload is visible-only by contract; the hidden
|
|
44
|
+
// record exists ONLY in the full payload. If the model ever goes back
|
|
45
|
+
// to counting `fullItems`, the hidden "rust" stack would reappear.
|
|
46
|
+
const visible = [
|
|
47
|
+
indexRecord("a", { stacks: ["python"] }),
|
|
48
|
+
indexRecord("b", { stacks: ["python", "go"] }),
|
|
49
|
+
indexRecord("c", { stacks: ["typescript"] }),
|
|
50
|
+
// Primary diverges from supporting (the open-webui shape): must
|
|
51
|
+
// count as python via the union.
|
|
52
|
+
indexRecord("d", { stack: "typescript", stacks: ["typescript", "python"] }),
|
|
53
|
+
];
|
|
54
|
+
const hidden = { ...indexRecord("ghost", { stacks: ["rust"] }), visibility: "hide" };
|
|
55
|
+
|
|
56
|
+
vi.mock("@grove/generated/records.full.json", () => ({
|
|
57
|
+
default: { records: [...visible, hidden] },
|
|
58
|
+
}));
|
|
59
|
+
vi.mock("@grove/generated/records.index.json", () => ({ default: { records: visible } }));
|
|
60
|
+
vi.mock("@grove/generated/site-config.json", () => ({ default: {} }));
|
|
61
|
+
|
|
62
|
+
const { getHomePageModel } = await import("./models.js");
|
|
63
|
+
|
|
64
|
+
const site = {
|
|
65
|
+
name: "Test Directory",
|
|
66
|
+
blueprintConfig: {
|
|
67
|
+
id: "project-directory",
|
|
68
|
+
kind: "project",
|
|
69
|
+
routeSlug: "projects",
|
|
70
|
+
itemSlug: "project",
|
|
71
|
+
labelSingular: "project",
|
|
72
|
+
labelPlural: "projects",
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
describe("getHomePageModel stack counts", () => {
|
|
77
|
+
it("counts primary AND supporting stacks via the canonical union", () => {
|
|
78
|
+
const home = getHomePageModel(site as Parameters<typeof getHomePageModel>[0]);
|
|
79
|
+
const counts = new Map(home.stacks.map((s) => [s.slug, s.count]));
|
|
80
|
+
expect(counts.get("python")).toBe(3); // a, b, d — d only via supporting
|
|
81
|
+
expect(counts.get("typescript")).toBe(2);
|
|
82
|
+
expect(counts.get("go")).toBe(1); // b's supporting stack
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("excludes hidden records from homepage counts", () => {
|
|
86
|
+
const home = getHomePageModel(site as Parameters<typeof getHomePageModel>[0]);
|
|
87
|
+
expect(home.stacks.find((s) => s.slug === "rust")).toBeUndefined();
|
|
88
|
+
const categoryTotal = home.categories.reduce((sum, c) => sum + c.count, 0);
|
|
89
|
+
expect(categoryTotal).toBe(visible.length);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("agrees with the browse facet counts for the same records", async () => {
|
|
93
|
+
const { buildFacets } = await import("@grove-dev/core");
|
|
94
|
+
const facets = buildFacets(visible as Parameters<typeof buildFacets>[0]);
|
|
95
|
+
const facetCounts = new Map(facets.stacks.map((f) => [f.value, f.count]));
|
|
96
|
+
const home = getHomePageModel(site as Parameters<typeof getHomePageModel>[0]);
|
|
97
|
+
for (const stack of home.stacks) {
|
|
98
|
+
expect(facetCounts.get(stack.slug), stack.slug).toBe(stack.count);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { applySort, filterRecords, filtersFromSearchParams } from "@grove-dev/core";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Regression tests for the homepage three-lens sectioning logic.
|
|
6
|
+
*
|
|
7
|
+
* The audit surfaced a bug where the homepage's "Trending now",
|
|
8
|
+
* "Recently added", and "Established" panels all echoed the same
|
|
9
|
+
* record cards because the hot and mature lists were both sliced
|
|
10
|
+
* from the index-order (alphabetical) and ~95% of mature items
|
|
11
|
+
* also carried the hot label.
|
|
12
|
+
*
|
|
13
|
+
* The fix in `getHomePageModel` is twofold:
|
|
14
|
+
* 1. Sort each lens by a meaningful key (stars, then date).
|
|
15
|
+
* 2. Exclude items already surfaced in the previous panels so the
|
|
16
|
+
* three sections read as distinct perspectives.
|
|
17
|
+
*
|
|
18
|
+
* These tests exercise the underlying primitives the model uses —
|
|
19
|
+
* applying them in the same order the model does — so the recipe
|
|
20
|
+
* stays covered without spinning up the full Astro model layer.
|
|
21
|
+
*/
|
|
22
|
+
function record(
|
|
23
|
+
slug: string,
|
|
24
|
+
options: { labels?: string[]; stars?: number; reviewedAt?: string } = {},
|
|
25
|
+
) {
|
|
26
|
+
return {
|
|
27
|
+
kind: "project" as const,
|
|
28
|
+
slug,
|
|
29
|
+
name: slug,
|
|
30
|
+
description: `${slug} description`,
|
|
31
|
+
category: "tools",
|
|
32
|
+
tags: [],
|
|
33
|
+
stack: "python",
|
|
34
|
+
stacks: ["python"],
|
|
35
|
+
platforms: ["linux"],
|
|
36
|
+
projectType: "real-app",
|
|
37
|
+
bestFor: [],
|
|
38
|
+
whyListed: [],
|
|
39
|
+
caveats: [],
|
|
40
|
+
links: {},
|
|
41
|
+
distribution: { channels: [] },
|
|
42
|
+
source: { type: "manual" as const },
|
|
43
|
+
visibility: "keep" as const,
|
|
44
|
+
github: {
|
|
45
|
+
stars: options.stars ?? 0,
|
|
46
|
+
forks: 0,
|
|
47
|
+
openIssues: 0,
|
|
48
|
+
language: "Python",
|
|
49
|
+
pushedAt: "2026-01-01T00:00:00Z",
|
|
50
|
+
archived: false,
|
|
51
|
+
license: "MIT",
|
|
52
|
+
fullName: `demo/${slug}`,
|
|
53
|
+
topics: [],
|
|
54
|
+
},
|
|
55
|
+
curation: {
|
|
56
|
+
reviewed: true,
|
|
57
|
+
labels: options.labels ?? [],
|
|
58
|
+
lenses: [],
|
|
59
|
+
reviewedAt: options.reviewedAt,
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sectionLikeModel(records: ReturnType<typeof record>[]) {
|
|
65
|
+
// Mirrors the filtering, sorting, and exclusion in `getHomePageModel`.
|
|
66
|
+
const projects = records.filter((r) => r.kind === "project");
|
|
67
|
+
const hot = applySort(
|
|
68
|
+
filterRecords(projects, filtersFromSearchParams(new URLSearchParams("label=hot"))),
|
|
69
|
+
"most-starred",
|
|
70
|
+
).slice(0, 6);
|
|
71
|
+
const hotSlugs = new Set(hot.map((r) => r.slug));
|
|
72
|
+
const recentlyAdded = applySort(
|
|
73
|
+
projects.filter((r) => !hotSlugs.has(r.slug)),
|
|
74
|
+
"recently-added",
|
|
75
|
+
).slice(0, 6);
|
|
76
|
+
const recentSlugs = new Set([...hotSlugs, ...recentlyAdded.map((r) => r.slug)]);
|
|
77
|
+
const established = applySort(
|
|
78
|
+
filterRecords(projects, filtersFromSearchParams(new URLSearchParams("label=mature"))).filter(
|
|
79
|
+
(r) => !recentSlugs.has(r.slug),
|
|
80
|
+
),
|
|
81
|
+
"most-starred",
|
|
82
|
+
).slice(0, 6);
|
|
83
|
+
return {
|
|
84
|
+
hot: hot.map((r) => r.slug),
|
|
85
|
+
recentlyAdded: recentlyAdded.map((r) => r.slug),
|
|
86
|
+
established: established.map((r) => r.slug),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe("homepage lens sectioning", () => {
|
|
91
|
+
it("sorts hot by stars desc and surfaces mature-only items distinct from hot", () => {
|
|
92
|
+
const records = [
|
|
93
|
+
record("alpha", { labels: ["hot", "mature"], stars: 1000 }),
|
|
94
|
+
record("bravo", { labels: ["hot", "mature"], stars: 500 }),
|
|
95
|
+
record("charlie", { labels: ["mature"], stars: 50 }),
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
const sections = sectionLikeModel(records);
|
|
99
|
+
expect(sections.hot).toEqual(["alpha", "bravo"]);
|
|
100
|
+
// Charlie is the only mature-only item; regardless of which lens
|
|
101
|
+
// he lands in, he must not also appear in the hot panel.
|
|
102
|
+
expect(sections.hot).not.toContain("charlie");
|
|
103
|
+
expect([...sections.recentlyAdded, ...sections.established]).toContain("charlie");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("never repeats a record across the three lens panels", () => {
|
|
107
|
+
const records = [
|
|
108
|
+
record("alpha", { labels: ["hot", "mature"], stars: 1000, reviewedAt: "2025-01-01" }),
|
|
109
|
+
record("bravo", { labels: ["hot", "mature"], stars: 500, reviewedAt: "2025-02-01" }),
|
|
110
|
+
record("charlie", { labels: ["hot", "mature"], stars: 250, reviewedAt: "2025-03-01" }),
|
|
111
|
+
record("delta", { labels: ["mature"], stars: 100, reviewedAt: "2024-01-01" }),
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
const sections = sectionLikeModel(records);
|
|
115
|
+
const seen = new Set<string>();
|
|
116
|
+
for (const panel of [sections.hot, sections.recentlyAdded, sections.established]) {
|
|
117
|
+
for (const slug of panel) {
|
|
118
|
+
expect(seen.has(slug)).toBe(false);
|
|
119
|
+
seen.add(slug);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("keeps recently-added items out of the hot panel even when they share labels", () => {
|
|
125
|
+
const records = [
|
|
126
|
+
record("alpha", { labels: ["hot"], stars: 100, reviewedAt: "2025-06-01" }),
|
|
127
|
+
record("bravo", { labels: [], stars: 50, reviewedAt: "2026-08-01" }),
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
const sections = sectionLikeModel(records);
|
|
131
|
+
expect(sections.hot).toEqual(["alpha"]);
|
|
132
|
+
expect(sections.recentlyAdded).toEqual(["bravo"]);
|
|
133
|
+
expect(sections.established).toEqual([]);
|
|
134
|
+
});
|
|
135
|
+
});
|