ferst-core 0.1.0 → 0.2.0

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 (70) hide show
  1. package/LICENSE +102 -201
  2. package/LICENSE-Apache-2.0 +201 -0
  3. package/NOTICE +24 -5
  4. package/README.md +89 -73
  5. package/bin/check-thin.mjs +74 -0
  6. package/components/BlockRenderer.astro +89 -0
  7. package/components/Button.astro +85 -33
  8. package/components/CalendarEmbed.astro +188 -0
  9. package/components/Card.astro +7 -17
  10. package/components/Icon.astro +35 -0
  11. package/components/PostArticle.astro +133 -0
  12. package/components/PostCard.astro +85 -225
  13. package/components/SiteFooter.astro +2 -2
  14. package/components/SiteHeader.astro +104 -51
  15. package/components/blocks/Badge.astro +29 -0
  16. package/components/blocks/ButtonBlock.astro +13 -0
  17. package/components/blocks/Divider.astro +12 -0
  18. package/components/blocks/Grid.astro +48 -0
  19. package/components/blocks/Heading.astro +42 -0
  20. package/components/blocks/ImageBlock.astro +80 -0
  21. package/components/blocks/List.astro +78 -0
  22. package/components/blocks/Prose.astro +29 -0
  23. package/components/blocks/Quote.astro +32 -0
  24. package/components/blocks/Section.astro +52 -0
  25. package/components/blocks/Spacer.astro +20 -0
  26. package/components/blocks/Stack.astro +30 -0
  27. package/components/blocks/Stat.astro +30 -0
  28. package/components/recipes/Accordion.astro +84 -0
  29. package/components/recipes/Announcement.astro +74 -0
  30. package/components/recipes/Banner.astro +134 -0
  31. package/components/recipes/Bento.astro +138 -0
  32. package/components/recipes/ContactForm.astro +172 -0
  33. package/components/recipes/Cta.astro +83 -0
  34. package/components/recipes/Faq.astro +80 -0
  35. package/components/recipes/FeatureCards.astro +105 -0
  36. package/components/recipes/Gallery.astro +55 -0
  37. package/components/recipes/Hero.astro +191 -0
  38. package/components/recipes/Logos.astro +77 -0
  39. package/components/recipes/Marquee.astro +75 -0
  40. package/components/recipes/Pricing.astro +149 -0
  41. package/components/recipes/Stats.astro +124 -0
  42. package/components/recipes/Steps.astro +120 -0
  43. package/components/recipes/Tabs.astro +133 -0
  44. package/components/recipes/Testimonial.astro +66 -0
  45. package/components/recipes/Tiles.astro +248 -0
  46. package/content/blocks.ts +534 -0
  47. package/content/calendar.ts +40 -0
  48. package/content/collections.ts +25 -14
  49. package/content/posts.ts +89 -0
  50. package/content/schemas.ts +58 -123
  51. package/layouts/Base.astro +30 -56
  52. package/lib/calendar.ts +12 -0
  53. package/lib/icons.ts +64 -0
  54. package/lib/pages.ts +42 -0
  55. package/lib/posts.ts +93 -0
  56. package/lib/siteSettings.ts +18 -36
  57. package/lib/themeTokens.ts +163 -0
  58. package/package.json +61 -48
  59. package/styles/theme.css +42 -0
  60. package/components/DocLayout.astro +0 -292
  61. package/components/GroupCard.astro +0 -164
  62. package/components/LatestPostList.astro +0 -64
  63. package/components/PaginationNav.astro +0 -81
  64. package/components/PostsToolbar.astro +0 -83
  65. package/content/filters.ts +0 -107
  66. package/lib/mailLinks.ts +0 -13
  67. package/lib/mapsLink.ts +0 -7
  68. package/utils/excerpt.ts +0 -56
  69. package/utils/formatDate.ts +0 -22
  70. package/utils/freshness.ts +0 -5
