@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
package/src/index.ts CHANGED
@@ -1,17 +1,64 @@
1
1
  /**
2
- * @grove-dev/astro — Astro UI primitives for Grove.
2
+ * @grove-dev/astro — Astro framework adapter for Grove.
3
3
  *
4
- * This package only owns Astro components, layouts, styles, and
5
- * Astro-specific integrations. Generic filter/sort/stats/score
6
- * helpers live in `@grove-dev/ui` and are re-exported from here for
7
- * convenience.
8
- *
9
- * Astro components are not exported from this barrel — consumers
10
- * import them by path, e.g.:
11
- * import ItemCard from "@grove-dev/astro/components/ItemCard.astro";
12
- * import BaseLayout from "@grove-dev/astro/layouts/BaseLayout.astro";
13
- *
14
- * That keeps the barrel type-only and lets `astro check` validate
15
- * the .astro files in their own context.
4
+ * Provides:
5
+ * - `default` export: an Astro integration that wires the
6
+ * `@grove-dev/astro/components/*` and `@grove-dev/astro/layouts/*`
7
+ * subpath imports into Vite so consumer projects can write:
8
+ * import ItemCard from "@grove-dev/astro/components/ItemCard.astro";
9
+ * and have Vite resolve the source `.astro` file (the package's
10
+ * `dist/` only contains TypeScript helpers, not the components
11
+ * themselves).
12
+ * - Re-exports the framework-agnostic `Resource` types from
13
+ * `@grove-dev/core` so the component prop signatures stay
14
+ * type-safe without forcing a dependency on the core package
15
+ * in every consumer file.
16
+ * - Re-exports the generic `lib/` helpers (search, lenses, scores,
17
+ * repo, format, display, taxonomy-counts) under a single import
18
+ * path so consumers can `import { ... } from "@grove-dev/astro"`.
16
19
  */
17
- export * from "@grove-dev/ui";
20
+ import { fileURLToPath } from "node:url";
21
+ import { dirname, resolve } from "node:path";
22
+ import type { AstroIntegration } from "astro";
23
+
24
+ export * from "@grove-dev/core";
25
+
26
+ // Generic lib helpers — repo URL parsing, formatting, search /
27
+ // facet / sort / paginate, lens application, score tiers, taxonomy
28
+ // counts, and pretty-print display maps. All dependency-free and
29
+ // typed against `@grove-dev/core`.
30
+ export * from "./lib/index.js";
31
+
32
+ const here = dirname(fileURLToPath(import.meta.url));
33
+ // `here` is the compiled `dist/` directory. The components and
34
+ // layouts live in the source tree (the `dist/` is for the
35
+ // integration helper only). Walk up one level to reach `src/`
36
+ // before resolving the subpath roots.
37
+ const srcRoot = resolve(here, "..", "src");
38
+ const componentsDir = resolve(srcRoot, "components");
39
+ const layoutsDir = resolve(srcRoot, "layouts");
40
+
41
+ export default function groveAstro(): AstroIntegration {
42
+ return {
43
+ name: "@grove-dev/astro",
44
+ hooks: {
45
+ "astro:config:setup": ({ updateConfig }) => {
46
+ // Alias the components/layouts source directories so
47
+ // consumer builds can import from
48
+ // @grove-dev/astro/components/ItemCard.astro
49
+ // without us shipping a glob-shaped `exports` map that
50
+ // Vite/Rollup does not expand reliably.
51
+ updateConfig({
52
+ vite: {
53
+ resolve: {
54
+ alias: {
55
+ "@grove-dev/astro/components": componentsDir,
56
+ "@grove-dev/astro/layouts": layoutsDir,
57
+ },
58
+ },
59
+ },
60
+ });
61
+ },
62
+ },
63
+ };
64
+ }
@@ -1,32 +1,244 @@
1
1
  ---
