@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.
Files changed (110) hide show
  1. package/dist/index.d.ts +3 -3
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +22 -33
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/display.d.ts +1 -1
  6. package/dist/lib/display.js +1 -1
  7. package/dist/lib/format.d.ts +1 -1
  8. package/dist/lib/format.js +1 -1
  9. package/dist/lib/icon-kinds.d.ts +1 -1
  10. package/dist/lib/icon-kinds.js +43 -43
  11. package/dist/lib/icon-registry.d.ts +2 -2
  12. package/dist/lib/icon-registry.js +23 -23
  13. package/dist/lib/index.d.ts +9 -9
  14. package/dist/lib/index.d.ts.map +1 -1
  15. package/dist/lib/index.js +9 -9
  16. package/dist/lib/index.js.map +1 -1
  17. package/dist/lib/lenses.d.ts +1 -1
  18. package/dist/lib/lenses.js +1 -1
  19. package/dist/lib/packaged-icons.d.ts +1 -1
  20. package/dist/lib/packaged-icons.js +4 -4
  21. package/dist/lib/repo.d.ts +1 -1
  22. package/dist/lib/repo.js +1 -1
  23. package/dist/lib/search.d.ts +1 -1
  24. package/dist/lib/search.js +1 -1
  25. package/dist/lib/taxonomy-counts.d.ts +1 -1
  26. package/dist/lib/taxonomy-counts.js +1 -1
  27. package/package.json +7 -17
  28. package/src/index.ts +23 -38
  29. package/src/lib/display.ts +1 -1
  30. package/src/lib/format.ts +1 -1
  31. package/src/lib/icon-kinds.ts +44 -44
  32. package/src/lib/icon-registry.ts +25 -25
  33. package/src/lib/index.ts +9 -9
  34. package/src/lib/lenses.ts +1 -1
  35. package/src/lib/packaged-icons.ts +8 -8
  36. package/src/lib/repo.ts +1 -1
  37. package/src/lib/search.ts +1 -1
  38. package/src/lib/taxonomy-counts.ts +1 -1
  39. package/src/server/collections.ts +53 -42
  40. package/src/server/contrast.ts +14 -9
  41. package/src/server/directory.ts +22 -17
  42. package/src/server/index.ts +3 -2
  43. package/src/server/models.ts +404 -213
  44. package/src/server/seo.ts +34 -31
  45. package/src/styles.css +235 -91
  46. package/dist/layouts/index.d.ts +0 -25
  47. package/dist/layouts/index.d.ts.map +0 -1
  48. package/dist/layouts/index.js +0 -25
  49. package/dist/layouts/index.js.map +0 -1
  50. package/dist/ui/button.d.ts +0 -53
  51. package/dist/ui/button.d.ts.map +0 -1
  52. package/dist/ui/button.js +0 -90
  53. package/dist/ui/button.js.map +0 -1
  54. package/src/components/CardGrid.astro +0 -41
  55. package/src/components/CardIcon.astro +0 -67
  56. package/src/components/CategoryGrid.astro +0 -114
  57. package/src/components/CollectionCard.astro +0 -56
  58. package/src/components/CollectionIndex.astro +0 -102
  59. package/src/components/CollectionPage.astro +0 -128
  60. package/src/components/CollectionRow.astro +0 -81
  61. package/src/components/CollectionTeaser.astro +0 -47
  62. package/src/components/ContributorsGrid.astro +0 -143
  63. package/src/components/DirectoryIndexClient.astro +0 -510
  64. package/src/components/EditorialSummary.astro +0 -58
  65. package/src/components/FilterGroupMenu.astro +0 -69
  66. package/src/components/FilterOptions.astro +0 -49
  67. package/src/components/FinalCta.astro +0 -43
  68. package/src/components/Hero.astro +0 -243
  69. package/src/components/Icon.astro +0 -191
  70. package/src/components/IndexRow.astro +0 -37
  71. package/src/components/LanguageBreakdown.astro +0 -67
  72. package/src/components/MarkdownBody.astro +0 -37
  73. package/src/components/OriginalCollection.astro +0 -117
  74. package/src/components/Pagination.astro +0 -50
  75. package/src/components/PoweredBy.astro +0 -60
  76. package/src/components/ProjectCard.astro +0 -321
  77. package/src/components/RecordHeader.astro +0 -221
  78. package/src/components/RecordSection.astro +0 -128
  79. package/src/components/RecordSidebar.astro +0 -387
  80. package/src/components/RefinePanel.astro +0 -168
  81. package/src/components/SmartLensTabs.astro +0 -81
  82. package/src/components/StackGrid.astro +0 -151
  83. package/src/components/StackPlatformChips.astro +0 -73
  84. package/src/components/SubmissionClient.astro +0 -254
  85. package/src/components/TableOfContents.astro +0 -140
  86. package/src/components/WhyThisExists.astro +0 -116
  87. package/src/layouts/BaseLayout.astro +0 -415
  88. package/src/layouts/Container.astro +0 -18
  89. package/src/layouts/Footer.astro +0 -231
  90. package/src/layouts/Header.astro +0 -214
  91. package/src/layouts/SectionHeader.astro +0 -43
  92. package/src/layouts/Seo.astro +0 -171
  93. package/src/layouts/ThemeToggle.astro +0 -118
  94. package/src/layouts/astro-shim.d.ts +0 -29
  95. package/src/layouts/index.ts +0 -24
  96. package/src/server/collections.test.ts +0 -212
  97. package/src/server/contrast.test.ts +0 -82
  98. package/src/server/directory.test.ts +0 -177
  99. package/src/server/github-repo.test.ts +0 -88
  100. package/src/server/models-home.test.ts +0 -101
  101. package/src/server/models.test.ts +0 -135
  102. package/src/server/seo.test.ts +0 -123
  103. package/src/ui/Badge.astro +0 -38
  104. package/src/ui/Button.astro +0 -42
  105. package/src/ui/EmptyState.astro +0 -38
  106. package/src/ui/FilterDrawer.astro +0 -124
  107. package/src/ui/PageHeader.astro +0 -39
  108. package/src/ui/SearchField.astro +0 -106
  109. package/src/ui/button.test.ts +0 -54
  110. package/src/ui/button.ts +0 -107
