@grove-dev/astro 0.1.5 → 0.2.5

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 (148) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.d.ts +4 -25
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +56 -22
  5. package/dist/index.js.map +1 -1
  6. package/dist/layouts/index.d.ts +25 -0
  7. package/dist/layouts/index.d.ts.map +1 -0
  8. package/dist/layouts/index.js +25 -0
  9. package/dist/layouts/index.js.map +1 -0
  10. package/dist/lib/display.d.ts +33 -0
  11. package/dist/lib/display.d.ts.map +1 -0
  12. package/dist/lib/display.js +104 -0
  13. package/dist/lib/display.js.map +1 -0
  14. package/dist/lib/format.d.ts +30 -0
  15. package/dist/lib/format.d.ts.map +1 -0
  16. package/dist/lib/format.js +73 -0
  17. package/dist/lib/format.js.map +1 -0
  18. package/dist/lib/index.d.ts +26 -0
  19. package/dist/lib/index.d.ts.map +1 -0
  20. package/dist/lib/index.js +26 -0
  21. package/dist/lib/index.js.map +1 -0
  22. package/dist/lib/lenses.d.ts +96 -0
  23. package/dist/lib/lenses.d.ts.map +1 -0
  24. package/dist/lib/lenses.js +181 -0
  25. package/dist/lib/lenses.js.map +1 -0
  26. package/dist/lib/repo.d.ts +30 -0
  27. package/dist/lib/repo.d.ts.map +1 -0
  28. package/dist/lib/repo.js +44 -0
  29. package/dist/lib/repo.js.map +1 -0
  30. package/dist/lib/scores.d.ts +38 -0
  31. package/dist/lib/scores.d.ts.map +1 -0
  32. package/dist/lib/scores.js +83 -0
  33. package/dist/lib/scores.js.map +1 -0
  34. package/dist/lib/search.d.ts +199 -0
  35. package/dist/lib/search.d.ts.map +1 -0
  36. package/dist/lib/search.js +357 -0
  37. package/dist/lib/search.js.map +1 -0
  38. package/dist/lib/taxonomy-counts.d.ts +25 -0
  39. package/dist/lib/taxonomy-counts.d.ts.map +1 -0
  40. package/dist/lib/taxonomy-counts.js +41 -0
  41. package/dist/lib/taxonomy-counts.js.map +1 -0
  42. package/package.json +7 -7
  43. package/src/components/AppsIndexRow.astro +204 -0
  44. package/src/components/AppsPagination.astro +126 -0
  45. package/src/components/CategoryGrid.astro +86 -17
  46. package/src/components/ContributorsGrid.astro +149 -0
  47. package/src/components/DecisionRow.astro +89 -0
  48. package/src/components/ExploreByCategory.astro +67 -0
  49. package/src/components/ExploreByStack.astro +72 -0
  50. package/src/components/Hero.astro +246 -0
  51. package/src/components/Icon.astro +223 -0
  52. package/src/components/ItemCard.astro +306 -30
  53. package/src/components/MinimalAbout.astro +82 -0
  54. package/src/components/OriginalCollection.astro +116 -0
  55. package/src/components/RecordSection.astro +88 -0
  56. package/src/components/RefinePanel.astro +197 -0
  57. package/src/components/ScoreBars.astro +92 -22
  58. package/src/components/SmartLensTabs.astro +71 -0
  59. package/src/components/StackGrid.astro +132 -0
  60. package/src/components/WhyThisExists.astro +116 -0
  61. package/src/index.ts +61 -14
  62. package/src/layouts/BaseLayout.astro +227 -15
  63. package/src/layouts/Container.astro +57 -0
  64. package/src/layouts/Footer.astro +235 -0
  65. package/src/layouts/Header.astro +159 -0
  66. package/src/layouts/SectionHeader.astro +58 -0
  67. package/{templates/default/src/components/layout → src/layouts}/Seo.astro +62 -38
  68. package/src/layouts/ThemeToggle.astro +110 -0
  69. package/src/layouts/astro-shim.d.ts +29 -0
  70. package/src/layouts/index.ts +24 -0
  71. package/src/lib/display.ts +118 -0
  72. package/src/lib/format.ts +65 -0
  73. package/src/lib/index.ts +26 -0
  74. package/src/lib/lenses.ts +241 -0
  75. package/src/lib/repo.ts +44 -0
  76. package/src/lib/scores.ts +94 -0
  77. package/src/lib/search.ts +481 -0
  78. package/src/lib/taxonomy-counts.ts +60 -0
  79. package/src/styles.css +366 -638
  80. package/templates/default/astro.config.mjs +8 -6
  81. package/templates/default/content/pages/about.md +66 -0
  82. package/templates/default/content/pages/methodology.md +103 -0
  83. package/templates/default/data/generated/records.full.json +8 -0
  84. package/templates/default/data/generated/records.index.json +7 -0
  85. package/templates/default/data/generated/records.json +8 -0
  86. package/templates/default/data/generated/site-config.json +31 -0
  87. package/templates/default/data/records/.gitkeep +0 -0
  88. package/templates/default/grove.config.ts +36 -0
  89. package/templates/default/package.json +6 -5
  90. package/templates/default/public/llms-full.txt +14 -0
  91. package/templates/default/public/llms.txt +11 -0
  92. package/templates/default/src/components/layout/BaseLayout.astro +10 -57
  93. package/templates/default/src/components/layout/Footer.astro +16 -134
  94. package/templates/default/src/components/layout/Header.astro +25 -126
  95. package/templates/default/src/data/records.ts +193 -0
  96. package/templates/default/src/lib/markdown.ts +171 -0
  97. package/templates/default/src/pages/about.astro +95 -429
  98. package/templates/default/src/pages/index.astro +263 -61
  99. package/templates/default/src/pages/projects/[slug].astro +480 -0
  100. package/templates/default/src/pages/projects/index.astro +193 -0
  101. package/templates/default/src/pages/sitemap.xml.ts +73 -61
  102. package/templates/default/src/pages/submit.astro +122 -411
  103. package/templates/default/src/styles/global.css +42 -68
  104. package/src/components/ActiveFilterChips.astro +0 -20
  105. package/src/components/DirectoryFilters.astro +0 -100
  106. package/src/components/DirectoryHero.astro +0 -32
  107. package/src/components/HealthBadge.astro +0 -12
  108. package/src/components/LensTabs.astro +0 -26
  109. package/src/components/MethodologyPanel.astro +0 -20
  110. package/src/components/Pagination.astro +0 -24
  111. package/src/components/ProjectDetail.astro +0 -166
  112. package/src/components/SubmitDraft.astro +0 -137
  113. package/templates/default/src/components/AppCard.astro +0 -248
  114. package/templates/default/src/components/AppSection.astro +0 -58
  115. package/templates/default/src/components/AppsIndexRow.astro +0 -230
  116. package/templates/default/src/components/AppsPagination.astro +0 -147
  117. package/templates/default/src/components/CategoryGrid.astro +0 -80
  118. package/templates/default/src/components/ContributorsGrid.astro +0 -105
  119. package/templates/default/src/components/DecisionRow.astro +0 -219
  120. package/templates/default/src/components/ExploreByCategory.astro +0 -74
  121. package/templates/default/src/components/ExploreByStack.astro +0 -102
  122. package/templates/default/src/components/Hero.astro +0 -176
  123. package/templates/default/src/components/MinimalAbout.astro +0 -57
  124. package/templates/default/src/components/OriginalCollection.astro +0 -144
  125. package/templates/default/src/components/RefinePanel.astro +0 -272
  126. package/templates/default/src/components/ScoreBars.astro +0 -72
  127. package/templates/default/src/components/SmartLensTabs.astro +0 -65
  128. package/templates/default/src/components/StackGrid.astro +0 -95
  129. package/templates/default/src/components/WhyThisExists.astro +0 -81
  130. package/templates/default/src/components/icons/Icon.astro +0 -148
  131. package/templates/default/src/components/layout/SectionHeader.astro +0 -42
  132. package/templates/default/src/components/layout/ThemeToggle.astro +0 -97
  133. package/templates/default/src/data/apps.ts +0 -149
  134. package/templates/default/src/data/categories.ts +0 -27
  135. package/templates/default/src/data/config.ts +0 -28
  136. package/templates/default/src/data/contributors.ts +0 -96
  137. package/templates/default/src/data/stacks.ts +0 -47
  138. package/templates/default/src/data/stats.ts +0 -65
  139. package/templates/default/src/data/types.ts +0 -248
  140. package/templates/default/src/lib/apps-search.ts +0 -228
  141. package/templates/default/src/lib/format.ts +0 -36
  142. package/templates/default/src/lib/lenses.ts +0 -98
  143. package/templates/default/src/lib/repo.ts +0 -12
  144. package/templates/default/src/lib/scores.ts +0 -51
  145. package/templates/default/src/lib/taxonomy-counts.ts +0 -31
  146. package/templates/default/src/pages/apps/[slug].astro +0 -709
  147. package/templates/default/src/pages/apps/index.astro +0 -669
  148. package/templates/default/src/pages/contributors.astro +0 -142
