@grove-dev/astro 0.2.6 → 0.2.13

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 (136) hide show
  1. package/dist/lib/display.d.ts +11 -17
  2. package/dist/lib/display.d.ts.map +1 -1
  3. package/dist/lib/display.js +43 -43
  4. package/dist/lib/display.js.map +1 -1
  5. package/dist/lib/format.d.ts +6 -7
  6. package/dist/lib/format.d.ts.map +1 -1
  7. package/dist/lib/format.js +11 -12
  8. package/dist/lib/format.js.map +1 -1
  9. package/dist/lib/lenses.d.ts +35 -73
  10. package/dist/lib/lenses.d.ts.map +1 -1
  11. package/dist/lib/lenses.js +144 -126
  12. package/dist/lib/lenses.js.map +1 -1
  13. package/dist/lib/repo.d.ts +11 -22
  14. package/dist/lib/repo.d.ts.map +1 -1
  15. package/dist/lib/repo.js +12 -25
  16. package/dist/lib/repo.js.map +1 -1
  17. package/dist/lib/scores.d.ts +15 -16
  18. package/dist/lib/scores.d.ts.map +1 -1
  19. package/dist/lib/scores.js +4 -11
  20. package/dist/lib/scores.js.map +1 -1
  21. package/dist/lib/search.d.ts +47 -129
  22. package/dist/lib/search.d.ts.map +1 -1
  23. package/dist/lib/search.js +195 -180
  24. package/dist/lib/search.js.map +1 -1
  25. package/dist/lib/taxonomy-counts.d.ts +7 -21
  26. package/dist/lib/taxonomy-counts.d.ts.map +1 -1
  27. package/dist/lib/taxonomy-counts.js +12 -31
  28. package/dist/lib/taxonomy-counts.js.map +1 -1
  29. package/package.json +4 -4
  30. package/src/components/AppsIndexRow.astro +55 -184
  31. package/src/components/AppsPagination.astro +45 -108
  32. package/src/components/CategoryGrid.astro +43 -31
  33. package/src/components/ContributorsGrid.astro +3 -3
  34. package/src/components/CurationGrid.astro +41 -0
  35. package/src/components/ExploreByStack.astro +6 -0
  36. package/src/components/FilterGroupMenu.astro +49 -0
  37. package/src/components/FilterOptions.astro +44 -0
  38. package/src/components/Hero.astro +198 -147
  39. package/src/components/Icon.astro +10 -3
  40. package/src/components/ItemCard.astro +266 -248
  41. package/src/components/RecordSection.astro +68 -33
  42. package/src/components/RefinePanel.astro +115 -141
  43. package/src/components/StackGrid.astro +32 -24
  44. package/src/components/WhyThisExists.astro +32 -32
  45. package/src/layouts/BaseLayout.astro +104 -49
  46. package/src/layouts/Container.astro +5 -44
  47. package/src/layouts/Footer.astro +31 -20
  48. package/src/layouts/Header.astro +40 -19
  49. package/src/layouts/SectionHeader.astro +27 -43
  50. package/src/layouts/Seo.astro +38 -56
  51. package/src/layouts/ThemeToggle.astro +35 -48
  52. package/src/lib/display.ts +46 -53
  53. package/src/lib/format.ts +11 -12
  54. package/src/lib/lenses.ts +151 -172
  55. package/src/lib/repo.ts +16 -28
  56. package/src/lib/scores.ts +15 -19
  57. package/src/lib/search.ts +222 -290
  58. package/src/lib/taxonomy-counts.ts +12 -46
  59. package/src/styles.css +207 -1
  60. package/templates/default/.github/ISSUE_TEMPLATE/bug_report.md +33 -0
  61. package/templates/default/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
  62. package/templates/default/.github/ISSUE_TEMPLATE/record_submission.md +48 -0
  63. package/templates/default/.github/pull_request_template.md +27 -0
  64. package/templates/default/.github/workflows/build.yml +55 -0
  65. package/templates/default/.github/workflows/cleanup-stale-records.yml +48 -0
  66. package/templates/default/.github/workflows/daily-refresh.yml +53 -0
  67. package/templates/default/.github/workflows/sync-contributors.yml +53 -0
  68. package/templates/default/.github/workflows/sync-github-metadata.yml +58 -0
  69. package/templates/default/.github/workflows/validate-data.yml +35 -0
  70. package/templates/default/data/records/coolify.yml +46 -0
  71. package/templates/default/data/records/gitea.yml +46 -0
  72. package/templates/default/data/records/hoppscotch.yml +47 -0
  73. package/templates/default/data/records/inventree.yml +47 -0
  74. package/templates/default/data/records/label-studio.yml +46 -0
  75. package/templates/default/data/records/logseq.yml +48 -0
  76. package/templates/default/data/records/private-gpt.yml +47 -0
  77. package/templates/default/data/records/wakapi.yml +44 -0
  78. package/templates/default/data/taxonomy/categories.yml +47 -0
  79. package/templates/default/data/taxonomy/distribution-channels.yml +43 -0
  80. package/templates/default/data/taxonomy/platforms.yml +27 -0
  81. package/templates/default/data/taxonomy/stacks.yml +41 -0
  82. package/templates/default/grove.config.ts +1 -1
  83. package/templates/default/package.json +5 -4
  84. package/templates/default/public/icons/stacks/bun.svg +1 -0
  85. package/templates/default/public/icons/stacks/clojure.svg +1 -0
  86. package/templates/default/public/icons/stacks/clojurescript.svg +1 -0
  87. package/templates/default/public/icons/stacks/deno.svg +1 -0
  88. package/templates/default/public/icons/stacks/django.svg +1 -0
  89. package/templates/default/public/icons/stacks/docker.svg +1 -0
  90. package/templates/default/public/icons/stacks/go.svg +1 -0
  91. package/templates/default/public/icons/stacks/llm.svg +1 -0
  92. package/templates/default/public/icons/stacks/node.js.svg +1 -0
  93. package/templates/default/public/icons/stacks/sveltekit.svg +1 -0
  94. package/templates/default/public/icons/stacks/vue.svg +1 -0
  95. package/templates/default/public/llms-full.txt +197 -5
  96. package/templates/default/public/llms.txt +107 -6
  97. package/templates/default/public/sitemap.xml +63 -0
  98. package/templates/default/scripts/build-llms.mjs +123 -0
  99. package/templates/default/scripts/build-records-json.mjs +27 -0
  100. package/templates/default/scripts/build-sitemap.mjs +20 -0
  101. package/templates/default/scripts/cleanup-stale-records.mjs +20 -0
  102. package/templates/default/scripts/enrich-github-metadata.mjs +99 -0
  103. package/templates/default/scripts/fetch-icons.mjs +137 -0
  104. package/templates/default/scripts/migrate-legacy-to-schema-v1.mjs +86 -0
  105. package/templates/default/scripts/parse-legacy-readme.mjs +59 -0
  106. package/templates/default/scripts/refresh-records-activity.mjs +24 -0
  107. package/templates/default/scripts/repair-license-format.mjs +72 -0
  108. package/templates/default/scripts/report-cleanup-candidates.mjs +21 -0
  109. package/templates/default/scripts/seed-from-github.mjs +62 -0
  110. package/templates/default/scripts/sync-contributors.mjs +145 -0
  111. package/templates/default/scripts/sync-github-metadata.mjs +28 -0
  112. package/templates/default/scripts/validate-records.mjs +27 -0
  113. package/templates/default/src/data/records.ts +134 -51
  114. package/templates/default/src/pages/404.astro +64 -0
  115. package/templates/default/src/pages/{projects/[slug].astro → [slug]/[itemSlug].astro} +236 -66
  116. package/templates/default/src/pages/[slug]/index.astro +476 -0
  117. package/templates/default/src/pages/about.astro +165 -82
  118. package/templates/default/src/pages/apps/[itemSlug].astro +301 -0
  119. package/templates/default/src/pages/apps/index.astro +146 -0
  120. package/templates/default/src/pages/contributors.astro +184 -0
  121. package/templates/default/src/pages/index.astro +119 -162
  122. package/templates/default/src/pages/submit.astro +276 -114
  123. package/templates/default/wrangler.jsonc +28 -0
  124. package/LICENSE +0 -21
  125. package/templates/default/data/generated/apps.json +0 -1
  126. package/templates/default/data/generated/records.full.json +0 -8
  127. package/templates/default/data/generated/records.index.json +0 -7
  128. package/templates/default/data/generated/records.json +0 -8
  129. package/templates/default/data/generated/repo-stats.json +0 -1
  130. package/templates/default/data/generated/site-config.json +0 -31
  131. package/templates/default/src/components/layout/BaseLayout.astro +0 -47
  132. package/templates/default/src/components/layout/Container.astro +0 -18
  133. package/templates/default/src/components/layout/Footer.astro +0 -27
  134. package/templates/default/src/components/layout/Header.astro +0 -43
  135. package/templates/default/src/lib/markdown.ts +0 -171
  136. package/templates/default/src/pages/projects/index.astro +0 -193