@@ -1,292 +0,0 @@
1
- ---
2
- /**
3
- * Shared chrome for the site's legal/policy pages (e.g. Privacy, Terms &
4
- * Conditions, Accessibility): eyebrow +
5
- * title + optional dek/"updated" badge, an optional highlighted callout,
6
- * and — once a page has more than one `sections` entry — a sticky "On
7
- * this page" rail with scroll-spy, matching the anchor ids of the
8
- * <section> elements the page itself supplies via the default slot.
9
- *
10
- * Deliberately data-driven (an explicit `sections` prop) rather than
11
- * auto-walking the slotted h2s at runtime: only 4 pages use this, their
12
- * structure rarely changes, and a static id/label pair is simpler and more
13
- * robust than id-slugification.
14
- */
15
- import Base from '../layouts/Base.astro';
16
-
17
- interface Props {
18
- title: string;
19
- eyebrow?: string;
20
- dek?: string;
21
- updated?: string;
22
- noteLabel?: string;
23
- noteText?: string;
24
- sections?: { id: string; label: string }[];
25
- }
26
-
27
- const {
28
- title,
29
- eyebrow = 'Legal & Policies',
30
- dek,
31
- updated,
32
- noteLabel,
33
- noteText,
34
- sections = [],
35
- } = Astro.props;
36
-
37
- const showToc = sections.length > 1;
38
- ---
39
-
40
- <Base title={title} description={dek} showSecondaryLogo>
41
- <div class="doc-page" id="top">
42
- <div class="doc-head">
43
- <h4>{eyebrow}</h4>
44
- <h1>{title}</h1>
45
- {dek && <p class="t-lead doc-dek">{dek}</p>}
46
- {updated && <span class="doc-updated">Updated {updated}</span>}
47
- </div>
48
-
49
- <div class:list={['doc-grid', { 'doc-grid--no-toc': !showToc }]}>
50
- {showToc && (
51
- <nav class="toc-rail" aria-label="On this page">
52
- <p class="toc-label">On this page</p>
53
- <ul class="toc-list">
54
- {sections.map((s) => (
55
- <li><a href={`#${s.id}`} data-toc-link={s.id}>{s.label}</a></li>
56
- ))}
57
- </ul>
58
- </nav>
59
- )}
60
-
61
- <div class="doc-body">
62
- <slot name="lead" />
63
- {(noteLabel || noteText) && (
64
- <div class="key-point">
65
- <span class="key-point__icon" aria-hidden="true">
66
- <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M2 12h20"></path></svg>
67
- </span>
68
- <div>
69
- {noteLabel && <p class="key-point__label">{noteLabel}</p>}
70
- {noteText && <p class="key-point__text">{noteText}</p>}
71
- </div>
72
- </div>
73
- )}
74
- <slot />
75
- {showToc && (
76
- <a class="back-to-top" href="#top">
77
- <span class="back-to-top__arrow" aria-hidden="true">&uarr;</span> Back to top
78
- </a>
79
- )}
80
- </div>
81
- </div>
82
- </div>
83
- </Base>
84
-
85
- <style>
86
- .doc-page { display: flex; flex-direction: column; }
87
-
88
- .doc-head { max-width: 46rem; }
89
- .doc-head h1 { text-align: left; }
90
- .doc-dek { color: var(--muted); max-width: 56ch; margin: 0.5rem 0 1rem; }
91
-
92
- .doc-updated {
93
- display: inline-flex;
94
- align-items: center;
95
- font-family: 'Poppins', sans-serif;
96
- font-size: 0.65rem;
97
- font-weight: 600;
98
- text-transform: uppercase;
99
- letter-spacing: 0.08em;
100
- color: var(--brown);
101
- background: rgba(180, 145, 70, 0.22);
102
- border-radius: 4px;
103
- padding: 0.2rem 0.5rem;
104
- line-height: 1.2;
105
- }
106
- :global(html[data-theme='dark']) .doc-updated {
107
- color: var(--text-light);
108
- background: rgba(255, 255, 255, 0.12);
109
- }
110
-
111
- .key-point {
112
- display: flex;
113
- gap: 0.85rem;
114
- padding: 1rem 1.15rem;
115
- background: rgba(200, 151, 58, 0.1);
116
- border: 1px solid rgba(200, 151, 58, 0.35);
117
- border-radius: 10px;
118
- }
119
- :global(html[data-theme='dark']) .key-point {
120
- background: rgba(255, 255, 255, 0.045);
121
- border-color: rgba(232, 168, 74, 0.35);
122
- }
123
- .key-point__icon {
124
- flex-shrink: 0;
125
- width: 28px;
126
- height: 28px;
127
- border-radius: 8px;
128
- background: var(--gold);
129
- color: var(--bg-surface);
130
- display: flex;
131
- align-items: center;
132
- justify-content: center;
133
- }
134
- .key-point__label {
135
- font-family: 'Poppins', sans-serif;
136
- font-size: 0.7rem;
137
- font-weight: 700;
138
- text-transform: uppercase;
139
- letter-spacing: 0.08em;
140
- color: var(--brown);
141
- margin: 0 0 0.25rem;
142
- }
143
- :global(html[data-theme='dark']) .key-point__label { color: var(--text-light); }
144
- .key-point__text { margin: 0; }
145
-
146
- .doc-grid {
147
- display: grid;
148
- grid-template-columns: 1fr;
149
- gap: 2.25rem;
150
- align-items: start;
151
- }
152
- @media (min-width: 62em) {
153
- .doc-grid:not(.doc-grid--no-toc) {
154
- grid-template-columns: 200px minmax(0, 46rem);
155
- }
156
- }
157
-
158
- .toc-rail { position: static; }
159
- @media (min-width: 62em) {
160
- .toc-rail { position: sticky; top: 5.5rem; }
161
- }
162
- .toc-label {
163
- font-family: 'Poppins', sans-serif;
164
- font-size: 0.68rem;
165
- font-weight: 700;
166
- text-transform: uppercase;
167
- letter-spacing: 0.1em;
168
- color: var(--text-subtle);
169
- margin: 0 0 0.7rem;
170
- }
171
- .toc-list {
172
- list-style: none;
173
- margin: 0;
174
- padding: 0;
175
- display: flex;
176
- flex-direction: column;
177
- gap: 0.15rem;
178
- border-left: 2px solid var(--border);
179
- }
180
- .toc-list a {
181
- display: block;
182
- padding: 0.4rem 0 0.4rem 0.85rem;
183
- margin-left: -2px;
184
- border-left: 2px solid transparent;
185
- font-size: 0.85rem;
186
- font-weight: 500;
187
- color: var(--muted);
188
- text-decoration: none;
189
- }
190
- .toc-list a:hover { color: var(--brown); }
191
- :global(html[data-theme='dark']) .toc-list a:hover { color: var(--gold); }
192
- .toc-list a.is-active {
193
- color: var(--brown);
194
- border-left-color: var(--gold);
195
- font-weight: 700;
196
- }
197
- :global(html[data-theme='dark']) .toc-list a.is-active { color: var(--gold); }
198
-
199
- /* Layout does the spacing here (matches .post-detail's technique in
200
- posts/[...slug].astro) rather than per-element margins, since the
201
- lead slot, the key-point box, and each <section> are siblings of
202
- varying content whose own p/li margins are zeroed globally by
203
- Base.astro — margin-based spacing between them silently collapsed
204
- to nothing without this. */
205
- .doc-body {
206
- min-width: 0;
207
- display: flex;
208
- flex-direction: column;
209
- gap: clamp(1.1rem, 2vw, 1.5rem);
210
- }
211
- .doc-body :global(section) {
212
- display: flex;
213
- flex-direction: column;
214
- gap: clamp(0.85rem, 1.5vw, 1.1rem);
215
- scroll-margin-top: 5rem;
216
- }
217
- .doc-body :global(section + section) {
218
- margin-top: 1.1rem;
219
- padding-top: 2.25rem;
220
- border-top: 1px solid var(--border);
221
- }
222
- .doc-body :global(h2) { margin: 0; text-align: left; }
223
- .doc-body :global(ol),
224
- .doc-body :global(ul) { margin: 0; padding-left: 1.25rem; }
225
- .doc-body :global(ol li),
226
- .doc-body :global(ul li) { margin-bottom: 0.75rem; }
227
- .doc-body :global(ol li:last-child),
228
- .doc-body :global(ul li:last-child) { margin-bottom: 0; }
229
- .doc-body :global(li::marker) { color: var(--gold); font-weight: 700; }
230
- .doc-body :global(p) { margin: 0; }
231
- .doc-body :global(.notice-strap) {
232
- font-weight: 700;
233
- color: var(--accent);
234
- background: var(--gold-light);
235
- border-left: 3px solid var(--gold);
236
- padding: 0.7rem 0.9rem;
237
- border-radius: 0 6px 6px 0;
238
- font-size: 0.92rem;
239
- }
240
- :global(html[data-theme='dark']) .doc-body :global(.notice-strap) { color: var(--text-light); }
241
-
242
- .back-to-top {
243
- display: inline-flex;
244
- align-items: center;
245
- gap: 0.35rem;
246
- margin-top: 1.25rem;
247
- font-family: 'Poppins', sans-serif;
248
- font-size: 0.78rem;
249
- font-weight: 700;
250
- color: var(--brown);
251
- text-transform: uppercase;
252
- letter-spacing: 0.06em;
253
- transition: color 0.2s ease;
254
- }
255
- .back-to-top:hover { color: var(--gold); }
256
- :global(html[data-theme='dark']) .back-to-top { color: var(--text-light); }
257
- :global(html[data-theme='dark']) .back-to-top:hover { color: var(--gold); }
258
- .back-to-top__arrow {
259
- display: inline-block;
260
- transition: transform 0.2s ease;
261
- }
262
- .back-to-top:hover .back-to-top__arrow { transform: translateY(-3px); }
263
- @media (prefers-reduced-motion: reduce) {
264
- .back-to-top__arrow { transition: none; }
265
- .back-to-top:hover .back-to-top__arrow { transform: none; }
266
- }
267
- </style>
268
-
269
- {showToc && (
270
- <script>
271
- const links = document.querySelectorAll<HTMLAnchorElement>('[data-toc-link]');
272
- if (links.length) {
273
- const map = new Map<string, HTMLAnchorElement>();
274
- links.forEach((l) => {
275
- const key = l.dataset.tocLink;
276
- if (key) map.set(key, l);
277
- });
278
- const observer = new IntersectionObserver(
279
- (entries) => {
280
- entries.forEach((entry) => {
281
- const link = map.get(entry.target.id);
282
- if (!link || !entry.isIntersecting) return;
283
- links.forEach((l) => l.classList.remove('is-active'));
284
- link.classList.add('is-active');
285
- });
286
- },
287
- { rootMargin: '-15% 0px -70% 0px' }
288
- );
289
- document.querySelectorAll('.doc-body section[id]').forEach((s) => observer.observe(s));
290
- }
291
- </script>
292
- )}
@@ -1,164 +0,0 @@
1
- ---
2
- import Card from './Card.astro';
3
- import { normalizeMailtoHref } from '../lib/mailLinks';
4
-
5
- interface Props {
6
- title: string;
7
- description: string;
8
- icon?: string;
9
- contact?: string;
10
- contactLabel?: string;
11
- linkHref?: string;
12
- imageSrc?: string;
13
- imageAlt?: string;
14
- }
15
-
16
- const { title, description, contact, contactLabel, linkHref, imageSrc, imageAlt } = Astro.props;
17
- const trimmedLink = linkHref?.trim();
18
- const trimmedContact = contact?.trim();
19
-
20
- function resolvedGroupHref(): string | undefined {
21
- if (trimmedLink) {
22
- if (/^https?:\/\//i.test(trimmedLink)) return trimmedLink;
23
- if (trimmedLink.startsWith('/') || trimmedLink.startsWith('#')) return trimmedLink;
24
- const mail = normalizeMailtoHref(trimmedLink);
25
- return mail || undefined;
26
- }
27
- if (trimmedContact) {
28
- const mail = normalizeMailtoHref(trimmedContact);
29
- return mail || undefined;
30
- }
31
- }
32
-
33
- const resolvedHref = resolvedGroupHref();
34
- const isExternal = resolvedHref ? /^https?:\/\//.test(resolvedHref) : false;
35
- const isEmail = resolvedHref ? /^mailto:/.test(resolvedHref) : false;
36
- ---
37
-
38
- <Card as="article" padding="0" class="group-card">
39
- {imageSrc ? (
40
- <img
41
- src={imageSrc}
42
- alt={imageAlt || title}
43
- class="group-card__img"
44
- loading="lazy"
45
- decoding="async"
46
- />
47
- ) : (
48
- <div class="group-card__placeholder" aria-hidden="true">
49
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" width="48" height="48" aria-hidden="true">
50
- <line x1="12" y1="3" x2="12" y2="21"/>
51
- <line x1="5" y1="8" x2="19" y2="8"/>
52
- </svg>
53
- </div>
54
- )}
55
- <div class="group-card__content">
56
- <h3>{title}</h3>
57
- <p>{description}</p>
58
- {resolvedHref && (
59
- <div>
60
- <a
61
- href={resolvedHref}
62
- target={isExternal ? '_blank' : undefined}
63
- rel={isExternal ? 'noopener noreferrer' : undefined}
64
- >
65
- {isEmail && (
66
- <svg
67
- class="group-card__link-icon"
68
- width="14"
69
- height="14"
70
- viewBox="0 0 24 24"
71
- fill="none"
72
- stroke="currentColor"
73
- stroke-width="2"
74
- stroke-linecap="round"
75
- stroke-linejoin="round"
76
- aria-hidden="true"
77
- >
78
- <rect x="2" y="4" width="20" height="16" rx="2"></rect>
79
- <path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"></path>
80
- </svg>
81
- )}
82
- {isExternal && (
83
- <svg
84
- class="group-card__link-icon"
85
- width="14"
86
- height="14"
87
- viewBox="0 0 24 24"
88
- fill="none"
89
- stroke="currentColor"
90
- stroke-width="2"
91
- stroke-linecap="round"
92
- stroke-linejoin="round"
93
- aria-hidden="true"
94
- >
95
- <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
96
- <polyline points="15 3 21 3 21 9"></polyline>
97
- <line x1="10" y1="14" x2="21" y2="3"></line>
98
- </svg>
99
- )}
100
- {contactLabel || contact}
101
- </a>
102
- </div>
103
- )}
104
- </div>
105
- </Card>
106
-
107
- <style>
108
- article {
109
- margin: 0;
110
- height: 100%;
111
- }
112
-
113
- .group-card__content {
114
- display: flex;
115
- flex-direction: column;
116
- flex: 1;
117
- gap: 0.75rem;
118
- padding: 1rem 1.25rem;
119
- }
120
-
121
- .group-card__content h3 {
122
- margin-block: clamp(0.75rem, 2vw, 1.25rem);
123
- }
124
-
125
- .group-card__content > div {
126
- margin-top: auto;
127
- display: flex;
128
- justify-content: flex-end;
129
- align-items: flex-end;
130
- }
131
-
132
- .group-card__content a {
133
- display: inline-flex;
134
- align-items: center;
135
- gap: 0.375rem;
136
- }
137
-
138
- .group-card__content a:hover {
139
- color: #755528;
140
- }
141
-
142
- :global([data-theme='dark']) .group-card__content a:hover {
143
- color: #C8D4C2;
144
- }
145
-
146
- img {
147
- display: block;
148
- width: 100%;
149
- aspect-ratio: 16 / 9;
150
- object-fit: cover;
151
- object-position: center;
152
- margin: 0;
153
- }
154
-
155
- .group-card__placeholder {
156
- width: 100%;
157
- aspect-ratio: 16 / 9;
158
- background: linear-gradient(135deg, #4A6741 0%, #2C3B2A 100%);
159
- display: flex;
160
- align-items: center;
161
- justify-content: center;
162
- color: rgba(255, 255, 255, 0.55);
163
- }
164
- </style>
@@ -1,64 +0,0 @@
1
- ---
2
- import type { CollectionEntry } from 'astro:content';
3
- import Card from './Card.astro';
4
- import PostCard from './PostCard.astro';
5
-
6
- interface Props {
7
- posts: CollectionEntry<'posts'>[];
8
- }
9
-
10
- const { posts } = Astro.props;
11
- ---
12
-
13
- <ul class="latest-list">
14
- {
15
- posts.map((post) => (
16
- <li class="latest-list__item">
17
- <Card padding="0" class="latest-list__card">
18
- <PostCard post={post} withImage />
19
- </Card>
20
- </li>
21
- ))
22
- }
23
- </ul>
24
-
25
- <style>
26
- .latest-list {
27
- list-style: none;
28
- padding: 0;
29
- margin: 0;
30
- display: grid;
31
- grid-template-columns: 1fr;
32
- gap: 1rem;
33
- }
34
-
35
- @media (min-width: 48em) {
36
- .latest-list {
37
- grid-template-columns: repeat(2, 1fr);
38
- gap: 1.25rem;
39
- }
40
- }
41
-
42
- @media (min-width: 64em) {
43
- .latest-list {
44
- grid-template-columns: repeat(3, 1fr);
45
- gap: 1.5rem;
46
- }
47
- }
48
-
49
- /* display: grid (not flex/block) so this reliably stretches its one child
50
- (.latest-list__card) to the full row height — a plain block wrapper
51
- doesn't pass a grid-stretched height down to a nested child the same
52
- way. Kept as a separate element from the Card itself so the
53
- reveal-on-scroll transform/opacity (armReveal, applied in
54
- [...page].astro) doesn't collide with Card's own hover transition on
55
- the same element. */
56
- .latest-list__item {
57
- display: grid;
58
- }
59
-
60
- .latest-list__card :global(.post-card) {
61
- border-bottom: none;
62
- padding: 0;
63
- }
64
- </style>
@@ -1,81 +0,0 @@
1
- ---
2
- import Button from './Button.astro';
3
-
4
- interface Props {
5
- currentPage: number;
6
- lastPage: number;
7
- prevUrl: string | undefined;
8
- nextUrl: string | undefined;
9
- }
10
-
11
- const { currentPage, lastPage, prevUrl, nextUrl } = Astro.props;
12
- ---
13
-
14
- <nav class="pagination-nav" aria-label="Pagination navigation">
15
- <div class="pagination-nav__controls">
16
- {prevUrl ? (
17
- <Button href={prevUrl} variant="primary" size="sm" rel="prev" class="pagination-nav__btn" style="border-radius: 8px;">
18
- ← Prev
19
- </Button>
20
- ) : (
21
- <Button variant="primary" size="sm" disabled aria-disabled="true" class="pagination-nav__btn" style="border-radius: 8px;">
22
- ← Prev
23
- </Button>
24
- )}
25
-
26
- <span class="pagination-nav__info">
27
- Page {currentPage} of {lastPage}
28
- </span>
29
-
30
- {nextUrl ? (
31
- <Button href={nextUrl} variant="primary" size="sm" rel="next" class="pagination-nav__btn" style="border-radius: 8px;">
32
- Next →
33
- </Button>
34
- ) : (
35
- <Button variant="primary" size="sm" disabled aria-disabled="true" class="pagination-nav__btn" style="border-radius: 8px;">
36
- Next →
37
- </Button>
38
- )}
39
- </div>
40
- </nav>
41
-
42
- <style>
43
- .pagination-nav {
44
- display: flex;
45
- justify-content: center;
46
- }
47
-
48
- .pagination-nav__controls {
49
- display: flex;
50
- align-items: center;
51
- justify-content: center;
52
- gap: clamp(1.5rem, 3vw, 2.5rem);
53
- flex-wrap: wrap;
54
- }
55
-
56
- .pagination-nav__info {
57
- font-family: 'Inter', sans-serif;
58
- font-size: clamp(0.875rem, 1.5vw, 1rem);
59
- line-height: 1.4;
60
- color: var(--muted);
61
- white-space: nowrap;
62
- }
63
-
64
- :global([data-theme='dark']) .pagination-nav__info {
65
- color: var(--text-light);
66
- }
67
-
68
- /* Mobile: tighter spacing */
69
- @media (max-width: 47.9375em) {
70
- .pagination-nav__controls {
71
- gap: 0.75rem;
72
- }
73
-
74
- .pagination-nav__info {
75
- font-size: 0.8rem;
76
- order: 3;
77
- width: 100%;
78
- text-align: center;
79
- }
80
- }
81
- </style>
@@ -1,83 +0,0 @@
1
- ---
2
- interface Props {
3
- availableTags: string[];
4
- activeTag?: string;
5
- tagCounts: Record<string, number>;
6
- totalCount: number;
7
- }
8
-
9
- const { availableTags, activeTag = '', tagCounts, totalCount } = Astro.props;
10
- ---
11
-
12
- <div class="tag-filters" role="group" aria-label="Filter posts by category">
13
- <a
14
- href="/posts/"
15
- class:list={['tag-filter', { active: !activeTag }]}
16
- aria-current={!activeTag ? 'true' : undefined}
17
- >
18
- All Posts ({totalCount})
19
- </a>
20
-
21
- {availableTags.map((tag) => (
22
- <a
23
- href={`/posts/tag/${tag}/`}
24
- class:list={['tag-filter', { active: activeTag === tag }]}
25
- aria-current={activeTag === tag ? 'true' : undefined}
26
- >
27
- {tag} ({tagCounts[tag] ?? 0})
28
- </a>
29
- ))}
30
- </div>
31
-
32
- <style>
33
- .tag-filters {
34
- display: flex;
35
- flex-wrap: wrap;
36
- gap: 0.55rem;
37
- }
38
-
39
- .tag-filter {
40
- font-family: 'Poppins', sans-serif;
41
- font-weight: 600;
42
- font-size: 0.75rem;
43
- letter-spacing: 0.03em;
44
- padding: 0.5rem 1rem;
45
- border-radius: 999px;
46
- background: rgba(200, 151, 58, 0.1);
47
- color: var(--fg);
48
- border: 1px solid transparent;
49
- text-decoration: none;
50
- text-transform: capitalize;
51
- transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
52
- }
53
-
54
- .tag-filter:hover {
55
- border-color: var(--gold);
56
- color: var(--gold);
57
- }
58
-
59
- .tag-filter:focus-visible {
60
- outline: 2px solid var(--gold);
61
- outline-offset: 2px;
62
- }
63
-
64
- .tag-filter.active {
65
- background: var(--gold);
66
- color: #2A1F12;
67
- }
68
-
69
- :global([data-theme='dark']) .tag-filter {
70
- background: rgba(255, 255, 255, 0.06);
71
- color: var(--text-light);
72
- }
73
-
74
- :global([data-theme='dark']) .tag-filter:hover {
75
- border-color: var(--gold);
76
- color: var(--gold);
77
- }
78
-
79
- :global([data-theme='dark']) .tag-filter.active {
80
- background: var(--gold);
81
- color: #2A1F12;
82
- }
83
- </style>