@grove-dev/astro 0.5.4 → 0.6.1

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 (41) hide show
  1. package/dist/index.d.ts.map +1 -1
  2. package/dist/index.js +8 -0
  3. package/dist/index.js.map +1 -1
  4. package/dist/lib/index.d.ts +0 -1
  5. package/dist/lib/index.d.ts.map +1 -1
  6. package/dist/lib/index.js +0 -1
  7. package/dist/lib/index.js.map +1 -1
  8. package/dist/ui/button.d.ts +8 -0
  9. package/dist/ui/button.d.ts.map +1 -1
  10. package/dist/ui/button.js +8 -0
  11. package/dist/ui/button.js.map +1 -1
  12. package/package.json +2 -2
  13. package/src/components/DirectoryIndexClient.astro +351 -178
  14. package/src/components/Hero.astro +15 -11
  15. package/src/components/Pagination.astro +3 -3
  16. package/src/components/PoweredBy.astro +60 -0
  17. package/src/components/ProjectCard.astro +42 -25
  18. package/src/components/RecordHeader.astro +14 -9
  19. package/src/components/RefinePanel.astro +11 -2
  20. package/src/components/TableOfContents.astro +11 -12
  21. package/src/index.ts +8 -0
  22. package/src/layouts/BaseLayout.astro +14 -1
  23. package/src/layouts/Footer.astro +18 -2
  24. package/src/layouts/Header.astro +1 -1
  25. package/src/layouts/Seo.astro +64 -38
  26. package/src/lib/index.ts +0 -1
  27. package/src/server/collections.ts +96 -67
  28. package/src/server/index.ts +1 -0
  29. package/src/server/models.ts +456 -25
  30. package/src/server/seo.test.ts +123 -0
  31. package/src/server/seo.ts +141 -0
  32. package/src/styles.css +23 -6
  33. package/src/ui/FilterDrawer.astro +25 -5
  34. package/src/ui/SearchField.astro +58 -2
  35. package/src/ui/button.test.ts +1 -1
  36. package/src/ui/button.ts +9 -0
  37. package/dist/lib/load-collections.d.ts +0 -12
  38. package/dist/lib/load-collections.d.ts.map +0 -1
  39. package/dist/lib/load-collections.js +0 -34
  40. package/dist/lib/load-collections.js.map +0 -1
  41. package/src/lib/load-collections.ts +0 -33
@@ -126,18 +126,22 @@ const primaryCta: CTA | undefined =
126
126
 
127
127
  <Container>
128
128
  <div class="relative mx-auto flex max-w-[900px] flex-col items-center py-[88px] text-center lg:py-[104px]">
129
- {/* Eyebrow */}
129
+ {/* Eyebrow. The `eyebrow` slot replaces the whole line — dot
130
+ included — so a consumer can put richer content here (a
131
+ `PoweredBy` link, a release badge) without forking the Hero. */}
130
132
  <span class="inline-flex animate-fade-in items-center gap-2 text-[13px] font-medium text-ink-500 dark:text-ink-400">
131
- <span aria-hidden="true" class="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500"></span>
132
- {eyebrow}
133
- {eyebrowBadge && (
134
- <span
135
- class="inline-flex items-center rounded-full border border-amber-300 bg-amber-50 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-800 dark:border-amber-700/50 dark:bg-amber-900/20 dark:text-amber-300"
136
- aria-label={eyebrowBadge}
137
- >
138
- {eyebrowBadge}
139
- </span>
140
- )}
133
+ <slot name="eyebrow">
134
+ <span aria-hidden="true" class="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500"></span>
135
+ {eyebrow}
136
+ {eyebrowBadge && (
137
+ <span
138
+ class="inline-flex items-center rounded-full border border-amber-300 bg-amber-50 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-800 dark:border-amber-700/50 dark:bg-amber-900/20 dark:text-amber-300"
139
+ aria-label={eyebrowBadge}
140
+ >
141
+ {eyebrowBadge}
142
+ </span>
143
+ )}
144
+ </slot>
141
145
  </span>
142
146
 
143
147
  {/* Headline */}
@@ -7,7 +7,7 @@ interface Props {
7
7
  }
