ferst-core 0.2.7 → 0.2.8

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.
@@ -11,6 +11,7 @@ import PostCard from '../PostCard.astro';
11
11
  import Heading from '../blocks/Heading.astro';
12
12
  import Button from '../Button.astro';
13
13
  import { getIndexPosts, getPostsByTag } from '../../lib/posts';
14
+ import { isRecent } from '../../content/posts';
14
15
 
15
16
  interface Props {
16
17
  title?: string;
@@ -18,6 +19,8 @@ interface Props {
18
19
  limit?: number;
19
20
  tag?: string;
20
21
  layout?: 'grid' | 'list';
22
+ /** In the list layout, badge posts newer than this many days as "New" (0 = off). */
23
+ newWithinDays?: number;
21
24
  viewAllHref?: string;
22
25
  viewAllLabel?: string;
23
26
  }
@@ -27,6 +30,7 @@ const {
27
30
  limit = 3,
28
31
  tag,
29
32
  layout = 'grid',
33
+ newWithinDays = 14,
30
34
  viewAllHref = '/posts',
31
35
  viewAllLabel = 'All posts',
32
36
  } = Astro.props;
@@ -40,6 +44,12 @@ const fmt = (date: Date | string) => {
40
44
  ? { iso: undefined as string | undefined, label: '' }
41
45
  : { iso: d.toISOString().slice(0, 10), label: d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }) };
42
46
  };
47
+
48
+ // "New" = published within `newWithinDays` of build time (recomputed each build;
49
+ // a site rebuilds on publish, so this stays fresh). isRecent lives in the pure
50
+ // posts module so it's unit-tested independently.
51
+ const nowMs = Date.now();
52
+ const isNew = (date: Date | string) => isRecent(date, newWithinDays, nowMs);
43
53
  ---
44
54
 
45
55
  <section class="b-latest-posts">