2
+ /**
3
+ * BaseLayout — wraps every page. Owns the theme-detection inline
4
+ * script and the global shell (Header, Footer). Per-page SEO
5
+ * (title, description, OG, JSON-LD) is delegated to <Seo /> so each
6
+ * page can declare what it needs without polluting the layout.
7
+ *
8
+ * Generic: takes a `site` prop with brand + nav, no siteConfig import.
9
+ * The header / footer / SEO components are imported from sibling
10
+ * files; consumers can override the Header/Footer via Astro's slot
11
+ * mechanism (`<Header slot="header" />`) if they need custom branding.
12
+ *
13
+ * The THEME_INIT inline script persists user preference in
14
+ * `localStorage` under the key "grove-theme" — matching the key
15
+ * `ThemeToggle.astro` uses.
16
+ */
17
+
2
18
  import "../styles.css";
19
+ import Header from "./Header.astro";
20
+ import Footer from "./Footer.astro";
21
+ import Seo from "./Seo.astro";
22
+
23
+ interface NavItem {
24
+ label: string;
25
+ href: string;
26
+ }
27
+
28
+ interface Site {
29
+ name: string;
30
+ tagline?: string;
31
+ description?: string;
32
+ nav: NavItem[];
33
+ repoUrl?: string;
34
+ url?: string;
35
+ }
3
36
 
4
37
  interface Props {
5
38
  title: string;
6
- description?: string;
39
+ description: string;
40
+ site: Site;
41
+ type?: "website" | "article" | "profile";
42
+ noindex?: boolean;
43
+ jsonLd?: Record<string, unknown> | Record<string, unknown>[];
44
+ /** Optional Google Analytics 4 Measurement ID (e.g. "G-XXXXXX"). */
45
+ gaId?: string;
46
+ /** Path used for JSON-LD `SearchAction` target. Defaults to "/projects". */
47
+ searchPath?: string;
48
+ /** Submit button label rendered in the header. Omit to hide. */
49
+ submitLabel?: string;
50
+ /** Submit button href rendered in the header. */
51
+ submitHref?: string;
7
52
  }
8
53
 
9
- const { title, description = "A living, health-aware developer directory." } = Astro.props;
54
+ const {
55
+ title,
56
+ description,
57
+ site,
58
+ type,
59
+ noindex,
60
+ jsonLd,
61
+ gaId,
62
+ searchPath,
63
+ submitLabel,
64
+ submitHref,
65
+ } = Astro.props;
66
+
67
+ const favicon =
68
+ "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";
10
69
  ---
11
70
 
12
71
  <!doctype html>
13
72
  <html lang="en">
14
73
  <head>
