@grove-dev/astro 0.2.10 → 0.2.14

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 (237) hide show
  1. package/README.md +26 -18
  2. package/dist/lib/display.d.ts +11 -17
  3. package/dist/lib/display.d.ts.map +1 -1
  4. package/dist/lib/display.js +43 -43
  5. package/dist/lib/display.js.map +1 -1
  6. package/dist/lib/format.d.ts +6 -7
  7. package/dist/lib/format.d.ts.map +1 -1
  8. package/dist/lib/format.js +11 -12
  9. package/dist/lib/format.js.map +1 -1
  10. package/dist/lib/lenses.d.ts +35 -73
  11. package/dist/lib/lenses.d.ts.map +1 -1
  12. package/dist/lib/lenses.js +144 -126
  13. package/dist/lib/lenses.js.map +1 -1
  14. package/dist/lib/repo.d.ts +12 -22
  15. package/dist/lib/repo.d.ts.map +1 -1
  16. package/dist/lib/repo.js +13 -25
  17. package/dist/lib/repo.js.map +1 -1
  18. package/dist/lib/scores.d.ts +15 -16
  19. package/dist/lib/scores.d.ts.map +1 -1
  20. package/dist/lib/scores.js +4 -11
  21. package/dist/lib/scores.js.map +1 -1
  22. package/dist/lib/search.d.ts +44 -129
  23. package/dist/lib/search.d.ts.map +1 -1
  24. package/dist/lib/search.js +192 -180
  25. package/dist/lib/search.js.map +1 -1
  26. package/dist/lib/taxonomy-counts.d.ts +7 -21
  27. package/dist/lib/taxonomy-counts.d.ts.map +1 -1
  28. package/dist/lib/taxonomy-counts.js +12 -31
  29. package/dist/lib/taxonomy-counts.js.map +1 -1
  30. package/package.json +4 -4
  31. package/src/components/CategoryGrid.astro +43 -31
  32. package/src/components/ContributorsGrid.astro +3 -3
  33. package/src/components/CurationGrid.astro +41 -0
  34. package/src/components/ExploreByStack.astro +6 -0
  35. package/src/components/FilterGroupMenu.astro +49 -0
  36. package/src/components/FilterOptions.astro +44 -0
  37. package/src/components/Hero.astro +198 -147
  38. package/src/components/Icon.astro +10 -3
  39. package/src/components/IndexRow.astro +75 -0
  40. package/src/components/ItemCard.astro +266 -248
  41. package/src/components/Pagination.astro +63 -0
  42. package/src/components/RecordSection.astro +70 -34
  43. package/src/components/RefinePanel.astro +115 -141
  44. package/src/components/StackGrid.astro +32 -24
  45. package/src/components/WhyThisExists.astro +32 -32
  46. package/src/layouts/BaseLayout.astro +104 -49
  47. package/src/layouts/Container.astro +5 -44
  48. package/src/layouts/Footer.astro +31 -20
  49. package/src/layouts/Header.astro +40 -19
  50. package/src/layouts/SectionHeader.astro +27 -43
  51. package/src/layouts/Seo.astro +38 -56
  52. package/src/layouts/ThemeToggle.astro +35 -48
  53. package/src/lib/display.ts +46 -53
  54. package/src/lib/format.ts +11 -12
  55. package/src/lib/lenses.ts +151 -172
  56. package/src/lib/repo.ts +17 -28
  57. package/src/lib/scores.ts +15 -19
  58. package/src/lib/search.ts +218 -291
  59. package/src/lib/taxonomy-counts.ts +12 -46
  60. package/src/styles.css +209 -1
  61. package/templates/default/.github/ISSUE_TEMPLATE/bug_report.md +33 -0
  62. package/templates/default/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
  63. package/templates/default/.github/ISSUE_TEMPLATE/record_submission.md +48 -0
  64. package/templates/default/.github/pull_request_template.md +27 -0
  65. package/templates/default/.github/workflows/build.yml +55 -0
  66. package/templates/default/.github/workflows/cleanup-stale-records.yml +48 -0
  67. package/templates/default/.github/workflows/daily-refresh.yml +53 -0
  68. package/templates/default/.github/workflows/sync-contributors.yml +53 -0
  69. package/templates/default/.github/workflows/sync-github-metadata.yml +58 -0
  70. package/templates/default/.github/workflows/validate-data.yml +35 -0
  71. package/templates/default/data/records/coolify.yml +46 -0
  72. package/templates/default/data/records/gitea.yml +46 -0
  73. package/templates/default/data/records/hoppscotch.yml +47 -0
  74. package/templates/default/data/records/inventree.yml +47 -0
  75. package/templates/default/data/records/label-studio.yml +46 -0
  76. package/templates/default/data/records/logseq.yml +48 -0
  77. package/templates/default/data/records/private-gpt.yml +47 -0
  78. package/templates/default/data/records/wakapi.yml +44 -0
  79. package/templates/default/data/taxonomy/categories.yml +47 -0
  80. package/templates/default/data/taxonomy/distribution-channels.yml +43 -0
  81. package/templates/default/data/taxonomy/platforms.yml +27 -0
  82. package/templates/default/data/taxonomy/stacks.yml +41 -0
  83. package/templates/default/grove.config.ts +1 -1
  84. package/templates/default/package.json +5 -4
  85. package/templates/default/public/icons/stacks/bun.svg +1 -0
  86. package/templates/default/public/icons/stacks/clojure.svg +1 -0
  87. package/templates/default/public/icons/stacks/clojurescript.svg +1 -0
  88. package/templates/default/public/icons/stacks/deno.svg +1 -0
  89. package/templates/default/public/icons/stacks/django.svg +1 -0
  90. package/templates/default/public/icons/stacks/docker.svg +1 -0
  91. package/templates/default/public/icons/stacks/go.svg +1 -0
  92. package/templates/default/public/icons/stacks/llm.svg +1 -0
  93. package/templates/default/public/icons/stacks/node.js.svg +1 -0
  94. package/templates/default/public/icons/stacks/sveltekit.svg +1 -0
  95. package/templates/default/public/icons/stacks/vue.svg +1 -0
  96. package/templates/default/public/llms-full.txt +197 -5
  97. package/templates/default/public/llms.txt +107 -6
  98. package/templates/default/public/sitemap.xml +63 -0
  99. package/templates/default/scripts/build-llms.mjs +123 -0
  100. package/templates/default/scripts/build-records-json.mjs +27 -0
  101. package/templates/default/scripts/build-sitemap.mjs +20 -0
  102. package/templates/default/scripts/cleanup-stale-records.mjs +20 -0
  103. package/templates/default/scripts/enrich-github-metadata.mjs +99 -0
  104. package/templates/default/scripts/fetch-icons.mjs +137 -0
  105. package/templates/default/scripts/migrate-legacy-to-schema-v1.mjs +86 -0
  106. package/templates/default/scripts/parse-legacy-readme.mjs +59 -0
  107. package/templates/default/scripts/refresh-records-activity.mjs +24 -0
  108. package/templates/default/scripts/repair-license-format.mjs +72 -0
  109. package/templates/default/scripts/report-cleanup-candidates.mjs +21 -0
  110. package/templates/default/scripts/seed-from-github.mjs +62 -0
  111. package/templates/default/scripts/sync-contributors.mjs +145 -0
  112. package/templates/default/scripts/sync-github-metadata.mjs +28 -0
  113. package/templates/default/scripts/validate-records.mjs +27 -0
  114. package/templates/default/src/data/records.ts +138 -51
  115. package/templates/default/src/pages/404.astro +64 -0
  116. package/templates/default/src/pages/{projects/[slug].astro → [slug]/[recordSlug].astro} +236 -66
  117. package/templates/default/src/pages/[slug]/index.astro +477 -0
  118. package/templates/default/src/pages/about.astro +165 -82
  119. package/templates/default/src/pages/apps/[recordSlug].astro +33 -0
  120. package/templates/default/src/pages/contributors.astro +184 -0
  121. package/templates/default/src/pages/index.astro +119 -162
  122. package/templates/default/src/pages/submit.astro +276 -114
  123. package/templates/default/wrangler.jsonc +28 -0
  124. package/LICENSE +0 -21
  125. package/src/components/AppsIndexRow.astro +0 -204
  126. package/src/components/AppsPagination.astro +0 -126
  127. package/templates/default/.astro/content.d.ts +0 -155
  128. package/templates/default/.astro/settings.json +0 -5
  129. package/templates/default/.astro/types.d.ts +0 -1
  130. package/templates/default/data/generated/apps.json +0 -1
  131. package/templates/default/data/generated/records.full.json +0 -8
  132. package/templates/default/data/generated/records.index.json +0 -7
  133. package/templates/default/data/generated/records.json +0 -8
  134. package/templates/default/data/generated/repo-stats.json +0 -1
  135. package/templates/default/data/generated/site-config.json +0 -31
  136. package/templates/default/dist/_astro/BaseLayout.Dz_HR-68.css +0 -1
  137. package/templates/default/dist/about/index.html +0 -165
  138. package/templates/default/dist/icons/platforms/android.svg +0 -1
  139. package/templates/default/dist/icons/platforms/apple-dark.svg +0 -1
  140. package/templates/default/dist/icons/platforms/apple-light.svg +0 -1
  141. package/templates/default/dist/icons/platforms/chrome.svg +0 -1
  142. package/templates/default/dist/icons/platforms/chromeos.svg +0 -5
  143. package/templates/default/dist/icons/platforms/desktop.svg +0 -4
  144. package/templates/default/dist/icons/platforms/embedded.svg +0 -5
  145. package/templates/default/dist/icons/platforms/ios.svg +0 -1
  146. package/templates/default/dist/icons/platforms/linux.svg +0 -1
  147. package/templates/default/dist/icons/platforms/macos.svg +0 -1
  148. package/templates/default/dist/icons/platforms/ubuntu.svg +0 -1
  149. package/templates/default/dist/icons/platforms/web.svg +0 -5
  150. package/templates/default/dist/icons/platforms/windows.svg +0 -6
  151. package/templates/default/dist/icons/stacks/android.svg +0 -1
  152. package/templates/default/dist/icons/stacks/apple-dark.svg +0 -1
  153. package/templates/default/dist/icons/stacks/apple-light.svg +0 -1
  154. package/templates/default/dist/icons/stacks/capacitor.svg +0 -14
  155. package/templates/default/dist/icons/stacks/dart.svg +0 -1
  156. package/templates/default/dist/icons/stacks/firebase.svg +0 -1
  157. package/templates/default/dist/icons/stacks/flutter.svg +0 -1
  158. package/templates/default/dist/icons/stacks/graphql.svg +0 -1
  159. package/templates/default/dist/icons/stacks/ionic.svg +0 -4
  160. package/templates/default/dist/icons/stacks/java.svg +0 -1
  161. package/templates/default/dist/icons/stacks/javascript.svg +0 -1
  162. package/templates/default/dist/icons/stacks/kotlin.svg +0 -1
  163. package/templates/default/dist/icons/stacks/mongodb.svg +0 -1
  164. package/templates/default/dist/icons/stacks/nodejs.svg +0 -1
  165. package/templates/default/dist/icons/stacks/python.svg +0 -1
  166. package/templates/default/dist/icons/stacks/react-native.svg +0 -1
  167. package/templates/default/dist/icons/stacks/react.svg +0 -1
  168. package/templates/default/dist/icons/stacks/rust.svg +0 -1
  169. package/templates/default/dist/icons/stacks/solidity.svg +0 -1
  170. package/templates/default/dist/icons/stacks/swift.svg +0 -1
  171. package/templates/default/dist/icons/stacks/tensorflow.svg +0 -1
  172. package/templates/default/dist/icons/stacks/typescript.svg +0 -1
  173. package/templates/default/dist/index.html +0 -55
  174. package/templates/default/dist/llms-full.txt +0 -14
  175. package/templates/default/dist/llms.txt +0 -11
  176. package/templates/default/dist/og-image.svg +0 -48
  177. package/templates/default/dist/projects/index.html +0 -61
  178. package/templates/default/dist/robots.txt +0 -53
  179. package/templates/default/dist/sitemap.xml +0 -21
  180. package/templates/default/dist/submit/index.html +0 -69
  181. package/templates/default/node_modules/.bin/astro +0 -21
  182. package/templates/default/node_modules/.bin/astro-check +0 -21
  183. package/templates/default/node_modules/.bin/grove +0 -21
  184. package/templates/default/node_modules/.bin/jiti +0 -21
  185. package/templates/default/node_modules/.bin/prettier +0 -21
  186. package/templates/default/node_modules/.bin/rollup +0 -21
  187. package/templates/default/node_modules/.bin/tsc +0 -21
  188. package/templates/default/node_modules/.bin/tsserver +0 -21
  189. package/templates/default/node_modules/.bin/tsx +0 -21
  190. package/templates/default/node_modules/.bin/vite +0 -21
  191. package/templates/default/node_modules/.bin/yaml +0 -21
  192. package/templates/default/node_modules/.vite/deps/_metadata.json +0 -79
  193. package/templates/default/node_modules/.vite/deps/astro-KRVZHBC5.js +0 -433
  194. package/templates/default/node_modules/.vite/deps/astro-KRVZHBC5.js.map +0 -7
  195. package/templates/default/node_modules/.vite/deps/astro___aria-query.js +0 -5
  196. package/templates/default/node_modules/.vite/deps/astro___aria-query.js.map +0 -7
  197. package/templates/default/node_modules/.vite/deps/astro___axobject-query.js +0 -5
  198. package/templates/default/node_modules/.vite/deps/astro___axobject-query.js.map +0 -7
  199. package/templates/default/node_modules/.vite/deps/astro___html-escaper.js +0 -9
  200. package/templates/default/node_modules/.vite/deps/astro___html-escaper.js.map +0 -7
  201. package/templates/default/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js +0 -241
  202. package/templates/default/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js.map +0 -7
  203. package/templates/default/node_modules/.vite/deps/audit-GYHQ3ITH.js +0 -1592
  204. package/templates/default/node_modules/.vite/deps/audit-GYHQ3ITH.js.map +0 -7
  205. package/templates/default/node_modules/.vite/deps/chunk-5WRI5ZAA.js +0 -30
  206. package/templates/default/node_modules/.vite/deps/chunk-5WRI5ZAA.js.map +0 -7
  207. package/templates/default/node_modules/.vite/deps/chunk-7P4KIK53.js +0 -186
  208. package/templates/default/node_modules/.vite/deps/chunk-7P4KIK53.js.map +0 -7
  209. package/templates/default/node_modules/.vite/deps/chunk-BQGHFQJH.js +0 -48
  210. package/templates/default/node_modules/.vite/deps/chunk-BQGHFQJH.js.map +0 -7
  211. package/templates/default/node_modules/.vite/deps/chunk-CJCDKWP2.js +0 -6779
  212. package/templates/default/node_modules/.vite/deps/chunk-CJCDKWP2.js.map +0 -7
  213. package/templates/default/node_modules/.vite/deps/chunk-FHJ3XBDE.js +0 -33
  214. package/templates/default/node_modules/.vite/deps/chunk-FHJ3XBDE.js.map +0 -7
  215. package/templates/default/node_modules/.vite/deps/chunk-FQEMNIUQ.js +0 -88
  216. package/templates/default/node_modules/.vite/deps/chunk-FQEMNIUQ.js.map +0 -7
  217. package/templates/default/node_modules/.vite/deps/chunk-HBSVDAQD.js +0 -72
  218. package/templates/default/node_modules/.vite/deps/chunk-HBSVDAQD.js.map +0 -7
  219. package/templates/default/node_modules/.vite/deps/chunk-HDOQ2U3U.js +0 -3757
  220. package/templates/default/node_modules/.vite/deps/chunk-HDOQ2U3U.js.map +0 -7
  221. package/templates/default/node_modules/.vite/deps/chunk-XEBKRCNE.js +0 -54
  222. package/templates/default/node_modules/.vite/deps/chunk-XEBKRCNE.js.map +0 -7
  223. package/templates/default/node_modules/.vite/deps/package.json +0 -3
  224. package/templates/default/node_modules/.vite/deps/settings-C5H4VHXU.js +0 -213
  225. package/templates/default/node_modules/.vite/deps/settings-C5H4VHXU.js.map +0 -7
  226. package/templates/default/node_modules/.vite/deps/toolbar-H5DB6HVP.js +0 -525
  227. package/templates/default/node_modules/.vite/deps/toolbar-H5DB6HVP.js.map +0 -7
  228. package/templates/default/node_modules/.vite/deps/ui-library-JGHU6JE2.js +0 -1020
  229. package/templates/default/node_modules/.vite/deps/ui-library-JGHU6JE2.js.map +0 -7
  230. package/templates/default/node_modules/.vite/deps/xray-EFCQOW4E.js +0 -147
  231. package/templates/default/node_modules/.vite/deps/xray-EFCQOW4E.js.map +0 -7
  232. package/templates/default/src/components/layout/BaseLayout.astro +0 -47
  233. package/templates/default/src/components/layout/Container.astro +0 -18
  234. package/templates/default/src/components/layout/Footer.astro +0 -27
  235. package/templates/default/src/components/layout/Header.astro +0 -43
  236. package/templates/default/src/lib/markdown.ts +0 -171
  237. package/templates/default/src/pages/projects/index.astro +0 -193
