@grove-dev/astro 0.5.1 → 0.5.3

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 (78) hide show
  1. package/assets/icons/.grove-icons.json +47 -0
  2. package/assets/icons/platforms/android.svg +1 -0
  3. package/assets/icons/platforms/app.svg +4 -0
  4. package/assets/icons/platforms/chrome.svg +1 -0
  5. package/assets/icons/platforms/desktop.svg +4 -0
  6. package/assets/icons/platforms/embedded.svg +5 -0
  7. package/assets/icons/platforms/ios.svg +1 -0
  8. package/assets/icons/platforms/linux.svg +1 -0
  9. package/assets/icons/platforms/macos.svg +1 -0
  10. package/assets/icons/platforms/ubuntu.svg +1 -0
  11. package/assets/icons/platforms/web.svg +5 -0
  12. package/assets/icons/platforms/windows.svg +1 -0
  13. package/assets/icons/stacks/android.svg +1 -0
  14. package/assets/icons/stacks/apple.svg +1 -0
  15. package/assets/icons/stacks/bun.svg +1 -0
  16. package/assets/icons/stacks/capacitor.svg +1 -0
  17. package/assets/icons/stacks/clojure.svg +1 -0
  18. package/assets/icons/stacks/dart.svg +1 -0
  19. package/assets/icons/stacks/deno.svg +1 -0
  20. package/assets/icons/stacks/django.svg +1 -0
  21. package/assets/icons/stacks/docker.svg +1 -0
  22. package/assets/icons/stacks/firebase.svg +1 -0
  23. package/assets/icons/stacks/flutter.svg +1 -0
  24. package/assets/icons/stacks/go.svg +1 -0
  25. package/assets/icons/stacks/graphql.svg +1 -0
  26. package/assets/icons/stacks/ionic.svg +1 -0
  27. package/assets/icons/stacks/java.svg +1 -0
  28. package/assets/icons/stacks/javascript.svg +1 -0
  29. package/assets/icons/stacks/kotlin.svg +1 -0
  30. package/assets/icons/stacks/llm.svg +4 -0
  31. package/assets/icons/stacks/mongodb.svg +1 -0
  32. package/assets/icons/stacks/nodejs.svg +1 -0
  33. package/assets/icons/stacks/python.svg +1 -0
  34. package/assets/icons/stacks/rag.svg +6 -0
  35. package/assets/icons/stacks/react-native.svg +1 -0
  36. package/assets/icons/stacks/react.svg +1 -0
  37. package/assets/icons/stacks/rust.svg +1 -0
  38. package/assets/icons/stacks/solidity.svg +1 -0
  39. package/assets/icons/stacks/sveltekit.svg +1 -0
  40. package/assets/icons/stacks/swift.svg +1 -0
  41. package/assets/icons/stacks/tauri.svg +1 -0
  42. package/assets/icons/stacks/tensorflow.svg +1 -0
  43. package/assets/icons/stacks/typescript.svg +1 -0
  44. package/assets/icons/stacks/vue.svg +1 -0
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +23 -4
  47. package/dist/index.js.map +1 -1
  48. package/dist/lib/icon-kinds.d.ts +13 -0
  49. package/dist/lib/icon-kinds.d.ts.map +1 -0
  50. package/dist/lib/icon-kinds.js +46 -0
  51. package/dist/lib/icon-kinds.js.map +1 -0
  52. package/dist/lib/icon-registry.d.ts +40 -0
  53. package/dist/lib/icon-registry.d.ts.map +1 -0
  54. package/dist/lib/icon-registry.js +85 -0
  55. package/dist/lib/icon-registry.js.map +1 -0
  56. package/dist/lib/index.d.ts +4 -0
  57. package/dist/lib/index.d.ts.map +1 -1
  58. package/dist/lib/index.js +4 -0
  59. package/dist/lib/index.js.map +1 -1
  60. package/dist/lib/packaged-icons.d.ts +9 -0
  61. package/dist/lib/packaged-icons.d.ts.map +1 -0
  62. package/dist/lib/packaged-icons.js +21 -0
  63. package/dist/lib/packaged-icons.js.map +1 -0
  64. package/package.json +3 -2
  65. package/src/components/CollectionRow.astro +12 -47
  66. package/src/components/Icon.astro +74 -152
  67. package/src/components/IndexRow.astro +7 -25
  68. package/src/components/ProjectCard.astro +76 -117
  69. package/src/components/RefinePanel.astro +10 -1
  70. package/src/components/SmartLensTabs.astro +11 -1
  71. package/src/index.ts +28 -4
  72. package/src/layouts/BaseLayout.astro +11 -1
  73. package/src/layouts/Header.astro +19 -4
  74. package/src/lib/icon-kinds.ts +59 -0
  75. package/src/lib/icon-registry.ts +111 -0
  76. package/src/lib/index.ts +4 -0
  77. package/src/lib/packaged-icons.ts +32 -0
  78. package/src/styles.css +61 -0