15
- <meta charset="utf-8" />
16
- <meta name="viewport" content="width=device-width, initial-scale=1" />
17
- <meta name="description" content={description} />
18
- <title>{title}</title>
74
+ <meta charset="UTF-8" />
75
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
76
+ <meta name="generator" content={Astro.generator} />
77
+ <meta name="theme-color" content="#0a0a0c" media="(prefers-color-scheme: dark)" />
78
+ <meta name="theme-color" content="#fafafa" media="(prefers-color-scheme: light)" />
79
+ <link rel="icon" href={favicon} />
80
+ <link rel="sitemap" href="/sitemap.xml" />
81
+ <link
82
+ rel="alternate"
83
+ type="text/plain"
84
+ href="/llms.txt"
85
+ title="LLM usage guidance"
86
+ />
87
+
88
+ {gaId && (
89
+ <>
90
+ {/* GA4 — gtag.js loader. We send the initial page_view with
91
+ the current path + query string so the dashboard
92
+ distinguishes /projects?stack=flutter from
93
+ /projects?stack=react (otherwise they'd all collapse to
94
+ /projects). */}
95
+ <script
96
+ is:inline
97
+ async
98
+ src={`https://www.googletagmanager.com/gtag/js?id=${gaId}`}
99
+ />
100
+ <script is:inline define:vars={{ gaId }}>
101
+ window.dataLayer = window.dataLayer || [];
102
+ function gtag() {
103
+ dataLayer.push(arguments);
104
+ }
105
+ gtag("js", new Date());
106
+ gtag("config", gaId, {
107
+ page_path: window.location.pathname + window.location.search,
108
+ page_title: document.title,
109
+ });
110
+ </script>
111
+ </>
112
+ )}
113
+
114
+ <Seo
115
+ title={title}
116
+ description={description}
117
+ type={type}
118
+ noindex={noindex}
119
+ jsonLd={jsonLd}
120
+ site={site}
121
+ searchPath={searchPath}
122
+ />
123
+
124
+ <slot name="head" />
125
+
126
+ <script is:inline>
127
+ // THEME_INIT — runs before paint so the user never sees a
128
+ // flash of the wrong theme. State is persisted in localStorage
129
+ // under the key "grove-theme" (matching ThemeToggle.astro).
130
+ (function () {
131
+ var STORAGE_KEY = "grove-theme";
132
+ function getMode() {
133
+ try {
134
+ var stored = localStorage.getItem(STORAGE_KEY);
135
+ if (stored === "light" || stored === "dark" || stored === "system")
136
+ return stored;
137
+ } catch (e) {
138
+ // localStorage may be blocked (private mode, etc.) —
139
+ // fall through to the system default.
140
+ }
141
+ return "system";
142
+ }
143
+ function apply(mode) {
144
+ var resolved =
145
+ mode === "system"
146
+ ? (window.matchMedia("(prefers-color-scheme: dark)").matches
147
+ ? "dark"
148
+ : "light")
149
+ : mode;
150
+ var root = document.documentElement;
151
+ if (resolved === "dark") root.classList.add("dark");
152
+ else root.classList.remove("dark");
153
+ root.dataset.theme = mode;
154
+ root.dataset.resolvedTheme = resolved;
155
+ }
156
+ apply(getMode());
157
+ window.addEventListener("theme:set", function (e) {
158
+ apply(e.detail);
159
+ });
160
+ var mq = window.matchMedia("(prefers-color-scheme: dark)");
161
+ mq.addEventListener("change", function () {
162
+ if (getMode() === "system") apply("system");
163
+ });
164
+ })();
165
+ </script>
19
166
  </head>
20
- <body>
21
- <header class="oc-topbar">
22
- <a href="/" class="oc-brand">Grove</a>
23
- <nav aria-label="Primary">
24
- <a href="/projects">Directory</a>
25
- <a href="/methodology">Methodology</a>
26
- </nav>
27
- </header>
28
- <main class="oc-shell">
167
+ <body class="min-h-screen bg-white text-ink-900 dark:bg-ink-950 dark:text-ink-100">
168
+ <slot name="header">
169
+ <Header
170
+ site={site}
171
+ submitLabel={submitLabel}
172
+ submitHref={submitHref}
173
+ />
174
+ </slot>
175
+
176
+ <main>
29
177
  <slot />
30
178
  </main>
179
+
180
+ <slot name="footer">
181
+ <Footer site={site} />
182
+ </slot>
183
+
184
+ <slot name="body-end" />
185
+
186
+ {gaId && (
187
+ <script is:inline define:vars={{ gaId }}>
188
+ // Mirror in-page SPA navigation (history.pushState /
189
+ // replaceState) into GA4 page_view events so filter changes
190
+ // on /projects show up as separate page views in the
191
+ // dashboard instead of one inflated session.
192
+ (function () {
193
+ var last = location.pathname + location.search;
194
+ function report() {
195
+ var next = location.pathname + location.search;
196
+ if (next === last) return;
197
+ last = next;
198
+ window.gtag("event", "page_view", {
199
+ page_path: next,
200
+ page_title: document.title,
201
+ page_location: location.href,
202
+ });
203
+ }
204
+ window.addEventListener("popstate", report);
205
+ var origPush = history.pushState;
206
+ var origReplace = history.replaceState;
207
+ history.pushState = function () {
208
+ origPush.apply(this, arguments);
209
+ report();
210
+ };
211
+ history.replaceState = function () {
212
+ origReplace.apply(this, arguments);
213
+ report();
214
+ };
215
+ })();
216
+ </script>
217
+ )}
218
+
219
+ {gaId && (
220
+ <script is:inline>
221
+ // Delegated click handler for `data-event` elements. We do
222
+ // this in one place so individual components just emit data
223
+ // attributes and stay declarative.
224
+ (function () {
225
+ document.addEventListener("click", function (e) {
226
+ var el = e.target instanceof Element
227
+ ? e.target.closest("[data-event]")
228
+ : null;
229
+ if (!el) return;
230
+ var name = el.getAttribute("data-event");
231
+ if (!name) return;
232
+ var params = {
233
+ source: el.getAttribute("data-event-source") || undefined,
234
+ app_slug: el.getAttribute("data-event-app") || undefined,
235
+ channel: el.getAttribute("data-event-channel") || undefined,
236
+ link_url: el.getAttribute("href") || undefined,
237
+ };
238
+ window.gtag("event", name, params);
239
+ });
240
+ })();
241
+ </script>
242
+ )}
31
243
  </body>