@@ -1,58 +1,42 @@
1
1
  ---
2
- /**
3
- * SectionHeader — eyebrow + title + description, used to title
4
- * every section on the home page. Optionally centered (good for
5
- * "Why this exists" and friends).
6
- */
7
-
8
2
  interface Props {
9
3
  eyebrow?: string;
10
4
  title: string;
11
5
  description?: string;
12
6
  align?: "left" | "center";
13
- /** 2 (default) for top-level sections, 3 for nested ones. */
14
7
  level?: 2 | 3;
15
- /** Extra class for the outer header element. */
16
- class?: string;
17
8
  }
18
9
 
19
- const {
20
- eyebrow,
21
- title,
22
- description,
23
- align = "left",
24
- level = 2,
25
- class: className = "",
26
- } = Astro.props;
10
+ const { eyebrow, title, description, align = "left", level = 2 } = Astro.props;
27
11
  const Heading = `h${level}` as "h2" | "h3";
28
12
  ---
29
13
 
30
14
  <header
31
- class:list={[
32
- "mb-6 flex flex-col gap-2",
33
- align === "center" ? "items-center text-center" : "",
34
- className,
35
- ]}
15
+ class={`mb-6 flex flex-col gap-2 ${
16
+ align === "center" ? "items-center text-center" : ""
17
+ }`}
36
18
  >
