@grove-dev/astro 0.7.0 → 0.9.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 +425 -218
- 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
|
|
@@ -602,11 +613,11 @@ export function getRecordDetailModel(
|
|
|
602
613
|
siteName: site.name,
|
|
603
614
|
});
|
|
604
615
|
const description = seoDescription(
|
|
605
|
-
(record.summary && record.summary.trim()) || record.description,
|
|
616
|
+
record.seo?.description || (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,58 @@ 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;
|
|
642
|
+
const addedAtLabel = record.addedAt
|
|
643
|
+
? new Date(String(record.addedAt)).toLocaleDateString()
|
|
644
|
+
: null;
|
|
618
645
|
const isArchived = !!github?.archived;
|
|
619
646
|
const isDisabled = !!github?.disabled;
|
|
620
647
|
const ownerRepo = repoUrl ? getOwnerAndRepoFromRepoUrl(repoUrl) : null;
|
|
621
648
|
const avatarSrc = proj?.logoUrl ?? (ownerRepo ? getOwnerAvatarUrl(ownerRepo.owner, 80) : null);
|
|
622
649
|
const rawScores = proj?.scores;
|
|
623
|
-
const scores =
|
|
624
|
-
|
|
625
|
-
|
|
650
|
+
const scores =
|
|
651
|
+
rawScores && Object.values(rawScores).some((value) => typeof value === 'number')
|
|
652
|
+
? rawScores
|
|
653
|
+
: null;
|
|
626
654
|
const healthStatus = proj?.health?.status;
|
|
627
655
|
const extras = record as ProjectExtras;
|
|
628
656
|
const monthlyCommits = (extras.github?.activity?.monthlyCommits ?? []).map(
|
|
629
|
-
(value): { month: string; commits: number } =>
|
|
630
|
-
|
|
631
|
-
|
|
657
|
+
(value): { month: string; commits: number } =>
|
|
658
|
+
typeof value === 'number'
|
|
659
|
+
? { month: '', commits: value }
|
|
660
|
+
: { month: value.month ?? '', commits: value.commits ?? 0 },
|
|
632
661
|
);
|
|
633
662
|
const contributionLabels: Record<string, string> = {
|
|
634
|
-
readme:
|
|
635
|
-
contributing:
|
|
636
|
-
security:
|
|
637
|
-
issueTemplates:
|
|
638
|
-
pullRequestTemplate:
|
|
663
|
+
readme: 'README',
|
|
664
|
+
contributing: 'Contributing',
|
|
665
|
+
security: 'Security',
|
|
666
|
+
issueTemplates: 'Issue templates',
|
|
667
|
+
pullRequestTemplate: 'PR template',
|
|
639
668
|
};
|
|
640
669
|
const contributionFiles = extras.github?.files ?? {};
|
|
641
670
|
const contributionSignals = Object.keys(contributionLabels)
|
|
642
671
|
.map((key) => ({ key, label: contributionLabels[key], ok: contributionFiles[key] }))
|
|
643
672
|
.filter((signal) => signal.ok !== undefined);
|
|
644
673
|
const languages = extras.github?.languages
|
|
645
|
-
? Object.entries(extras.github.languages)
|
|
674
|
+
? Object.entries(extras.github.languages)
|
|
675
|
+
.sort((a, b) => b[1] - a[1])
|
|
676
|
+
.slice(0, 5)
|
|
646
677
|
: [];
|
|
647
678
|
const healthLabel = healthStatus ? statusDisplay(healthStatus) : null;
|
|
648
679
|
const tags = record.tags ?? [];
|
|
649
|
-
const tocBody = readContentFile(typeof record.content ===
|
|
680
|
+
const tocBody = readContentFile(typeof record.content === 'string' ? record.content : '');
|
|
650
681
|
|
|
651
682
|
const siteUrl = siteUrlOf(site);
|
|
652
683
|
const pageUrl = absoluteUrl(siteUrl, `${routeSlug}/${recordSlug}/`);
|
|
@@ -654,11 +685,15 @@ export function getRecordDetailModel(
|
|
|
654
685
|
let recordLd: Record<string, unknown>;
|
|
655
686
|
if (isProject && proj) {
|
|
656
687
|
const sameAs = [repoUrl, homepageUrl].filter(Boolean);
|
|
657
|
-
|
|
688
|
+
// schema.org `dateCreated` is when the *work* was created, so the
|
|
689
|
+
// repository's own creation date is the honest answer. This used to
|
|
690
|
+
// read `curation.reviewedAt` and published the review date as the
|
|
691
|
+
// project's birthday — off by years on most records.
|
|
692
|
+
const dateCreated = github?.created_at ? String(github.created_at) : undefined;
|
|
658
693
|
recordLd = {
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
694
|
+
'@context': 'https://schema.org',
|
|
695
|
+
'@type': 'SoftwareSourceCode',
|
|
696
|
+
'@id': `${pageUrl}#record`,
|
|
662
697
|
name,
|
|
663
698
|
headline: name,
|
|
664
699
|
description,
|
|
@@ -668,45 +703,54 @@ export function getRecordDetailModel(
|
|
|
668
703
|
programmingLanguage: language ?? undefined,
|
|
669
704
|
license: licenseSpdx ?? undefined,
|
|
670
705
|
applicationCategory: record.category || undefined,
|
|
671
|
-
operatingSystem: platforms.length ? platforms.join(
|
|
672
|
-
keywords: record.tags?.length ? record.tags.join(
|
|
706
|
+
operatingSystem: platforms.length ? platforms.join(', ') : undefined,
|
|
707
|
+
keywords: record.tags?.length ? record.tags.join(', ') : undefined,
|
|
673
708
|
dateCreated: dateCreated ?? undefined,
|
|
674
709
|
dateModified: pushedAt ?? dateCreated ?? undefined,
|
|
675
|
-
interactionStatistic:
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
710
|
+
interactionStatistic:
|
|
711
|
+
stars > 0
|
|
712
|
+
? {
|
|
713
|
+
'@type': 'InteractionCounter',
|
|
714
|
+
interactionType: { '@type': 'LikeAction' },
|
|
715
|
+
userInteractionCount: stars,
|
|
716
|
+
}
|
|
717
|
+
: undefined,
|
|
718
|
+
author: ownerRepo
|
|
719
|
+
? {
|
|
720
|
+
'@type': 'Organization',
|
|
721
|
+
name: ownerRepo.owner,
|
|
722
|
+
url: `https://github.com/${ownerRepo.owner}`,
|
|
723
|
+
}
|
|
724
|
+
: undefined,
|
|
685
725
|
isAccessibleForFree: true,
|
|
686
726
|
};
|
|
687
|
-
} else if (record.kind ===
|
|
727
|
+
} else if (record.kind === 'resource') {
|
|
688
728
|
const schemaTypes: Record<string, string> = {
|
|
689
|
-
article:
|
|
729
|
+
article: 'Article',
|
|
730
|
+
book: 'Book',
|
|
731
|
+
course: 'Course',
|
|
732
|
+
podcast: 'PodcastSeries',
|
|
733
|
+
video: 'VideoObject',
|
|
690
734
|
};
|
|
691
735
|
recordLd = {
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
736
|
+
'@context': 'https://schema.org',
|
|
737
|
+
'@type': schemaTypes[record.type] ?? 'CreativeWork',
|
|
738
|
+
'@id': `${pageUrl}#record`,
|
|
695
739
|
name,
|
|
696
740
|
headline: name,
|
|
697
741
|
description,
|
|
698
742
|
url: pageUrl,
|
|
699
743
|
...(homepageUrl ? { sameAs: [homepageUrl] } : {}),
|
|
700
|
-
author: record.author ? {
|
|
744
|
+
author: record.author ? { '@type': 'Person', name: record.author } : undefined,
|
|
701
745
|
datePublished: record.publishedAt || undefined,
|
|
702
|
-
keywords: record.tags?.length ? record.tags.join(
|
|
746
|
+
keywords: record.tags?.length ? record.tags.join(', ') : undefined,
|
|
703
747
|
isAccessibleForFree: true,
|
|
704
748
|
};
|
|
705
749
|
} else {
|
|
706
750
|
recordLd = {
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
751
|
+
'@context': 'https://schema.org',
|
|
752
|
+
'@type': entity?.type === 'person' ? 'Person' : 'Organization',
|
|
753
|
+
'@id': `${pageUrl}#record`,
|
|
710
754
|
name,
|
|
711
755
|
headline: name,
|
|
712
756
|
description,
|
|
@@ -714,23 +758,25 @@ export function getRecordDetailModel(
|
|
|
714
758
|
...(homepageUrl ? { sameAs: [homepageUrl] } : {}),
|
|
715
759
|
foundingDate: entity?.founded || undefined,
|
|
716
760
|
location: entity?.location || undefined,
|
|
717
|
-
keywords: entity?.tags?.length ? entity.tags.join(
|
|
761
|
+
keywords: entity?.tags?.length ? entity.tags.join(', ') : undefined,
|
|
718
762
|
};
|
|
719
763
|
}
|
|
720
764
|
|
|
721
765
|
// "<Name> — <descriptor> | <Site>": the descriptor gives the search
|
|
722
|
-
// snippet a reason to exist beyond the bare project name.
|
|
766
|
+
// snippet a reason to exist beyond the bare project name. A curator's
|
|
767
|
+
// `record.seo.title` (when set) is used verbatim, same override
|
|
768
|
+
// pattern as `getCollectionPageModel`'s `collection.seo?.title`.
|
|
723
769
|
const descriptor = recordSeoDescriptor({
|
|
724
770
|
summary: record.summary,
|
|
725
771
|
...(categoryLabel ? { categoryLabel } : {}),
|
|
726
772
|
singular,
|
|
727
773
|
});
|
|
728
|
-
const title = seoTitle(`${name} — ${descriptor}`, site.name);
|
|
774
|
+
const title = record.seo?.title ?? seoTitle(`${name} — ${descriptor}`, site.name);
|
|
729
775
|
const pluralTitle = titleCaseFirst(site.blueprintConfig?.labelPlural ?? itemLabelPlural());
|
|
730
776
|
const jsonLd = [
|
|
731
777
|
recordLd,
|
|
732
778
|
breadcrumbs(siteUrl, [
|
|
733
|
-
{ path:
|
|
779
|
+
{ path: '', name: 'Home' },
|
|
734
780
|
{ path: `${routeSlug}/`, name: pluralTitle },
|
|
735
781
|
{ path: `${routeSlug}/${recordSlug}/`, name },
|
|
736
782
|
]),
|
|
@@ -738,7 +784,7 @@ export function getRecordDetailModel(
|
|
|
738
784
|
const seo: PageSeo = {
|
|
739
785
|
title,
|
|
740
786
|
description,
|
|
741
|
-
image: ogPath(
|
|
787
|
+
image: ogPath('record', recordSlug),
|
|
742
788
|
imageAlt: `${name} — ${site.name}`,
|
|
743
789
|
jsonLd,
|
|
744
790
|
};
|
|
@@ -766,9 +812,16 @@ export function getRecordDetailModel(
|
|
|
766
812
|
pushedAt,
|
|
767
813
|
starsLabel: formatStars(stars),
|
|
768
814
|
pushedLabel: formatRelative(pushedAt),
|
|
815
|
+
firstCommitYear,
|
|
816
|
+
lastFetchedLabel,
|
|
817
|
+
reviewedAtLabel,
|
|
818
|
+
addedAtLabel,
|
|
769
819
|
ownerRepo,
|
|
770
820
|
avatarSrc,
|
|
771
|
-
initials: name
|
|
821
|
+
initials: name
|
|
822
|
+
.replace(/[^a-z0-9]/gi, '')
|
|
823
|
+
.slice(0, 2)
|
|
824
|
+
.toUpperCase(),
|
|
772
825
|
bestFor: proj?.bestFor ?? [],
|
|
773
826
|
whyListed: proj?.whyListed ?? [],
|
|
774
827
|
caveats: proj?.caveats ?? [],
|
|
@@ -785,8 +838,8 @@ export function getRecordDetailModel(
|
|
|
785
838
|
// Curated summary (Open Apps-written) takes priority over the
|
|
786
839
|
// raw `description` (typically copied from GitHub). Fall back to
|
|
787
840
|
// `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 ??
|
|
841
|
+
summary: (record.summary && record.summary.trim()) || record.description || '',
|
|
842
|
+
sourceDescription: record.sourceDescription ?? record.description ?? '',
|
|
790
843
|
// Collection membership is populated by the consumer detail page
|
|
791
844
|
// after calling `findCollectionsFor`. Defaulting to [] keeps the
|
|
792
845
|
// model safe when the consumer doesn't wire it.
|
|
@@ -798,7 +851,10 @@ export function getRecordDetailModel(
|
|
|
798
851
|
maxMonthlyCommits: Math.max(1, ...monthlyCommits.map((item) => item.commits)),
|
|
799
852
|
contributionSignals,
|
|
800
853
|
languages,
|
|
801
|
-
totalLanguageBytes: Math.max(
|
|
854
|
+
totalLanguageBytes: Math.max(
|
|
855
|
+
1,
|
|
856
|
+
languages.reduce((sum, [, bytes]) => sum + bytes, 0),
|
|
857
|
+
),
|
|
802
858
|
// ── Sidebar-shape fields ────────────────────────────────
|
|
803
859
|
// Read once at model-build time so pages don't repeat the
|
|
804
860
|
// "is there data for this card?" logic.
|
|
@@ -821,6 +877,7 @@ export function getRecordDetailModel(
|
|
|
821
877
|
category: record.category,
|
|
822
878
|
contributionSignals,
|
|
823
879
|
reviewedAt: record.curation?.reviewedAt,
|
|
880
|
+
addedAt: record.addedAt,
|
|
824
881
|
}),
|
|
825
882
|
// ── Body-derived fields ──────────────────────────────────
|
|
826
883
|
// Both depend on the sidecar Markdown; the body is re-read
|
|
@@ -844,7 +901,7 @@ export function getRecordDetailModel(
|
|
|
844
901
|
*/
|
|
845
902
|
function recordFallbackSentence(input: {
|
|
846
903
|
name: string;
|
|
847
|
-
kind:
|
|
904
|
+
kind: 'project' | 'resource' | 'entity';
|
|
848
905
|
entityType?: string;
|
|
849
906
|
resourceType?: string;
|
|
850
907
|
categoryLabel?: string;
|
|
@@ -853,17 +910,17 @@ function recordFallbackSentence(input: {
|
|
|
853
910
|
}): string {
|
|
854
911
|
const category = input.categoryLabel;
|
|
855
912
|
const listed = `listed on ${input.siteName}.`;
|
|
856
|
-
if (input.kind ===
|
|
857
|
-
if (input.entityType ===
|
|
858
|
-
return `${input.name}${category ? `, a ${category.toLowerCase()} contributor` :
|
|
913
|
+
if (input.kind === 'entity') {
|
|
914
|
+
if (input.entityType === 'person') {
|
|
915
|
+
return `${input.name}${category ? `, a ${category.toLowerCase()} contributor` : ''} ${listed}`;
|
|
859
916
|
}
|
|
860
|
-
return `${input.name}, an open-source ${category ? `${category} ` :
|
|
917
|
+
return `${input.name}, an open-source ${category ? `${category} ` : ''}organization ${listed}`;
|
|
861
918
|
}
|
|
862
|
-
if (input.kind ===
|
|
863
|
-
const type = input.resourceType ??
|
|
864
|
-
return `${input.name}, a ${type}${category ? ` in ${category}` :
|
|
919
|
+
if (input.kind === 'resource') {
|
|
920
|
+
const type = input.resourceType ?? 'resource';
|
|
921
|
+
return `${input.name}, a ${type}${category ? ` in ${category}` : ''} ${listed}`;
|
|
865
922
|
}
|
|
866
|
-
return `${input.name}, an open-source ${category ? `${category} ` :
|
|
923
|
+
return `${input.name}, an open-source ${category ? `${category} ` : ''}${input.singular} ${listed}`;
|
|
867
924
|
}
|
|
868
925
|
|
|
869
926
|
// ── Sidebar predicates ────────────────────────────────────────────
|
|
@@ -891,23 +948,22 @@ export function computeActivityBadge(input: {
|
|
|
891
948
|
monthlyCommits: Array<{ month: string; commits: number }>;
|
|
892
949
|
isArchived: boolean;
|
|
893
950
|
isDisabled: boolean;
|
|
894
|
-
}): { label: string; tone:
|
|
951
|
+
}): { label: string; tone: 'fresh' | 'ok' | 'low' | 'dead' } | null {
|
|
895
952
|
if (input.isArchived || input.isDisabled) {
|
|
896
|
-
return { label:
|
|
953
|
+
return { label: 'Archived', tone: 'dead' };
|
|
897
954
|
}
|
|
898
955
|
const recentCommits = (input.monthlyCommits ?? [])
|
|
899
956
|
.slice(-3)
|
|
900
957
|
.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:
|
|
958
|
+
if (recentCommits >= 50) return { label: 'Very active', tone: 'fresh' };
|
|
959
|
+
if (recentCommits >= 10) return { label: 'Active', tone: 'ok' };
|
|
960
|
+
if (recentCommits > 0) return { label: 'Maintained', tone: 'ok' };
|
|
904
961
|
if (input.pushedAt) {
|
|
905
|
-
const days =
|
|
906
|
-
|
|
907
|
-
if (days <
|
|
908
|
-
if (days <
|
|
909
|
-
|
|
910
|
-
return { label: "Stale", tone: "low" };
|
|
962
|
+
const days = (Date.now() - new Date(input.pushedAt).getTime()) / 86_400_000;
|
|
963
|
+
if (days < 30) return { label: 'Active', tone: 'ok' };
|
|
964
|
+
if (days < 180) return { label: 'Maintained', tone: 'ok' };
|
|
965
|
+
if (days < 365) return { label: 'Low activity', tone: 'low' };
|
|
966
|
+
return { label: 'Stale', tone: 'low' };
|
|
911
967
|
}
|
|
912
968
|
return null;
|
|
913
969
|
}
|
|
@@ -931,6 +987,7 @@ export function computeSidebarVisibility(input: {
|
|
|
931
987
|
category: string | undefined;
|
|
932
988
|
contributionSignals: Array<{ key: string; label: string; ok: boolean }>;
|
|
933
989
|
reviewedAt: string | undefined;
|
|
990
|
+
addedAt: string | undefined;
|
|
934
991
|
}): {
|
|
935
992
|
showActivity: boolean;
|
|
936
993
|
showFreshness: boolean;
|
|
@@ -939,26 +996,21 @@ export function computeSidebarVisibility(input: {
|
|
|
939
996
|
} {
|
|
940
997
|
const repoFields = input.repo ?? {};
|
|
941
998
|
const hasRepo =
|
|
942
|
-
typeof input.repo ===
|
|
943
|
-
input.repo !== null &&
|
|
944
|
-
Object.keys(repoFields).length > 0;
|
|
999
|
+
typeof input.repo === 'object' && input.repo !== null && Object.keys(repoFields).length > 0;
|
|
945
1000
|
return {
|
|
946
|
-
showActivity:
|
|
947
|
-
|
|
948
|
-
!!input.language ||
|
|
949
|
-
!!input.licenseSpdx,
|
|
950
|
-
showFreshness:
|
|
951
|
-
hasRepo ||
|
|
952
|
-
!!input.pushedAt ||
|
|
953
|
-
!!input.healthLabel ||
|
|
954
|
-
input.isArchived,
|
|
1001
|
+
showActivity: hasRepo || !!input.language || !!input.licenseSpdx,
|
|
1002
|
+
showFreshness: hasRepo || !!input.pushedAt || !!input.healthLabel || input.isArchived,
|
|
955
1003
|
showEcosystem:
|
|
956
1004
|
input.stacks.length > 0 ||
|
|
957
1005
|
input.platforms.length > 0 ||
|
|
958
1006
|
input.tags.length > 0 ||
|
|
959
1007
|
!!input.category ||
|
|
960
1008
|
input.contributionSignals.length > 0,
|
|
961
|
-
|
|
1009
|
+
// Was `!!input.reviewedAt`, which hid the whole Source card — and
|
|
1010
|
+
// with it "Also in" and the notes word count — on every record a
|
|
1011
|
+
// curator had not stamped. `addedAt` is set on every record, so the
|
|
1012
|
+
// card now renders for all of them.
|
|
1013
|
+
showSource: !!input.addedAt || !!input.reviewedAt,
|
|
962
1014
|
};
|
|
963
1015
|
}
|
|
964
1016
|
|
|
@@ -967,7 +1019,7 @@ export function computeSidebarVisibility(input: {
|
|
|
967
1019
|
* here so the CLI's scaffold and the example app share one definition.
|
|
968
1020
|
*/
|
|
969
1021
|
export function recordDetailPaths(site: DirectorySiteConfig) {
|
|
970
|
-
const dirSlug = site.blueprintConfig?.routeSlug ??
|
|
1022
|
+
const dirSlug = site.blueprintConfig?.routeSlug ?? 'items';
|
|
971
1023
|
return fullItems.map((record) => ({
|
|
972
1024
|
params: { slug: dirSlug, recordSlug: record.slug },
|
|
973
1025
|
}));
|
|
@@ -975,12 +1027,12 @@ export function recordDetailPaths(site: DirectorySiteConfig) {
|
|
|
975
1027
|
|
|
976
1028
|
// ── Taxonomy page models ──────────────────────────────────────────
|
|
977
1029
|
|
|
978
|
-
export type TaxonomyPageKind =
|
|
1030
|
+
export type TaxonomyPageKind = 'categories' | 'stacks' | 'licenses';
|
|
979
1031
|
|
|
980
1032
|
const TAXONOMY_EYEBROW: Record<TaxonomyPageKind, string> = {
|
|
981
|
-
categories:
|
|
982
|
-
stacks:
|
|
983
|
-
licenses:
|
|
1033
|
+
categories: 'Category',
|
|
1034
|
+
stacks: 'Stack',
|
|
1035
|
+
licenses: 'License',
|
|
984
1036
|
};
|
|
985
1037
|
|
|
986
1038
|
/**
|
|
@@ -1002,15 +1054,15 @@ export function getTaxonomyPageModel(
|
|
|
1002
1054
|
) {
|
|
1003
1055
|
const plural = site.blueprintConfig?.labelPlural ?? itemLabelPlural();
|
|
1004
1056
|
const filters: IndexFilters =
|
|
1005
|
-
kind ===
|
|
1057
|
+
kind === 'categories'
|
|
1006
1058
|
? { categories: [id] }
|
|
1007
|
-
: kind ===
|
|
1059
|
+
: kind === 'stacks'
|
|
1008
1060
|
? { stacks: [id] }
|
|
1009
1061
|
: { licenses: [id] };
|
|
1010
1062
|
const records = filterRecords(items, filters);
|
|
1011
1063
|
const count = records.length;
|
|
1012
1064
|
const siteUrl = siteUrlOf(site);
|
|
1013
|
-
const routeSlug = site.blueprintConfig?.routeSlug ??
|
|
1065
|
+
const routeSlug = site.blueprintConfig?.routeSlug ?? 'projects';
|
|
1014
1066
|
const pagePath = `${kind}/${id}/`;
|
|
1015
1067
|
|
|
1016
1068
|
// "MIT License" → "MIT" so the license title reads "MIT-licensed
|
|
@@ -1019,29 +1071,27 @@ export function getTaxonomyPageModel(
|
|
|
1019
1071
|
// displayName yields a consistent title and description — Google
|
|
1020
1072
|
// flags title/description fragments that disagree on whether the
|
|
1021
1073
|
// word "License" appears as low quality.
|
|
1022
|
-
const licenseLabel = displayName.replace(/\s+license$/i,
|
|
1074
|
+
const licenseLabel = displayName.replace(/\s+license$/i, '');
|
|
1023
1075
|
const main =
|
|
1024
|
-
kind ===
|
|
1076
|
+
kind === 'categories'
|
|
1025
1077
|
? `${displayName} ${plural} on ${site.name}`
|
|
1026
|
-
: kind ===
|
|
1078
|
+
: kind === 'stacks'
|
|
1027
1079
|
? `${titleCaseFirst(plural)} built with ${displayName} on ${site.name}`
|
|
1028
1080
|
: `${licenseLabel}-licensed ${plural} on ${site.name}`;
|
|
1029
1081
|
const description = seoDescription(
|
|
1030
1082
|
undefined,
|
|
1031
|
-
kind ===
|
|
1083
|
+
kind === 'categories'
|
|
1032
1084
|
? `${count} curated open-source ${plural} in the ${displayName} category on ${site.name}. Compare stars, activity, and licenses.`
|
|
1033
|
-
: kind ===
|
|
1085
|
+
: kind === 'stacks'
|
|
1034
1086
|
? `${count} curated open-source ${plural} built with ${displayName}, listed on ${site.name} with stars, activity, and license data.`
|
|
1035
1087
|
: `${count} open-source ${plural} under the ${licenseLabel} license on ${site.name}.`,
|
|
1036
1088
|
);
|
|
1037
1089
|
|
|
1038
1090
|
const crumbs: Array<{ path: string; name: string }> = [
|
|
1039
|
-
{ path:
|
|
1091
|
+
{ path: '', name: 'Home' },
|
|
1040
1092
|
// Licenses have no index page in the scaffold, so their trail
|
|
1041
1093
|
// goes straight from Home to the license itself.
|
|
1042
|
-
...(kind ===
|
|
1043
|
-
? []
|
|
1044
|
-
: [{ path: `${kind}/`, name: titleCaseFirst(kind) }]),
|
|
1094
|
+
...(kind === 'licenses' ? [] : [{ path: `${kind}/`, name: titleCaseFirst(kind) }]),
|
|
1045
1095
|
{ path: pagePath, name: displayName },
|
|
1046
1096
|
];
|
|
1047
1097
|
const listItems = records.slice(0, 50).map((record) => {
|
|
@@ -1057,10 +1107,7 @@ export function getTaxonomyPageModel(
|
|
|
1057
1107
|
// it still runs for the length/whitespace normalization.
|
|
1058
1108
|
title: seoTitle(main, site.name),
|
|
1059
1109
|
description,
|
|
1060
|
-
image: ogPath(
|
|
1061
|
-
kind === "categories" ? "category" : kind === "stacks" ? "stack" : "license",
|
|
1062
|
-
id,
|
|
1063
|
-
),
|
|
1110
|
+
image: ogPath(kind === 'categories' ? 'category' : kind === 'stacks' ? 'stack' : 'license', id),
|
|
1064
1111
|
imageAlt: `${displayName} — ${site.name}`,
|
|
1065
1112
|
jsonLd: [
|
|
1066
1113
|
...collectionSchema({
|
|
@@ -1091,25 +1138,28 @@ export function getTaxonomyPageModel(
|
|
|
1091
1138
|
* SEO block for the `/categories/` and `/stacks/` index pages.
|
|
1092
1139
|
*/
|
|
1093
1140
|
export function getTaxonomyIndexSeo(
|
|
1094
|
-
kind:
|
|
1141
|
+
kind: 'categories' | 'stacks',
|
|
1095
1142
|
site: DirectorySiteConfig,
|
|
1096
1143
|
): PageSeo {
|
|
1097
1144
|
const plural = site.blueprintConfig?.labelPlural ?? itemLabelPlural();
|
|
1098
1145
|
const { categories, stacks } = countTaxonomies();
|
|
1099
|
-
const entries = kind ===
|
|
1100
|
-
const sample = entries
|
|
1146
|
+
const entries = kind === 'categories' ? categories : stacks;
|
|
1147
|
+
const sample = entries
|
|
1148
|
+
.slice(0, 3)
|
|
1149
|
+
.map((entry) => entry.name)
|
|
1150
|
+
.join(', ');
|
|
1101
1151
|
const siteUrl = siteUrlOf(site);
|
|
1102
|
-
const title = seoTitle(kind ===
|
|
1152
|
+
const title = seoTitle(kind === 'categories' ? 'Categories' : 'Stacks', site.name);
|
|
1103
1153
|
const description = seoDescription(
|
|
1104
1154
|
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}` :
|
|
1155
|
+
kind === 'categories'
|
|
1156
|
+
? `Browse all ${entries.length} categories of ${plural} on ${site.name}${sample ? ` — from ${sample} and more` : ''}.`
|
|
1157
|
+
: `Browse ${plural} on ${site.name} by technology stack — ${entries.length} stacks${sample ? ` including ${sample}` : ''}.`,
|
|
1108
1158
|
);
|
|
1109
1159
|
return {
|
|
1110
1160
|
title,
|
|
1111
1161
|
description,
|
|
1112
|
-
image: ogPath(
|
|
1162
|
+
image: ogPath('default'),
|
|
1113
1163
|
jsonLd: [
|
|
1114
1164
|
...collectionSchema({
|
|
1115
1165
|
url: absoluteUrl(siteUrl, `${kind}/`),
|
|
@@ -1120,7 +1170,7 @@ export function getTaxonomyIndexSeo(
|
|
|
1120
1170
|
name: entry.name,
|
|
1121
1171
|
})),
|
|
1122
1172
|
crumbs: [
|
|
1123
|
-
{ url: `${siteUrl}/`, name:
|
|
1173
|
+
{ url: `${siteUrl}/`, name: 'Home' },
|
|
1124
1174
|
{ url: absoluteUrl(siteUrl, `${kind}/`), name: titleCaseFirst(kind) },
|
|
1125
1175
|
],
|
|
1126
1176
|
}),
|
|
@@ -1133,32 +1183,189 @@ export function getTaxonomyIndexSeo(
|
|
|
1133
1183
|
*/
|
|
1134
1184
|
export function getAboutPageSeo(site: DirectorySiteConfig): PageSeo {
|
|
1135
1185
|
const siteUrl = siteUrlOf(site);
|
|
1136
|
-
const title = seoTitle(
|
|
1186
|
+
const title = seoTitle('About', site.name);
|
|
1137
1187
|
const description = seoDescription(
|
|
1138
1188
|
undefined,
|
|
1139
|
-
`About ${site.name}: ${site.tagline ?? site.description ??
|
|
1189
|
+
`About ${site.name}: ${site.tagline ?? site.description ?? 'a file-first knowledge site.'}`,
|
|
1140
1190
|
);
|
|
1141
1191
|
return {
|
|
1142
1192
|
title,
|
|
1143
1193
|
description,
|
|
1144
|
-
image: ogPath(
|
|
1194
|
+
image: ogPath('default'),
|
|
1145
1195
|
jsonLd: [
|
|
1146
1196
|
{
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
url: absoluteUrl(siteUrl,
|
|
1197
|
+
'@context': 'https://schema.org',
|
|
1198
|
+
'@type': ['AboutPage', 'WebPage'],
|
|
1199
|
+
'@id': `${absoluteUrl(siteUrl, 'about/')}#page`,
|
|
1200
|
+
url: absoluteUrl(siteUrl, 'about/'),
|
|
1151
1201
|
name: title,
|
|
1152
1202
|
description,
|
|
1153
1203
|
},
|
|
1154
1204
|
breadcrumbs(siteUrl, [
|
|
1155
|
-
{ path:
|
|
1156
|
-
{ path:
|
|
1205
|
+
{ path: '', name: 'Home' },
|
|
1206
|
+
{ path: 'about/', name: 'About' },
|
|
1157
1207
|
]),
|
|
1158
1208
|
],
|
|
1159
1209
|
};
|
|
1160
1210
|
}
|
|
1161
1211
|
|
|
1212
|
+
// ── ProjectCard view-model ──────────────────────────────────────────
|
|
1213
|
+
|
|
1214
|
+
/**
|
|
1215
|
+
* The raw shapes `ProjectCard` has historically been asked to render:
|
|
1216
|
+
* a slim index record (browse/home lists), a full `Resource` (a
|
|
1217
|
+
* project or resource sidecar), a full `ProjectRecord`, or the
|
|
1218
|
+
* index-projected `IndexProjectRecord`. `buildProjectCardModel`
|
|
1219
|
+
* absorbs all four so the component itself never branches on `kind`.
|
|
1220
|
+
*/
|
|
1221
|
+
export type CardRecord = IndexRecord | Resource | ProjectRecord | IndexProjectRecord;
|
|
1222
|
+
|
|
1223
|
+
export interface ProjectCardModel {
|
|
1224
|
+
name: string;
|
|
1225
|
+
slug?: string;
|
|
1226
|
+
description: string;
|
|
1227
|
+
initials: string;
|
|
1228
|
+
avatarUrl?: string;
|
|
1229
|
+
repoHref?: string;
|
|
1230
|
+
owner?: string;
|
|
1231
|
+
repo?: string;
|
|
1232
|
+
/** True when repoHref+owner+repo all resolved — the card renders
|
|
1233
|
+
* as an `<article>` with a stretched title link. */
|
|
1234
|
+
isArticle: boolean;
|
|
1235
|
+
stars?: number;
|
|
1236
|
+
starsLabel: string | null;
|
|
1237
|
+
hasStars: boolean;
|
|
1238
|
+
pushedAt?: string | null;
|
|
1239
|
+
updatedLabel: string | null;
|
|
1240
|
+
hasUpdated: boolean;
|
|
1241
|
+
stackIds: string[];
|
|
1242
|
+
/** First 4 stack ids — what the card footer actually renders. */
|
|
1243
|
+
visibleStacks: string[];
|
|
1244
|
+
/** `stackIds.length - visibleStacks.length`. */
|
|
1245
|
+
stackOverflow: number;
|
|
1246
|
+
hasStacks: boolean;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
/** Explicit overrides for record-derived `ProjectCardModel` fields.
|
|
1250
|
+
* Adapters that only have a lightweight shape (e.g. `CollectionEntry`)
|
|
1251
|
+
* pass these instead of a `record`; explicit values always win over
|
|
1252
|
+
* whatever the record would have derived. */
|
|
1253
|
+
export interface ProjectCardModelOverrides {
|
|
1254
|
+
logoUrl?: string;
|
|
1255
|
+
description?: string;
|
|
1256
|
+
name?: string;
|
|
1257
|
+
slug?: string;
|
|
1258
|
+
repoUrl?: string;
|
|
1259
|
+
stars?: number;
|
|
1260
|
+
pushedAt?: string | null;
|
|
1261
|
+
stack?: string;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/**
|
|
1265
|
+
* Build the presentation-ready model for `ProjectCard` — every
|
|
1266
|
+
* branch/cast/format call that used to live in the component's
|
|
1267
|
+
* frontmatter, extracted verbatim. Registry UI reads `model.*` and
|
|
1268
|
+
* does no derivation of its own (see `v1-architecture.md` §7-14).
|
|
1269
|
+
*/
|
|
1270
|
+
export function buildProjectCardModel(
|
|
1271
|
+
record?: CardRecord,
|
|
1272
|
+
overrides: ProjectCardModelOverrides = {},
|
|
1273
|
+
): ProjectCardModel {
|
|
1274
|
+
const {
|
|
1275
|
+
logoUrl: logoUrlProp,
|
|
1276
|
+
description: descriptionProp,
|
|
1277
|
+
name: nameProp,
|
|
1278
|
+
slug: slugProp,
|
|
1279
|
+
repoUrl: repoUrlProp,
|
|
1280
|
+
stars: starsProp,
|
|
1281
|
+
pushedAt: pushedAtProp,
|
|
1282
|
+
stack: stackProp,
|
|
1283
|
+
} = overrides;
|
|
1284
|
+
|
|
1285
|
+
const isProject = record?.kind === 'project';
|
|
1286
|
+
const name =
|
|
1287
|
+
nameProp ??
|
|
1288
|
+
(record
|
|
1289
|
+
? record.kind === 'resource'
|
|
1290
|
+
? (record as { title: string }).title
|
|
1291
|
+
: (record as { name: string }).name
|
|
1292
|
+
: '');
|
|
1293
|
+
const slug = slugProp ?? (record as { slug?: string } | undefined)?.slug;
|
|
1294
|
+
const repoHref =
|
|
1295
|
+
repoUrlProp ??
|
|
1296
|
+
(isProject
|
|
1297
|
+
? (record as { repoUrl?: string }).repoUrl
|
|
1298
|
+
: (record as { links?: { github?: string } } | undefined)?.links?.github);
|
|
1299
|
+
|
|
1300
|
+
const { owner, repo } = getOwnerAndRepoFromRepoUrl(repoHref ?? '');
|
|
1301
|
+
const avatarUrl =
|
|
1302
|
+
logoUrlProp ??
|
|
1303
|
+
(isProject ? (record as { logoUrl?: string }).logoUrl : undefined) ??
|
|
1304
|
+
getOwnerAvatarUrl(owner, 80) ??
|
|
1305
|
+
undefined;
|
|
1306
|
+
|
|
1307
|
+
const initials = nameInitials(name);
|
|
1308
|
+
|
|
1309
|
+
// Trimmed on a word boundary before the two-line clamp gets to it: a
|
|
1310
|
+
// CSS clamp cuts at whatever character the box runs out of room on,
|
|
1311
|
+
// which is what produced "…and multi-", "…and retrieval", "…an" across
|
|
1312
|
+
// a grid.
|
|
1313
|
+
const description = truncateWords(
|
|
1314
|
+
descriptionProp ?? (record as { description?: string } | undefined)?.description ?? '',
|
|
1315
|
+
);
|
|
1316
|
+
|
|
1317
|
+
const stars =
|
|
1318
|
+
starsProp ??
|
|
1319
|
+
(isProject ? (record as { github?: { stars?: number } }).github?.stars : undefined);
|
|
1320
|
+
const pushedAt =
|
|
1321
|
+
pushedAtProp ??
|
|
1322
|
+
(isProject
|
|
1323
|
+
? (record as { github?: { pushedAt?: string | null } }).github?.pushedAt
|
|
1324
|
+
: (record as { publishedAt?: string | null } | undefined)?.publishedAt);
|
|
1325
|
+
const stackIds = stackProp
|
|
1326
|
+
? [stackProp]
|
|
1327
|
+
: isProject
|
|
1328
|
+
? projectStackIds(record as { stack?: string; stacks?: string[] })
|
|
1329
|
+
: [];
|
|
1330
|
+
|
|
1331
|
+
const starsLabel = formatStars(stars);
|
|
1332
|
+
const updatedLabel = formatRelative(pushedAt);
|
|
1333
|
+
|
|
1334
|
+
const hasStars = starsLabel !== null && typeof stars === 'number' && stars > 0;
|
|
1335
|
+
const hasUpdated = Boolean(pushedAt);
|
|
1336
|
+
|
|
1337
|
+
// `<a>`-in-`<a>` is invalid HTML, and the `owner/repo` line is its own
|
|
1338
|
+
// link to the repository — so any card that shows one renders as an
|
|
1339
|
+
// `<article>` whose title link is stretched over the whole card.
|
|
1340
|
+
const isArticle = Boolean(repoHref && owner && repo);
|
|
1341
|
+
|
|
1342
|
+
const visibleStacks = stackIds.slice(0, 4);
|
|
1343
|
+
const stackOverflow = stackIds.length - visibleStacks.length;
|
|
1344
|
+
const hasStacks = visibleStacks.length > 0;
|
|
1345
|
+
|
|
1346
|
+
return {
|
|
1347
|
+
name,
|
|
1348
|
+
slug,
|
|
1349
|
+
description,
|
|
1350
|
+
initials,
|
|
1351
|
+
avatarUrl,
|
|
1352
|
+
repoHref,
|
|
1353
|
+
owner: owner ?? undefined,
|
|
1354
|
+
repo: repo ?? undefined,
|
|
1355
|
+
isArticle,
|
|
1356
|
+
stars,
|
|
1357
|
+
starsLabel,
|
|
1358
|
+
hasStars,
|
|
1359
|
+
pushedAt,
|
|
1360
|
+
updatedLabel,
|
|
1361
|
+
hasUpdated,
|
|
1362
|
+
stackIds,
|
|
1363
|
+
visibleStacks,
|
|
1364
|
+
stackOverflow,
|
|
1365
|
+
hasStacks,
|
|
1366
|
+
};
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1162
1369
|
export type DirectoryIndexModel = ReturnType<typeof getDirectoryIndexModel>;
|
|
1163
1370
|
export type DirectoryHomeModel = ReturnType<typeof getHomePageModel>;
|
|
1164
1371
|
export type SubmissionPageModel = ReturnType<typeof getSubmissionPageModel>;
|