@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 +2 -2
- package/src/components/IndexRow.astro +2 -2
- package/src/components/ItemCard.astro +2 -1
- package/src/components/RecordHeader.astro +33 -2
- package/src/components/RecordSidebar.astro +13 -3
- package/src/components/SubmissionClient.astro +47 -3
- package/src/server/collections.ts +77 -1
- package/src/server/models.ts +23 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@grove-dev/astro",
|
|
3
|
-
"version": "0.5.0-next.
|
|
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.
|
|
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
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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}?
|
|
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)
|
|
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
|
+
}
|
package/src/server/models.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|