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.
- package/cms/config.template.yml +232 -227
- package/components/blocks/ImageBlock.astro +3 -2
- package/components/recipes/Bento.astro +3 -3
- 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 +21 -8
- package/layouts/Base.astro +3 -1
- package/lib/inline-markdown.ts +76 -71
- package/package.json +1 -1
package/cms/config.template.yml
CHANGED
|
@@ -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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
- { name:
|
|
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:
|
|
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(),
|
|
@@ -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
|
)
|
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.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",
|