32
244
  </html>
@@ -0,0 +1,57 @@
1
+ ---
2
+ /**
3
+ * Container — page-width wrapper.
4
+ *
5
+ * Centralizes the three layout widths the showcase uses:
6
+ *
7
+ * - "default" → 1140px (the standard reading width)
8
+ * - "wide" → 1400px (home, full-bleed grid sections)
9
+ * - "narrow" → 720px (about, methodology, content-heavy pages)
10
+ *
11
+ * Pure layout, no styling dependencies beyond Tailwind's `max-w-*`
12
+ * tokens defined in `@grove-dev/astro/styles.css`.
13
+ */
14
+
15
+ interface Props {
16
+ size?: "default" | "wide" | "narrow";
17
+ class?: string;
18
+ /** Padding preset. Defaults to a comfortable 1.25rem / 1.75rem. */
19
+ padding?: "default" | "none" | "tight";
20
+ }
21
+
22
+ const {
23
+ size = "default",
24
+ class: className = "",
25
+ padding = "default",
26
+ } = Astro.props;
27
+
28
+ const sizeClass =
29
+ size === "wide"
30
+ ? "grove-container-wide"
31
+ : size === "narrow"
32
+ ? "grove-container-narrow"
33
+ : "grove-container";
34
+
35
+ const paddingClass =
36
+ padding === "none"
37
+ ? ""
38
+ : padding === "tight"
39
+ ? "px-4 sm:px-5"
40
+ : "px-5 sm:px-7";
41
+ ---
42
+
43
+ <div class:list={["mx-auto", sizeClass, paddingClass, className]}>
44
+ <slot />
45
+ </div>
46
+
47
+ <style>
48
+ .grove-container {
49
+ max-width: var(--container-container, 1140px);
50
+ }
51
+ .grove-container-wide {
52
+ max-width: var(--container-wide, 1400px);
53
+ }
54
+ .grove-container-narrow {
55
+ max-width: var(--container-narrow, 720px);
56
+ }
57
+ </style>
@@ -0,0 +1,235 @@
1
+ ---
2
+ /**
3
+ * Footer — 4-column grid (brand + Discover + Contribute + Project) plus
4
+ * a thin bar at the bottom with copyright + repo link.
5
+ *
6
+ * Generic: takes a `site` prop with brand + repoUrl. No siteConfig
7
+ * import — consumers compose props from their own config.
8
+ *
9
+ * Each column's links are passed in as a `{ heading, items[] }` shape
10
+ * so consumers can customize the order and copy. Defaults are provided
11
+ * for the brand and bar so the footer still renders if no extra
12
+ * link data is supplied.
13
+ */
14
+
15
+ interface NavItem {
16
+ label: string;
17
+ href: string;
18
+ /** When true, opens in a new tab (external). */
19
+ external?: boolean;
20
+ }
21
+
22
+ interface FooterColumn {
23
+ heading: string;
24
+ items: NavItem[];
25
+ }
26
+
27
+ interface Site {
28
+ name: string;
29
+ tagline?: string;
30
+ description?: string;
31
+ repoUrl?: string;
32
+ }
33
+
34
+ interface Props {
35
+ site: Site;
36
+ /** Discover links — first content column. */
37
+ discover?: FooterColumn;
38
+ /** Contribute links — second content column. */
39
+ contribute?: FooterColumn;
40
+ /** Project links — third content column. */
41
+ project?: FooterColumn;
42
+ /** Stats line under brand blurb (e.g. "79 apps · 12 categories"). */
43
+ stats?: { apps?: number; categories?: number; contributors?: number };
44
+ /** Copyright suffix. Defaults to site.name. */
45
+ copyrightHolder?: string;
46
+ /** License line under the bar (e.g. "Content under CC BY-SA 4.0."). */
47
+ licenseLine?: string;
48
+ /** Extra class for the outer footer element. */
49
+ class?: string;
50
+ }
51
+
52
+ const {
53
+ site,
54
+ discover = { heading: "Discover", items: [] },
55
+ contribute = { heading: "Contribute", items: [] },
56
+ project = { heading: "Project", items: [] },
57
+ stats,
58
+ copyrightHolder,
59
+ licenseLine,
60
+ class: className = "",
61
+ } = Astro.props;
62
+
63
+ const year = new Date().getFullYear();
64
+ const holder = copyrightHolder ?? site.name;
65
+ const gh = site.repoUrl;
66
+
67
+ const defaultDiscover: NavItem[] = gh
68
+ ? [
69
+ { label: "Source on GitHub", href: gh, external: true },
70
+ { label: "Latest release", href: `${gh}/releases`, external: true },
71
+ ]
72
+ : [];
73
+ const defaultContribute: NavItem[] = gh
74
+ ? [
75
+ { label: "Submit via GitHub", href: `${gh}/blob/main/CONTRIBUTING.md`, external: true },
76
+ { label: "Report an issue", href: `${gh}/issues`, external: true },
77
+ { label: "Contributing guide", href: `${gh}/blob/main/CONTRIBUTING.md`, external: true },
78
+ ]
79
+ : [];
80
+ const defaultProject: NavItem[] = gh
81
+ ? [
82
+ { label: "README", href: `${gh}/blob/main/README.md`, external: true },
83
+ { label: "Changelog", href: `${gh}/blob/main/CHANGELOG.md`, external: true },
84
+ ]
85
+ : [];
86
+
87
+ const discoverItems = discover.items.length ? discover.items : defaultDiscover;
88
+ const contributeItems = contribute.items.length ? contribute.items : defaultContribute;
89
+ const projectItems = project.items.length ? project.items : defaultProject;
90
+ ---
91
+
92
+ <footer
93
+ class:list={[
94
+ "mt-16 border-t border-ink-200 bg-white text-ink-500 dark:border-ink-800 dark:bg-ink-950 dark:text-ink-400",
95
+ className,
96
+ ]}
97
+ >
98
+ <div
99
+ class="mx-auto grid max-w-wide grid-cols-2 gap-8 px-5 py-12 md:grid-cols-4 sm:px-7"
100
+ style="max-width: var(--container-wide, 1400px);"
101
+ >
102
+ <div class="col-span-2 md:col-span-1">
103
+ <div
104
+ class="mb-2 inline-flex items-center gap-2 text-[0.95rem] font-semibold text-ink-900 dark:text-ink-100"
105
+ >
106
+ <span
107
+ aria-hidden="true"
108
+ class="inline-block h-3.5 w-3.5 rounded bg-ink-900 dark:bg-ink-100"
109
+ ></span>
110
+ <span>{site.name}</span>
111
+ </div>
112
+ {site.tagline && (
113
+ <p class="m-0 max-w-[36ch] text-[0.8125rem] leading-relaxed text-ink-500 dark:text-ink-400">
114
+ {site.tagline}
115
+ </p>
116
+ )}
117
+ {stats && (
118
+ <p class="mt-3 m-0 text-2xs text-ink-500 dark:text-ink-400">
119
+ {stats.apps !== undefined && (
120
+ <>
121
+ <span class="font-mono">{stats.apps}</span> apps
122
+ </>
123
+ )}
124
+ {stats.apps !== undefined && (stats.categories !== undefined || stats.contributors !== undefined) && (
125
+ <span class="mx-1 text-ink-300 dark:text-ink-700">·</span>
126
+ )}
127
+ {stats.categories !== undefined && (
128
+ <>
129
+ <span class="font-mono">{stats.categories}</span> categories
130
+ </>
131
+ )}
132
+ {stats.categories !== undefined && stats.contributors !== undefined && (
133
+ <span class="mx-1 text-ink-300 dark:text-ink-700">·</span>
134
+ )}
135
+ {stats.contributors !== undefined && (
136
+ <>
137
+ <span class="font-mono">{stats.contributors}</span> contributors
138
+ </>
139
+ )}
140
+ </p>
141
+ )}
142
+ </div>
143
+
144
+ {discoverItems.length > 0 && (
145
+ <div>
146
+ <h4 class="mb-3 text-2xs font-semibold uppercase tracking-wider text-ink-900 dark:text-ink-100">
147
+ {discover.heading}
148
+ </h4>
149
+ <ul class="m-0 flex list-none flex-col gap-2 p-0">
150
+ {discoverItems.map((item) => (
151
+ <li>
152
+ <a
153
+ href={item.href}
154
+ class="text-sm transition-colors hover:text-ink-900 dark:hover:text-ink-100"
155
+ {...(item.external
156
+ ? { target: "_blank", rel: "noopener noreferrer" }
157
+ : {})}
158
+ >
159
+ {item.label}
160
+ </a>
161
+ </li>
162
+ ))}
163
+ </ul>
164
+ </div>
165
+ )}
166
+
167
+ {contributeItems.length > 0 && (
168
+ <div>
169
+ <h4 class="mb-3 text-2xs font-semibold uppercase tracking-wider text-ink-900 dark:text-ink-100">
170
+ {contribute.heading}
171
+ </h4>
172
+ <ul class="m-0 flex list-none flex-col gap-2 p-0">
173
+ {contributeItems.map((item) => (
174
+ <li>
175
+ <a
176
+ href={item.href}
177
+ class="text-sm transition-colors hover:text-ink-900 dark:hover:text-ink-100"
178
+ {...(item.external
179
+ ? { target: "_blank", rel: "noopener noreferrer" }
180
+ : {})}
181
+ >
182
+ {item.label}
183
+ </a>
184
+ </li>
185
+ ))}
186
+ </ul>
187
+ </div>
188
+ )}
189
+
190
+ {projectItems.length > 0 && (
191
+ <div>
192
+ <h4 class="mb-3 text-2xs font-semibold uppercase tracking-wider text-ink-900 dark:text-ink-100">
193
+ {project.heading}
194
+ </h4>
195
+ <ul class="m-0 flex list-none flex-col gap-2 p-0">
196
+ {projectItems.map((item) => (
197
+ <li>
198
+ <a
199
+ href={item.href}
200
+ class="text-sm transition-colors hover:text-ink-900 dark:hover:text-ink-100"
201
+ {...(item.external
202
+ ? { target: "_blank", rel: "noopener noreferrer" }
203
+ : {})}
204
+ >
205
+ {item.label}
206
+ </a>
207
+ </li>
208
+ ))}
209
+ </ul>
210
+ </div>
211
+ )}
212
+ </div>
213
+
214
+ <div class="border-t border-ink-200 dark:border-ink-800">
215
+ <div
216
+ class="mx-auto flex 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"
217
+ style="max-width: var(--container-wide, 1400px);"
218
+ >
219
+ <span>© {year} {holder}.{licenseLine ? ` ${licenseLine}` : ""}</span>
220
+ {gh && (
221
+ <>
222
+ <span class="text-ink-300 dark:text-ink-700">·</span>
223
+ <a
224
+ href={gh}
225
+ target="_blank"
226
+ rel="noopener noreferrer"
227
+ class="transition-colors hover:text-ink-900 dark:hover:text-ink-100"
228
+ >
229
+ View source on GitHub
230
+ </a>
231
+ </>
232
+ )}
233
+ </div>
234
+ </div>
235
+ </footer>