@grove-dev/astro 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +22 -33
- package/dist/index.js.map +1 -1
- package/dist/lib/display.d.ts +1 -1
- package/dist/lib/display.js +1 -1
- package/dist/lib/format.d.ts +1 -1
- package/dist/lib/format.js +1 -1
- package/dist/lib/icon-kinds.d.ts +1 -1
- package/dist/lib/icon-kinds.js +43 -43
- package/dist/lib/icon-registry.d.ts +2 -2
- package/dist/lib/icon-registry.js +23 -23
- package/dist/lib/index.d.ts +9 -9
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +9 -9
- package/dist/lib/index.js.map +1 -1
- package/dist/lib/lenses.d.ts +1 -1
- package/dist/lib/lenses.js +1 -1
- package/dist/lib/packaged-icons.d.ts +1 -1
- package/dist/lib/packaged-icons.js +4 -4
- package/dist/lib/repo.d.ts +1 -1
- package/dist/lib/repo.js +1 -1
- package/dist/lib/search.d.ts +1 -1
- package/dist/lib/search.js +1 -1
- package/dist/lib/taxonomy-counts.d.ts +1 -1
- package/dist/lib/taxonomy-counts.js +1 -1
- package/package.json +7 -17
- package/src/index.ts +23 -38
- package/src/lib/display.ts +1 -1
- package/src/lib/format.ts +1 -1
- package/src/lib/icon-kinds.ts +44 -44
- package/src/lib/icon-registry.ts +25 -25
- package/src/lib/index.ts +9 -9
- package/src/lib/lenses.ts +1 -1
- package/src/lib/packaged-icons.ts +8 -8
- package/src/lib/repo.ts +1 -1
- package/src/lib/search.ts +1 -1
- package/src/lib/taxonomy-counts.ts +1 -1
- package/src/server/collections.ts +53 -42
- package/src/server/contrast.ts +14 -9
- package/src/server/directory.ts +22 -17
- package/src/server/index.ts +3 -2
- package/src/server/models.ts +404 -213
- package/src/server/seo.ts +34 -31
- package/src/styles.css +235 -91
- package/dist/layouts/index.d.ts +0 -25
- package/dist/layouts/index.d.ts.map +0 -1
- package/dist/layouts/index.js +0 -25
- package/dist/layouts/index.js.map +0 -1
- package/dist/ui/button.d.ts +0 -53
- package/dist/ui/button.d.ts.map +0 -1
- package/dist/ui/button.js +0 -90
- package/dist/ui/button.js.map +0 -1
- package/src/components/CardGrid.astro +0 -41
- package/src/components/CardIcon.astro +0 -67
- package/src/components/CategoryGrid.astro +0 -114
- package/src/components/CollectionCard.astro +0 -56
- package/src/components/CollectionIndex.astro +0 -102
- package/src/components/CollectionPage.astro +0 -128
- package/src/components/CollectionRow.astro +0 -81
- package/src/components/CollectionTeaser.astro +0 -47
- package/src/components/ContributorsGrid.astro +0 -143
- package/src/components/DirectoryIndexClient.astro +0 -510
- package/src/components/EditorialSummary.astro +0 -58
- package/src/components/FilterGroupMenu.astro +0 -69
- package/src/components/FilterOptions.astro +0 -49
- package/src/components/FinalCta.astro +0 -43
- package/src/components/Hero.astro +0 -243
- package/src/components/Icon.astro +0 -191
- package/src/components/IndexRow.astro +0 -37
- package/src/components/LanguageBreakdown.astro +0 -67
- package/src/components/MarkdownBody.astro +0 -37
- package/src/components/OriginalCollection.astro +0 -117
- package/src/components/Pagination.astro +0 -50
- package/src/components/PoweredBy.astro +0 -60
- package/src/components/ProjectCard.astro +0 -321
- package/src/components/RecordHeader.astro +0 -221
- package/src/components/RecordSection.astro +0 -128
- package/src/components/RecordSidebar.astro +0 -387
- package/src/components/RefinePanel.astro +0 -168
- package/src/components/SmartLensTabs.astro +0 -81
- package/src/components/StackGrid.astro +0 -151
- package/src/components/StackPlatformChips.astro +0 -73
- package/src/components/SubmissionClient.astro +0 -254
- package/src/components/TableOfContents.astro +0 -140
- package/src/components/WhyThisExists.astro +0 -116
- package/src/layouts/BaseLayout.astro +0 -415
- package/src/layouts/Container.astro +0 -18
- package/src/layouts/Footer.astro +0 -231
- package/src/layouts/Header.astro +0 -214
- package/src/layouts/SectionHeader.astro +0 -43
- package/src/layouts/Seo.astro +0 -171
- package/src/layouts/ThemeToggle.astro +0 -118
- package/src/layouts/astro-shim.d.ts +0 -29
- package/src/layouts/index.ts +0 -24
- package/src/server/collections.test.ts +0 -212
- package/src/server/contrast.test.ts +0 -82
- package/src/server/directory.test.ts +0 -177
- package/src/server/github-repo.test.ts +0 -88
- package/src/server/models-home.test.ts +0 -101
- package/src/server/models.test.ts +0 -135
- package/src/server/seo.test.ts +0 -123
- package/src/ui/Badge.astro +0 -38
- package/src/ui/Button.astro +0 -42
- package/src/ui/EmptyState.astro +0 -38
- package/src/ui/FilterDrawer.astro +0 -124
- package/src/ui/PageHeader.astro +0 -39
- package/src/ui/SearchField.astro +0 -106
- package/src/ui/button.test.ts +0 -54
- package/src/ui/button.ts +0 -107
package/src/server/models.ts
CHANGED
|
@@ -4,15 +4,17 @@
|
|
|
4
4
|
* These functions contain directory decisions and generated-data access,
|
|
5
5
|
* while consumer-owned Astro pages keep control of markup and composition.
|
|
6
6
|
*/
|
|
7
|
-
import { existsSync, readFileSync } from
|
|
8
|
-
import { resolve } from
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { resolve } from 'node:path';
|
|
9
9
|
import type {
|
|
10
10
|
IndexFilters,
|
|
11
|
+
IndexProjectRecord,
|
|
11
12
|
IndexRecord,
|
|
12
13
|
ProjectRecord,
|
|
13
14
|
ReadingMetrics,
|
|
15
|
+
Resource,
|
|
14
16
|
TocEntry,
|
|
15
|
-
} from
|
|
17
|
+
} from '@grove-dev/core';
|
|
16
18
|
import {
|
|
17
19
|
activeFilterChips,
|
|
18
20
|
applySort,
|
|
@@ -31,14 +33,16 @@ import {
|
|
|
31
33
|
hasAnyFilter,
|
|
32
34
|
hrefForClearedFilters,
|
|
33
35
|
hrefForFilters,
|
|
36
|
+
nameInitials,
|
|
34
37
|
pagePathHref,
|
|
35
38
|
paginate,
|
|
36
39
|
projectStackIds,
|
|
37
|
-
readingMetrics,
|
|
38
40
|
readContentFile,
|
|
41
|
+
readingMetrics,
|
|
39
42
|
statusDisplay,
|
|
40
43
|
totalPages,
|
|
41
|
-
|
|
44
|
+
truncateWords,
|
|
45
|
+
} from '@grove-dev/core';
|
|
42
46
|
import {
|
|
43
47
|
entityBySlug,
|
|
44
48
|
fullItems,
|
|
@@ -50,7 +54,7 @@ import {
|
|
|
50
54
|
projectBySlug,
|
|
51
55
|
resourceBySlug,
|
|
52
56
|
taxonomyLabel,
|
|
53
|
-
} from
|
|
57
|
+
} from './directory.js';
|
|
54
58
|
import {
|
|
55
59
|
absoluteUrl,
|
|
56
60
|
breadcrumbs,
|
|
@@ -60,7 +64,7 @@ import {
|
|
|
60
64
|
seoDescription,
|
|
61
65
|
seoTitle,
|
|
62
66
|
titleCaseFirst,
|
|
63
|
-
} from
|
|
67
|
+
} from './seo.js';
|
|
64
68
|
|
|
65
69
|
export interface DirectorySiteConfig {
|
|
66
70
|
name: string;
|
|
@@ -135,14 +139,14 @@ export interface DirectoryContributor {
|
|
|
135
139
|
* site-config always carries `siteUrl`; the fallback mirrors
|
|
136
140
|
* build-data's own default so both stay aligned. */
|
|
137
141
|
function siteUrlOf(site?: DirectorySiteConfig): string {
|
|
138
|
-
return (site?.siteUrl ?? site?.url ??
|
|
142
|
+
return (site?.siteUrl ?? site?.url ?? 'https://example.com').replace(/\/$/, '');
|
|
139
143
|
}
|
|
140
144
|
|
|
141
145
|
export function loadDirectoryContributors(root = process.cwd()): DirectoryContributor[] {
|
|
142
|
-
const path = resolve(root,
|
|
146
|
+
const path = resolve(root, 'data', 'generated', 'contributors.json');
|
|
143
147
|
if (!existsSync(path)) return [];
|
|
144
148
|
try {
|
|
145
|
-
const parsed = JSON.parse(readFileSync(path,
|
|
149
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8')) as {
|
|
146
150
|
contributors?: DirectoryContributor[];
|
|
147
151
|
};
|
|
148
152
|
return parsed.contributors ?? [];
|
|
@@ -164,23 +168,21 @@ export function countTaxonomies() {
|
|
|
164
168
|
const stackCounts = new Map<string, number>();
|
|
165
169
|
const categoryCounts = new Map<string, number>();
|
|
166
170
|
for (const record of items) {
|
|
167
|
-
if (record.category)
|
|
168
|
-
|
|
171
|
+
if (record.category)
|
|
172
|
+
categoryCounts.set(record.category, (categoryCounts.get(record.category) ?? 0) + 1);
|
|
173
|
+
if (record.kind === 'project') {
|
|
169
174
|
for (const stackId of projectStackIds(record)) {
|
|
170
175
|
stackCounts.set(stackId, (stackCounts.get(stackId) ?? 0) + 1);
|
|
171
176
|
}
|
|
172
177
|
}
|
|
173
178
|
}
|
|
174
|
-
const toEntries = (
|
|
175
|
-
counts: Map<string, number>,
|
|
176
|
-
kind: "stacks" | "categories",
|
|
177
|
-
) =>
|
|
179
|
+
const toEntries = (counts: Map<string, number>, kind: 'stacks' | 'categories') =>
|
|
178
180
|
[...counts]
|
|
179
181
|
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
180
182
|
.map(([id, count]) => ({ name: taxonomyLabel(kind, id), slug: id, count }));
|
|
181
183
|
return {
|
|
182
|
-
stacks: toEntries(stackCounts,
|
|
183
|
-
categories: toEntries(categoryCounts,
|
|
184
|
+
stacks: toEntries(stackCounts, 'stacks'),
|
|
185
|
+
categories: toEntries(categoryCounts, 'categories'),
|
|
184
186
|
};
|
|
185
187
|
}
|
|
186
188
|
|
|
@@ -193,10 +195,10 @@ export function getTaxonomyIndexModel() {
|
|
|
193
195
|
}
|
|
194
196
|
|
|
195
197
|
export function getHomePageModel(site: DirectorySiteConfig) {
|
|
196
|
-
const slug = site.blueprintConfig?.routeSlug ??
|
|
198
|
+
const slug = site.blueprintConfig?.routeSlug ?? 'projects';
|
|
197
199
|
const singular = site.blueprintConfig?.labelSingular ?? itemLabel();
|
|
198
200
|
const plural = site.blueprintConfig?.labelPlural ?? itemLabelPlural();
|
|
199
|
-
const projects = items.filter((record) => record.kind ===
|
|
201
|
+
const projects = items.filter((record) => record.kind === 'project');
|
|
200
202
|
// Hot: most-starred items carrying the "hot" lens label. Without an
|
|
201
203
|
// explicit sort, the filter preserves the index order, which is
|
|
202
204
|
// alphabetical — so the homepage's "Trending now" panel ends up
|
|
@@ -209,14 +211,17 @@ export function getHomePageModel(site: DirectorySiteConfig) {
|
|
|
209
211
|
// three-column grid. Topped-up records are still excluded from the
|
|
210
212
|
// "Recently added" / "Established" panels below.
|
|
211
213
|
const MIN_HOT = 3;
|
|
212
|
-
const hotLabelled = applySort(filterRecords(projects, { labels: [
|
|
214
|
+
const hotLabelled = applySort(filterRecords(projects, { labels: ['hot'] }), 'most-starred').slice(
|
|
215
|
+
0,
|
|
216
|
+
6,
|
|
217
|
+
);
|
|
213
218
|
const labelledSlugs = new Set(hotLabelled.map((r) => r.slug));
|
|
214
219
|
const hot =
|
|
215
220
|
hotLabelled.length >= MIN_HOT
|
|
216
221
|
? hotLabelled
|
|
217
222
|
: [
|
|
218
223
|
...hotLabelled,
|
|
219
|
-
...applySort(projects,
|
|
224
|
+
...applySort(projects, 'most-starred')
|
|
220
225
|
.filter((r) => !labelledSlugs.has(r.slug))
|
|
221
226
|
.slice(0, MIN_HOT - hotLabelled.length),
|
|
222
227
|
];
|
|
@@ -226,15 +231,15 @@ export function getHomePageModel(site: DirectorySiteConfig) {
|
|
|
226
231
|
const hotSlugs = new Set(hot.map((r) => r.slug));
|
|
227
232
|
const recentlyAdded = applySort(
|
|
228
233
|
projects.filter((r) => !hotSlugs.has(r.slug)),
|
|
229
|
-
|
|
234
|
+
'recently-added',
|
|
230
235
|
).slice(0, 6);
|
|
231
236
|
// Established: longest-running mature items, again excluding any
|
|
232
237
|
// record already surfaced in the hot or recently-added panel so the
|
|
233
238
|
// three sections read as distinct perspectives.
|
|
234
239
|
const recentSlugs = new Set([...hotSlugs, ...recentlyAdded.map((r) => r.slug)]);
|
|
235
240
|
const established = applySort(
|
|
236
|
-
filterRecords(projects, { labels: [
|
|
237
|
-
|
|
241
|
+
filterRecords(projects, { labels: ['mature'] }).filter((r) => !recentSlugs.has(r.slug)),
|
|
242
|
+
'most-starred',
|
|
238
243
|
).slice(0, 6);
|
|
239
244
|
|
|
240
245
|
// Homepage layout choice: cap the stack grid at 12. The counting
|
|
@@ -249,15 +254,13 @@ export function getHomePageModel(site: DirectorySiteConfig) {
|
|
|
249
254
|
// "{Site} — {tagline}", but never a dangling "{Site} —" when the
|
|
250
255
|
// tagline is unset, and never a tagline that pushes the title past
|
|
251
256
|
// the ~60-char display cap.
|
|
252
|
-
const tagline = (site.tagline ??
|
|
257
|
+
const tagline = (site.tagline ?? '').trim();
|
|
253
258
|
const title =
|
|
254
|
-
tagline && `${site.name} — ${tagline}`.length <= 65
|
|
255
|
-
? `${site.name} — ${tagline}`
|
|
256
|
-
: site.name;
|
|
259
|
+
tagline && `${site.name} — ${tagline}`.length <= 65 ? `${site.name} — ${tagline}` : site.name;
|
|
257
260
|
const seo: PageSeo = {
|
|
258
261
|
title,
|
|
259
262
|
description,
|
|
260
|
-
image: ogPath(
|
|
263
|
+
image: ogPath('home'),
|
|
261
264
|
};
|
|
262
265
|
|
|
263
266
|
return {
|
|
@@ -274,7 +277,7 @@ export function getHomePageModel(site: DirectorySiteConfig) {
|
|
|
274
277
|
description,
|
|
275
278
|
seo,
|
|
276
279
|
stats: {
|
|
277
|
-
originalRepo: site.stats?.originalRepo ??
|
|
280
|
+
originalRepo: site.stats?.originalRepo ?? '',
|
|
278
281
|
apps: site.stats?.totalApps ?? 0,
|
|
279
282
|
categories: site.stats?.totalCategories ?? 0,
|
|
280
283
|
stacks: site.stats?.totalStacks ?? 0,
|
|
@@ -295,11 +298,11 @@ function enabledFacetDefs(site?: DirectorySiteConfig) {
|
|
|
295
298
|
|
|
296
299
|
/** Taxonomy list that labels/orders each facet dimension. */
|
|
297
300
|
const TAXONOMY_KIND_FOR_DIMENSION = {
|
|
298
|
-
stacks:
|
|
299
|
-
platforms:
|
|
300
|
-
categories:
|
|
301
|
-
tags:
|
|
302
|
-
licenses:
|
|
301
|
+
stacks: 'stacks',
|
|
302
|
+
platforms: 'platforms',
|
|
303
|
+
categories: 'categories',
|
|
304
|
+
tags: 'topics',
|
|
305
|
+
licenses: 'licenses',
|
|
303
306
|
} as const;
|
|
304
307
|
|
|
305
308
|
/**
|
|
@@ -335,11 +338,11 @@ export function getDirectoryIndexModel(
|
|
|
335
338
|
label: taxonomyLabel(TAXONOMY_KIND_FOR_DIMENSION[dimension], option.value),
|
|
336
339
|
}));
|
|
337
340
|
const facets = {
|
|
338
|
-
stacks: enabled.has(
|
|
339
|
-
platforms: enabled.has(
|
|
340
|
-
categories: enabled.has(
|
|
341
|
-
tags: enabled.has(
|
|
342
|
-
licenses: enabled.has(
|
|
341
|
+
stacks: enabled.has('stacks') ? labeled('stacks') : [],
|
|
342
|
+
platforms: enabled.has('platforms') ? labeled('platforms') : [],
|
|
343
|
+
categories: enabled.has('categories') ? labeled('categories') : [],
|
|
344
|
+
tags: enabled.has('tags') ? labeled('tags') : [],
|
|
345
|
+
licenses: enabled.has('licenses') ? labeled('licenses') : [],
|
|
343
346
|
};
|
|
344
347
|
// Ordered filter groups — `browse.facets` array order is the render
|
|
345
348
|
// order; label + selection mode come from the core registry.
|
|
@@ -355,16 +358,16 @@ export function getDirectoryIndexModel(
|
|
|
355
358
|
.filter((group) => group.options.length > 0);
|
|
356
359
|
const plural = site?.blueprintConfig?.labelPlural ?? itemLabelPlural();
|
|
357
360
|
// Placeholder names only the ENABLED dimensions, in config order.
|
|
358
|
-
const searchPlaceholder = `Search ${plural}, ${defs.map((def) => def.dimension).join(
|
|
361
|
+
const searchPlaceholder = `Search ${plural}, ${defs.map((def) => def.dimension).join(', ')}...`;
|
|
359
362
|
const sort = effectiveSort(filters);
|
|
360
363
|
const sorted = applySort(filterRecords(items, filters), sort);
|
|
361
364
|
const pageCount = totalPages(sorted.length);
|
|
362
365
|
const page = Math.min(Math.max(1, options.page ?? effectivePage(filters)), pageCount);
|
|
363
|
-
const pathPrefix = `/${site?.blueprintConfig?.routeSlug ??
|
|
366
|
+
const pathPrefix = `/${site?.blueprintConfig?.routeSlug ?? 'projects'}`;
|
|
364
367
|
const siteUrl = siteUrlOf(site);
|
|
365
|
-
const siteName = site?.name ??
|
|
368
|
+
const siteName = site?.name ?? '';
|
|
366
369
|
const pluralTitle = titleCaseFirst(plural);
|
|
367
|
-
const facetNames = defs.map((def) => def.label.toLowerCase()).join(
|
|
370
|
+
const facetNames = defs.map((def) => def.label.toLowerCase()).join(', ');
|
|
368
371
|
const listItems = sorted.slice(0, 50).map((record) => {
|
|
369
372
|
const r = record as { slug: string; name?: string; title?: string; description?: string };
|
|
370
373
|
return {
|
|
@@ -381,18 +384,18 @@ export function getDirectoryIndexModel(
|
|
|
381
384
|
description: seoDescription(
|
|
382
385
|
undefined,
|
|
383
386
|
page > 1
|
|
384
|
-
? `Browse page ${page} of ${pageCount} — ${items.length} curated ${plural} on ${siteName ||
|
|
385
|
-
: `Search and filter ${items.length} curated ${plural} on ${siteName ||
|
|
387
|
+
? `Browse page ${page} of ${pageCount} — ${items.length} curated ${plural} on ${siteName || 'this site'}, filtered by ${facetNames || 'category and stack'}.`
|
|
388
|
+
: `Search and filter ${items.length} curated ${plural} on ${siteName || 'this site'} — by ${facetNames || 'category and stack'}.`,
|
|
386
389
|
),
|
|
387
|
-
image: ogPath(
|
|
390
|
+
image: ogPath('default'),
|
|
388
391
|
jsonLd: [
|
|
389
392
|
...collectionSchema({
|
|
390
393
|
url: absoluteUrl(siteUrl, `${pathPrefix}/`),
|
|
391
394
|
name: `Browse ${pluralTitle}`,
|
|
392
|
-
description: `All ${plural} on ${siteName ||
|
|
395
|
+
description: `All ${plural} on ${siteName || 'this site'}.`,
|
|
393
396
|
items: listItems,
|
|
394
397
|
crumbs: [
|
|
395
|
-
{ url: `${siteUrl}/`, name:
|
|
398
|
+
{ url: `${siteUrl}/`, name: 'Home' },
|
|
396
399
|
{ url: absoluteUrl(siteUrl, `${pathPrefix}/`), name: pluralTitle },
|
|
397
400
|
],
|
|
398
401
|
}),
|
|
@@ -429,7 +432,7 @@ export function getDirectoryIndexModel(
|
|
|
429
432
|
: pagePathHref(pathPrefix, target),
|
|
430
433
|
clearFiltersHref: hrefForClearedFilters(filters, pathPrefix),
|
|
431
434
|
chips: activeFilterChips(filters, { taxonomy: site?.taxonomy, pathPrefix }),
|
|
432
|
-
clientItemsJson: JSON.stringify(items).replace(/</g,
|
|
435
|
+
clientItemsJson: JSON.stringify(items).replace(/</g, '\\u003c'),
|
|
433
436
|
};
|
|
434
437
|
}
|
|
435
438
|
|
|
@@ -440,13 +443,14 @@ export function getContributorsPageModel(site: DirectorySiteConfig) {
|
|
|
440
443
|
// bot accounts (login ends with `[bot]`) on the public contributors
|
|
441
444
|
// page. The ContributorsGrid uses the same total for its heading, so
|
|
442
445
|
// the headline count always agrees with the rendered grid.
|
|
443
|
-
const human = contributors.filter((c) => !c.username.endsWith(
|
|
444
|
-
const sorted = [...human].sort(
|
|
445
|
-
(
|
|
446
|
+
const human = contributors.filter((c) => !c.username.endsWith('[bot]'));
|
|
447
|
+
const sorted = [...human].sort(
|
|
448
|
+
(a, b) =>
|
|
449
|
+
(b.contributions ?? 0) - (a.contributions ?? 0) || a.username.localeCompare(b.username),
|
|
446
450
|
);
|
|
447
|
-
const repo = site.repoUrl?.replace(/\/$/,
|
|
451
|
+
const repo = site.repoUrl?.replace(/\/$/, '');
|
|
448
452
|
const siteUrl = siteUrlOf(site);
|
|
449
|
-
const title = seoTitle(
|
|
453
|
+
const title = seoTitle('Contributors', site.name);
|
|
450
454
|
const description = seoDescription(
|
|
451
455
|
undefined,
|
|
452
456
|
sorted.length > 0
|
|
@@ -456,25 +460,25 @@ export function getContributorsPageModel(site: DirectorySiteConfig) {
|
|
|
456
460
|
const seo: PageSeo = {
|
|
457
461
|
title,
|
|
458
462
|
description,
|
|
459
|
-
image: ogPath(
|
|
463
|
+
image: ogPath('default'),
|
|
460
464
|
jsonLd: [
|
|
461
465
|
{
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
url: absoluteUrl(siteUrl,
|
|
466
|
+
'@context': 'https://schema.org',
|
|
467
|
+
'@type': ['CollectionPage', 'WebPage'],
|
|
468
|
+
'@id': `${absoluteUrl(siteUrl, 'contributors/')}#page`,
|
|
469
|
+
url: absoluteUrl(siteUrl, 'contributors/'),
|
|
466
470
|
name: title,
|
|
467
471
|
description,
|
|
468
472
|
},
|
|
469
473
|
{
|
|
470
|
-
|
|
471
|
-
|
|
474
|
+
'@context': 'https://schema.org',
|
|
475
|
+
'@type': 'ItemList',
|
|
472
476
|
numberOfItems: sorted.length,
|
|
473
477
|
itemListElement: sorted.slice(0, 50).map((c, i) => ({
|
|
474
|
-
|
|
478
|
+
'@type': 'ListItem',
|
|
475
479
|
position: i + 1,
|
|
476
480
|
item: {
|
|
477
|
-
|
|
481
|
+
'@type': 'Person',
|
|
478
482
|
name: c.name ?? c.username,
|
|
479
483
|
...(c.profileUrl ? { url: c.profileUrl } : {}),
|
|
480
484
|
...(c.avatarUrl ? { image: c.avatarUrl } : {}),
|
|
@@ -482,8 +486,8 @@ export function getContributorsPageModel(site: DirectorySiteConfig) {
|
|
|
482
486
|
})),
|
|
483
487
|
},
|
|
484
488
|
breadcrumbs(siteUrl, [
|
|
485
|
-
{ path:
|
|
486
|
-
{ path:
|
|
489
|
+
{ path: '', name: 'Home' },
|
|
490
|
+
{ path: 'contributors/', name: 'Contributors' },
|
|
487
491
|
]),
|
|
488
492
|
],
|
|
489
493
|
};
|
|
@@ -504,22 +508,25 @@ export function getContributorsPageModel(site: DirectorySiteConfig) {
|
|
|
504
508
|
export function getSubmissionPageModel(site: DirectorySiteConfig) {
|
|
505
509
|
const singular = site.blueprintConfig?.labelSingular ?? itemLabel();
|
|
506
510
|
const enabled = new Set(enabledFacetDefs(site).map((def) => def.dimension));
|
|
507
|
-
const values = (key:
|
|
508
|
-
[...new Set(fullProjects.map((record) => String(record[key] ??
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
.map((id) =>
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
511
|
+
const values = (key: 'category' | 'stack') =>
|
|
512
|
+
[...new Set(fullProjects.map((record) => String(record[key] ?? '')).filter(Boolean))].sort(
|
|
513
|
+
(a, b) => a.localeCompare(b),
|
|
514
|
+
);
|
|
515
|
+
const categories = (
|
|
516
|
+
site.taxonomy?.categories?.length
|
|
517
|
+
? site.taxonomy.categories.map(({ id }) => id)
|
|
518
|
+
: values('category')
|
|
519
|
+
).map((id) => ({ id, label: taxonomyLabel('categories', id) }));
|
|
520
|
+
const stacks = (
|
|
521
|
+
site.taxonomy?.stacks?.length ? site.taxonomy.stacks.map(({ id }) => id) : values('stack')
|
|
522
|
+
).map((id) => ({ id, label: taxonomyLabel('stacks', id) }));
|
|
523
|
+
const existingFullNames = fullProjects
|
|
524
|
+
.map((record) => {
|
|
525
|
+
const url = String(record.repoUrl ?? record.links?.github ?? '');
|
|
526
|
+
const match = url.match(/github\.com\/([^/]+)\/([^/?#]+)/i);
|
|
527
|
+
return match ? `${match[1]}/${match[2].replace(/\.git$/, '')}`.toLowerCase() : null;
|
|
528
|
+
})
|
|
529
|
+
.filter((value): value is string => Boolean(value));
|
|
523
530
|
|
|
524
531
|
const title = seoTitle(`Submit ${singular}`, site.name);
|
|
525
532
|
const description = seoDescription(
|
|
@@ -532,23 +539,27 @@ export function getSubmissionPageModel(site: DirectorySiteConfig) {
|
|
|
532
539
|
description,
|
|
533
540
|
// Thin form wrapper — kept out of the index, so no OG/JSON-LD.
|
|
534
541
|
seo: { title, description, noindex: true } satisfies PageSeo,
|
|
535
|
-
repoUrl: site.repoUrl ??
|
|
542
|
+
repoUrl: site.repoUrl ?? 'https://github.com/tortuvshin/grove',
|
|
536
543
|
copy: site.submission ?? {},
|
|
537
544
|
fields: {
|
|
538
|
-
category: enabled.has(
|
|
539
|
-
stack: enabled.has(
|
|
540
|
-
platforms: enabled.has(
|
|
541
|
-
tags: enabled.has(
|
|
545
|
+
category: enabled.has('categories'),
|
|
546
|
+
stack: enabled.has('stacks'),
|
|
547
|
+
platforms: enabled.has('platforms'),
|
|
548
|
+
tags: enabled.has('tags'),
|
|
542
549
|
// The submit form mirrors the browse dimensions: a directory
|
|
543
550
|
// that filters by license also collects it at submission time.
|
|
544
|
-
license: enabled.has(
|
|
551
|
+
license: enabled.has('licenses'),
|
|
545
552
|
},
|
|
546
553
|
categories,
|
|
547
554
|
stacks,
|
|
548
555
|
existingSlugs: fullProjects.map((record) => record.slug).filter(Boolean),
|
|
549
556
|
existingFullNames,
|
|
550
|
-
platformOptions:
|
|
551
|
-
|
|
557
|
+
platformOptions:
|
|
558
|
+
site.taxonomy?.platforms?.map(({ id, name }) => ({ id, label: name })) ??
|
|
559
|
+
['ios', 'android', 'web', 'macos', 'windows', 'linux'].map((id) => ({
|
|
560
|
+
id,
|
|
561
|
+
label: taxonomyLabel('platforms', id),
|
|
562
|
+
})),
|
|
552
563
|
licenseOptions: site.taxonomy?.licenses?.map(({ id, name }) => ({ id, label: name })) ?? [],
|
|
553
564
|
// Ids for client-side validation — without this the submit form's
|
|
554
565
|
// taxonomy checks silently no-op.
|
|
@@ -573,7 +584,7 @@ type ProjectExtras = {
|
|
|
573
584
|
export function getRecordDetailModel(
|
|
574
585
|
recordSlug: string,
|
|
575
586
|
site: DirectorySiteConfig,
|
|
576
|
-
routeSlug = site.blueprintConfig?.routeSlug ??
|
|
587
|
+
routeSlug = site.blueprintConfig?.routeSlug ?? 'projects',
|
|
577
588
|
) {
|
|
578
589
|
const project = projectBySlug(recordSlug);
|
|
579
590
|
const resource = resourceBySlug(recordSlug);
|
|
@@ -581,11 +592,11 @@ export function getRecordDetailModel(
|
|
|
581
592
|
const record = project ?? resource ?? entity;
|
|
582
593
|
if (!record) return null;
|
|
583
594
|
|
|
584
|
-
const isProject = record.kind ===
|
|
595
|
+
const isProject = record.kind === 'project';
|
|
585
596
|
const proj: ProjectRecord | null = project ?? null;
|
|
586
|
-
const name = record.kind ===
|
|
597
|
+
const name = record.kind === 'resource' ? record.title : record.name;
|
|
587
598
|
const singular = site.blueprintConfig?.labelSingular ?? itemLabel();
|
|
588
|
-
const categoryLabel = record.category ? taxonomyLabel(
|
|
599
|
+
const categoryLabel = record.category ? taxonomyLabel('categories', record.category) : undefined;
|
|
589
600
|
// Fallback sentence when neither a curated summary nor a GitHub
|
|
590
601
|
// description is available. The noun follows the schema.org @type
|
|
591
602
|
// implied by `record.kind` so entities don't read as "open-source
|
|
@@ -605,8 +616,8 @@ export function getRecordDetailModel(
|
|
|
605
616
|
(record.summary && record.summary.trim()) || record.description,
|
|
606
617
|
fallbackSentence,
|
|
607
618
|
);
|
|
608
|
-
const repoUrl = proj?.repoUrl ?? record.links?.github ??
|
|
609
|
-
const homepageUrl = record.links?.website ??
|
|
619
|
+
const repoUrl = proj?.repoUrl ?? record.links?.github ?? '';
|
|
620
|
+
const homepageUrl = record.links?.website ?? '';
|
|
610
621
|
const stacks = projectStackIds(proj);
|
|
611
622
|
const platforms = proj?.platforms ?? [];
|
|
612
623
|
const github = proj?.github?.repository;
|
|
@@ -615,38 +626,55 @@ export function getRecordDetailModel(
|
|
|
615
626
|
const language = github?.language ?? null;
|
|
616
627
|
const licenseSpdx = github?.license?.spdx_id ?? null;
|
|
617
628
|
const pushedAt = github?.pushed_at ?? null;
|
|
629
|
+
// Pre-formatted date fields for the record sidebar — computed once
|
|
630
|
+
// here so the template never does its own `new Date(...)` business
|
|
631
|
+
// logic (only the raw ISO string for a `<time datetime>` attribute
|
|
632
|
+
// is still read directly off the record in the template).
|
|
633
|
+
const firstCommitYear = github?.created_at
|
|
634
|
+
? new Date(String(github.created_at)).getUTCFullYear()
|
|
635
|
+
: null;
|
|
636
|
+
const lastFetchedLabel = github?.updated_at
|
|
637
|
+
? new Date(String(github.updated_at)).toLocaleDateString()
|
|
638
|
+
: null;
|
|
639
|
+
const reviewedAtLabel = record.curation?.reviewedAt
|
|
640
|
+
? new Date(String(record.curation.reviewedAt)).toLocaleDateString()
|
|
641
|
+
: null;
|
|
618
642
|
const isArchived = !!github?.archived;
|
|
619
643
|
const isDisabled = !!github?.disabled;
|
|
620
644
|
const ownerRepo = repoUrl ? getOwnerAndRepoFromRepoUrl(repoUrl) : null;
|
|
621
645
|
const avatarSrc = proj?.logoUrl ?? (ownerRepo ? getOwnerAvatarUrl(ownerRepo.owner, 80) : null);
|
|
622
646
|
const rawScores = proj?.scores;
|
|
623
|
-
const scores =
|
|
624
|
-
|
|
625
|
-
|
|
647
|
+
const scores =
|
|
648
|
+
rawScores && Object.values(rawScores).some((value) => typeof value === 'number')
|
|
649
|
+
? rawScores
|
|
650
|
+
: null;
|
|
626
651
|
const healthStatus = proj?.health?.status;
|
|
627
652
|
const extras = record as ProjectExtras;
|
|
628
653
|
const monthlyCommits = (extras.github?.activity?.monthlyCommits ?? []).map(
|
|
629
|
-
(value): { month: string; commits: number } =>
|
|
630
|
-
|
|
631
|
-
|
|
654
|
+
(value): { month: string; commits: number } =>
|
|
655
|
+
typeof value === 'number'
|
|
656
|
+
? { month: '', commits: value }
|
|
657
|
+
: { month: value.month ?? '', commits: value.commits ?? 0 },
|
|
632
658
|
);
|
|
633
659
|
const contributionLabels: Record<string, string> = {
|
|
634
|
-
readme:
|
|
635
|
-
contributing:
|
|
636
|
-
security:
|
|
637
|
-
issueTemplates:
|
|
638
|
-
pullRequestTemplate:
|
|
660
|
+
readme: 'README',
|
|
661
|
+
contributing: 'Contributing',
|
|
662
|
+
security: 'Security',
|
|
663
|
+
issueTemplates: 'Issue templates',
|
|
664
|
+
pullRequestTemplate: 'PR template',
|
|
639
665
|
};
|
|
640
666
|
const contributionFiles = extras.github?.files ?? {};
|
|
641
667
|
const contributionSignals = Object.keys(contributionLabels)
|
|
642
668
|
.map((key) => ({ key, label: contributionLabels[key], ok: contributionFiles[key] }))
|
|
643
669
|
.filter((signal) => signal.ok !== undefined);
|
|
644
670
|
const languages = extras.github?.languages
|
|
645
|
-
? Object.entries(extras.github.languages)
|
|
671
|
+
? Object.entries(extras.github.languages)
|
|
672
|
+
.sort((a, b) => b[1] - a[1])
|
|
673
|
+
.slice(0, 5)
|
|
646
674
|
: [];
|
|
647
675
|
const healthLabel = healthStatus ? statusDisplay(healthStatus) : null;
|
|
648
676
|
const tags = record.tags ?? [];
|
|
649
|
-
const tocBody = readContentFile(typeof record.content ===
|
|
677
|
+
const tocBody = readContentFile(typeof record.content === 'string' ? record.content : '');
|
|
650
678
|
|
|
651
679
|
const siteUrl = siteUrlOf(site);
|
|
652
680
|
const pageUrl = absoluteUrl(siteUrl, `${routeSlug}/${recordSlug}/`);
|
|
@@ -656,9 +684,9 @@ export function getRecordDetailModel(
|
|
|
656
684
|
const sameAs = [repoUrl, homepageUrl].filter(Boolean);
|
|
657
685
|
const dateCreated = record.curation?.reviewedAt;
|
|
658
686
|
recordLd = {
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
687
|
+
'@context': 'https://schema.org',
|
|
688
|
+
'@type': 'SoftwareSourceCode',
|
|
689
|
+
'@id': `${pageUrl}#record`,
|
|
662
690
|
name,
|
|
663
691
|
headline: name,
|
|
664
692
|
description,
|
|
@@ -668,45 +696,54 @@ export function getRecordDetailModel(
|
|
|
668
696
|
programmingLanguage: language ?? undefined,
|
|
669
697
|
license: licenseSpdx ?? undefined,
|
|
670
698
|
applicationCategory: record.category || undefined,
|
|
671
|
-
operatingSystem: platforms.length ? platforms.join(
|
|
672
|
-
keywords: record.tags?.length ? record.tags.join(
|
|
699
|
+
operatingSystem: platforms.length ? platforms.join(', ') : undefined,
|
|
700
|
+
keywords: record.tags?.length ? record.tags.join(', ') : undefined,
|
|
673
701
|
dateCreated: dateCreated ?? undefined,
|
|
674
702
|
dateModified: pushedAt ?? dateCreated ?? undefined,
|
|
675
|
-
interactionStatistic:
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
703
|
+
interactionStatistic:
|
|
704
|
+
stars > 0
|
|
705
|
+
? {
|
|
706
|
+
'@type': 'InteractionCounter',
|
|
707
|
+
interactionType: { '@type': 'LikeAction' },
|
|
708
|
+
userInteractionCount: stars,
|
|
709
|
+
}
|
|
710
|
+
: undefined,
|
|
711
|
+
author: ownerRepo
|
|
712
|
+
? {
|
|
713
|
+
'@type': 'Organization',
|
|
714
|
+
name: ownerRepo.owner,
|
|
715
|
+
url: `https://github.com/${ownerRepo.owner}`,
|
|
716
|
+
}
|
|
717
|
+
: undefined,
|
|
685
718
|
isAccessibleForFree: true,
|
|
686
719
|
};
|
|
687
|
-
} else if (record.kind ===
|
|
720
|
+
} else if (record.kind === 'resource') {
|
|
688
721
|
const schemaTypes: Record<string, string> = {
|
|
689
|
-
article:
|
|
722
|
+
article: 'Article',
|
|
723
|
+
book: 'Book',
|
|
724
|
+
course: 'Course',
|
|
725
|
+
podcast: 'PodcastSeries',
|
|
726
|
+
video: 'VideoObject',
|
|
690
727
|
};
|
|
691
728
|
recordLd = {
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
729
|
+
'@context': 'https://schema.org',
|
|
730
|
+
'@type': schemaTypes[record.type] ?? 'CreativeWork',
|
|
731
|
+
'@id': `${pageUrl}#record`,
|
|
695
732
|
name,
|
|
696
733
|
headline: name,
|
|
697
734
|
description,
|
|
698
735
|
url: pageUrl,
|
|
699
736
|
...(homepageUrl ? { sameAs: [homepageUrl] } : {}),
|
|
700
|
-
author: record.author ? {
|
|
737
|
+
author: record.author ? { '@type': 'Person', name: record.author } : undefined,
|
|
701
738
|
datePublished: record.publishedAt || undefined,
|
|
702
|
-
keywords: record.tags?.length ? record.tags.join(
|
|
739
|
+
keywords: record.tags?.length ? record.tags.join(', ') : undefined,
|
|
703
740
|
isAccessibleForFree: true,
|
|
704
741
|
};
|
|
705
742
|
} else {
|
|
706
743
|
recordLd = {
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
744
|
+
'@context': 'https://schema.org',
|
|
745
|
+
'@type': entity?.type === 'person' ? 'Person' : 'Organization',
|
|
746
|
+
'@id': `${pageUrl}#record`,
|
|
710
747
|
name,
|
|
711
748
|
headline: name,
|
|
712
749
|
description,
|
|
@@ -714,7 +751,7 @@ export function getRecordDetailModel(
|
|
|
714
751
|
...(homepageUrl ? { sameAs: [homepageUrl] } : {}),
|
|
715
752
|
foundingDate: entity?.founded || undefined,
|
|
716
753
|
location: entity?.location || undefined,
|
|
717
|
-
keywords: entity?.tags?.length ? entity.tags.join(
|
|
754
|
+
keywords: entity?.tags?.length ? entity.tags.join(', ') : undefined,
|
|
718
755
|
};
|
|
719
756
|
}
|
|
720
757
|
|
|
@@ -730,7 +767,7 @@ export function getRecordDetailModel(
|
|
|
730
767
|
const jsonLd = [
|
|
731
768
|
recordLd,
|
|
732
769
|
breadcrumbs(siteUrl, [
|
|
733
|
-
{ path:
|
|
770
|
+
{ path: '', name: 'Home' },
|
|
734
771
|
{ path: `${routeSlug}/`, name: pluralTitle },
|
|
735
772
|
{ path: `${routeSlug}/${recordSlug}/`, name },
|
|
736
773
|
]),
|
|
@@ -738,7 +775,7 @@ export function getRecordDetailModel(
|
|
|
738
775
|
const seo: PageSeo = {
|
|
739
776
|
title,
|
|
740
777
|
description,
|
|
741
|
-
image: ogPath(
|
|
778
|
+
image: ogPath('record', recordSlug),
|
|
742
779
|
imageAlt: `${name} — ${site.name}`,
|
|
743
780
|
jsonLd,
|
|
744
781
|
};
|
|
@@ -766,9 +803,15 @@ export function getRecordDetailModel(
|
|
|
766
803
|
pushedAt,
|
|
767
804
|
starsLabel: formatStars(stars),
|
|
768
805
|
pushedLabel: formatRelative(pushedAt),
|
|
806
|
+
firstCommitYear,
|
|
807
|
+
lastFetchedLabel,
|
|
808
|
+
reviewedAtLabel,
|
|
769
809
|
ownerRepo,
|
|
770
810
|
avatarSrc,
|
|
771
|
-
initials: name
|
|
811
|
+
initials: name
|
|
812
|
+
.replace(/[^a-z0-9]/gi, '')
|
|
813
|
+
.slice(0, 2)
|
|
814
|
+
.toUpperCase(),
|
|
772
815
|
bestFor: proj?.bestFor ?? [],
|
|
773
816
|
whyListed: proj?.whyListed ?? [],
|
|
774
817
|
caveats: proj?.caveats ?? [],
|
|
@@ -785,8 +828,8 @@ export function getRecordDetailModel(
|
|
|
785
828
|
// Curated summary (Open Apps-written) takes priority over the
|
|
786
829
|
// raw `description` (typically copied from GitHub). Fall back to
|
|
787
830
|
// `description` when the curator has not written a summary yet.
|
|
788
|
-
summary: (record.summary && record.summary.trim()) || record.description ||
|
|
789
|
-
sourceDescription: record.sourceDescription ?? record.description ??
|
|
831
|
+
summary: (record.summary && record.summary.trim()) || record.description || '',
|
|
832
|
+
sourceDescription: record.sourceDescription ?? record.description ?? '',
|
|
790
833
|
// Collection membership is populated by the consumer detail page
|
|
791
834
|
// after calling `findCollectionsFor`. Defaulting to [] keeps the
|
|
792
835
|
// model safe when the consumer doesn't wire it.
|
|
@@ -798,7 +841,10 @@ export function getRecordDetailModel(
|
|
|
798
841
|
maxMonthlyCommits: Math.max(1, ...monthlyCommits.map((item) => item.commits)),
|
|
799
842
|
contributionSignals,
|
|
800
843
|
languages,
|
|
801
|
-
totalLanguageBytes: Math.max(
|
|
844
|
+
totalLanguageBytes: Math.max(
|
|
845
|
+
1,
|
|
846
|
+
languages.reduce((sum, [, bytes]) => sum + bytes, 0),
|
|
847
|
+
),
|
|
802
848
|
// ── Sidebar-shape fields ────────────────────────────────
|
|
803
849
|
// Read once at model-build time so pages don't repeat the
|
|
804
850
|
// "is there data for this card?" logic.
|
|
@@ -844,7 +890,7 @@ export function getRecordDetailModel(
|
|
|
844
890
|
*/
|
|
845
891
|
function recordFallbackSentence(input: {
|
|
846
892
|
name: string;
|
|
847
|
-
kind:
|
|
893
|
+
kind: 'project' | 'resource' | 'entity';
|
|
848
894
|
entityType?: string;
|
|
849
895
|
resourceType?: string;
|
|
850
896
|
categoryLabel?: string;
|
|
@@ -853,17 +899,17 @@ function recordFallbackSentence(input: {
|
|
|
853
899
|
}): string {
|
|
854
900
|
const category = input.categoryLabel;
|
|
855
901
|
const listed = `listed on ${input.siteName}.`;
|
|
856
|
-
if (input.kind ===
|
|
857
|
-
if (input.entityType ===
|
|
858
|
-
return `${input.name}${category ? `, a ${category.toLowerCase()} contributor` :
|
|
902
|
+
if (input.kind === 'entity') {
|
|
903
|
+
if (input.entityType === 'person') {
|
|
904
|
+
return `${input.name}${category ? `, a ${category.toLowerCase()} contributor` : ''} ${listed}`;
|
|
859
905
|
}
|
|
860
|
-
return `${input.name}, an open-source ${category ? `${category} ` :
|
|
906
|
+
return `${input.name}, an open-source ${category ? `${category} ` : ''}organization ${listed}`;
|
|
861
907
|
}
|
|
862
|
-
if (input.kind ===
|
|
863
|
-
const type = input.resourceType ??
|
|
864
|
-
return `${input.name}, a ${type}${category ? ` in ${category}` :
|
|
908
|
+
if (input.kind === 'resource') {
|
|
909
|
+
const type = input.resourceType ?? 'resource';
|
|
910
|
+
return `${input.name}, a ${type}${category ? ` in ${category}` : ''} ${listed}`;
|
|
865
911
|
}
|
|
866
|
-
return `${input.name}, an open-source ${category ? `${category} ` :
|
|
912
|
+
return `${input.name}, an open-source ${category ? `${category} ` : ''}${input.singular} ${listed}`;
|
|
867
913
|
}
|
|
868
914
|
|
|
869
915
|
// ── Sidebar predicates ────────────────────────────────────────────
|
|
@@ -891,23 +937,22 @@ export function computeActivityBadge(input: {
|
|
|
891
937
|
monthlyCommits: Array<{ month: string; commits: number }>;
|
|
892
938
|
isArchived: boolean;
|
|
893
939
|
isDisabled: boolean;
|
|
894
|
-
}): { label: string; tone:
|
|
940
|
+
}): { label: string; tone: 'fresh' | 'ok' | 'low' | 'dead' } | null {
|
|
895
941
|
if (input.isArchived || input.isDisabled) {
|
|
896
|
-
return { label:
|
|
942
|
+
return { label: 'Archived', tone: 'dead' };
|
|
897
943
|
}
|
|
898
944
|
const recentCommits = (input.monthlyCommits ?? [])
|
|
899
945
|
.slice(-3)
|
|
900
946
|
.reduce((sum, c) => sum + (c.commits ?? 0), 0);
|
|
901
|
-
if (recentCommits >= 50) return { label:
|
|
902
|
-
if (recentCommits >= 10) return { label:
|
|
903
|
-
if (recentCommits > 0) return { label:
|
|
947
|
+
if (recentCommits >= 50) return { label: 'Very active', tone: 'fresh' };
|
|
948
|
+
if (recentCommits >= 10) return { label: 'Active', tone: 'ok' };
|
|
949
|
+
if (recentCommits > 0) return { label: 'Maintained', tone: 'ok' };
|
|
904
950
|
if (input.pushedAt) {
|
|
905
|
-
const days =
|
|
906
|
-
|
|
907
|
-
if (days <
|
|
908
|
-
if (days <
|
|
909
|
-
|
|
910
|
-
return { label: "Stale", tone: "low" };
|
|
951
|
+
const days = (Date.now() - new Date(input.pushedAt).getTime()) / 86_400_000;
|
|
952
|
+
if (days < 30) return { label: 'Active', tone: 'ok' };
|
|
953
|
+
if (days < 180) return { label: 'Maintained', tone: 'ok' };
|
|
954
|
+
if (days < 365) return { label: 'Low activity', tone: 'low' };
|
|
955
|
+
return { label: 'Stale', tone: 'low' };
|
|
911
956
|
}
|
|
912
957
|
return null;
|
|
913
958
|
}
|
|
@@ -939,19 +984,10 @@ export function computeSidebarVisibility(input: {
|
|
|
939
984
|
} {
|
|
940
985
|
const repoFields = input.repo ?? {};
|
|
941
986
|
const hasRepo =
|
|
942
|
-
typeof input.repo ===
|
|
943
|
-
input.repo !== null &&
|
|
944
|
-
Object.keys(repoFields).length > 0;
|
|
987
|
+
typeof input.repo === 'object' && input.repo !== null && Object.keys(repoFields).length > 0;
|
|
945
988
|
return {
|
|
946
|
-
showActivity:
|
|
947
|
-
|
|
948
|
-
!!input.language ||
|
|
949
|
-
!!input.licenseSpdx,
|
|
950
|
-
showFreshness:
|
|
951
|
-
hasRepo ||
|
|
952
|
-
!!input.pushedAt ||
|
|
953
|
-
!!input.healthLabel ||
|
|
954
|
-
input.isArchived,
|
|
989
|
+
showActivity: hasRepo || !!input.language || !!input.licenseSpdx,
|
|
990
|
+
showFreshness: hasRepo || !!input.pushedAt || !!input.healthLabel || input.isArchived,
|
|
955
991
|
showEcosystem:
|
|
956
992
|
input.stacks.length > 0 ||
|
|
957
993
|
input.platforms.length > 0 ||
|
|
@@ -967,7 +1003,7 @@ export function computeSidebarVisibility(input: {
|
|
|
967
1003
|
* here so the CLI's scaffold and the example app share one definition.
|
|
968
1004
|
*/
|
|
969
1005
|
export function recordDetailPaths(site: DirectorySiteConfig) {
|
|
970
|
-
const dirSlug = site.blueprintConfig?.routeSlug ??
|
|
1006
|
+
const dirSlug = site.blueprintConfig?.routeSlug ?? 'items';
|
|
971
1007
|
return fullItems.map((record) => ({
|
|
972
1008
|
params: { slug: dirSlug, recordSlug: record.slug },
|
|
973
1009
|
}));
|
|
@@ -975,12 +1011,12 @@ export function recordDetailPaths(site: DirectorySiteConfig) {
|
|
|
975
1011
|
|
|
976
1012
|
// ── Taxonomy page models ──────────────────────────────────────────
|
|
977
1013
|
|
|
978
|
-
export type TaxonomyPageKind =
|
|
1014
|
+
export type TaxonomyPageKind = 'categories' | 'stacks' | 'licenses';
|
|
979
1015
|
|
|
980
1016
|
const TAXONOMY_EYEBROW: Record<TaxonomyPageKind, string> = {
|
|
981
|
-
categories:
|
|
982
|
-
stacks:
|
|
983
|
-
licenses:
|
|
1017
|
+
categories: 'Category',
|
|
1018
|
+
stacks: 'Stack',
|
|
1019
|
+
licenses: 'License',
|
|
984
1020
|
};
|
|
985
1021
|
|
|
986
1022
|
/**
|
|
@@ -1002,15 +1038,15 @@ export function getTaxonomyPageModel(
|
|
|
1002
1038
|
) {
|
|
1003
1039
|
const plural = site.blueprintConfig?.labelPlural ?? itemLabelPlural();
|
|
1004
1040
|
const filters: IndexFilters =
|
|
1005
|
-
kind ===
|
|
1041
|
+
kind === 'categories'
|
|
1006
1042
|
? { categories: [id] }
|
|
1007
|
-
: kind ===
|
|
1043
|
+
: kind === 'stacks'
|
|
1008
1044
|
? { stacks: [id] }
|
|
1009
1045
|
: { licenses: [id] };
|
|
1010
1046
|
const records = filterRecords(items, filters);
|
|
1011
1047
|
const count = records.length;
|
|
1012
1048
|
const siteUrl = siteUrlOf(site);
|
|
1013
|
-
const routeSlug = site.blueprintConfig?.routeSlug ??
|
|
1049
|
+
const routeSlug = site.blueprintConfig?.routeSlug ?? 'projects';
|
|
1014
1050
|
const pagePath = `${kind}/${id}/`;
|
|
1015
1051
|
|
|
1016
1052
|
// "MIT License" → "MIT" so the license title reads "MIT-licensed
|
|
@@ -1019,29 +1055,27 @@ export function getTaxonomyPageModel(
|
|
|
1019
1055
|
// displayName yields a consistent title and description — Google
|
|
1020
1056
|
// flags title/description fragments that disagree on whether the
|
|
1021
1057
|
// word "License" appears as low quality.
|
|
1022
|
-
const licenseLabel = displayName.replace(/\s+license$/i,
|
|
1058
|
+
const licenseLabel = displayName.replace(/\s+license$/i, '');
|
|
1023
1059
|
const main =
|
|
1024
|
-
kind ===
|
|
1060
|
+
kind === 'categories'
|
|
1025
1061
|
? `${displayName} ${plural} on ${site.name}`
|
|
1026
|
-
: kind ===
|
|
1062
|
+
: kind === 'stacks'
|
|
1027
1063
|
? `${titleCaseFirst(plural)} built with ${displayName} on ${site.name}`
|
|
1028
1064
|
: `${licenseLabel}-licensed ${plural} on ${site.name}`;
|
|
1029
1065
|
const description = seoDescription(
|
|
1030
1066
|
undefined,
|
|
1031
|
-
kind ===
|
|
1067
|
+
kind === 'categories'
|
|
1032
1068
|
? `${count} curated open-source ${plural} in the ${displayName} category on ${site.name}. Compare stars, activity, and licenses.`
|
|
1033
|
-
: kind ===
|
|
1069
|
+
: kind === 'stacks'
|
|
1034
1070
|
? `${count} curated open-source ${plural} built with ${displayName}, listed on ${site.name} with stars, activity, and license data.`
|
|
1035
1071
|
: `${count} open-source ${plural} under the ${licenseLabel} license on ${site.name}.`,
|
|
1036
1072
|
);
|
|
1037
1073
|
|
|
1038
1074
|
const crumbs: Array<{ path: string; name: string }> = [
|
|
1039
|
-
{ path:
|
|
1075
|
+
{ path: '', name: 'Home' },
|
|
1040
1076
|
// Licenses have no index page in the scaffold, so their trail
|
|
1041
1077
|
// goes straight from Home to the license itself.
|
|
1042
|
-
...(kind ===
|
|
1043
|
-
? []
|
|
1044
|
-
: [{ path: `${kind}/`, name: titleCaseFirst(kind) }]),
|
|
1078
|
+
...(kind === 'licenses' ? [] : [{ path: `${kind}/`, name: titleCaseFirst(kind) }]),
|
|
1045
1079
|
{ path: pagePath, name: displayName },
|
|
1046
1080
|
];
|
|
1047
1081
|
const listItems = records.slice(0, 50).map((record) => {
|
|
@@ -1057,10 +1091,7 @@ export function getTaxonomyPageModel(
|
|
|
1057
1091
|
// it still runs for the length/whitespace normalization.
|
|
1058
1092
|
title: seoTitle(main, site.name),
|
|
1059
1093
|
description,
|
|
1060
|
-
image: ogPath(
|
|
1061
|
-
kind === "categories" ? "category" : kind === "stacks" ? "stack" : "license",
|
|
1062
|
-
id,
|
|
1063
|
-
),
|
|
1094
|
+
image: ogPath(kind === 'categories' ? 'category' : kind === 'stacks' ? 'stack' : 'license', id),
|
|
1064
1095
|
imageAlt: `${displayName} — ${site.name}`,
|
|
1065
1096
|
jsonLd: [
|
|
1066
1097
|
...collectionSchema({
|
|
@@ -1091,25 +1122,28 @@ export function getTaxonomyPageModel(
|
|
|
1091
1122
|
* SEO block for the `/categories/` and `/stacks/` index pages.
|
|
1092
1123
|
*/
|
|
1093
1124
|
export function getTaxonomyIndexSeo(
|
|
1094
|
-
kind:
|
|
1125
|
+
kind: 'categories' | 'stacks',
|
|
1095
1126
|
site: DirectorySiteConfig,
|
|
1096
1127
|
): PageSeo {
|
|
1097
1128
|
const plural = site.blueprintConfig?.labelPlural ?? itemLabelPlural();
|
|
1098
1129
|
const { categories, stacks } = countTaxonomies();
|
|
1099
|
-
const entries = kind ===
|
|
1100
|
-
const sample = entries
|
|
1130
|
+
const entries = kind === 'categories' ? categories : stacks;
|
|
1131
|
+
const sample = entries
|
|
1132
|
+
.slice(0, 3)
|
|
1133
|
+
.map((entry) => entry.name)
|
|
1134
|
+
.join(', ');
|
|
1101
1135
|
const siteUrl = siteUrlOf(site);
|
|
1102
|
-
const title = seoTitle(kind ===
|
|
1136
|
+
const title = seoTitle(kind === 'categories' ? 'Categories' : 'Stacks', site.name);
|
|
1103
1137
|
const description = seoDescription(
|
|
1104
1138
|
undefined,
|
|
1105
|
-
kind ===
|
|
1106
|
-
? `Browse all ${entries.length} categories of ${plural} on ${site.name}${sample ? ` — from ${sample} and more` :
|
|
1107
|
-
: `Browse ${plural} on ${site.name} by technology stack — ${entries.length} stacks${sample ? ` including ${sample}` :
|
|
1139
|
+
kind === 'categories'
|
|
1140
|
+
? `Browse all ${entries.length} categories of ${plural} on ${site.name}${sample ? ` — from ${sample} and more` : ''}.`
|
|
1141
|
+
: `Browse ${plural} on ${site.name} by technology stack — ${entries.length} stacks${sample ? ` including ${sample}` : ''}.`,
|
|
1108
1142
|
);
|
|
1109
1143
|
return {
|
|
1110
1144
|
title,
|
|
1111
1145
|
description,
|
|
1112
|
-
image: ogPath(
|
|
1146
|
+
image: ogPath('default'),
|
|
1113
1147
|
jsonLd: [
|
|
1114
1148
|
...collectionSchema({
|
|
1115
1149
|
url: absoluteUrl(siteUrl, `${kind}/`),
|
|
@@ -1120,7 +1154,7 @@ export function getTaxonomyIndexSeo(
|
|
|
1120
1154
|
name: entry.name,
|
|
1121
1155
|
})),
|
|
1122
1156
|
crumbs: [
|
|
1123
|
-
{ url: `${siteUrl}/`, name:
|
|
1157
|
+
{ url: `${siteUrl}/`, name: 'Home' },
|
|
1124
1158
|
{ url: absoluteUrl(siteUrl, `${kind}/`), name: titleCaseFirst(kind) },
|
|
1125
1159
|
],
|
|
1126
1160
|
}),
|
|
@@ -1133,32 +1167,189 @@ export function getTaxonomyIndexSeo(
|
|
|
1133
1167
|
*/
|
|
1134
1168
|
export function getAboutPageSeo(site: DirectorySiteConfig): PageSeo {
|
|
1135
1169
|
const siteUrl = siteUrlOf(site);
|
|
1136
|
-
const title = seoTitle(
|
|
1170
|
+
const title = seoTitle('About', site.name);
|
|
1137
1171
|
const description = seoDescription(
|
|
1138
1172
|
undefined,
|
|
1139
|
-
`About ${site.name}: ${site.tagline ?? site.description ??
|
|
1173
|
+
`About ${site.name}: ${site.tagline ?? site.description ?? 'a file-first knowledge site.'}`,
|
|
1140
1174
|
);
|
|
1141
1175
|
return {
|
|
1142
1176
|
title,
|
|
1143
1177
|
description,
|
|
1144
|
-
image: ogPath(
|
|
1178
|
+
image: ogPath('default'),
|
|
1145
1179
|
jsonLd: [
|
|
1146
1180
|
{
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
url: absoluteUrl(siteUrl,
|
|
1181
|
+
'@context': 'https://schema.org',
|
|
1182
|
+
'@type': ['AboutPage', 'WebPage'],
|
|
1183
|
+
'@id': `${absoluteUrl(siteUrl, 'about/')}#page`,
|
|
1184
|
+
url: absoluteUrl(siteUrl, 'about/'),
|
|
1151
1185
|
name: title,
|
|
1152
1186
|
description,
|
|
1153
1187
|
},
|
|
1154
1188
|
breadcrumbs(siteUrl, [
|
|
1155
|
-
{ path:
|
|
1156
|
-
{ path:
|
|
1189
|
+
{ path: '', name: 'Home' },
|
|
1190
|
+
{ path: 'about/', name: 'About' },
|
|
1157
1191
|
]),
|
|
1158
1192
|
],
|
|
1159
1193
|
};
|
|
1160
1194
|
}
|
|
1161
1195
|
|
|
1196
|
+
// ── ProjectCard view-model ──────────────────────────────────────────
|
|
1197
|
+
|
|
1198
|
+
/**
|
|
1199
|
+
* The raw shapes `ProjectCard` has historically been asked to render:
|
|
1200
|
+
* a slim index record (browse/home lists), a full `Resource` (a
|
|
1201
|
+
* project or resource sidecar), a full `ProjectRecord`, or the
|
|
1202
|
+
* index-projected `IndexProjectRecord`. `buildProjectCardModel`
|
|
1203
|
+
* absorbs all four so the component itself never branches on `kind`.
|
|
1204
|
+
*/
|
|
1205
|
+
export type CardRecord = IndexRecord | Resource | ProjectRecord | IndexProjectRecord;
|
|
1206
|
+
|
|
1207
|
+
export interface ProjectCardModel {
|
|
1208
|
+
name: string;
|
|
1209
|
+
slug?: string;
|
|
1210
|
+
description: string;
|
|
1211
|
+
initials: string;
|
|
1212
|
+
avatarUrl?: string;
|
|
1213
|
+
repoHref?: string;
|
|
1214
|
+
owner?: string;
|
|
1215
|
+
repo?: string;
|
|
1216
|
+
/** True when repoHref+owner+repo all resolved — the card renders
|
|
1217
|
+
* as an `<article>` with a stretched title link. */
|
|
1218
|
+
isArticle: boolean;
|
|
1219
|
+
stars?: number;
|
|
1220
|
+
starsLabel: string | null;
|
|
1221
|
+
hasStars: boolean;
|
|
1222
|
+
pushedAt?: string | null;
|
|
1223
|
+
updatedLabel: string | null;
|
|
1224
|
+
hasUpdated: boolean;
|
|
1225
|
+
stackIds: string[];
|
|
1226
|
+
/** First 4 stack ids — what the card footer actually renders. */
|
|
1227
|
+
visibleStacks: string[];
|
|
1228
|
+
/** `stackIds.length - visibleStacks.length`. */
|
|
1229
|
+
stackOverflow: number;
|
|
1230
|
+
hasStacks: boolean;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/** Explicit overrides for record-derived `ProjectCardModel` fields.
|
|
1234
|
+
* Adapters that only have a lightweight shape (e.g. `CollectionEntry`)
|
|
1235
|
+
* pass these instead of a `record`; explicit values always win over
|
|
1236
|
+
* whatever the record would have derived. */
|
|
1237
|
+
export interface ProjectCardModelOverrides {
|
|
1238
|
+
logoUrl?: string;
|
|
1239
|
+
description?: string;
|
|
1240
|
+
name?: string;
|
|
1241
|
+
slug?: string;
|
|
1242
|
+
repoUrl?: string;
|
|
1243
|
+
stars?: number;
|
|
1244
|
+
pushedAt?: string | null;
|
|
1245
|
+
stack?: string;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
/**
|
|
1249
|
+
* Build the presentation-ready model for `ProjectCard` — every
|
|
1250
|
+
* branch/cast/format call that used to live in the component's
|
|
1251
|
+
* frontmatter, extracted verbatim. Registry UI reads `model.*` and
|
|
1252
|
+
* does no derivation of its own (see `v1-architecture.md` §7-14).
|
|
1253
|
+
*/
|
|
1254
|
+
export function buildProjectCardModel(
|
|
1255
|
+
record?: CardRecord,
|
|
1256
|
+
overrides: ProjectCardModelOverrides = {},
|
|
1257
|
+
): ProjectCardModel {
|
|
1258
|
+
const {
|
|
1259
|
+
logoUrl: logoUrlProp,
|
|
1260
|
+
description: descriptionProp,
|
|
1261
|
+
name: nameProp,
|
|
1262
|
+
slug: slugProp,
|
|
1263
|
+
repoUrl: repoUrlProp,
|
|
1264
|
+
stars: starsProp,
|
|
1265
|
+
pushedAt: pushedAtProp,
|
|
1266
|
+
stack: stackProp,
|
|
1267
|
+
} = overrides;
|
|
1268
|
+
|
|
1269
|
+
const isProject = record?.kind === 'project';
|
|
1270
|
+
const name =
|
|
1271
|
+
nameProp ??
|
|
1272
|
+
(record
|
|
1273
|
+
? record.kind === 'resource'
|
|
1274
|
+
? (record as { title: string }).title
|
|
1275
|
+
: (record as { name: string }).name
|
|
1276
|
+
: '');
|
|
1277
|
+
const slug = slugProp ?? (record as { slug?: string } | undefined)?.slug;
|
|
1278
|
+
const repoHref =
|
|
1279
|
+
repoUrlProp ??
|
|
1280
|
+
(isProject
|
|
1281
|
+
? (record as { repoUrl?: string }).repoUrl
|
|
1282
|
+
: (record as { links?: { github?: string } } | undefined)?.links?.github);
|
|
1283
|
+
|
|
1284
|
+
const { owner, repo } = getOwnerAndRepoFromRepoUrl(repoHref ?? '');
|
|
1285
|
+
const avatarUrl =
|
|
1286
|
+
logoUrlProp ??
|
|
1287
|
+
(isProject ? (record as { logoUrl?: string }).logoUrl : undefined) ??
|
|
1288
|
+
getOwnerAvatarUrl(owner, 80) ??
|
|
1289
|
+
undefined;
|
|
1290
|
+
|
|
1291
|
+
const initials = nameInitials(name);
|
|
1292
|
+
|
|
1293
|
+
// Trimmed on a word boundary before the two-line clamp gets to it: a
|
|
1294
|
+
// CSS clamp cuts at whatever character the box runs out of room on,
|
|
1295
|
+
// which is what produced "…and multi-", "…and retrieval", "…an" across
|
|
1296
|
+
// a grid.
|
|
1297
|
+
const description = truncateWords(
|
|
1298
|
+
descriptionProp ?? (record as { description?: string } | undefined)?.description ?? '',
|
|
1299
|
+
);
|
|
1300
|
+
|
|
1301
|
+
const stars =
|
|
1302
|
+
starsProp ??
|
|
1303
|
+
(isProject ? (record as { github?: { stars?: number } }).github?.stars : undefined);
|
|
1304
|
+
const pushedAt =
|
|
1305
|
+
pushedAtProp ??
|
|
1306
|
+
(isProject
|
|
1307
|
+
? (record as { github?: { pushedAt?: string | null } }).github?.pushedAt
|
|
1308
|
+
: (record as { publishedAt?: string | null } | undefined)?.publishedAt);
|
|
1309
|
+
const stackIds = stackProp
|
|
1310
|
+
? [stackProp]
|
|
1311
|
+
: isProject
|
|
1312
|
+
? projectStackIds(record as { stack?: string; stacks?: string[] })
|
|
1313
|
+
: [];
|
|
1314
|
+
|
|
1315
|
+
const starsLabel = formatStars(stars);
|
|
1316
|
+
const updatedLabel = formatRelative(pushedAt);
|
|
1317
|
+
|
|
1318
|
+
const hasStars = starsLabel !== null && typeof stars === 'number' && stars > 0;
|
|
1319
|
+
const hasUpdated = Boolean(pushedAt);
|
|
1320
|
+
|
|
1321
|
+
// `<a>`-in-`<a>` is invalid HTML, and the `owner/repo` line is its own
|
|
1322
|
+
// link to the repository — so any card that shows one renders as an
|
|
1323
|
+
// `<article>` whose title link is stretched over the whole card.
|
|
1324
|
+
const isArticle = Boolean(repoHref && owner && repo);
|
|
1325
|
+
|
|
1326
|
+
const visibleStacks = stackIds.slice(0, 4);
|
|
1327
|
+
const stackOverflow = stackIds.length - visibleStacks.length;
|
|
1328
|
+
const hasStacks = visibleStacks.length > 0;
|
|
1329
|
+
|
|
1330
|
+
return {
|
|
1331
|
+
name,
|
|
1332
|
+
slug,
|
|
1333
|
+
description,
|
|
1334
|
+
initials,
|
|
1335
|
+
avatarUrl,
|
|
1336
|
+
repoHref,
|
|
1337
|
+
owner: owner ?? undefined,
|
|
1338
|
+
repo: repo ?? undefined,
|
|
1339
|
+
isArticle,
|
|
1340
|
+
stars,
|
|
1341
|
+
starsLabel,
|
|
1342
|
+
hasStars,
|
|
1343
|
+
pushedAt,
|
|
1344
|
+
updatedLabel,
|
|
1345
|
+
hasUpdated,
|
|
1346
|
+
stackIds,
|
|
1347
|
+
visibleStacks,
|
|
1348
|
+
stackOverflow,
|
|
1349
|
+
hasStacks,
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1162
1353
|
export type DirectoryIndexModel = ReturnType<typeof getDirectoryIndexModel>;
|
|
1163
1354
|
export type DirectoryHomeModel = ReturnType<typeof getHomePageModel>;
|
|
1164
1355
|
export type SubmissionPageModel = ReturnType<typeof getSubmissionPageModel>;
|