ferst-core 0.7.4 → 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.
@@ -0,0 +1,92 @@
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 '../../lib/prose';
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).map((part) =>
27
+ part.kind === 'p' ? <p set:html={part.html} />
28
+ : part.kind === 'ul' ? <ul>{part.items.map((x) => <li set:html={x} />)}</ul>
29
+ : <ol>{part.items.map((x) => <li set:html={x} />)}</ol>
30
+ )}
31
+ {cta && <div class="b-mediatext__cta"><Button href={cta.href} variant="primary">{cta.label}</Button></div>}
32
+ </div>
33
+ <figure class="b-mediatext__media">
34
+ <img src={image.src} alt={image.alt ?? ''} loading="lazy" decoding="async" style={ratio !== 'auto' ? `aspect-ratio: ${ratio}; object-fit: cover` : undefined} />
35
+ {image.caption && <figcaption>{image.caption}</figcaption>}
36
+ </figure>
37
+ </section>
38
+
39
+ <style>
40
+ .b-mediatext {
41
+ display: grid;
42
+ gap: clamp(1.5rem, 4vw, 3rem);
43
+ align-items: center;
44
+ width: 100%;
45
+ max-width: 72rem;
46
+ }
47
+ @media (min-width: 48em) {
48
+ .b-mediatext {
49
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
50
+ }
51
+ .b-mediatext[data-side='start'] .b-mediatext__media {
52
+ order: -1;
53
+ }
54
+ }
55
+ .b-mediatext__text {
56
+ display: flex;
57
+ flex-direction: column;
58
+ gap: 0.9rem;
59
+ }
60
+ .b-mediatext__text p,
61
+ .b-mediatext__text ul,
62
+ .b-mediatext__text ol {
63
+ margin: 0;
64
+ line-height: 1.7;
65
+ }
66
+ .b-mediatext__text ul,
67
+ .b-mediatext__text ol {
68
+ padding-inline-start: 1.4em;
69
+ }
70
+ .b-mediatext__text :global(a:not(.btn)) {
71
+ color: var(--accent);
72
+ text-decoration: underline;
73
+ text-underline-offset: 0.15em;
74
+ }
75
+ .b-mediatext__cta {
76
+ margin-top: 0.4rem;
77
+ }
78
+ .b-mediatext__media {
79
+ margin: 0;
80
+ }
81
+ .b-mediatext__media img {
82
+ display: block;
83
+ width: 100%;
84
+ height: auto;
85
+ border-radius: var(--radius);
86
+ }
87
+ .b-mediatext__media figcaption {
88
+ margin-top: 0.5rem;
89
+ font-size: 0.875rem;
90
+ color: var(--muted);
91
+ }
92
+ </style>
@@ -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>
@@ -8,6 +8,8 @@
8
8
  import Heading from '../blocks/Heading.astro';
9
9
 
10
10
  import { inlineMarkdown } from '../../lib/inline-markdown';
11
+ import { filledColumns } from '../../lib/table';
12
+ import { proseParts } from '../../lib/prose';
11
13
  interface Props {
12
14
  title?: string;
13
15
  intro?: string;
@@ -15,25 +17,27 @@ interface Props {
15
17
  rows?: string[][];
16
18
  }
17
19
  const { title, intro, columns = [], rows = [] } = Astro.props;
20
+ // Columns empty in every row (an unused "Evening") are dropped, not shown blank.
21
+ const keep = filledColumns(columns, rows);
18
22
  ---
19
23
 
20
24
  <section class="b-table">
21
25
  {(title || intro) && (
22
26
  <div class="b-table__head">
23
27
  {title && <Heading text={title} level={2} />}
24
- {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>)}
25
29
  </div>
26
30
  )}
27
31
  <div class="b-table__scroll">
28
32
  <table class="b-table__table">