@@ -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 "node:fs";
8
- import { resolve } from "node:path";
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 "@grove-dev/core";
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
- } from "@grove-dev/core";
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 "./directory.js";
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 "./seo.js";
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 ?? "https://example.com").replace(/\/$/, "");
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, "data", "generated", "contributors.json");
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, "utf8")) as {
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) categoryCounts.set(record.category, (categoryCounts.get(record.category) ?? 0) + 1);
168
- if (record.kind === "project") {
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, "stacks"),
183
- categories: toEntries(categoryCounts, "categories"),
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 ?? "projects";
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 === "project");
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: ["hot"] }), "most-starred").slice(0, 6);
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, "most-starred")
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
- "recently-added",
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: ["mature"] }).filter((r) => !recentSlugs.has(r.slug)),
237
- "most-starred",
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 ?? "").trim();
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("home"),
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: "stacks",
299
- platforms: "platforms",
300
- categories: "categories",
301
- tags: "topics",
302
- licenses: "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("stacks") ? labeled("stacks") : [],
339
- platforms: enabled.has("platforms") ? labeled("platforms") : [],
340
- categories: enabled.has("categories") ? labeled("categories") : [],
341
- tags: enabled.has("tags") ? labeled("tags") : [],
342
- licenses: enabled.has("licenses") ? labeled("licenses") : [],
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 ?? "projects"}`;
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 || "this site"}, filtered by ${facetNames || "category and stack"}.`
385
- : `Search and filter ${items.length} curated ${plural} on ${siteName || "this site"} — by ${facetNames || "category and stack"}.`,
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("default"),
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 || "this site"}.`,
395
+ description: `All ${plural} on ${siteName || 'this site'}.`,
393
396
  items: listItems,
394
397
  crumbs: [
395
- { url: `${siteUrl}/`, name: "Home" },
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, "\\u003c"),
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("[bot]"));
444
- const sorted = [...human].sort((a, b) =>
445
- (b.contributions ?? 0) - (a.contributions ?? 0) || a.username.localeCompare(b.username)
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("Contributors", site.name);
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("default"),
463
+ image: ogPath('default'),
460
464
  jsonLd: [
461
465
  {
462
- "@context": "https://schema.org",
463
- "@type": ["CollectionPage", "WebPage"],
464
- "@id": `${absoluteUrl(siteUrl, "contributors/")}#page`,
465
- url: absoluteUrl(siteUrl, "contributors/"),
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
- "@context": "https://schema.org",
471
- "@type": "ItemList",
474
+ '@context': 'https://schema.org',
475
+ '@type': 'ItemList',
472
476
  numberOfItems: sorted.length,