37
- {eyebrow && (
38
- <span class="text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">
39
- {eyebrow}
40
- </span>
41
- )}
42
- <div class="flex flex-wrap items-end justify-between gap-3">
43
- <Heading class="m-0 text-[1.5rem] sm:text-[1.625rem] font-semibold tracking-tight text-ink-900 dark:text-ink-100">
44
- {title}
45
- </Heading>
46
- <slot name="actions" />
47
- </div>
48
- {description && (
49
- <p
50
- class:list={[
51
- "m-0 text-[0.9375rem] leading-relaxed text-ink-500 dark:text-ink-400",
52
- align === "center" ? "max-w-2xl" : "max-w-[56ch]",
53
- ]}
54
- >
55
- {description}
56
- </p>
57
- )}
19
+ {
20
+ eyebrow && (
21
+ <span class="text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">
22
+ {eyebrow}
23
+ </span>
24
+ )
25
+ }
26
+ <Heading
27
+ class="text-[1.5rem] sm:text-[1.625rem] font-semibold tracking-tight text-ink-900 dark:text-ink-100 m-0"
28
+ >
29
+ {title}
30
+ </Heading>
31
+ {
32
+ description && (
33
+ <p
34
+ class={`text-[0.9375rem] leading-relaxed text-ink-500 dark:text-ink-400 m-0 ${
35
+ align === "center" ? "max-w-2xl" : "max-w-[56ch]"
36
+ }`}
37
+ >
38
+ {description}
39
+ </p>
40
+ )
41
+ }
58
42
  </header>
@@ -1,80 +1,73 @@
1
1
  ---
2
+ // SPDX-License-Identifier: MIT
3
+
2
4
  /**
3
- * Seo — per-page <head> content: title, description, canonical,
4
- * Open Graph, Twitter Card, and JSON-LD structured data. Designed
5
- * to be used inside the <head> slot of BaseLayout. Pages pass the
6
- * rich props they care about; everything else has a sensible default
7
- * derived from `site` and the Astro URL.
5
+ * Per-page SEO: <title>, description, canonical, Open Graph, Twitter Card,
6
+ * and JSON-LD structured data. Designed to be used inside <head> by the
7
+ * BaseLayout. Pages pass the rich props they care about; everything else
8
+ * has a sensible default derived from the site config and the Astro URL.
8
9
  *
9
10
  * Renders:
10
11
  * - <title>, meta description, canonical link
11
12
  * - Open Graph (og:*) for Facebook, LinkedIn, Discord, Slack, etc.
12
13
  * - Twitter Card (twitter:*) for X / Twitter
13
14
  * - JSON-LD WebSite + Organization on every page
14
- * - JSON-LD custom payload (e.g. SoftwareSourceCode) when `jsonLd`
15
- * is provided.
15
+ * - JSON-LD SoftwareSourceCode (or custom) when `jsonLd` is provided
16
+ * (e.g. on /items/[slug] for the per-item structured data)
16
17
  *
17
- * Noindex is opt-in via the `noindex` prop. By default everything
18
- * is indexable.
18
+ * Noindex is opt-in via the `noindex` prop. By default everything is
19
+ * indexable; the only noindex target today is the thin /submit wrapper.
19
20
  */