@@ -1,62 +1,54 @@
1
1
  ---
2
2
  /**
3
- * ItemCard — generic project/resource/entity card.
3
+ * ItemCard — compact card for one record on the home page (or any
4
+ * 3-column grid).
4
5
  *
5
- * The most-decorated card in the showcase. Used on home (curated
6
- * sections), the list page (cards-only views), and the search
7
- * results. Renders:
6
+ * V1 (published) API: takes `record` (a Resource / IndexProjectRecord)
7
+ * and `href` (the full detail-page URL). The page that calls this
8
+ * component owns the path layout — blueprint-aware consumers
9
+ * (project-directory → /projects/<slug>, resource-hub →
10
+ * /resources/<slug>, ecosystem-map → /entities/<slug>) pass the
11
+ * correct href via `RecordSection`'s `hrefFor` callback.
8
12
  *
9
- * - Logo/avatar with initials fallback (logoUrl -> GitHub owner
10
- * avatar -> 2-letter initials).
11
- * - Owner / repo link (when `repoUrl` is github.com).
12
- * - Stack + platform chips (first 3, then "+N" overflow).
13
- * - Optional curated mark (label badge) — e.g. "Featured", "New".
14
- * - Health badge (Active / Mature / Stale / Archived / ...).
15
- * - License + star count + last-updated date.
16
- * - Two CTAs: "View repo" (links.github if present, else repoUrl)
17
- * and "Details" (the page link).
18
- *
19
- * Generic: takes any `Resource` (`kind: "project" | "resource" |
20
- * "entity"`). For "project" we read the project-style fields
21
- * (`repoUrl`, `logoUrl`, `stacks`, `platforms`, `github`,
22
- * `curation`, `health`); for other kinds we degrade gracefully to
23
- * the minimal view.
24
- *
25
- * Index-payload-friendly: when the consumer passes an
26
- * `IndexProjectRecord` (the slim list-page shape), the component
27
- * reads the projected fields directly — `github.stars` /
28
- * `github.pushedAt` / `github.license` — rather than the raw
29
- * `github.repository` block.
13
+ * V1 (legacy) API: also accepts `item` + `pathPrefix` as a
14
+ * convenience for callers that haven't migrated. When both are
15
+ * provided, `href` wins; when only `pathPrefix` is provided, the
16
+ * detail href is `${pathPrefix}/${item.slug}`.
30
17
  */
