@grove-dev/astro 0.5.4 → 0.6.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 (41) hide show
  1. package/dist/index.d.ts.map +1 -1
  2. package/dist/index.js +8 -0
  3. package/dist/index.js.map +1 -1
  4. package/dist/lib/index.d.ts +0 -1
  5. package/dist/lib/index.d.ts.map +1 -1
  6. package/dist/lib/index.js +0 -1
  7. package/dist/lib/index.js.map +1 -1
  8. package/dist/ui/button.d.ts +8 -0
  9. package/dist/ui/button.d.ts.map +1 -1
  10. package/dist/ui/button.js +8 -0
  11. package/dist/ui/button.js.map +1 -1
  12. package/package.json +2 -2
  13. package/src/components/DirectoryIndexClient.astro +351 -178
  14. package/src/components/Hero.astro +15 -11
  15. package/src/components/Pagination.astro +3 -3
  16. package/src/components/PoweredBy.astro +60 -0
  17. package/src/components/ProjectCard.astro +42 -25
  18. package/src/components/RecordHeader.astro +14 -9
  19. package/src/components/RefinePanel.astro +11 -2
  20. package/src/components/TableOfContents.astro +11 -12
  21. package/src/index.ts +8 -0
  22. package/src/layouts/BaseLayout.astro +14 -1
  23. package/src/layouts/Footer.astro +18 -2
  24. package/src/layouts/Header.astro +1 -1
  25. package/src/layouts/Seo.astro +64 -38
  26. package/src/lib/index.ts +0 -1
  27. package/src/server/collections.ts +96 -67
  28. package/src/server/index.ts +1 -0
  29. package/src/server/models.ts +456 -25
  30. package/src/server/seo.test.ts +123 -0
  31. package/src/server/seo.ts +141 -0
  32. package/src/styles.css +23 -6
  33. package/src/ui/FilterDrawer.astro +25 -5
  34. package/src/ui/SearchField.astro +58 -2
  35. package/src/ui/button.test.ts +1 -1
  36. package/src/ui/button.ts +9 -0
  37. package/dist/lib/load-collections.d.ts +0 -12
  38. package/dist/lib/load-collections.d.ts.map +0 -1
  39. package/dist/lib/load-collections.js +0 -34
  40. package/dist/lib/load-collections.js.map +0 -1
  41. package/src/lib/load-collections.ts +0 -33
