@grove-dev/astro 0.5.4 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/index.d.ts +0 -1
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +0 -1
- package/dist/lib/index.js.map +1 -1
- package/dist/ui/button.d.ts +8 -0
- package/dist/ui/button.d.ts.map +1 -1
- package/dist/ui/button.js +8 -0
- package/dist/ui/button.js.map +1 -1
- package/package.json +2 -2
- package/src/components/DirectoryIndexClient.astro +351 -178
- package/src/components/Hero.astro +15 -11
- package/src/components/Pagination.astro +3 -3
- package/src/components/PoweredBy.astro +60 -0
- package/src/components/ProjectCard.astro +42 -25
- package/src/components/RecordHeader.astro +14 -9
- package/src/components/RefinePanel.astro +11 -2
- package/src/components/TableOfContents.astro +11 -12
- package/src/index.ts +8 -0
- package/src/layouts/BaseLayout.astro +14 -1
- package/src/layouts/Footer.astro +18 -2
- package/src/layouts/Header.astro +1 -1
- package/src/layouts/Seo.astro +64 -38
- package/src/lib/index.ts +0 -1
- package/src/server/collections.ts +96 -67
- package/src/server/index.ts +1 -0
- package/src/server/models.ts +456 -25
- package/src/server/seo.test.ts +123 -0
- package/src/server/seo.ts +141 -0
- package/src/styles.css +23 -6
- package/src/ui/FilterDrawer.astro +25 -5
- package/src/ui/SearchField.astro +58 -2
- package/src/ui/button.test.ts +1 -1
- package/src/ui/button.ts +9 -0
- package/dist/lib/load-collections.d.ts +0 -12
- package/dist/lib/load-collections.d.ts.map +0 -1
- package/dist/lib/load-collections.js +0 -34
- package/dist/lib/load-collections.js.map +0 -1
- package/src/lib/load-collections.ts +0 -33
package/src/server/models.ts
CHANGED
|
@@ -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 =
|
|
218
|
-
|
|
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
|
|
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
|
-
|
|
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 ${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"}.`,
|
|
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
|
-
|
|
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
|
|
342
|
-
description
|
|
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
|
|
374
|
-
description
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
481
|
-
url:
|
|
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
|
-
|
|
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
|
|
513
|
-
url:
|
|
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
|
-
|
|
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
|
|
526
|
-
url:
|
|
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
|
|
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 };
|