@@ -4,10 +4,11 @@
4
4
  *
5
5
  * Keeps the historical public props (`record`, `detailHref`,
6
6
  * `showChips`, `eager`) so consumer pages copied from earlier versions
7
- * keep working, while the visual language is now identical to every
8
- * other card surface. The directory extras curated badge, license,
9
- * category · status line, labelled stack/platform chips, and the
10
- * GitHub footer link are enabled via `ProjectCard`'s opt-in props.
7
+ * keep working. The card itself is the same on every surface: a
8
+ * record must not look like a different record depending on whether
9
+ * you found it on the home page or in the browse list. The repository
10
+ * is reached through the card's `owner/repo` line, so this adapter
11
+ * adds nothing to the card at all.
11
12
  */
12
13
  import type { IndexRecord } from "@grove-dev/core";
13
14
  import ProjectCard from "./ProjectCard.astro";
@@ -15,7 +16,7 @@ import ProjectCard from "./ProjectCard.astro";
15
16
  interface Props {
16
17
  record: IndexRecord;
17
18
  detailHref: string;
18
- /** Whether to show stack/platform chips. Default true. */
19
+ /** @deprecated Ignored the card renders one fixed layout. */
19
20
  showChips?: boolean;
20
21
  /** Set true on the first 1-2 rows of a list to bypass lazy
21
22
  * loading for the above-the-fold avatars. Pair with
@@ -24,32 +25,13 @@ interface Props {
24
25
  class?: string;
25
26
  }
26
27
 
27
- const { record, detailHref, showChips = true, eager = false, class: className = "" } = Astro.props;
28
+ const { record, detailHref, eager = false, class: className = "" } = Astro.props;
28
29
 
29
- const repoHref =
30
- record.kind === "project" ? (record.repoUrl ?? record.links.github) : record.links.github;
31
-
32
- const secondaryLinks = repoHref
33
- ? [
34
- {
35
- label: "GitHub",
36
- href: repoHref,
37
- external: true,
38
- event: "external_repo_click",
39
- eventSource: "index_row_github",
40
- },
41
- ]
42
- : [];
43
30
  ---
44
31
 
45
32
  <ProjectCard
46
33
  record={record}
47
34
  href={detailHref}
48
- showCurated
49
- showLicense
50
- showCategoryStatus
51
- labelledChips={showChips}
52
- secondaryLinks={secondaryLinks}
53
35
  eager={eager}
54
36
  class={className}
55
37
  />
@@ -10,18 +10,24 @@
10
10
  * - Default mode: the whole card is one clickable `<a>` (cursor:
11
11
  * pointer, subtle border + surface lift on hover, no more than
12
12
  * 1–2px vertical motion).
13
- * - When `secondaryLinks` are passed the root becomes an `<article>`
14
- * with a stretched title link, because nesting `<a>` inside `<a>`
15
- * is invalid HTML. Secondary links sit above the stretched link
13
+ * - When the record has a repository the root becomes an
14
+ * `<article>` with a stretched title link, because nesting `<a>`
15
+ * inside `<a>` is invalid HTML. The `owner/repo` line is a real
16
+ * link to the repository and sits above the stretched overlay
16
17
  * (`relative z-10`).
17
- * - App icon top-left (48px), app name + repository owner/repo,
18
+ * - App icon top-left (48px), app name + clickable owner/repo,
18
19
  * two-line description with reserved vertical space.
19
- * - Footer row: platform logos (or labelled stack/platform chips via
20
- * `labelledChips`), stars/updated/license on the right, and the
21
- * navigation arrow anchored bottom-right.
22
- * - Everything beyond the default home-page rendering is opt-in:
23
- * `showCurated`, `showLicense`, `showCategoryStatus`,
24
- * `labelledChips`, `secondaryLinks`, analytics `data-event` props.
20
+ * - Footer row: stack logos on the left, stars and last-updated on
21
+ * the right, navigation arrow anchored bottom-right.
22
+ *
23
+ * **The layout is fixed.** Earlier versions let list pages opt into a
24
+ * curated badge, a license tag, a `category · status` line and
25
+ * labelled chip rows, so the same record rendered differently
26
+ * depending on where a visitor found it. Those flags are now
27
+ * deprecated no-ops, as is `secondaryLinks` — the repository is
28
+ * reached through the `owner/repo` line rather than a separate footer
29
+ * button. Platform support is likewise off the card: it is reference
30
+ * detail and belongs on the record page (`RecordSidebar`).
25
31
  *
26
32
  * Data can come from a full `record` or from explicit props (adapters
27
33
  * for shapes like `CollectionEntry` pass fields directly); explicit
@@ -37,14 +43,11 @@ import {
37
43
  getOwnerAvatarUrl,
38
44
  formatRelative,
39
45
  formatStars,
40
- licenseDisplay,
41
46
  nameInitials,
42
47
  projectStackIds,
43
- statusDisplay,
44
48
  } from "@grove-dev/astro";
45
49
  import CardIcon from "./CardIcon.astro";
46
50
  import Icon from "./Icon.astro";
47
- import StackPlatformChips from "./StackPlatformChips.astro";
48
51
 
49
52
  type CardRecord = IndexRecord | Resource | ProjectRecord | IndexProjectRecord;
50
53
 
@@ -78,14 +81,31 @@ interface Props {
78
81
  status?: string;
79
82
  curated?: boolean;
80
83
  stack?: string;
84
+ /**
85
+ * @deprecated Cards render the stack, not platform support — that
86
+ * belongs on the record page. Accepted and ignored.
87
+ */
81
88
  platforms?: string[];
82
- /** Opt-in extras (all default off to keep the home grid unchanged). */
89
+ /**
90
+ * @deprecated The card renders one fixed metadata set on every
91
+ * surface. These flags used to add a curated badge, a license tag,
92
+ * a `category · status` line, and labelled chip rows on list pages
93
+ * only, which made the same record look like a different record
94
+ * depending on where you found it. Accepted and ignored.
95
+ */
83
96
  showCurated?: boolean;
97
+ /** @deprecated Ignored — see `showCurated`. */
84
98
  showLicense?: boolean;
99
+ /** @deprecated Ignored — see `showCurated`. */
85
100
  showCategoryStatus?: boolean;
86
- /** Labelled stack/platform chip rows instead of the logo strip. */
101
+ /** @deprecated Ignored see `showCurated`. */
87
102
  labelledChips?: boolean;
88
103
  /** Footer links; presence switches the root to `<article>` mode. */
104
+ /**
105
+ * @deprecated Ignored. The repository is reached by clicking the
106
+ * `owner/repo` line under the title, so cards no longer carry a
107
+ * separate footer link button.
108
+ */
89
109
  secondaryLinks?: SecondaryLink[];
90
110
  /** Eager-load the avatar (above-the-fold rows). */
91
111
  eager?: boolean;
@@ -105,17 +125,7 @@ const {
105
125
  repoUrl: repoUrlProp,
106
126
  stars: starsProp,
107
127
  pushedAt: pushedAtProp,
108
- license: licenseProp,
109
- category: categoryProp,
110
- status: statusProp,
111
- curated: curatedProp,
112
128
  stack: stackProp,
113
- platforms: platformsProp,
114
- showCurated = false,
115
- showLicense = false,
116
- showCategoryStatus = false,
117
- labelledChips = false,
118
- secondaryLinks = [],
119
129
  eager = false,
120
130
  event,
121
131
  eventSource,
@@ -148,8 +158,6 @@ const initials = nameInitials(name);
148
158
  const description =
149
159
  descriptionProp ?? (record as { description?: string } | undefined)?.description ?? "";
150
160
 
151
- const platforms =
152
- platformsProp ?? (isProject ? ((record as { platforms?: string[] }).platforms ?? []) : []);
153
161
  const stars =
154
162
  starsProp ?? (isProject ? (record as { github?: { stars?: number } }).github?.stars : undefined);
155
163
  const pushedAt =
@@ -157,53 +165,34 @@ const pushedAt =
157
165
  (isProject
158
166
  ? (record as { github?: { pushedAt?: string | null } }).github?.pushedAt
159
167
  : (record as { publishedAt?: string | null } | undefined)?.publishedAt);
160
- const license =
161
- licenseProp ??
162
- (isProject ? (record as { github?: { license?: string | null } }).github?.license : undefined);
163
- const category = categoryProp ?? (record as { category?: string } | undefined)?.category;
164
- const status =
165
- statusProp ?? (isProject ? (record as { health?: { status?: string } }).health?.status : undefined);
166
- const curated =
167
- curatedProp ??
168
- Boolean(
169
- (record as { curation?: { reviewed?: boolean } } | undefined)?.curation?.reviewed ||
170
- (isProject &&
171
- ((record as { bestFor?: string[] }).bestFor?.length ||
172
- (record as { whyListed?: string[] }).whyListed?.length ||
173
- (record as { caveats?: string[] }).caveats?.length)),
174
- );
175
-
176
- const stackId =
177
- stackProp ??
178
- (isProject
179
- ? projectStackIds(record as { stack?: string; stacks?: string[] })[0]
180
- : undefined);
168
+ const stackIds = stackProp
169
+ ? [stackProp]
170
+ : isProject
171
+ ? projectStackIds(record as { stack?: string; stacks?: string[] })
172
+ : [];
181
173
 
182
174
  const starsLabel = formatStars(stars);
183
175
  const updatedLabel = formatRelative(pushedAt);
184
- // "Unknown" is an internal placeholder, not information — suppress it
185
- // so card metadata never reads "Category · Unknown" to visitors.
186
- const rawStatusText = status ? statusDisplay(status as Parameters<typeof statusDisplay>[0]) : "";
187
- const statusText = rawStatusText === "Unknown" ? "" : rawStatusText;
188
- const licenseLabel = license ? licenseDisplay(license) : null;
189
176
 
190
177
  const hasStars = starsLabel !== null && typeof stars === "number" && stars > 0;
191
178
  const hasUpdated = Boolean(pushedAt);
192
179
 
193
- // `<a>`-in-`<a>` is invalid HTML, so cards with secondary links render
194
- // as an `<article>` whose title link is stretched over the card.
195
- const isArticle = secondaryLinks.length > 0;
180
+ // `<a>`-in-`<a>` is invalid HTML, and the `owner/repo` line is its own
181
+ // link to the repository so any card that shows one renders as an
182
+ // `<article>` whose title link is stretched over the whole card.
183
+ const isArticle = Boolean(repoHref && owner && repo);
196
184
  const Root = isArticle ? "article" : "a";
197
185
  const rootAttrs = isArticle ? {} : { href, "aria-label": `Open ${name}` };
198
186
 
199
- // Render the homepage brand strip through the shared `Icon` component
200
- // so brand marks come from the same `/icons/platforms/` files the
201
- // chip rows use. No more inline SVG strings one source of truth.
202
- // `Icon` falls back to initials when an asset is missing, so the
203
- // strip simply renders whatever platforms the record declares.
204
- const visiblePlatforms = platforms.slice(0, 4);
205
- const platformOverflow = platforms.length - visiblePlatforms.length;
206
- const hasPlatforms = visiblePlatforms.length > 0;
187
+ // The card's brand strip shows the record's STACK, not its platforms.
188
+ // Stack is what distinguishes one card from its neighbours in a list;
189
+ // platform support is reference detail, and it belongs on the record
190
+ // page (`RecordSidebar`) where there is room to label it. Marks come
191
+ // from the shared `Icon` component, which falls back to initials when
192
+ // an asset is missing.
193
+ const visibleStacks = stackIds.slice(0, 4);
194
+ const stackOverflow = stackIds.length - visibleStacks.length;
195
+ const hasStacks = visibleStacks.length > 0;
207
196
  ---
208
197
 
209
198
  <Root
@@ -251,27 +240,25 @@ const hasPlatforms = visiblePlatforms.length > 0;
251
240
  </h3>
252
241
  {owner && repo && (
253
242
  <p class="m-0 truncate font-mono text-[13px] text-ink-500 dark:text-ink-400">
254
- {owner}<span class="opacity-60">/</span>{repo}
255
- </p>
256
- )}
257
- {showCategoryStatus && (category || statusText) && (
258
- <p class="m-0 truncate text-2xs text-ink-500 dark:text-ink-400">
259
- {category}
260
- {category && statusText && <span class="mx-1 text-ink-300 dark:text-ink-700">·</span>}
261
- {statusText}
243
+ {repoHref ? (
244
+ <a
245
+ href={repoHref}
246
+ target="_blank"
247
+ rel="noopener noreferrer"
248
+ title={`${owner}/${repo} on GitHub`}
249
+ data-event="external_repo_click"
250
+ data-event-source="project_card_repo"
251
+ data-event-item={slug}
252
+ class="relative z-10 text-inherit no-underline transition-colors hover:text-ink-900 hover:underline dark:hover:text-ink-100"
253
+ >
254
+ {owner}<span class="opacity-60">/</span>{repo}
255
+ </a>
256
+ ) : (
257
+ <Fragment>{owner}<span class="opacity-60">/</span>{repo}</Fragment>
258
+ )}
262
259
  </p>
263
260
  )}
264
261
  </div>
265
-
266
- {showCurated && curated && (
267
- <span
268
- class="inline-flex shrink-0 items-center gap-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground"
269
- title="Reviewed by a curator"
270
- >
271
- <CardIcon name="curated" />
272
- curated
273
- </span>
274
- )}
275
262
  </header>
276
263
 
277
264
  {/* Description — two-line clamp with reserved vertical space */}
@@ -281,39 +268,16 @@ const hasPlatforms = visiblePlatforms.length > 0;
281
268
  {description}
282
269
  </p>
283
270
 
284
- {/* Labelled stack/platform chip rows (opt-in; directory surfaces) */}
285
- {labelledChips && (
286
- <StackPlatformChips stack={stackId} platform={platforms} />
287
- )}
288
-
289
- {/* Footer: links / platform logos · meta · arrow */}
271
+ {/* Footer: stack logos · stars/updated · arrow */}
290
272
  <footer class="mt-auto flex items-center justify-between gap-3 text-[12px] sm:text-[13px]">
291
273
  <div class="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-ink-500 dark:text-ink-400">
292
- {secondaryLinks.map((link) => (
293
- <a
294
- href={link.href}
295
- {...(link.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
296
- data-event={link.event}
297
- data-event-source={link.eventSource}
298
- data-event-item={link.event ? slug : undefined}
299
- class="relative z-10 inline-flex items-center gap-1 font-medium text-ink-700 no-underline transition-colors hover:text-ink-900 dark:text-ink-300 dark:hover:text-ink-100"
300
- >
301
- {link.label}
302
- {link.external && <CardIcon name="external" />}
303
- </a>
304
- ))}
305
- {!labelledChips && hasPlatforms && (
306
- <div class="flex items-center gap-2.5">
307
- {visiblePlatforms.map((platform) => (
308
- <Icon
309
- name={platform}
310
- category="platform"
311
- size={22}
312
- title={platform}
313
- />
274
+ {hasStacks && (
275
+ <div class="flex items-center gap-2">
276
+ {visibleStacks.map((stack) => (
277
+ <Icon name={stack} category="stack" size={16} title={stack} />
314
278
  ))}
315
- {platformOverflow > 0 && (
316
- <span class="text-[12px] text-ink-500 dark:text-ink-400">+{platformOverflow}</span>
279
+ {stackOverflow > 0 && (
280
+ <span class="text-[12px] text-ink-500 dark:text-ink-400">+{stackOverflow}</span>
317
281
  )}
318
282
  </div>
319
283
  )}
@@ -331,11 +295,6 @@ const hasPlatforms = visiblePlatforms.length > 0;
331
295
  Updated {updatedLabel}
332
296
  </span>
333
297
  )}
334
- {showLicense && licenseLabel && (
335
- <span class="font-mono text-[10px] uppercase tracking-wide" title={`License: ${licenseLabel}`}>
336
- {licenseLabel}
337
- </span>
338
- )}
339
298
  <CardIcon
340
299
  name="arrow"
341
300
  class="shrink-0 text-ink-400 transition-colors group-hover:text-ink-700 dark:text-ink-500 dark:group-hover:text-ink-100"
@@ -39,6 +39,14 @@ interface Props {
39
39
  initial: IndexFilters;
40
40
  pathPrefix?: string;
41
41
  class?: string;
42
+ /**
43
+ * Emit the default flex layout on the root. Pass `false` when the
44
+ * caller owns the layout — e.g. `layout={false} class="contents"` to
45
+ * promote the filter triggers into the parent's flex row. Without
46
+ * this the root's own `flex` would win over any display utility in
47
+ * `class`, since Tailwind emits `.contents` before `.flex`.
48
+ */
49
+ layout?: boolean;
42
50
  /** Hide the sort select (e.g. when the page renders its own). */
43
51
  hide?: Array<"sort">;
44
52
  }
@@ -48,6 +56,7 @@ const {
48
56
  initial,
49
57
  pathPrefix = "/",
50
58
  class: className = "",
59
+ layout = true,
51
60
  hide = [],
52
61
  } = Astro.props;
53
62
 
@@ -69,7 +78,7 @@ const groups = groupModels.map((group) => ({
69
78
  }));
70
79
  ---
71
80
 
72
- <div class:list={["flex flex-wrap items-center gap-2", className]} role="toolbar" aria-label="Filter items">
81
+ <div class:list={[layout && "flex flex-wrap items-center gap-2", className]} role="toolbar" aria-label="Filter items">
73
82
  {groups.map((group) => <FilterGroupMenu {...group} />)}
74
83
 
75
84
  {!hide.includes("sort") && (
@@ -23,6 +23,14 @@ interface Props {
23
23
  currentParams: URLSearchParams;
24
24
  pathPrefix?: string;
25
25
  class?: string;
26
+ /**
27
+ * Emit the default flex layout on the root. Pass `false` when the
28
+ * caller owns the layout — e.g. `layout={false} class="contents"` to
29
+ * promote the tabs into the parent's flex row. Without this the
30
+ * root's own `flex` would win over any display utility in `class`,
31
+ * since Tailwind emits `.contents` before `.flex`.
32
+ */
33
+ layout?: boolean;
26
34
  /** Override the visible lens list (defaults to LENSES). */
27
35
  lensIds?: readonly string[];
28
36
  /** Consumer noun for the unfiltered lens, e.g. "All apps". */
@@ -33,6 +41,7 @@ const {
33
41
  currentParams,
34
42
  pathPrefix = "/",
35
43
  class: className = "",
44
+ layout = true,
36
45
  lensIds,
37
46
  allLabel,
38
47
  } = Astro.props;
@@ -48,7 +57,8 @@ const lenses =
48
57
 
49
58
  <nav
50
59
  class:list={[
51
- "grove-lens-tabs flex flex-wrap items-center gap-1.5",
60
+ "grove-lens-tabs",
61
+ layout && "flex flex-wrap items-center gap-1.5",
52
62
  className,
53
63
  ]}
54
64
  aria-label="Curated views"
package/src/index.ts CHANGED
@@ -29,6 +29,7 @@ import { fileURLToPath } from "node:url";
29
29
  import { dirname, resolve } from "node:path";
30
30
  import type { AstroIntegration } from "astro";
31
31
  import { loadConfig, prepareDirectory } from "@grove-dev/core";
32
+ import { syncPackagedIcons } from "./lib/packaged-icons.js";
32
33
 
33
34
  export * from "@grove-dev/core";
34
35
 
@@ -58,7 +59,7 @@ export default function groveAstro(): AstroIntegration {
58
59
  return {
59
60
  name: "@grove-dev/astro",
60
61
  hooks: {
61
- "astro:config:setup": async ({ config, updateConfig, injectScript }) => {
62
+ "astro:config:setup": async ({ config, updateConfig, injectScript, logger }) => {
62
63
  // Alias the components/layouts source directories so
63
64
  // consumer builds can import from
64
65
  // @grove-dev/astro/components/ProjectCard.astro
@@ -68,10 +69,33 @@ export default function groveAstro(): AstroIntegration {
68
69
  const groveConfigPath = resolve(consumerRoot, "grove.config.ts");
69
70
  if (existsSync(groveConfigPath)) {
70
71
  await prepareDirectory(consumerRoot);
71
- // Preparation is the integration's only site-level side effect.
72
- // Routes belong to the consumer's `src/pages`, where they remain
73
- // visible, editable, and safe from future Grove syncs.
72
+ // Data preparation and the icon sync below are the integration's
73
+ // only site-level side effects. Routes belong to the consumer's
74
+ // `src/pages`, where they remain visible, editable, and safe from
75
+ // future Grove syncs.
74
76
  await loadConfig(consumerRoot);
77
+
78
+ // `Icon.astro` resolves every mark to `/icons/**` in the
79
+ // consumer's `public/`, so the packaged SVGs have to land there
80
+ // before the build asks for them. Files the consumer edited are
81
+ // left alone and reported.
82
+ const icons = await syncPackagedIcons(fileURLToPath(config.publicDir));
83
+ if (icons.adopted && icons.written.length > 0) {
84
+ // One-time migration for a site that predates the sidecar.
85
+ // Say so: the overwrite is safe for the scaffold set, but a
86
+ // pre-sidecar hand edit would show up here, and version
87
+ // control is what makes it recoverable.
88
+ logger.info(
89
+ `Adopted ${icons.written.length} icon(s) into public/icons/ and recorded them in ` +
90
+ ".grove-icons.json. Review with `git diff public/icons` if you had edited any.",
91
+ );
92
+ }
93
+ if (icons.skipped.length > 0) {
94
+ logger.warn(
95
+ `Kept ${icons.skipped.length} locally modified icon(s): ${icons.skipped.join(", ")}. ` +
96
+ "Run `grove icons sync --force` to restore the packaged versions.",
97
+ );
98
+ }
75
99
  }
76
100
  const globalCssPath = resolve(consumerRoot, "src/styles/global.css");
77
101
  const globalCssExists = existsSync(globalCssPath);
@@ -62,6 +62,10 @@ interface Site {
62
62
  };
63
63
  repoUrl?: string;
64
64
  url?: string;
65
+ /** Header logo path under `public/` (e.g. `/logo.svg`). */
66
+ logo?: string;
67
+ /** Favicon path under `public/` (e.g. `/favicon.svg`). */
68
+ favicon?: string;
65
69
  analytics?: {
66
70
  googleAnalyticsId?: string;
67
71
  };
@@ -172,8 +176,14 @@ if (themeConfig) {
172
176
  }
173
177
  const themeStyle = themeStyleParts.length > 0 ? `${themeStyleParts.join("; ")};` : undefined;
174
178
 
179
+ // `site.favicon` points at a file the consumer owns under `public/`.
180
+ // The fallback is a generated square — tinted with the configured
181
+ // brand color rather than a hardcoded near-black, so an unconfigured
182
+ // site still gets a tab mark that is recognisably its own.
183
+ const faviconFill = (site.theme?.primaryColor ?? "#18181b").trim();
175
184
  const favicon =
176
- "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%2318181b'/%3E%3C/svg%3E";
185
+ site.favicon ??
186
+ `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='${encodeURIComponent(faviconFill)}'/%3E%3C/svg%3E`;
177
187
  ---
178
188
 
179
189
  <!doctype html>
@@ -28,6 +28,8 @@ interface Site {
28
28
  description?: string;
29
29
  repoUrl?: string;
30
30
  url?: string;
31
+ /** Logo path under `public/` (e.g. `/logo.svg`). */
32
+ logo?: string;
31
33
  stats?: {
32
34
  /** Stars on this site's own source repository. */
33
35
  repositoryStars?: number;
@@ -87,10 +89,23 @@ const starsLabel =
87
89
  href="/"
88
90
  class="inline-flex min-w-0 shrink items-center gap-2 text-[0.95rem] font-semibold tracking-tight text-ink-900 dark:text-ink-100"
89
91
  >
90
- <span
91
- aria-hidden="true"
92
- class="inline-block h-[18px] w-[18px] rounded bg-ink-900 dark:bg-ink-100"
93
- ></span>
92
+ {site.logo ? (
93
+ <img
94
+ src={site.logo}
95
+ alt=""
96
+ aria-hidden="true"
97
+ width="18"
98
+ height="18"
99
+ class="inline-block h-[18px] w-[18px] shrink-0 object-contain"
100
+ />
101
+ ) : (
102
+ /* No logo configured — a neutral mark is the honest default.
103
+ Set `site.logo` in grove.config.ts to replace it. */
104
+ <span
105
+ aria-hidden="true"
106
+ class="inline-block h-[18px] w-[18px] rounded bg-ink-900 dark:bg-ink-100"
107
+ ></span>
108
+ )}
94
109
  <span class="truncate">{site.name}</span>
95
110
  </a>
96
111
 
@@ -0,0 +1,59 @@
1
+ // GENERATED by scripts/sync-icons.mjs — do not edit by hand.
2
+ // Run `pnpm icons:sync` to regenerate.
3
+ /**
4
+ * How each packaged icon is painted.
5
+ *
6
+ * This map CLASSIFIES; it does not gate availability. A name that is
7
+ * absent falls through to the `<img>` + initials path, which is what
8
+ * keeps consumer-supplied SVGs under `public/icons/` working.
9
+ *
10
+ * Keyed by `${folder}/${resolvedName}` — the folder matters because
11
+ * `apple` exists under both `stacks/` and `platforms/`.
12
+ */
13
+ export type IconKind = "color" | "mono";
14
+
15
+ export const ICON_KINDS: Readonly<Record<string, IconKind>> = {
16
+ "platforms/android": "color",
17
+ "platforms/app": "mono",
18
+ "platforms/chrome": "color",
19
+ "platforms/desktop": "mono",
20
+ "platforms/embedded": "mono",
21
+ "platforms/ios": "mono",
22
+ "platforms/linux": "color",
23
+ "platforms/macos": "mono",
24
+ "platforms/ubuntu": "color",
25
+ "platforms/web": "mono",
26
+ "platforms/windows": "color",
27
+ "stacks/android": "color",
28
+ "stacks/apple": "mono",
29
+ "stacks/bun": "color",
30
+ "stacks/capacitor": "color",
31
+ "stacks/clojure": "color",
32
+ "stacks/dart": "color",
33
+ "stacks/deno": "mono",
34
+ "stacks/django": "color",
35
+ "stacks/docker": "color",
36
+ "stacks/firebase": "color",
37
+ "stacks/flutter": "color",
38
+ "stacks/go": "color",
39
+ "stacks/graphql": "color",
40
+ "stacks/ionic": "color",
41
+ "stacks/java": "color",
42
+ "stacks/javascript": "color",
43
+ "stacks/kotlin": "color",
44
+ "stacks/llm": "mono",
45
+ "stacks/mongodb": "color",
46
+ "stacks/nodejs": "color",
47
+ "stacks/python": "color",
48
+ "stacks/rag": "mono",
49
+ "stacks/react": "color",
50
+ "stacks/react-native": "color",
51
+ "stacks/rust": "mono",
52
+ "stacks/solidity": "mono",
53
+ "stacks/sveltekit": "color",
54
+ "stacks/swift": "color",
55
+ "stacks/tauri": "mono",
56
+ "stacks/tensorflow": "color",
57
+ "stacks/typescript": "color",
58
+ "stacks/vue": "color",
59
+ };