@@ -17,6 +17,7 @@ import {
17
17
  activeFilterChips,
18
18
  applySort,
19
19
  buildFacets,
20
+ collectionSchema,
20
21
  configuredFacetDefs,
21
22
  effectivePage,
22
23
  effectiveSort,
@@ -27,6 +28,11 @@ import {
27
28
  formatStars,
28
29
  getOwnerAndRepoFromRepoUrl,
29
30
  getOwnerAvatarUrl,
31
+ hasAnyFilter,
32
+ hrefForClearedFilters,
33
+ hrefForFilters,
34
+ pagePathHref,
35
+ paginate,
30
36
  projectStackIds,
31
37
  readingMetrics,
32
38
  readContentFile,
@@ -45,12 +51,27 @@ import {
45
51
  resourceBySlug,
46
52
  taxonomyLabel,
47
53
  } from "./directory.js";
54
+ import {
55
+ absoluteUrl,
56
+ breadcrumbs,
57
+ ogPath,
58
+ type PageSeo,
59
+ recordSeoDescriptor,
60
+ seoDescription,
61
+ seoTitle,
62
+ titleCaseFirst,
63
+ } from "./seo.js";
48
64
 
49
65
  export interface DirectorySiteConfig {
50
66
  name: string;
51
67
  tagline?: string;
52
68
  description?: string;
53
69
  repoUrl?: string;
70
+ /** Absolute site URL. `site-config.json` ships it as `siteUrl`;
71
+ * `url` is accepted for hand-built configs. Used to absolutize
72
+ * JSON-LD URLs (breadcrumbs, ItemList entries). */
73
+ siteUrl?: string;
74
+ url?: string;
54
75
  nav?: Array<{ label: string; href: string }>;
55
76
  browse?: {
56
77
  facets?: string[];
@@ -110,6 +131,13 @@ export interface DirectoryContributor {
110
131
  contributions?: number;
111
132
  }
112
133
 
134
+ /** Site URL used to absolutize JSON-LD links. The generated
135
+ * site-config always carries `siteUrl`; the fallback mirrors
136
+ * build-data's own default so both stay aligned. */
137
+ function siteUrlOf(site?: DirectorySiteConfig): string {
138
+ return (site?.siteUrl ?? site?.url ?? "https://example.com").replace(/\/$/, "");
139
+ }
140
+
113
141
  export function loadDirectoryContributors(root = process.cwd()): DirectoryContributor[] {
114
142
  const path = resolve(root, "data", "generated", "contributors.json");
115
143
  if (!existsSync(path)) return [];
@@ -214,8 +242,23 @@ export function getHomePageModel(site: DirectorySiteConfig) {
214
242
  const { stacks: allStacks, categories } = countTaxonomies();
215
243
  const stacks = allStacks.slice(0, 12);
216
244
  const contributors = loadDirectoryContributors();
217
- const description = site.description ??
218
- `A searchable directory of real ${plural} — organized by stack, category, platform, license, activity, and maturity.`;
245
+ const description = seoDescription(
246
+ site.description,
247
+ `A searchable directory of real ${plural} — organized by stack, category, platform, license, activity, and maturity.`,
248
+ );
249
+ // "{Site} — {tagline}", but never a dangling "{Site} —" when the
250
+ // tagline is unset, and never a tagline that pushes the title past
251
+ // the ~60-char display cap.
252
+ const tagline = (site.tagline ?? "").trim();
253
+ const title =
254
+ tagline && `${site.name} — ${tagline}`.length <= 65
255
+ ? `${site.name} — ${tagline}`
256
+ : site.name;
257
+ const seo: PageSeo = {
258
+ title,
259
+ description,
260
+ image: ogPath("home"),
261
+ };
219
262
 
220
263
  return {
221
264
  slug,
@@ -227,8 +270,9 @@ export function getHomePageModel(site: DirectorySiteConfig) {
227
270
  stacks,
228
271
  categories,
229
272
  contributors,
230
- title: `${site.name} — ${site.tagline ?? ""}`.trim(),
273
+ title,
231
274
  description,
275
+ seo,
232
276
  stats: {
233
277
  originalRepo: site.stats?.originalRepo ?? "",
234
278
  apps: site.stats?.totalApps ?? 0,
@@ -258,7 +302,16 @@ const TAXONOMY_KIND_FOR_DIMENSION = {
258
302
  licenses: "licenses",
259
303
  } as const;
260
304
 
261
- export function getDirectoryIndexModel(searchParams: URLSearchParams, site?: DirectorySiteConfig) {
305
+ /**
306
+ * @param options.page Route-supplied page number. The browse route is
307
+ * prerendered per page (`/projects/`, `/projects/2/`), so the page comes
308
+ * from the path, not from a query string the build can never see.
309
+ */
310
+ export function getDirectoryIndexModel(
311
+ searchParams: URLSearchParams,
312
+ site?: DirectorySiteConfig,
313
+ options: { page?: number } = {},
314
+ ) {
262
315
  const filters = filtersFromSearchParams(searchParams);
263
316
  // Taxonomy YAML owns option order: the generated arrays preserve
264
317
  // file/`order:` position, so their id sequence IS the display order.
@@ -306,8 +359,47 @@ export function getDirectoryIndexModel(searchParams: URLSearchParams, site?: Dir
306
359
  const sort = effectiveSort(filters);
307
360
  const sorted = applySort(filterRecords(items, filters), sort);
308
361
  const pageCount = totalPages(sorted.length);
309
- const page = Math.min(effectivePage(filters), pageCount);
362
+ const page = Math.min(Math.max(1, options.page ?? effectivePage(filters)), pageCount);
363
+ const pathPrefix = `/${site?.blueprintConfig?.routeSlug ?? "projects"}`;
364
+ const siteUrl = siteUrlOf(site);
365
+ const siteName = site?.name ?? "";
366
+ const pluralTitle = titleCaseFirst(plural);
367
+ const facetNames = defs.map((def) => def.label.toLowerCase()).join(", ");
368
+ const listItems = sorted.slice(0, 50).map((record) => {
369
+ const r = record as { slug: string; name?: string; title?: string; description?: string };
370
+ return {
371
+ url: absoluteUrl(siteUrl, `${pathPrefix}/${r.slug}/`),
372
+ name: r.name ?? r.title ?? r.slug,
373
+ ...(r.description ? { description: r.description } : {}),
374
+ };
375
+ });
376
+ const seo: PageSeo = {
377
+ title: seoTitle(`Browse ${pluralTitle}`, siteName),
378
+ // Build the page-aware description BEFORE seoDescription truncates,
379
+ // so the page suffix fits inside the 160-char cap rather than
380
+ // pushing the sentence over and getting clipped mid-word.
381
+ description: seoDescription(
382
+ undefined,
383
+ page > 1
384
+ ? `Browse page ${page} of ${pages} — ${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"}.`,
386
+ ),
387
+ image: ogPath("default"),
388
+ jsonLd: [
389
+ ...collectionSchema({
390
+ url: absoluteUrl(siteUrl, `${pathPrefix}/`),
391
+ name: `Browse ${pluralTitle}`,
392
+ description: `All ${plural} on ${siteName || "this site"}.`,
393
+ items: listItems,
394
+ crumbs: [
395
+ { url: `${siteUrl}/`, name: "Home" },
396
+ { url: absoluteUrl(siteUrl, `${pathPrefix}/`), name: pluralTitle },
397
+ ],
398
+ }),
399
+ ],
400
+ };
310
401
  return {
402
+ seo,
311
403
  items,
312
404
  total: items.length,
313
405
  filters,
@@ -316,9 +408,27 @@ export function getDirectoryIndexModel(searchParams: URLSearchParams, site?: Dir
316
408
  searchPlaceholder,
317
409
  sort,
318
410
  sorted,
411
+ /**
412
+ * The records this page actually renders. The page used to print
413
+ * every record and let the client hide the rest, so the DOM and the
414
+ * HTML both scaled with the whole directory.
415
+ */
416
+ pageItems: paginate(sorted, page),
319
417
  pages: pageCount,
320
418
  page,
321
- chips: activeFilterChips(filters),
419
+ pathPrefix,
420
+ /**
421
+ * Plain pages are real paths; anything the URL narrows or reorders
422
+ * stays a query. `sort` counts: every `/page/N/` document is built
423
+ * with the default sort, so paging out of `?sort=most-starred` onto
424
+ * one would re-sort the list mid-journey.
425
+ */
426
+ hrefForResultPage: (target: number) =>
427
+ hasAnyFilter(filters) || filters.sort
428
+ ? hrefForFilters({ ...filters, page: target }, pathPrefix)
429
+ : pagePathHref(pathPrefix, target),
430
+ clearFiltersHref: hrefForClearedFilters(filters, pathPrefix),
431
+ chips: activeFilterChips(filters, { taxonomy: site?.taxonomy, pathPrefix }),
322
432
  clientItemsJson: JSON.stringify(items).replace(/</g, "\\u003c"),
323
433
  };
324
434
  }
@@ -335,11 +445,54 @@ export function getContributorsPageModel(site: DirectorySiteConfig) {
335
445
  (b.contributions ?? 0) - (a.contributions ?? 0) || a.username.localeCompare(b.username)
336
446
  );
337
447
  const repo = site.repoUrl?.replace(/\/$/, "");
448
+ const siteUrl = siteUrlOf(site);
449
+ const title = seoTitle("Contributors", site.name);
450
+ const description = seoDescription(
451
+ undefined,
452
+ sorted.length > 0
453
+ ? `${sorted.length} people maintain ${site.name} — every entry is a file in a public repository, and these are the contributors behind it.`
454
+ : `The people behind ${site.name} — every entry is a file in a public repository, maintained through code, curation, and review.`,
455
+ );
456
+ const seo: PageSeo = {
457
+ title,
458
+ description,
459
+ image: ogPath("default"),
460
+ jsonLd: [
461
+ {
462
+ "@context": "https://schema.org",
463
+ "@type": ["CollectionPage", "WebPage"],
464
+ "@id": `${absoluteUrl(siteUrl, "contributors/")}#page`,
465
+ url: absoluteUrl(siteUrl, "contributors/"),
466
+ name: title,
467
+ description,
468
+ },
469
+ {
470
+ "@context": "https://schema.org",
471
+ "@type": "ItemList",
472
+ numberOfItems: sorted.length,
473
+ itemListElement: sorted.slice(0, 50).map((c, i) => ({
474
+ "@type": "ListItem",
475
+ position: i + 1,
476
+ item: {
477
+ "@type": "Person",
478
+ name: c.name ?? c.username,
479
+ ...(c.profileUrl ? { url: c.profileUrl } : {}),
480
+ ...(c.avatarUrl ? { image: c.avatarUrl } : {}),
481
+ },
482
+ })),
483
+ },
484
+ breadcrumbs(siteUrl, [
485
+ { path: "", name: "Home" },
486
+ { path: "contributors/", name: "Contributors" },
487
+ ]),
488
+ ],
489
+ };
338
490
  return {
339
491
  contributors: sorted,
340
492
  total: sorted.length,
341
- title: `Community — ${site.name}`,
342
- description: `The people who have contributed to ${site.name} through code, curation, and review.`,
493
+ title,
494
+ description,
495
+ seo,
343
496
  contributorsGraphHref: repo ? `${repo}/graphs/contributors` : null,
344
497
  // Surface the consumer's per-user contribution-count preference to
345
498
  // the consumer page so it can render quieter cards when the
@@ -368,10 +521,17 @@ export function getSubmissionPageModel(site: DirectorySiteConfig) {
368
521
  return match ? `${match[1]}/${match[2].replace(/\.git$/, "")}`.toLowerCase() : null;
369
522
  }).filter((value): value is string => Boolean(value));
370
523
 
524
+ const title = seoTitle(`Submit ${singular}`, site.name);
525
+ const description = seoDescription(
526
+ undefined,
527
+ `Submit a new ${singular} to ${site.name}. Every listing is a file in a public repository — open a pull request and it ships everywhere at once.`,
528
+ );
371
529
  return {
372
530
  singular,
373
- title: `Submit ${singular} - ${site.name}`,
374
- description: `Submit a new ${singular} to ${site.name}.`,
531
+ title,
532
+ description,
533
+ // Thin form wrapper — kept out of the index, so no OG/JSON-LD.
534
+ seo: { title, description, noindex: true } satisfies PageSeo,
375
535
  repoUrl: site.repoUrl ?? "https://github.com/tortuvshin/grove",
376
536
  copy: site.submission ?? {},
377
537
  fields: {
@@ -424,7 +584,27 @@ export function getRecordDetailModel(
424
584
  const isProject = record.kind === "project";
425
585
  const proj: ProjectRecord | null = project ?? null;
426
586
  const name = record.kind === "resource" ? record.title : record.name;
427
- const description = record.description ?? `${name} on ${site.name}`;
587
+ const singular = site.blueprintConfig?.labelSingular ?? itemLabel();
588
+ const categoryLabel = record.category ? taxonomyLabel("categories", record.category) : undefined;
589
+ // Fallback sentence when neither a curated summary nor a GitHub
590
+ // description is available. The noun follows the schema.org @type
591
+ // implied by `record.kind` so entities don't read as "open-source
592
+ // Database project" and resources use their subtype (article, book,
593
+ // etc.). Curated summary wins over `record.description` because the
594
+ // latter is usually GitHub-synced and noisy.
595
+ const fallbackSentence = recordFallbackSentence({
596
+ name,
597
+ kind: record.kind,
598
+ entityType: entity?.type,
599
+ resourceType: record.type,
600
+ categoryLabel,
601
+ singular,
602
+ siteName: site.name,
603
+ });
604
+ const description = seoDescription(
605
+ (record.summary && record.summary.trim()) || record.description,
606
+ fallbackSentence,
607
+ );
428
608
  const repoUrl = proj?.repoUrl ?? record.links?.github ?? "";
429
609
  const homepageUrl = record.links?.website ?? "";
430
610
  const stacks = projectStackIds(proj);
@@ -464,21 +644,25 @@ export function getRecordDetailModel(
464
644
  const languages = extras.github?.languages
465
645
  ? Object.entries(extras.github.languages).sort((a, b) => b[1] - a[1]).slice(0, 5)
466
646
  : [];
467
- const healthLabel = healthStatus ? statusDisplay(healthStatus) : null;
468
- const tags = record.tags ?? [];
469
- const tocBody = readContentFile(typeof record.content === "string" ? record.content : "");
647
+ const healthLabel = healthStatus ? statusDisplay(healthStatus) : null;
648
+ const tags = record.tags ?? [];
649
+ const tocBody = readContentFile(typeof record.content === "string" ? record.content : "");
470
650
 
471
- let jsonLd: Record<string, unknown>;
651
+ const siteUrl = siteUrlOf(site);
652
+ const pageUrl = absoluteUrl(siteUrl, `${routeSlug}/${recordSlug}/`);
653
+
654
+ let recordLd: Record<string, unknown>;
472
655
  if (isProject && proj) {
473
656
  const sameAs = [repoUrl, homepageUrl].filter(Boolean);
474
657
  const dateCreated = record.curation?.reviewedAt;
475
- jsonLd = {
658
+ recordLd = {
476
659
  "@context": "https://schema.org",
477
660
  "@type": "SoftwareSourceCode",
661
+ "@id": `${pageUrl}#record`,
478
662
  name,
479
663
  headline: name,
480
- description: record.description ?? undefined,
481
- url: repoUrl || homepageUrl || undefined,
664
+ description,
665
+ url: pageUrl,
482
666
  codeRepository: repoUrl || undefined,
483
667
  sameAs: sameAs.length ? sameAs : undefined,
484
668
  programmingLanguage: language ?? undefined,
@@ -504,32 +688,61 @@ const tocBody = readContentFile(typeof record.content === "string" ? record.cont
504
688
  const schemaTypes: Record<string, string> = {
505
689
  article: "Article", book: "Book", course: "Course", podcast: "PodcastSeries", video: "VideoObject",
506
690
  };
507
- jsonLd = {
691
+ recordLd = {
508
692
  "@context": "https://schema.org",
509
693
  "@type": schemaTypes[record.type] ?? "CreativeWork",
694
+ "@id": `${pageUrl}#record`,
510
695
  name,
511
696
  headline: name,
512
- description: record.description || undefined,
513
- url: homepageUrl || undefined,
697
+ description,
698
+ url: pageUrl,
699
+ ...(homepageUrl ? { sameAs: [homepageUrl] } : {}),
514
700
  author: record.author ? { "@type": "Person", name: record.author } : undefined,
515
701
  datePublished: record.publishedAt || undefined,
516
702
  keywords: record.tags?.length ? record.tags.join(", ") : undefined,
517
703
  isAccessibleForFree: true,
518
704
  };
519
705
  } else {
520
- jsonLd = {
706
+ recordLd = {
521
707
  "@context": "https://schema.org",
522
708
  "@type": entity?.type === "person" ? "Person" : "Organization",
709
+ "@id": `${pageUrl}#record`,
523
710
  name,
524
711
  headline: name,
525
- description: entity?.description || undefined,
526
- url: homepageUrl || undefined,
712
+ description,
713
+ url: pageUrl,
714
+ ...(homepageUrl ? { sameAs: [homepageUrl] } : {}),
527
715
  foundingDate: entity?.founded || undefined,
528
716
  location: entity?.location || undefined,
529
717
  keywords: entity?.tags?.length ? entity.tags.join(", ") : undefined,
530
718
  };
531
719
  }
532
720
 
721
+ // "<Name> — <descriptor> | <Site>": the descriptor gives the search
722
+ // snippet a reason to exist beyond the bare project name.
723
+ const descriptor = recordSeoDescriptor({
724
+ summary: record.summary,
725
+ ...(categoryLabel ? { categoryLabel } : {}),
726
+ singular,
727
+ });
728
+ const title = seoTitle(`${name} — ${descriptor}`, site.name);
729
+ const pluralTitle = titleCaseFirst(site.blueprintConfig?.labelPlural ?? itemLabelPlural());
730
+ const jsonLd = [
731
+ recordLd,
732
+ breadcrumbs(siteUrl, [
733
+ { path: "", name: "Home" },
734
+ { path: `${routeSlug}/`, name: pluralTitle },
735
+ { path: `${routeSlug}/${recordSlug}/`, name },
736
+ ]),
737
+ ];
738
+ const seo: PageSeo = {
739
+ title,
740
+ description,
741
+ image: ogPath("record", recordSlug),
742
+ imageAlt: `${name} — ${site.name}`,
743
+ jsonLd,
744
+ };
745
+
533
746
  return {
534
747
  slug: routeSlug,
535
748
  record,
@@ -537,8 +750,9 @@ const tocBody = readContentFile(typeof record.content === "string" ? record.cont
537
750
  entity,
538
751
  isProject,
539
752
  name,
540
- title: `${name} — ${site.name}`,
753
+ title,
541
754
  description,
755
+ seo,
542
756
  itemSingular: site.blueprintConfig?.labelSingular ?? record.kind,
543
757
  repoUrl,
544
758
  homepageUrl,
@@ -622,6 +836,36 @@ const tocBody = readContentFile(typeof record.content === "string" ? record.cont
622
836
  };
623
837
  }
624
838
 
839
+ /**
840
+ * Fallback meta description when neither a curated summary nor a
841
+ * GitHub description is available. Noun follows the schema.org
842
+ * @type implied by `kind` so an entity never reads as
843
+ * "an open-source Database project".
844
+ */
845
+ function recordFallbackSentence(input: {
846
+ name: string;
847
+ kind: "project" | "resource" | "entity";
848
+ entityType?: string;
849
+ resourceType?: string;
850
+ categoryLabel?: string;
851
+ singular: string;
852
+ siteName: string;
853
+ }): string {
854
+ const category = input.categoryLabel;
855
+ 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}`;
859
+ }
860
+ return `${input.name}, an open-source ${category ? `${category} ` : ""}organization ${listed}`;
861
+ }
862
+ if (input.kind === "resource") {
863
+ const type = input.resourceType ?? "resource";
864
+ return `${input.name}, a ${type}${category ? ` in ${category}` : ""} ${listed}`;
865
+ }
866
+ return `${input.name}, an open-source ${category ? `${category} ` : ""}${input.singular} ${listed}`;
867
+ }
868
+
625
869
  // ── Sidebar predicates ────────────────────────────────────────────
626
870
 
627
871
  /**
@@ -729,9 +973,196 @@ export function recordDetailPaths(site: DirectorySiteConfig) {
729
973
  }));
730
974
  }
731
975
 
976
+ // ── Taxonomy page models ──────────────────────────────────────────
977
+
978
+ export type TaxonomyPageKind = "categories" | "stacks" | "licenses";
979
+
980
+ const TAXONOMY_EYEBROW: Record<TaxonomyPageKind, string> = {
981
+ categories: "Category",
982
+ stacks: "Stack",
983
+ licenses: "License",
984
+ };
985
+
986
+ /**
987
+ * View-model for a taxonomy detail page (`/categories/<id>/`,
988
+ * `/stacks/<id>/`, `/licenses/<id>/`). Owns the record filtering the
989
+ * three pages used to inline, plus the full SEO block. The three
990
+ * title patterns are deliberately distinct so `/categories/python/`
991
+ * and `/stacks/python/` never emit duplicate titles:
992
+ *
993
+ * category: "Python projects on Open Apps"
994
+ * stack: "Projects built with Python on Open Apps"
995
+ * license: "MIT-licensed projects on Open Apps"
996
+ */
997
+ export function getTaxonomyPageModel(
998
+ kind: TaxonomyPageKind,
999
+ id: string,
1000
+ displayName: string,
1001
+ site: DirectorySiteConfig,
1002
+ ) {
1003
+ const plural = site.blueprintConfig?.labelPlural ?? itemLabelPlural();
1004
+ const filters: IndexFilters =
1005
+ kind === "categories"
1006
+ ? { categories: [id] }
1007
+ : kind === "stacks"
1008
+ ? { stacks: [id] }
1009
+ : { licenses: [id] };
1010
+ const records = filterRecords(items, filters);
1011
+ const count = records.length;
1012
+ const siteUrl = siteUrlOf(site);
1013
+ const routeSlug = site.blueprintConfig?.routeSlug ?? "projects";
1014
+ const pagePath = `${kind}/${id}/`;
1015
+
1016
+ // "MIT License" → "MIT" so the license title reads "MIT-licensed
1017
+ // projects", not "MIT License-licensed projects". The description
1018
+ // uses the same stripped form ("under the MIT license") so a single
1019
+ // displayName yields a consistent title and description — Google
1020
+ // flags title/description fragments that disagree on whether the
1021
+ // word "License" appears as low quality.
1022
+ const licenseLabel = displayName.replace(/\s+license$/i, "");
1023
+ const main =
1024
+ kind === "categories"
1025
+ ? `${displayName} ${plural} on ${site.name}`
1026
+ : kind === "stacks"
1027
+ ? `${titleCaseFirst(plural)} built with ${displayName} on ${site.name}`
1028
+ : `${licenseLabel}-licensed ${plural} on ${site.name}`;
1029
+ const description = seoDescription(
1030
+ undefined,
1031
+ kind === "categories"
1032
+ ? `${count} curated open-source ${plural} in the ${displayName} category on ${site.name}. Compare stars, activity, and licenses.`
1033
+ : kind === "stacks"
1034
+ ? `${count} curated open-source ${plural} built with ${displayName}, listed on ${site.name} with stars, activity, and license data.`
1035
+ : `${count} open-source ${plural} under the ${licenseLabel} license on ${site.name}.`,
1036
+ );
1037
+
1038
+ const crumbs: Array<{ path: string; name: string }> = [
1039
+ { path: "", name: "Home" },
1040
+ // Licenses have no index page in the scaffold, so their trail
1041
+ // goes straight from Home to the license itself.
1042
+ ...(kind === "licenses"
1043
+ ? []
1044
+ : [{ path: `${kind}/`, name: titleCaseFirst(kind) }]),
1045
+ { path: pagePath, name: displayName },
1046
+ ];
1047
+ const listItems = records.slice(0, 50).map((record) => {
1048
+ const r = record as { slug: string; name?: string; title?: string; description?: string };
1049
+ return {
1050
+ url: absoluteUrl(siteUrl, `${routeSlug}/${r.slug}/`),
1051
+ name: r.name ?? r.title ?? r.slug,
1052
+ ...(r.description ? { description: r.description } : {}),
1053
+ };
1054
+ });
1055
+ const seo: PageSeo = {
1056
+ // `main` already names the site, so seoTitle appends nothing —
1057
+ // it still runs for the length/whitespace normalization.
1058
+ title: seoTitle(main, site.name),
1059
+ description,
1060
+ image: ogPath(
1061
+ kind === "categories" ? "category" : kind === "stacks" ? "stack" : "license",
1062
+ id,
1063
+ ),
1064
+ imageAlt: `${displayName} — ${site.name}`,
1065
+ jsonLd: [
1066
+ ...collectionSchema({
1067
+ url: absoluteUrl(siteUrl, pagePath),
1068
+ name: main,
1069
+ description,
1070
+ items: listItems,
1071
+ crumbs: crumbs.map((c) => ({
1072
+ url: absoluteUrl(siteUrl, c.path),
1073
+ name: c.name,
1074
+ })),
1075
+ }),
1076
+ ],
1077
+ };
1078
+
1079
+ return {
1080
+ kind,
1081
+ id,
1082
+ displayName,
1083
+ eyebrow: TAXONOMY_EYEBROW[kind],
1084
+ records,
1085
+ count,
1086
+ seo,
1087
+ };
1088
+ }
1089
+
1090
+ /**
1091
+ * SEO block for the `/categories/` and `/stacks/` index pages.
1092
+ */
1093
+ export function getTaxonomyIndexSeo(
1094
+ kind: "categories" | "stacks",
1095
+ site: DirectorySiteConfig,
1096
+ ): PageSeo {
1097
+ const plural = site.blueprintConfig?.labelPlural ?? itemLabelPlural();
1098
+ const { categories, stacks } = countTaxonomies();
1099
+ const entries = kind === "categories" ? categories : stacks;
1100
+ const sample = entries.slice(0, 3).map((entry) => entry.name).join(", ");
1101
+ const siteUrl = siteUrlOf(site);
1102
+ const title = seoTitle(kind === "categories" ? "Categories" : "Stacks", site.name);
1103
+ const description = seoDescription(
1104
+ 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}` : ""}.`,
1108
+ );
1109
+ return {
1110
+ title,
1111
+ description,
1112
+ image: ogPath("default"),
1113
+ jsonLd: [
1114
+ ...collectionSchema({
1115
+ url: absoluteUrl(siteUrl, `${kind}/`),
1116
+ name: title,
1117
+ description,
1118
+ items: entries.map((entry) => ({
1119
+ url: absoluteUrl(siteUrl, `${kind}/${entry.slug}/`),
1120
+ name: entry.name,
1121
+ })),
1122
+ crumbs: [
1123
+ { url: `${siteUrl}/`, name: "Home" },
1124
+ { url: absoluteUrl(siteUrl, `${kind}/`), name: titleCaseFirst(kind) },
1125
+ ],
1126
+ }),
1127
+ ],
1128
+ };
1129
+ }
1130
+
1131
+ /**
1132
+ * SEO block for the static About page.
1133
+ */
1134
+ export function getAboutPageSeo(site: DirectorySiteConfig): PageSeo {
1135
+ const siteUrl = siteUrlOf(site);
1136
+ const title = seoTitle("About", site.name);
1137
+ const description = seoDescription(
1138
+ undefined,
1139
+ `About ${site.name}: ${site.tagline ?? site.description ?? "a file-first knowledge site."}`,
1140
+ );
1141
+ return {
1142
+ title,
1143
+ description,
1144
+ image: ogPath("default"),
1145
+ jsonLd: [
1146
+ {
1147
+ "@context": "https://schema.org",
1148
+ "@type": ["AboutPage", "WebPage"],
1149
+ "@id": `${absoluteUrl(siteUrl, "about/")}#page`,
1150
+ url: absoluteUrl(siteUrl, "about/"),
1151
+ name: title,
1152
+ description,
1153
+ },
1154
+ breadcrumbs(siteUrl, [
1155
+ { path: "", name: "Home" },
1156
+ { path: "about/", name: "About" },
1157
+ ]),
1158
+ ],
1159
+ };
1160
+ }
1161
+
732
1162
  export type DirectoryIndexModel = ReturnType<typeof getDirectoryIndexModel>;
733
1163
  export type DirectoryHomeModel = ReturnType<typeof getHomePageModel>;
734
1164
  export type SubmissionPageModel = ReturnType<typeof getSubmissionPageModel>;
735
1165
  export type ContributorsPageModel = ReturnType<typeof getContributorsPageModel>;
736
1166
  export type RecordDetailModel = NonNullable<ReturnType<typeof getRecordDetailModel>>;
1167
+ export type TaxonomyPageModel = ReturnType<typeof getTaxonomyPageModel>;
737
1168
  export type { IndexFilters };