473
477
  itemListElement: sorted.slice(0, 50).map((c, i) => ({
474
- "@type": "ListItem",
478
+ '@type': 'ListItem',
475
479
  position: i + 1,
476
480
  item: {
477
- "@type": "Person",
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: "", name: "Home" },
486
- { path: "contributors/", name: "Contributors" },
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: "category" | "stack") =>
508
- [...new Set(fullProjects.map((record) => String(record[key] ?? "")).filter(Boolean))]
509
- .sort((a, b) => a.localeCompare(b));
510
- const categories = (site.taxonomy?.categories?.length
511
- ? site.taxonomy.categories.map(({ id }) => id)
512
- : values("category"))
513
- .map((id) => ({ id, label: taxonomyLabel("categories", id) }));
514
- const stacks = (site.taxonomy?.stacks?.length
515
- ? site.taxonomy.stacks.map(({ id }) => id)
516
- : values("stack"))
517
- .map((id) => ({ id, label: taxonomyLabel("stacks", id) }));
518
- const existingFullNames = fullProjects.map((record) => {
519
- const url = String(record.repoUrl ?? record.links?.github ?? "");
520
- const match = url.match(/github\.com\/([^/]+)\/([^/?#]+)/i);
521
- return match ? `${match[1]}/${match[2].replace(/\.git$/, "")}`.toLowerCase() : null;
522
- }).filter((value): value is string => Boolean(value));
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 ?? "https://github.com/tortuvshin/grove",
542
+ repoUrl: site.repoUrl ?? 'https://github.com/tortuvshin/grove',
536
543
  copy: site.submission ?? {},
537
544
  fields: {
538
- category: enabled.has("categories"),
539
- stack: enabled.has("stacks"),
540
- platforms: enabled.has("platforms"),
541
- tags: enabled.has("tags"),
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("licenses"),
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: site.taxonomy?.platforms?.map(({ id, name }) => ({ id, label: name })) ??
551
- ["ios", "android", "web", "macos", "windows", "linux"].map((id) => ({ id, label: taxonomyLabel("platforms", id) })),
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 ?? "projects",
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 === "project";
595
+ const isProject = record.kind === 'project';
585
596
  const proj: ProjectRecord | null = project ?? null;
586
- const name = record.kind === "resource" ? record.title : record.name;
597
+ const name = record.kind === 'resource' ? record.title : record.name;
587
598
  const singular = site.blueprintConfig?.labelSingular ?? itemLabel();
588
- const categoryLabel = record.category ? taxonomyLabel("categories", record.category) : undefined;
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 = rawScores && Object.values(rawScores).some((value) => typeof value === "number")
624
- ? rawScores
625
- : null;
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 } => typeof value === "number"
630
- ? { month: "", commits: value }
631
- : { month: value.month ?? "", commits: value.commits ?? 0 },
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: "README",
635
- contributing: "Contributing",
636
- security: "Security",
637
- issueTemplates: "Issue templates",
638
- pullRequestTemplate: "PR template",
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).sort((a, b) => b[1] - a[1]).slice(0, 5)
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 === "string" ? 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
- "@context": "https://schema.org",
660
- "@type": "SoftwareSourceCode",
661
- "@id": `${pageUrl}#record`,
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(", ") : undefined,
672
- keywords: record.tags?.length ? record.tags.join(", ") : undefined,
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: stars > 0 ? {
676
- "@type": "InteractionCounter",
677
- interactionType: { "@type": "LikeAction" },
678
- userInteractionCount: stars,
679
- } : undefined,
680
- author: ownerRepo ? {
681
- "@type": "Organization",
682
- name: ownerRepo.owner,
683
- url: `https://github.com/${ownerRepo.owner}`,
684
- } : undefined,
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 === "resource") {
720
+ } else if (record.kind === 'resource') {
688
721
  const schemaTypes: Record<string, string> = {
689
- article: "Article", book: "Book", course: "Course", podcast: "PodcastSeries", video: "VideoObject",
722
+ article: 'Article',
723
+ book: 'Book',
724
+ course: 'Course',
725
+ podcast: 'PodcastSeries',
726
+ video: 'VideoObject',
690
727
  };
691
728
  recordLd = {
692
- "@context": "https://schema.org",
693
- "@type": schemaTypes[record.type] ?? "CreativeWork",
694
- "@id": `${pageUrl}#record`,
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 ? { "@type": "Person", name: record.author } : undefined,
737
+ author: record.author ? { '@type': 'Person', name: record.author } : undefined,
701
738
  datePublished: record.publishedAt || undefined,
702
- keywords: record.tags?.length ? record.tags.join(", ") : undefined,
739
+ keywords: record.tags?.length ? record.tags.join(', ') : undefined,
703
740
  isAccessibleForFree: true,
704
741
  };
705
742
  } else {
706
743
  recordLd = {
707
- "@context": "https://schema.org",
708
- "@type": entity?.type === "person" ? "Person" : "Organization",
709
- "@id": `${pageUrl}#record`,
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(", ") : undefined,
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: "", name: "Home" },
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("record", recordSlug),
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.replace(/[^a-z0-9]/gi, "").slice(0, 2).toUpperCase(),
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(1, languages.reduce((sum, [, bytes]) => sum + bytes, 0)),
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: "project" | "resource" | "entity";
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 === "entity") {
857
- if (input.entityType === "person") {
858
- return `${input.name}${category ? `, a ${category.toLowerCase()} contributor` : ""} ${listed}`;
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} ` : ""}organization ${listed}`;
906
+ return `${input.name}, an open-source ${category ? `${category} ` : ''}organization ${listed}`;
861
907
  }
862
- if (input.kind === "resource") {
863
- const type = input.resourceType ?? "resource";
864
- return `${input.name}, a ${type}${category ? ` in ${category}` : ""} ${listed}`;
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} ` : ""}${input.singular} ${listed}`;
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: "fresh" | "ok" | "low" | "dead" } | null {
940
+ }): { label: string; tone: 'fresh' | 'ok' | 'low' | 'dead' } | null {
895
941
  if (input.isArchived || input.isDisabled) {
896
- return { label: "Archived", tone: "dead" };
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: "Very active", tone: "fresh" };
902
- if (recentCommits >= 10) return { label: "Active", tone: "ok" };
903
- if (recentCommits > 0) return { label: "Maintained", tone: "ok" };
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
- (Date.now() - new Date(input.pushedAt).getTime()) / 86_400_000;
907
- if (days < 30) return { label: "Active", tone: "ok" };
908
- if (days < 180) return { label: "Maintained", tone: "ok" };
909
- if (days < 365) return { label: "Low activity", tone: "low" };
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 === "object" &&
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
- hasRepo ||
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 ?? "items";
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 = "categories" | "stacks" | "licenses";
1014
+ export type TaxonomyPageKind = 'categories' | 'stacks' | 'licenses';
979
1015
 
980
1016
  const TAXONOMY_EYEBROW: Record<TaxonomyPageKind, string> = {
981
- categories: "Category",
982
- stacks: "Stack",
983
- licenses: "License",
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 === "categories"
1041
+ kind === 'categories'
1006
1042
  ? { categories: [id] }
1007
- : kind === "stacks"
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 ?? "projects";
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 === "categories"
1060
+ kind === 'categories'
1025
1061
  ? `${displayName} ${plural} on ${site.name}`
1026
- : kind === "stacks"
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 === "categories"
1067
+ kind === 'categories'
1032
1068
  ? `${count} curated open-source ${plural} in the ${displayName} category on ${site.name}. Compare stars, activity, and licenses.`
1033
- : kind === "stacks"
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: "", name: "Home" },
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 === "licenses"
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: "categories" | "stacks",
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 === "categories" ? categories : stacks;
1100
- const sample = entries.slice(0, 3).map((entry) => entry.name).join(", ");
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 === "categories" ? "Categories" : "Stacks", site.name);
1136
+ const title = seoTitle(kind === 'categories' ? 'Categories' : 'Stacks', site.name);
1103
1137
  const description = seoDescription(
1104
1138
  undefined,
1105
- kind === "categories"
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("default"),
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: "Home" },
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("About", site.name);
1170
+ const title = seoTitle('About', site.name);
1137
1171
  const description = seoDescription(
1138
1172
  undefined,
1139
- `About ${site.name}: ${site.tagline ?? site.description ?? "a file-first knowledge site."}`,
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("default"),
1178
+ image: ogPath('default'),
1145
1179
  jsonLd: [
1146
1180
  {
1147
- "@context": "https://schema.org",
1148
- "@type": ["AboutPage", "WebPage"],
1149
- "@id": `${absoluteUrl(siteUrl, "about/")}#page`,
1150
- url: absoluteUrl(siteUrl, "about/"),
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: "", name: "Home" },
1156
- { path: "about/", name: "About" },
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>;