@@ -56,13 +66,26 @@ const fmt = (date: Date | string) => {
56
66
  <ul class="b-latest-posts__list">
57
67
  {posts.map((p) => {
58
68
  const { iso, label } = fmt(p.data.date);
69
+ const href = `/posts/${p.slug}`;
70
+ const cats = (p.data.tags ?? []).slice(0, 2);
59
71
  return (
60
- <li class="b-latest-posts__row">
61
- <a class="b-latest-posts__row-link" href={`/posts/${p.slug}`}>
62
- <span class="b-latest-posts__row-title">{p.data.title}</span>
63
- {label && <time class="b-latest-posts__row-date" datetime={iso}>{label}</time>}
64
- </a>
65
- {p.data.summary && <p class="b-latest-posts__row-summary">{p.data.summary}</p>}
72
+ <li class="b-post-row">
73
+ {p.data.heroImage && (
74
+ <a class="b-post-row__media" href={href} tabindex="-1" aria-hidden="true">
75
+ <img src={p.data.heroImage} alt="" loading="lazy" />
76
+ </a>
77
+ )}
78
+ <div class="b-post-row__main">
79
+ {cats.length > 0 && (
80
+ <span class="b-post-row__cat">{cats.join(' · ')}</span>
81
+ )}
82
+ <h3 class="b-post-row__title"><a href={href}>{p.data.title}</a></h3>
83
+ </div>
84
+ <div class="b-post-row__aside">
85
+ {isNew(p.data.date) && <span class="b-post-row__new">New</span>}
86
+ {label && <time class="b-post-row__date" datetime={iso}>{label}</time>}
87
+ <span class="b-post-row__chevron" aria-hidden="true">&rsaquo;</span>
88
+ </div>
66
89
  </li>
67
90
  );
68
91
  })}
@@ -122,7 +145,9 @@ const fmt = (date: Date | string) => {
122
145
  @media (min-width: 60em) {
123
146
  .b-latest-posts__grid { grid-template-columns: repeat(3, 1fr); }
124
147
  }
125
- /* Compact list — no images; a tidy dated index for sidebars or short homepages. */
148
+ /* Compact miniature list — a thumbnail + category + title per row with the date
149
+ (and an optional "New" badge) on the right, split by hairlines. Stays short
150
+ however many posts it shows; the whole row is one click target. */
126
151
  .b-latest-posts__list {
127
152
  list-style: none;
128
153
  margin: 0;
@@ -130,40 +155,118 @@ const fmt = (date: Date | string) => {
130
155
  display: flex;
131
156
  flex-direction: column;
132
157
  }
133
- .b-latest-posts__row {
134
- padding: 0.9rem 0;
135
- border-top: var(--card-border);
158
+ .b-post-row {
159
+ position: relative;
160
+ display: flex;
161
+ align-items: center;
162
+ gap: 1rem;
163
+ padding: 0.85rem 0;
164
+ border-top: 1px solid var(--border);
136
165
  }
137
- .b-latest-posts__row:first-child {
166
+ .b-post-row:first-child {
138
167
  border-top: 0;
139
168
  }
140
- .b-latest-posts__row-link {
169
+ /* Thumbnail — a small square, cover-cropped (never stretched). */
170
+ .b-post-row__media {
171
+ flex: 0 0 auto;
172
+ display: block;
173
+ width: 3.25rem;
174
+ height: 3.25rem;
175
+ border-radius: var(--radius-sm);
176
+ overflow: hidden;
177
+ background: var(--bg-section);
178
+ }
179
+ .b-post-row .b-post-row__media img {
180
+ width: 100%;
181
+ height: 100%;
182
+ object-fit: cover;
183
+ display: block;
184
+ }
185
+ .b-post-row__main {
186
+ flex: 1 1 auto;
187
+ min-width: 0;
141
188
  display: flex;
142
- flex-wrap: wrap;
143
- align-items: baseline;
144
- justify-content: space-between;
145
- gap: 0.25rem 1rem;
189
+ flex-direction: column;
190
+ gap: 0.25rem;
191
+ }
192
+ /* Category chip — the post's tags, joined, as a small brand-tinted pill. */
193
+ .b-post-row__cat {
194
+ align-self: flex-start;
195
+ max-width: 100%;
196
+ padding: 0.1rem 0.5rem;
197
+ border-radius: var(--radius-pill);
198
+ background: var(--bg-tag);
199
+ color: var(--muted);
200
+ font-family: var(--font-heading);
201
+ font-size: 0.66rem;
202
+ font-weight: 700;
203
+ letter-spacing: 0.06em;
204
+ text-transform: uppercase;
205
+ white-space: nowrap;
206
+ overflow: hidden;
207
+ text-overflow: ellipsis;
208
+ }
209
+ .b-post-row__title {
210
+ margin: 0;
211
+ font-size: 1.05rem;
212
+ line-height: 1.35;
213
+ }
214
+ .b-post-row__title a {
146
215
  color: var(--fg);
147
- text-decoration: none;
216
+ font-family: var(--font-heading);
217
+ font-weight: 600;
148
218
  }
149
- .b-latest-posts__row-link:hover .b-latest-posts__row-title {
219
+ .b-post-row:hover .b-post-row__title a {
150
220
  color: var(--gold);
151
221
  }
152
- .b-latest-posts__row-title {
222
+ /* Stretch the title link across the whole row so any part of it is clickable. */
223
+ .b-post-row__title a::after {
224
+ content: '';
225
+ position: absolute;
226
+ inset: 0;
227
+ }
228
+ .b-post-row__aside {
229
+ flex: 0 0 auto;
230
+ display: flex;
231
+ align-items: center;
232
+ gap: 0.6rem 0.8rem;
233
+ }
234
+ .b-post-row__new {
235
+ padding: 0.12rem 0.45rem;
236
+ border-radius: var(--radius-pill);
237
+ background: var(--gold);
238
+ color: var(--text-light);
153
239
  font-family: var(--font-heading);
154
- font-weight: 600;
155
- font-size: 1.05rem;
240
+ font-size: 0.62rem;
241
+ font-weight: 700;
242
+ letter-spacing: 0.06em;
243
+ text-transform: uppercase;
156
244
  }
157
- .b-latest-posts__row-date {
245
+ .b-post-row__date {
158
246
  color: var(--muted);
159
247
  font-size: 0.85rem;
160
248
  white-space: nowrap;
249
+ font-variant-numeric: tabular-nums;
161
250
  }
162
- .b-latest-posts__row-summary {
163
- margin: 0.35rem 0 0;
251
+ .b-post-row__chevron {
164
252
  color: var(--muted);
165
- font-size: 0.95rem;
166
- line-height: 1.55;
253
+ font-size: 1.25rem;
254
+ line-height: 1;
255
+ }
256
+ .b-post-row:hover .b-post-row__chevron {
257
+ color: var(--gold);
258
+ }
259
+ /* On narrow screens keep the title on the left and let the date tuck under the
260
+ "New" badge if needed; the chevron drops (touch targets are the whole row). */
261
+ @media (max-width: 30rem) {
262
+ .b-post-row__chevron {
263
+ display: none;
264
+ }
265
+ .b-post-row__aside {
266
+ flex-direction: column;
267
+ align-items: flex-end;
268
+ gap: 0.25rem;
269
+ }
167
270
  }
168
271
  .b-latest-posts__empty {
169
272
  margin: 0;
package/content/blocks.ts CHANGED
@@ -393,7 +393,12 @@ export const latestPostsBlock = z.object({
393
393
  limit: z.number().int().min(1).max(12).default(3),
394
394
  /** Filter to a single tag slug; omit for the main index (minus excluded tags). */
395
395
  tag: z.string().optional(),
396
+ /** `grid` = full image cards; `list` = a compact miniature index (thumbnail +
397
+ * category + date per row) that stays short even with many posts. */
396
398
  layout: z.enum(['grid', 'list']).default('grid'),
399
+ /** In the `list` layout, flag posts newer than this many days with a "New"
400
+ * badge (0 disables it). Ignored by the grid. */
401
+ newWithinDays: z.number().int().min(0).max(365).default(14),
397
402
  /** "View all" target + label (the posts index by default). */
398
403
  viewAllHref: z.string().default('/posts'),
399
404
  viewAllLabel: z.string().default('All posts'),
package/content/posts.ts CHANGED
@@ -87,3 +87,17 @@ export function tagCounts(posts: Array<{ data: { tags?: string[] } }>): Record<s
87
87
  for (const p of posts) for (const t of p.data.tags ?? []) counts[t] = (counts[t] ?? 0) + 1;
88
88
  return counts;
89
89
  }
90
+
91
+ /**
92
+ * Whether a post date is within the last `days` of `now` — used to flag a "New"
93
+ * badge in the compact latest-posts list. `days <= 0` disables it; a future or
94
+ * unparseable date is never "new". Pure; `now` is injectable for tests.
95
+ */
96
+ export function isRecent(date: Date | string, days: number, now: number = Date.now()): boolean {
97
+ if (!days || days <= 0) return false;
98
+ const d = date instanceof Date ? date : new Date(date);
99
+ const t = d.valueOf();
100
+ if (Number.isNaN(t)) return false;
101
+ const ageMs = now - t;
102
+ return ageMs >= 0 && ageMs <= days * 86_400_000;
103
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ferst-core",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "type": "module",
5
5
  "description": "Ferst Core — the shared, brand-agnostic client-site system: Astro components, layouts, content schemas, the layered config resolver, and a neutral styling architecture that every client overrides.",
6
6
  "license": "BUSL-1.1",