ferst-core 0.7.10 → 0.7.12
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/blocks/ImageBlock.astro +3 -2
- package/components/recipes/Bento.astro +3 -3
- package/components/recipes/Hero.astro +19 -10
- package/components/recipes/MediaCards.astro +5 -4
- package/components/recipes/MediaText.astro +88 -88
- package/components/recipes/Tiles.astro +3 -3
- package/content/blocks.ts +18 -8
- package/layouts/Base.astro +3 -1
- package/lib/inline-markdown.ts +76 -71
- package/package.json +1 -1
|
@@ -16,9 +16,10 @@ interface Props {
|
|
|
16
16
|
shadow?: boolean;
|
|
17
17
|
border?: boolean;
|
|
18
18
|
ratio?: 'auto' | 'square' | '4/3' | '3/2' | '16/9' | '21/9';
|
|
19
|
+
focus?: string;
|
|
19
20
|
reveal?: boolean;
|
|
20
21
|
}
|
|
21
|
-
const { src, alt = '', caption, radius = 'soft', shadow = false, border = false, ratio = 'auto', reveal = false } = Astro.props;
|
|
22
|
+
const { src, alt = '', caption, radius = 'soft', shadow = false, border = false, ratio = 'auto', reveal = false, focus } = Astro.props;
|
|
22
23
|
const ratioValue = ratio === 'auto' ? null : ratio === 'square' ? '1 / 1' : ratio.replace('/', ' / ');
|
|
23
24
|
---
|
|
24
25
|
|
|
@@ -34,7 +35,7 @@ const ratioValue = ratio === 'auto' ? null : ratio === 'square' ? '1 / 1' : rati
|
|
|
34
35
|
alt={alt}
|
|
35
36
|
loading="lazy"
|
|
36
37
|
decoding="async"
|
|
37
|
-
style={ratioValue ? `aspect-ratio: ${ratioValue}` : undefined}
|
|
38
|
+
style={ratioValue ? `aspect-ratio: ${ratioValue}${focus ? `; object-position: ${focus}` : ''}` : undefined}
|
|
38
39
|
/>
|
|
39
40
|
{caption && <figcaption>{caption}</figcaption>}
|
|
40
41
|
</figure>
|
|
@@ -15,7 +15,7 @@ interface Item {
|
|
|
15
15
|
title: string;
|
|
16
16
|
text?: string;
|
|
17
17
|
href?: string;
|
|
18
|
-
image?: { src: string; alt?: string };
|
|
18
|
+
image?: { src: string; alt?: string; focus?: string };
|
|
19
19
|
}
|
|
20
20
|
interface Props {
|
|
21
21
|
title?: string;
|
|
@@ -39,7 +39,7 @@ const headAlign = headingAlign ?? blockAlign ?? 'center';
|
|
|
39
39
|
<div class="b-bento__grid">
|
|
40
40
|
{items.map((item) => {
|
|
41
41
|
const Tag = item.href ? 'a' : 'div';
|
|
42
|
-
const style = item.variant === 'image' && item.image ? `--bento-image: url('${item.image.src}')` : undefined;
|
|
42
|
+
const style = item.variant === 'image' && item.image ? `--bento-image: url('${item.image.src}')${item.image.focus ? `; --bento-focus: ${item.image.focus}` : ''}` : undefined;
|
|
43
43
|
return (
|
|
44
44
|
<Tag class="b-bento__item" data-size={item.size ?? 'sm'} data-variant={item.variant ?? 'surface'} href={item.href} style={style}>
|
|
45
45
|
{item.icon && isIconKey(item.icon) && (
|
|
@@ -115,7 +115,7 @@ const headAlign = headingAlign ?? blockAlign ?? 'center';
|
|
|
115
115
|
z-index: -1;
|
|
116
116
|
background-image: linear-gradient(to top, rgba(8,10,18,0.82), rgba(8,10,18,0.15)), var(--bento-image);
|
|
117
117
|
background-size: cover;
|
|
118
|
-
background-position: center;
|
|
118
|
+
background-position: center, var(--bento-focus, center);
|
|
119
119
|
}
|
|
120
120
|
a.b-bento__item:hover { transform: translateY(-3px); }
|
|
121
121
|
a.b-bento__item[data-variant='surface']:hover { box-shadow: var(--shadow-lg); }
|
|
@@ -46,7 +46,10 @@ const provider = bgLink ? videoEmbed(bgLink)?.provider : undefined;
|
|
|
46
46
|
const watchLabel = media?.linkLabel || (provider ? `Watch the full video on ${provider}` : 'Watch the full video');
|
|
47
47
|
// The still: the image (no motion), or an embed's poster until the player may load.
|
|
48
48
|
const stillSrc = embed ? media?.poster ?? still : !media?.video && !slides.length ? still : undefined;
|
|
49
|
-
|
|
49
|
+
// Drawn as a real <img>, never a CSS background: a picture set through a CSS variable is
|
|
50
|
+
// found late (never by the preload scanner), so the hero sat grey until it arrived, and
|
|
51
|
+
// some browsers kept it grey. A real image, loaded first, is found early and painted.
|
|
52
|
+
const heroImg = isMedia && stillSrc ? stillSrc : undefined;
|
|
50
53
|
---
|
|
51
54
|
|
|
52
55
|
<section
|
|
@@ -58,10 +61,11 @@ const bgImage = isMedia && stillSrc ? `--hero-image: url('${stillSrc}')` : '';
|
|
|
58
61
|
data-linked={bgLink ? 'true' : undefined}
|
|
59
62
|
data-parallax={isMedia && parallax ? 'true' : undefined}
|
|
60
63
|
data-full-bleed={isMedia && fullBleed ? 'true' : undefined}
|
|
61
|
-
style={isMedia ? `--hero-overlay: ${overlay}
|
|
64
|
+
style={isMedia ? `--hero-overlay: ${overlay}${focus ? `; --hero-focus: ${focus}` : ''}` : undefined}
|
|
62
65
|
>
|
|
63
66
|
{isMedia && (
|
|
64
67
|
<div class="b-hero__bg" aria-hidden="true">
|
|
68
|
+
{heroImg && <img class="b-hero__img" src={heroImg} alt="" loading="eager" fetchpriority="high" decoding="async" />}
|
|
65
69
|
{embed && <div class="b-hero__embed" data-embed-url={embed.embedUrl} data-provider={embed.provider}></div>}
|
|
66
70
|
{media?.video && !embed ? (
|
|
67
71
|
<video autoplay muted loop playsinline poster={media.poster} preload="metadata">
|
|
@@ -69,7 +73,7 @@ const bgImage = isMedia && stillSrc ? `--hero-image: url('${stillSrc}')` : '';
|
|
|
69
73
|
</video>
|
|
70
74
|
) : null}
|
|
71
75
|
{slides.map((src, i) => (
|
|
72
|
-
<
|
|
76
|
+
<img class:list={['b-hero__slide', { 'is-on': i === 0 }]} src={src} alt="" loading={i === 0 ? 'eager' : 'lazy'} fetchpriority={i === 0 ? 'high' : undefined} decoding="async" />
|
|
73
77
|
))}
|
|
74
78
|
<span class="b-hero__scrim"></span>
|
|
75
79
|
</div>
|
|
@@ -346,11 +350,20 @@ const bgImage = isMedia && stillSrc ? `--hero-image: url('${stillSrc}')` : '';
|
|
|
346
350
|
position: absolute;
|
|
347
351
|
inset: 0;
|
|
348
352
|
z-index: -1;
|
|
349
|
-
background-image: var(--hero-image, none);
|
|
350
|
-
background-size: cover;
|
|
351
|
-
background-position: var(--hero-focus, center);
|
|
352
353
|
background-color: var(--bg-section);
|
|
353
354
|
}
|
|
355
|
+
/* The picture (and each rotating slide) covers the band, framed by `focus`. */
|
|
356
|
+
.b-hero__img,
|
|
357
|
+
.b-hero__slide {
|
|
358
|
+
position: absolute;
|
|
359
|
+
inset: 0;
|
|
360
|
+
width: 100%;
|
|
361
|
+
height: 100%;
|
|
362
|
+
object-fit: cover;
|
|
363
|
+
object-position: var(--hero-focus, center);
|
|
364
|
+
display: block;
|
|
365
|
+
max-width: none;
|
|
366
|
+
}
|
|
354
367
|
/* Parallax: give the background room to drift without uncovering an edge. */
|
|
355
368
|
.b-hero.is-parallax .b-hero__bg {
|
|
356
369
|
inset: 0 0 -35% 0;
|
|
@@ -390,10 +403,6 @@ const bgImage = isMedia && stillSrc ? `--hero-image: url('${stillSrc}')` : '';
|
|
|
390
403
|
display: none;
|
|
391
404
|
}
|
|
392
405
|
.b-hero__slide {
|
|
393
|
-
position: absolute;
|
|
394
|
-
inset: 0;
|
|
395
|
-
background-size: cover;
|
|
396
|
-
background-position: var(--hero-focus, center);
|
|
397
406
|
opacity: 0;
|
|
398
407
|
transition: opacity 1.6s ease-in-out;
|
|
399
408
|
}
|
|
@@ -14,7 +14,7 @@ import Heading from '../blocks/Heading.astro';
|
|
|
14
14
|
|
|
15
15
|
import { inlineMarkdown, inlineMarkdownNoLinks } from '../../lib/inline-markdown';
|
|
16
16
|
interface Card {
|
|
17
|
-
image?: { src: string; alt?: string };
|
|
17
|
+
image?: { src: string; alt?: string; focus?: string };
|
|
18
18
|
icon?: string;
|
|
19
19
|
title: string;
|
|
20
20
|
meta?: string;
|
|
@@ -26,9 +26,10 @@ interface Props {
|
|
|
26
26
|
title?: string;
|
|
27
27
|
intro?: string;
|
|
28
28
|
columns?: 2 | 3;
|
|
29
|
+
ratio?: '16/9' | '4/3' | '3/2' | '1/1' | '3/4';
|
|
29
30
|
cards?: Card[];
|
|
30
31
|
}
|
|
31
|
-
const { title, intro, columns = 3, cards = [] } = Astro.props;
|
|
32
|
+
const { title, intro, columns = 3, ratio = '16/9', cards = [] } = Astro.props;
|
|
32
33
|
// Never reserve more columns than there are cards; `data-single` centres a lone one.
|
|
33
34
|
const cols = Math.min(columns, Math.max(cards.length, 1)) as 1 | 2 | 3;
|
|
34
35
|
---
|
|
@@ -49,8 +50,8 @@ const cols = Math.min(columns, Math.max(cards.length, 1)) as 1 | 2 | 3;
|
|
|
49
50
|
return (
|
|
50
51
|
<article class:list={['b-media-cards__card', 'ferst-tile', 'ferst-tile--sm', card.href && 'is-link']}>
|
|
51
52
|
{card.image && (
|
|
52
|
-
<div class="b-media-cards__media">
|
|
53
|
-
<img src={card.image.src} alt={card.image.alt ?? ''} loading="lazy" />
|
|
53
|
+
<div class="b-media-cards__media" data-ratio={ratio} style={`aspect-ratio: ${ratio.replace('/', ' / ')}`}>
|
|
54
|
+
<img src={card.image.src} alt={card.image.alt ?? ''} loading="lazy" style={card.image.focus ? `object-position: ${card.image.focus}` : undefined} />
|
|
54
55
|
</div>
|
|
55
56
|
)}
|
|
56
57
|
<div class="b-media-cards__body">
|
|
@@ -1,88 +1,88 @@
|
|
|
1
|
-
---
|
|
2
|
-
/**
|
|
3
|
-
* MediaText — an image beside its text, on the start or end side; stacks (text, then image)
|
|
4
|
-
* on a phone. The text takes prose formatting (paragraphs, lists, links); the image keeps
|
|
5
|
-
* its own shape unless `ratio` sets one (cropped to fill). An optional button follows the
|
|
6
|
-
* text.
|
|
7
|
-
*/
|
|
8
|
-
import Heading from '../blocks/Heading.astro';
|
|
9
|
-
import Button from '../Button.astro';
|
|
10
|
-
import ProseParts from '../ProseParts.astro';
|
|
11
|
-
|
|
12
|
-
interface Props {
|
|
13
|
-
title?: string;
|
|
14
|
-
text: string;
|
|
15
|
-
image: { src: string; alt?: string; caption?: string };
|
|
16
|
-
imageSide?: 'start' | 'end';
|
|
17
|
-
ratio?: 'auto' | '1/1' | '4/3' | '3/2' | '16/9';
|
|
18
|
-
cta?: { label: string; href: string };
|
|
19
|
-
}
|
|
20
|
-
const { title, text, image, imageSide = 'end', ratio = 'auto', cta } = Astro.props;
|
|
21
|
-
---
|
|
22
|
-
|
|
23
|
-
<section class="b-mediatext" data-side={imageSide}>
|
|
24
|
-
<div class="b-mediatext__text">
|
|
25
|
-
{title && <Heading text={title} level={2} />}
|
|
26
|
-
<ProseParts text={text} />
|
|
27
|
-
{cta && <div class="b-mediatext__cta"><Button href={cta.href} variant="primary">{cta.label}</Button></div>}
|
|
28
|
-
</div>
|
|
29
|
-
<figure class="b-mediatext__media">
|
|
30
|
-
<img src={image.src} alt={image.alt ?? ''} loading="lazy" decoding="async" style={ratio !== 'auto' ? `aspect-ratio: ${ratio}; object-fit: cover` : undefined} />
|
|
31
|
-
{image.caption && <figcaption>{image.caption}</figcaption>}
|
|
32
|
-
</figure>
|
|
33
|
-
</section>
|
|
34
|
-
|
|
35
|
-
<style>
|
|
36
|
-
.b-mediatext {
|
|
37
|
-
display: grid;
|
|
38
|
-
gap: clamp(1.5rem, 4vw, 3rem);
|
|
39
|
-
align-items: center;
|
|
40
|
-
width: 100%;
|
|
41
|
-
max-width: 72rem;
|
|
42
|
-
}
|
|
43
|
-
@media (min-width: 48em) {
|
|
44
|
-
.b-mediatext {
|
|
45
|
-
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
|
46
|
-
}
|
|
47
|
-
.b-mediatext[data-side='start'] .b-mediatext__media {
|
|
48
|
-
order: -1;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
.b-mediatext__text {
|
|
52
|
-
display: flex;
|
|
53
|
-
flex-direction: column;
|
|
54
|
-
gap: 0.9rem;
|
|
55
|
-
}
|
|
56
|
-
.b-mediatext__text :global(p),
|
|
57
|
-
.b-mediatext__text :global(ul),
|
|
58
|
-
.b-mediatext__text :global(ol) {
|
|
59
|
-
margin: 0;
|
|
60
|
-
line-height: 1.7;
|
|
61
|
-
}
|
|
62
|
-
.b-mediatext__text :global(ul),
|
|
63
|
-
.b-mediatext__text :global(ol) {
|
|
64
|
-
padding-inline-start: 1.4em;
|
|
65
|
-
}
|
|
66
|
-
.b-mediatext__text :global(a:not(.btn)) {
|
|
67
|
-
color: var(--accent);
|
|
68
|
-
text-decoration: underline;
|
|
69
|
-
text-underline-offset: 0.15em;
|
|
70
|
-
}
|
|
71
|
-
.b-mediatext__cta {
|
|
72
|
-
margin-top: 0.4rem;
|
|
73
|
-
}
|
|
74
|
-
.b-mediatext__media {
|
|
75
|
-
margin: 0;
|
|
76
|
-
}
|
|
77
|
-
.b-mediatext__media img {
|
|
78
|
-
display: block;
|
|
79
|
-
width: 100%;
|
|
80
|
-
height: auto;
|
|
81
|
-
border-radius: var(--radius);
|
|
82
|
-
}
|
|
83
|
-
.b-mediatext__media figcaption {
|
|
84
|
-
margin-top: 0.5rem;
|
|
85
|
-
font-size: 0.875rem;
|
|
86
|
-
color: var(--muted);
|
|
87
|
-
}
|
|
88
|
-
</style>
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* MediaText — an image beside its text, on the start or end side; stacks (text, then image)
|
|
4
|
+
* on a phone. The text takes prose formatting (paragraphs, lists, links); the image keeps
|
|
5
|
+
* its own shape unless `ratio` sets one (cropped to fill). An optional button follows the
|
|
6
|
+
* text.
|
|
7
|
+
*/
|
|
8
|
+
import Heading from '../blocks/Heading.astro';
|
|
9
|
+
import Button from '../Button.astro';
|
|
10
|
+
import ProseParts from '../ProseParts.astro';
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
title?: string;
|
|
14
|
+
text: string;
|
|
15
|
+
image: { src: string; alt?: string; caption?: string; focus?: string };
|
|
16
|
+
imageSide?: 'start' | 'end';
|
|
17
|
+
ratio?: 'auto' | '1/1' | '4/3' | '3/2' | '16/9';
|
|
18
|
+
cta?: { label: string; href: string };
|
|
19
|
+
}
|
|
20
|
+
const { title, text, image, imageSide = 'end', ratio = 'auto', cta } = Astro.props;
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
<section class="b-mediatext" data-side={imageSide}>
|
|
24
|
+
<div class="b-mediatext__text">
|
|
25
|
+
{title && <Heading text={title} level={2} />}
|
|
26
|
+
<ProseParts text={text} />
|
|
27
|
+
{cta && <div class="b-mediatext__cta"><Button href={cta.href} variant="primary">{cta.label}</Button></div>}
|
|
28
|
+
</div>
|
|
29
|
+
<figure class="b-mediatext__media">
|
|
30
|
+
<img src={image.src} alt={image.alt ?? ''} loading="lazy" decoding="async" style={ratio !== 'auto' ? `aspect-ratio: ${ratio}; object-fit: cover${image.focus ? `; object-position: ${image.focus}` : ''}` : undefined} />
|
|
31
|
+
{image.caption && <figcaption>{image.caption}</figcaption>}
|
|
32
|
+
</figure>
|
|
33
|
+
</section>
|
|
34
|
+
|
|
35
|
+
<style>
|
|
36
|
+
.b-mediatext {
|
|
37
|
+
display: grid;
|
|
38
|
+
gap: clamp(1.5rem, 4vw, 3rem);
|
|
39
|
+
align-items: center;
|
|
40
|
+
width: 100%;
|
|
41
|
+
max-width: 72rem;
|
|
42
|
+
}
|
|
43
|
+
@media (min-width: 48em) {
|
|
44
|
+
.b-mediatext {
|
|
45
|
+
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
|
46
|
+
}
|
|
47
|
+
.b-mediatext[data-side='start'] .b-mediatext__media {
|
|
48
|
+
order: -1;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
.b-mediatext__text {
|
|
52
|
+
display: flex;
|
|
53
|
+
flex-direction: column;
|
|
54
|
+
gap: 0.9rem;
|
|
55
|
+
}
|
|
56
|
+
.b-mediatext__text :global(p),
|
|
57
|
+
.b-mediatext__text :global(ul),
|
|
58
|
+
.b-mediatext__text :global(ol) {
|
|
59
|
+
margin: 0;
|
|
60
|
+
line-height: 1.7;
|
|
61
|
+
}
|
|
62
|
+
.b-mediatext__text :global(ul),
|
|
63
|
+
.b-mediatext__text :global(ol) {
|
|
64
|
+
padding-inline-start: 1.4em;
|
|
65
|
+
}
|
|
66
|
+
.b-mediatext__text :global(a:not(.btn)) {
|
|
67
|
+
color: var(--accent);
|
|
68
|
+
text-decoration: underline;
|
|
69
|
+
text-underline-offset: 0.15em;
|
|
70
|
+
}
|
|
71
|
+
.b-mediatext__cta {
|
|
72
|
+
margin-top: 0.4rem;
|
|
73
|
+
}
|
|
74
|
+
.b-mediatext__media {
|
|
75
|
+
margin: 0;
|
|
76
|
+
}
|
|
77
|
+
.b-mediatext__media img {
|
|
78
|
+
display: block;
|
|
79
|
+
width: 100%;
|
|
80
|
+
height: auto;
|
|
81
|
+
border-radius: var(--radius);
|
|
82
|
+
}
|
|
83
|
+
.b-mediatext__media figcaption {
|
|
84
|
+
margin-top: 0.5rem;
|
|
85
|
+
font-size: 0.875rem;
|
|
86
|
+
color: var(--muted);
|
|
87
|
+
}
|
|
88
|
+
</style>
|
|
@@ -25,7 +25,7 @@ interface Tile {
|
|
|
25
25
|
text?: string;
|
|
26
26
|
href?: string;
|
|
27
27
|
linkLabel?: string;
|
|
28
|
-
image?: { src: string; alt?: string };
|
|
28
|
+
image?: { src: string; alt?: string; focus?: string };
|
|
29
29
|
}
|
|
30
30
|
interface Props {
|
|
31
31
|
title?: string;
|
|
@@ -59,7 +59,7 @@ const cols = Math.min(columns, Math.max(tiles.length, 1)) as 1 | 2 | 3 | 4;
|
|
|
59
59
|
const plain = variant === 'image' && !!tile.image && !tile.title && !tile.text && !tile.eyebrow;
|
|
60
60
|
const bg =
|
|
61
61
|
variant === 'image' && tile.image && !plain
|
|
62
|
-
? `--tile-image: url('${tile.image.src}')`
|
|
62
|
+
? `--tile-image: url('${tile.image.src}')${tile.image.focus ? `; --tile-focus: ${tile.image.focus}` : ''}`
|
|
63
63
|
: undefined;
|
|
64
64
|
return (
|
|
65
65
|
<Tag
|
|
@@ -166,7 +166,7 @@ const cols = Math.min(columns, Math.max(tiles.length, 1)) as 1 | 2 | 3 | 4;
|
|
|
166
166
|
z-index: -1;
|
|
167
167
|
background-image: linear-gradient(to top, rgba(10, 12, 20, 0.85), rgba(10, 12, 20, 0.15)), var(--tile-image);
|
|
168
168
|
background-size: cover;
|
|
169
|
-
background-position: center;
|
|
169
|
+
background-position: center, var(--tile-focus, center);
|
|
170
170
|
}
|
|
171
171
|
/* A picture tile shows the whole image, whatever its shape: no scrim, no crop. */
|
|
172
172
|
.b-tiles__grid[data-variant='image'] .b-tile[data-plain] {
|
package/content/blocks.ts
CHANGED
|
@@ -45,6 +45,15 @@ export const proseBlock = z.object({
|
|
|
45
45
|
variant: z.enum(['body', 'lead']).default('body'),
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
/** Which part of an image stays in view when it's cropped to a shape: one or two positions,
|
|
49
|
+
* keywords or percentages (`center 25%` keeps a face in a portrait, `top`, `left 20%`).
|
|
50
|
+
* Positions only, so no other CSS can get in. Default `center`. */
|
|
51
|
+
export const focusPosition = z
|
|
52
|
+
.string()
|
|
53
|
+
.regex(/^(?:left|center|right|top|bottom|\d{1,3}%)(?: (?:left|center|right|top|bottom|\d{1,3}%))?$/, 'focus is one or two of left / center / right / top / bottom / a percentage, e.g. "center 25%"');
|
|
54
|
+
/** An image a block may crop to a shape, with the part to keep in view. */
|
|
55
|
+
const croppedImage = z.object({ src: z.string(), alt: z.string().default(''), focus: focusPosition.optional() });
|
|
56
|
+
|
|
48
57
|
export const imageBlock = z.object({
|
|
49
58
|
type: z.literal('image'),
|
|
50
59
|
enabled: z.boolean().default(true),
|
|
@@ -57,6 +66,8 @@ export const imageBlock = z.object({
|
|
|
57
66
|
shadow: z.boolean().default(false),
|
|
58
67
|
border: z.boolean().default(false),
|
|
59
68
|
ratio: z.enum(['auto', 'square', '4/3', '3/2', '16/9', '21/9']).default('auto'),
|
|
69
|
+
/** Which part stays in view when `ratio` crops it. */
|
|
70
|
+
focus: focusPosition.optional(),
|
|
60
71
|
/** Opt in to a soft clip-path reveal as the image scrolls into view
|
|
61
72
|
* (progressive: no motion under reduced-motion or unsupported browsers). */
|
|
62
73
|
reveal: z.boolean().default(false),
|
|
@@ -155,10 +166,7 @@ export const heroBlock = z.object({
|
|
|
155
166
|
fullBleed: z.boolean().default(false),
|
|
156
167
|
/** Which part of the background image shows when it's cropped: one or two positions,
|
|
157
168
|
* keywords or percentages (e.g. `center 30%`, `top`, `left 20%`). Default `center`. */
|
|
158
|
-
focus:
|
|
159
|
-
.string()
|
|
160
|
-
.regex(/^(?:left|center|right|top|bottom|\d{1,3}%)(?: (?:left|center|right|top|bottom|\d{1,3}%))?$/, 'focus is one or two of left / center / right / top / bottom / a percentage, e.g. "center 30%"')
|
|
161
|
-
.optional(),
|
|
169
|
+
focus: focusPosition.optional(),
|
|
162
170
|
/** A `media` hero's background drifts more slowly than the page as it scrolls (any
|
|
163
171
|
* background: image, slides or video). Off on touch devices and under reduced motion. */
|
|
164
172
|
parallax: z.boolean().default(false),
|
|
@@ -302,7 +310,7 @@ export const tilesBlock = z.object({
|
|
|
302
310
|
href: z.string().optional(),
|
|
303
311
|
/** The link cue on a linking tile ("Learn more" by default). */
|
|
304
312
|
linkLabel: z.string().optional(),
|
|
305
|
-
image:
|
|
313
|
+
image: croppedImage.optional(),
|
|
306
314
|
})
|
|
307
315
|
.refine((t) => !!t.title || !!t.image, { message: 'A tile needs a title or an image', path: ['title'] })
|
|
308
316
|
)
|
|
@@ -425,7 +433,7 @@ export const bentoBlock = z.object({
|
|
|
425
433
|
title: z.string(),
|
|
426
434
|
text: z.string().optional(),
|
|
427
435
|
href: z.string().optional(),
|
|
428
|
-
image:
|
|
436
|
+
image: croppedImage.optional(),
|
|
429
437
|
})
|
|
430
438
|
)
|
|
431
439
|
.default([]),
|
|
@@ -520,10 +528,12 @@ export const mediaCardsBlock = z.object({
|
|
|
520
528
|
title: z.string().optional(),
|
|
521
529
|
intro: z.string().optional(),
|
|
522
530
|
columns: z.union([z.literal(2), z.literal(3)]).default(3),
|
|
531
|
+
/** The image shape: landscape 16:9 (default), 4:3, 3:2, square, or portrait 3:4 (people). */
|
|
532
|
+
ratio: z.enum(['16/9', '4/3', '3/2', '1/1', '3/4']).default('16/9'),
|
|
523
533
|
cards: z
|
|
524
534
|
.array(
|
|
525
535
|
z.object({
|
|
526
|
-
image:
|
|
536
|
+
image: croppedImage.optional(),
|
|
527
537
|
icon: iconName.optional(),
|
|
528
538
|
title: z.string(),
|
|
529
539
|
/** A short meta line shown as a pill (e.g. "Sundays 10:45 · Main hall"). */
|
|
@@ -702,7 +712,7 @@ export const mediaTextBlock = z.object({
|
|
|
702
712
|
enabled: z.boolean().default(true),
|
|
703
713
|
title: z.string().optional(),
|
|
704
714
|
text: z.string(),
|
|
705
|
-
image: z.object({ src: z.string(), alt: z.string().default(''), caption: z.string().optional() }),
|
|
715
|
+
image: z.object({ src: z.string(), alt: z.string().default(''), caption: z.string().optional(), focus: focusPosition.optional() }),
|
|
706
716
|
imageSide: z.enum(['start', 'end']).default('end'),
|
|
707
717
|
ratio: z.enum(['auto', '1/1', '4/3', '3/2', '16/9']).default('auto'),
|
|
708
718
|
cta: z.object({ label: z.string(), href: z.string() }).optional(),
|
package/layouts/Base.astro
CHANGED
|
@@ -160,7 +160,9 @@ const metaDescription = description ?? identity.description;
|
|
|
160
160
|
return;
|
|
161
161
|
}
|
|
162
162
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return; // skip mailto/tel/etc.
|
|
163
|
-
|
|
163
|
+
// Same-site pages stay in the tab; a same-site document (a PDF leaflet, a form)
|
|
164
|
+
// opens in a new one, like an external link.
|
|
165
|
+
if (url.host === host && !/\.(?:pdf|docx?|xlsx?|pptx?)$/i.test(url.pathname)) return;
|
|
164
166
|
if (!a.target) a.target = '_blank';
|
|
165
167
|
const rel = new Set((a.getAttribute('rel') || '').split(/\s+/).filter(Boolean));
|
|
166
168
|
rel.add('noopener');
|
package/lib/inline-markdown.ts
CHANGED
|
@@ -1,74 +1,79 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* inlineMarkdown — render a *tiny*, safe subset of markdown from trusted editor/CMS
|
|
3
|
-
* text: links `[label](href)`, bold `**text**`, italic `*text*` / `_text_`, and single
|
|
4
|
-
* newlines → `<br>`.
|
|
5
|
-
* Everything is HTML-escaped first, and only safe href schemes (http(s), root-
|
|
6
|
-
* relative, mailto, tel, in-page #anchors) become links — an unsafe href renders
|
|
7
|
-
* as literal text. The result is safe to drop into `set:html` for trusted content.
|
|
8
|
-
*
|
|
9
|
-
* This exists so recipes that show a line or two of editor copy (FAQ answers,
|
|
10
|
-
* notices) can carry a link without a full markdown pipeline — and without the
|
|
11
|
-
* `[text](url)` showing through as literal characters.
|
|
12
|
-
*/
|
|
13
|
-
const ESCAPE: Record<string, string> = {
|
|
14
|
-
'&': '&',
|
|
15
|
-
'<': '<',
|
|
16
|
-
'>': '>',
|
|
17
|
-
'"': '"',
|
|
18
|
-
"'": ''',
|
|
19
|
-
};
|
|
20
|
-
function escapeHtml(s: string): string {
|
|
21
|
-
return s.replace(/[&<>"']/g, (c) => ESCAPE[c]);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** Only these href schemes are allowed to become real links. */
|
|
25
|
-
const SAFE_HREF = /^(?:https?:\/\/|\/|mailto:|tel:|#)/i;
|
|
26
|
-
|
|
27
|
-
const LINK_RE = /\[([^\]]+)\]\(([^)\s]+)\)/g;
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
*
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
export function
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* inlineMarkdown — render a *tiny*, safe subset of markdown from trusted editor/CMS
|
|
3
|
+
* text: links `[label](href)`, bold `**text**`, italic `*text*` / `_text_`, and single
|
|
4
|
+
* newlines → `<br>`.
|
|
5
|
+
* Everything is HTML-escaped first, and only safe href schemes (http(s), root-
|
|
6
|
+
* relative, mailto, tel, in-page #anchors) become links — an unsafe href renders
|
|
7
|
+
* as literal text. The result is safe to drop into `set:html` for trusted content.
|
|
8
|
+
*
|
|
9
|
+
* This exists so recipes that show a line or two of editor copy (FAQ answers,
|
|
10
|
+
* notices) can carry a link without a full markdown pipeline — and without the
|
|
11
|
+
* `[text](url)` showing through as literal characters.
|
|
12
|
+
*/
|
|
13
|
+
const ESCAPE: Record<string, string> = {
|
|
14
|
+
'&': '&',
|
|
15
|
+
'<': '<',
|
|
16
|
+
'>': '>',
|
|
17
|
+
'"': '"',
|
|
18
|
+
"'": ''',
|
|
19
|
+
};
|
|
20
|
+
function escapeHtml(s: string): string {
|
|
21
|
+
return s.replace(/[&<>"']/g, (c) => ESCAPE[c]);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Only these href schemes are allowed to become real links. */
|
|
25
|
+
const SAFE_HREF = /^(?:https?:\/\/|\/|mailto:|tel:|#)/i;
|
|
26
|
+
|
|
27
|
+
const LINK_RE = /\[([^\]]+)\]\(([^)\s]+)\)/g;
|
|
28
|
+
|
|
29
|
+
/** A link to a document (PDF, Word, Excel, PowerPoint) opens in a new tab, so a visitor
|
|
30
|
+
* doesn't lose the site — as external links already do. */
|
|
31
|
+
export const DOCUMENT_HREF = /\.(?:pdf|docx?|xlsx?|pptx?)(?:[?#]|$)/i;
|
|
32
|
+
|
|
33
|
+
/** Bold, italic + single-newline breaks, applied to already-escaped text. Italic
|
|
34
|
+
* `*x*` / `_x_` must hug its text, and `_` only counts at word edges, so
|
|
35
|
+
* snake_case and a lone asterisk stay literal. */
|
|
36
|
+
function inlineFormat(escaped: string): string {
|
|
37
|
+
return escaped
|
|
38
|
+
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
39
|
+
.replace(/(^|[^*\w])\*(?![\s*])([^*]*?[^\s*])\*(?!\*)/g, '$1<em>$2</em>')
|
|
40
|
+
.replace(/(^|[^\w])_(?![\s_])([^_]*?[^\s_])_(?!\w)/g, '$1<em>$2</em>')
|
|
41
|
+
.replace(/\n/g, '<br>');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Escaped text with single newlines → `<br>`, and no links: for text that already sits
|
|
45
|
+
* inside a link (a nested <a> is invalid). */
|
|
46
|
+
export function textWithBreaks(text: string | undefined | null): string {
|
|
47
|
+
return text ? escapeHtml(text).replace(/\n/g, '<br>') : '';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Inline formatting (bold, italic, line breaks) WITHOUT links: `[label](href)` becomes
|
|
51
|
+
* its label. For text inside something that is already a link (a linked tile or card). */
|
|
52
|
+
export function inlineMarkdownNoLinks(text: string | undefined | null): string {
|
|
53
|
+
return text ? inlineFormat(escapeHtml(text.replace(LINK_RE, '$1'))) : '';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function inlineMarkdown(text: string | undefined | null): string {
|
|
57
|
+
if (!text) return '';
|
|
58
|
+
let out = '';
|
|
59
|
+
let last = 0;
|
|
60
|
+
let m: RegExpExecArray | null;
|
|
61
|
+
LINK_RE.lastIndex = 0;
|
|
62
|
+
while ((m = LINK_RE.exec(text))) {
|
|
63
|
+
out += inlineFormat(escapeHtml(text.slice(last, m.index)));
|
|
64
|
+
const label = inlineFormat(escapeHtml(m[1]));
|
|
65
|
+
const href = m[2];
|
|
66
|
+
if (SAFE_HREF.test(href)) {
|
|
67
|
+
const newTab = DOCUMENT_HREF.test(href) ? ' target="_blank" rel="noopener"' : '';
|
|
68
|
+
out += `<a href="${escapeHtml(href)}"${newTab}>${label}</a>`;
|
|
69
|
+
} else {
|
|
70
|
+
out += inlineFormat(escapeHtml(m[0])); // unsafe scheme → keep literal
|
|
71
|
+
}
|
|
72
|
+
last = m.index + m[0].length;
|
|
73
|
+
}
|
|
74
|
+
out += inlineFormat(escapeHtml(text.slice(last)));
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
72
77
|
|
|
73
78
|
/** The words of formatted copy with the formatting gone (a link becomes its label): for
|
|
74
79
|
* places that must be plain text, like a page's meta description. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ferst-core",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.12",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Ferst Core \u2014 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",
|