@grove-dev/astro 0.5.0-next.2 → 0.5.1
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 +9 -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/src/ui/Badge.astro +38 -0
- package/src/ui/Button.astro +42 -0
- package/src/ui/EmptyState.astro +38 -0
- package/src/ui/FilterDrawer.astro +104 -0
- package/src/ui/PageHeader.astro +39 -0
- package/src/ui/SearchField.astro +50 -0
- package/src/ui/button.test.ts +54 -0
- package/src/ui/button.ts +98 -0
- 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,104 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* FilterDrawer — mobile filter surface built on native `<dialog>`.
|
|
4
|
+
*
|
|
5
|
+
* `showModal()` gives focus trapping, Escape handling, and the
|
|
6
|
+
* top-layer backdrop for free — no dependency, no hand-rolled trap.
|
|
7
|
+
* On viewports ≥ md the trigger button hides and the slotted filter
|
|
8
|
+
* controls render inline through the same slot used on desktop, so
|
|
9
|
+
* there is exactly one copy of the filter markup.
|
|
10
|
+
*
|
|
11
|
+
* No-JS fallback: the slot content stays in normal flow (the dialog
|
|
12
|
+
* only wraps it once the client script moves it), so filters remain
|
|
13
|
+
* usable without JavaScript.
|
|
14
|
+
*/
|
|
15
|
+
import { buttonClass } from "./button.js";
|
|
16
|
+
|
|
17
|
+
interface Props {
|
|
18
|
+
/** Trigger button label. */
|
|
19
|
+
label?: string;
|
|
20
|
+
/** Count of active filters shown on the trigger. */
|
|
21
|
+
activeCount?: number;
|
|
22
|
+
class?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const { label = "Filters", activeCount = 0, class: className = "" } = Astro.props;
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
<div class={`grove-filter-drawer ${className}`} data-grove-filter-drawer>
|
|
29
|
+
<button
|
|
30
|
+
type="button"
|
|
31
|
+
data-drawer-open
|
|
32
|
+
class={buttonClass("secondary", "md", "w-full md:hidden")}
|
|
33
|
+
aria-haspopup="dialog"
|
|
34
|
+
>
|
|
35
|
+
{label}
|
|
36
|
+
{/* Always in the DOM (hidden at zero) so the client script can
|
|
37
|
+
update it from the live URL state on this prerendered page. */}
|
|
38
|
+
<span
|
|
39
|
+
data-drawer-count
|
|
40
|
+
class:list={[
|
|
41
|
+
"inline-flex min-w-5 items-center justify-center rounded-full bg-selected px-1.5 py-0.5 font-mono text-2xs leading-none text-selected-foreground",
|
|
42
|
+
activeCount === 0 && "hidden",
|
|
43
|
+
]}
|
|
44
|
+
>
|
|
45
|
+
{activeCount}
|
|
46
|
+
</span>
|
|
47
|
+
</button>
|
|
48
|
+
|
|
49
|
+
{/* On mobile the client script moves this content into the dialog;
|
|
50
|
+
on ≥ md it stays inline. Not hidden by CSS — without JS the
|
|
51
|
+
filters must remain visible in flow on every viewport. */}
|
|
52
|
+
<div data-drawer-content class="md:contents">
|
|
53
|
+
<slot />
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<dialog
|
|
57
|
+
data-drawer-dialog
|
|
58
|
+
class="m-0 mt-auto w-full max-w-none rounded-t-[calc(var(--radius)+0.5rem)] border-0 bg-surface-overlay p-0 text-foreground backdrop:bg-black/40 md:hidden"
|
|
59
|
+
aria-label={label}
|
|
60
|
+
>
|
|
61
|
+
<div class="flex items-center justify-between border-b border-border px-4 py-3">
|
|
62
|
+
<p class="m-0 text-sm font-semibold">{label}</p>
|
|
63
|
+
<button type="button" data-drawer-close class={buttonClass("ghost", "sm")}>
|
|
64
|
+
Done
|
|
65
|
+
</button>
|
|
66
|
+
</div>
|
|
67
|
+
<div data-drawer-body class="max-h-[70vh] overflow-y-auto p-4"></div>
|
|
68
|
+
</dialog>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<script>
|
|
72
|
+
document.querySelectorAll<HTMLElement>("[data-grove-filter-drawer]").forEach((root) => {
|
|
73
|
+
const dialog = root.querySelector<HTMLDialogElement>("[data-drawer-dialog]");
|
|
74
|
+
const content = root.querySelector<HTMLElement>("[data-drawer-content]");
|
|
75
|
+
const body = root.querySelector<HTMLElement>("[data-drawer-body]");
|
|
76
|
+
const open = root.querySelector<HTMLButtonElement>("[data-drawer-open]");
|
|
77
|
+
const close = root.querySelector<HTMLButtonElement>("[data-drawer-close]");
|
|
78
|
+
if (!dialog || !content || !body || !open) return;
|
|
79
|
+
|
|
80
|
+
const mobile = window.matchMedia("(max-width: 47.999rem)");
|
|
81
|
+
const place = () => {
|
|
82
|
+
// One copy of the filter markup, moved between the inline slot
|
|
83
|
+
// (desktop) and the dialog body (mobile).
|
|
84
|
+
if (mobile.matches) {
|
|
85
|
+
body.append(...content.children);
|
|
86
|
+
content.classList.add("hidden");
|
|
87
|
+
} else {
|
|
88
|
+
if (dialog.open) dialog.close();
|
|
89
|
+
content.append(...body.children);
|
|
90
|
+
content.classList.remove("hidden");
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
place();
|
|
94
|
+
mobile.addEventListener("change", place);
|
|
95
|
+
|
|
96
|
+
open.addEventListener("click", () => dialog.showModal());
|
|
97
|
+
close?.addEventListener("click", () => dialog.close());
|
|
98
|
+
dialog.addEventListener("click", (event) => {
|
|
99
|
+
// Backdrop click closes: the dialog element itself is the
|
|
100
|
+
// target only when the click lands outside the panel content.
|
|
101
|
+
if (event.target === dialog) dialog.close();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
</script>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* PageHeader — the page's title block. Always renders a semantic
|
|
4
|
+
* heading (h1 by default — every route needs exactly one) with an
|
|
5
|
+
* optional eyebrow and description.
|
|
6
|
+
*
|
|
7
|
+
* For section headings INSIDE a page keep using `SectionHeader`
|
|
8
|
+
* (layouts/), which defaults to h2/h3.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
eyebrow?: string;
|
|
13
|
+
title: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
/** Escape hatch for embedded use; the default 1 is the point. */
|
|
16
|
+
level?: 1 | 2;
|
|
17
|
+
class?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const { eyebrow, title, description, level = 1, class: className = "" } = Astro.props;
|
|
21
|
+
const Heading = `h${level}` as "h1" | "h2";
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
<header class={`flex flex-col gap-1 ${className}`}>
|
|
25
|
+
{eyebrow && (
|
|
26
|
+
<span class="text-2xs font-medium uppercase tracking-wider text-muted-foreground">
|
|
27
|
+
{eyebrow}
|
|
28
|
+
</span>
|
|
29
|
+
)}
|
|
30
|
+
<Heading class="m-0 text-[1.875rem] font-semibold tracking-tight text-foreground sm:text-[2.125rem]">
|
|
31
|
+
{title}
|
|
32
|
+
</Heading>
|
|
33
|
+
{description && (
|
|
34
|
+
<p class="m-0 mt-1 max-w-[64ch] text-[0.9375rem] leading-relaxed text-muted-foreground">
|
|
35
|
+
{description}
|
|
36
|
+
</p>
|
|
37
|
+
)}
|
|
38
|
+
<slot />
|
|
39
|
+
</header>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* SearchField — labeled search input with the leading glass icon.
|
|
4
|
+
*
|
|
5
|
+
* The label is visually hidden but always present; placeholder text
|
|
6
|
+
* is display copy, not the accessible name. Pass the derived
|
|
7
|
+
* placeholder from `getDirectoryIndexModel().searchPlaceholder` so it
|
|
8
|
+
* only names ENABLED facets.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
label: string;
|
|
13
|
+
name?: string;
|
|
14
|
+
id?: string;
|
|
15
|
+
value?: string;
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
class?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const {
|
|
21
|
+
label,
|
|
22
|
+
name = "q",
|
|
23
|
+
id = "search-input",
|
|
24
|
+
value = "",
|
|
25
|
+
placeholder,
|
|
26
|
+
class: className = "",
|
|
27
|
+
} = Astro.props;
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
<label class={`relative block ${className}`}>
|
|
31
|
+
<span class="sr-only">{label}</span>
|
|
32
|
+
<svg
|
|
33
|
+
viewBox="0 0 16 16"
|
|
34
|
+
width="16"
|
|
35
|
+
height="16"
|
|
36
|
+
aria-hidden="true"
|
|
37
|
+
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
|
38
|
+
fill="currentColor"
|
|
39
|
+
>
|
|
40
|
+
<path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.5 4.5 0 1 0-9 0 4.5 4.5 0 0 0 9 0Z" />
|
|
41
|
+
</svg>
|
|
42
|
+
<input
|
|
43
|
+
id={id}
|
|
44
|
+
type="search"
|
|
45
|
+
name={name}
|
|
46
|
+
value={value}
|
|
47
|
+
placeholder={placeholder}
|
|
48
|
+
class="h-10 w-full rounded-md border border-input bg-background py-2 pl-9 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/20"
|
|
49
|
+
/>
|
|
50
|
+
</label>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { buttonClass, chipClass, filterTriggerClass, lensTabClass } from "./button.js";
|
|
5
|
+
|
|
6
|
+
describe("button class builders", () => {
|
|
7
|
+
it("keeps selection state on the selected tokens, never the brand primary", () => {
|
|
8
|
+
for (const cls of [
|
|
9
|
+
buttonClass("selected"),
|
|
10
|
+
filterTriggerClass(true),
|
|
11
|
+
lensTabClass(true),
|
|
12
|
+
]) {
|
|
13
|
+
expect(cls).toContain("bg-selected");
|
|
14
|
+
expect(cls).not.toContain("bg-primary");
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("uses the brand primary only for the primary variant", () => {
|
|
19
|
+
expect(buttonClass("primary")).toContain("bg-primary");
|
|
20
|
+
expect(buttonClass("secondary")).not.toContain("bg-primary");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("guarantees a 44px mobile hit target on md and lg sizes", () => {
|
|
24
|
+
expect(buttonClass("secondary", "md")).toContain("max-sm:min-h-11");
|
|
25
|
+
expect(buttonClass("secondary", "lg")).toContain("h-11");
|
|
26
|
+
expect(filterTriggerClass(false)).toContain("max-sm:min-h-11");
|
|
27
|
+
expect(lensTabClass(false)).toContain("max-sm:min-h-11");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("keeps the filter-trigger hook class through client reassignment", () => {
|
|
31
|
+
expect(filterTriggerClass(true)).toContain("grove-filter-trigger");
|
|
32
|
+
expect(filterTriggerClass(false)).toContain("grove-filter-trigger");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("is the single source for chip classes on both server and client", () => {
|
|
36
|
+
// The browse page (server) and DirectoryIndexClient (client JS)
|
|
37
|
+
// must render byte-identical chips. Both now import chipClass —
|
|
38
|
+
// assert neither has regressed to an inline copy of the string.
|
|
39
|
+
const clientSource = readFileSync(
|
|
40
|
+
resolve(import.meta.dirname, "../components/DirectoryIndexClient.astro"),
|
|
41
|
+
"utf8",
|
|
42
|
+
);
|
|
43
|
+
const serverPage = readFileSync(
|
|
44
|
+
resolve(import.meta.dirname, "../../../../apps/example/src/pages/[slug]/index.astro"),
|
|
45
|
+
"utf8",
|
|
46
|
+
);
|
|
47
|
+
expect(clientSource).toContain("chipClass()");
|
|
48
|
+
expect(serverPage).toContain("chipClass()");
|
|
49
|
+
const inlineChip = "min-h-5 items-center justify-center gap-1 rounded-full";
|
|
50
|
+
expect(clientSource).not.toContain(inlineChip);
|
|
51
|
+
expect(serverPage).not.toContain(inlineChip);
|
|
52
|
+
expect(chipClass()).toContain(inlineChip);
|
|
53
|
+
});
|
|
54
|
+
});
|
package/src/ui/button.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Button / chip class builders — the single source for interactive
|
|
3
|
+
* control styling.
|
|
4
|
+
*
|
|
5
|
+
* Plain TS (not an .astro component) on purpose: `DirectoryIndexClient`
|
|
6
|
+
* rebuilds pagination links and active-filter chips in CLIENT-side JS,
|
|
7
|
+
* and those class strings must stay byte-identical with the server
|
|
8
|
+
* render. Both sides import these builders, so they cannot drift.
|
|
9
|
+
*
|
|
10
|
+
* Variant semantics (see styles.css):
|
|
11
|
+
* - `primary` — the brand action (config `theme.primaryColor`).
|
|
12
|
+
* - `secondary` — default chrome buttons.
|
|
13
|
+
* - `outline` / `ghost` — quieter chrome.
|
|
14
|
+
* - `selected` — "you are here" state (active filter, current lens,
|
|
15
|
+
* current page). Neutral inversion via the `selected` tokens —
|
|
16
|
+
* deliberately NOT the brand primary, so green never doubles as a
|
|
17
|
+
* selection indicator.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type ButtonVariant = "primary" | "secondary" | "outline" | "ghost" | "selected";
|
|
21
|
+
/** `bare` emits no size classes — pass custom geometry via `extra`. */
|
|
22
|
+
export type ButtonSize = "sm" | "md" | "lg" | "bare";
|
|
23
|
+
|
|
24
|
+
const BASE =
|
|
25
|
+
"inline-flex cursor-pointer items-center justify-center gap-1.5 rounded-[var(--radius)] border border-transparent font-medium leading-none no-underline outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `md`/`lg` guarantee a ≥44px hit target on small viewports
|
|
29
|
+
* (`max-sm:min-h-11`) while keeping the compact desktop height.
|
|
30
|
+
*/
|
|
31
|
+
const SIZES: Record<ButtonSize, string> = {
|
|
32
|
+
sm: "h-8 px-3 text-sm",
|
|
33
|
+
md: "h-9 px-3 text-sm max-sm:min-h-11",
|
|
34
|
+
lg: "h-11 px-4 text-sm",
|
|
35
|
+
bare: "",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const VARIANTS: Record<ButtonVariant, string> = {
|
|
39
|
+
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
40
|
+
secondary: "border-border bg-secondary text-foreground hover:bg-accent",
|
|
41
|
+
outline: "border-border bg-transparent text-foreground hover:bg-accent",
|
|
42
|
+
ghost: "text-foreground hover:bg-accent",
|
|
43
|
+
selected: "bg-selected text-selected-foreground hover:bg-selected/90",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function buttonClass(
|
|
47
|
+
variant: ButtonVariant = "secondary",
|
|
48
|
+
size: ButtonSize = "sm",
|
|
49
|
+
extra = "",
|
|
50
|
+
): string {
|
|
51
|
+
return [BASE, SIZES[size], VARIANTS[variant], extra].filter(Boolean).join(" ");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Layout extras for pagination controls — shared by `Pagination.astro`
|
|
56
|
+
* and the client-side pagination rebuild in `DirectoryIndexClient`.
|
|
57
|
+
*/
|
|
58
|
+
export const PAGINATION_EXTRA = "min-w-9 px-2 py-1.5 text-2xs";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Facet filter trigger (the "Stack: Any" dropdown buttons). Includes
|
|
62
|
+
* the `grove-filter-trigger` hook class because the client script
|
|
63
|
+
* reassigns the whole className when the active state changes — the
|
|
64
|
+
* hook must survive that reassignment.
|
|
65
|
+
*/
|
|
66
|
+
export function filterTriggerClass(active: boolean): string {
|
|
67
|
+
return [
|
|
68
|
+
"grove-filter-trigger",
|
|
69
|
+
BASE,
|
|
70
|
+
"h-9 px-3 text-[0.8125rem] max-sm:min-h-11",
|
|
71
|
+
active ? `${VARIANTS.selected} hover:text-selected-foreground` : VARIANTS.secondary,
|
|
72
|
+
].join(" ");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Lens tab (the All / Hot / Mature row). Same geometry as the filter
|
|
77
|
+
* trigger; `data-lens-id` targeting keeps its own hook attribute.
|
|
78
|
+
*/
|
|
79
|
+
export function lensTabClass(active: boolean): string {
|
|
80
|
+
return [
|
|
81
|
+
BASE,
|
|
82
|
+
"h-9 px-3 text-[0.8125rem] max-sm:min-h-11",
|
|
83
|
+
active ? `${VARIANTS.selected} hover:text-selected-foreground` : VARIANTS.secondary,
|
|
84
|
+
].join(" ");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Active-filter chip (the removable "Stack: Python ×" pills). One
|
|
89
|
+
* definition for the server render and the client rebuild.
|
|
90
|
+
*/
|
|
91
|
+
export function chipClass(extra = ""): string {
|
|
92
|
+
return [
|
|
93
|
+
"inline-flex min-h-5 items-center justify-center gap-1 rounded-full border border-transparent bg-secondary px-2 py-0.5 align-middle text-xs font-medium leading-none text-secondary-foreground transition-colors hover:border-ink-400",
|
|
94
|
+
extra,
|
|
95
|
+
]
|
|
96
|
+
.filter(Boolean)
|
|
97
|
+
.join(" ");
|
|
98
|
+
}
|
package/dist/lib/scores.d.ts
DELETED
package/dist/lib/scores.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"scores.d.ts","sourceRoot":"","sources":["../../src/lib/scores.ts"],"names":[],"mappings":"AAAA,cAAc,iBAAiB,CAAC"}
|
package/dist/lib/scores.js
DELETED
package/dist/lib/scores.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"scores.js","sourceRoot":"","sources":["../../src/lib/scores.ts"],"names":[],"mappings":"AAAA,cAAc,iBAAiB,CAAC"}
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
interface Props {
|
|
3
|
-
bestFor?: string[];
|
|
4
|
-
whyListed?: string[];
|
|
5
|
-
caveats?: string[];
|
|
6
|
-
class?: string;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
const {
|
|
10
|
-
bestFor = [],
|
|
11
|
-
whyListed = [],
|
|
12
|
-
caveats = [],
|
|
13
|
-
class: className = "",
|
|
14
|
-
} = Astro.props;
|
|
15
|
-
|
|
16
|
-
const columns = [
|
|
17
|
-
{ title: "Best for", items: bestFor },
|
|
18
|
-
{ title: "Why listed", items: whyListed },
|
|
19
|
-
{ title: "Caveats", items: caveats },
|
|
20
|
-
].filter((column) => column.items.length > 0);
|
|
21
|
-
---
|
|
22
|
-
|
|
23
|
-
{columns.length > 0 && (
|
|
24
|
-
<section
|
|
25
|
-
class:list={["grid grid-cols-1 gap-4 md:grid-cols-3", className]}
|
|
26
|
-
aria-label="Curation notes"
|
|
27
|
-
>
|
|
28
|
-
{columns.map((column) => (
|
|
29
|
-
<div class="rounded-[calc(var(--radius)+0.25rem)] border-0 bg-muted shadow-none transition-colors p-5">
|
|
30
|
-
<h2 class="m-0 text-sm font-semibold uppercase tracking-wider text-ink-500 dark:text-ink-400">
|
|
31
|
-
{column.title}
|
|
32
|
-
</h2>
|
|
33
|
-
<ul class="mt-3 mb-0 flex list-disc flex-col gap-1.5 pl-5">
|
|
34
|
-
{column.items.map((item) => (
|
|
35
|
-
<li class="text-sm text-ink-700 dark:text-ink-200">{item}</li>
|
|
36
|
-
))}
|
|
37
|
-
</ul>
|
|
38
|
-
</div>
|
|
39
|
-
))}
|
|
40
|
-
</section>
|
|
41
|
-
)}
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
/**
|
|
3
|
-
* DecisionRow — a single row of the curation decision table.
|
|
4
|
-
*
|
|
5
|
-
* Used on the "Decisions" admin/curation page. Renders the record
|
|
6
|
-
* name, the current visibility/tier, the cleanup-candidate flag,
|
|
7
|
-
* and links to the source repo and detail page.
|
|
8
|
-
*
|
|
9
|
-
* Generic: takes a `Resource` and reads the standard fields. No
|
|
10
|
-
* blueprint-specific copy.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import type { Resource, ProjectRecord } from "@grove-dev/core";
|
|
14
|
-
import { statusDisplay, prettySlug } from "../lib/display";
|
|
15
|
-
import { getOwnerAndRepoFromRepoUrl } from "../lib/repo";
|
|
16
|
-
|
|
17
|
-
interface Props {
|
|
18
|
-
record: Resource;
|
|
19
|
-
detailHref?: string;
|
|
20
|
-
class?: string;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const { record, detailHref, class: className = "" } = Astro.props;
|
|
24
|
-
|
|
25
|
-
const isProject = record.kind === "project";
|
|
26
|
-
const project = isProject ? (record as ProjectRecord) : null;
|
|
27
|
-
|
|
28
|
-
const name =
|
|
29
|
-
record.kind === "resource"
|
|
30
|
-
? (record as { title: string }).title
|
|
31
|
-
: (record as { name: string }).name;
|
|
32
|
-
|
|
33
|
-
const repoUrl = project?.repoUrl;
|
|
34
|
-
const ownerRepo = repoUrl ? getOwnerAndRepoFromRepoUrl(repoUrl) : null;
|
|
35
|
-
|
|
36
|
-
const visibility = project?.health?.visibility ?? "keep";
|
|
37
|
-
const tier = project?.health?.tier;
|
|
38
|
-
const status = project?.health?.status;
|
|
39
|
-
const cleanup = project?.health?.cleanupCandidate;
|
|
40
|
-
---
|
|
41
|
-
|
|
42
|
-
<tr
|
|
43
|
-
class:list={["grove-decision-row align-top text-sm", className]}
|
|
44
|
-
data-record-slug={record.slug}
|
|
45
|
-
>
|
|
46
|
-
<td class="px-3 py-2">
|
|
47
|
-
<div class="flex flex-col gap-0.5">
|
|
48
|
-
<a
|
|
49
|
-
href={detailHref ?? `/${record.slug}`}
|
|
50
|
-
class="font-medium text-ink-900 hover:underline dark:text-ink-100"
|
|
51
|
-
>
|
|
52
|
-
{name}
|
|
53
|
-
</a>
|
|
54
|
-
{ownerRepo && (
|
|
55
|
-
<span class="text-2xs text-ink-500 dark:text-ink-400">
|
|
56
|
-
@{ownerRepo.owner}/{ownerRepo.repo}
|
|
57
|
-
</span>
|
|
58
|
-
)}
|
|
59
|
-
</div>
|
|
60
|
-
</td>
|
|
61
|
-
<td class="px-3 py-2 font-mono text-2xs text-ink-700 dark:text-ink-300">
|
|
62
|
-
{prettySlug(visibility)}
|
|
63
|
-
</td>
|
|
64
|
-
<td class="px-3 py-2 font-mono text-2xs text-ink-700 dark:text-ink-300">
|
|
65
|
-
{tier ? prettySlug(tier) : "—"}
|
|
66
|
-
</td>
|
|
67
|
-
<td class="px-3 py-2 font-mono text-2xs text-ink-700 dark:text-ink-300">
|
|
68
|
-
{status ? statusDisplay(status) : "—"}
|
|
69
|
-
</td>
|
|
70
|
-
<td class="px-3 py-2 text-2xs">
|
|
71
|
-
{cleanup ? (
|
|
72
|
-
<span class="inline-flex min-h-5 items-center justify-center gap-1 rounded-full border border-transparent bg-secondary px-2 py-0.5 align-middle text-xs font-medium leading-none text-secondary-foreground !bg-amber-50 !text-amber-800 dark:!bg-amber-950/80 dark:!text-amber-300">cleanup</span>
|
|
73
|
-
) : (
|
|
74
|
-
<span class="text-ink-400">—</span>
|
|
75
|
-
)}
|
|
76
|
-
</td>
|
|
77
|
-
<td class="px-3 py-2 text-right">
|
|
78
|
-
{repoUrl && (
|
|
79
|
-
<a
|
|
80
|
-
href={repoUrl}
|
|
81
|
-
target="_blank"
|
|
82
|
-
rel="noopener noreferrer"
|
|
83
|
-
class="text-2xs text-ink-500 hover:text-ink-900 dark:text-ink-400 dark:hover:text-ink-100"
|
|
84
|
-
>
|
|
85
|
-
repo →
|
|
86
|
-
</a>
|
|
87
|
-
)}
|
|
88
|
-
</td>
|
|
89
|
-
</tr>
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
/**
|
|
3
|
-
* ExploreByCategory — compact, count-only browse-by-category row.
|
|
4
|
-
*
|
|
5
|
-
* Light sibling of CategoryGrid. Pill-style links with a count.
|
|
6
|
-
* Generic: takes `{ name, slug, count }` entries.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
interface CategoryEntry {
|
|
10
|
-
name: string;
|
|
11
|
-
slug: string;
|
|
12
|
-
count?: number;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
interface Props {
|
|
16
|
-
categories: CategoryEntry[];
|
|
17
|
-
pathPrefix?: string;
|
|
18
|
-
title?: string;
|
|
19
|
-
description?: string;
|
|
20
|
-
class?: string;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const {
|
|
24
|
-
categories,
|
|
25
|
-
pathPrefix = "/",
|
|
26
|
-
title = "Explore by category",
|
|
27
|
-
description,
|
|
28
|
-
class: className = "",
|
|
29
|
-
} = Astro.props;
|
|
30
|
-
|
|
31
|
-
function buildHref(slug: string): string {
|
|
32
|
-
const base = pathPrefix.replace(/\/$/, "");
|
|
33
|
-
return `${base}?category=${encodeURIComponent(slug)}`;
|
|
34
|
-
}
|
|
35
|
-
---
|
|
36
|
-
|
|
37
|
-
<section class:list={["py-10 sm:py-12", className]}>
|
|
38
|
-
<div class="mx-auto w-full max-w-wide px-5 sm:px-7">
|
|
39
|
-
<header class="mb-3 flex flex-col gap-1">
|
|
40
|
-
<h2 class="m-0 text-[1.125rem] font-semibold tracking-tight text-ink-900 dark:text-ink-100">
|
|
41
|
-
{title}
|
|
42
|
-
</h2>
|
|
43
|
-
{description && (
|
|
44
|
-
<p class="m-0 text-[0.8125rem] text-ink-500 dark:text-ink-400">
|
|
45
|
-
{description}
|
|
46
|
-
</p>
|
|
47
|
-
)}
|
|
48
|
-
</header>
|
|
49
|
-
<ul class="m-0 flex list-none flex-wrap gap-2 p-0">
|
|
50
|
-
{categories.map((c) => (
|
|
51
|
-
<li>
|
|
52
|
-
<a
|
|
53
|
-
href={buildHref(c.slug)}
|
|
54
|
-
class="inline-flex min-h-5 items-center justify-center gap-1 rounded-full border border-transparent bg-secondary px-2 py-0.5 align-middle text-xs font-medium leading-none text-secondary-foreground items-center gap-1.5 transition-colors hover:border-ink-400 dark:hover:border-ink-600"
|
|
55
|
-
data-event="explore_category_click"
|
|
56
|
-
data-event-source="explore_category"
|
|
57
|
-
>
|
|
58
|
-
<span>{c.name}</span>
|
|
59
|
-
{typeof c.count === "number" && (
|
|
60
|
-
<span class="font-mono text-ink-400">{c.count}</span>
|
|
61
|
-
)}
|
|
62
|
-
</a>
|
|
63
|
-
</li>
|
|
64
|
-
))}
|
|
65
|
-
</ul>
|
|
66
|
-
</div>
|
|
67
|
-
</section>
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
/**
|
|
3
|
-
* ExploreByStack — compact, count-only browse-by-stack row.
|
|
4
|
-
*
|
|
5
|
-
* The "explore" companion to StackGrid. Renders a single dense row
|
|
6
|
-
* of pill links (one per stack) with a count next to each. No
|
|
7
|
-
* cards, no blurbs — meant for the section that fits below the
|
|
8
|
-
* primary lens grid.
|
|
9
|
-
*
|
|
10
|
-
* Generic: takes a list of `{ name, slug, count }` entries. No
|
|
11
|
-
* blueprint-specific terms.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
interface StackEntry {
|
|
15
|
-
name: string;
|
|
16
|
-
slug: string;
|
|
17
|
-
count?: number;
|
|
18
|
-
status?: "live" | "expanding" | "planned";
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
interface Props {
|
|
22
|
-
stacks: StackEntry[];
|
|
23
|
-
pathPrefix?: string;
|
|
24
|
-
title?: string;
|
|
25
|
-
description?: string;
|
|
26
|
-
class?: string;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const {
|
|
30
|
-
stacks,
|
|
31
|
-
pathPrefix = "/",
|
|
32
|
-
title = "Explore by stack",
|
|
33
|
-
description,
|
|
34
|
-
class: className = "",
|
|
35
|
-
} = Astro.props;
|
|
36
|
-
|
|
37
|
-
function buildHref(slug: string): string {
|
|
38
|
-
const base = pathPrefix.replace(/\/$/, "");
|
|
39
|
-
return `${base}?stack=${encodeURIComponent(slug)}`;
|
|
40
|
-
}
|
|
41
|
-
---
|
|
42
|
-
|
|
43
|
-
<section class:list={["py-10 sm:py-12", className]}>
|
|
44
|
-
<div class="mx-auto w-full max-w-wide px-5 sm:px-7">
|
|
45
|
-
<header class="mb-3 flex flex-col gap-1">
|
|
46
|
-
<h2 class="m-0 text-[1.125rem] font-semibold tracking-tight text-ink-900 dark:text-ink-100">
|
|
47
|
-
{title}
|
|
48
|
-
</h2>
|
|
49
|
-
{description && (
|
|
50
|
-
<p class="m-0 text-[0.8125rem] text-ink-500 dark:text-ink-400">
|
|
51
|
-
{description}
|
|
52
|
-
</p>
|
|
53
|
-
)}
|
|
54
|
-
</header>
|
|
55
|
-
<ul class="m-0 flex list-none flex-wrap gap-2 p-0">
|
|
56
|
-
{stacks.map((s) => (
|
|
57
|
-
<li>
|
|
58
|
-
<a
|
|
59
|
-
href={buildHref(s.slug)}
|
|
60
|
-
class="inline-flex min-h-5 items-center justify-center gap-1 rounded-full border border-transparent bg-secondary px-2 py-0.5 align-middle text-xs font-medium leading-none text-secondary-foreground items-center gap-1.5 transition-colors hover:border-ink-400 dark:hover:border-ink-600"
|
|
61
|
-
data-event="explore_stack_click"
|
|
62
|
-
data-event-source="explore_stack"
|
|
63
|
-
>
|
|
64
|
-
<span>{s.name}</span>
|
|
65
|
-
{typeof s.count === "number" && (
|
|
66
|
-
<span class="font-mono text-ink-400">{s.count}</span>
|
|
67
|
-
)}
|
|
68
|
-
{s.status && s.status !== "live" && (
|
|
69
|
-
<span class="text-[10px] uppercase tracking-wide text-ink-400">
|
|
70
|
-
{s.status}
|
|
71
|
-
</span>
|
|
72
|
-
)}
|
|
73
|
-
</a>
|
|
74
|
-
</li>
|
|
75
|
-
))}
|
|
76
|
-
</ul>
|
|
77
|
-
</div>
|
|
78
|
-
</section>
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
import type { PageDocument } from "@grove-dev/core";
|
|
3
|
-
interface Props {
|
|
4
|
-
document: PageDocument;
|
|
5
|
-
}
|
|
6
|
-
const { document: doc } = Astro.props;
|
|
7
|
-
---
|
|
8
|
-
<title>{doc.metadata.title}</title>
|
|
9
|
-
<meta name="description" content={doc.metadata.description} />
|
|
10
|
-
<meta name="robots" content={doc.metadata.robots} />
|
|
11
|
-
<link rel="canonical" href={doc.identity.canonical.toString()} />
|
|
12
|
-
{doc.identity.hreflang?.map((lang) => (
|
|
13
|
-
<link rel="alternate" hreflang={lang} href={doc.identity.canonical.toString()} />
|
|
14
|
-
))}
|
|
15
|
-
<meta property="og:title" content={doc.metadata.openGraph.title} />
|
|
16
|
-
<meta property="og:description" content={doc.metadata.openGraph.description} />
|
|
17
|
-
<meta property="og:url" content={doc.metadata.openGraph.url} />
|
|
18
|
-
<meta property="og:image" content={doc.metadata.openGraph.image} />
|
|
19
|
-
<meta property="og:type" content={doc.metadata.openGraph.type} />
|
|
20
|
-
{doc.metadata.openGraph.siteName && (
|
|
21
|
-
<meta property="og:site_name" content={doc.metadata.openGraph.siteName} />
|
|
22
|
-
)}
|
|
23
|
-
<meta name="twitter:card" content={doc.metadata.twitter.card} />
|
|
24
|
-
{doc.metadata.twitter.site && <meta name="twitter:site" content={doc.metadata.twitter.site} />}
|
|
25
|
-
{doc.metadata.twitter.creator && <meta name="twitter:creator" content={doc.metadata.twitter.creator} />}
|
|
26
|
-
{doc.structuredData.map((node) => (
|
|
27
|
-
<script type="application/ld+json" set:html={JSON.stringify(node)} />
|
|
28
|
-
))}
|