@grove-dev/astro 0.5.0-next.0 → 0.5.0-next.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grove-dev/astro",
3
- "version": "0.5.0-next.0",
3
+ "version": "0.5.0-next.2",
4
4
  "type": "module",
5
5
  "description": "Composable Astro UI, data adapters, and integration for Grove directories.",
6
6
  "license": "MIT",
@@ -56,7 +56,7 @@
56
56
  "sanitize-html": "^2.17.5",
57
57
  "shiki": "^1.29.2",
58
58
  "yaml": "^2.9.0",
59
- "@grove-dev/core": "0.5.0-next.0"
59
+ "@grove-dev/core": "0.5.0-next.2"
60
60
  },
61
61
  "peerDependencies": {
62
62
  "astro": "^6.0.0 || ^7.0.0"
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  import type { IndexRecord } from "@grove-dev/core";
3
3
  import StackPlatformChips from "./StackPlatformChips.astro";
4
- import { formatRelative, formatStars, getOwnerAndRepoFromRepoUrl, getOwnerAvatarUrl, projectStackIds, statusDisplay } from "@grove-dev/astro";
4
+ import { formatRelative, formatStars, getOwnerAndRepoFromRepoUrl, getOwnerAvatarUrl, projectStackIds, statusDisplay, licenseDisplay } from "@grove-dev/astro";
5
5
 
6
6
  interface Props {
7
7
  record: IndexRecord;
@@ -71,7 +71,7 @@ const curated = Boolean(item.curation?.reviewed || (project && (project.bestFor.
71
71
  <dl class="grid grid-cols-3 gap-x-3 text-2xs">
72
72
  <div class="flex items-baseline gap-1.5"><dt class="font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">Stars</dt><dd class="font-mono text-ink-700 dark:text-ink-300">{formatStars(stars) ?? "—"}</dd></div>
73
73
  <div class="flex items-baseline gap-1.5"><dt class="font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">Updated</dt><dd class="font-mono text-ink-700 dark:text-ink-300">{formatRelative(updatedAt)}</dd></div>
74
- <div class="flex items-baseline gap-1.5"><dt class="font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">License</dt><dd class="font-mono text-ink-700 dark:text-ink-300">{license ?? "—"}</dd></div>
74
+ <div class="flex items-baseline gap-1.5"><dt class="font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">License</dt><dd class="font-mono text-ink-700 dark:text-ink-300">{license ? licenseDisplay(license) : "—"}</dd></div>
75
75
  </dl>
76
76
 
77
77
  {showChips && (platforms.length > 0 || stacks.length > 0) && (
@@ -28,6 +28,7 @@ import StackPlatformChips from "./StackPlatformChips.astro";
28
28
  import { getOwnerAndRepoFromRepoUrl, getOwnerAvatarUrl, projectStackIds } from "@grove-dev/astro";
29
29
  import { formatStars, formatRelative } from "@grove-dev/astro";
30
30
  import { statusDisplay } from "@grove-dev/astro";
31
+ import { licenseDisplay } from "@grove-dev/astro";
31
32
 
32
33
  type CardRecord = IndexRecord | Resource | ProjectRecord | IndexProjectRecord;
33
34
 
@@ -273,7 +274,7 @@ const category = (item as { category?: string }).category;
273
274
  )}
274
275
  {license && (
275
276
  <span class="inline-flex items-center gap-1" title={`License: ${license}`}>
276
- <span class="font-mono uppercase tracking-wide text-[10px]">{license}</span>
277
+ <span class="font-mono uppercase tracking-wide text-[10px]">{licenseDisplay(license)}</span>
277
278
  </span>
278
279
  )}
279
280
  </div>
@@ -36,6 +36,9 @@ const {
36
36
  initials,
37
37
  curationLabels,
38
38
  activityBadge,
39
+ summary,
40
+ sourceDescription,
41
+ screenshots,
39
42
  category,
40
43
  description,
41
44
  name,
@@ -123,9 +126,15 @@ const {
123
126
  </p>
124
127
  )}
125
128
 
126
- {description && (
129
+ {summary && (
127
130
  <p class="m-0 mt-3 max-w-[68ch] text-base leading-relaxed text-ink-700 dark:text-ink-200">
128
- {description}
131
+ {summary}
132
+ </p>
133
+ )}
134
+ {sourceDescription && sourceDescription !== summary && (
135
+ <p class="m-0 mt-2 max-w-[68ch] text-sm leading-relaxed text-ink-500 dark:text-ink-400">
136
+ <span class="text-2xs font-semibold uppercase tracking-wider">From the project's README: </span>
137
+ {sourceDescription}
129
138
  </p>
130
139
  )}
131
140
 
@@ -153,4 +162,26 @@ const {
153
162
  )}
154
163
  </div>
155
164
  </div>
165
+ {screenshots && screenshots.length > 0 && (
166
+ <div class="mt-6 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3" aria-label="Screenshots">
167
+ {screenshots.map((shot) => (
168
+ <figure class="m-0 overflow-hidden rounded-[var(--radius-lg)] border border-ink-200 bg-muted dark:border-ink-800">
169
+ <img
170
+ src={shot.src}
171
+ alt={shot.alt}
172
+ loading="lazy"
173
+ decoding="async"
174
+ width={shot.width}
175
+ height={shot.height}
176
+ class="block h-auto w-full object-cover"
177
+ />
178
+ {shot.source && (
179
+ <figcaption class="px-3 py-1.5 text-2xs text-ink-500 dark:text-ink-400">
180
+ Source: <a href={shot.source} target="_blank" rel="noopener noreferrer" class="text-inherit no-underline hover:underline">{shot.source.replace(/^https?:\/\//, "")}</a>
181
+ </figcaption>
182
+ )}
183
+ </figure>
184
+ ))}
185
+ </div>
186
+ )}
156
187
  </header>
@@ -19,7 +19,7 @@
19
19
  * scrolls the body. Cards are individually rendered so the
20
20
  * sticky container's height is bounded by the actual content.
21
21
  */
22
- import { formatStars, compact } from "@grove-dev/core";
22
+ import { formatStars, compact, licenseDisplay } from "@grove-dev/core";
23
23
  import type { RecordDetailModel } from "../server/models.js";
24
24
  import LanguageBreakdown from "./LanguageBreakdown.astro";
25
25
 
@@ -119,7 +119,7 @@ function row(label: string, value: unknown) {
119
119
  {row("License", licenseSpdx) && (
120
120
  <div class="flex items-baseline justify-between gap-3">
121
121
  <dt class="text-ink-500 dark:text-ink-400">License</dt>
122
- <dd class="font-mono text-ink-900 dark:text-ink-100">{licenseSpdx}</dd>
122
+ <dd class="font-mono text-ink-900 dark:text-ink-100">{licenseDisplay(licenseSpdx)}</dd>
123
123
  </div>
124
124
  )}
125
125
  {row("Default branch", repo?.default_branch) && (
@@ -274,7 +274,7 @@ function row(label: string, value: unknown) {
274
274
  <ul class="m-0 flex list-none flex-wrap gap-1.5 p-0">
275
275
  {tags.map((t) => (
276
276
  <li>
277
- <a href={`/${detail.slug}?q=${encodeURIComponent(t)}`} class="rounded-full border border-ink-200 px-2 py-0.5 text-2xs font-medium text-ink-700 no-underline dark:border-ink-800 dark:text-ink-300">
277
+ <a href={`/${detail.slug}?tag=${encodeURIComponent(t)}`} class="rounded-full border border-ink-200 px-2 py-0.5 text-2xs font-medium text-ink-700 no-underline dark:border-ink-800 dark:text-ink-300">
278
278
  {t}
279
279
  </a>
280
280
  </li>
@@ -319,6 +319,16 @@ function row(label: string, value: unknown) {
319
319
  {record.curation?.reviewedAt ? new Date(String(record.curation.reviewedAt)).toLocaleDateString() : "—"}
320
320
  </time>.
321
321
  </p>
322
+ {detail.collectionMembership && detail.collectionMembership.length > 0 && (
323
+ <p class="m-0 mt-1.5 leading-relaxed">
324
+ <span class="font-semibold">Also in: </span>
325
+ {detail.collectionMembership.map((c, i) => (
326
+ <>
327
+ <a href={c.url} class="text-inherit no-underline hover:underline">{c.title}</a>{i < detail.collectionMembership.length - 1 ? ", " : ""}
328
+ </>
329
+ ))}
330
+ </p>
331
+ )}
322
332
  {readingMetrics.wordCount > 0 && (
323
333
  <p class="m-0 mt-1 leading-relaxed">
324
334
  Notes: {readingMetrics.wordCount.toLocaleString()} words · {readingMetrics.minutes} min read.
@@ -4,11 +4,17 @@ interface Props {
4
4
  existingFullNames: string[];
5
5
  repoUrl: string;
6
6
  fields: { category: boolean; stack: boolean; platforms: boolean; tags: boolean };
7
+ /** Taxonomy ids for client-side validation. */
8
+ taxonomy?: {
9
+ categoryIds?: string[];
10
+ stackIds?: string[];
11
+ platformIds?: string[];
12
+ };
7
13
  }
8
- const { existingSlugs, existingFullNames, repoUrl, fields } = Astro.props;
14
+ const { existingSlugs, existingFullNames, repoUrl, fields, taxonomy } = Astro.props;
9
15
  ---
10
16
 
11
- <script is:inline define:vars={{ existingSlugs, existingFullNames, repoUrl, fields }}>
17
+ <script is:inline define:vars={{ existingSlugs, existingFullNames, repoUrl, fields, taxonomy }}>
12
18
  // @ts-nocheck
13
19
  const $ = (selector) => document.querySelector(selector);
14
20
  const inputs = {
@@ -24,6 +30,9 @@ const { existingSlugs, existingFullNames, repoUrl, fields } = Astro.props;
24
30
  const openPrLink = $("#open-pr-link");
25
31
  const slugSet = new Set(existingSlugs);
26
32
  const repoSet = new Set(existingFullNames);
33
+ const categoryIds = new Set(taxonomy?.categoryIds ?? []);
34
+ const stackIds = new Set(taxonomy?.stackIds ?? []);
35
+ const platformIds = new Set(taxonomy?.platformIds ?? []);
27
36
  let repository = null;
28
37
 
29
38
  const slugify = (value) => String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
@@ -80,10 +89,45 @@ const { existingSlugs, existingFullNames, repoUrl, fields } = Astro.props;
80
89
  "",
81
90
  ].join("\n");
82
91
  };
92
+ const validationIssues = () => {
93
+ const issues = [];
94
+ if (slugSet.has(slugify(inputs.slug.value))) {
95
+ issues.push("A record with that slug already exists — update the existing one instead of opening a duplicate PR.");
96
+ }
97
+ if (inputs.description.value.trim().length < 40) {
98
+ issues.push("Description must be at least 40 characters.");
99
+ }
100
+ if (fields.category && (!inputs.category.value || inputs.category.value === "uncategorized" || (categoryIds.size > 0 && !categoryIds.has(inputs.category.value)))) {
101
+ issues.push("Choose a category from the taxonomy.");
102
+ }
103
+ if (fields.stack && (!inputs.stack.value || (stackIds.size > 0 && !stackIds.has(inputs.stack.value)))) {
104
+ issues.push("Choose a primary stack from the taxonomy.");
105
+ }
106
+ if (fields.platforms && selectedPlatforms().length === 0) {
107
+ issues.push("Select at least one platform.");
108
+ } else if (fields.platforms && platformIds.size > 0) {
109
+ const bad = selectedPlatforms().find((p) => !platformIds.has(p));
110
+ if (bad) issues.push(`Platform "${bad}" is not in the taxonomy.`);
111
+ }
112
+ return issues;
113
+ };
83
114
  const refresh = () => {
84
115
  const yaml = generateYaml();
85
116
  preview.textContent = yaml || "Paste a GitHub URL to generate a draft.";
86
- if (!yaml) return;
117
+ if (!yaml) {
118
+ openPrLink.classList.add("pointer-events-none", "opacity-50");
119
+ copyButton.disabled = true;
120
+ copyButton.classList.add("pointer-events-none", "opacity-50");
121
+ return;
122
+ }
123
+ const issues = validationIssues();
124
+ if (issues.length > 0) {
125
+ setStatus(issues[0], "error");
126
+ openPrLink.classList.add("pointer-events-none", "opacity-50");
127
+ copyButton.disabled = true;
128
+ copyButton.classList.add("pointer-events-none", "opacity-50");
129
+ return;
130
+ }
87
131
  const params = new URLSearchParams({ filename: `data/records/${slugify(inputs.slug.value)}.yml`, value: yaml });
88
132
  openPrLink.href = `${repoUrl}/new/main?${params}`;
89
133
  openPrLink.classList.remove("pointer-events-none", "opacity-50");
@@ -19,7 +19,12 @@ interface RawRecord {
19
19
  platforms?: string[];
20
20
  license?: string;
21
21
  visibility?: string;
22
+ /** GitHub stargazers count. Stored on `github.repository.stargazers_count`
23
+ * in the full payload; read explicitly because the index payload
24
+ * does not flatten it. */
22
25
  stars?: number;
26
+ /** GitHub fork count. Stored on `github.repository.forks_count`. */
27
+ forks?: number;
23
28
  pushedAt?: string;
24
29
  lastCommitAt?: string;
25
30
  category?: string;
@@ -27,6 +32,15 @@ interface RawRecord {
27
32
  scores?: { curation?: number; activity?: number };
28
33
  repoUrl?: string;
29
34
  links?: { github?: string; website?: string };
35
+ /** GitHub metadata block on the full record. The star / fork counts
36
+ * are read from `github.repository.stargazers_count` /
37
+ * `github.repository.forks_count` here. */
38
+ github?: {
39
+ repository?: {
40
+ stargazers_count?: number;
41
+ forks_count?: number;
42
+ };
43
+ };
30
44
  }
31
45
 
32
46
  /**
@@ -68,7 +82,8 @@ export function recordsToCollectionEntries(
68
82
  platform: r.platforms,
69
83
  license: r.license,
70
84
  status: r.visibility,
71
- stars: r.stars,
85
+ stars: r.stars ?? r.github?.repository?.stargazers_count,
86
+ forks: r.forks ?? r.github?.repository?.forks_count,
72
87
  pushedAt: r.pushedAt ?? r.lastCommitAt,
73
88
  curationScore: r.scores?.curation,
74
89
  activityScore: r.scores?.activity,
@@ -97,6 +112,10 @@ export interface CollectionPageModel {
97
112
  total: number;
98
113
  isEmpty: boolean;
99
114
  entries: CollectionEntry[];
115
+ /** ItemList JSON-LD block. Pass to BaseLayout as the `jsonLd` prop
116
+ * so it ships in the document head and is indexable by search
117
+ * engines as a list of `SoftwareApplication` items. */
118
+ jsonLd?: unknown;
100
119
  related: Array<{ slug: string; title: string; url: string }>;
101
120
  }
102
121
 
@@ -121,6 +140,7 @@ export function getCollectionPageModel(
121
140
  collection: Collection,
122
141
  entries: CollectionEntry[],
123
142
  allCollections: Collection[],
143
+ site?: { name?: string; url?: string },
124
144
  ): CollectionPageModel {
125
145
  const result = runCollection(collection, entries);
126
146
  const related = findRelated(collection, allCollections, 4).map((c) => ({
@@ -128,6 +148,29 @@ export function getCollectionPageModel(
128
148
  title: c.title,
129
149
  url: `/collections/${c.slug}/`,
130
150
  }));
151
+ // ItemList JSON-LD. Search engines can use this to surface
152
+ // individual entries directly from the collection URL.
153
+ // Cap at 50 entries to keep the JSON-LD payload bounded;
154
+ // search engines don't index beyond that anyway.
155
+ const itemListElement = result.entries.slice(0, 50).map((entry, index) => ({
156
+ "@type": "ListItem",
157
+ position: index + 1,
158
+ item: {
159
+ "@type": "SoftwareApplication",
160
+ name: entry.title,
161
+ url: entry.url,
162
+ description: entry.description,
163
+ },
164
+ }));
165
+ const jsonLd = {
166
+ "@context": "https://schema.org",
167
+ "@type": "ItemList",
168
+ name: collection.title,
169
+ description: collection.description,
170
+ url: site?.url ? `${site.url.replace(/\/$/, "")}/collections/${collection.slug}/` : undefined,
171
+ numberOfItems: result.entries.length,
172
+ itemListElement,
173
+ };
131
174
  return {
132
175
  collection: {
133
176
  slug: collection.slug,
@@ -141,6 +184,7 @@ export function getCollectionPageModel(
141
184
  isEmpty: result.isEmpty,
142
185
  entries: result.entries,
143
186
  related,
187
+ jsonLd,
144
188
  };
145
189
  }
146
190
 
@@ -202,3 +246,35 @@ export async function loadCollections(cwd: string): Promise<Collection[]> {
202
246
  }
203
247
  return out;
204
248
  }
249
+
250
+ /**
251
+ * Reverse lookup — given a record, return the slugs of every
252
+ * curated collection that includes it. Walks each collection's
253
+ * query + ranking once, applies the same filter the collection
254
+ * page uses, and returns the collection slugs that contain the
255
+ * target record. Used by the detail page's sidebar to show
256
+ * "Also in" / "Collection membership" links.
257
+ *
258
+ * The returned array includes `{slug, title}` pairs so the
259
+ * sidebar can render both the link target and a user-facing
260
+ * label without re-loading the collection YAML.
261
+ */
262
+ export function findCollectionsFor(
263
+ target: { slug?: string; stack?: string; stacks?: string[]; platforms?: string[]; licenses?: string[]; category?: string; visibility?: string; status?: string },
264
+ collections: Collection[],
265
+ entries: CollectionEntry[],
266
+ ): { slug: string; title: string; url: string }[] {
267
+ if (!target.slug) return [];
268
+ const result: { slug: string; title: string; url: string }[] = [];
269
+ for (const collection of collections) {
270
+ const filtered = runCollection(collection, entries).entries;
271
+ if (filtered.some((entry) => entry.slug === target.slug)) {
272
+ result.push({
273
+ slug: collection.slug,
274
+ title: collection.title,
275
+ url: `/collections/${collection.slug}/`,
276
+ });
277
+ }
278
+ }
279
+ return result;
280
+ }
@@ -186,7 +186,10 @@ function configuredFacets(site?: DirectorySiteConfig) {
186
186
 
187
187
  export function getDirectoryIndexModel(searchParams: URLSearchParams, site?: DirectorySiteConfig) {
188
188
  const filters = filtersFromSearchParams(searchParams);
189
- const rawFacets = buildFacets(items);
189
+ const rawFacets = buildFacets(items, {
190
+ curatedTagIds: site.taxonomy?.topics?.map((t) => t.id),
191
+ filters, // Intersection counts: each facet reflects all OTHER filters.
192
+ });
190
193
  const enabled = configuredFacets(site);
191
194
  const facets = {
192
195
  stacks: enabled.has("stacks") ? rawFacets.stacks.map((option) => ({ ...option, label: taxonomyLabel("stacks", option.value) })) : [],
@@ -215,7 +218,13 @@ export function getDirectoryIndexModel(searchParams: URLSearchParams, site?: Dir
215
218
 
216
219
  export function getContributorsPageModel(site: DirectorySiteConfig) {
217
220
  const contributors = loadDirectoryContributors();
218
- const sorted = [...contributors].sort((a, b) =>
221
+ // Render-time guard: even if a stale `data/generated/contributors.json`
222
+ // was produced before the sync-time filter was added, never surface
223
+ // bot accounts (login ends with `[bot]`) on the public contributors
224
+ // page. The ContributorsGrid uses the same total for its heading, so
225
+ // the headline count always agrees with the rendered grid.
226
+ const human = contributors.filter((c) => !c.username.endsWith("[bot]"));
227
+ const sorted = [...human].sort((a, b) =>
219
228
  (b.contributions ?? 0) - (a.contributions ?? 0) || a.username.localeCompare(b.username)
220
229
  );
221
230
  const repo = site.repoUrl?.replace(/\/$/, "");
@@ -433,6 +442,18 @@ const tocBody = readContentFile(typeof record.content === "string" ? record.cont
433
442
  tags,
434
443
  healthLabel,
435
444
  contentHtml: isProject ? getContentHtml(recordSlug) : null,
445
+ // Curated summary (Open Apps-written) takes priority over the
446
+ // raw `description` (typically copied from GitHub). Fall back to
447
+ // `description` when the curator has not written a summary yet.
448
+ summary: (record.summary && record.summary.trim()) || record.description || "",
449
+ sourceDescription: record.sourceDescription ?? record.description ?? "",
450
+ // Collection membership is populated by the consumer detail page
451
+ // after calling `findCollectionsFor`. Defaulting to [] keeps the
452
+ // model safe when the consumer doesn't wire it.
453
+ collectionMembership: [],
454
+ // Curated screenshots array. Defaulting to [] keeps the renderer
455
+ // safe; RecordHeader shows a gallery strip when non-empty.
456
+ screenshots: proj?.screenshots ?? [],
436
457
  monthlyCommits,
437
458
  maxMonthlyCommits: Math.max(1, ...monthlyCommits.map((item) => item.commits)),
438
459
  contributionSignals,