20
21
 
21
- interface SiteRef {
22
- name: string;
23
- description?: string;
24
- url?: string;
25
- repoUrl?: string;
26
- searchPath?: string;
27
- }
28
-
29
22
  interface Props {
30
23
  title: string;
31
24
  description: string;
32
- /** Open Graph / Twitter image. Default: /og-image.svg. */
25
+ site: {
26
+ name: string;
27
+ description?: string;
28
+ repoUrl?: string;
29
+ };
30
+ searchPath?: string;
31
+ /** Open Graph / Twitter image. Default: /og-image.svg. Should be absolute or root-relative. */
33
32
  image?: string;
34
- /** og:type. Default: "website". */
33
+ /** og:type. Default: "website". Use "article" for blog posts / changelog. */
35
34
  type?: "website" | "article" | "profile";
36
35
  /** Set true to add noindex,nofollow. */
37
36
  noindex?: boolean;
38
- /** Extra JSON-LD payload (object) — typically SoftwareSourceCode. */
37
+ /** Extra JSON-LD payload (object) — typically SoftwareSourceCode on item pages. */
39
38
  jsonLd?: Record<string, unknown> | Record<string, unknown>[];
40
- /** Site identity (name, description, url, repoUrl). Required. */
41
- site: SiteRef;
42
39
  }
43
40
 
44
41
  const {
45
42
  title,
46
43
  description,
44
+ site: siteConfig,
45
+ searchPath = "/items",
47
46
  image = "/og-image.svg",
48
47
  type = "website",
49
48
  noindex = false,
50
49
  jsonLd,
51
- site,
52
50
  } = Astro.props;
53
51
 
54
- const siteUrl = site.url
55
- ? new URL(site.url)
56
- : (Astro.site ?? new URL("https://grove.dev"));
52
+ const siteUrl = Astro.site ?? new URL("https://example.com");
57
53
  const canonical = new URL(Astro.url.pathname, siteUrl).toString();
58
54
  const ogImage = new URL(image, siteUrl).toString();
59
55
 
60
- // Truncate description for OG/Twitter where longer is worse (160-200 chars).
56
+ // Truncate description for OG/Twitter where longer is worse (160–200 chars).
61
57
  function truncate(s: string, max: number): string {
62
58
  if (s.length <= max) return s;
63
59
  return s.slice(0, max - 1).trimEnd() + "\u2026";
64
60
  }
65
61
  const shortDescription = truncate(description, 200);
66
- const siteDescription =
67
- site.description ??
68
- "A growing community knowledge site powered by Grove.";
69
62
 
