@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.
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 +425 -218
  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
@@ -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 = rawScores && Object.values(rawScores).some((value) => typeof value === "number")
624
- ? rawScores
625
- : null;
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 } => typeof value === "number"
630
- ? { month: "", commits: value }
631
- : { month: value.month ?? "", commits: value.commits ?? 0 },
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: "README",
635
- contributing: "Contributing",
636
- security: "Security",
637
- issueTemplates: "Issue templates",
638
- pullRequestTemplate: "PR template",
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).sort((a, b) => b[1] - a[1]).slice(0, 5)
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 === "string" ? 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
- const dateCreated = record.curation?.reviewedAt;
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
- "@context": "https://schema.org",
660
- "@type": "SoftwareSourceCode",
661
- "@id": `${pageUrl}#record`,
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(", ") : undefined,
672
- keywords: record.tags?.length ? record.tags.join(", ") : undefined,
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: 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,
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 === "resource") {
727
+ } else if (record.kind === 'resource') {
688
728
  const schemaTypes: Record<string, string> = {
689
- article: "Article", book: "Book", course: "Course", podcast: "PodcastSeries", video: "VideoObject",
729
+ article: 'Article',
730
+ book: 'Book',
731
+ course: 'Course',
732
+ podcast: 'PodcastSeries',
733
+ video: 'VideoObject',
690
734
  };
691
735
  recordLd = {
692
- "@context": "https://schema.org",
693
- "@type": schemaTypes[record.type] ?? "CreativeWork",
694
- "@id": `${pageUrl}#record`,
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 ? { "@type": "Person", name: record.author } : undefined,
744
+ author: record.author ? { '@type': 'Person', name: record.author } : undefined,
701
745
  datePublished: record.publishedAt || undefined,
702
- keywords: record.tags?.length ? record.tags.join(", ") : undefined,
746
+ keywords: record.tags?.length ? record.tags.join(', ') : undefined,
703
747
  isAccessibleForFree: true,
704
748
  };
705
749
  } else {
706
750
  recordLd = {
707
- "@context": "https://schema.org",
708
- "@type": entity?.type === "person" ? "Person" : "Organization",
709
- "@id": `${pageUrl}#record`,
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(", ") : undefined,
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: "", name: "Home" },
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("record", recordSlug),
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.replace(/[^a-z0-9]/gi, "").slice(0, 2).toUpperCase(),
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(1, languages.reduce((sum, [, bytes]) => sum + bytes, 0)),
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: "project" | "resource" | "entity";
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 === "entity") {
857
- if (input.entityType === "person") {
858
- return `${input.name}${category ? `, a ${category.toLowerCase()} contributor` : ""} ${listed}`;
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} ` : ""}organization ${listed}`;
917
+ return `${input.name}, an open-source ${category ? `${category} ` : ''}organization ${listed}`;
861
918
  }
862
- if (input.kind === "resource") {
863
- const type = input.resourceType ?? "resource";
864
- return `${input.name}, a ${type}${category ? ` in ${category}` : ""} ${listed}`;
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} ` : ""}${input.singular} ${listed}`;
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: "fresh" | "ok" | "low" | "dead" } | null {
951
+ }): { label: string; tone: 'fresh' | 'ok' | 'low' | 'dead' } | null {
895
952
  if (input.isArchived || input.isDisabled) {
896
- return { label: "Archived", tone: "dead" };
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: "Very active", tone: "fresh" };
902
- if (recentCommits >= 10) return { label: "Active", tone: "ok" };
903
- if (recentCommits > 0) return { label: "Maintained", tone: "ok" };
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
- (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" };
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 === "object" &&
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
- hasRepo ||
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
- showSource: !!input.reviewedAt,
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 ?? "items";
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 = "categories" | "stacks" | "licenses";
1030
+ export type TaxonomyPageKind = 'categories' | 'stacks' | 'licenses';
979
1031
 
980
1032
  const TAXONOMY_EYEBROW: Record<TaxonomyPageKind, string> = {
981
- categories: "Category",
982
- stacks: "Stack",
983
- licenses: "License",
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 === "categories"
1057
+ kind === 'categories'
1006
1058
  ? { categories: [id] }
1007
- : kind === "stacks"
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 ?? "projects";
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 === "categories"
1076
+ kind === 'categories'
1025
1077
  ? `${displayName} ${plural} on ${site.name}`
1026
- : kind === "stacks"
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 === "categories"
1083
+ kind === 'categories'
1032
1084
  ? `${count} curated open-source ${plural} in the ${displayName} category on ${site.name}. Compare stars, activity, and licenses.`
1033
- : kind === "stacks"
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: "", name: "Home" },
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 === "licenses"
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: "categories" | "stacks",
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 === "categories" ? categories : stacks;
1100
- const sample = entries.slice(0, 3).map((entry) => entry.name).join(", ");
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 === "categories" ? "Categories" : "Stacks", site.name);
1152
+ const title = seoTitle(kind === 'categories' ? 'Categories' : 'Stacks', site.name);
1103
1153
  const description = seoDescription(
1104
1154
  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}` : ""}.`,
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("default"),
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: "Home" },
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("About", site.name);
1186
+ const title = seoTitle('About', site.name);
1137
1187
  const description = seoDescription(
1138
1188
  undefined,
1139
- `About ${site.name}: ${site.tagline ?? site.description ?? "a file-first knowledge site."}`,
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("default"),
1194
+ image: ogPath('default'),
1145
1195
  jsonLd: [
1146
1196
  {
1147
- "@context": "https://schema.org",
1148
- "@type": ["AboutPage", "WebPage"],
1149
- "@id": `${absoluteUrl(siteUrl, "about/")}#page`,
1150
- url: absoluteUrl(siteUrl, "about/"),
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: "", name: "Home" },
1156
- { path: "about/", name: "About" },
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>;