29
33
  {columns.length > 0 && (
30
34
  <thead>
31
- <tr>{columns.map((c) => <th scope="col">{c}</th>)}</tr>
35
+ <tr>{keep.map((i) => <th scope="col">{columns[i]}</th>)}</tr>
32
36
  </thead>
33
37
  )}
34
38
  <tbody>
35
39
  {rows.map((row) => (
36
- <tr>{row.map((cell) => <td set:html={inlineMarkdown(cell)} />)}</tr>
40
+ <tr>{(columns.length ? keep : row.map((_, i) => i)).map((i) => <td set:html={inlineMarkdown(row[i] ?? '')} />)}</tr>
37
41
  ))}
38
42
  </tbody>
39
43
  </table>
@@ -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
  })
@@ -631,6 +638,51 @@ export const timetableBlock = z.object({
631
638
  .min(1),
632
639
  });
633
640
 
641
+ /** Contacts — a compact directory of people or offices: role as the card title, phone and
642
+ * email as tap targets (email with an optional subject), address and hours, and a shared
643
+ * note per group (e.g. office hours) instead of repeating it on every card. */
644
+ export const contactsBlock = z.object({
645
+ type: z.literal('contacts'),
646
+ enabled: z.boolean().default(true),
647
+ title: z.string().optional(),
648
+ intro: z.string().optional(),
649
+ groups: z
650
+ .array(
651
+ z.object({
652
+ heading: z.string().optional(),
653
+ note: z.string().optional(),
654
+ contacts: z
655
+ .array(
656
+ z.object({
657
+ role: z.string().optional(),
658
+ name: z.string().optional(),
659
+ phone: z.string().optional(),
660
+ email: z.string().optional(),
661
+ emailSubject: z.string().optional(),
662
+ address: z.string().optional(),
663
+ hours: z.string().optional(),
664
+ })
665
+ .refine((c) => !!c.role || !!c.name, { message: 'A contact needs a role or a name', path: ['role'] })
666
+ )
667
+ .min(1),
668
+ })
669
+ )
670
+ .min(1),
671
+ });
672
+
673
+ /** MediaText — an image beside its text (start or end), stacking on a phone. The text takes
674
+ * prose formatting; the image keeps its shape (`ratio` crops to a set shape); optional CTA. */
675
+ export const mediaTextBlock = z.object({
676
+ type: z.literal('mediaText'),
677
+ enabled: z.boolean().default(true),
678
+ title: z.string().optional(),
679
+ text: z.string(),
680
+ image: z.object({ src: z.string(), alt: z.string().default(''), caption: z.string().optional() }),
681
+ imageSide: z.enum(['start', 'end']).default('end'),
682
+ ratio: z.enum(['auto', '1/1', '4/3', '3/2', '16/9']).default('auto'),
683
+ cta: z.object({ label: z.string(), href: z.string() }).optional(),
684
+ });
685
+
634
686
  export type RecipeBlock =
635
687
  | z.infer<typeof heroBlock>
636
688
  | z.infer<typeof featureCardsBlock>
@@ -661,7 +713,9 @@ export type RecipeBlock =
661
713
  | z.infer<typeof documentsBlock>
662
714
  | z.infer<typeof linkRowBlock>
663
715
  | z.infer<typeof documentBlock>
664
- | z.infer<typeof timetableBlock>;
716
+ | z.infer<typeof timetableBlock>
717
+ | z.infer<typeof contactsBlock>
718
+ | z.infer<typeof mediaTextBlock>;
665
719
 
666
720
  /* ── Level-1 layout / container primitives ─────────────────────────────────
667
721
  * These hold child blocks, so the schema is **recursive** (a container's
@@ -777,6 +831,8 @@ export const blockSchema = z.discriminatedUnion('type', [
777
831
  linkRowBlock,
778
832
  documentBlock,
779
833
  timetableBlock,
834
+ contactsBlock,
835
+ mediaTextBlock,
780
836
  sectionBlock,
781
837
  gridBlock,
782
838
  stackBlock,