@@ -0,0 +1,67 @@
1
+ ---
2
+ /**
3
+ * ExploreByCategory — compact, count-only browse-by-category row.
4
+ *
5
+ * Light sibling of CategoryGrid. Pill-style links with a count.
6
+ * Generic: takes `{ name, slug, count }` entries.
7
+ */
8
+
9
+ interface CategoryEntry {
10
+ name: string;
11
+ slug: string;
12
+ count?: number;
13
+ }
14
+
15
+ interface Props {
16
+ categories: CategoryEntry[];
17
+ pathPrefix?: string;
18
+ title?: string;
19
+ description?: string;
20
+ class?: string;
21
+ }
22
+
23
+ const {
24
+ categories,
25
+ pathPrefix = "/",
26
+ title = "Explore by category",
27
+ description,
28
+ class: className = "",
29
+ } = Astro.props;
30
+
31
+ function buildHref(slug: string): string {
32
+ const base = pathPrefix.replace(/\/$/, "");
33
+ return `${base}?category=${encodeURIComponent(slug)}`;
34
+ }
35
+ ---
36
+
37
+ <section class:list={["grove-section-tight", className]}>
38
+ <div class="grove-container-wide mx-auto px-5 sm:px-7">
39
+ <header class="mb-3 flex flex-col gap-1">
40
+ <h2 class="m-0 text-[1.125rem] font-semibold tracking-tight text-ink-900 dark:text-ink-100">
41
+ {title}
42
+ </h2>
43
+ {description && (
44
+ <p class="m-0 text-[0.8125rem] text-ink-500 dark:text-ink-400">
45
+ {description}
46
+ </p>
47
+ )}
48
+ </header>
49
+ <ul class="m-0 flex list-none flex-wrap gap-2 p-0">
50
+ {categories.map((c) => (
51
+ <li>
52
+ <a
53
+ href={buildHref(c.slug)}
54
+ class="grove-badge inline-flex items-center gap-1.5 transition-colors hover:border-ink-400 dark:hover:border-ink-600"
55
+ data-event="explore_category_click"
56
+ data-event-source="explore_category"
57
+ >
58
+ <span>{c.name}</span>
59
+ {typeof c.count === "number" && (
60
+ <span class="font-mono text-ink-400">{c.count}</span>
61
+ )}
62
+ </a>
63
+ </li>
64
+ ))}
65
+ </ul>
66
+ </div>
67
+ </section>
@@ -0,0 +1,72 @@
1
+ ---
2
+ /**
3
+ * ExploreByStack — compact, count-only browse-by-stack row.
4
+ *
5
+ * The "explore" companion to StackGrid. Renders a single dense row
6
+ * of pill links (one per stack) with a count next to each. No
7
+ * cards, no blurbs — meant for the section that fits below the
8
+ * primary lens grid.
9
+ *
10
+ * Generic: takes a list of `{ name, slug, count }` entries. No
11
+ * blueprint-specific terms.
12
+ */
13
+
14
+ interface StackEntry {
15
+ name: string;
16
+ slug: string;
17
+ count?: number;
18
+ }
19
+
20
+ interface Props {
21
+ stacks: StackEntry[];
22
+ pathPrefix?: string;
23
+ title?: string;
24
+ description?: string;
25
+ class?: string;
26
+ }
27
+
28
+ const {
29
+ stacks,
30
+ pathPrefix = "/",
31
+ title = "Explore by stack",
32
+ description,
33
+ class: className = "",
34
+ } = Astro.props;
35
+
36
+ function buildHref(slug: string): string {
37
+ const base = pathPrefix.replace(/\/$/, "");
38
+ return `${base}?stack=${encodeURIComponent(slug)}`;
39
+ }
40
+ ---
41
+
42
+ <section class:list={["grove-section-tight", className]}>
43
+ <div class="grove-container-wide mx-auto px-5 sm:px-7">
44
+ <header class="mb-3 flex flex-col gap-1">
45
+ <h2 class="m-0 text-[1.125rem] font-semibold tracking-tight text-ink-900 dark:text-ink-100">
46
+ {title}
47
+ </h2>
48
+ {description && (
49
+ <p class="m-0 text-[0.8125rem] text-ink-500 dark:text-ink-400">
50
+ {description}
51
+ </p>
52
+ )}
53
+ </header>
54
+ <ul class="m-0 flex list-none flex-wrap gap-2 p-0">
55
+ {stacks.map((s) => (
56
+ <li>
57
+ <a
58
+ href={buildHref(s.slug)}
59
+ class="grove-badge inline-flex items-center gap-1.5 transition-colors hover:border-ink-400 dark:hover:border-ink-600"
60
+ data-event="explore_stack_click"
61
+ data-event-source="explore_stack"
62
+ >
63
+ <span>{s.name}</span>
64
+ {typeof s.count === "number" && (
65
+ <span class="font-mono text-ink-400">{s.count}</span>
66
+ )}
67
+ </a>
68
+ </li>
69
+ ))}
70
+ </ul>
71
+ </div>
72
+ </section>
@@ -0,0 +1,246 @@
1
+ ---
2
+ /**
3
+ * Hero — the home page banner.
4
+ *
5
+ * Renders: trust stats (apps, categories, stacks, contributors, stars),
6
+ * a search form, an animated gradient backdrop, and up to 2 CTAs.
7
+ *
8
+ * The search form is a static GET form pointing at `searchHref` (the
9
+ * list page) with a `q` query param. JS enhancement is a consumer
10
+ * concern (e.g. the showcase wires the home hero's input into the
11
+ * list page filter via a custom event).
12
+ *
13
+ * Generic: every value comes through props — site name, tagline,
14
+ * stats, search target. No siteConfig import.
15
+ */
16
+
17
+ interface CTA {
18
+ label: string;
19
+ href: string;
20
+ variant?: "primary" | "outline" | "ghost";
21
+ external?: boolean;
22
+ }
23
+
24
+ interface HeroStats {
25
+ apps?: number;
26
+ categories?: number;
27
+ stacks?: number;
28
+ contributors?: number;
29
+ stars?: number;
30
+ }
31
+
32
+ interface Props {
33
+ /** Site/brand name — rendered as the heading. */
34
+ name: string;
35
+ /** Short tagline displayed under the heading. */
36
+ tagline: string;
37
+ /** Longer description, rendered below the tagline. */
38
+ description?: string;
39
+ /** Trust stats row. */
40
+ stats?: HeroStats;
41
+ /** Placeholder for the search input. */
42
+ searchPlaceholder?: string;
43
+ /** Form action — the list page URL (e.g. "/projects"). */
44
+ searchHref?: string;
45
+ /** Optional CTAs rendered next to the search form. */
46
+ ctas?: CTA[];
47
+ /** Extra class for the outer <section>. */
48
+ class?: string;
49
+ /** Eyebrow text rendered above the title. */
50
+ eyebrow?: string;
51
+ }
52
+
53
+ const {
54
+ name,
55
+ tagline,
56
+ description,
57
+ stats = {},
58
+ searchPlaceholder = "Search the directory...",
59
+ searchHref = "/",
60
+ ctas = [],
61
+ class: className = "",
62
+ eyebrow,
63
+ } = Astro.props;
64
+
65
+ function fmt(n: number | undefined): string {
66
+ if (typeof n !== "number" || !Number.isFinite(n)) return "—";
67
+ if (n >= 1000) {
68
+ const k = n / 1000;
69
+ return (k >= 10 ? Math.round(k).toString() : k.toFixed(1)) + "k";
70
+ }
71
+ return n.toString();
72
+ }
73
+
74
+ const hasStats =
75
+ stats.apps !== undefined ||
76
+ stats.categories !== undefined ||
77
+ stats.stacks !== undefined ||
78
+ stats.contributors !== undefined ||
79
+ stats.stars !== undefined;
80
+ ---
81
+
82
+ <section
83
+ class:list={[
84
+ "grove-hero relative overflow-hidden border-b border-ink-200 dark:border-ink-800",
85
+ className,
86
+ ]}
87
+ aria-labelledby="grove-hero-heading"
88
+ >
89
+ <div
90
+ aria-hidden="true"
91
+ class="grove-hero__gradient pointer-events-none absolute inset-0 -z-10"
92
+ ></div>
93
+
94
+ <div class="grove-container-wide mx-auto px-5 py-16 sm:px-7 sm:py-20">
95
+ <div class="max-w-3xl">
96
+ {eyebrow && (
97
+ <span class="text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">
98
+ {eyebrow}
99
+ </span>
100
+ )}
101
+ <h1
102
+ id="grove-hero-heading"
103
+ class="m-0 mt-2 text-[2.25rem] font-semibold leading-[1.05] tracking-tight text-ink-900 sm:text-[3rem] dark:text-ink-100"
104
+ >
105
+ {name}
106
+ </h1>
107
+ <p class="m-0 mt-4 max-w-2xl text-lg leading-relaxed text-ink-700 dark:text-ink-200">
108
+ {tagline}
109
+ </p>
110
+ {description && (
111
+ <p class="m-0 mt-2 max-w-2xl text-[0.9375rem] leading-relaxed text-ink-500 dark:text-ink-400">
112
+ {description}
113
+ </p>
114
+ )}
115
+
116
+ <form
117
+ class="mt-8 flex flex-col gap-2 sm:flex-row sm:items-center"
118
+ role="search"
119
+ action={searchHref}
120
+ method="get"
121
+ data-grove-hero-search
122
+ >
123
+ <label class="relative flex-1">
124
+ <span class="sr-only">Search</span>
125
+ <svg
126
+ aria-hidden="true"
127
+ viewBox="0 0 16 16"
128
+ width="14"
129
+ height="14"
130
+ class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-ink-400"
131
+ fill="currentColor"
132
+ >
133
+ <path d="M10.68 11.74a6 6 0 1 1 1.06-1.06l3.04 3.04a.75.75 0 1 1-1.06 1.06l-3.04-3.04ZM11.5 7a4.5 4.5 0 1 0-9 0 4.5 4.5 0 0 0 9 0Z"></path>
134
+ </svg>
135
+ <input
136
+ type="search"
137
+ name="q"
138
+ placeholder={searchPlaceholder}
139
+ autocomplete="off"
140
+ class="w-full rounded-md border border-ink-200 bg-white py-2.5 pl-9 pr-3 text-sm placeholder:text-ink-400 focus:border-ink-900 focus:outline-none focus:ring-2 focus:ring-ink-900/10 dark:border-ink-800 dark:bg-ink-950 dark:text-ink-100"
141
+ />
142
+ </label>
143
+ <button type="submit" class="grove-btn grove-btn-primary">
144
+ Search
145
+ </button>
146
+ </form>
147
+
148
+ {ctas.length > 0 && (
149
+ <div class="mt-4 flex flex-wrap items-center gap-2">
150
+ {ctas.map((cta) => (
151
+ <a
152
+ href={cta.href}
153
+ class:list={[
154
+ "grove-btn",
155
+ cta.variant === "primary"
156
+ ? "grove-btn-primary"
157
+ : cta.variant === "ghost"
158
+ ? "grove-btn-ghost"
159
+ : "grove-btn-outline",
160
+ ]}
161
+ {...(cta.external
162
+ ? { target: "_blank", rel: "noopener noreferrer" }
163
+ : {})}
164
+ data-event="hero_cta_click"
165
+ data-event-source="hero"
166
+ >
167
+ {cta.label}
168
+ </a>
169
+ ))}
170
+ </div>
171
+ )}
172
+
173
+ {hasStats && (
174
+ <dl
175
+ class="mt-10 flex flex-wrap items-center gap-x-6 gap-y-3 text-2xs text-ink-500 dark:text-ink-400"
176
+ aria-label="Directory statistics"
177
+ >
178
+ {stats.apps !== undefined && (
179
+ <div class="flex items-baseline gap-1.5">
180
+ <dd class="m-0 font-mono text-base font-semibold text-ink-900 dark:text-ink-100">
181
+ {fmt(stats.apps)}
182
+ </dd>
183
+ <dt>apps</dt>
184
+ </div>
185
+ )}
186
+ {stats.categories !== undefined && (
187
+ <div class="flex items-baseline gap-1.5">
188
+ <dd class="m-0 font-mono text-base font-semibold text-ink-900 dark:text-ink-100">
189
+ {fmt(stats.categories)}
190
+ </dd>
191
+ <dt>categories</dt>
192
+ </div>
193
+ )}
194
+ {stats.stacks !== undefined && (
195
+ <div class="flex items-baseline gap-1.5">
196
+ <dd class="m-0 font-mono text-base font-semibold text-ink-900 dark:text-ink-100">
197
+ {fmt(stats.stacks)}
198
+ </dd>
199
+ <dt>stacks</dt>
200
+ </div>
201
+ )}
202
+ {stats.contributors !== undefined && (
203
+ <div class="flex items-baseline gap-1.5">
204
+ <dd class="m-0 font-mono text-base font-semibold text-ink-900 dark:text-ink-100">
205
+ {fmt(stats.contributors)}
206
+ </dd>
207
+ <dt>contributors</dt>
208
+ </div>
209
+ )}
210
+ {stats.stars !== undefined && (
211
+ <div class="flex items-baseline gap-1.5">
212
+ <dd class="m-0 font-mono text-base font-semibold text-ink-900 dark:text-ink-100">
213
+ {fmt(stats.stars)}
214
+ </dd>
215
+ <dt>total stars</dt>
216
+ </div>
217
+ )}
218
+ </dl>
219
+ )}
220
+ </div>
221
+ </div>
222
+ </section>
223
+
224
+ <style>
225
+ .grove-hero__gradient {
226
+ background: linear-gradient(
227
+ 120deg,
228
+ rgba(31, 111, 235, 0.08) 0%,
229
+ rgba(110, 231, 183, 0.06) 35%,
230
+ rgba(253, 186, 116, 0.06) 65%,
231
+ rgba(147, 197, 253, 0.08) 100%
232
+ );
233
+ background-size: 200% 200%;
234
+ animation: var(--animate-gradient-shift, gradient-shift 8s ease-in-out infinite);
235
+ }
236
+ :global(.dark) .grove-hero__gradient {
237
+ background: linear-gradient(
238
+ 120deg,
239
+ rgba(31, 111, 235, 0.18) 0%,
240
+ rgba(6, 95, 70, 0.12) 35%,
241
+ rgba(67, 20, 7, 0.12) 65%,
242
+ rgba(30, 64, 175, 0.18) 100%
243
+ );
244
+ background-size: 200% 200%;
245
+ }
246
+ </style>
@@ -0,0 +1,223 @@
1
+ ---
2
+ /**
3
+ * Icon — brand icon registry.
4
+ *
5
+ * Renders an `<img src="/icons/{stacks|platforms|brands}/{name}.svg" />`
6
+ * for a given registry name. The Astro integration ships the default
7
+ * `public/icons/{stacks,platforms}/` assets inside the `default`
8
+ * template's `public/` directory; consumers can ship their own SVGs
9
+ * under the same path (or override the `base` prop) without forking
10
+ * this component.
11
+ *
12
+ * The component is **generic** — it does not import any consumer-side
13
+ * `siteConfig`, `app`, or blueprint-specific module. Every consumer
14
+ * supplies its own icon list (and optionally an `aliases` map for
15
+ * friendly ids) via props, and any `name` not in the registry
16
+ * falls back to the initials placeholder.
17
+ *
18
+ * `category` selects the subfolder:
19
+ *
20
+ * - "stack" → `/icons/stacks/{name}.svg`
21
+ * - "platform" → `/icons/platforms/{name}.svg`
22
+ * - "brand" → `/icons/brands/{name}.svg` (consumer-supplied)
23
+ *
24
+ * Theme-aware: when the consumer sets `mode: "auto"` the component
25
+ * looks for `{name}-light.svg` and `{name}-dark.svg` siblings in
26
+ * addition to the default `{name}.svg` and switches the `src` based
27
+ * on the resolved theme via the inline `data-grove-icon` script.
28
+ * For static (non-themed) icons this is a no-op — set `mode: "static"`
29
+ * (the default) to keep the markup simple and avoid hydration cost.
30
+ *
31
+ * Aliases: a tiny `aliases` map (e.g. `rn → react-native`) lets the
32
+ * registry accept multiple ids for the same asset without a renames
33
+ * pass. The lookup falls through the aliases map before failing over
34
+ * to the initials placeholder.
35
+ */
36
+ import { getOwnerAndRepoFromRepoUrl } from "../lib/repo";
37
+
38
+ export type IconCategory = "stack" | "platform" | "brand";
39
+ export type IconMode = "static" | "auto";
40
+
41
+ interface Props {
42
+ /**
43
+ * Registry id. Resolved through `aliases` first, then matched
44
+ * against the SVG file name (case-insensitive, slug-normalized).
45
+ */
46
+ name: string;
47
+ /** Subfolder under `/icons/`. */
48
+ category: IconCategory;
49
+ /** Pixel size (square). Defaults to 16. */
50
+ size?: number;
51
+ /** Extra class for the outer wrapper. */
52
+ class?: string;
53
+ /** Tooltip / aria-label. */
54
+ title?: string;
55
+ /**
56
+ * Static (single SVG) or auto (light/dark variants via inline
57
+ * script). Default "static".
58
+ */
59
+ mode?: IconMode;
60
+ /**
61
+ * Optional id → id remap. Lookup goes `name → aliases[name] →
62
+ * {name}.svg` (case-insensitive).
63
+ */
64
+ aliases?: Record<string, string>;
65
+ /**
66
+ * Initials to render when the icon file is missing. Defaults to
67
+ * the first 1–2 characters of `name`.
68
+ */
69
+ fallbackInitials?: string;
70
+ /**
71
+ * Base path for icon files. Defaults to `/icons` (matches the
72
+ * default template's `public/icons/`).
73
+ */
74
+ base?: string;
75
+ }
76
+
77
+ const {
78
+ name,
79
+ category,
80
+ size = 16,
81
+ class: className = "",
82
+ title,
83
+ mode = "static",
84
+ aliases,
85
+ fallbackInitials,
86
+ base = "/icons",
87
+ } = Astro.props;
88
+
89
+ // Normalize: lowercase + replace spaces/underscores with dashes.
90
+ function slugify(s: string): string {
91
+ return s.toLowerCase().trim().replace(/[\s_]+/g, "-");
92
+ }
93
+
94
+ // Resolve `name` through the aliases map.
95
+ function resolveName(raw: string): string {
96
+ if (!raw) return "";
97
+ if (aliases) {
98
+ const lower = raw.toLowerCase();
99
+ for (const [k, v] of Object.entries(aliases)) {
100
+ if (k.toLowerCase() === lower) return slugify(v);
101
+ }
102
+ }
103
+ return slugify(raw);
104
+ }
105
+
106
+ const resolved = resolveName(name);
107
+ const folder =
108
+ category === "stack" ? "stacks" : category === "platform" ? "platforms" : "brands";
109
+ const baseUrl = `${base.replace(/\/$/, "")}/${folder}/${resolved}`;
110
+
111
+ // Static: just point at the canonical svg.
112
+ const staticSrc = `${baseUrl}.svg`;
113
+
114
+ // Initials fallback: first 1–2 alphanumerics, uppercased.
115
+ const init = (fallbackInitials ?? name ?? "")
116
+ .replace(/[^a-z0-9]/gi, "")
117
+ .slice(0, 2)
118
+ .toUpperCase();
119
+ const showFallback = !resolved;
120
+
121
+ // Build aria-label.
122
+ const ariaLabel = title ?? name;
123
+ const a11y: Record<string, string> = title
124
+ ? { "aria-label": ariaLabel, role: "img" }
125
+ : { "aria-hidden": "true", "focusable": "false" };
126
+ ---
127
+
128
+ {
129
+ showFallback ? (
130
+ <span
131
+ class:list={["grove-icon grove-icon-fallback", className]}
132
+ style={`width:${size}px;height:${size}px;font-size:${Math.max(10, size - 6)}px;`}
133
+ {...a11y}
134
+ >
135
+ {init || "·"}
136
+ </span>
137
+ ) : mode === "auto" ? (
138
+ <img
139
+ src={staticSrc}
140
+ data-grove-icon
141
+ data-grove-icon-light={`${baseUrl}-light.svg`}
142
+ data-grove-icon-dark={`${baseUrl}-dark.svg`}
143
+ data-grove-icon-fallback={staticSrc}
144
+ width={size}
145
+ height={size}
146
+ alt=""
147
+ class:list={["grove-icon grove-icon-themed", className]}
148
+ {...a11y}
149
+ />
150
+ ) : (
151
+ <img
152
+ src={staticSrc}
153
+ width={size}
154
+ height={size}
155
+ alt=""
156
+ class:list={["grove-icon", className]}
157
+ loading="lazy"
158
+ decoding="async"
159
+ {...a11y}
160
+ />
161
+ )
162
+ }
163
+
164
+ <style>
165
+ .grove-icon {
166
+ display: inline-block;
167
+ vertical-align: middle;
168
+ flex-shrink: 0;
169
+ }
170
+ .grove-icon-themed {
171
+ /* Theme-aware: dark/light variants selected by the inline script
172
+ below based on `data-resolved-theme` set by BaseLayout's
173
+ THEME_INIT. Default to the light variant so the markup looks
174
+ correct even before the script runs. */
175
+ }
176
+ .grove-icon-fallback {
177
+ display: inline-flex;
178
+ align-items: center;
179
+ justify-content: center;
180
+ border-radius: 0.25rem;
181
+ background: var(--color-ink-100, #f4f4f5);
182
+ color: var(--color-ink-700, #3f3f46);
183
+ font-weight: 600;
184
+ line-height: 1;
185
+ text-transform: uppercase;
186
+ user-select: none;
187
+ }
188
+ :global(.dark) .grove-icon-fallback {
189
+ background: var(--color-ink-800, #27272a);
190
+ color: var(--color-ink-200, #e4e4e7);
191
+ }
192
+ </style>
193
+
194
+ {mode === "auto" && (
195
+ <script is:inline>
196
+ // Theme-aware icon swap. Runs after THEME_INIT (BaseLayout) so
197
+ // `data-resolved-theme` is already populated. Idempotent — the
198
+ // MutationObserver picks up theme changes from ThemeToggle.
199
+ (function () {
200
+ function swap() {
201
+ var theme =
202
+ document.documentElement.dataset.resolvedTheme || "light";
203
+ var nodes = document.querySelectorAll(
204
+ "img[data-grove-icon]:not([data-grove-icon-bound])",
205
+ );
206
+ for (var i = 0; i < nodes.length; i++) {
207
+ var img = nodes[i];
208
+ img.setAttribute("data-grove-icon-bound", "1");
209
+ var next =
210
+ theme === "dark"
211
+ ? img.getAttribute("data-grove-icon-dark")
212
+ : img.getAttribute("data-grove-icon-light");
213
+ if (next) img.src = next;
214
+ }
215
+ }
216
+ swap();
217
+ new MutationObserver(swap).observe(document.documentElement, {
218
+ attributes: true,
219
+ attributeFilter: ["data-resolved-theme"],
220
+ });
221
+ })();
222
+ </script>
223
+ )}