8
8
 
9
9
  import { paginationPageList } from "@grove-dev/astro";
10
- import { buttonClass, PAGINATION_EXTRA } from "../ui/button.js";
10
+ import { buttonClass, PAGINATION_DISABLED, PAGINATION_EXTRA } from "../ui/button.js";
11
11
 
12
12
  const { current, total, hrefFor, class: className = "" } = Astro.props;
13
13
 
@@ -24,7 +24,7 @@ const currentClass = buttonClass("selected", "sm", PAGINATION_EXTRA);
24
24
  {current > 1 ? (
25
25
  <a href={hrefFor(current - 1)} class={linkClass} rel="prev">Previous</a>
26
26
  ) : (
27
- <span class={`${linkClass} pointer-events-none opacity-40`}>Previous</span>
27
+ <span class={`${linkClass} ${PAGINATION_DISABLED}`}>Previous</span>
28
28
  )}
29
29
 
30
30
  {pages.map((page) =>
@@ -44,7 +44,7 @@ const currentClass = buttonClass("selected", "sm", PAGINATION_EXTRA);
44
44
  {current < total ? (
45
45
  <a href={hrefFor(current + 1)} class={linkClass} rel="next">Next</a>
46
46
  ) : (
47
- <span class={`${linkClass} pointer-events-none opacity-40`}>Next</span>
47
+ <span class={`${linkClass} ${PAGINATION_DISABLED}`}>Next</span>
48
48
  )}
49
49
  </nav>
50
50
  )}
@@ -0,0 +1,60 @@
1
+ ---
2
+ /**
3
+ * PoweredBy — "Powered by Grove" attribution.
4
+ *
5
+ * The mark is **inlined**, not loaded through `<img src>`: an SVG
6
+ * served as its own document cannot see page CSS, so `currentColor`
7
+ * never resolves there (the bug that cost the packaged icon set both
8
+ * themes before 0.5.3). Inlined, the mark simply inherits the
9
+ * surrounding text colour and follows the site's theme toggle — no
10
+ * second file, no script.
11
+ *
12
+ * Generic: no `siteConfig` import. `Footer` renders it under the brand
13
+ * block when `footer.poweredBy` is on; consumers can also drop it
14
+ * anywhere themselves — e.g. into `Hero`'s `eyebrow` slot.
15
+ */
16
+
17
+ interface Props {
18
+ /** Where the link points. Override only for a staging/docs mirror. */
19
+ href?: string;
20
+ /** Mark size in px. Default 16 — sized for 13px text. */
21
+ size?: number;
22
+ /** Extra class on the link. */
23
+ class?: string;
24
+ }
25
+
26
+ const {
27
+ href = "https://grove.dev.mn",
28
+ size = 16,
29
+ class: className = "",
30
+ } = Astro.props;
31
+ ---
32
+
33
+ <a
34
+ href={href}
35
+ target="_blank"
36
+ rel="noopener noreferrer"
37
+ class:list={[
38
+ "inline-flex items-center gap-1.5 transition-colors hover:text-ink-900 dark:hover:text-ink-100",
39
+ className,
40
+ ]}
41
+ >
42
+ <span>Powered by</span>
43
+ <svg
44
+ xmlns="http://www.w3.org/2000/svg"
45
+ viewBox="0 0 32 32"
46
+ width={size}
47
+ height={size}
48
+ style={`width:${size}px;height:${size}px`}
49
+ fill="none"
50
+ aria-hidden="true"
51
+ class="shrink-0 align-middle"
52
+ >
53
+ <path d="M16 28V14" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
54
+ <path d="M16 14C16 14 16 8 22 6C22 6 22 12 16 14Z" fill="currentColor" fill-opacity="0.85"></path>
55
+ <path d="M16 14C16 14 16 8 10 6C10 6 10 12 16 14Z" fill="currentColor" fill-opacity="0.6"></path>
56
+ <path d="M16 18C16 18 16 22 20 23" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path>
57
+ <path d="M16 21C16 21 16 24 12 25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path>
58
+ </svg>
59
+ <span class="font-semibold">Grove</span>
60
+ </a>
@@ -45,6 +45,7 @@ import {
45
45
  formatStars,
46
46
  nameInitials,
47
47
  projectStackIds,
48
+ truncateWords,
48
49
  } from "@grove-dev/astro";
