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