ferst-core 0.7.9 → 0.7.11

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.
@@ -1,227 +1,232 @@
1
- # Sveltia CMS — the content editor for a Ferst site. SINGLE SOURCE: this file lives
2
- # in ferst-core and is written to dist/admin/config.yml at build by `ferst-cms-config`,
3
- # so every client's collections are identical and always match the engine. The client
4
- # repo commits NO config.yml — it is a build product, so it can never drift by hand.
5
- #
6
- # The backend repo (owner/name), git-proxy origin, its /git api_root and this
7
- # deployment's branch are injected at build by ferst-cms-config.
8
- backend:
9
- name: github
10
- repo: __REPO__
11
- branch: __BRANCH__
12
- base_url: __BASE_URL__
13
- auth_endpoint: auth
14
- api_root: __API_ROOT__
15
-
16
- media_folder: public/uploads
17
- public_folder: /uploads
18
-
19
- collections:
20
- - name: settings
21
- label: Site settings
22
- files:
23
- - name: theme
24
- label: Design tokens
25
- file: src/content/themeSettings/index.json
26
- icon: palette
27
- description: "Edit your brand in the Theme Studio, where you preview changes live before publishing — open portal.ferst.co.uk, choose this site, then the Design tab. The fields here still work as a fallback."
28
- fields:
29
- - { name: brand, label: Brand colour, widget: color, default: '#0E4D4A', hint: Buttons, links, icons, highlights. }
30
- - { name: ink, label: Ink colour, widget: color, default: '#0C2123', hint: Headings and body text. }
31
- - { name: surface, label: Surface colour, widget: color, default: '#FFFFFF', hint: Page background. }
32
- - name: fonts
33
- label: Typography
34
- widget: object
35
- collapsed: false
36
- fields:
37
- - name: heading
38
- label: Heading font
39
- widget: select
40
- required: false
41
- default: ''
42
- options: &fonts
43
- - { label: 'Default (theme)', value: '' }
44
- - { label: 'Poppins', value: 'Poppins, system-ui, sans-serif' }
45
- - { label: 'Inter', value: 'Inter, system-ui, sans-serif' }
46
- - { label: 'Playfair Display', value: "'Playfair Display', Georgia, serif" }
47
- - { label: 'Georgia', value: "Georgia, 'Times New Roman', serif" }
48
- - { label: 'System', value: 'system-ui, sans-serif' }
49
- - { name: body, label: Body font, widget: select, required: false, default: '', options: *fonts }
50
- - { name: display, label: Display font, widget: select, required: false, default: '', options: *fonts }
51
- - { name: corners, label: Corners, widget: select, default: rounded, options: [{ label: Sharp, value: sharp }, { label: Rounded, value: rounded }, { label: Soft, value: soft }] }
52
- - { name: elevation, label: Elevation, widget: select, default: raised, options: [{ label: Raised, value: raised }, { label: Flat, value: flat }] }
53
- - { name: stroke, label: Card stroke, widget: select, default: 'on', options: [{ label: 'On', value: 'on' }, { label: 'Off', value: 'off' }] }
54
- - { name: fields, label: Input fields, widget: select, default: boxed, options: [{ label: Boxed, value: boxed }, { label: Filled, value: filled }, { label: Underline, value: underline }] }
55
- - { name: darkDepth, label: Dark depth, widget: number, value_type: float, min: 0.14, max: 0.34, step: 0.01, default: 0.21 }
56
- - { name: darkChroma, label: Dark colour intensity, widget: number, value_type: float, min: 0, max: 0.15, step: 0.01, default: 0.05 }
57
- - name: darkOverrides
58
- label: Dark colour overrides (optional)
59
- widget: object
60
- collapsed: true
61
- fields:
62
- - { name: ground, label: Ground, widget: string, required: false }
63
- - { name: text, label: Text, widget: string, required: false }
64
- - { name: accent, label: Accent, widget: string, required: false }
65
-
66
- - name: identity
67
- label: Identity & contact
68
- file: src/content/siteSettings/index.json
69
- icon: info
70
- fields:
71
- - name: identity
72
- label: Identity
73
- widget: object
74
- fields:
75
- - { name: siteTitle, label: Site title, widget: string }
76
- - { name: shortName, label: Short name, widget: string }
77
- - { name: description, label: Description, widget: text }
78
- - { name: location, label: Location, widget: string, required: false }
79
- - name: contact
80
- label: Contact
81
- widget: object
82
- fields:
83
- - { name: phoneDisplay, label: Phone (display), widget: string, required: false }
84
- - { name: phoneTel, label: 'Phone (tel: digits)', widget: string, required: false }
85
- - { name: email, label: Email, widget: string, required: false }
86
- - { name: facebookUrl, label: Facebook URL, widget: string, required: false }
87
-
88
- - name: logos
89
- label: Logos
90
- file: src/content/logoSettings/index.json
91
- icon: image
92
- fields:
93
- - { name: primaryLogoLight, label: Header logo (light), widget: image, required: false }
94
- - { name: primaryLogoDark, label: Header logo (dark), widget: image, required: false }
95
- - { name: secondaryLogoLight, label: Footer logo (light), widget: image, required: false }
96
- - { name: secondaryLogoDark, label: Footer logo (dark), widget: image, required: false }
97
-
98
- - name: navbar
99
- label: Navigation
100
- file: src/content/navbarSettings/index.json
101
- icon: menu
102
- fields:
103
- - name: primary
104
- label: Primary links
105
- widget: list
106
- fields:
107
- - { name: label, label: Label, widget: string }
108
- - { name: href, label: Link, widget: string }
109
- - name: cta
110
- label: Header button
111
- widget: object
112
- required: false
113
- hint: The button at the right of the header (and in the footer). Leave as Contact → /contact/ unless your contact page lives elsewhere.
114
- fields:
115
- - { name: label, label: Label, widget: string, default: Contact }
116
- - { name: href, label: Link, widget: string, default: /contact/ }
117
-
118
- - name: footer
119
- label: Footer
120
- file: src/content/footerSettings/index.json
121
- icon: table_rows
122
- fields:
123
- - name: footer
124
- label: Footer links
125
- widget: list
126
- fields:
127
- - { name: label, label: Label, widget: string }
128
- - { name: href, label: Link, widget: string }
129
- - name: groups
130
- label: Footer columns
131
- widget: list
132
- required: false
133
- hint: Headed columns for policies, safeguarding, documents and forms — keep the main menu short.
134
- fields:
135
- - { name: heading, label: Heading, widget: string }
136
- - name: links
137
- label: Links
138
- widget: list
139
- fields:
140
- - { name: label, label: Label, widget: string }
141
- - { name: href, label: Link, widget: string }
142
-
143
- - name: calendar-settings
144
- label: Calendar
145
- file: src/content/calendarSettings/index.json
146
- icon: calendar_today
147
- fields:
148
- - { name: title, label: Heading, widget: string, default: Calendar }
149
- - { name: intro, label: Intro, widget: text, required: false }
150
- - { name: embedUrl, label: Embed URL, widget: string, required: false }
151
- - { name: publicUrl, label: Public link, widget: string, required: false }
152
- - { name: height, label: Height (px), widget: number, default: 700, value_type: int, min: 200, max: 2000 }
153
- - { name: provider, label: Provider name, widget: string, default: Google Calendar }
154
- - { name: weekStart, label: Week starts on, widget: select, default: monday, options: [{ label: "Monday", value: monday }, { label: "Sunday", value: sunday }] }
155
-
156
- - name: posts
157
- label: Posts
158
- label_singular: Post
159
- folder: src/content/posts
160
- create: true
161
- slug: "{{slug}}"
162
- extension: md
163
- format: frontmatter
164
- icon: article
165
- summary: "{{title}} — {{date}}"
166
- sortable_fields: [date, title]
167
- fields:
168
- - { name: title, label: Title, widget: string }
169
- - { name: date, label: Date, widget: datetime, picker_utc: true }
170
- - { name: summary, label: Summary, widget: text, required: false }
171
- - name: tags
172
- label: Tags
173
- widget: relation
174
- required: false
175
- collection: tags
176
- value_field: "{{slug}}"
177
- display_fields: [label]
178
- search_fields: [label]
179
- multiple: true
180
- - { name: heroImage, label: Hero image, widget: image, required: false }
181
- - { name: attachment, label: Attachment (e.g. PDF), widget: file, required: false }
182
- - { name: draft, label: Draft, widget: boolean, default: false, required: false }
183
- - { name: body, label: Body, widget: markdown, required: false }
184
-
185
- - name: tags
186
- label: Tags
187
- label_singular: Tag
188
- folder: src/content/tags
189
- create: true
190
- slug: "{{slug}}"
191
- extension: json
192
- format: json
193
- icon: label
194
- identifier_field: label
195
- fields:
196
- - { name: label, label: Label, widget: string }
197
- - { name: description, label: Description, widget: text, required: false }
198
- - { name: hidden, label: Hidden, widget: boolean, default: false, required: false }
199
-
200
- - name: pages
201
- label: Pages
202
- label_singular: Page
203
- description: "See each page and request a change — our team makes it and sends it back for you to check. You don't edit the page layout here."
204
- folder: src/content/pages
205
- extension: json
206
- format: json
207
- icon: description
208
- nested: { depth: 3, summary: "{{title}}" }
209
- summary: "{{title}}"
210
- identifier_field: title
211
- fields:
212
- - { name: title, label: Page title, widget: string }
213
- - name: requests
214
- label: Requested changes
215
- label_singular: Request
216
- widget: list
217
- required: false
218
- summary: "{{fields.text}}"
219
- fields:
220
- - { name: text, label: "What would you like changed?", widget: text }
221
- - { name: status, label: Status, widget: select, default: open, options: [{ label: Open, value: open }, { label: Done, value: done }] }
222
- - { name: description, label: SEO description, widget: hidden, required: false }
223
- - { name: heroImage, widget: hidden, required: false }
224
- - { name: align, widget: hidden, required: false }
225
- - { name: templateId, widget: hidden, required: false }
226
- - { name: draft, widget: hidden, required: false }
227
- - { name: blocks, widget: hidden }
1
+ # Sveltia CMS — the content editor for a Ferst site. SINGLE SOURCE: this file lives
2
+ # in ferst-core and is written to dist/admin/config.yml at build by `ferst-cms-config`,
3
+ # so every client's collections are identical and always match the engine. The client
4
+ # repo commits NO config.yml — it is a build product, so it can never drift by hand.
5
+ #
6
+ # The backend repo (owner/name), git-proxy origin, its /git api_root and this
7
+ # deployment's branch are injected at build by ferst-cms-config.
8
+ backend:
9
+ name: github
10
+ repo: __REPO__
11
+ branch: __BRANCH__
12
+ base_url: __BASE_URL__
13
+ auth_endpoint: auth
14
+ api_root: __API_ROOT__
15
+
16
+ media_folder: public/uploads
17
+ public_folder: /uploads
18
+
19
+ collections:
20
+ - name: settings
21
+ label: Site settings
22
+ files:
23
+ - name: theme
24
+ label: Design tokens
25
+ file: src/content/themeSettings/index.json
26
+ icon: palette
27
+ description: "Edit your brand in the Theme Studio, where you preview changes live before publishing — open portal.ferst.co.uk, choose this site, then the Design tab. The fields here still work as a fallback."
28
+ fields:
29
+ - { name: brand, label: Brand colour, widget: color, default: '#0E4D4A', hint: Buttons, links, icons, highlights. }
30
+ - { name: ink, label: Ink colour, widget: color, default: '#0C2123', hint: Headings and body text. }
31
+ - { name: surface, label: Surface colour, widget: color, default: '#FFFFFF', hint: Page background. }
32
+ - name: fonts
33
+ label: Typography
34
+ widget: object
35
+ collapsed: false
36
+ fields:
37
+ - name: heading
38
+ label: Heading font
39
+ widget: select
40
+ required: false
41
+ default: ''
42
+ options: &fonts
43
+ - { label: 'Default (theme)', value: '' }
44
+ - { label: 'Poppins', value: 'Poppins, system-ui, sans-serif' }
45
+ - { label: 'Inter', value: 'Inter, system-ui, sans-serif' }
46
+ - { label: 'Playfair Display', value: "'Playfair Display', Georgia, serif" }
47
+ - { label: 'Georgia', value: "Georgia, 'Times New Roman', serif" }
48
+ - { label: 'System', value: 'system-ui, sans-serif' }
49
+ - { name: body, label: Body font, widget: select, required: false, default: '', options: *fonts }
50
+ - { name: display, label: Display font, widget: select, required: false, default: '', options: *fonts }
51
+ - { name: corners, label: Corners, widget: select, default: rounded, options: [{ label: Sharp, value: sharp }, { label: Rounded, value: rounded }, { label: Soft, value: soft }] }
52
+ - { name: elevation, label: Elevation, widget: select, default: raised, options: [{ label: Raised, value: raised }, { label: Flat, value: flat }] }
53
+ - { name: stroke, label: Card stroke, widget: select, default: 'on', options: [{ label: 'On', value: 'on' }, { label: 'Off', value: 'off' }] }
54
+ - { name: fields, label: Input fields, widget: select, default: boxed, options: [{ label: Boxed, value: boxed }, { label: Filled, value: filled }, { label: Underline, value: underline }] }
55
+ - { name: darkDepth, label: Dark depth, widget: number, value_type: float, min: 0.14, max: 0.34, step: 0.01, default: 0.21 }
56
+ - { name: darkChroma, label: Dark colour intensity, widget: number, value_type: float, min: 0, max: 0.15, step: 0.01, default: 0.05 }
57
+ - name: darkOverrides
58
+ label: Dark colour overrides (optional)
59
+ widget: object
60
+ collapsed: true
61
+ fields:
62
+ - { name: ground, label: Ground, widget: string, required: false }
63
+ - { name: text, label: Text, widget: string, required: false }
64
+ - { name: accent, label: Accent, widget: string, required: false }
65
+
66
+ - name: identity
67
+ label: Identity & contact
68
+ file: src/content/siteSettings/index.json
69
+ icon: info
70
+ fields:
71
+ - name: identity
72
+ label: Identity
73
+ widget: object
74
+ fields:
75
+ - { name: siteTitle, label: Site title, widget: string }
76
+ - { name: shortName, label: Short name, widget: string }
77
+ - { name: description, label: Description, widget: text }
78
+ - { name: location, label: Location, widget: string, required: false }
79
+ - name: contact
80
+ label: Contact
81
+ widget: object
82
+ fields:
83
+ - { name: phoneDisplay, label: Phone (display), widget: string, required: false }
84
+ - { name: phoneTel, label: 'Phone (tel: digits)', widget: string, required: false }
85
+ - { name: email, label: Email, widget: string, required: false }
86
+ - { name: facebookUrl, label: Facebook URL, widget: string, required: false }
87
+
88
+ - name: logos
89
+ label: Logos
90
+ file: src/content/logoSettings/index.json
91
+ icon: image
92
+ fields:
93
+ - { name: primaryLogoLight, label: Header logo (light), widget: image, required: false }
94
+ - { name: primaryLogoDark, label: Header logo (dark), widget: image, required: false }
95
+ - { name: secondaryLogoLight, label: Footer logo (light), widget: image, required: false }
96
+ - { name: secondaryLogoDark, label: Footer logo (dark), widget: image, required: false }
97
+
98
+ - name: navbar
99
+ label: Navigation
100
+ file: src/content/navbarSettings/index.json
101
+ icon: menu
102
+ fields:
103
+ - name: primary
104
+ label: Primary links
105
+ widget: list
106
+ fields:
107
+ - { name: label, label: Label, widget: string }
108
+ - { name: href, label: Link, widget: string }
109
+ - name: cta
110
+ label: Header button
111
+ widget: object
112
+ required: false
113
+ hint: The button at the right of the header (and in the footer). Leave as Contact → /contact/ unless your contact page lives elsewhere.
114
+ fields:
115
+ - { name: label, label: Label, widget: string, default: Contact }
116
+ - { name: href, label: Link, widget: string, default: /contact/ }
117
+
118
+ - name: footer
119
+ label: Footer
120
+ file: src/content/footerSettings/index.json
121
+ icon: table_rows
122
+ fields:
123
+ - name: footer
124
+ label: Footer links
125
+ widget: list
126
+ fields:
127
+ - { name: label, label: Label, widget: string }
128
+ - { name: href, label: Link, widget: string }
129
+ - name: groups
130
+ label: Footer columns
131
+ widget: list
132
+ required: false
133
+ hint: Headed columns for policies, safeguarding, documents and forms — keep the main menu short.
134
+ fields:
135
+ - { name: heading, label: Heading, widget: string }
136
+ - name: links
137
+ label: Links
138
+ widget: list
139
+ fields:
140
+ - { name: label, label: Label, widget: string }
141
+ - { name: href, label: Link, widget: string }
142
+
143
+ - name: calendar-settings
144
+ label: Calendar
145
+ file: src/content/calendarSettings/index.json
146
+ icon: calendar_today
147
+ fields:
148
+ - { name: title, label: Heading, widget: string, default: Calendar }
149
+ - { name: intro, label: Intro, widget: text, required: false }
150
+ - { name: embedUrl, label: Embed URL, widget: string, required: false }
151
+ - { name: publicUrl, label: Public link, widget: string, required: false }
152
+ - { name: height, label: Height (px), widget: number, default: 700, value_type: int, min: 200, max: 2000 }
153
+ - { name: provider, label: Provider name, widget: string, default: Google Calendar }
154
+ - { name: weekStart, label: Week starts on, widget: select, default: monday, options: [{ label: "Monday", value: monday }, { label: "Sunday", value: sunday }] }
155
+
156
+ - name: posts
157
+ label: Posts
158
+ label_singular: Post
159
+ folder: src/content/posts
160
+ create: true
161
+ slug: "{{slug}}"
162
+ extension: md
163
+ format: frontmatter
164
+ icon: article
165
+ summary: "{{title}} — {{date}}"
166
+ sortable_fields: [date, title]
167
+ fields:
168
+ - { name: title, label: Title, widget: string }
169
+ - { name: date, label: Date, widget: datetime, picker_utc: true }
170
+ - { name: summary, label: Summary, widget: text, required: false }
171
+ - name: tags
172
+ label: Tags
173
+ widget: relation
174
+ required: false
175
+ collection: tags
176
+ value_field: "{{slug}}"
177
+ display_fields: [label]
178
+ search_fields: [label]
179
+ multiple: true
180
+ - { name: heroImage, label: Hero image, widget: image, required: false }
181
+ - { name: attachment, label: Attachment (e.g. PDF), widget: file, required: false }
182
+ - { name: draft, label: Draft, widget: boolean, default: false, required: false }
183
+ - { name: body, label: Body, widget: markdown, required: false }
184
+
185
+ - name: tags
186
+ label: Tags
187
+ label_singular: Tag
188
+ folder: src/content/tags
189
+ create: true
190
+ slug: "{{slug}}"
191
+ extension: json
192
+ format: json
193
+ icon: label
194
+ identifier_field: label
195
+ fields:
196
+ - { name: label, label: Label, widget: string }
197
+ - { name: description, label: Description, widget: text, required: false }
198
+ - { name: hidden, label: Hidden, widget: boolean, default: false, required: false }
199
+
200
+ - name: pages
201
+ label: Pages
202
+ label_singular: Page
203
+ description: "See each page and request a change — our team makes it and sends it back for you to check. You don't edit the page layout here."
204
+ folder: src/content/pages
205
+ extension: json
206
+ format: json
207
+ icon: description
208
+ nested: { depth: 3, summary: "{{title}}" }
209
+ summary: "{{title}}"
210
+ identifier_field: title
211
+ fields:
212
+ - { name: title, label: Page title, widget: string }
213
+ - name: requests
214
+ label: Requested changes
215
+ label_singular: Request
216
+ widget: list
217
+ required: false
218
+ summary: "{{fields.text}}"
219
+ fields:
220
+ - { name: text, label: "What would you like changed?", widget: text }
221
+ - { name: status, label: Status, widget: select, default: open, options: [{ label: Open, value: open }, { label: Done, value: done }] }
222
+ # Stamped by the platform when the request is raised with the team (who, when, the
223
+ # tracking number). Hidden, but kept on every save so a request is raised only once.
224
+ - { name: issue, widget: hidden, required: false }
225
+ - { name: by, widget: hidden, required: false }
226
+ - { name: at, widget: hidden, required: false }
227
+ - { name: description, label: SEO description, widget: hidden, required: false }
228
+ - { name: heroImage, widget: hidden, required: false }
229
+ - { name: align, widget: hidden, required: false }
230
+ - { name: templateId, widget: hidden, required: false }
231
+ - { name: draft, widget: hidden, required: false }
232
+ - { name: blocks, widget: hidden }
@@ -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); }
@@ -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: z
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: z.object({ src: z.string(), alt: z.string().default('') }).optional(),
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: z.object({ src: z.string(), alt: z.string().default('') }).optional(),
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: z.object({ src: z.string(), alt: z.string().default('') }).optional(),
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(),
@@ -929,8 +939,11 @@ export const pageSchema = z.object({
929
939
  .array(
930
940
  z.object({
931
941
  text: z.string(),
942
+ /** Stamped by the platform when the request becomes an issue in the site's repo:
943
+ * when, who asked (from their session) and the issue number. */
932
944
  at: z.string().optional(),
933
945
  by: z.string().optional(),
946
+ issue: z.number().int().optional(),
934
947
  status: z.enum(['open', 'done']).default('open'),
935
948
  }),
936
949
  )
@@ -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
- if (url.host === host) return; // same-site
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');
@@ -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
- '&': '&amp;',
15
- '<': '&lt;',
16
- '>': '&gt;',
17
- '"': '&quot;',
18
- "'": '&#39;',
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
- /** Bold, italic + single-newline breaks, applied to already-escaped text. Italic
30
- * `*x*` / `_x_` must hug its text, and `_` only counts at word edges, so
31
- * snake_case and a lone asterisk stay literal. */
32
- function inlineFormat(escaped: string): string {
33
- return escaped
34
- .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
35
- .replace(/(^|[^*\w])\*(?![\s*])([^*]*?[^\s*])\*(?!\*)/g, '$1<em>$2</em>')
36
- .replace(/(^|[^\w])_(?![\s_])([^_]*?[^\s_])_(?!\w)/g, '$1<em>$2</em>')
37
- .replace(/\n/g, '<br>');
38
- }
39
-
40
- /** Escaped text with single newlines → `<br>`, and no links: for text that already sits
41
- * inside a link (a nested <a> is invalid). */
42
- export function textWithBreaks(text: string | undefined | null): string {
43
- return text ? escapeHtml(text).replace(/\n/g, '<br>') : '';
44
- }
45
-
46
- /** Inline formatting (bold, italic, line breaks) WITHOUT links: `[label](href)` becomes
47
- * its label. For text inside something that is already a link (a linked tile or card). */
48
- export function inlineMarkdownNoLinks(text: string | undefined | null): string {
49
- return text ? inlineFormat(escapeHtml(text.replace(LINK_RE, '$1'))) : '';
50
- }
51
-
52
- export function inlineMarkdown(text: string | undefined | null): string {
53
- if (!text) return '';
54
- let out = '';
55
- let last = 0;
56
- let m: RegExpExecArray | null;
57
- LINK_RE.lastIndex = 0;
58
- while ((m = LINK_RE.exec(text))) {
59
- out += inlineFormat(escapeHtml(text.slice(last, m.index)));
60
- const label = inlineFormat(escapeHtml(m[1]));
61
- const href = m[2];
62
- if (SAFE_HREF.test(href)) {
63
- out += `<a href="${escapeHtml(href)}">${label}</a>`;
64
- } else {
65
- out += inlineFormat(escapeHtml(m[0])); // unsafe scheme → keep literal
66
- }
67
- last = m.index + m[0].length;
68
- }
69
- out += inlineFormat(escapeHtml(text.slice(last)));
70
- return out;
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
+ '&': '&amp;',
15
+ '<': '&lt;',
16
+ '>': '&gt;',
17
+ '"': '&quot;',
18
+ "'": '&#39;',
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.9",
3
+ "version": "0.7.11",
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",