ferst-core 0.1.0 → 0.2.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 (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 +93 -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,262 +1,130 @@
1
1
  ---
2
- import type { CollectionEntry } from 'astro:content';
3
- import { opensAttachmentDirectly } from '../content/filters';
4
- import { plainTextExcerpt } from '../utils/excerpt';
5
- import { isFreshPost } from '../utils/freshness';
6
- import { formatDate } from '../utils/formatDate';
7
-
2
+ /**
3
+ * PostCard a post preview for the news index and tag pages. Token-driven (radius,
4
+ * border, shadow, colours all from the theme), equal-height in a grid, with the
5
+ * whole card clickable via a stretched title link. White-room (no CTK code).
6
+ */
8
7
  interface Props {
9
- post: CollectionEntry<'posts'>;
10
- withImage?: boolean;
11
- showExcerpt?: boolean;
12
- /** Whole-tile click (stretched-link on the title, no separate "Read" link) —
13
- used by /posts/. */
14
- linkWholeCard?: boolean;
8
+ href: string;
9
+ title: string;
10
+ date: Date | string;
11
+ summary?: string;
12
+ heroImage?: string;
13
+ /** Optional tag labels shown as a small eyebrow. */
14
+ tagLabels?: string[];
15
15
  }
16
-
17
- const { post, withImage = false, showExcerpt = true, linkWholeCard = true } = Astro.props;
18
- // A body-less post with an attachment (the typical newsletter shape) has
19
- // nothing to show on its detail page, so the tile opens the attachment
20
- // directly instead — same interaction as before the newsletters collection
21
- // existed. Any post with a body still opens /posts/<slug>/ normally, even if
22
- // it also carries an attachment (offered there as a separate link).
23
- const directAttachment = opensAttachmentDirectly(post);
24
- const href = directAttachment ? post.data.attachment! : `/posts/${post.slug}`;
25
- const linkTarget = directAttachment ? '_blank' : undefined;
26
- const linkRel = directAttachment ? 'noopener noreferrer' : undefined;
27
- const displayTags = post.data.tags;
28
- const isFresh = isFreshPost(post.data.date);
29
- const dateLabel = formatDate(post.data.date);
30
- const hasImage = Boolean(post.data.image);
31
- const excerpt = showExcerpt ? plainTextExcerpt(post.body ?? '', withImage ? 400 : 280) : '';
16
+ const { href, title, date, summary, heroImage, tagLabels = [] } = Astro.props;
17
+ const d = date instanceof Date ? date : new Date(date);
18
+ const iso = Number.isNaN(d.valueOf()) ? undefined : d.toISOString().slice(0, 10);
19
+ const display = Number.isNaN(d.valueOf())
20
+ ? ''
21
+ : d.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });
32
22
  ---
33
23
 
