@grove-dev/astro 0.7.0 → 0.9.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.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +22 -33
- package/dist/index.js.map +1 -1
- package/dist/lib/display.d.ts +1 -1
- package/dist/lib/display.js +1 -1
- package/dist/lib/format.d.ts +1 -1
- package/dist/lib/format.js +1 -1
- package/dist/lib/icon-kinds.d.ts +1 -1
- package/dist/lib/icon-kinds.js +43 -43
- package/dist/lib/icon-registry.d.ts +2 -2
- package/dist/lib/icon-registry.js +23 -23
- package/dist/lib/index.d.ts +9 -9
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +9 -9
- package/dist/lib/index.js.map +1 -1
- package/dist/lib/lenses.d.ts +1 -1
- package/dist/lib/lenses.js +1 -1
- package/dist/lib/packaged-icons.d.ts +1 -1
- package/dist/lib/packaged-icons.js +4 -4
- package/dist/lib/repo.d.ts +1 -1
- package/dist/lib/repo.js +1 -1
- package/dist/lib/search.d.ts +1 -1
- package/dist/lib/search.js +1 -1
- package/dist/lib/taxonomy-counts.d.ts +1 -1
- package/dist/lib/taxonomy-counts.js +1 -1
- package/package.json +7 -17
- package/src/index.ts +23 -38
- package/src/lib/display.ts +1 -1
- package/src/lib/format.ts +1 -1
- package/src/lib/icon-kinds.ts +44 -44
- package/src/lib/icon-registry.ts +25 -25
- package/src/lib/index.ts +9 -9
- package/src/lib/lenses.ts +1 -1
- package/src/lib/packaged-icons.ts +8 -8
- package/src/lib/repo.ts +1 -1
- package/src/lib/search.ts +1 -1
- package/src/lib/taxonomy-counts.ts +1 -1
- package/src/server/collections.ts +53 -42
- package/src/server/contrast.ts +14 -9
- package/src/server/directory.ts +22 -17
- package/src/server/index.ts +3 -2
- package/src/server/models.ts +425 -218
- package/src/server/seo.ts +34 -31
- package/src/styles.css +235 -91
- package/dist/layouts/index.d.ts +0 -25
- package/dist/layouts/index.d.ts.map +0 -1
- package/dist/layouts/index.js +0 -25
- package/dist/layouts/index.js.map +0 -1
- package/dist/ui/button.d.ts +0 -53
- package/dist/ui/button.d.ts.map +0 -1
- package/dist/ui/button.js +0 -90
- package/dist/ui/button.js.map +0 -1
- package/src/components/CardGrid.astro +0 -41
- package/src/components/CardIcon.astro +0 -67
- package/src/components/CategoryGrid.astro +0 -114
- package/src/components/CollectionCard.astro +0 -56
- package/src/components/CollectionIndex.astro +0 -102
- package/src/components/CollectionPage.astro +0 -128
- package/src/components/CollectionRow.astro +0 -81
- package/src/components/CollectionTeaser.astro +0 -47
- package/src/components/ContributorsGrid.astro +0 -143
- package/src/components/DirectoryIndexClient.astro +0 -510
- package/src/components/EditorialSummary.astro +0 -58
- package/src/components/FilterGroupMenu.astro +0 -69
- package/src/components/FilterOptions.astro +0 -49
- package/src/components/FinalCta.astro +0 -43
- package/src/components/Hero.astro +0 -243
- package/src/components/Icon.astro +0 -191
- package/src/components/IndexRow.astro +0 -37
- package/src/components/LanguageBreakdown.astro +0 -67
- package/src/components/MarkdownBody.astro +0 -37
- package/src/components/OriginalCollection.astro +0 -117
- package/src/components/Pagination.astro +0 -50
- package/src/components/PoweredBy.astro +0 -60
- package/src/components/ProjectCard.astro +0 -321
- package/src/components/RecordHeader.astro +0 -221
- package/src/components/RecordSection.astro +0 -128
- package/src/components/RecordSidebar.astro +0 -387
- package/src/components/RefinePanel.astro +0 -168
- package/src/components/SmartLensTabs.astro +0 -81
- package/src/components/StackGrid.astro +0 -151
- package/src/components/StackPlatformChips.astro +0 -73
- package/src/components/SubmissionClient.astro +0 -254
- package/src/components/TableOfContents.astro +0 -140
- package/src/components/WhyThisExists.astro +0 -116
- package/src/layouts/BaseLayout.astro +0 -415
- package/src/layouts/Container.astro +0 -18
- package/src/layouts/Footer.astro +0 -231
- package/src/layouts/Header.astro +0 -214
- package/src/layouts/SectionHeader.astro +0 -43
- package/src/layouts/Seo.astro +0 -171
- package/src/layouts/ThemeToggle.astro +0 -118
- package/src/layouts/astro-shim.d.ts +0 -29
- package/src/layouts/index.ts +0 -24
- package/src/server/collections.test.ts +0 -212
- package/src/server/contrast.test.ts +0 -82
- package/src/server/directory.test.ts +0 -177
- package/src/server/github-repo.test.ts +0 -88
- package/src/server/models-home.test.ts +0 -101
- package/src/server/models.test.ts +0 -135
- package/src/server/seo.test.ts +0 -123
- package/src/ui/Badge.astro +0 -38
- package/src/ui/Button.astro +0 -42
- package/src/ui/EmptyState.astro +0 -38
- package/src/ui/FilterDrawer.astro +0 -124
- package/src/ui/PageHeader.astro +0 -39
- package/src/ui/SearchField.astro +0 -106
- package/src/ui/button.test.ts +0 -54
- package/src/ui/button.ts +0 -107
|
@@ -1,88 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,101 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,135 +0,0 @@
|
|
|
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
|
-
});
|
package/src/server/seo.test.ts
DELETED
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
absoluteUrl,
|
|
4
|
-
breadcrumbs,
|
|
5
|
-
ogPath,
|
|
6
|
-
recordSeoDescriptor,
|
|
7
|
-
seoDescription,
|
|
8
|
-
seoTitle,
|
|
9
|
-
titleCaseFirst,
|
|
10
|
-
} from "./seo.js";
|
|
11
|
-
|
|
12
|
-
describe("seoTitle", () => {
|
|
13
|
-
it("appends the site name with the pipe separator", () => {
|
|
14
|
-
expect(seoTitle("Collections", "Open Apps")).toBe("Collections | Open Apps");
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it("skips the suffix when the main part already names the site", () => {
|
|
18
|
-
expect(seoTitle("Python projects on Open Apps", "Open Apps")).toBe(
|
|
19
|
-
"Python projects on Open Apps",
|
|
20
|
-
);
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it("skips the suffix instead of exceeding the display cap", () => {
|
|
24
|
-
const long = "A very long record name — with an equally long descriptor attached";
|
|
25
|
-
expect(seoTitle(long, "Open Apps")).toBe(long);
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it("falls back to the site name for an empty main part", () => {
|
|
29
|
-
expect(seoTitle(" ", "Open Apps")).toBe("Open Apps");
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it("returns the main part unchanged when no site name is given", () => {
|
|
33
|
-
expect(seoTitle("Collections", "")).toBe("Collections");
|
|
34
|
-
});
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
describe("seoDescription", () => {
|
|
38
|
-
it("prefers the raw text and collapses whitespace", () => {
|
|
39
|
-
expect(seoDescription("A spaced\n\ntext.", "fallback")).toBe("A spaced text.");
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it("uses the fallback when raw is empty", () => {
|
|
43
|
-
expect(seoDescription(undefined, "The fallback.")).toBe("The fallback.");
|
|
44
|
-
expect(seoDescription(" ", "The fallback.")).toBe("The fallback.");
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it("truncates on a word boundary near 160 chars with an ellipsis", () => {
|
|
48
|
-
const long = "word ".repeat(60).trim();
|
|
49
|
-
const out = seoDescription(long, "");
|
|
50
|
-
expect(out.length).toBeLessThanOrEqual(160);
|
|
51
|
-
expect(out.endsWith("…")).toBe(true);
|
|
52
|
-
expect(out).not.toMatch(/\swor…$/);
|
|
53
|
-
});
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
describe("recordSeoDescriptor", () => {
|
|
57
|
-
it("uses the summary's first clause when short enough", () => {
|
|
58
|
-
expect(
|
|
59
|
-
recordSeoDescriptor({
|
|
60
|
-
summary: "Self-hosted photo backup. Works offline and syncs later.",
|
|
61
|
-
singular: "app",
|
|
62
|
-
}),
|
|
63
|
-
).toBe("Self-hosted photo backup");
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it("falls back to the category phrase when the summary is too long", () => {
|
|
67
|
-
expect(
|
|
68
|
-
recordSeoDescriptor({
|
|
69
|
-
summary:
|
|
70
|
-
"A very long first clause that keeps going and going far past the cap",
|
|
71
|
-
categoryLabel: "Photos",
|
|
72
|
-
singular: "app",
|
|
73
|
-
}),
|
|
74
|
-
).toBe("Open-source Photos app");
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it("omits the category when it is missing or uncategorized", () => {
|
|
78
|
-
expect(recordSeoDescriptor({ singular: "project" })).toBe("Open-source project");
|
|
79
|
-
expect(
|
|
80
|
-
recordSeoDescriptor({ categoryLabel: "Uncategorized", singular: "project" }),
|
|
81
|
-
).toBe("Open-source project");
|
|
82
|
-
});
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
describe("titleCaseFirst", () => {
|
|
86
|
-
it("capitalizes only the first character", () => {
|
|
87
|
-
expect(titleCaseFirst("projects")).toBe("Projects");
|
|
88
|
-
expect(titleCaseFirst("")).toBe("");
|
|
89
|
-
});
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
describe("ogPath", () => {
|
|
93
|
-
it("falls back to the static SVG when no generated image exists", () => {
|
|
94
|
-
// Point at a directory with no public/og at all.
|
|
95
|
-
expect(ogPath("record", "nothing-here", "/nonexistent-cwd")).toBe("/og-image.svg");
|
|
96
|
-
expect(ogPath("home", undefined, "/nonexistent-cwd")).toBe("/og-image.svg");
|
|
97
|
-
});
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
describe("absoluteUrl", () => {
|
|
101
|
-
it("joins trailing-slash-safely", () => {
|
|
102
|
-
expect(absoluteUrl("https://a.com/", "/x/")).toBe("https://a.com/x/");
|
|
103
|
-
expect(absoluteUrl("https://a.com", "x/")).toBe("https://a.com/x/");
|
|
104
|
-
});
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
describe("breadcrumbs", () => {
|
|
108
|
-
it("builds an absolute BreadcrumbList with trailing slashes", () => {
|
|
109
|
-
const node = breadcrumbs("https://a.com", [
|
|
110
|
-
{ path: "", name: "Home" },
|
|
111
|
-
{ path: "categories", name: "Categories" },
|
|
112
|
-
{ path: "categories/photos/", name: "Photos" },
|
|
113
|
-
]);
|
|
114
|
-
expect(node["@type"]).toBe("BreadcrumbList");
|
|
115
|
-
const items = node.itemListElement as Array<{ position: number; name: string; item: string }>;
|
|
116
|
-
expect(items.map((i) => i.item)).toEqual([
|
|
117
|
-
"https://a.com/",
|
|
118
|
-
"https://a.com/categories/",
|
|
119
|
-
"https://a.com/categories/photos/",
|
|
120
|
-
]);
|
|
121
|
-
expect(items[2]).toMatchObject({ position: 3, name: "Photos" });
|
|
122
|
-
});
|
|
123
|
-
});
|
package/src/ui/Badge.astro
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
/**
|
|
3
|
-
* Badge — small labeled state indicator.
|
|
4
|
-
*
|
|
5
|
-
* Variant is the MEANING, not the color:
|
|
6
|
-
* - `neutral` — plain metadata (counts, kinds).
|
|
7
|
-
* - `selected` — "you are here" (neutral inversion, never green).
|
|
8
|
-
* - `success` / `warning` / `danger` / `info` — actual status. These
|
|
9
|
-
* use the status tokens, so green here means "healthy/succeeded",
|
|
10
|
-
* not "brand" and not "selected".
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
export type BadgeVariant = "neutral" | "selected" | "success" | "warning" | "danger" | "info";
|
|
14
|
-
|
|
15
|
-
interface Props {
|
|
16
|
-
variant?: BadgeVariant;
|
|
17
|
-
class?: string;
|
|
18
|
-
[key: string]: unknown;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const VARIANTS: Record<BadgeVariant, string> = {
|
|
22
|
-
neutral: "border-border bg-secondary text-secondary-foreground",
|
|
23
|
-
selected: "border-transparent bg-selected text-selected-foreground",
|
|
24
|
-
success: "border-transparent bg-success text-success-foreground",
|
|
25
|
-
warning: "border-transparent bg-warning text-warning-foreground",
|
|
26
|
-
danger: "border-transparent bg-danger text-danger-foreground",
|
|
27
|
-
info: "border-transparent bg-info text-info-foreground",
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
const { variant = "neutral", class: className = "", ...rest } = Astro.props;
|
|
31
|
-
---
|
|
32
|
-
|
|
33
|
-
<span
|
|
34
|
-
class={`inline-flex min-h-5 items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium leading-none ${VARIANTS[variant]} ${className}`}
|
|
35
|
-
{...rest}
|
|
36
|
-
>
|
|
37
|
-
<slot />
|
|
38
|
-
</span>
|
package/src/ui/Button.astro
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
/**
|
|
3
|
-
* Button — the shared interactive control.
|
|
4
|
-
*
|
|
5
|
-
* Renders an `<a>` when `href` is given, otherwise a `<button>`.
|
|
6
|
-
* Styling comes exclusively from `buttonClass()` (ui/button.ts) so
|
|
7
|
-
* server components, consumer pages, and the client script share one
|
|
8
|
-
* definition. Pass layout-only extras (widths, one-off spacing)
|
|
9
|
-
* through `class`.
|
|
10
|
-
*/
|
|
11
|
-
import { buttonClass, type ButtonSize, type ButtonVariant } from "./button.js";
|
|
12
|
-
|
|
13
|
-
interface Props {
|
|
14
|
-
variant?: ButtonVariant;
|
|
15
|
-
size?: ButtonSize;
|
|
16
|
-
href?: string;
|
|
17
|
-
type?: "button" | "submit" | "reset";
|
|
18
|
-
rel?: string;
|
|
19
|
-
target?: string;
|
|
20
|
-
disabled?: boolean;
|
|
21
|
-
id?: string;
|
|
22
|
-
class?: string;
|
|
23
|
-
[key: string]: unknown;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const {
|
|
27
|
-
variant = "secondary",
|
|
28
|
-
size = "sm",
|
|
29
|
-
href,
|
|
30
|
-
type = "button",
|
|
31
|
-
class: className = "",
|
|
32
|
-
...rest
|
|
33
|
-
} = Astro.props;
|
|
34
|
-
|
|
35
|
-
const classes = buttonClass(variant, size, className);
|
|
36
|
-
---
|
|
37
|
-
|
|
38
|
-
{href ? (
|
|
39
|
-
<a href={href} class={classes} {...rest}><slot /></a>
|
|
40
|
-
) : (
|
|
41
|
-
<button type={type} class={classes} {...rest}><slot /></button>
|
|
42
|
-
)}
|
package/src/ui/EmptyState.astro
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
/**
|
|
3
|
-
* EmptyState — a deliberate "nothing here" block that cannot be
|
|
4
|
-
* mistaken for normal content. Explains WHY the area is empty and
|
|
5
|
-
* offers a recovery action.
|
|
6
|
-
*
|
|
7
|
-
* Marked with `data-grove-empty-state` so quality gates can assert
|
|
8
|
-
* that empty regions render as empty states, not silent gaps.
|
|
9
|
-
*/
|
|
10
|
-
import { buttonClass, type ButtonVariant } from "./button.js";
|
|
11
|
-
|
|
12
|
-
interface Props {
|
|
13
|
-
title: string;
|
|
14
|
-
/** Why it's empty / what will make it non-empty. */
|
|
15
|
-
description?: string;
|
|
16
|
-
/** Recovery action (rendered as a button-styled link). */
|
|
17
|
-
action?: { label: string; href: string; variant?: ButtonVariant };
|
|
18
|
-
class?: string;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const { title, description, action, class: className = "" } = Astro.props;
|
|
22
|
-
---
|
|
23
|
-
|
|
24
|
-
<div
|
|
25
|
-
data-grove-empty-state
|
|
26
|
-
class={`flex flex-col items-center gap-2 rounded-[calc(var(--radius)+0.25rem)] border border-dashed border-border bg-surface-sunken px-6 py-10 text-center ${className}`}
|
|
27
|
-
>
|
|
28
|
-
<p class="m-0 text-sm font-medium text-foreground">{title}</p>
|
|
29
|
-
{description && (
|
|
30
|
-
<p class="m-0 max-w-[52ch] text-sm leading-relaxed text-muted-foreground">{description}</p>
|
|
31
|
-
)}
|
|
32
|
-
<slot />
|
|
33
|
-
{action && (
|
|
34
|
-
<a href={action.href} class={buttonClass(action.variant ?? "secondary", "sm", "mt-2")}>
|
|
35
|
-
{action.label}
|
|
36
|
-
</a>
|
|
37
|
-
)}
|
|
38
|
-
</div>
|