@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.
Files changed (72) hide show
  1. package/dist/index.js +2 -2
  2. package/dist/index.js.map +1 -1
  3. package/dist/lib/index.d.ts +0 -2
  4. package/dist/lib/index.d.ts.map +1 -1
  5. package/dist/lib/index.js +0 -2
  6. package/dist/lib/index.js.map +1 -1
  7. package/dist/ui/button.d.ts +45 -0
  8. package/dist/ui/button.d.ts.map +1 -0
  9. package/dist/ui/button.js +82 -0
  10. package/dist/ui/button.js.map +1 -0
  11. package/package.json +8 -5
  12. package/src/components/CardGrid.astro +41 -0
  13. package/src/components/CardIcon.astro +67 -0
  14. package/src/components/CategoryGrid.astro +20 -5
  15. package/src/components/CollectionCard.astro +56 -0
  16. package/src/components/CollectionIndex.astro +21 -26
  17. package/src/components/CollectionPage.astro +36 -13
  18. package/src/components/CollectionRow.astro +62 -192
  19. package/src/components/CollectionTeaser.astro +4 -0
  20. package/src/components/ContributorsGrid.astro +4 -1
  21. package/src/components/DirectoryIndexClient.astro +127 -30
  22. package/src/components/EditorialSummary.astro +6 -5
  23. package/src/components/FilterGroupMenu.astro +24 -10
  24. package/src/components/FilterOptions.astro +6 -1
  25. package/src/components/FinalCta.astro +5 -3
  26. package/src/components/Hero.astro +85 -139
  27. package/src/components/IndexRow.astro +36 -69
  28. package/src/components/LanguageBreakdown.astro +5 -1
  29. package/src/components/OriginalCollection.astro +3 -2
  30. package/src/components/Pagination.astro +7 -20
  31. package/src/components/ProjectCard.astro +345 -0
  32. package/src/components/RecordHeader.astro +111 -82
  33. package/src/components/RecordSection.astro +14 -10
  34. package/src/components/RecordSidebar.astro +68 -20
  35. package/src/components/RefinePanel.astro +41 -62
  36. package/src/components/SmartLensTabs.astro +3 -7
  37. package/src/components/StackGrid.astro +26 -6
  38. package/src/components/SubmissionClient.astro +140 -63
  39. package/src/components/TableOfContents.astro +31 -5
  40. package/src/components/WhyThisExists.astro +1 -1
  41. package/src/index.ts +2 -2
  42. package/src/layouts/BaseLayout.astro +79 -9
  43. package/src/layouts/Header.astro +5 -4
  44. package/src/layouts/SectionHeader.astro +3 -2
  45. package/src/layouts/Seo.astro +28 -17
  46. package/src/layouts/ThemeToggle.astro +23 -6
  47. package/src/lib/index.ts +0 -2
  48. package/src/server/collections.ts +11 -0
  49. package/src/server/contrast.test.ts +82 -0
  50. package/src/server/contrast.ts +117 -0
  51. package/src/server/directory.test.ts +177 -0
  52. package/src/server/directory.ts +328 -206
  53. package/src/server/github-repo.test.ts +88 -0
  54. package/src/server/github-repo.ts +104 -0
  55. package/src/server/index.ts +4 -3
  56. package/src/server/models-home.test.ts +101 -0
  57. package/src/server/models.test.ts +135 -0
  58. package/src/server/models.ts +170 -42
  59. package/src/styles.css +128 -16
  60. package/dist/lib/scores.d.ts +0 -2
  61. package/dist/lib/scores.d.ts.map +0 -1
  62. package/dist/lib/scores.js +0 -2
  63. package/dist/lib/scores.js.map +0 -1
  64. package/src/components/CurationGrid.astro +0 -41
  65. package/src/components/DecisionRow.astro +0 -89
  66. package/src/components/ExploreByCategory.astro +0 -67
  67. package/src/components/ExploreByStack.astro +0 -78
  68. package/src/components/GroveDocumentHead.astro +0 -28
  69. package/src/components/ItemCard.astro +0 -324
  70. package/src/components/MinimalAbout.astro +0 -82
  71. package/src/components/ScoreBars.astro +0 -102
  72. 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
+ }
@@ -1,3 +1,4 @@
1
- export * from "./directory.js";
2
- export * from "./models.js";
3
- export * from "./collections.js";
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
+ });