34
- <article class="post-card" class:list={{ 'post-card--with-image': withImage }}>
35
- {isFresh && (
36
- <span class="new-flag" data-fresh-since={post.data.date.toISOString()}>New</span>
37
- )}
38
- {withImage && (
39
- hasImage ? (
40
- <img
41
- src={post.data.image}
42
- alt={post.data.title}
43
- class="post-card__img"
44
- loading="lazy"
45
- decoding="async"
46
- />
47
- ) : (
48
- <div class="post-card__placeholder" aria-hidden="true"></div>
49
- )
24
+ <article class="b-post-card">
25
+ {heroImage && (
26
+ <a class="b-post-card__media" href={href} tabindex="-1" aria-hidden="true">
27
+ <img src={heroImage} alt="" loading="lazy" />
28
+ </a>
50
29
  )}
51
- <div class="post-content">
52
- {displayTags.length > 0 && (
53
- <div class="post-tags" aria-label="Post tags">
54
- {displayTags.map((tag) => (
55
- <span class="post-tag">{tag}</span>
56
- ))}
57
- </div>
58
- )}
59
-
60
- <h3>{linkWholeCard ? <a href={href} target={linkTarget} rel={linkRel}>{post.data.title}</a> : post.data.title}</h3>
61
-
62
- {excerpt ? <p>{excerpt}</p> : null}
63
-
64
- <div class="post-card__footer">
65
- <time datetime={post.data.date.toISOString()}>{dateLabel}</time>
66
- {linkWholeCard ? (
67
- <svg class="post-card__chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
68
- <polyline points="9 6 15 12 9 18"/>
69
- </svg>
70
- ) : (
71
- <a href={href} target={linkTarget} rel={linkRel} aria-label={`Read ${post.data.title}`}>
72
- <svg class="post-card__chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
73
- <polyline points="9 6 15 12 9 18"/>
74
- </svg>
75
- </a>
76
- )}
30
+ <div class="b-post-card__body">
31
+ <div class="b-post-card__meta">
32
+ {tagLabels.length > 0 && <span class="b-post-card__eyebrow">{tagLabels[0]}</span>}
33
+ {display && <time datetime={iso}>{display}</time>}
77
34
  </div>
35
+ <h3 class="b-post-card__title">
36
+ <a href={href}>{title}</a>
37
+ </h3>
38
+ {summary && <p class="b-post-card__summary">{summary}</p>}
39
+ <span class="b-post-card__more" aria-hidden="true">Read more</span>
78
40
  </div>
79
41
  </article>
80
42
 
81
43
  <style>
82
- .post-card {
83
- position: relative;
84
- padding: 0;
85
- border-bottom: none;
86
- overflow: hidden;
44
+ .b-post-card {
87
45
  display: flex;
88
46
  flex-direction: column;
89
- flex: 1;
47
+ background: var(--bg-surface);
48
+ border: var(--card-border);
49
+ border-radius: var(--radius);
50
+ box-shadow: var(--shadow-sm);
51
+ overflow: hidden;
52
+ position: relative;
53
+ transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
90
54
  }
91
-
92
- .post-content {
55
+ .b-post-card:hover {
56
+ transform: translateY(-2px);
57
+ box-shadow: var(--shadow-md);
58
+ border-color: var(--gold);
59
+ }
60
+ .b-post-card__media {
61
+ display: block;
62
+ position: relative;
63
+ aspect-ratio: 16 / 9;
64
+ overflow: hidden;
65
+ background: var(--bg-section);
66
+ }
67
+ /* Absolutely-positioned + object-fit:cover so ANY source ratio is cropped to a
68
+ uniform 16:9 tile — never stretched, never overflowing into the text. The
69
+ compound selector (and inset/size) also out-specifies the global
70
+ `img { height:auto }` in Base, which otherwise lets tall images blow the box. */
71
+ .b-post-card .b-post-card__media img {
72
+ position: absolute;
73
+ inset: 0;
74
+ width: 100%;
75
+ height: 100%;
76
+ object-fit: cover;
77
+ display: block;
78
+ }
79
+ .b-post-card__body {
93
80
  display: flex;
94
81
  flex-direction: column;
82
+ gap: 0.5rem;
83
+ padding: 1.1rem 1.2rem 1.3rem;
95
84
  flex: 1;
96
- min-height: 0;
97
- gap: 0.75rem;
98
- padding: clamp(0.875rem, 2.2vw, 1.25rem);
99
- color: var(--news-card-body);
100
85
  }
101
-
102
- .post-tags {
86
+ .b-post-card__meta {
103
87
  display: flex;
104
88
  flex-wrap: wrap;
105
- justify-content: flex-end;
106
- gap: 0.35rem;
107
- margin: 0;
108
- width: 100%;
109
- }
110
-
111
- .post-tag {
112
- display: inline-flex;
113
89
  align-items: center;
114
- font-family: 'Poppins', sans-serif;
115
- font-size: 0.65rem;
90
+ gap: 0.5rem 0.75rem;
91
+ }
92
+ .b-post-card__eyebrow {
93
+ font-family: var(--font-heading);
94
+ font-size: 0.7rem;
116
95
  font-weight: 600;
96
+ letter-spacing: 0.08em;
117
97
  text-transform: uppercase;
118
- letter-spacing: 0.12em;
119
- color: var(--brown);
120
- background: rgba(180, 145, 70, 0.22);
121
- border-radius: 4px;
122
- padding: 0.15rem 0.45rem;
123
- line-height: 1.2;
124
- }
125
- :global(html[data-theme="dark"]) .post-tag {
126
- color: var(--text-light);
127
- background: rgba(255, 255, 255, 0.12);
128
- }
129
-
130
- .post-content h3 {
131
- /* Full title, never truncated — the admin's exact wording is shown
132
- in full, however many lines it takes. */
133
- margin-block: 0;
98
+ color: var(--gold);
134
99
  }
135
-
136
- /* Stretched-link pattern: the title anchor's ::after is expanded to cover
137
- the whole card (positioned against .post-card, the nearest positioned
138
- ancestor), so clicking anywhere on the tile opens the post — no JS
139
- needed. The footer "Read" link sits above it via z-index so it stays
140
- independently clickable/focusable. */
141
- .post-card h3 a {
142
- color: inherit;
143
- text-decoration: none;
100
+ .b-post-card__title {
101
+ margin: 0;
144
102
  }
145
-
146
- .post-card h3 a::after {
103
+ /* Stretch the title link over the whole card so the card is clickable, while
104
+ the meta/summary stay selectable. */
105
+ .b-post-card__title a::after {
147
106
  content: '';
148
107
  position: absolute;
149
108
  inset: 0;
150
- z-index: 1;
151
109
  }
152
-
153
- .new-flag {
154
- position: absolute;
155
- top: clamp(0.5rem, 1.6vw, 0.75rem);
156
- left: clamp(0.5rem, 1.6vw, 0.75rem);
157
- z-index: 2;
158
- display: inline-block;
159
- font-family: 'Poppins', sans-serif;
160
- font-size: 0.6875rem;
161
- font-weight: 700;
162
- letter-spacing: 0.12em;
163
- text-transform: uppercase;
164
- color: #fff;
165
- background: var(--accent);
166
- border-radius: 4px;
167
- padding: 0.22rem 0.55rem;
168
- line-height: 1.2;
169
- transform: rotate(-4deg);
170
- transform-origin: top left;
171
- box-shadow:
172
- 0 2px 6px rgba(0, 0, 0, 0.18),
173
- 0 1px 2px rgba(0, 0, 0, 0.08);
174
- pointer-events: none;
110
+ .b-post-card__title a {
111
+ color: var(--fg);
175
112
  }
176
- :global(html[data-theme="dark"]) .new-flag {
177
- background: var(--gold);
178
- color: #2A1F12;
113
+ .b-post-card:hover .b-post-card__title a {
114
+ color: var(--gold);
179
115
  }
180
-
181
- .post-content > p {
182
- font-size: clamp(0.8125rem, 0.7rem + 0.5vw, 0.9375rem);
116
+ .b-post-card__summary {
117
+ margin: 0;
118
+ color: var(--muted);
119
+ font-size: 0.95rem;
183
120
  line-height: 1.55;
184
- display: -webkit-box;
185
- -webkit-line-clamp: 3;
186
- -webkit-box-orient: vertical;
187
- overflow: hidden;
188
121
  }
189
-
190
- /* Date on the left, chevron on the right — chevron keeps the same
191
- slide-right-on-hover language as the homepage's Latest from the
192
- Notice rows (.notice-row / .notice-chevron). */
193
- .post-card__footer {
194
- display: flex;
195
- justify-content: space-between;
196
- align-items: center;
197
- gap: 0.4rem;
122
+ .b-post-card__more {
198
123
  margin-top: auto;
199
- }
200
-
201
- .post-card__footer time {
202
- flex-shrink: 0;
203
- }
204
-
205
- .post-card__footer a {
206
- position: relative;
207
- z-index: 2;
208
- display: inline-flex;
209
- align-items: center;
210
- }
211
-
212
- .post-card__chevron {
213
- flex-shrink: 0;
214
- color: var(--muted);
215
- transition: transform 0.15s ease, color 0.15s ease;
216
- }
217
-
218
- /* Decorative in linkWholeCard mode (the real link is the title's
219
- stretched-link), so it reacts to hover on the whole card. */
220
- .post-card:hover .post-card__chevron {
221
- transform: translateX(3px);
124
+ padding-top: 0.4rem;
125
+ font-family: var(--font-heading);
126
+ font-size: 0.85rem;
127
+ font-weight: 600;
222
128
  color: var(--gold);
223
129
  }
224
-
225
- /* With-image variant: opt-in via the `withImage` prop, used by the
226
- /posts/ listing. Image always sits above the text, at every screen
227
- size. */
228
- .post-card--with-image .post-card__img,
229
- .post-card--with-image .post-card__placeholder {
230
- display: block;
231
- width: 100%;
232
- aspect-ratio: 16 / 9;
233
- border-radius: 10px 10px 0 0;
234
- margin: 0;
235
- }
236
-
237
- .post-card--with-image .post-card__img {
238
- object-fit: cover;
239
- object-position: center;
240
- }
241
-
242
- .post-card--with-image .post-card__placeholder {
243
- background: linear-gradient(135deg, var(--gold-light) 0%, var(--gold) 100%);
244
- border-bottom: 1px solid rgba(0, 0, 0, 0.06);
245
- }
246
-
247
- :global(html[data-theme="dark"]) .post-card--with-image .post-card__placeholder {
248
- background: linear-gradient(135deg, #4A6741 0%, #2C3B2A 100%);
249
- }
250
-
251
- .post-card--with-image .post-content > p {
252
- -webkit-line-clamp: 5;
253
- }
254
-
255
- .post-card--with-image .post-tags {
256
- justify-content: flex-start;
257
- }
258
-
259
- .post-card--with-image .post-content {
260
- padding-top: clamp(1.5rem, 3.5vw, 2rem);
261
- }
262
130
  </style>
@@ -131,7 +131,7 @@ const year = new Date().getFullYear();
131
131
  }
132
132
  .site-footer .footer-brand {
133
133
  margin: 0;
134
- font-family: 'Poppins', sans-serif;
134
+ font-family: var(--font-heading);
135
135
  font-weight: 700;
136
136
  font-size: 1.15rem;
137
137
  line-height: 1.3;
@@ -217,7 +217,7 @@ const year = new Date().getFullYear();
217
217
  }
218
218
  .site-footer .footer-copy {
219
219
  margin: 0;
220
- font-family: 'Inter', sans-serif;
220
+ font-family: var(--font-body);
221
221
  font-size: 0.68rem;
222
222
  font-weight: 400;
223
223
  color: var(--muted);