49
50
  import CardIcon from "./CardIcon.astro";
50
51
  import Icon from "./Icon.astro";
@@ -155,8 +156,13 @@ const avatarUrl =
155
156
 
156
157
  const initials = nameInitials(name);
157
158
 
158
- const description =
159
- descriptionProp ?? (record as { description?: string } | undefined)?.description ?? "";
159
+ // Trimmed on a word boundary before the two-line clamp gets to it: a
160
+ // CSS clamp cuts at whatever character the box runs out of room on,
161
+ // which is what produced "…and multi-", "…and retrieval", "…an" across
162
+ // a grid.
163
+ const description = truncateWords(
164
+ descriptionProp ?? (record as { description?: string } | undefined)?.description ?? "",
165
+ );
160
166
 
161
167
  const stars =
162
168
  starsProp ?? (isProject ? (record as { github?: { stars?: number } }).github?.stars : undefined);
@@ -197,15 +203,22 @@ const hasStacks = visibleStacks.length > 0;
197
203
 
198
204
  <Root
199
205
  {...rootAttrs}
200
- class={`groove-project-card group relative flex h-full min-w-0 flex-col gap-3 rounded-xl border border-ink-200 bg-card p-5 outline-none transition-[border-color,background-color,transform] duration-150 hover:-translate-y-0.5 hover:border-ink-300 hover:bg-ink-50 focus-visible:border-ink-500 focus-visible:ring-2 focus-visible:ring-ink-900/15 dark:border-ink-800 dark:hover:border-ink-700 dark:hover:bg-ink-900 dark:focus-visible:border-ink-500 dark:focus-visible:ring-ink-100/15 ${className}`}
206
+ class={`group relative flex h-full min-w-0 flex-col gap-3 rounded-xl border border-ink-200 bg-card p-5 outline-none transition-[border-color,background-color,transform] duration-150 hover:-translate-y-0.5 hover:border-ink-400 hover:bg-ink-50 focus-visible:border-ink-500 focus-visible:ring-2 focus-visible:ring-ink-900/15 dark:border-ink-800 dark:hover:border-ink-600 dark:hover:bg-ink-900 dark:focus-visible:border-ink-500 dark:focus-visible:ring-ink-100/15 ${className}`}
201
207
  data-record-slug={slug}
202
208
  data-event={event}
203
209
  data-event-source={eventSource}
204
210
  data-event-item={event ? slug : undefined}
205
211
  >
206
- {/* Header: icon + name + owner */}
212
+ {/* Header: icon + name + owner.
213
+
214
+ The initials chip is always in the DOM, `hidden` behind the image,
215
+ and unhidden by the image's own `onerror` — the same pattern
216
+ `Icon.astro` uses. Rendering it only when `avatarUrl` is falsy made
217
+ it unreachable for every record with a GitHub owner, so a dead
218
+ logo URL or a renamed org showed the browser's broken-image glyph
219
+ instead of a fallback. */}
207
220
  <header class="flex items-start gap-3">