70
- // JSON-LD: site-wide identity. Google uses these for site links
71
- // and the knowledge panel.
72
- const searchPath = site.searchPath ?? "/projects";
73
- const websiteLd: Record<string, unknown> = {
63
+ // WebSite + Organization JSON-LD. These are site-wide identity signals
64
+ // that should appear on every page (Google uses them for site links
65
+ // and the knowledge panel).
66
+ const websiteLd = {
74
67
  "@context": "https://schema.org",
75
68
  "@type": "WebSite",
76
- name: site.name,
77
- description: siteDescription,
69
+ name: siteConfig.name,
70
+ description: siteConfig.description ?? description,
78
71
  url: siteUrl.toString().replace(/\/$/, ""),
79
72
  inLanguage: "en",
80
73
  potentialAction: {
@@ -84,17 +77,14 @@ const websiteLd: Record<string, unknown> = {
84
77
  },
85
78
  };
86
79
 
87
- const organizationLd: Record<string, unknown> = {
80
+ const organizationLd = {
88
81
  "@context": "https://schema.org",
89
82
  "@type": "Organization",
90
- name: site.name,
83
+ name: siteConfig.name,
91
84
  url: siteUrl.toString().replace(/\/$/, ""),
92
85
  logo: new URL("/og-image.svg", siteUrl).toString(),
86
+ ...(siteConfig.repoUrl ? { sameAs: [siteConfig.repoUrl] } : {}),
93
87
  };
94
-
95
- if (site.repoUrl) {
96
- organizationLd.sameAs = [site.repoUrl];
97
- }
98
88
  ---
99
89
 
100
90
  <!-- Primary meta -->
@@ -111,8 +101,8 @@ if (site.repoUrl) {
111
101
  <meta property="og:image" content={ogImage} />
112
102
  <meta property="og:image:width" content="1200" />
113
103
  <meta property="og:image:height" content="630" />
114
- <meta property="og:image:alt" content={`${site.name} — ${siteDescription}`} />
115
- <meta property="og:site_name" content={site.name} />
104
+ <meta property="og:image:alt" content={`${siteConfig.name} social preview`} />
105
+ <meta property="og:site_name" content={siteConfig.name} />
116
106
  <meta property="og:locale" content="en" />
117
107
 
118
108
  <!-- Twitter Card -->
@@ -120,21 +110,13 @@ if (site.repoUrl) {
120
110
  <meta name="twitter:title" content={title} />
121
111
  <meta name="twitter:description" content={shortDescription} />
122
112
  <meta name="twitter:image" content={ogImage} />
123
- <meta name="twitter:image:alt" content={`${site.name} — ${siteDescription}`} />
113
+ <meta name="twitter:image:alt" content={`${siteConfig.name} social preview`} />
124
114
 
125
115
  <!-- JSON-LD: site-wide identity -->
126
- <script
127
- is:inline
128
- type="application/ld+json"
129
- set:html={JSON.stringify(websiteLd)}
130
- ></script>
131
- <script
132
- is:inline
133
- type="application/ld+json"
134
- set:html={JSON.stringify(organizationLd)}
135
- ></script>
116
+ <script is:inline type="application/ld+json" set:html={JSON.stringify(websiteLd)} />
117
+ <script is:inline type="application/ld+json" set:html={JSON.stringify(organizationLd)} />
136
118
 
137
- <!-- JSON-LD: per-page (SoftwareSourceCode for [slug], etc.) -->
119
+ <!-- JSON-LD: per-page (SoftwareSourceCode for /items/[slug], etc.) -->
138
120
  {jsonLd && (
139
121
  <script
140
122
  is:inline
@@ -1,17 +1,15 @@
1
1
  ---
2
- /**
3
- * ThemeToggle — a three-position theme switcher (light / dark / system).
4
- *
5
- * State is persisted in `localStorage` under the key "grove-theme"
6
- * (matching `STORAGE_KEY` in `BaseLayout.astro`'s THEME_INIT script).
7
- *
8
- * The actual `<html class="dark">` toggle happens in the inline
9
- * THEME_INIT script in BaseLayout — that script runs before paint
10
- * so the user never sees a flash of the wrong theme.
11
- *
12
- * This button just rotates the stored mode and dispatches a
13
- * `theme:set` event that the THEME_INIT listener picks up.
14
- */
2
+ // A three-position theme switcher: light / dark / system.
3
+ //
4
+ // State is persisted in `localStorage` under the key "grove-theme".
5
+ // On mount it reads the stored value (or "system" if none), applies
6
+ // the corresponding class to <html>, and re-syncs when the OS
7
+ // preference changes (only meaningful when mode is "system").
8
+ //
9
+ // Implementation note: the actual <html class="dark"> toggle happens
10
+ // in the inline script in `src/components/layout/BaseLayout.astro`
11
+ // (THEME_INIT). That script runs before paint so the user never sees
12
+ // a flash of the wrong theme.
15
13
 
16
14
  interface Props {
17
15
  class?: string;
@@ -22,17 +20,15 @@ const { class: className = "" } = Astro.props;
22
20
 
23
21
  <button
24
22
  type="button"
23
+ id="theme-toggle"
25
24
  data-theme-toggle
26
25
  aria-label="Toggle theme"
27
26
  title="Toggle theme"
28
- class:list={[
29
- "relative inline-flex h-8 w-8 items-center justify-center rounded-md border border-ink-200 bg-white text-ink-700 transition-colors hover:border-ink-300 hover:bg-ink-50 dark:border-ink-800 dark:bg-ink-950 dark:text-ink-200 dark:hover:border-ink-700 dark:hover:bg-ink-900/40",
30
- className,
31
- ]}
27
+ class={`relative inline-flex h-8 w-8 items-center justify-center rounded-md border border-ink-200 bg-white text-ink-700 transition-colors hover:border-ink-300 hover:bg-ink-50 dark:border-ink-800 dark:bg-ink-950 dark:text-ink-200 dark:hover:border-ink-700 dark:hover:bg-ink-900/40 ${className}`}
32
28
  >
33
29
  {/* Sun — shown in dark mode (click to go light) */}
34
30
  <svg
35
- class="hidden h-4 w-4 dark:block"
31
+ class="h-4 w-4 hidden dark:block"
36
32
  viewBox="0 0 24 24"
37
33
  fill="none"
38
34
  stroke="currentColor"
@@ -41,12 +37,12 @@ const { class: className = "" } = Astro.props;
41
37
  stroke-linejoin="round"
42
38
  aria-hidden="true"
43
39
  >
44
- <circle cx="12" cy="12" r="4"></circle>
45
- <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"></path>
40
+ <circle cx="12" cy="12" r="4"/>
41
+ <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
46
42
  </svg>
47
43
  {/* Moon — shown in light mode (click to go dark) */}
48
44
  <svg
49
- class="block h-4 w-4 dark:hidden"
45
+ class="h-4 w-4 block dark:hidden"
50
46
  viewBox="0 0 24 24"
51
47
  fill="none"
52
48
  stroke="currentColor"
@@ -55,7 +51,7 @@ const { class: className = "" } = Astro.props;
55
51
  stroke-linejoin="round"
56
52
  aria-hidden="true"
57
53
  >
58
- <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79Z"></path>
54
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79Z"/>
59
55
  </svg>
60
56
  </button>
61
57
 
@@ -67,44 +63,35 @@ const { class: className = "" } = Astro.props;
67
63
  type Mode = "light" | "dark" | "system";
68
64
 
69
65
  function currentMode(): Mode {
70
- try {
71
- const stored = localStorage.getItem(STORAGE_KEY);
72
- return stored === "light" || stored === "dark" || stored === "system"
73
- ? stored
74
- : "system";
75
- } catch {
76
- return "system";
77
- }
66
+ const stored = localStorage.getItem(STORAGE_KEY);
67
+ return stored === "light" || stored === "dark" || stored === "system"
68
+ ? stored
69
+ : "system";
78
70
  }
79
71
 
80
72
  function rotate(): Mode {
81
73
  const order: Mode[] = ["light", "dark", "system"];
82
- const cur = currentMode();
83
- const next = order[(order.indexOf(cur) + 1) % order.length];
84
- try {
85
- localStorage.setItem(STORAGE_KEY, next);
86
- } catch {
87
- // localStorage may be blocked (private mode, etc.) — we still
88
- // dispatch the event so the UI updates for the current page.
89
- }
90
- // Notify the page-level listener (THEME_INIT).
74
+ const next = order[(order.indexOf(currentMode()) + 1) % order.length];
75
+ localStorage.setItem(STORAGE_KEY, next);
76
+ // Notify the page-level listener.
91
77
  window.dispatchEvent(new CustomEvent("theme:set", { detail: next }));
92
78
  return next;
93
79
  }
94
80
 
81
+ // A small tooltip label so the user knows what mode they'll get
82
+ // on the next click. We re-read current mode on focus/hover so it
83
+ // stays in sync.
95
84
  function updateLabel(btn: HTMLButtonElement) {
96
85
  btn.title = `Theme: ${currentMode()} (click to switch)`;
97
86
  }
98
87
 
99
- document
100
- .querySelectorAll<HTMLButtonElement>("[data-theme-toggle]")
101
- .forEach((btn) => {
88
+ document.querySelectorAll<HTMLButtonElement>("[data-theme-toggle]").forEach((btn) => {
89
+ updateLabel(btn);
90
+ btn.addEventListener("click", () => {
91
+ rotate();
102
92
  updateLabel(btn);
103
- btn.addEventListener("click", () => {
104
- rotate();
105
- updateLabel(btn);
106
- });
107
- btn.addEventListener("mouseenter", () => updateLabel(btn));
108
- btn.addEventListener("focus", () => updateLabel(btn));
109
93
  });
94
+ btn.addEventListener("mouseenter", () => updateLabel(btn));
95
+ btn.addEventListener("focus", () => updateLabel(btn));
96
+ });
110
97
  </script>
@@ -1,24 +1,15 @@
1
1
  /**
2
- * Display-name helpers for curation labels, statuses, lenses, and
3
- * sort options. Internal ids (e.g. "hot", "stale", "production-like")
4
- * stay stable for URL state and data files; this module owns the
5
- * mapping from id → user-facing string.
2
+ * Display-name helpers for curation labels, statuses, lenses, and sort
3
+ * options. Internal ids (e.g. "hot", "stale", "production-like") stay
4
+ * stable for URL state and data files; this module owns the mapping
5
+ * from id → user-facing string.
6
6
  *
7
- * Every export is dependency-free so it can be imported by both
7
+ * Every export here is dependency-free so it can be imported by both
8
8
  * server-rendered Astro components and any client-side script.
9
- *
10
- * Status ids accept the `HealthStatus` union from `@grove-dev/core`
11
- * plus a couple of curated composites (e.g. "needs-maintainer")
12
- * that appear in URL state.
13
9
  */
14
10
 
15
- import type {
16
- AppLabel,
17
- HealthStatus,
18
- } from "@grove-dev/core";
19
-
20
- // ── Label ids (the `curation.labels` array on a record) ──────────
21
- export type LabelId = AppLabel;
11
+ // ── Label ids (the `labels` array on an item) ────────────────────────
12
+ export type LabelId = "new" | "hot" | "mature" | "featured";
22
13
 
23
14
  export const LABEL_DISPLAY: Record<LabelId, string> = {
24
15
  new: "Recently added",
@@ -27,70 +18,58 @@ export const LABEL_DISPLAY: Record<LabelId, string> = {
27
18
  featured: "Featured",
28
19
  };
29
20
 
30
- /** Pretty-print a slug to Title Case. Used for arbitrary slugs. */
31
- export function prettySlug(slug: string | null | undefined): string {
32
- if (!slug) return "";
33
- return slug
34
- .replace(/[-_]+/g, " ")
35
- .replace(/\s+/g, " ")
36
- .trim()
37
- .split(" ")
38
- .map((w) => (w ? w[0]!.toUpperCase() + w.slice(1) : ""))
39
- .join(" ");
40
- }
41
-
42
21
  /** Returns a human-readable name for a label id, or null if unknown. */
43
22
  export function labelDisplay(id: string | null | undefined): string | null {
44
23
  if (!id) return null;
45
- if (id in LABEL_DISPLAY) {
46
- return LABEL_DISPLAY[id as LabelId];
47
- }
48
- return prettySlug(id);
24
+ return LABEL_DISPLAY[id as LabelId] ?? null;
49
25
  }
50
26
 
51
- // ── Status ids (the `health.status` field on a record) ───────────
27
+ // ── Status ids (the `status` field on an item) ───────────────────────
52
28
  /**
53
- * Canonical status display map. Includes the curated
54
- * "needs-maintainer" composite that's emitted by the
55
- * `needs-maintainer` lens.
29
+ * Canonical status ids, including the curated "needs-maintainer"
30
+ * composite that's emitted by the `needs-maintainer` lens (it expands
31
+ * to "stale,quiet" in URL state but presents as one chip).
56
32
  */
57
33
  export const STATUS_DISPLAY: Record<string, string> = {
58
34
  active: "Active",
59
35
  mature: "Mature",
36
+ quiet: "Quiet",
60
37
  stale: "Not recently active",
61
38
  inactive: "Inactive",
39
+ "needs-maintainer": "Needs maintainer",
62
40
  archived: "Archived",
63
- unknown: "Unknown",
64
41
  historical: "Historical",
65
42
  needs_review: "Needs review",
66
- quiet: "Quiet",
67
43
  unavailable: "Unavailable",
68
- "needs-maintainer": "Needs maintainer",
44
+ unknown: "Unknown",
69
45
  };
70
46
 
71
- export function statusDisplay(
72
- s: HealthStatus | string | null | undefined,
73
- ): string {
47
+ export function statusDisplay(s: string | null | undefined): string {
74
48
  if (!s) return STATUS_DISPLAY.unknown;
75
- return STATUS_DISPLAY[s] ?? prettySlug(s);
49
+ return STATUS_DISPLAY[s] ?? STATUS_DISPLAY.unknown;
76
50
  }
77
51
 
78
- // ── Lens ids (the curated tabs on /projects) ─────────────────────
52
+ // ── Lens ids (the curated tabs on /items) ────────────────────────────
79
53
  export const LENS_DISPLAY: Record<string, string> = {
80
- all: "All",
81
- hot: "Trending",
54
+ all: "All items",
82
55
  new: "Recently added",
56
+ hot: "Trending",
83
57
  mature: "Established",
84
- featured: "Featured",
85
- "needs-review": "Needs review",
58
+ "good-to-learn": "Good to learn",
59
+ "production-like": "Production-like",
60
+ "beginner-friendly": "Beginner friendly",
61
+ "contribution-ready": "Contribution ready",
62
+ launches: "Launches",
63
+ "actively-developed": "Active",
64
+ "needs-maintainer": "Needs maintainer",
86
65
  };
87
66
 
88
67
  export function lensDisplay(id: string | null | undefined): string {
89
68
  if (!id) return LENS_DISPLAY.all;
90
- return LENS_DISPLAY[id] ?? prettySlug(id);
69
+ return LENS_DISPLAY[id] ?? id;
91
70
  }
92
71
 
93
- // ── Sort ids (the /projects sort dropdown) ───────────────────────
72
+ // ── Sort ids (the /items sort dropdown) ──────────────────────────────
94
73
  export const SORT_DISPLAY: Record<string, string> = {
95
74
  "recently-updated": "Recently updated",
96
75
  "most-starred": "Most starred",
@@ -101,18 +80,32 @@ export const SORT_DISPLAY: Record<string, string> = {
101
80
 
102
81
  export function sortDisplay(id: string | null | undefined): string {
103
82
  if (!id) return SORT_DISPLAY["recently-updated"];
104
- return SORT_DISPLAY[id] ?? prettySlug(id);
83
+ return SORT_DISPLAY[id] ?? id;
84
+ }
85
+
86
+ export function prettySlug(value: string | null | undefined): string {
87
+ if (!value) return "";
88
+ return value
89
+ .split(/[-_]+/)
90
+ .filter(Boolean)
91
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
92
+ .join(" ");
105
93
  }
106
94
 
107
- // ── Status option list (used by filter dropdowns) ───────────────
95
+ // ── Status option list (used by filter dropdowns) ───────────────────
108
96
  // "needs-maintainer" is included even though it's a derived/composite
109
97
  // status — filter dropdowns and chip rendering need to offer it
110
- // alongside the raw HealthStatus values.
98
+ // alongside the raw AppStatus values.
111
99
  export const STATUS_OPTIONS: string[] = [
112
100
  "active",
101
+ "mature",
113
102
  "quiet",
114
103
  "stale",
104
+ "inactive",
115
105
  "needs-maintainer",
116
106
  "archived",
107
+ "historical",
108
+ "needs_review",
109
+ "unavailable",
117
110
  "unknown",
118
111
  ];
package/src/lib/format.ts CHANGED
@@ -1,9 +1,8 @@
1
1
  /**
2
- * Shared formatting helpers.
2
+ * Shared formatting helpers used across components.
3
3
  *
4
- * Dependency-free — importable by both server-rendered Astro
5
- * components and any future client-side script. No `Intl`-heavy
6
- * calls that would explode the client bundle.
4
+ * Keep these dependency-free so they can be imported by both server-
5
+ * rendered Astro components and any future client-side scripts.
7
6
  */
8
7
 
9
8
  /**
@@ -19,7 +18,7 @@ export function compact(n: number): string {
19
18
  }
20
19
 
21
20
  /**
22
- * Format a star count compactly. Returns `null` when input is not a
21
+ * Format a star count compactly. Returns null when input is not a
23
22
  * finite number, so callers can render "—" without an extra guard.
24
23
  */
25
24
  export function formatStars(n: number | null | undefined): string | null {
@@ -32,9 +31,9 @@ export function formatStars(n: number | null | undefined): string | null {
32
31
  * Returns "—" for missing or invalid input, and "today" for future
33
32
  * dates (which can happen with bad upstream timestamps).
34
33
  */
35
- export function formatRelative(date: string | null | undefined): string {
36
- if (!date) return "—";
37
- const d = new Date(date);
34
+ export function formatRelative(iso: string | null | undefined): string {
35
+ if (!iso) return "—";
36
+ const d = new Date(iso);
38
37
  if (Number.isNaN(d.valueOf())) return "—";
39
38
  const days = Math.floor((Date.now() - d.valueOf()) / (1000 * 60 * 60 * 24));
40
39
  if (days <= 0) return "today";
@@ -53,10 +52,10 @@ export function formatRelative(date: string | null | undefined): string {
53
52
  * Returns the original string for unparseable input, or "—" when
54
53
  * no value is provided.
55
54
  */
56
- export function formatDate(date: string | null | undefined): string {
57
- if (!date) return "—";
58
- const d = new Date(date);
59
- if (Number.isNaN(d.valueOf())) return date;
55
+ export function formatDate(iso: string | null | undefined): string {
56
+ if (!iso) return "—";
57
+ const d = new Date(iso);
58
+ if (Number.isNaN(d.valueOf())) return iso;
60
59
  return d.toLocaleDateString("en-US", {
61
60
  year: "numeric",
62
61
  month: "short",