31
- import type {
32
- Resource,
33
- ProjectRecord,
34
- HealthStatus,
35
- IndexProjectRecord,
36
- } from "@grove-dev/core";
37
- import { getOwnerAndRepoFromRepoUrl, getOwnerAvatarUrl } from "../lib/repo";
38
- import { formatRelative, formatStars } from "../lib/format";
39
- import { statusDisplay, prettySlug, labelDisplay } from "../lib/display";
18
+ import type { IndexRecord, Resource, ProjectRecord, IndexProjectRecord } from "@grove-dev/core";
19
+ import Icon from "./Icon.astro";
20
+ import { getOwnerAndRepoFromRepoUrl, getOwnerAvatarUrl } from "@grove-dev/astro";
21
+ import { formatStars, formatRelative } from "@grove-dev/astro";
22
+ import { statusDisplay } from "@grove-dev/astro";
23
+
24
+ type CardRecord = IndexRecord | Resource | ProjectRecord | IndexProjectRecord;
40
25
 
41
26
  interface Props {
42
- record: Resource | IndexProjectRecord;
43
- /** Detail page href. */
44
- href: string;
27
+ /** V1 API — full Resource/IndexProjectRecord. */
28
+ record?: CardRecord;
29
+ /** V1 API — full detail-page URL. */
30
+ href?: string;
31
+ /** Legacy API — singular item, equivalent to `record`. */
32
+ item?: CardRecord;
33
+ /** Legacy API — URL prefix, equivalent to deriving from
34
+ * blueprint routeSlug + slug. */
35
+ pathPrefix?: string;
45
36
  /** When true, show owner/repo below the title. Default true. */
46
37
  showOwner?: boolean;
47
- /** When true, render the stack/platform chip row. Default true. */
38
+ /** When false, hide the platform + stack chip row. Default true. */
48
39
  showChips?: boolean;
49
- /** When true, render the curated label badge row. Default true. */
40
+ /** When false, hide the curated label. Default true. */
50
41
  showLabel?: boolean;
51
- /** When true, render the metadata strip (license/stars/updated). */
42
+ /** When false, hide the stars/updated/license metadata row. */
52
43
  showMeta?: boolean;
53
- /** Extra class on the outer <article>. */
54
44
  class?: string;
55
45
  }