208
- {avatarUrl ? (
221
+ {avatarUrl && (
209
222
  <img
210
223
  src={avatarUrl}
211
224
  alt=""
@@ -214,16 +227,18 @@ const hasStacks = visibleStacks.length > 0;
214
227
  loading={eager ? "eager" : "lazy"}
215
228
  fetchpriority={eager ? "high" : undefined}
216
229
  decoding="async"
217
- class="h-12 w-12 shrink-0 rounded-lg bg-white object-contain p-1 dark:bg-white"
230
+ onerror="this.hidden=true;this.nextElementSibling.hidden=false"
231
+ class="h-12 w-12 shrink-0 rounded-lg bg-white object-contain p-1"
218
232
  />
219
- ) : (
220
- <span
221
- aria-hidden="true"
222
- class="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-ink-100 text-base font-semibold uppercase text-ink-700 dark:bg-ink-800 dark:text-ink-200"
223
- >
224
- {initials}
225
- </span>
226
233
  )}
234
+ <span
235
+ aria-hidden="true"
236
+ hidden={Boolean(avatarUrl)}
237
+ data-grove-card-fallback-initials
238
+ class="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-ink-100 text-base font-semibold uppercase text-ink-700 dark:bg-ink-800 dark:text-ink-200"
239
+ >
240
+ {initials}
241
+ </span>
227
242
 
228
243
  <div class="flex min-w-0 flex-1 flex-col gap-0.5">
229
244
  <h3 class="m-0 truncate text-[17px] font-semibold leading-snug tracking-tight text-ink-900 sm:text-[18px] dark:text-ink-100">
@@ -270,18 +285,20 @@ const hasStacks = visibleStacks.length > 0;
270
285
 
271
286
  {/* Footer: stack logos · stars/updated · arrow */}
272
287
  <footer class="mt-auto flex items-center justify-between gap-3 text-[12px] sm:text-[13px]">
273
- <div class="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-ink-500 dark:text-ink-400">
274
- {hasStacks && (
275
- <div class="flex items-center gap-2">
276
- {visibleStacks.map((stack) => (
277
- <Icon name={stack} category="stack" size={16} title={stack} />
278
- ))}
279
- {stackOverflow > 0 && (
280
- <span class="text-[12px] text-ink-500 dark:text-ink-400">+{stackOverflow}</span>
281
- )}
282
- </div>
283
- )}
284
- </div>
288
+ {hasStacks ? (
289
+ <div class="flex min-w-0 items-center gap-2 text-ink-500 dark:text-ink-400">
290
+ {visibleStacks.map((stack) => (
291
+ <Icon name={stack} category="stack" size={16} title={stack} />
292
+ ))}
293
+ {stackOverflow > 0 && (
294
+ <span class="text-[12px] text-ink-500 dark:text-ink-400">+{stackOverflow}</span>
295
+ )}
296
+ </div>
297
+ ) : (
298
+ /* Keeps the footer's stars/updated block right-aligned without
299
+ shipping an empty flex container on every stackless card. */
300
+ <span></span>
301
+ )}
285
302
 
286
303
  <div class="flex min-w-0 shrink-0 items-center gap-3 text-ink-500 dark:text-ink-400">
287
304
  {hasStars && (
@@ -51,7 +51,10 @@ const {
51
51
  at the row's right edge on wide viewports and wrap below on
52
52
  mobile. */}
53
53
  <div class="flex items-center gap-4">
54
- {avatarSrc ? (
54
+ {/* Initials stay in the DOM behind the logo and are revealed by the
55
+ image's own `onerror` — a dead logo URL or a renamed GitHub org
56
+ would otherwise render the broken-image glyph. */}
57
+ {avatarSrc && (
55
58
  <img
56
59
  src={avatarSrc}
57
60
  alt=""
@@ -59,16 +62,18 @@ const {
59
62
  height="56"
60
63
  loading="lazy"
61
64
  decoding="async"
62
- class="h-12 w-12 shrink-0 rounded-lg border border-ink-200 object-contain bg-white p-1 dark:border-ink-800 dark:bg-ink-950 sm:h-14 sm:w-14"
65
+ onerror="this.hidden=true;this.nextElementSibling.hidden=false"
66
+ class="h-12 w-12 shrink-0 rounded-lg border border-ink-200 object-contain bg-white p-1 dark:border-ink-800 sm:h-14 sm:w-14"
63
67
  />
64
- ) : (
65
- <span
66
- aria-hidden="true"
67
- class="inline-flex h-12 w-12 shrink-0 items-center justify-center rounded-lg border border-ink-200 bg-ink-50 text-xl font-semibold text-ink-700 dark:border-ink-800 dark:bg-ink-900 dark:text-ink-200 sm:h-14 sm:w-14"
68
- >
69
- {initials || "·"}
70
- </span>
71
68
  )}
69
+ <span
70
+ aria-hidden="true"
71
+ hidden={Boolean(avatarSrc)}
72
+ data-grove-card-fallback-initials
73
+ class="inline-flex h-12 w-12 shrink-0 items-center justify-center rounded-lg border border-ink-200 bg-ink-50 text-xl font-semibold text-ink-700 dark:border-ink-800 dark:bg-ink-900 dark:text-ink-200 sm:h-14 sm:w-14"
74
+ >
75
+ {initials || "·"}
76
+ </span>
72
77
 
73
78
  <div class="min-w-0 flex-1">
74
79
  <h1 class="m-0 text-[1.75rem] font-semibold leading-tight tracking-tight text-ink-900 dark:text-ink-100 sm:text-[2rem]">
@@ -110,13 +110,22 @@ const groups = groupModels.map((group) => ({
110
110
  }
111
111
  });
112
112
  }
113
+ // A directory controller (DirectoryIndexClient) can adopt the URL
114
+ // in place and re-render without a reload; it signals that by
115
+ // cancelling the event. Anywhere else — a taxonomy page, no
116
+ // controller on the page at all — this falls back to navigating,
117
+ // so the panel keeps working on its own.
118
+ function go(url) {
119
+ const event = new CustomEvent("grove:navigate", { detail: { url }, cancelable: true });
120
+ if (document.dispatchEvent(event)) location.assign(url);
121
+ }
113
122
  function navigate(group, values) {
114
123
  const params = new URLSearchParams(location.search);
115
124
  const key = keys[group.dataset.filterGroupKey];
116
125
  params.delete(key);
117
126
  params.delete("page");
118
127
  values.forEach((value) => params.append(key, value));
119
- location.assign(params.toString() ? `${pathPrefix}?${params}` : pathPrefix);
128
+ go(params.toString() ? `${pathPrefix}?${params}` : pathPrefix);
120
129
  }
121
130
  groups.forEach((group) => {
122
131
  const trigger = group.querySelector(".grove-filter-trigger");
@@ -153,7 +162,7 @@ const groups = groupModels.map((group) => ({
153
162
  const params = new URLSearchParams(location.search);
154
163
  params.set("sort", event.target.value);
155
164
  params.delete("page");
156
- location.assign(params.toString() ? `${pathPrefix}?${params}` : pathPrefix);
165
+ go(params.toString() ? `${pathPrefix}?${params}` : pathPrefix);
157
166
  });
158
167
  })();
159
168
  </script>
@@ -32,34 +32,33 @@ const hasItems = items.length > 1;
32
32
  ---
33
33
 
34
34
  {hasItems && (
35
- <details class="grove-toc rounded-[var(--radius-lg)] border border-ink-200 bg-card open:bg-card dark:border-ink-800">
36
- <summary class="cursor-pointer list-none px-4 py-3 text-2xs font-semibold uppercase tracking-wider text-ink-500 hover:text-ink-900 dark:text-ink-400 dark:hover:text-ink-100 lg:cursor-default lg:pointer-events-none">
35
+ <details class="grove-toc lg:border-0">
36
+ <summary class="cursor-pointer list-none pb-3 text-2xs font-semibold uppercase tracking-wider text-ink-500 hover:text-ink-900 dark:text-ink-400 dark:hover:text-ink-100 lg:cursor-default lg:pointer-events-none lg:pb-2">
37
37
  <span class="inline-flex items-center gap-1.5">
38
38
  <svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true" fill="currentColor" class="grove-toc-chevron transition-transform lg:hidden">
39
39
  <path d="M6 4l4 4-4 4V4z" />
40
40
  </svg>
41
41
  {label}
42
- <span class="ml-1 rounded-full bg-ink-100 px-1.5 py-0.5 text-[10px] font-medium text-ink-500 dark:bg-ink-800 dark:text-ink-300">
43
- {items.length}
44
- </span>
45
42
  </span>
46
43
  </summary>
47
44
  {/* Full list, no inner scroll — the TOC lives in the right rail
48
- and every entry stays visible. h3 entries indent under their
49
- h2 parents. */}
50
- <ol class="grove-toc-list mt-1 list-none border-t border-ink-200 p-2 text-sm dark:border-ink-800 lg:border-t-0 lg:p-0">
45
+ and every entry stays visible. h3 entries indent further under
46
+ their h2 parents; the active state is a left-rail indicator +
47
+ text-color emphasis set by the script + styles.css. */}
48
+ <ol class="grove-toc-list list-none py-1 text-sm lg:py-0">
51
49
  {items.map((entry) => (
52
50
  <li
53
51
  class:list={[
54
- "grove-toc-item border-b border-ink-100 last:border-b-0 dark:border-ink-800 lg:border-b-0 lg:py-0.5",
55
- entry.depth >= 3 && "ml-4 lg:ml-3",
52
+ "grove-toc-item relative",
53
+ entry.depth >= 3 && "grove-toc-item--h3",
56
54
  ]}
57
55
  >
58
56
  <a
59
57
  href={`#${entry.id}`}
60
58
  class:list={[
61
- "grove-toc-link block truncate px-3 py-2 text-ink-500 no-underline transition-colors hover:text-ink-900 dark:text-ink-400 dark:hover:text-ink-100 lg:px-2 lg:py-1",
62
- entry.depth >= 3 && "text-[0.8125rem]",
59
+ "grove-toc-link block py-1.5 text-ink-500 no-underline transition-colors hover:text-ink-900 dark:text-ink-400 dark:hover:text-ink-100",
60
+ entry.depth >= 2 && "pl-3 lg:pl-4",
61
+ entry.depth >= 3 && "pl-6 text-[0.8125rem] lg:pl-7",
63
62
  ]}
64
63
  data-toc-link={entry.id}
65
64
  >
package/src/index.ts CHANGED
@@ -109,6 +109,14 @@ export default function groveAstro(): AstroIntegration {
109
109
  "@grove/generated": resolve(consumerRoot, "data/generated"),
110
110
  },
111
111
  },
112
+ // The OG image pipeline (`@grove-dev/core`'s og-image.ts)
113
+ // dynamically imports satori + resvg's native binding.
114
+ // They only ever run in `prepareDirectory` (Node, build
115
+ // time) — never in page code — so keep the bundler away
116
+ // from the .node binary.
117
+ ssr: {
118
+ external: ["satori", "@resvg/resvg-js"],
119
+ },
112
120
  plugins: [
113
121
  {
114
122
  name: "grove:consumer-global-css-resolver",
@@ -66,6 +66,10 @@ interface Site {
66
66
  logo?: string;
67
67
  /** Favicon path under `public/` (e.g. `/favicon.svg`). */
68
68
  favicon?: string;
69
+ /** BCP-47 language tag (e.g. "en"). Drives `<html lang>` and og:locale. */
70
+ locale?: string;
71
+ /** Twitter/X handle (e.g. "@grove") — emitted as twitter:site. */
72
+ twitter?: string;
69
73
  analytics?: {
70
74
  googleAnalyticsId?: string;
71
75
  };
@@ -99,6 +103,11 @@ interface Props {
99
103
  type?: 'website' | 'article' | 'profile';
100
104
  noindex?: boolean;
101
105
  jsonLd?: Record<string, unknown> | Record<string, unknown>[];
106
+ /** Open Graph / Twitter image path (e.g. "/og/records/foo.png").
107
+ * Defaults to the site-wide /og-image.svg inside <Seo />. */
108
+ image?: string;
109
+ /** Alt text for the OG/Twitter image. */
110
+ imageAlt?: string;
102
111
  /** Optional Google Analytics 4 Measurement ID (e.g. "G-XXXXXX"). */
103
112
  gaId?: string;
104
113
  /** Path used for JSON-LD `SearchAction` target. Defaults to "/items". */
@@ -116,6 +125,8 @@ const {
116
125
  type,
117
126
  noindex,
118
127
  jsonLd,
128
+ image,
129
+ imageAlt,
119
130
  gaId,
120
131
  searchPath,
121
132
  submitLabel,
@@ -187,7 +198,7 @@ const favicon =
187
198
  ---
188
199
 
189
200
  <!doctype html>
190
- <html lang="en" style={themeStyle}>
201
+ <html lang={site.locale ?? 'en'} style={themeStyle}>
191
202
  <head>
192
203
  <meta charset="UTF-8" />
193
204
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -235,6 +246,8 @@ const favicon =
235
246
  type={type}
236
247
  noindex={noindex}
237
248
  jsonLd={jsonLd}
249
+ {...(image ? { image } : {})}
250
+ imageAlt={imageAlt}
238
251
  site={site}
239
252
  searchPath={directoryPath}
240
253
  />
@@ -12,6 +12,7 @@
12
12
  * so consumers can customize the order and copy. Defaults are
13
13
  * provided so the footer still renders if no extra data is supplied.
14
14
  */
15
+ import PoweredBy from "../components/PoweredBy.astro";
15
16
 
16
17
  interface NavItem {
17
18
  label: string;
@@ -34,6 +35,8 @@ interface Site {
34
35
  columns?: FooterColumn[];
35
36
  copyright?: string;
36
37
  license?: string;
38
+ /** "Powered by Grove" under the brand block. Defaults to on. */
39
+ poweredBy?: boolean;
37
40
  };
38
41
  stats?: {
39
42
  totalApps?: number;
@@ -62,6 +65,11 @@ interface Props {
62
65
  copyrightHolder?: string;
63
66
  /** License line under the bar (e.g. "Content under CC BY-SA 4.0."). */
64
67
  licenseLine?: string;
68
+ /**
69
+ * "Powered by Grove" under the brand block. Falls back to
70
+ * `site.footer.poweredBy`, which defaults to on.
71
+ */
72
+ poweredBy?: boolean;
65
73
  /** Extra class for the outer footer element. */
66
74
  class?: string;
67
75
  }
@@ -75,9 +83,12 @@ const {
75
83
  itemsLabel = "items",
76
84
  copyrightHolder,
77
85
  licenseLine,
86
+ poweredBy,
78
87
  class: className = "",
79
88
  } = Astro.props;
80
89
 
90
+ const showPoweredBy = poweredBy ?? site.footer?.poweredBy ?? true;
91
+
81
92
  const year = new Date().getFullYear();
82
93
  const holder = copyrightHolder ?? site.footer?.copyright ?? site.name;
83
94
  const gh = site.repoUrl;
@@ -124,7 +135,7 @@ const resolvedLicenseLine = licenseLine ?? site.footer?.license;
124
135
  ]}
125
136
  >
126
137
  <div
127
- class="mx-auto grid w-full max-w-wide grid-cols-2 gap-8 px-5 py-12 md:grid-cols-4 sm:px-7"
138
+ class="mx-auto grid w-full max-w-container grid-cols-2 gap-8 px-5 py-12 md:grid-cols-4 sm:px-7"
128
139
  >
129
140
  <div class="col-span-2 md:col-span-1">
130
141
  <div
@@ -166,6 +177,11 @@ const resolvedLicenseLine = licenseLine ?? site.footer?.license;
166
177
  )}
167
178
  </p>
168
179
  )}
180
+ {showPoweredBy && (
181
+ <p class="mt-4 m-0 text-[0.8125rem] text-ink-500 dark:text-ink-400">
182
+ <PoweredBy size={16} />
183
+ </p>
184
+ )}
169
185
  </div>
170
186
 
171
187
  {columns.map((column) => (
@@ -194,7 +210,7 @@ const resolvedLicenseLine = licenseLine ?? site.footer?.license;
194
210
 
195
211
  <div class="border-t border-ink-200 dark:border-ink-800">
196
212
  <div
197
- class="mx-auto flex w-full max-w-wide flex-wrap items-center gap-3 px-5 py-4 text-2xs text-ink-500 sm:px-7 dark:text-ink-400"
213
+ class="mx-auto flex w-full max-w-container flex-wrap items-center gap-3 px-5 py-4 text-2xs text-ink-500 sm:px-7 dark:text-ink-400"
198
214
  >
199
215
  <span>© {year} {holder}{resolvedLicenseLine ? `. ${resolvedLicenseLine}` : "."}</span>
200
216
  {gh && (
@@ -83,7 +83,7 @@ const starsLabel =
83
83
  ]}
84
84
  >
85
85
  <div
86
- class="mx-auto flex w-full max-w-wide items-center gap-3 px-5 py-3 sm:gap-6 sm:px-7"
86
+ class="mx-auto flex w-full max-w-container items-center gap-3 px-5 py-3 sm:gap-6 sm:px-7"
87
87
  >
88
88
  <a
89
89
  href="/"