@grove-dev/astro 0.6.1 → 0.8.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/README.md +77 -5
- 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 +404 -213
- 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,118 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
// A three-position theme switcher: light / dark / system.
|
|
3
|
-
//
|
|
4
|
-
// State is persisted in `localStorage` under the key "grove-theme".
|
|
5
|
-
// On mount it reads the stored value (or "system" if none), applies
|
|
6
|
-
// the corresponding class to <html>, and re-syncs when the OS
|
|
7
|
-
// preference changes (only meaningful when mode is "system").
|
|
8
|
-
//
|
|
9
|
-
// Implementation note: the actual <html class="dark"> toggle happens
|
|
10
|
-
// in the inline script in `src/components/layout/BaseLayout.astro`
|
|
11
|
-
// (THEME_INIT). That script runs before paint so the user never sees
|
|
12
|
-
// a flash of the wrong theme.
|
|
13
|
-
|
|
14
|
-
interface Props {
|
|
15
|
-
class?: string;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const { class: className = "" } = Astro.props;
|
|
19
|
-
---
|
|
20
|
-
|
|
21
|
-
<button
|
|
22
|
-
type="button"
|
|
23
|
-
id="theme-toggle"
|
|
24
|
-
data-theme-toggle
|
|
25
|
-
aria-label="Theme: system — switch to light"
|
|
26
|
-
title="Toggle theme"
|
|
27
|
-
class={`inline-flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-[var(--radius)] border border-border bg-secondary p-0 text-foreground outline-none transition-colors hover:bg-accent focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35 ${className}`}
|
|
28
|
-
>
|
|
29
|
-
{/* Sun — shown in dark mode (click to go light) */}
|
|
30
|
-
<svg
|
|
31
|
-
width="18"
|
|
32
|
-
height="18"
|
|
33
|
-
class="hidden shrink-0 dark:block"
|
|
34
|
-
viewBox="0 0 24 24"
|
|
35
|
-
fill="none"
|
|
36
|
-
stroke="currentColor"
|
|
37
|
-
stroke-width="1.8"
|
|
38
|
-
stroke-linecap="round"
|
|
39
|
-
stroke-linejoin="round"
|
|
40
|
-
aria-hidden="true"
|
|
41
|
-
>
|
|
42
|
-
<circle cx="12" cy="12" r="4"/>
|
|
43
|
-
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
|
|
44
|
-
</svg>
|
|
45
|
-
{/* Moon — shown in light mode (click to go dark) */}
|
|
46
|
-
<svg
|
|
47
|
-
width="18"
|
|
48
|
-
height="18"
|
|
49
|
-
class="block shrink-0 dark:hidden"
|
|
50
|
-
viewBox="0 0 24 24"
|
|
51
|
-
fill="none"
|
|
52
|
-
stroke="currentColor"
|
|
53
|
-
stroke-width="1.8"
|
|
54
|
-
stroke-linecap="round"
|
|
55
|
-
stroke-linejoin="round"
|
|
56
|
-
aria-hidden="true"
|
|
57
|
-
>
|
|
58
|
-
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79Z"/>
|
|
59
|
-
</svg>
|
|
60
|
-
</button>
|
|
61
|
-
{/* Announces theme changes to assistive tech — the icon alone can't
|
|
62
|
-
distinguish "dark" from "system resolving to dark". */}
|
|
63
|
-
<span data-theme-toggle-status role="status" class="sr-only"></span>
|
|
64
|
-
|
|
65
|
-
<script>
|
|
66
|
-
// The toggle button simply rotates between the three modes on
|
|
67
|
-
// click. The actual class application lives in the page-level
|
|
68
|
-
// THEME_INIT script so behaviour stays consistent.
|
|
69
|
-
const STORAGE_KEY = "grove-theme";
|
|
70
|
-
type Mode = "light" | "dark" | "system";
|
|
71
|
-
|
|
72
|
-
function currentMode(): Mode {
|
|
73
|
-
const stored = localStorage.getItem(STORAGE_KEY);
|
|
74
|
-
return stored === "light" || stored === "dark" || stored === "system"
|
|
75
|
-
? stored
|
|
76
|
-
: "system";
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function rotate(): Mode {
|
|
80
|
-
const order: Mode[] = ["light", "dark", "system"];
|
|
81
|
-
const next = order[(order.indexOf(currentMode()) + 1) % order.length];
|
|
82
|
-
localStorage.setItem(STORAGE_KEY, next);
|
|
83
|
-
// Notify the page-level listener.
|
|
84
|
-
window.dispatchEvent(new CustomEvent("theme:set", { detail: next }));
|
|
85
|
-
return next;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function nextMode(): Mode {
|
|
89
|
-
const order: Mode[] = ["light", "dark", "system"];
|
|
90
|
-
return order[(order.indexOf(currentMode()) + 1) % order.length];
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// The accessible name carries BOTH the current state and the next
|
|
94
|
-
// action ("Theme: dark — switch to system"); title mirrors it for
|
|
95
|
-
// sighted tooltip users. Re-read on focus/hover so another tab or
|
|
96
|
-
// toggle instance can't leave it stale.
|
|
97
|
-
function updateLabel(btn: HTMLButtonElement) {
|
|
98
|
-
const label = `Theme: ${currentMode()} — switch to ${nextMode()}`;
|
|
99
|
-
btn.setAttribute("aria-label", label);
|
|
100
|
-
btn.title = label;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function announce(mode: Mode) {
|
|
104
|
-
const status = document.querySelector("[data-theme-toggle-status]");
|
|
105
|
-
if (status) status.textContent = `Theme set to ${mode}`;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
document.querySelectorAll<HTMLButtonElement>("[data-theme-toggle]").forEach((btn) => {
|
|
109
|
-
updateLabel(btn);
|
|
110
|
-
btn.addEventListener("click", () => {
|
|
111
|
-
const mode = rotate();
|
|
112
|
-
updateLabel(btn);
|
|
113
|
-
announce(mode);
|
|
114
|
-
});
|
|
115
|
-
btn.addEventListener("mouseenter", () => updateLabel(btn));
|
|
116
|
-
btn.addEventListener("focus", () => updateLabel(btn));
|
|
117
|
-
});
|
|
118
|
-
</script>
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Type declarations for the .astro layout components.
|
|
3
|
-
*
|
|
4
|
-
* The `.astro` files are excluded from `tsc` (the build only emits
|
|
5
|
-
* TypeScript helpers, not compiled components), so we declare the
|
|
6
|
-
* module shape here for the rare consumer that wants to write:
|
|
7
|
-
*
|
|
8
|
-
* import { BaseLayout } from "@grove-dev/astro/layouts";
|
|
9
|
-
*
|
|
10
|
-
* Consumers that just want the components should import them by
|
|
11
|
-
* file:
|
|
12
|
-
*
|
|
13
|
-
* import BaseLayout from "@grove-dev/astro/layouts/BaseLayout.astro";
|
|
14
|
-
*
|
|
15
|
-
* The `AstroComponentFactory` is the runtime type Astro uses for
|
|
16
|
-
* every `.astro` module's default export. In a normal Astro project
|
|
17
|
-
* this is declared by the generated `.astro/types.d.ts` (produced
|
|
18
|
-
* by `astro sync`); the `@grove-dev/astro` package itself doesn't
|
|
19
|
-
* run an Astro build, so we re-declare it here.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
declare module "*.astro" {
|
|
23
|
-
// Pull the factory type from Astro's server runtime. The path
|
|
24
|
-
// `astro/runtime/server` is a documented deep import that has
|
|
25
|
-
// shipped across Astro 4 and 5.
|
|
26
|
-
import type { AstroComponentFactory } from "astro/runtime/server";
|
|
27
|
-
const component: AstroComponentFactory;
|
|
28
|
-
export default component;
|
|
29
|
-
}
|
package/src/layouts/index.ts
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @grove-dev/astro — generic layout components.
|
|
3
|
-
*
|
|
4
|
-
* These layouts are the public, framework-agnostic shell used by
|
|
5
|
-
* every consumer page. They:
|
|
6
|
-
*
|
|
7
|
-
* - take a `site` prop (name, tagline, nav, repoUrl) instead of
|
|
8
|
-
* importing a `siteConfig` from a project-local path;
|
|
9
|
-
* - compose the standard Header / Footer / Seo / Container /
|
|
10
|
-
* SectionHeader / ThemeToggle components;
|
|
11
|
-
* - share the `grove-theme` localStorage key for theme state.
|
|
12
|
-
*
|
|
13
|
-
* Re-exported as ESM module names. Astro resolves the
|
|
14
|
-
* `@grove-dev/astro/layouts/*` subpath import from `src/layouts/`
|
|
15
|
-
* (the package's `dist/` only contains TypeScript helpers, not the
|
|
16
|
-
* `.astro` files themselves).
|
|
17
|
-
*/
|
|
18
|
-
export { default as BaseLayout } from "./BaseLayout.astro";
|
|
19
|
-
export { default as Container } from "./Container.astro";
|
|
20
|
-
export { default as Footer } from "./Footer.astro";
|
|
21
|
-
export { default as Header } from "./Header.astro";
|
|
22
|
-
export { default as SectionHeader } from "./SectionHeader.astro";
|
|
23
|
-
export { default as Seo } from "./Seo.astro";
|
|
24
|
-
export { default as ThemeToggle } from "./ThemeToggle.astro";
|
|
@@ -1,212 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
recordsToCollectionEntries,
|
|
4
|
-
getCollectionPageModel,
|
|
5
|
-
getCollectionIndexModel,
|
|
6
|
-
getCollectionTeaserModel,
|
|
7
|
-
} from "./collections.js";
|
|
8
|
-
import type { Collection, CollectionEntry } from "@grove-dev/core";
|
|
9
|
-
|
|
10
|
-
describe("recordsToCollectionEntries", () => {
|
|
11
|
-
it("maps a full record to a CollectionEntry", () => {
|
|
12
|
-
const records = [
|
|
13
|
-
{
|
|
14
|
-
slug: "crewai",
|
|
15
|
-
name: "CrewAI",
|
|
16
|
-
description: "Agent framework",
|
|
17
|
-
stack: "python",
|
|
18
|
-
stacks: ["python"],
|
|
19
|
-
platforms: ["linux", "macos"],
|
|
20
|
-
license: "MIT",
|
|
21
|
-
visibility: "keep",
|
|
22
|
-
stars: 100,
|
|
23
|
-
pushedAt: "2026-01-15T00:00:00Z",
|
|
24
|
-
lastCommitAt: "2026-02-01T00:00:00Z",
|
|
25
|
-
category: "agents",
|
|
26
|
-
tags: ["multi-agent", "automation"],
|
|
27
|
-
scores: { curation: 0.8, activity: 0.7 },
|
|
28
|
-
},
|
|
29
|
-
];
|
|
30
|
-
const entries = recordsToCollectionEntries(records as never, { routeSlug: "projects" });
|
|
31
|
-
expect(entries).toHaveLength(1);
|
|
32
|
-
expect(entries[0]).toMatchObject({
|
|
33
|
-
slug: "crewai",
|
|
34
|
-
title: "CrewAI",
|
|
35
|
-
description: "Agent framework",
|
|
36
|
-
url: "/projects/crewai/",
|
|
37
|
-
stack: "python",
|
|
38
|
-
platform: ["linux", "macos"],
|
|
39
|
-
license: "MIT",
|
|
40
|
-
status: "keep",
|
|
41
|
-
stars: 100,
|
|
42
|
-
pushedAt: "2026-01-15T00:00:00Z",
|
|
43
|
-
curationScore: 0.8,
|
|
44
|
-
activityScore: 0.7,
|
|
45
|
-
categories: expect.arrayContaining(["agents", "multi-agent", "automation"]),
|
|
46
|
-
});
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it("filters out hidden records", () => {
|
|
50
|
-
const records = [
|
|
51
|
-
{ slug: "a", name: "A", visibility: "keep" },
|
|
52
|
-
{ slug: "b", name: "B", visibility: "hide" },
|
|
53
|
-
];
|
|
54
|
-
const entries = recordsToCollectionEntries(records as never, { routeSlug: "projects" });
|
|
55
|
-
expect(entries.map((e) => e.slug)).toEqual(["a"]);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("falls back to stacks[0] when stack is missing", () => {
|
|
59
|
-
const records = [{ slug: "x", name: "X", stacks: ["node"] }];
|
|
60
|
-
const entries = recordsToCollectionEntries(records as never, { routeSlug: "projects" });
|
|
61
|
-
expect(entries[0].stack).toBe("node");
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it("prefers lastCommitAt when pushedAt is missing", () => {
|
|
65
|
-
const records = [{ slug: "x", name: "X", lastCommitAt: "2026-03-01T00:00:00Z" }];
|
|
66
|
-
const entries = recordsToCollectionEntries(records as never, { routeSlug: "projects" });
|
|
67
|
-
expect(entries[0].pushedAt).toBe("2026-03-01T00:00:00Z");
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it("deduplicates categories when category is also in tags", () => {
|
|
71
|
-
const records = [{ slug: "x", name: "X", category: "agents", tags: ["agents", "x"] }];
|
|
72
|
-
const entries = recordsToCollectionEntries(records as never, { routeSlug: "projects" });
|
|
73
|
-
expect(entries[0].categories).toEqual(["agents", "x"]);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it("defaults routeSlug to 'projects' when not provided", () => {
|
|
77
|
-
const records = [{ slug: "x", name: "X" }];
|
|
78
|
-
const entries = recordsToCollectionEntries(records as never, {});
|
|
79
|
-
expect(entries[0].url).toBe("/projects/x/");
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it("populates repoHref from repoUrl and homepageHref from links.website", () => {
|
|
83
|
-
const records = [
|
|
84
|
-
{
|
|
85
|
-
slug: "crewai",
|
|
86
|
-
name: "CrewAI",
|
|
87
|
-
repoUrl: "https://github.com/crewAIInc/crewAI",
|
|
88
|
-
links: { github: "https://github.com/other/repo", website: "https://crewai.com" },
|
|
89
|
-
},
|
|
90
|
-
];
|
|
91
|
-
const entries = recordsToCollectionEntries(records as never, {});
|
|
92
|
-
expect(entries[0].repoHref).toBe("https://github.com/crewAIInc/crewAI");
|
|
93
|
-
expect(entries[0].homepageHref).toBe("https://crewai.com");
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it("falls back to links.github when repoUrl is missing", () => {
|
|
97
|
-
const records = [
|
|
98
|
-
{ slug: "x", name: "X", links: { github: "https://github.com/foo/bar" } },
|
|
99
|
-
];
|
|
100
|
-
const entries = recordsToCollectionEntries(records as never, {});
|
|
101
|
-
expect(entries[0].repoHref).toBe("https://github.com/foo/bar");
|
|
102
|
-
});
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
const collection: Collection = {
|
|
106
|
-
slug: "top-ai-agents",
|
|
107
|
-
kind: "curated",
|
|
108
|
-
title: "Top AI Agents",
|
|
109
|
-
description: "Best agent frameworks.",
|
|
110
|
-
query: { stacks: ["python"] },
|
|
111
|
-
ranking: { preset: "quality" },
|
|
112
|
-
seo: { index: true },
|
|
113
|
-
editorial: { selectionNote: "Ranked by activity and curation." },
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
const otherCollection: Collection = {
|
|
117
|
-
slug: "top-tools",
|
|
118
|
-
kind: "curated",
|
|
119
|
-
title: "Top Tools",
|
|
120
|
-
description: "All tools.",
|
|
121
|
-
query: { stacks: ["python"] },
|
|
122
|
-
ranking: { preset: "recency" },
|
|
123
|
-
seo: { index: true },
|
|
124
|
-
};
|
|
125
|
-
|
|
126
|
-
const emptyCollection: Collection = {
|
|
127
|
-
slug: "no-matches",
|
|
128
|
-
kind: "curated",
|
|
129
|
-
title: "No Matches",
|
|
130
|
-
description: "Won't match anything.",
|
|
131
|
-
query: { stacks: ["rust"] },
|
|
132
|
-
ranking: { preset: "recency" },
|
|
133
|
-
seo: { index: true },
|
|
134
|
-
};
|
|
135
|
-
|
|
136
|
-
const entries: CollectionEntry[] = [
|
|
137
|
-
{
|
|
138
|
-
slug: "crewai",
|
|
139
|
-
title: "CrewAI",
|
|
140
|
-
description: "Agent framework",
|
|
141
|
-
url: "/projects/crewai/",
|
|
142
|
-
stack: "python",
|
|
143
|
-
curationScore: 0.9,
|
|
144
|
-
activityScore: 0.8,
|
|
145
|
-
},
|
|
146
|
-
{
|
|
147
|
-
slug: "dify",
|
|
148
|
-
title: "Dify",
|
|
149
|
-
description: "LLM platform",
|
|
150
|
-
url: "/projects/dify/",
|
|
151
|
-
stack: "python",
|
|
152
|
-
curationScore: 0.7,
|
|
153
|
-
activityScore: 0.6,
|
|
154
|
-
},
|
|
155
|
-
{
|
|
156
|
-
slug: "flowise",
|
|
157
|
-
title: "Flowise",
|
|
158
|
-
description: "Node tool",
|
|
159
|
-
url: "/projects/flowise/",
|
|
160
|
-
stack: "node",
|
|
161
|
-
curationScore: 0.5,
|
|
162
|
-
activityScore: 0.4,
|
|
163
|
-
},
|
|
164
|
-
];
|
|
165
|
-
|
|
166
|
-
describe("getCollectionPageModel", () => {
|
|
167
|
-
it("returns ranked entries filtered by query", () => {
|
|
168
|
-
const model = getCollectionPageModel(collection, entries, [collection, otherCollection]);
|
|
169
|
-
expect(model.total).toBe(2);
|
|
170
|
-
expect(model.isEmpty).toBe(false);
|
|
171
|
-
expect(model.entries.map((e) => e.slug)).toEqual(["crewai", "dify"]);
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
it("exposes editorial selection note", () => {
|
|
175
|
-
const model = getCollectionPageModel(collection, entries, [collection]);
|
|
176
|
-
expect(model.collection.selectionNote).toBe("Ranked by activity and curation.");
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
it("returns related collections (max 4)", () => {
|
|
180
|
-
const model = getCollectionPageModel(collection, entries, [collection, otherCollection]);
|
|
181
|
-
expect(model.related).toHaveLength(1);
|
|
182
|
-
expect(model.related[0].slug).toBe("top-tools");
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
it("reports empty when no entries match", () => {
|
|
186
|
-
const model = getCollectionPageModel(emptyCollection, entries, [collection, otherCollection]);
|
|
187
|
-
expect(model.isEmpty).toBe(true);
|
|
188
|
-
});
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
describe("getCollectionIndexModel", () => {
|
|
192
|
-
it("counts entries per collection", () => {
|
|
193
|
-
const model = getCollectionIndexModel([collection, otherCollection], entries);
|
|
194
|
-
expect(model.total).toBe(2);
|
|
195
|
-
expect(model.collections).toHaveLength(2);
|
|
196
|
-
const agentCol = model.collections.find((c) => c.slug === "top-ai-agents");
|
|
197
|
-
expect(agentCol?.count).toBe(2);
|
|
198
|
-
expect(agentCol?.url).toBe("/collections/top-ai-agents/");
|
|
199
|
-
});
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
describe("getCollectionTeaserModel", () => {
|
|
203
|
-
it("respects the limit", () => {
|
|
204
|
-
const model = getCollectionTeaserModel([collection, otherCollection], entries, 1);
|
|
205
|
-
expect(model.collections).toHaveLength(1);
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
it("uses the project's route slug for entry URLs", () => {
|
|
209
|
-
const model = getCollectionTeaserModel([collection], entries, 5);
|
|
210
|
-
expect(model.collections[0].count).toBe(2);
|
|
211
|
-
});
|
|
212
|
-
});
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { contrastRatio, derivePrimaryPalette, hexToRgb, relativeLuminance, rgbToHex } from "./contrast";
|
|
3
|
-
|
|
4
|
-
describe("WCAG contrast math", () => {
|
|
5
|
-
it("parses 3- and 6-digit hex and rejects garbage", () => {
|
|
6
|
-
expect(hexToRgb("#fff")).toEqual([255, 255, 255]);
|
|
7
|
-
expect(hexToRgb("#16a34a")).toEqual([0x16, 0xa3, 0x4a]);
|
|
8
|
-
expect(hexToRgb("#16a34aff")).toEqual([0x16, 0xa3, 0x4a]);
|
|
9
|
-
expect(hexToRgb("#xyz")).toBeNull();
|
|
10
|
-
expect(hexToRgb("green")).toBeNull();
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
it("computes relative luminance at the anchors", () => {
|
|
14
|
-
expect(relativeLuminance([0, 0, 0])).toBe(0);
|
|
15
|
-
expect(relativeLuminance([255, 255, 255])).toBeCloseTo(1, 5);
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it("computes the canonical 21:1 black/white ratio", () => {
|
|
19
|
-
expect(contrastRatio("#000000", "#ffffff")).toBeCloseTo(21, 1);
|
|
20
|
-
expect(contrastRatio("#ffffff", "#000000")).toBeCloseTo(21, 1);
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it("confirms the audit finding: white on #16a34a fails AA", () => {
|
|
24
|
-
const ratio = contrastRatio("#16a34a", "#ffffff");
|
|
25
|
-
expect(ratio).toBeGreaterThan(3);
|
|
26
|
-
expect(ratio).toBeLessThan(4.5);
|
|
27
|
-
});
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
describe("derivePrimaryPalette", () => {
|
|
31
|
-
it("pairs #16a34a with AA text instead of white (the P0 fix)", () => {
|
|
32
|
-
// White on #16a34a is 3.3:1 (the shipped failure). Near-black
|
|
33
|
-
// reaches 6:1, so the brand hex stays exact and the TEXT changes.
|
|
34
|
-
const palette = derivePrimaryPalette("#16a34a");
|
|
35
|
-
expect(palette).not.toBeNull();
|
|
36
|
-
const p = palette as NonNullable<typeof palette>;
|
|
37
|
-
expect(p.solid).toBe("#16a34a");
|
|
38
|
-
expect(p.solidForeground).toBe("#0a0a0a");
|
|
39
|
-
expect(contrastRatio(p.solid, p.solidForeground)).toBeGreaterThanOrEqual(4.5);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it("darkens a mid-tone hex only when no text color reaches AA on it", () => {
|
|
43
|
-
// #777777: white is ~4.48:1 and near-black ~4.42:1 — neither
|
|
44
|
-
// passes, so the solid must shift until white does.
|
|
45
|
-
const p = derivePrimaryPalette("#777777");
|
|
46
|
-
expect(p).not.toBeNull();
|
|
47
|
-
const pal = p as NonNullable<typeof p>;
|
|
48
|
-
expect(pal.solid).not.toBe("#777777");
|
|
49
|
-
expect(pal.solidForeground).toBe("#ffffff");
|
|
50
|
-
expect(contrastRatio(pal.solid, pal.solidForeground)).toBeGreaterThanOrEqual(4.5);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("keeps an already-AA dark brand color untouched with white text", () => {
|
|
54
|
-
const p = derivePrimaryPalette("#15803d");
|
|
55
|
-
expect(p?.solid).toBe("#15803d");
|
|
56
|
-
expect(p?.solidForeground).toBe("#ffffff");
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it("pairs a light brand color with near-black text instead of mutating it", () => {
|
|
60
|
-
const p = derivePrimaryPalette("#facc15");
|
|
61
|
-
expect(p?.solid).toBe("#facc15");
|
|
62
|
-
expect(p?.solidForeground).toBe("#0a0a0a");
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it("always yields an AA dark-theme pair from the lifted variant", () => {
|
|
66
|
-
for (const hex of ["#16a34a", "#1d4ed8", "#0a0a0a", "#facc15", "#e11d48"]) {
|
|
67
|
-
const p = derivePrimaryPalette(hex);
|
|
68
|
-
expect(p, hex).not.toBeNull();
|
|
69
|
-
const pal = p as NonNullable<typeof p>;
|
|
70
|
-
expect(contrastRatio(pal.dark, pal.darkForeground), hex).toBeGreaterThanOrEqual(4.5);
|
|
71
|
-
expect(contrastRatio(pal.solid, pal.solidForeground), hex).toBeGreaterThanOrEqual(4.5);
|
|
72
|
-
}
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("returns null for unparseable input", () => {
|
|
76
|
-
expect(derivePrimaryPalette("rebeccapurple")).toBeNull();
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it("round-trips rgb↔hex", () => {
|
|
80
|
-
expect(rgbToHex(hexToRgb("#16a34a") as [number, number, number])).toBe("#16a34a");
|
|
81
|
-
});
|
|
82
|
-
});
|
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for the markdown → safe-HTML pipeline in `directory.ts`.
|
|
3
|
-
*
|
|
4
|
-
* `directory.ts` imports `@grove/generated/*.json` at module load (build-time
|
|
5
|
-
* artifacts), which don't exist in the test environment. We mock those JSON
|
|
6
|
-
* imports so the module can be loaded for its pure functions (specifically
|
|
7
|
-
* `renderMarkdownToSafeHtml`).
|
|
8
|
-
*/
|
|
9
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
10
|
-
|
|
11
|
-
vi.mock('@grove/generated/records.full.json', () => ({ default: { records: [] } }));
|
|
12
|
-
vi.mock('@grove/generated/records.index.json', () => ({ default: { records: [] } }));
|
|
13
|
-
vi.mock('@grove/generated/site-config.json', () => ({ default: {} }));
|
|
14
|
-
|
|
15
|
-
const { renderMarkdownToSafeHtml } = await import('./directory.js');
|
|
16
|
-
|
|
17
|
-
describe('renderMarkdownToSafeHtml', () => {
|
|
18
|
-
it('renders basic paragraphs', () => {
|
|
19
|
-
const html = renderMarkdownToSafeHtml('Hello, **world**.');
|
|
20
|
-
expect(html).toContain('<strong>world</strong>');
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it('renders headings with stable ids', () => {
|
|
24
|
-
const html = renderMarkdownToSafeHtml('## My Section');
|
|
25
|
-
expect(html).toMatch(/<h2[^>]*id="my-section"[^>]*>My Section<\/h2>/);
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it('deduplicates repeated heading ids', () => {
|
|
29
|
-
const html = renderMarkdownToSafeHtml('## Examples\n\n## Examples\n');
|
|
30
|
-
const ids = [...html.matchAll(/id="(examples(?:-\d+)?)"/g)].map((m) => m[1]);
|
|
31
|
-
expect(ids).toEqual(['examples', 'examples-2']);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it('strips script tags from the body', () => {
|
|
35
|
-
const html = renderMarkdownToSafeHtml(
|
|
36
|
-
'Some text.\n\n<script>alert("xss")</script>\n\nMore text.',
|
|
37
|
-
);
|
|
38
|
-
expect(html).not.toContain('<script');
|
|
39
|
-
expect(html).not.toContain('alert(');
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it('strips inline event handler attributes from links', () => {
|
|
43
|
-
const html = renderMarkdownToSafeHtml('[click](https://example.com "onclick=alert(1)")');
|
|
44
|
-
// The sanitizer's allowlist is `["href", "title", "rel", "target"]` for
|
|
45
|
-
// `<a>` tags — `onclick` is never an attribute. (The substring `onclick=`
|
|
46
|
-
// may appear inside a `title="…"` tooltip, but that's plain text.)
|
|
47
|
-
expect(html).not.toMatch(/<a[^>]*\sonclick\s*=/i);
|
|
48
|
-
expect(html).toContain('href="https://example.com"');
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it('forces noopener noreferrer on links', () => {
|
|
52
|
-
const html = renderMarkdownToSafeHtml('[link](https://example.com)');
|
|
53
|
-
expect(html).toContain('rel="noopener noreferrer"');
|
|
54
|
-
expect(html).toContain('target="_blank"');
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('rejects javascript: URLs', () => {
|
|
58
|
-
const html = renderMarkdownToSafeHtml('[evil](javascript:alert(1))');
|
|
59
|
-
expect(html).not.toContain('javascript:');
|
|
60
|
-
expect(html).not.toMatch(/<a[^>]*href/);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it('forces img loading=lazy decoding=async', () => {
|
|
64
|
-
const html = renderMarkdownToSafeHtml('');
|
|
65
|
-
expect(html).toMatch(/loading="lazy"/);
|
|
66
|
-
expect(html).toMatch(/decoding="async"/);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it('strips style attributes from disallowed tags', () => {
|
|
70
|
-
const html = renderMarkdownToSafeHtml(
|
|
71
|
-
'<a href="https://example.com" style="color:red">link</a>',
|
|
72
|
-
);
|
|
73
|
-
expect(html).not.toMatch(/<a[^>]*style/);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('renders GFM tables inside the table-wrap div', () => {
|
|
77
|
-
const md = `
|
|
78
|
-
| col1 | col2 |
|
|
79
|
-
| ---- | ---- |
|
|
80
|
-
| a | b |
|
|
81
|
-
`;
|
|
82
|
-
const html = renderMarkdownToSafeHtml(md);
|
|
83
|
-
expect(html).toContain('grove-prose-table-wrap');
|
|
84
|
-
expect(html).toContain('<table>');
|
|
85
|
-
expect(html).toContain('<thead>');
|
|
86
|
-
expect(html).toContain('<tbody>');
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it('renders task-list checkboxes as disabled', () => {
|
|
90
|
-
const md = '- [x] done\n- [ ] todo\n';
|
|
91
|
-
const html = renderMarkdownToSafeHtml(md);
|
|
92
|
-
expect(html).toContain('type="checkbox"');
|
|
93
|
-
expect(html).toContain('disabled');
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it('accepts a narrower page allowlist', () => {
|
|
97
|
-
const html = renderMarkdownToSafeHtml('<details><summary>x</summary>y</details>', {
|
|
98
|
-
allowlist: [
|
|
99
|
-
'h1',
|
|
100
|
-
'h2',
|
|
101
|
-
'h3',
|
|
102
|
-
'h4',
|
|
103
|
-
'p',
|
|
104
|
-
'br',
|
|
105
|
-
'hr',
|
|
106
|
-
'ul',
|
|
107
|
-
'ol',
|
|
108
|
-
'li',
|
|
109
|
-
'strong',
|
|
110
|
-
'em',
|
|
111
|
-
'b',
|
|
112
|
-
'i',
|
|
113
|
-
'u',
|
|
114
|
-
's',
|
|
115
|
-
'del',
|
|
116
|
-
'a',
|
|
117
|
-
'code',
|
|
118
|
-
'pre',
|
|
119
|
-
'blockquote',
|
|
120
|
-
'img',
|
|
121
|
-
],
|
|
122
|
-
});
|
|
123
|
-
expect(html).not.toContain('<details');
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it('strips raw iframe tags', () => {
|
|
127
|
-
const html = renderMarkdownToSafeHtml(
|
|
128
|
-
'Text.\n\n<iframe src="https://evil.example"></iframe>\n\nMore.',
|
|
129
|
-
);
|
|
130
|
-
expect(html).not.toContain('<iframe');
|
|
131
|
-
expect(html).not.toContain('evil.example');
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it('strips raw svg tags (including script-bearing svgs)', () => {
|
|
135
|
-
const html = renderMarkdownToSafeHtml(
|
|
136
|
-
'Text.\n\n<svg><script>alert("xss")</script></svg>\n\nMore.',
|
|
137
|
-
);
|
|
138
|
-
expect(html).not.toContain('<svg');
|
|
139
|
-
expect(html).not.toContain('<script');
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
it('strips raw object and embed tags', () => {
|
|
143
|
-
const html = renderMarkdownToSafeHtml(
|
|
144
|
-
'<object data="https://evil.example/x.swf"></object>\n<embed src="x">',
|
|
145
|
-
);
|
|
146
|
-
expect(html).not.toContain('<object');
|
|
147
|
-
expect(html).not.toContain('<embed');
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it('filters malicious CSS from <span style> (closes the url(javascript:) bypass)', () => {
|
|
151
|
-
const html = renderMarkdownToSafeHtml(
|
|
152
|
-
'<span style="background:url(javascript:alert(1));color:red">x</span>',
|
|
153
|
-
);
|
|
154
|
-
expect(html).not.toContain('javascript:');
|
|
155
|
-
// The color: declaration is also stripped because it doesn't pass
|
|
156
|
-
// the allowStyles regex (no semicolon-terminated hex / rgb / hsl).
|
|
157
|
-
expect(html).not.toMatch(/<span[^>]*style=/);
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
it('preserves Shiki-emitted CSS variable declarations on <span>', () => {
|
|
161
|
-
const html = renderMarkdownToSafeHtml(
|
|
162
|
-
'<span style="--shiki-light:#fff;--shiki-dark:#000;color:#fff">x</span>',
|
|
163
|
-
);
|
|
164
|
-
expect(html).toMatch(/--shiki-light/);
|
|
165
|
-
expect(html).toMatch(/--shiki-dark/);
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
it('rejects data: URLs on <a> but allows them on <img>', () => {
|
|
169
|
-
const html = renderMarkdownToSafeHtml(
|
|
170
|
-
'[evil](data:text/html,<script>alert(1)</script>)\n\n',
|
|
171
|
-
);
|
|
172
|
-
// The malicious <a> URL must be removed; the inline <img> data: URL
|
|
173
|
-
// (a base64 PNG) is allowed by the per-tag scheme rule.
|
|
174
|
-
expect(html).not.toMatch(/<a[^>]*href="data:/);
|
|
175
|
-
expect(html).toMatch(/<img[^>]*src="data:image\/png/);
|
|
176
|
-
});
|
|
177
|
-
});
|