ferst-core 0.7.5 → 0.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,7 +11,7 @@ import Icon from '../Icon.astro';
11
11
  import { inlineMarkdown, textWithBreaks } from '../../lib/inline-markdown';
12
12
  import { mailtoHref, telHref } from '../../lib/links';
13
13
 
14
- interface Contact { role: string; name?: string; phone?: string; email?: string; emailSubject?: string; address?: string; hours?: string }
14
+ interface Contact { role?: string; name?: string; phone?: string; email?: string; emailSubject?: string; address?: string; hours?: string }
15
15
  interface Props {
16
16
  title?: string;
17
17
  intro?: string;
@@ -38,8 +38,8 @@ const { title, intro, groups = [] } = Astro.props;
38
38
  <ul class="b-contacts__grid">
39
39
  {g.contacts.map((c) => (
40
40
  <li class="b-contacts__card">
41
- <h3 class="b-contacts__role">{c.role}</h3>
42
- {c.name && <p class="b-contacts__name">{c.name}</p>}
41
+ <h3 class="b-contacts__role">{c.role ?? c.name}</h3>
42
+ {c.role && c.name && <p class="b-contacts__name">{c.name}</p>}
43
43
  <div class="b-contacts__lines">
44
44
  {c.phone && <a class="b-contacts__line" href={telHref(c.phone)}><Icon name="phone" size={16} /><span>{c.phone}</span></a>}
45
45
  {c.email && <a class="b-contacts__line" href={mailtoHref(c.email, c.emailSubject)}><Icon name="email" size={16} /><span>{c.email}</span></a>}
@@ -8,7 +8,7 @@
8
8
  import Heading from '../blocks/Heading.astro';
9
9
  import Icon from '../Icon.astro';
10
10
  import { documentGroups, type DocumentItem } from '../../lib/documents';
11
-
11
+ import { inlineMarkdown } from '../../lib/inline-markdown';
12
12
  interface Props {
13
13
  title?: string;
14
14
  intro?: string;
@@ -23,7 +23,7 @@ const groups = documentGroups(items, groupByYear);
23
23
  {(title || intro) && (
24
24
  <div class="b-docs__head">
25
25
  {title && <Heading text={title} level={2} />}
26
- {intro && <p class="b-docs__intro">{intro}</p>}
26
+ {intro && <p class="b-docs__intro" set:html={inlineMarkdown(intro)} />}
27
27
  </div>
28
28
  )}
29
29
  {items.length === 0 ? (
@@ -1,120 +1,121 @@
1
- ---
2
- /** Steps — a Level-2 recipe: a numbered process. `horizontal` lays connected
3
- * cards in a row; `vertical` a stacked timeline. Composes Heading + Icon. */
4
- import Heading from '../blocks/Heading.astro';
5
- import Icon from '../Icon.astro';
6
- import { isIconKey } from '../../lib/icons';
7
-
8
- interface Item { title: string; text?: string; icon?: string }
9
- interface Props {
10
- title?: string;
11
- intro?: string;
12
- layout?: 'horizontal' | 'vertical';
13
- items?: Item[];
14
- }
15
- const { title, intro, layout = 'horizontal', items = [] } = Astro.props;
16
- ---
17
-
18
- <section class="b-steps" data-layout={layout}>
19
- {(title || intro) && (
20
- <div class="b-steps__head">
21
- {title && <Heading text={title} level={2} align="center" />}
22
- {intro && <p class="b-steps__intro">{intro}</p>}
23
- </div>
24
- )}
25
- <ol class="b-steps__list">
26
- {items.map((item, i) => (
27
- <li class="b-steps__item">
28
- <span class="b-steps__marker" aria-hidden="true">
29
- {item.icon && isIconKey(item.icon) ? <Icon name={item.icon} size={20} /> : i + 1}
30
- </span>
31
- <div class="b-steps__body">
32
- <h3 class="b-steps__title">{item.title}</h3>
33
- {item.text && <p class="b-steps__text">{item.text}</p>}
34
- </div>
35
- </li>
36
- ))}
37
- </ol>
38
- </section>
39
-
40
- <style>
41
- .b-steps {
42
- display: flex;
43
- flex-direction: column;
44
- gap: 2rem;
45
- max-width: 72rem;
46
- margin-inline: auto;
47
- padding-inline: 1rem;
48
- }
49
- .b-steps__head {
50
- max-width: var(--max);
51
- margin-inline: auto;
52
- text-align: center;
53
- display: flex;
54
- flex-direction: column;
55
- gap: 0.75rem;
56
- }
57
- .b-steps__intro { margin: 0; color: var(--muted); line-height: 1.6; }
58
- .b-steps__list {
59
- list-style: none;
60
- margin: 0;
61
- padding: 0;
62
- display: grid;
63
- gap: 1.5rem;
64
- }
65
- /* Base (and mobile): marker left, body right. */
66
- .b-steps__item {
67
- display: flex;
68
- gap: 1rem;
69
- align-items: flex-start;
70
- }
71
- .b-steps__marker {
72
- flex-shrink: 0;
73
- width: 2.5rem;
74
- height: 2.5rem;
75
- display: inline-flex;
76
- align-items: center;
77
- justify-content: center;
78
- border-radius: var(--radius-pill);
79
- background: var(--gold);
80
- color: var(--text-light);
81
- font-family: var(--font-heading);
82
- font-weight: 700;
83
- font-size: 1.05rem;
84
- }
85
- .b-steps__title { margin: 0 0 0.35rem; font-size: 1.1rem; color: var(--fg); }
86
- .b-steps__text { margin: 0; color: var(--muted); line-height: 1.6; }
87
-
88
- /* Vertical: a centred, stacked timeline. */
89
- .b-steps[data-layout='vertical'] .b-steps__list { max-width: 40rem; margin-inline: auto; }
90
-
91
- /* Horizontal (≥48em): connected cards in a row, marker over centred body. */
92
- @media (min-width: 48em) {
93
- .b-steps[data-layout='horizontal'] .b-steps__list {
94
- grid-auto-flow: column;
95
- grid-auto-columns: 1fr;
96
- gap: 1rem;
97
- }
98
- .b-steps[data-layout='horizontal'] .b-steps__item {
99
- flex-direction: column;
100
- align-items: center;
101
- text-align: center;
102
- gap: 0.85rem;
103
- position: relative;
104
- }
105
- /* Connector from this marker's right edge to the next marker's left edge.
106
- The +1.5rem / -2rem account for the 2.5rem marker AND the 1rem grid gap,
107
- so the line sits centred between markers (not falling short). */
108
- .b-steps[data-layout='horizontal'] .b-steps__item:not(:last-child)::after {
109
- content: '';
110
- position: absolute;
111
- top: calc(1.25rem - 1px);
112
- left: calc(50% + 1.5rem);
113
- width: calc(100% - 2rem);
114
- height: 2px;
115
- background: var(--border);
116
- z-index: 0;
117
- }
118
- .b-steps[data-layout='horizontal'] .b-steps__marker { position: relative; z-index: 1; }
119
- }
120
- </style>
1
+ ---
2
+ /** Steps — a Level-2 recipe: a numbered process. `horizontal` lays connected
3
+ * cards in a row; `vertical` a stacked timeline. Composes Heading + Icon. */
4
+ import Heading from '../blocks/Heading.astro';
5
+ import Icon from '../Icon.astro';
6
+ import { isIconKey } from '../../lib/icons';
7
+
8
+ import { inlineMarkdown } from '../../lib/inline-markdown';
9
+ interface Item { title: string; text?: string; icon?: string }
10
+ interface Props {
11
+ title?: string;
12
+ intro?: string;
13
+ layout?: 'horizontal' | 'vertical';
14
+ items?: Item[];
15
+ }
16
+ const { title, intro, layout = 'horizontal', items = [] } = Astro.props;
17
+ ---
18
+
19
+ <section class="b-steps" data-layout={layout}>
20
+ {(title || intro) && (
21
+ <div class="b-steps__head">
22
+ {title && <Heading text={title} level={2} align="center" />}
23
+ {intro && <p class="b-steps__intro" set:html={inlineMarkdown(intro)} />}
24
+ </div>
25
+ )}
26
+ <ol class="b-steps__list">
27
+ {items.map((item, i) => (
28
+ <li class="b-steps__item">
29
+ <span class="b-steps__marker" aria-hidden="true">
30
+ {item.icon && isIconKey(item.icon) ? <Icon name={item.icon} size={20} /> : i + 1}
31
+ </span>
32
+ <div class="b-steps__body">
33
+ <h3 class="b-steps__title">{item.title}</h3>
34
+ {item.text && <p class="b-steps__text">{item.text}</p>}
35
+ </div>
36
+ </li>
37
+ ))}
38
+ </ol>
39
+ </section>
40
+
41
+ <style>
42
+ .b-steps {
43
+ display: flex;
44
+ flex-direction: column;
45
+ gap: 2rem;
46
+ max-width: 72rem;
47
+ margin-inline: auto;
48
+ padding-inline: 1rem;
49
+ }
50
+ .b-steps__head {
51
+ max-width: var(--max);
52
+ margin-inline: auto;
53
+ text-align: center;
54
+ display: flex;
55
+ flex-direction: column;
56
+ gap: 0.75rem;
57
+ }
58
+ .b-steps__intro { margin: 0; color: var(--muted); line-height: 1.6; }
59
+ .b-steps__list {
60
+ list-style: none;
61
+ margin: 0;
62
+ padding: 0;
63
+ display: grid;
64
+ gap: 1.5rem;
65
+ }
66
+ /* Base (and mobile): marker left, body right. */
67
+ .b-steps__item {
68
+ display: flex;
69
+ gap: 1rem;
70
+ align-items: flex-start;
71
+ }
72
+ .b-steps__marker {
73
+ flex-shrink: 0;
74
+ width: 2.5rem;
75
+ height: 2.5rem;
76
+ display: inline-flex;
77
+ align-items: center;
78
+ justify-content: center;
79
+ border-radius: var(--radius-pill);
80
+ background: var(--gold);
81
+ color: var(--text-light);
82
+ font-family: var(--font-heading);
83
+ font-weight: 700;
84
+ font-size: 1.05rem;
85
+ }
86
+ .b-steps__title { margin: 0 0 0.35rem; font-size: 1.1rem; color: var(--fg); }
87
+ .b-steps__text { margin: 0; color: var(--muted); line-height: 1.6; }
88
+
89
+ /* Vertical: a centred, stacked timeline. */
90
+ .b-steps[data-layout='vertical'] .b-steps__list { max-width: 40rem; margin-inline: auto; }
91
+
92
+ /* Horizontal (≥48em): connected cards in a row, marker over centred body. */
93
+ @media (min-width: 48em) {
94
+ .b-steps[data-layout='horizontal'] .b-steps__list {
95
+ grid-auto-flow: column;
96
+ grid-auto-columns: 1fr;
97
+ gap: 1rem;
98
+ }
99
+ .b-steps[data-layout='horizontal'] .b-steps__item {
100
+ flex-direction: column;
101
+ align-items: center;
102
+ text-align: center;
103
+ gap: 0.85rem;
104
+ position: relative;
105
+ }
106
+ /* Connector from this marker's right edge to the next marker's left edge.
107
+ The +1.5rem / -2rem account for the 2.5rem marker AND the 1rem grid gap,
108
+ so the line sits centred between markers (not falling short). */
109
+ .b-steps[data-layout='horizontal'] .b-steps__item:not(:last-child)::after {
110
+ content: '';
111
+ position: absolute;
112
+ top: calc(1.25rem - 1px);
113
+ left: calc(50% + 1.5rem);
114
+ width: calc(100% - 2rem);
115
+ height: 2px;
116
+ background: var(--border);
117
+ z-index: 0;
118
+ }
119
+ .b-steps[data-layout='horizontal'] .b-steps__marker { position: relative; z-index: 1; }
120
+ }
121
+ </style>
@@ -9,6 +9,7 @@ import Heading from '../blocks/Heading.astro';
9
9
 
10
10
  import { inlineMarkdown } from '../../lib/inline-markdown';
11
11
  import { filledColumns } from '../../lib/table';
12
+ import { proseParts } from '../../lib/prose';
12
13
  interface Props {
13
14
  title?: string;
14
15
  intro?: string;
@@ -24,7 +25,7 @@ const keep = filledColumns(columns, rows);
24
25
  {(title || intro) && (
25
26
  <div class="b-table__head">
26
27
  {title && <Heading text={title} level={2} />}
27
- {intro && <p class="b-table__intro" set:html={inlineMarkdown(intro)} />}
28
+ {intro && proseParts(intro).map((part) => part.kind === 'p' ? <p class="b-table__intro" set:html={part.html} /> : <ul class="b-table__intro">{part.items.map((x) => <li set:html={x} />)}</ul>)}
28
29
  </div>
29
30
  )}
30
31
  <div class="b-table__scroll">
@@ -38,7 +38,7 @@ const safeLink = /^https:\/\//i.test(url) ? url : undefined;
38
38
  </div>
39
39
  <div class="b-video__frame"></div>
40
40
  </div>
41
- {caption && <figcaption class="b-video__caption">{caption}</figcaption>}
41
+ {caption && <figcaption class="b-video__caption" set:html={inlineMarkdown(caption)} />}
42
42
  </figure>
43
43
  {/* Always visible, before AND after the player loads: the way to the provider. */}
44
44
  <div class="b-video__footer">
package/content/blocks.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { iconKeys, isIconKey } from '../lib/icons';
2
3
 
3
4
  /**
4
5
  * The block palette — the heart of the Ferst page model (see
@@ -14,6 +15,12 @@ import { z } from 'zod';
14
15
 
15
16
  /** Every block shares a `type` discriminant and an `enabled` flag (keep a block
16
17
  * in the page data without rendering it). */
18
+ /** An icon name from the engine's registry (lib/icons). An unknown name fails validation,
19
+ * and so the build, with the valid names listed, instead of rendering nothing silently. */
20
+ const iconName = z.string().refine(isIconKey, (v) => ({
21
+ message: `Unknown icon "${v}". Use one of: ${iconKeys.join(', ')}.`,
22
+ }));
23
+
17
24
  export const headingBlock = z.object({
18
25
  type: z.literal('heading'),
19
26
  enabled: z.boolean().default(true),
@@ -159,7 +166,7 @@ export const featureCardsBlock = z.object({
159
166
  cards: z
160
167
  .array(
161
168
  z.object({
162
- icon: z.string().optional(),
169
+ icon: iconName.optional(),
163
170
  title: z.string(),
164
171
  text: z.string().optional(),
165
172
  href: z.string().optional(),
@@ -261,7 +268,7 @@ export const tilesBlock = z.object({
261
268
  .array(
262
269
  z
263
270
  .object({
264
- icon: z.string().optional(),
271
+ icon: iconName.optional(),
265
272
  eyebrow: z.string().optional(),
266
273
  /** Optional only for a picture tile: with an image and no text, the tile is
267
274
  * the picture alone (no scrim) — a linked poster/logo — named by its alt. */
@@ -319,7 +326,7 @@ export const stepsBlock = z.object({
319
326
  title: z.string().optional(),
320
327
  intro: z.string().optional(),
321
328
  layout: z.enum(['horizontal', 'vertical']).default('horizontal'),
322
- items: z.array(z.object({ title: z.string(), text: z.string().optional(), icon: z.string().optional() })).default([]),
329
+ items: z.array(z.object({ title: z.string(), text: z.string().optional(), icon: iconName.optional() })).default([]),
323
330
  });
324
331
 
325
332
  /** Pricing — a row of plan cards (name, price, period, feature list, CTA). One
@@ -388,7 +395,7 @@ export const bentoBlock = z.object({
388
395
  z.object({
389
396
  size: z.enum(['sm', 'wide', 'tall', 'lg']).default('sm'),
390
397
  variant: z.enum(['surface', 'filled', 'gradient', 'image']).default('surface'),
391
- icon: z.string().optional(),
398
+ icon: iconName.optional(),
392
399
  eyebrow: z.string().optional(),
393
400
  title: z.string(),
394
401
  text: z.string().optional(),
@@ -471,7 +478,7 @@ export const noticeBlock = z.object({
471
478
  type: z.literal('notice'),
472
479
  enabled: z.boolean().default(true),
473
480
  tone: z.enum(['accent', 'neutral']).default('accent'),
474
- icon: z.string().optional(),
481
+ icon: iconName.optional(),
475
482
  title: z.string().optional(),
476
483
  text: z.string(),
477
484
  link: z.object({ label: z.string(), href: z.string() }).optional(),
@@ -492,7 +499,7 @@ export const mediaCardsBlock = z.object({
492
499
  .array(
493
500
  z.object({
494
501
  image: z.object({ src: z.string(), alt: z.string().default('') }).optional(),
495
- icon: z.string().optional(),
502
+ icon: iconName.optional(),
496
503
  title: z.string(),
497
504
  /** A short meta line shown as a pill (e.g. "Sundays 10:45 · Main hall"). */
498
505
  meta: z.string().optional(),
@@ -517,7 +524,7 @@ export const contactCardBlock = z.object({
517
524
  items: z
518
525
  .array(
519
526
  z.object({
520
- icon: z.string().optional(),
527
+ icon: iconName.optional(),
521
528
  value: z.string(),
522
529
  href: z.string().optional(),
523
530
  })
@@ -647,7 +654,7 @@ export const contactsBlock = z.object({
647
654
  contacts: z
648
655
  .array(
649
656
  z.object({
650
- role: z.string(),
657
+ role: z.string().optional(),
651
658
  name: z.string().optional(),
652
659
  phone: z.string().optional(),
653
660
  email: z.string().optional(),
@@ -655,6 +662,7 @@ export const contactsBlock = z.object({
655
662
  address: z.string().optional(),
656
663
  hours: z.string().optional(),
657
664
  })
665
+ .refine((c) => !!c.role || !!c.name, { message: 'A contact needs a role or a name', path: ['role'] })
658
666
  )
659
667
  .min(1),
660
668
  })
@@ -90,6 +90,11 @@ export const navbarSettingsSchema = z.object({
90
90
  export const footerSettingsSchema = z.object({
91
91
  /** Footer legal/info nav. Empty by default — every client defines its own. */
92
92
  footer: z.array(navLinkSchema).default([]),
93
+ /** Headed footer columns for reference material (policies, safeguarding, documents,
94
+ * forms) — where it belongs, so the main menu stays short and clear. */
95
+ groups: z
96
+ .array(z.object({ heading: z.string(), links: z.array(navLinkSchema).min(1) }))
97
+ .default([]),
93
98
  });
94
99
 
95
100
  /**