@grove-dev/astro 0.4.1 → 0.5.0-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/src/components/EditorialSummary.astro +57 -0
- package/src/components/LanguageBreakdown.astro +63 -0
- package/src/components/MarkdownBody.astro +37 -0
- package/src/components/RecordHeader.astro +156 -0
- package/src/components/RecordSidebar.astro +329 -0
- package/src/components/TableOfContents.astro +115 -0
- package/src/server/directory.ts +311 -65
- package/src/server/models.ts +155 -3
- package/src/styles.css +485 -59
package/src/server/models.ts
CHANGED
|
@@ -6,13 +6,20 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { existsSync, readFileSync } from "node:fs";
|
|
8
8
|
import { resolve } from "node:path";
|
|
9
|
-
import type {
|
|
9
|
+
import type {
|
|
10
|
+
IndexFilters,
|
|
11
|
+
IndexRecord,
|
|
12
|
+
ProjectRecord,
|
|
13
|
+
ReadingMetrics,
|
|
14
|
+
TocEntry,
|
|
15
|
+
} from "@grove-dev/core";
|
|
10
16
|
import {
|
|
11
17
|
activeFilterChips,
|
|
12
18
|
applySort,
|
|
13
19
|
buildFacets,
|
|
14
20
|
effectivePage,
|
|
15
21
|
effectiveSort,
|
|
22
|
+
extractToc,
|
|
16
23
|
filterRecords,
|
|
17
24
|
filtersFromSearchParams,
|
|
18
25
|
formatRelative,
|
|
@@ -20,6 +27,8 @@ import {
|
|
|
20
27
|
getOwnerAndRepoFromRepoUrl,
|
|
21
28
|
getOwnerAvatarUrl,
|
|
22
29
|
projectStackIds,
|
|
30
|
+
readingMetrics,
|
|
31
|
+
readContentFile,
|
|
23
32
|
statusDisplay,
|
|
24
33
|
totalPages,
|
|
25
34
|
} from "@grove-dev/core";
|
|
@@ -295,6 +304,8 @@ export function getRecordDetailModel(
|
|
|
295
304
|
const language = github?.language ?? null;
|
|
296
305
|
const licenseSpdx = github?.license?.spdx_id ?? null;
|
|
297
306
|
const pushedAt = github?.pushed_at ?? null;
|
|
307
|
+
const isArchived = !!github?.archived;
|
|
308
|
+
const isDisabled = !!github?.disabled;
|
|
298
309
|
const ownerRepo = repoUrl ? getOwnerAndRepoFromRepoUrl(repoUrl) : null;
|
|
299
310
|
const avatarSrc = proj?.logoUrl ?? (ownerRepo ? getOwnerAvatarUrl(ownerRepo.owner, 80) : null);
|
|
300
311
|
const rawScores = proj?.scores;
|
|
@@ -322,6 +333,9 @@ export function getRecordDetailModel(
|
|
|
322
333
|
const languages = extras.github?.languages
|
|
323
334
|
? Object.entries(extras.github.languages).sort((a, b) => b[1] - a[1]).slice(0, 5)
|
|
324
335
|
: [];
|
|
336
|
+
const healthLabel = healthStatus ? statusDisplay(healthStatus) : null;
|
|
337
|
+
const tags = record.tags ?? [];
|
|
338
|
+
const tocBody = readContentFile(typeof record.content === "string" ? record.content : "");
|
|
325
339
|
|
|
326
340
|
let jsonLd: Record<string, unknown>;
|
|
327
341
|
if (isProject && proj) {
|
|
@@ -416,18 +430,156 @@ export function getRecordDetailModel(
|
|
|
416
430
|
distribution: proj?.distribution?.channels ?? [],
|
|
417
431
|
scores,
|
|
418
432
|
curationLabels: record.curation?.labels ?? [],
|
|
419
|
-
tags
|
|
420
|
-
healthLabel
|
|
433
|
+
tags,
|
|
434
|
+
healthLabel,
|
|
421
435
|
contentHtml: isProject ? getContentHtml(recordSlug) : null,
|
|
422
436
|
monthlyCommits,
|
|
423
437
|
maxMonthlyCommits: Math.max(1, ...monthlyCommits.map((item) => item.commits)),
|
|
424
438
|
contributionSignals,
|
|
425
439
|
languages,
|
|
426
440
|
totalLanguageBytes: Math.max(1, languages.reduce((sum, [, bytes]) => sum + bytes, 0)),
|
|
441
|
+
// ── Sidebar-shape fields ────────────────────────────────
|
|
442
|
+
// Read once at model-build time so pages don't repeat the
|
|
443
|
+
// "is there data for this card?" logic.
|
|
444
|
+
activityBadge: computeActivityBadge({
|
|
445
|
+
pushedAt,
|
|
446
|
+
monthlyCommits,
|
|
447
|
+
isArchived,
|
|
448
|
+
isDisabled,
|
|
449
|
+
}),
|
|
450
|
+
sidebar: computeSidebarVisibility({
|
|
451
|
+
language,
|
|
452
|
+
licenseSpdx,
|
|
453
|
+
repo: github,
|
|
454
|
+
pushedAt,
|
|
455
|
+
isArchived,
|
|
456
|
+
healthLabel,
|
|
457
|
+
stacks,
|
|
458
|
+
platforms,
|
|
459
|
+
tags,
|
|
460
|
+
category: record.category,
|
|
461
|
+
contributionSignals,
|
|
462
|
+
reviewedAt: record.curation?.reviewedAt,
|
|
463
|
+
}),
|
|
464
|
+
// ── Body-derived fields ──────────────────────────────────
|
|
465
|
+
// Both depend on the sidecar Markdown; the body is re-read
|
|
466
|
+
// here even though it's also read for `contentHtml` upstream
|
|
467
|
+
// because the TOC + reading-time want the *body* (frontmatter
|
|
468
|
+
// stripped), not the HTML. Re-reading is cheap and keeps the
|
|
469
|
+
// pipeline self-documenting.
|
|
470
|
+
toc: tocBody ? extractToc(tocBody.body, { maxDepth: 2 }) : [],
|
|
471
|
+
readingMetrics: tocBody ? readingMetrics(tocBody.body) : { wordCount: 0, minutes: 1 },
|
|
427
472
|
jsonLd,
|
|
428
473
|
};
|
|
429
474
|
}
|
|
430
475
|
|
|
476
|
+
// ── Sidebar predicates ────────────────────────────────────────────
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Tone + label for the activity badge that appears in the header.
|
|
480
|
+
*
|
|
481
|
+
* Rules (intentionally explicit so a reader can predict the badge
|
|
482
|
+
* without consulting a weighting formula):
|
|
483
|
+
*
|
|
484
|
+
* - Archived or disabled repo → "Archived"
|
|
485
|
+
* - Recent monthly-commit total ≥ 50 → "Very active"
|
|
486
|
+
* - ≥ 10 recent commits, or last push < 30d ago → "Active"
|
|
487
|
+
* - ≥ 1 commit, or last push < 180d ago → "Maintained"
|
|
488
|
+
* - last push 180d–365d → "Low activity"
|
|
489
|
+
* - last push > 365d → "Stale"
|
|
490
|
+
* - no push data and no commit data → null
|
|
491
|
+
*
|
|
492
|
+
* Returns `null` when neither GitHub push nor monthly commits are
|
|
493
|
+
* present (e.g. an un-synced record), so the badge simply doesn't
|
|
494
|
+
* render.
|
|
495
|
+
*/
|
|
496
|
+
export function computeActivityBadge(input: {
|
|
497
|
+
pushedAt: string | null;
|
|
498
|
+
monthlyCommits: Array<{ month: string; commits: number }>;
|
|
499
|
+
isArchived: boolean;
|
|
500
|
+
isDisabled: boolean;
|
|
501
|
+
}): { label: string; tone: "fresh" | "ok" | "low" | "dead" } | null {
|
|
502
|
+
if (input.isArchived || input.isDisabled) {
|
|
503
|
+
return { label: "Archived", tone: "dead" };
|
|
504
|
+
}
|
|
505
|
+
const recentCommits = (input.monthlyCommits ?? [])
|
|
506
|
+
.slice(-3)
|
|
507
|
+
.reduce((sum, c) => sum + (c.commits ?? 0), 0);
|
|
508
|
+
if (recentCommits >= 50) return { label: "Very active", tone: "fresh" };
|
|
509
|
+
if (recentCommits >= 10) return { label: "Active", tone: "ok" };
|
|
510
|
+
if (recentCommits > 0) return { label: "Maintained", tone: "ok" };
|
|
511
|
+
if (input.pushedAt) {
|
|
512
|
+
const days =
|
|
513
|
+
(Date.now() - new Date(input.pushedAt).getTime()) / 86_400_000;
|
|
514
|
+
if (days < 30) return { label: "Active", tone: "ok" };
|
|
515
|
+
if (days < 180) return { label: "Maintained", tone: "ok" };
|
|
516
|
+
if (days < 365) return { label: "Low activity", tone: "low" };
|
|
517
|
+
return { label: "Stale", tone: "low" };
|
|
518
|
+
}
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Visibility booleans for each sidebar card. Cards with no data
|
|
524
|
+
* suppress themselves entirely so the sidebar never shows "—" or
|
|
525
|
+
* "0" placeholders that confuse "we haven't fetched yet" with
|
|
526
|
+
* "the value is genuinely zero".
|
|
527
|
+
*/
|
|
528
|
+
export function computeSidebarVisibility(input: {
|
|
529
|
+
language: string | null;
|
|
530
|
+
licenseSpdx: string | null;
|
|
531
|
+
repo: Record<string, unknown> | null | undefined;
|
|
532
|
+
pushedAt: string | null;
|
|
533
|
+
isArchived: boolean;
|
|
534
|
+
healthLabel: string | null;
|
|
535
|
+
stacks: string[];
|
|
536
|
+
platforms: string[];
|
|
537
|
+
tags: string[];
|
|
538
|
+
category: string | undefined;
|
|
539
|
+
contributionSignals: Array<{ key: string; label: string; ok: boolean }>;
|
|
540
|
+
reviewedAt: string | undefined;
|
|
541
|
+
}): {
|
|
542
|
+
showActivity: boolean;
|
|
543
|
+
showFreshness: boolean;
|
|
544
|
+
showEcosystem: boolean;
|
|
545
|
+
showSource: boolean;
|
|
546
|
+
} {
|
|
547
|
+
const repoFields = input.repo ?? {};
|
|
548
|
+
const hasRepo =
|
|
549
|
+
typeof input.repo === "object" &&
|
|
550
|
+
input.repo !== null &&
|
|
551
|
+
Object.keys(repoFields).length > 0;
|
|
552
|
+
return {
|
|
553
|
+
showActivity:
|
|
554
|
+
hasRepo ||
|
|
555
|
+
!!input.language ||
|
|
556
|
+
!!input.licenseSpdx,
|
|
557
|
+
showFreshness:
|
|
558
|
+
hasRepo ||
|
|
559
|
+
!!input.pushedAt ||
|
|
560
|
+
!!input.healthLabel ||
|
|
561
|
+
input.isArchived,
|
|
562
|
+
showEcosystem:
|
|
563
|
+
input.stacks.length > 0 ||
|
|
564
|
+
input.platforms.length > 0 ||
|
|
565
|
+
input.tags.length > 0 ||
|
|
566
|
+
!!input.category ||
|
|
567
|
+
input.contributionSignals.length > 0,
|
|
568
|
+
showSource: !!input.reviewedAt,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* `getStaticPaths()` body for the consumer's detail page. Centralized
|
|
574
|
+
* here so the CLI's scaffold and the example app share one definition.
|
|
575
|
+
*/
|
|
576
|
+
export function recordDetailPaths(site: DirectorySiteConfig) {
|
|
577
|
+
const dirSlug = site.blueprintConfig?.routeSlug ?? "items";
|
|
578
|
+
return fullItems.map((record) => ({
|
|
579
|
+
params: { slug: dirSlug, recordSlug: record.slug },
|
|
580
|
+
}));
|
|
581
|
+
}
|
|
582
|
+
|
|
431
583
|
export type DirectoryIndexModel = ReturnType<typeof getDirectoryIndexModel>;
|
|
432
584
|
export type DirectoryHomeModel = ReturnType<typeof getHomePageModel>;
|
|
433
585
|
export type SubmissionPageModel = ReturnType<typeof getSubmissionPageModel>;
|