56
46
 
57
47
  const {
58
- record,
59
- href,
48
+ record: recordProp,
49
+ href: hrefProp,
50
+ item: itemProp,
51
+ pathPrefix = "/items",
60
52
  showOwner = true,
61
53
  showChips = true,
62
54
  showLabel = true,
@@ -64,254 +56,280 @@ const {
64
56
  class: className = "",
65
57
  } = Astro.props;
66
58
 
67
- // Project-specific extraction. The `isIndex` branch reads the slim
68
- // fields from `toIndexRecord`; the full branch reads the raw
69
- // `github.repository` block for sites that want max fidelity.
70
- const isIndex = "github" in record && record.github !== undefined
71
- && (record as IndexProjectRecord).github !== undefined
72
- && (record.github as { fullName?: string })?.fullName !== undefined;
73
- const project = record.kind === "project" ? (record as ProjectRecord | IndexProjectRecord) : null;
59
+ // Resolve `item` (legacy) `record` (V1) and `hrefProp` → final.
60
+ const item: CardRecord = (recordProp ?? itemProp) as CardRecord;
61
+ const slug = (item as { slug: string }).slug;
62
+ let detailHref: string;
63
+ if (typeof hrefProp === "string" && hrefProp.length > 0) {
64
+ detailHref = hrefProp;
65
+ } else {
66
+ const base = pathPrefix.replace(/\/$/, "");
67
+ detailHref = `${base}/${slug}`;
68
+ }
69
+
70
+ const isProject = item.kind === "project";
71
+ const name = item.kind === "resource" ? (item as { title: string }).title : (item as { name: string }).name;
72
+ const repoHref = isProject
73
+ ? (item as { repoUrl?: string }).repoUrl
74
+ : (item as { links: { github?: string } }).links?.github;
75
+ const homepageHref = isProject
76
+ ? undefined
77
+ : (item.links as { website?: string; source?: string }).website ??
78
+ (item.links as { source?: string }).source;
74
79
 
75
- const name =
76
- record.kind === "resource"
77
- ? (record as { title: string }).title
78
- : (record as { name: string }).name;
79
- const description = record.description ?? "";
80
- const tags = (record.tags ?? []).slice(0, 4);
81
- const repoUrl = (project as ProjectRecord | IndexProjectRecord | null)?.repoUrl;
82
- const logoUrl = (project as ProjectRecord | IndexProjectRecord | null)?.logoUrl;
83
- const stacks = (project?.stacks ?? (project?.stack ? [project.stack] : [])) as string[];
84
- const platforms = (project?.platforms ?? []) as string[];
85
- const healthStatus: HealthStatus | undefined = project?.health?.status;
86
- const labels = record.curation?.labels ?? [];
87
- // Slim (IndexProjectRecord.github) → full (ProjectRecord.github.repository).
88
- const indexGh = isIndex ? (project as IndexProjectRecord).github : null;
89
- const fullGh = !isIndex ? (project as ProjectRecord | null)?.github?.repository : null;
90
- const stars = indexGh?.stars ?? fullGh?.stargazers_count;
91
- const licenseSpdx = indexGh?.license ?? fullGh?.license?.spdx_id ?? null;
92
- const pushedAt = indexGh?.pushedAt ?? fullGh?.pushed_at;
93
- const githubUrl =
94
- repoUrl ?? (record.links as { github?: string } | undefined)?.github;
80
+ // Curated = at least one of the hand-written fields is filled in.
81
+ const isCurated = Boolean(
82
+ (item as { curation?: { reviewed?: boolean } }).curation?.reviewed ||
83
+ (isProject && (
84
+ ((item as { bestFor?: string[] }).bestFor?.length ?? 0) > 0 ||
85
+ ((item as { whyListed?: string[] }).whyListed?.length ?? 0) > 0 ||
86
+ ((item as { caveats?: string[] }).caveats?.length ?? 0) > 0
87
+ )),
88
+ );
95
89
 
96
- // Logo fallback chain
97
- const ownerRepo = repoUrl ? getOwnerAndRepoFromRepoUrl(repoUrl) : null;
98
- const avatarSrc =
99
- logoUrl ?? (ownerRepo ? getOwnerAvatarUrl(ownerRepo.owner, 80) : null);
100
- const initials = (name || "")
101
- .replace(/[^a-z0-9]/gi, "")
90
+ // Initials for placeholder logo.
91
+ const initials = (name ?? "")
92
+ .split(/\s+/)
93
+ .map((w) => w[0])
94
+ .filter(Boolean)
102
95
  .slice(0, 2)
96
+ .join("")
103
97
  .toUpperCase();
104
98
 
105
- // Chips: 3 stacks/platforms, then overflow count
106
- function withOverflow(
107
- items: string[],
108
- ): { shown: string[]; overflow: number } {
109
- const shown = items.filter(Boolean).slice(0, 3);
110
- const overflow = Math.max(0, items.filter(Boolean).length - shown.length);
111
- return { shown, overflow };
112
- }
113
- const stackChips = withOverflow(stacks);
114
- const platformChips = withOverflow(platforms);
99
+ // Logo URL: explicit `logoUrl` (project only) → GitHub owner avatar
100
+ // → initials placeholder.
101
+ const { owner, repo } = getOwnerAndRepoFromRepoUrl(repoHref ?? "");
102
+ const logoUrl = isProject
103
+ ? (item as { logoUrl?: string }).logoUrl ?? getOwnerAvatarUrl(owner, 80)
104
+ : undefined;
115
105
 
116
- // Curated mark
117
- const curatedLabel = labels[0] ?? null;
106
+ // Up to 4 platforms + stack chips (project only).
107
+ const platforms = isProject ? (item as { platforms?: string[] }).platforms ?? [] : [];
108
+ const visiblePlatforms = platforms.slice(0, 4);
109
+ const platformOverflow = platforms.length - visiblePlatforms.length;
118
110
 
119
- // Health class hook (for badge variant). `project` is the union of
120
- // ProjectRecord | IndexProjectRecord, and both share a `health` field
121
- // with the same shape, so a single optional-chain read covers both
122
- // shapes no isIndex branch is needed for the status itself.
123
- function healthBadgeClass(s: HealthStatus | undefined): string {
124
- switch (s) {
125
- case "mature":
126
- case "active":
127
- return "grove-badge-new";
128
- case "stale":
129
- case "inactive":
130
- return "grove-badge-stale";
131
- case "archived":
132
- case "unavailable":
133
- return "grove-badge-hot";
134
- default:
135
- return "";
136
- }
137
- }
138
- const healthLabel = healthStatus ? statusDisplay(healthStatus) : null;
111
+ const allStacks = isProject
112
+ ? [
113
+ (item as { stack?: string }).stack,
114
+ ...((item as { stacks?: string[] }).stacks ?? []),
115
+ ].filter((s): s is string => Boolean(s))
116
+ : [];
117
+ const visibleStacks = allStacks.slice(0, 3);
118
+ const stackOverflow = allStacks.length - visibleStacks.length;
139
119
 
120
+ const stars = isProject ? (item as { github?: { stars?: number } }).github?.stars : undefined;
121
+ const updatedAt = isProject
122
+ ? (item as { github?: { pushedAt?: string | null } }).github?.pushedAt
123
+ : (item as { publishedAt?: string | null }).publishedAt;
140
124
  const starsLabel = formatStars(stars);
141
- const updatedLabel = formatRelative(pushedAt);
125
+ const updatedLabel = formatRelative(updatedAt);
126
+ const license = isProject ? (item as { github?: { license?: string } }).github?.license : null;
127
+ const status = isProject ? (item as { health?: { status?: string } }).health?.status : undefined;
128
+
129
+ const externalHref = repoHref ?? homepageHref;
130
+ const statusText = status ? statusDisplay(status as Parameters<typeof statusDisplay>[0]) : "";
131
+ const category = (item as { category?: string }).category;
142
132
  ---
143
133
 
144
134
  <article
145
- class:list={["grove-card grove-card-hover grove-item-card p-4", className]}
146
- data-record-slug={record.slug}
147
- data-record-kind={record.kind}
148
- data-record-category={record.category}
149
- data-record-stacks={stacks.join(",")}
150
- data-record-platforms={platforms.join(",")}
135
+ class={`card card-hover group relative flex h-full flex-col gap-3 p-4 ${className}`}
151
136
  >
152
- <div class="flex items-start gap-3">
153
- <div class="grove-item-card__logo flex-shrink-0">
154
- {avatarSrc ? (
155
- <img
156
- src={avatarSrc}
157
- alt=""
158
- width="40"
159
- height="40"
160
- loading="lazy"
161
- decoding="async"
162
- class="h-10 w-10 rounded-md border border-ink-200 object-contain bg-white dark:border-ink-800 dark:bg-ink-950"
163
- />
164
- ) : (
165
- <span
166
- class="inline-flex h-10 w-10 items-center justify-center rounded-md border border-ink-200 bg-ink-50 text-sm font-semibold text-ink-700 dark:border-ink-800 dark:bg-ink-900 dark:text-ink-200"
167
- aria-hidden="true"
137
+ {/* Top row: logo + name + (optional) status + curated mark. */}
138
+ <header class="relative z-10 flex items-start gap-3">
139
+ <a
140
+ href={detailHref}
141
+ aria-label={`Open ${name} details`}
142
+ class="absolute inset-0 z-0 rounded-lg"
143
+ ></a>
144
+
145
+ {logoUrl ? (
146
+ <img
147
+ src={logoUrl}
148
+ alt={`${name} avatar`}
149
+ width="40"
150
+ height="40"
151
+ loading="lazy"
152
+ decoding="async"
153
+ class="h-10 w-10 shrink-0 rounded-md border border-ink-200 bg-white object-cover dark:border-ink-800 dark:bg-ink-900"
154
+ />
155
+ ) : (
156
+ <span
157
+ aria-hidden="true"
158
+ class="flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-ink-200 bg-ink-50 text-2xs font-semibold text-ink-700 dark:border-ink-800 dark:bg-ink-900/60 dark:text-ink-300"
159
+ >
160
+ {initials}
161
+ </span>
162
+ )}
163
+
164
+ <div class="flex min-w-0 flex-1 flex-col gap-0.5">
165
+ <h3
166
+ class="m-0 truncate text-[0.9375rem] font-semibold tracking-tight text-ink-900 group-hover:underline dark:text-ink-100"
167
+ >
168
+ {/* Name → detail page (not GitHub repo). Stacks above the
169
+ stretched link via z-index. */}
170
+ <a
171
+ href={detailHref}
172
+ class="relative z-10 text-inherit no-underline"
168
173
  >
169
- {initials || "·"}
174
+ {name}
175
+ </a>
176
+ </h3>
177
+ {showLabel && (
178
+ <span class="truncate text-2xs text-ink-500 dark:text-ink-400">
179
+ {showOwner && owner ? (
180
+ <>
181
+ <a
182
+ href={repoHref}
183
+ target="_blank"
184
+ rel="noopener noreferrer"
185
+ data-event="external_repo_click"
186
+ data-event-source="card_owner"
187
+ data-event-item={slug}
188
+ class="relative z-10 font-mono text-ink-500 hover:text-ink-900 dark:text-ink-400 dark:hover:text-ink-100"
189
+ >
190
+ <span class="text-ink-400 dark:text-ink-500">@</span>{owner}{repo ? <span class="text-ink-400 dark:text-ink-500">/{repo}</span> : ""}
191
+ </a>
192
+ <span class="mx-1 text-ink-300 dark:text-ink-700">·</span>
193
+ {category}
194
+ {status && (
195
+ <>
196
+ <span class="mx-1 text-ink-300 dark:text-ink-700">·</span>
197
+ <span class="text-ink-500 dark:text-ink-400">{statusText}</span>
198
+ </>
199
+ )}
200
+ </>
201
+ ) : (
202
+ <>
203
+ {category}
204
+ {status && (
205
+ <>
206
+ <span class="mx-1 text-ink-300 dark:text-ink-700">·</span>
207
+ <span class="text-ink-500 dark:text-ink-400">{statusText}</span>
208
+ </>
209
+ )}
210
+ </>
211
+ )}
170
212
  </span>
171
213
  )}
172
214
  </div>
173
215
 
174
- <div class="min-w-0 flex-1">
175
- <div class="flex flex-wrap items-baseline gap-2">
176
- <h2 class="m-0 text-[1rem] font-semibold leading-tight tracking-tight text-ink-900 dark:text-ink-100">
177
- <a href={href} class="text-inherit no-underline hover:underline">
178
- {name}
179
- </a>
180
- </h2>
181
- {showLabel && curatedLabel && (
182
- <span
183
- class:list={["grove-badge", `grove-badge-${curatedLabel}`]}
184
- title={`Curated: ${labelDisplay(curatedLabel) ?? curatedLabel}`}
185
- >
186
- {labelDisplay(curatedLabel) ?? prettySlug(curatedLabel)}
187
- </span>
188
- )}
189
- {healthLabel && (
190
- <span class:list={["grove-badge", healthBadgeClass(healthStatus)]}>
191
- {healthLabel}
192
- </span>
193
- )}
194
- </div>
216
+ {isCurated && showLabel && (
217
+ <span
218
+ class="inline-flex shrink-0 items-center gap-1 text-[10px] font-medium uppercase tracking-wider text-emerald-700 dark:text-emerald-400"
219
+ title="Hand-curated entry: bestFor, whyListed, caveats, or scores are filled in"
220
+ >
221
+ <svg viewBox="0 0 16 16" width="10" height="10" aria-hidden="true" fill="currentColor">
222
+ <path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0Zm3.41 5.09a.75.75 0 0 0-1.06 0L6.75 8.69 5.65 7.59a.75.75 0 1 0-1.06 1.06l1.65 1.65a.75.75 0 0 0 1.06 0l4.11-4.11a.75.75 0 0 0 0-1.06Z"/>
223
+ </svg>
224
+ curated
225
+ </span>
226
+ )}
227
+ </header>
195
228
 
196
- {showOwner && ownerRepo && (
197
- <p class="m-0 mt-0.5 text-2xs text-ink-500 dark:text-ink-400">
198
- <a
199
- href={`https://github.com/${ownerRepo.owner}`}
200
- target="_blank"
201
- rel="noopener noreferrer"
202
- class="text-inherit no-underline hover:text-ink-900 dark:hover:text-ink-100"
203
- >
204
- @{ownerRepo.owner}
205
- </a>
206
- <span class="mx-1 text-ink-300 dark:text-ink-700">/</span>
207
- <a
208
- href={repoUrl}
209
- target="_blank"
210
- rel="noopener noreferrer"
211
- class="text-inherit no-underline hover:text-ink-900 dark:hover:text-ink-100"
212
- >
213
- {ownerRepo.repo}
214
- </a>
215
- </p>
216
- )}
229
+ {/* Description */}
230
+ {item.description && (
231
+ <p
232
+ class="relative z-10 m-0 line-clamp-2 text-[0.8125rem] leading-relaxed text-ink-500 dark:text-ink-400"
233
+ >
234
+ {item.description}
235
+ </p>
236
+ )}
217
237
 
218
- {description && (
219
- <p class="m-0 mt-1.5 line-clamp-2 text-[0.8125rem] leading-snug text-ink-500 dark:text-ink-400">
220
- {description}
221
- </p>
238
+ {/* Metadata row: stars · updated · license — the three signals
239
+ that answer "is this alive and usable?" at a glance. */}
240
+ {showMeta && (
241
+ <div class="relative z-10 flex flex-wrap items-center gap-x-3 gap-y-1 text-2xs text-ink-500 dark:text-ink-400">
242
+ {starsLabel !== null && (
243
+ <span class="inline-flex items-center gap-1" title={`${stars ?? 0} stars on GitHub`}>
244
+ <svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true" fill="currentColor" class="text-amber-500">
245
+ <path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Z"/>
246
+ </svg>
247
+ <span class="font-mono">{starsLabel}</span>
248
+ </span>
222
249
  )}
223
- </div>
224
- </div>
225
-
226
- {showChips && (stackChips.shown.length > 0 || platformChips.shown.length > 0) && (
227
- <div class="mt-3 flex flex-wrap items-center gap-1.5">
228
- {stackChips.shown.map((s) => (
229
- <span class="grove-badge" title={`Stack: ${s}`}>{s}</span>
230
- ))}
231
- {stackChips.overflow > 0 && (
232
- <span class="grove-badge" title={`${stackChips.overflow} more stacks`}>
233
- +{stackChips.overflow}
250
+ {updatedAt && (
251
+ <span class="inline-flex items-center gap-1" title={`Last updated ${updatedAt}`}>
252
+ <svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true" fill="currentColor" class="text-ink-400">
253
+ <path d="M8 2a6 6 0 1 0 0 12A6 6 0 0 0 8 2Zm0 1.5a4.5 4.5 0 1 1 0 9 4.5 4.5 0 0 1 0-9Zm-.5 1.5a.5.5 0 0 0-1 0v3.25c0 .15.07.29.18.39l1.95 1.65a.5.5 0 0 0 .66-.76L7.5 7.55V5Z"/>
254
+ </svg>
255
+ <span class="font-mono">Updated {updatedLabel}</span>
234
256
  </span>
235
257
  )}
236
- {platformChips.shown.map((p) => (
237
- <span class="grove-badge" title={`Platform: ${p}`}>{p}</span>
238
- ))}
239
- {platformChips.overflow > 0 && (
240
- <span
241
- class="grove-badge"
242
- title={`${platformChips.overflow} more platforms`}
243
- >
244
- +{platformChips.overflow}
258
+ {license && (
259
+ <span class="inline-flex items-center gap-1" title={`License: ${license}`}>
260
+ <span class="font-mono uppercase tracking-wide text-[10px]">{license}</span>
245
261
  </span>
246
262
  )}
247
- {tags.length > 0 && tags.map((t) => <span class="grove-badge">{t}</span>)}
248
263
  </div>
249
264
  )}
250
265
 
251
- {showMeta && (starsLabel || licenseSpdx || updatedLabel !== "—") && (
252
- <dl
253
- class="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-2xs text-ink-500 dark:text-ink-400"
254
- aria-label="Project signals"
255
- >
256
- {starsLabel && (
257
- <div class="flex items-center gap-1">
258
- <dt class="sr-only">Stars</dt>
259
- <dd class="m-0 font-mono">{starsLabel}</dd>
260
- <span aria-hidden="true">★</span>
261
- </div>
262
- )}
263
- {licenseSpdx && (
264
- <div class="flex items-center gap-1">
265
- <dt class="sr-only">License</dt>
266
- <dd class="m-0">{licenseSpdx}</dd>
266
+ {/* Platform + stack chips, pushed to the bottom. */}
267
+ {showChips && (
268
+ <div class="relative z-10 mt-auto flex flex-col gap-1.5">
269
+ {visiblePlatforms.length > 0 && (
270
+ <div class="flex flex-wrap items-center gap-1.5">
271
+ {visiblePlatforms.map((p) => (
272
+ <span class="chip">
273
+ <Icon name={p} category="platform" size={11} />
274
+ <span>{p}</span>
275
+ </span>
276
+ ))}
277
+ {platformOverflow > 0 && (
278
+ <span class="chip text-ink-400 dark:text-ink-500">+{platformOverflow}</span>
279
+ )}
267
280
  </div>
268
281
  )}
269
- {updatedLabel && updatedLabel !== "—" && (
270
- <div class="flex items-center gap-1">
271
- <dt class="sr-only">Last updated</dt>
272
- <dd class="m-0">Updated {updatedLabel}</dd>
282
+ {visibleStacks.length > 0 && (
283
+ <div class="flex flex-wrap items-center gap-1.5">
284
+ {visibleStacks.map((s) => (
285
+ <span class="chip chip-stack">
286
+ <Icon name={s} category="stack" size={11} />
287
+ <span>{s}</span>
288
+ </span>
289
+ ))}
290
+ {stackOverflow > 0 && (
291
+ <span class="chip chip-stack text-ink-400 dark:text-ink-500">+{stackOverflow}</span>
292
+ )}
273
293
  </div>
274
294
  )}
275
- </dl>
295
+ </div>
276
296
  )}
277
297
 
278
- <div class="mt-4 flex flex-wrap items-center gap-2">
279
- <a
280
- href={href}
281
- class="grove-btn grove-btn-outline"
282
- data-event="card_details_click"
283
- data-event-source="item_card"
284
- data-event-app={record.slug}
285
- >
286
- Details
287
- </a>
288
- {githubUrl && (
298
+ {/* Footer row: View repo + Details links. */}
299
+ <footer class="relative z-10 mt-1 flex items-center justify-between gap-2 border-t border-ink-100 pt-2 text-2xs dark:border-ink-900">
300
+ {externalHref && (
289
301
  <a
290
- href={githubUrl}
302
+ href={externalHref}
291
303
  target="_blank"
292
304
  rel="noopener noreferrer"
293
- class="grove-btn grove-btn-ghost"
294
- data-event="card_repo_click"
295
- data-event-source="item_card"
296
- data-event-app={record.slug}
305
+ data-event="external_repo_click"
306
+ data-event-source="card_view_repo"
307
+ data-event-item={slug}
308
+ class="relative z-10 inline-flex items-center gap-1 font-medium text-ink-700 transition-colors hover:text-ink-900 dark:text-ink-300 dark:hover:text-ink-100"
297
309
  >
298
- View repo
310
+ {repoHref ? "View repo" : "Visit source"}
311
+ <svg viewBox="0 0 16 16" width="10" height="10" aria-hidden="true" fill="currentColor">
312
+ <path d="M3.75 2A1.75 1.75 0 0 0 2 3.75v8.5C2 13.216 2.784 14 3.75 14h8.5A1.75 1.75 0 0 0 14 12.25v-3.5a.75.75 0 0 0-1.5 0v3.5a.25.25 0 0 1-.25.25h-8.5a.25.25 0 0 1-.25-.25v-8.5a.25.25 0 0 1 .25-.25h3.5a.75.75 0 0 0 0-1.5h-3.5Z"/>
313
+ <path d="M10 0a.75.75 0 0 0-.75.75v.75h-3a.75.75 0 0 0 0 1.5h3v.75A.75.75 0 0 0 10.75 4.5h2.5A.75.75 0 0 0 14 3.75v-2.5A.75.75 0 0 0 13.25.5H10Z"/>
314
+ </svg>
299
315
  </a>
300
316
  )}
301
- </div>
317
+ <a
318
+ href={detailHref}
319
+ class="relative z-10 inline-flex items-center gap-1 font-medium text-ink-500 transition-colors hover:text-ink-900 dark:text-ink-400 dark:hover:text-ink-100"
320
+ >
321
+ Details →
322
+ </a>
323
+ </footer>
302
324
  </article>
303
325
 
304
326
  <style>
305
- .grove-item-card {
306
- display: flex;
307
- flex-direction: column;
308
- gap: 0.25rem;
309
- height: 100%;
327
+ @reference "../styles.css";
328
+
329
+ .chip {
330
+ @apply inline-flex items-center gap-1 rounded-md border border-ink-200 bg-white px-1.5 py-0.5 text-2xs font-medium text-ink-700 transition-colors dark:border-ink-200 dark:bg-ink-950 dark:text-ink-300;
310
331
  }
311
- .line-clamp-2 {
312
- display: -webkit-box;
313
- -webkit-line-clamp: 2;
314
- -webkit-box-orient: vertical;
315
- overflow: hidden;
332
+ .chip-stack {
333
+ @apply bg-ink-50 dark:bg-ink-900/60;
316
334
  }
317
335
  </style>
@@ -0,0 +1,63 @@
1
+ ---
2
+ interface Props {
3
+ current: number;
4
+ total: number;
5
+ hrefFor: (page: number) => string;
6
+ class?: string;
7
+ }
8
+
9
+ const { current, total, hrefFor, class: className = "" } = Astro.props;
10
+
11
+ function pageList(active: number, count: number): Array<number | "ellipsis"> {
12
+ if (count <= 7) return Array.from({ length: count }, (_, index) => index + 1);
13
+ const pages = new Set([1, count, active - 1, active, active + 1]);
14
+ const sorted = [...pages].filter((page) => page >= 1 && page <= count).sort((a, b) => a - b);
15
+ const result: Array<number | "ellipsis"> = [];
16
+ for (const page of sorted) {
17
+ const previous = result.at(-1);
18
+ if (typeof previous === "number" && page - previous > 1) result.push("ellipsis");
19
+ result.push(page);
20
+ }
21
+ return result;
22
+ }
23
+
24
+ const pages = pageList(current, total);
25
+ const linkClass =
26
+ "grove-btn grove-btn-outline min-w-9 px-2 py-1.5 text-2xs";
27
+ ---
28
+
29
+ {total > 1 && (
30
+ <nav
31
+ class:list={["flex flex-wrap items-center justify-center gap-1.5", className]}
32
+ aria-label="Pagination"
33
+ >
34
+ {current > 1 ? (
35
+ <a href={hrefFor(current - 1)} class={linkClass} rel="prev">Previous</a>
36
+ ) : (
37
+ <span class={`${linkClass} pointer-events-none opacity-40`}>Previous</span>
38
+ )}
39
+
40
+ {pages.map((page) =>
41
+ page === "ellipsis" ? (
42
+ <span class="px-1 text-2xs text-ink-400" aria-hidden="true">...</span>
43
+ ) : (
44
+ <a
45
+ href={hrefFor(page)}
46
+ class:list={[
47
+ linkClass,
48
+ page === current && "grove-btn-primary",
49
+ ]}
50
+ aria-current={page === current ? "page" : undefined}
51
+ >
52
+ {page}
53
+ </a>
54
+ ),
55
+ )}
56
+
57
+ {current < total ? (
58
+ <a href={hrefFor(current + 1)} class={linkClass} rel="next">Next</a>
59
+ ) : (
60
+ <span class={`${linkClass} pointer-events-none opacity-40`}>Next</span>
61
+ )}
62
+ </nav>
63
+ )}