@grove-dev/astro 0.4.0 → 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.
@@ -38,7 +38,13 @@ import { fileURLToPath } from "node:url";
38
38
  import { dirname, resolve } from "node:path";
39
39
  import { marked } from "marked";
40
40
  import sanitizeHtml from "sanitize-html";
41
+ import { createHighlighter } from "shiki";
41
42
  import { prettySlug } from "../lib/display.js";
43
+ import {
44
+ readContentFile,
45
+ stripFrontmatter,
46
+ uniqueSlug,
47
+ } from "@grove-dev/core";
42
48
  import type {
43
49
  ProjectRecord,
44
50
  ResourceRecord,
@@ -287,13 +293,299 @@ export const fullProjects: ProjectRecord[] = fullRecords.filter(
287
293
  // missing. No page module needs to import `node:fs` anymore.
288
294
 
289
295
  const here = dirname(fileURLToPath(import.meta.url));
290
- function resolveContentPath(contentPath: string): string {
291
- const candidates = [
292
- resolve(here, "..", "..", contentPath),
293
- resolve(here, "..", "..", "..", contentPath),
294
- resolve(process.cwd(), contentPath),
295
- ];
296
- return candidates.find((p) => existsSync(p)) ?? "";
296
+
297
+ // ── Markdown rendering ────────────────────────────────────────────────
298
+ //
299
+ // Record bodies and consumer-authored pages both go through the
300
+ // same pipeline:
301
+ //
302
+ // body → marked.parse(body) → sanitizeHtml(html, allowlist)
303
+ //
304
+ // The differences are:
305
+ // - record bodies use the wide `RECORD_BODY_ALLOWLIST` (tables,
306
+ // images, task-list inputs, details/summary, kbd/mark/sub/sup,
307
+ // …) so a curated `.md` sidecar can use the full surface.
308
+ // - page bodies use the narrower `PAGE_BODY_ALLOWLIST` so a
309
+ // page-author's `.md` stays in a conservative prose-only space.
310
+ //
311
+ // Both paths run at module load so `getContentHtml(slug)` and
312
+ // `getPageContentHtml(name)` return pre-sanitized HTML with no
313
+ // per-request work.
314
+
315
+ /** Wide allowlist used for `ProjectRecord.content` Markdown bodies. */
316
+ const RECORD_BODY_ALLOWLIST = [
317
+ // Headings — full depth.
318
+ "h1", "h2", "h3", "h4", "h5", "h6",
319
+ // Block-level content.
320
+ "p", "br", "hr", "div", "blockquote",
321
+ // Lists.
322
+ "ul", "ol", "li",
323
+ // Definition lists.
324
+ "dl", "dt", "dd",
325
+ // Tables (GFM).
326
+ "table", "thead", "tbody", "tfoot", "tr", "th", "td",
327
+ "caption", "colgroup", "col",
328
+ // Inline formatting.
329
+ "strong", "em", "b", "i", "u", "s", "del", "ins",
330
+ "mark", "small", "sub", "sup", "kbd", "abbr",
331
+ // `<span>` is only here for Shiki's per-token wrappers inside
332
+ // highlighted code blocks. Sanitize-html's default is to strip
333
+ // any tag not in this list, so without `span` here the syntax
334
+ // highlights silently disappear from fenced code.
335
+ "span",
336
+ // Links + images.
337
+ "a", "img",
338
+ // Code.
339
+ "code", "pre",
340
+ // Forms (task-list checkboxes only).
341
+ "input", "label",
342
+ // Collapsibles and semantic blocks.
343
+ "details", "summary",
344
+ "figure", "figcaption",
345
+ "time",
346
+ ];
347
+
348
+ /** Narrow allowlist used for `content/pages/<page>.md`. */
349
+ const PAGE_BODY_ALLOWLIST = [
350
+ "h1", "h2", "h3", "h4",
351
+ "p", "br", "hr",
352
+ "ul", "ol", "li",
353
+ "strong", "em", "b", "i", "u", "s", "del",
354
+ "a", "code", "pre", "blockquote", "img",
355
+ ];
356
+
357
+ /**
358
+ * Attributes the sanitizer keeps per tag. The shared `* → ["id", "class"]`
359
+ * rule preserves the heading anchors the renderer adds and the
360
+ * Shiki syntax-highlighting classes (`shiki`, `language-bash`,
361
+ * …) on `<pre>` and `<code>` blocks. Image `loading/decoding`
362
+ * get forced to lazy/async to keep page weight down; `<input>`
363
+ * becomes a hardened disabled checkbox for GFM task lists.
364
+ */
365
+ const COMMON_BODY_ATTRIBUTES = {
366
+ a: ["href", "title", "rel", "target"],
367
+ img: ["src", "alt", "title", "width", "height", "loading", "decoding"],
368
+ th: ["scope", "colspan", "rowspan", "align"],
369
+ td: ["colspan", "rowspan", "align"],
370
+ col: ["span", "align"],
371
+ input: ["type", "checked", "disabled"],
372
+ label: ["for"],
373
+ abbr: ["title"],
374
+ time: ["datetime"],
375
+ details: ["open"],
376
+ div: ["class"], // table-wrap div the renderer injects
377
+ span: ["style"], // Shiki emits inline `--shiki-light` / `--shiki-dark` CSS variables
378
+ pre: ["class", "style"], // Shiki also tags the outer `<pre>` with theme classes
379
+ code: ["class"],
380
+ "*": ["id"],
381
+ };
382
+
383
+ /** `<a>` tag normalization — open in a new tab, no opener. */
384
+ const ANCHOR_TRANSFORM = sanitizeHtml.simpleTransform(
385
+ "a",
386
+ { rel: "noopener noreferrer", target: "_blank" },
387
+ true,
388
+ );
389
+
390
+ /** Hardens `<img>` to lazy-load + async-decode. */
391
+ const IMG_TRANSFORM = (
392
+ tagName: string,
393
+ attribs: Record<string, string>,
394
+ ) => ({
395
+ tagName,
396
+ attribs: {
397
+ ...attribs,
398
+ loading: attribs.loading ?? "lazy",
399
+ decoding: attribs.decoding ?? "async",
400
+ },
401
+ });
402
+
403
+ /** Forces `<input type="checkbox">` to be disabled so task lists
404
+ * render read-only at runtime (no on-page state to persist). */
405
+ const INPUT_TRANSFORM = (
406
+ tagName: string,
407
+ attribs: Record<string, string>,
408
+ ) => ({
409
+ tagName,
410
+ attribs: {
411
+ ...attribs,
412
+ type: "checkbox",
413
+ disabled: "",
414
+ ...(attribs.checked !== undefined ? { checked: "" } : {}),
415
+ },
416
+ });
417
+
418
+ /**
419
+ * GitHub-style slug for a heading. Matches `core/extractToc` so the
420
+ * IDs the renderer emits line up with the IDs a TOC consumer reads
421
+ * out of the same body — keeping deep links in lockstep.
422
+ */
423
+ function headingSlug(text: string): string {
424
+ return text
425
+ .toLowerCase()
426
+ .replace(/[‘’]/g, "")
427
+ .replace(/[^a-z0-9\s-]/g, "")
428
+ .replace(/\s+/g, "-")
429
+ .replace(/-+/g, "-")
430
+ .replace(/^-|-+$/g, "");
431
+ }
432
+
433
+ /**
434
+ * Render a Markdown string to sanitized HTML. The renderer is
435
+ * configured once (heading IDs + table wrap) and reused for both
436
+ * record and page bodies.
437
+ *
438
+ * Why pass `body` rather than a slug: this is a pure transform; the
439
+ * caller owns where the body came from (file, in-memory cache, …).
440
+ * `getContentHtml(slug)` is the per-record wrapper that reads the
441
+ * file and calls this.
442
+ */
443
+ export function renderMarkdownToSafeHtml(
444
+ body: string,
445
+ options: { allowlist?: string[] } = {},
446
+ ): string {
447
+ const allowlist = options.allowlist ?? RECORD_BODY_ALLOWLIST;
448
+ // Configure marked. GFM is on by default in v18 but set explicitly
449
+ // so the intent is visible; `breaks: false` keeps single newlines
450
+ // from becoming `<br>` per CommonMark.
451
+ //
452
+ // We register a small extension that:
453
+ // 1. Adds a stable `id` to every h2–h6 so the TOC sidebar can
454
+ // deep-link into the rendered body.
455
+ // 2. Wraps GFM tables in `<div class="grove-prose-table-wrap">`
456
+ // so the column-width overflow is contained inside a rounded
457
+ // border instead of breaking the layout.
458
+ marked.use({
459
+ gfm: true,
460
+ breaks: false,
461
+ renderer: {
462
+ heading({ tokens, depth }) {
463
+ const inline = this.parser.parseInline(tokens);
464
+ const plain = tokens
465
+ .map((t: { text?: string; raw?: string }) => t.text ?? t.raw ?? "")
466
+ .join("");
467
+ const id = depth === 1 ? "" : ` id="${uniqueSlug(headingSlug(plain) || "section", headingIds)}"`;
468
+ return `<h${depth}${id}>${inline}</h${depth}>\n`;
469
+ },
470
+ table(token: {
471
+ header: Array<{ tokens: unknown[] }>;
472
+ rows: Array<Array<{ tokens: unknown[] }>>;
473
+ }) {
474
+ const head = token.header
475
+ .map((cell) => `<th>${this.parser.parseInline(cell.tokens)}</th>`)
476
+ .join("");
477
+ const body = token.rows
478
+ .map((row) =>
479
+ `<tr>${row
480
+ .map((cell) => `<td>${this.parser.parseInline(cell.tokens)}</td>`)
481
+ .join("")}</tr>`,
482
+ )
483
+ .join("");
484
+ return `<div class="grove-prose-table-wrap"><table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>\n`;
485
+ },
486
+ // Fenced code blocks: route through Shiki instead of marked's
487
+ // default `<pre><code class="language-…">…</code></pre>`. The
488
+ // returned HTML already wraps in `<pre>` and `<code>`; we
489
+ // hand it to the sanitizer unchanged (Shiki's output is
490
+ // safe by construction — no script / event handlers).
491
+ code({ text, lang }) {
492
+ return highlightCode(text, lang ?? "") + "\n";
493
+ },
494
+ },
495
+ });
496
+
497
+ const rawHtml = marked.parse(body, { async: false }) as string;
498
+ return sanitizeHtml(rawHtml, {
499
+ allowedTags: allowlist,
500
+ allowedAttributes: COMMON_BODY_ATTRIBUTES,
501
+ allowedSchemes: ["http", "https", "mailto", "tel"],
502
+ allowedSchemesByTag: {
503
+ a: ["http", "https", "mailto", "tel"],
504
+ img: ["http", "https", "data"],
505
+ },
506
+ allowedSchemesAppliedToAttributes: ["href", "src"],
507
+ transformTags: {
508
+ a: ANCHOR_TRANSFORM,
509
+ img: IMG_TRANSFORM,
510
+ input: INPUT_TRANSFORM,
511
+ },
512
+ disallowedTagsMode: "discard",
513
+ });
514
+ }
515
+
516
+ /** Per-render counter so heading IDs don't collide within a body. */
517
+ const headingIds = new Map<string, number>();
518
+
519
+ // ── Shiki syntax highlighter ─────────────────────────────────────────
520
+ //
521
+ // Build-time tokenisation for fenced code blocks. Shiki returns a
522
+ // string of HTML — `<pre class="shiki …"><code>…</code></pre>` —
523
+ // and we drop it straight into the marked output where the code
524
+ // block would otherwise be a flat `<pre><code class="language-…">`.
525
+ //
526
+ // Two themes (light + dark) are loaded; the `defaultColor: false`
527
+ // flag asks Shiki to emit CSS variables (`--shiki-light`,
528
+ // `--shiki-dark`) instead of inline `color:` declarations. The
529
+ // selectors in `styles.css` then swap which set of variables wins
530
+ // when the consumer toggles `.dark` on `<html>`. Net effect:
531
+ // the same HTML is themed correctly in both modes without us
532
+ // re-rendering at runtime.
533
+ //
534
+ // We use `github-dark-default` rather than `github-dark` for the
535
+ // dark side: `github-dark` paints comments at `#6A737D`, which
536
+ // fails WCAG AA contrast (3.72:1) against our `--color-ink-950`
537
+ // `#171717` code-block background and trips the Lighthouse
538
+ // accessibility audit on any record that contains a `# …` shell
539
+ // comment. `github-dark-default` shifts comments to `#8B949E`,
540
+ // which clears 4.5:1 against the same background (~6.3:1).
541
+ //
542
+ // `getSingletonHighlighter()` lazily loads the engine on first use
543
+ // and caches it for every subsequent call — Shiki's WASM/grammar
544
+ // load takes a few hundred ms, so we want to amortise across all
545
+ // records in a build. The list of supported languages is curated
546
+ // (not `ALL`) so the build doesn't pull Shiki's full grammar pack.
547
+ const SUPPORTED_LANGS = [
548
+ "bash", "sh", "shell", "console",
549
+ "python", "py",
550
+ "javascript", "js", "jsx", "typescript", "ts", "tsx",
551
+ "json", "jsonc", "yaml", "yml", "toml",
552
+ "markdown", "md", "mdx",
553
+ "html", "css", "scss", "sass",
554
+ "sql", "graphql",
555
+ "dockerfile", "diff",
556
+ "rust", "go", "java", "kotlin", "swift", "ruby", "php",
557
+ "c", "cpp", "csharp", "objective-c",
558
+ "xml", "ini", "properties",
559
+ ];
560
+ const highlighter = await createHighlighter({
561
+ themes: ["github-light", "github-dark-default"],
562
+ langs: SUPPORTED_LANGS,
563
+ });
564
+
565
+ /**
566
+ * Highlight a fenced code block with Shiki. Returns the Shiki HTML
567
+ * wrapped in our `<pre>`-equivalent class so the existing
568
+ * `.grove-prose pre` rules continue to apply (padding, border,
569
+ * scroll behaviour).
570
+ */
571
+ function highlightCode(text: string, lang: string): string {
572
+ const normalized = lang?.toLowerCase() ?? "";
573
+ const safeLang = highlighter.getLoadedLanguages().includes(normalized)
574
+ ? normalized
575
+ : "text";
576
+ const html = highlighter.codeToHtml(text, {
577
+ lang: safeLang,
578
+ themes: { light: "github-light", dark: "github-dark-default" },
579
+ defaultColor: false,
580
+ });
581
+ // Strip the outer `<pre>`'s inline `background-color` so it doesn't
582
+ // fight our package's prose background. The per-token `<span>`
583
+ // `style="--shiki-light: …; --shiki-dark: …"` declarations must
584
+ // survive — they're what makes dual-theme work.
585
+ return html.replace(
586
+ /(<pre[^>]*?)\s+style="[^"]*"/g,
587
+ "$1",
588
+ );
297
589
  }
298
590
 
299
591
  const contentHtmlBySlug = new Map<string, string>();
@@ -301,36 +593,15 @@ for (const r of fullRecords) {
301
593
  if (r.kind !== "project") continue;
302
594
  const projectRecord = r as ProjectRecord;
303
595
  if (!projectRecord.content) continue;
304
- const path = resolveContentPath(projectRecord.content);
305
- if (!path) continue;
596
+ const read = readContentFile(projectRecord.content);
597
+ if (!read) continue;
598
+ // Each render needs a fresh headingIds map so the per-body
599
+ // collision counter starts at zero. Otherwise the second record
600
+ // in the loop would inherit the first record's counters and
601
+ // duplicate-heading IDs would collide across records.
602
+ headingIds.clear();
306
603
  try {
307
- const text = readFileSync(path, "utf8");
308
- // `async: false` keeps the call synchronous so we can populate
309
- // the map at module-load time. (marked v18 defaults to
310
- // Promise-returning; v9-17 also support this flag.)
311
- const rawHtml = marked.parse(text, { async: false }) as string;
312
- const safeHtml = sanitizeHtml(rawHtml, {
313
- allowedTags: [
314
- "h1", "h2", "h3", "h4",
315
- "p", "br", "hr",
316
- "ul", "ol", "li",
317
- "strong", "em", "b", "i", "u", "s", "del",
318
- "a", "code", "pre", "blockquote",
319
- ],
320
- allowedAttributes: {
321
- a: ["href", "title", "rel", "target"],
322
- },
323
- allowedSchemes: ["http", "https", "mailto", "tel"],
324
- allowedSchemesByTag: { a: ["http", "https", "mailto", "tel"] },
325
- transformTags: {
326
- a: sanitizeHtml.simpleTransform("a", {
327
- rel: "noopener noreferrer",
328
- target: "_blank",
329
- }, true),
330
- },
331
- disallowedTagsMode: "discard",
332
- });
333
- contentHtmlBySlug.set(r.slug, safeHtml);
604
+ contentHtmlBySlug.set(r.slug, renderMarkdownToSafeHtml(read.body));
334
605
  } catch {
335
606
  // Missing / unreadable / parse-failed content: skip the record
336
607
  // rather than render broken HTML. The page treats `null` as
@@ -364,35 +635,10 @@ export function getPageContentHtml(page: string): string | null {
364
635
  if (!path) return null;
365
636
 
366
637
  try {
367
- const markdown = readFileSync(path, "utf8").replace(
368
- /^---\r?\n[\s\S]*?\r?\n---\r?\n?/,
369
- "",
370
- );
371
- const rawHtml = marked.parse(markdown, { async: false }) as string;
372
- return sanitizeHtml(rawHtml, {
373
- allowedTags: [
374
- "h1", "h2", "h3", "h4",
375
- "p", "br", "hr",
376
- "ul", "ol", "li",
377
- "strong", "em", "b", "i", "u", "s", "del",
378
- "a", "code", "pre", "blockquote", "img",
379
- ],
380
- allowedAttributes: {
381
- a: ["href", "title", "rel", "target"],
382
- img: ["src", "alt", "title", "width", "height", "loading"],
383
- },
384
- allowedSchemes: ["http", "https", "mailto", "tel"],
385
- allowedSchemesByTag: {
386
- a: ["http", "https", "mailto", "tel"],
387
- img: ["http", "https"],
388
- },
389
- transformTags: {
390
- a: sanitizeHtml.simpleTransform("a", {
391
- rel: "noopener noreferrer",
392
- target: "_blank",
393
- }, true),
394
- },
395
- disallowedTagsMode: "discard",
638
+ const markdown = stripFrontmatter(readFileSync(path, "utf8"));
639
+ headingIds.clear();
640
+ return renderMarkdownToSafeHtml(markdown, {
641
+ allowlist: PAGE_BODY_ALLOWLIST,
396
642
  });
397
643
  } catch {
398
644
  return null;
@@ -6,13 +6,20 @@
6
6
  */
7
7
  import { existsSync, readFileSync } from "node:fs";
8
8
  import { resolve } from "node:path";
9
- import type { IndexFilters, IndexRecord, ProjectRecord } from "@grove-dev/core";
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: record.tags ?? [],
420
- healthLabel: healthStatus ? statusDisplay(healthStatus) : null,
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>;