ferst-core 0.2.7 → 0.2.9
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.
- package/components/recipes/Banner.astro +13 -1
- package/components/recipes/LatestPosts.astro +132 -27
- package/content/blocks.ts +10 -0
- package/content/posts.ts +14 -0
- package/package.json +1 -1
|
@@ -22,8 +22,9 @@ interface Props {
|
|
|
22
22
|
overlay?: number;
|
|
23
23
|
align?: 'start' | 'center' | 'end';
|
|
24
24
|
parallax?: boolean;
|
|
25
|
+
fullBleed?: boolean;
|
|
25
26
|
}
|
|
26
|
-
const { image, eyebrow, title, text, cta, overlay = 50, align = 'start', parallax = true } = Astro.props;
|
|
27
|
+
const { image, eyebrow, title, text, cta, overlay = 50, align = 'start', parallax = true, fullBleed = false } = Astro.props;
|
|
27
28
|
const scrim = overlay / 100;
|
|
28
29
|
---
|
|
29
30
|
|
|
@@ -31,6 +32,7 @@ const scrim = overlay / 100;
|
|
|
31
32
|
class="b-banner"
|
|
32
33
|
data-align={align}
|
|
33
34
|
data-parallax={parallax ? 'true' : 'false'}
|
|
35
|
+
data-full-bleed={fullBleed ? 'true' : 'false'}
|
|
34
36
|
style={`--banner-image: url('${image}'); --banner-scrim: ${scrim};`}
|
|
35
37
|
>
|
|
36
38
|
<div class="b-banner__scrim" aria-hidden="true"></div>
|
|
@@ -60,6 +62,16 @@ const scrim = overlay / 100;
|
|
|
60
62
|
background-position: center;
|
|
61
63
|
background-repeat: no-repeat;
|
|
62
64
|
}
|
|
65
|
+
/* Full-bleed: break out of the content column to the full viewport width, edges
|
|
66
|
+
squared off. Safe from a horizontal scrollbar because Base sets overflow-x:
|
|
67
|
+
clip on html/body. The card keeps a comfortable inset that tracks the content
|
|
68
|
+
column on wide screens, so it still lines up with the page below. */
|
|
69
|
+
.b-banner[data-full-bleed='true'] {
|
|
70
|
+
width: 100vw;
|
|
71
|
+
margin-inline: calc(50% - 50vw);
|
|
72
|
+
border-radius: 0;
|
|
73
|
+
padding-inline: max(clamp(1.25rem, 5vw, 4rem), calc((100vw - 72rem) / 2 + 2rem));
|
|
74
|
+
}
|
|
63
75
|
/* Parallax: pin the image to the viewport so it slides under the content.
|
|
64
76
|
Only on fine pointers with motion allowed — `fixed` is ignored/janky on
|
|
65
77
|
touch (iOS) and unwelcome under reduced-motion. */
|
|
@@ -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,14 @@ 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
|
+
// The "New" badge marks only the single most-recent post, and only if it was
|
|
49
|
+
// published within `newWithinDays` of build time — so a site that posts often
|
|
50
|
+
// doesn't end up flagging every row (which would make the badge meaningless).
|
|
51
|
+
// Recomputed each build (a site rebuilds on publish, so it stays fresh); isRecent
|
|
52
|
+
// lives in the pure posts module so it's unit-tested independently.
|
|
53
|
+
const nowMs = Date.now();
|
|
54
|
+
const isNew = (date: Date | string) => isRecent(date, newWithinDays, nowMs);
|
|
43
55
|
---
|
|
44
56
|
|
|
45
57
|
<section class="b-latest-posts">
|
|
@@ -54,15 +66,28 @@ const fmt = (date: Date | string) => {
|
|
|
54
66
|
<p class="b-latest-posts__empty">No posts yet.</p>
|
|
55
67
|
) : layout === 'list' ? (
|
|
56
68
|
<ul class="b-latest-posts__list">
|
|
57
|
-
{posts.map((p) => {
|
|
69
|
+
{posts.map((p, i) => {
|
|
58
70
|
const { iso, label } = fmt(p.data.date);
|
|
71
|
+
const href = `/posts/${p.slug}`;
|
|
72
|
+
const cats = (p.data.tags ?? []).slice(0, 2);
|
|
59
73
|
return (
|
|
60
|
-
<li class="b-
|
|
61
|
-
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
74
|
+
<li class="b-post-row">
|
|
75
|
+
{p.data.heroImage && (
|
|
76
|
+
<a class="b-post-row__media" href={href} tabindex="-1" aria-hidden="true">
|
|
77
|
+
<img src={p.data.heroImage} alt="" loading="lazy" />
|
|
78
|
+
</a>
|
|
79
|
+
)}
|
|
80
|
+
<div class="b-post-row__main">
|
|
81
|
+
{cats.length > 0 && (
|
|
82
|
+
<span class="b-post-row__cat">{cats.join(' · ')}</span>
|
|
83
|
+
)}
|
|
84
|
+
<h3 class="b-post-row__title"><a href={href}>{p.data.title}</a></h3>
|
|
85
|
+
</div>
|
|
86
|
+
<div class="b-post-row__aside">
|
|
87
|
+
{i === 0 && isNew(p.data.date) && <span class="b-post-row__new">New</span>}
|
|
88
|
+
{label && <time class="b-post-row__date" datetime={iso}>{label}</time>}
|
|
89
|
+
<span class="b-post-row__chevron" aria-hidden="true">›</span>
|
|
90
|
+
</div>
|
|
66
91
|
</li>
|
|
67
92
|
);
|
|
68
93
|
})}
|
|
@@ -122,7 +147,9 @@ const fmt = (date: Date | string) => {
|
|
|
122
147
|
@media (min-width: 60em) {
|
|
123
148
|
.b-latest-posts__grid { grid-template-columns: repeat(3, 1fr); }
|
|
124
149
|
}
|
|
125
|
-
/* Compact list —
|
|
150
|
+
/* Compact miniature list — a thumbnail + category + title per row with the date
|
|
151
|
+
(and an optional "New" badge) on the right, split by hairlines. Stays short
|
|
152
|
+
however many posts it shows; the whole row is one click target. */
|
|
126
153
|
.b-latest-posts__list {
|
|
127
154
|
list-style: none;
|
|
128
155
|
margin: 0;
|
|
@@ -130,40 +157,118 @@ const fmt = (date: Date | string) => {
|
|
|
130
157
|
display: flex;
|
|
131
158
|
flex-direction: column;
|
|
132
159
|
}
|
|
133
|
-
.b-
|
|
134
|
-
|
|
135
|
-
|
|
160
|
+
.b-post-row {
|
|
161
|
+
position: relative;
|
|
162
|
+
display: flex;
|
|
163
|
+
align-items: center;
|
|
164
|
+
gap: 1rem;
|
|
165
|
+
padding: 0.85rem 0;
|
|
166
|
+
border-top: 1px solid var(--border);
|
|
136
167
|
}
|
|
137
|
-
.b-
|
|
168
|
+
.b-post-row:first-child {
|
|
138
169
|
border-top: 0;
|
|
139
170
|
}
|
|
140
|
-
|
|
171
|
+
/* Thumbnail — a small square, cover-cropped (never stretched). */
|
|
172
|
+
.b-post-row__media {
|
|
173
|
+
flex: 0 0 auto;
|
|
174
|
+
display: block;
|
|
175
|
+
width: 3.25rem;
|
|
176
|
+
height: 3.25rem;
|
|
177
|
+
border-radius: var(--radius-sm);
|
|
178
|
+
overflow: hidden;
|
|
179
|
+
background: var(--bg-section);
|
|
180
|
+
}
|
|
181
|
+
.b-post-row .b-post-row__media img {
|
|
182
|
+
width: 100%;
|
|
183
|
+
height: 100%;
|
|
184
|
+
object-fit: cover;
|
|
185
|
+
display: block;
|
|
186
|
+
}
|
|
187
|
+
.b-post-row__main {
|
|
188
|
+
flex: 1 1 auto;
|
|
189
|
+
min-width: 0;
|
|
141
190
|
display: flex;
|
|
142
|
-
flex-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
191
|
+
flex-direction: column;
|
|
192
|
+
gap: 0.25rem;
|
|
193
|
+
}
|
|
194
|
+
/* Category chip — the post's tags, joined, as a small brand-tinted pill. */
|
|
195
|
+
.b-post-row__cat {
|
|
196
|
+
align-self: flex-start;
|
|
197
|
+
max-width: 100%;
|
|
198
|
+
padding: 0.1rem 0.5rem;
|
|
199
|
+
border-radius: var(--radius-pill);
|
|
200
|
+
background: var(--bg-tag);
|
|
201
|
+
color: var(--muted);
|
|
202
|
+
font-family: var(--font-heading);
|
|
203
|
+
font-size: 0.66rem;
|
|
204
|
+
font-weight: 700;
|
|
205
|
+
letter-spacing: 0.06em;
|
|
206
|
+
text-transform: uppercase;
|
|
207
|
+
white-space: nowrap;
|
|
208
|
+
overflow: hidden;
|
|
209
|
+
text-overflow: ellipsis;
|
|
210
|
+
}
|
|
211
|
+
.b-post-row__title {
|
|
212
|
+
margin: 0;
|
|
213
|
+
font-size: 1.05rem;
|
|
214
|
+
line-height: 1.35;
|
|
215
|
+
}
|
|
216
|
+
.b-post-row__title a {
|
|
146
217
|
color: var(--fg);
|
|
147
|
-
|
|
218
|
+
font-family: var(--font-heading);
|
|
219
|
+
font-weight: 600;
|
|
148
220
|
}
|
|
149
|
-
.b-
|
|
221
|
+
.b-post-row:hover .b-post-row__title a {
|
|
150
222
|
color: var(--gold);
|
|
151
223
|
}
|
|
152
|
-
|
|
224
|
+
/* Stretch the title link across the whole row so any part of it is clickable. */
|
|
225
|
+
.b-post-row__title a::after {
|
|
226
|
+
content: '';
|
|
227
|
+
position: absolute;
|
|
228
|
+
inset: 0;
|
|
229
|
+
}
|
|
230
|
+
.b-post-row__aside {
|
|
231
|
+
flex: 0 0 auto;
|
|
232
|
+
display: flex;
|
|
233
|
+
align-items: center;
|
|
234
|
+
gap: 0.6rem 0.8rem;
|
|
235
|
+
}
|
|
236
|
+
.b-post-row__new {
|
|
237
|
+
padding: 0.12rem 0.45rem;
|
|
238
|
+
border-radius: var(--radius-pill);
|
|
239
|
+
background: var(--gold);
|
|
240
|
+
color: var(--text-light);
|
|
153
241
|
font-family: var(--font-heading);
|
|
154
|
-
font-
|
|
155
|
-
font-
|
|
242
|
+
font-size: 0.62rem;
|
|
243
|
+
font-weight: 700;
|
|
244
|
+
letter-spacing: 0.06em;
|
|
245
|
+
text-transform: uppercase;
|
|
156
246
|
}
|
|
157
|
-
.b-
|
|
247
|
+
.b-post-row__date {
|
|
158
248
|
color: var(--muted);
|
|
159
249
|
font-size: 0.85rem;
|
|
160
250
|
white-space: nowrap;
|
|
251
|
+
font-variant-numeric: tabular-nums;
|
|
161
252
|
}
|
|
162
|
-
.b-
|
|
163
|
-
margin: 0.35rem 0 0;
|
|
253
|
+
.b-post-row__chevron {
|
|
164
254
|
color: var(--muted);
|
|
165
|
-
font-size:
|
|
166
|
-
line-height: 1
|
|
255
|
+
font-size: 1.25rem;
|
|
256
|
+
line-height: 1;
|
|
257
|
+
}
|
|
258
|
+
.b-post-row:hover .b-post-row__chevron {
|
|
259
|
+
color: var(--gold);
|
|
260
|
+
}
|
|
261
|
+
/* On narrow screens keep the title on the left and let the date tuck under the
|
|
262
|
+
"New" badge if needed; the chevron drops (touch targets are the whole row). */
|
|
263
|
+
@media (max-width: 30rem) {
|
|
264
|
+
.b-post-row__chevron {
|
|
265
|
+
display: none;
|
|
266
|
+
}
|
|
267
|
+
.b-post-row__aside {
|
|
268
|
+
flex-direction: column;
|
|
269
|
+
align-items: flex-end;
|
|
270
|
+
gap: 0.25rem;
|
|
271
|
+
}
|
|
167
272
|
}
|
|
168
273
|
.b-latest-posts__empty {
|
|
169
274
|
margin: 0;
|
package/content/blocks.ts
CHANGED
|
@@ -268,6 +268,10 @@ export const bannerBlock = z.object({
|
|
|
268
268
|
overlay: z.number().min(0).max(100).default(50),
|
|
269
269
|
align: z.enum(['start', 'center', 'end']).default('start'),
|
|
270
270
|
parallax: z.boolean().default(true),
|
|
271
|
+
/** Break the band out to the full viewport width (edge-to-edge, no rounded
|
|
272
|
+
* corners); the card stays aligned to the page's content column. Great for a
|
|
273
|
+
* homepage hero. Off by default (the band sits inside the content column). */
|
|
274
|
+
fullBleed: z.boolean().default(false),
|
|
271
275
|
});
|
|
272
276
|
|
|
273
277
|
/** Stats — a band of headline numbers (value + label + optional note), over an
|
|
@@ -393,7 +397,13 @@ export const latestPostsBlock = z.object({
|
|
|
393
397
|
limit: z.number().int().min(1).max(12).default(3),
|
|
394
398
|
/** Filter to a single tag slug; omit for the main index (minus excluded tags). */
|
|
395
399
|
tag: z.string().optional(),
|
|
400
|
+
/** `grid` = full image cards; `list` = a compact miniature index (thumbnail +
|
|
401
|
+
* category + date per row) that stays short even with many posts. */
|
|
396
402
|
layout: z.enum(['grid', 'list']).default('grid'),
|
|
403
|
+
/** In the `list` layout, badge the most-recent post as "New" when it was
|
|
404
|
+
* published within this many days (0 disables it). Only the newest row is ever
|
|
405
|
+
* flagged, so a frequently-posting site doesn't mark every row. Grid ignores it. */
|
|
406
|
+
newWithinDays: z.number().int().min(0).max(365).default(14),
|
|
397
407
|
/** "View all" target + label (the posts index by default). */
|
|
398
408
|
viewAllHref: z.string().default('/posts'),
|
|
399
409
|
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.
|
|
3
|
+
"version": "0.2.9",
|
|
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",
|