ferst-core 0.7.7 → 0.7.9

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.
@@ -9,9 +9,25 @@
9
9
  // Forms and calendars live in the portal, never in the repo: place a form with a
10
10
  // `{ "type": "form", "form": "<id>" }` block, and show the calendar by setting
11
11
  // calendarSettings.embedUrl to the address printed here.
12
- import { readFileSync } from 'node:fs';
12
+ import { readFileSync, realpathSync } from 'node:fs';
13
13
  import { resolve } from 'node:path';
14
- import { isDirectInvocation, resolveGitBase } from './cms-config.mjs';
14
+ import { pathToFileURL } from 'node:url';
15
+ import { resolveGitBase } from './cms-config.mjs';
16
+
17
+ /**
18
+ * True when THIS file is run as a CLI (not imported). Each bin needs its own copy:
19
+ * `import.meta.url` is per file, so one borrowed from another bin only ever matches that
20
+ * bin (0.7.7 printed nothing). Handles npm's `node_modules/.bin` symlink via realpath.
21
+ */
22
+ export function isDirectInvocation(argv1) {
23
+ if (!argv1) return false;
24
+ if (import.meta.url === pathToFileURL(argv1).href) return true;
25
+ try {
26
+ return import.meta.url === pathToFileURL(realpathSync(argv1)).href;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
15
31
 
16
32
  /** The site to ask about: `--site <ref>`, else ferst-site.json's siteId, else null. */
17
33
  export function moduleRef(argv, siteMeta) {
@@ -1,119 +1,125 @@
1
- ---
2
- /**
3
- * Renders an ordered list of blocks by mapping each block's `type` to its
4
- * component via a registry. Disabled blocks are skipped. The registry is typed
5
- * `Record<BlockType, …>`, so adding a block type to the union without a renderer
6
- * here is a compile error — no silent `undefined` component at runtime.
7
- */
8
- import type { Block, BlockType } from '../content/blocks';
9
- import Heading from './blocks/Heading.astro';
10
- import Prose from './blocks/Prose.astro';
11
- import ImageBlock from './blocks/ImageBlock.astro';
12
- import ButtonBlock from './blocks/ButtonBlock.astro';
13
- import Quote from './blocks/Quote.astro';
14
- import Divider from './blocks/Divider.astro';
15
- import Spacer from './blocks/Spacer.astro';
16
- import Badge from './blocks/Badge.astro';
17
- import Stat from './blocks/Stat.astro';
18
- import List from './blocks/List.astro';
19
- import Section from './blocks/Section.astro';
20
- import Grid from './blocks/Grid.astro';
21
- import Stack from './blocks/Stack.astro';
22
- import Hero from './recipes/Hero.astro';
23
- import FeatureCards from './recipes/FeatureCards.astro';
24
- import Cta from './recipes/Cta.astro';
25
- import Faq from './recipes/Faq.astro';
26
- import Gallery from './recipes/Gallery.astro';
27
- import Testimonial from './recipes/Testimonial.astro';
28
- import ContactForm from './recipes/ContactForm.astro';
29
- import Form from './recipes/Form.astro';
30
- import Accordion from './recipes/Accordion.astro';
31
- import Tiles from './recipes/Tiles.astro';
32
- import Banner from './recipes/Banner.astro';
33
- import Stats from './recipes/Stats.astro';
34
- import Steps from './recipes/Steps.astro';
35
- import Pricing from './recipes/Pricing.astro';
36
- import Logos from './recipes/Logos.astro';
37
- import Tabs from './recipes/Tabs.astro';
38
- import Announcement from './recipes/Announcement.astro';
39
- import Bento from './recipes/Bento.astro';
40
- import Marquee from './recipes/Marquee.astro';
41
- import LatestPosts from './recipes/LatestPosts.astro';
42
- import MapEmbed from './recipes/MapEmbed.astro';
43
- import Table from './recipes/Table.astro';
44
- import Notice from './recipes/Notice.astro';
45
- import MediaCards from './recipes/MediaCards.astro';
46
- import ContactCard from './recipes/ContactCard.astro';
47
- import Locations from './recipes/Locations.astro';
48
- import Video from './recipes/Video.astro';
49
- import Documents from './recipes/Documents.astro';
50
- import LinkRow from './recipes/LinkRow.astro';
51
- import DocumentRecipe from './recipes/Document.astro';
52
- import Timetable from './recipes/Timetable.astro';
53
- import Contacts from './recipes/Contacts.astro';
54
- import MediaText from './recipes/MediaText.astro';
55
-
56
- interface Props {
57
- blocks: Block[];
58
- }
59
-
60
- const { blocks } = Astro.props;
61
-
62
- const registry: Record<BlockType, any> = {
63
- heading: Heading,
64
- prose: Prose,
65
- image: ImageBlock,
66
- button: ButtonBlock,
67
- quote: Quote,
68
- divider: Divider,
69
- spacer: Spacer,
70
- badge: Badge,
71
- stat: Stat,
72
- list: List,
73
- section: Section,
74
- grid: Grid,
75
- stack: Stack,
76
- hero: Hero,
77
- featureCards: FeatureCards,
78
- cta: Cta,
79
- faq: Faq,
80
- gallery: Gallery,
81
- testimonial: Testimonial,
82
- contactForm: ContactForm,
83
- form: Form,
84
- accordion: Accordion,
85
- tiles: Tiles,
86
- banner: Banner,
87
- stats: Stats,
88
- steps: Steps,
89
- pricing: Pricing,
90
- logos: Logos,
91
- tabs: Tabs,
92
- announcement: Announcement,
93
- bento: Bento,
94
- marquee: Marquee,
95
- latestPosts: LatestPosts,
96
- mapEmbed: MapEmbed,
97
- table: Table,
98
- notice: Notice,
99
- mediaCards: MediaCards,
100
- contactCard: ContactCard,
101
- locations: Locations,
102
- video: Video,
103
- documents: Documents,
104
- linkRow: LinkRow,
105
- document: DocumentRecipe,
106
- timetable: Timetable,
107
- contacts: Contacts,
108
- mediaText: MediaText,
109
- };
110
- ---
111
-
112
- {
113
- blocks
114
- .filter((block) => block.enabled !== false)
115
- .map((block) => {
116
- const Component = registry[block.type];
117
- return <Component {...block} />;
118
- })
119
- }
1
+ ---
2
+ /**
3
+ * Renders an ordered list of blocks by mapping each block's `type` to its
4
+ * component via a registry. Disabled blocks are skipped. The registry is typed
5
+ * `Record<BlockType, …>`, so adding a block type to the union without a renderer
6
+ * here is a compile error — no silent `undefined` component at runtime.
7
+ */
8
+ import type { Block, BlockType } from '../content/blocks';
9
+ import Heading from './blocks/Heading.astro';
10
+ import Prose from './blocks/Prose.astro';
11
+ import ImageBlock from './blocks/ImageBlock.astro';
12
+ import ButtonBlock from './blocks/ButtonBlock.astro';
13
+ import Quote from './blocks/Quote.astro';
14
+ import Divider from './blocks/Divider.astro';
15
+ import Spacer from './blocks/Spacer.astro';
16
+ import Badge from './blocks/Badge.astro';
17
+ import Stat from './blocks/Stat.astro';
18
+ import List from './blocks/List.astro';
19
+ import Section from './blocks/Section.astro';
20
+ import Grid from './blocks/Grid.astro';
21
+ import Stack from './blocks/Stack.astro';
22
+ import Hero from './recipes/Hero.astro';
23
+ import FeatureCards from './recipes/FeatureCards.astro';
24
+ import Cta from './recipes/Cta.astro';
25
+ import Faq from './recipes/Faq.astro';
26
+ import Gallery from './recipes/Gallery.astro';
27
+ import Testimonial from './recipes/Testimonial.astro';
28
+ import ContactForm from './recipes/ContactForm.astro';
29
+ import Form from './recipes/Form.astro';
30
+ import Accordion from './recipes/Accordion.astro';
31
+ import Tiles from './recipes/Tiles.astro';
32
+ import Banner from './recipes/Banner.astro';
33
+ import Stats from './recipes/Stats.astro';
34
+ import Steps from './recipes/Steps.astro';
35
+ import Pricing from './recipes/Pricing.astro';
36
+ import Logos from './recipes/Logos.astro';
37
+ import Tabs from './recipes/Tabs.astro';
38
+ import Announcement from './recipes/Announcement.astro';
39
+ import Bento from './recipes/Bento.astro';
40
+ import Marquee from './recipes/Marquee.astro';
41
+ import LatestPosts from './recipes/LatestPosts.astro';
42
+ import MapEmbed from './recipes/MapEmbed.astro';
43
+ import Table from './recipes/Table.astro';
44
+ import Notice from './recipes/Notice.astro';
45
+ import MediaCards from './recipes/MediaCards.astro';
46
+ import ContactCard from './recipes/ContactCard.astro';
47
+ import Locations from './recipes/Locations.astro';
48
+ import Video from './recipes/Video.astro';
49
+ import Documents from './recipes/Documents.astro';
50
+ import LinkRow from './recipes/LinkRow.astro';
51
+ import DocumentRecipe from './recipes/Document.astro';
52
+ import Timetable from './recipes/Timetable.astro';
53
+ import Contacts from './recipes/Contacts.astro';
54
+ import MediaText from './recipes/MediaText.astro';
55
+
56
+ interface Props {
57
+ blocks: Block[];
58
+ }
59
+
60
+ const { blocks } = Astro.props;
61
+
62
+ const registry: Record<BlockType, any> = {
63
+ heading: Heading,
64
+ prose: Prose,
65
+ image: ImageBlock,
66
+ button: ButtonBlock,
67
+ quote: Quote,
68
+ divider: Divider,
69
+ spacer: Spacer,
70
+ badge: Badge,
71
+ stat: Stat,
72
+ list: List,
73
+ section: Section,
74
+ grid: Grid,
75
+ stack: Stack,
76
+ hero: Hero,
77
+ featureCards: FeatureCards,
78
+ cta: Cta,
79
+ faq: Faq,
80
+ gallery: Gallery,
81
+ testimonial: Testimonial,
82
+ contactForm: ContactForm,
83
+ form: Form,
84
+ accordion: Accordion,
85
+ tiles: Tiles,
86
+ banner: Banner,
87
+ stats: Stats,
88
+ steps: Steps,
89
+ pricing: Pricing,
90
+ logos: Logos,
91
+ tabs: Tabs,
92
+ announcement: Announcement,
93
+ bento: Bento,
94
+ marquee: Marquee,
95
+ latestPosts: LatestPosts,
96
+ mapEmbed: MapEmbed,
97
+ table: Table,
98
+ notice: Notice,
99
+ mediaCards: MediaCards,
100
+ contactCard: ContactCard,
101
+ locations: Locations,
102
+ video: Video,
103
+ documents: Documents,
104
+ linkRow: LinkRow,
105
+ document: DocumentRecipe,
106
+ timetable: Timetable,
107
+ contacts: Contacts,
108
+ mediaText: MediaText,
109
+ };
110
+ ---
111
+
112
+ {
113
+ blocks
114
+ .filter((block) => block.enabled !== false)
115
+ .map((block) => {
116
+ const Component = registry[block.type];
117
+ // A block's own `align` sets the alignment cascade for everything inside it. The
118
+ // wrapper is `display: contents`, so it adds no box and the layout is unchanged;
119
+ // heading / banner / section handle their own `align`.
120
+ const own = block.type === 'heading' || block.type === 'banner' || block.type === 'section';
121
+ return block.align && !own
122
+ ? <div class="block-align" data-content-align={block.align}><Component {...block} /></div>
123
+ : <Component {...block} />;
124
+ })
125
+ }
@@ -159,7 +159,7 @@ const {
159
159
  align-items: center;
160
160
  justify-content: center;
161
161
  margin-top: auto;
162
- align-self: flex-start;
162
+ align-self: var(--content-self, flex-start);
163
163
  background: var(--gold);
164
164
  color: var(--text-light);
165
165
  border: none;
@@ -1,4 +1,5 @@
1
1
  ---
2
+ import { inlineMarkdown } from '../lib/inline-markdown';
2
3
  /**
3
4
  * CalendarEmbed — a consent-gated external calendar embed (e.g. Google Calendar).
4
5
  * Nothing loads from the provider until the visitor clicks "Load calendar", so no
@@ -38,7 +39,7 @@ const safe = isSafeEmbedUrl(finalEmbedUrl);
38
39
 
39
40
  <section class="b-calendar">
40
41
  {title && <h2 class="b-calendar__title">{title}</h2>}
41
- {intro && <p class="b-calendar__intro">{intro}</p>}
42
+ {intro && <p class="b-calendar__intro" set:html={inlineMarkdown(intro)} />}
42
43
 
43
44
  {!finalEmbedUrl ? (
44
45
  <p class="b-calendar__note">The calendar isn't set up yet.</p>
@@ -27,6 +27,8 @@ const { as: Tag = 'div', hover = true, padding = '1.25rem 1.5rem', overflow = 'h
27
27
  padding: var(--card-padding);
28
28
  display: flex;
29
29
  flex-direction: column;
30
+ /* Children follow the page / section alignment (Base: --content-items). */
31
+ align-items: var(--content-items, stretch);
30
32
  }
31
33
 
32
34
  .card--hover {
@@ -1026,6 +1026,29 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
1026
1026
  window.addEventListener('scroll', onScrollCompact, { passive: true });
1027
1027
  }
1028
1028
 
1029
+ // The header's full height, published as --header-h on <html> so a full-screen opener
1030
+ // fills the screen BELOW the sticky header (Hero size "screen"). Only the full-size header
1031
+ // counts: while it's condensed on scroll, or animating between sizes, the last full value
1032
+ // stays, so the opener never resizes as the page scrolls.
1033
+ let headerAnimating = false;
1034
+ function syncHeaderHeight() {
1035
+ if (!siteHeader || headerAnimating || siteHeader.classList.contains('is-compact')) return;
1036
+ document.documentElement.style.setProperty('--header-h', `${siteHeader.getBoundingClientRect().height}px`);
1037
+ }
1038
+ if (siteHeader) {
1039
+ siteHeader.addEventListener('transitionrun', (e) => {
1040
+ if (e.target === siteHeader && e.propertyName === 'height') headerAnimating = true;
1041
+ });
1042
+ const settled = (e: TransitionEvent) => {
1043
+ if (e.target !== siteHeader || e.propertyName !== 'height') return;
1044
+ headerAnimating = false;
1045
+ syncHeaderHeight();
1046
+ };
1047
+ siteHeader.addEventListener('transitionend', settled);
1048
+ siteHeader.addEventListener('transitioncancel', settled);
1049
+ syncHeaderHeight();
1050
+ }
1051
+
1029
1052
  // Crowding (#11): on desktop the menu is centred on the header. When the site name (or
1030
1053
  // the right-hand buttons) would run into it, give the menu its own row instead. Decided
1031
1054
  // by measuring the real layout, so it adapts to any name and any number of links.
@@ -1048,6 +1071,7 @@ const primaryNavLongestMatch = Math.max(-1, ...primaryNavMatchLengths);
1048
1071
  const queue = () => { if (!queued) { queued = true; requestAnimationFrame(() => { queued = false; fitCheck(); }); } };
1049
1072
  if (brand) new ResizeObserver(queue).observe(brand);
1050
1073
  new ResizeObserver(queue).observe(siteHeader);
1074
+ new ResizeObserver(syncHeaderHeight).observe(siteHeader);
1051
1075
  desktop.addEventListener('change', queue);
1052
1076
  document.fonts?.ready.then(queue);
1053
1077
  fitCheck();
@@ -1,87 +1,91 @@
1
- ---
2
- /** Accordion — a Level-2 recipe: an optional heading over expand/collapse rows
3
- * (title + body). Generic sibling of FAQ (which is question/answer-flavoured).
4
- * Native <details>/<summary> — accessible, interactive, zero JavaScript. */
5
- import Heading from '../blocks/Heading.astro';
6
-
7
- import ProseParts from '../ProseParts.astro';
8
- interface Props {
9
- title?: string;
10
- items?: { heading: string; body: string }[];
11
- }
12
- const { title, items = [] } = Astro.props;
13
- ---
14
-
15
- <section class="b-accordion">
16
- {title && <Heading text={title} level={2} align="center" />}
17
- <div class="b-accordion__list">
18
- {items.map((item) => (
19
- <details class="b-accordion__item">
20
- <summary class="b-accordion__head">{item.heading}</summary>
21
- <div class="b-accordion__body">
22
- <ProseParts text={item.body} />
23
- </div>
24
- </details>
25
- ))}
26
- </div>
27
- </section>
28
-
29
- <style>
30
- .b-accordion {
31
- display: flex;
32
- flex-direction: column;
33
- gap: 1.5rem;
34
- padding-inline: 1rem;
35
- }
36
- .b-accordion__list {
37
- max-width: var(--max);
38
- margin-inline: auto;
39
- width: 100%;
40
- display: flex;
41
- flex-direction: column;
42
- gap: 0.5rem;
43
- }
44
- .b-accordion__item {
45
- border: var(--card-border);
46
- border-radius: var(--radius-sm);
47
- padding: 0.25rem 1rem;
48
- background: var(--bg-surface);
49
- }
50
- .b-accordion__head {
51
- cursor: pointer;
52
- font-weight: 600;
53
- color: var(--fg);
54
- padding: 0.85rem 0;
55
- list-style: none;
56
- display: flex;
57
- align-items: center;
58
- justify-content: space-between;
59
- gap: 0.75rem;
60
- }
61
- .b-accordion__head::-webkit-details-marker {
62
- display: none;
63
- }
64
- .b-accordion__head::after {
65
- content: '+';
66
- color: var(--gold);
67
- font-weight: 700;
68
- font-size: 1.25rem;
69
- line-height: 1;
70
- transition: transform 0.2s ease;
71
- }
72
- .b-accordion__item[open] .b-accordion__head::after {
73
- content: '\2013'; /* en dash */
74
- }
75
- .b-accordion__head:focus-visible {
76
- outline: 2px solid var(--gold);
77
- outline-offset: 2px;
78
- }
79
- .b-accordion__body {
80
- padding: 0 0 0.9rem;
81
- color: var(--muted);
82
- line-height: 1.6;
83
- }
84
- .b-accordion__body :global(p) {
85
- margin: 0;
86
- }
87
- </style>
1
+ ---
2
+ /** Accordion — a Level-2 recipe: an optional heading over expand/collapse rows
3
+ * (title + body). Generic sibling of FAQ (which is question/answer-flavoured).
4
+ * Native <details>/<summary> — accessible, interactive, zero JavaScript. */
5
+ import Heading from '../blocks/Heading.astro';
6
+
7
+ import ProseParts from '../ProseParts.astro';
8
+ interface Props {
9
+ title?: string;
10
+ items?: { heading: string; body: string }[];
11
+ }
12
+ const { title, items = [] } = Astro.props;
13
+ // Alignment overrides (BlockLayout): the title + intro default to centred; the block's
14
+ // own `align`, or `headingAlign` for the title alone, overrides it from the page data.
15
+ const { align: blockAlign, headingAlign } = Astro.props as { align?: 'start' | 'center' | 'end'; headingAlign?: 'start' | 'center' | 'end' };
16
+ const headAlign = headingAlign ?? blockAlign ?? 'center';
17
+ ---
18
+
19
+ <section class="b-accordion">
20
+ {title && <Heading text={title} level={2} align={headAlign} />}
21
+ <div class="b-accordion__list">
22
+ {items.map((item) => (
23
+ <details class="b-accordion__item">
24
+ <summary class="b-accordion__head">{item.heading}</summary>
25
+ <div class="b-accordion__body">
26
+ <ProseParts text={item.body} />
27
+ </div>
28
+ </details>
29
+ ))}
30
+ </div>
31
+ </section>
32
+
33
+ <style>
34
+ .b-accordion {
35
+ display: flex;
36
+ flex-direction: column;
37
+ gap: 1.5rem;
38
+ padding-inline: 1rem;
39
+ }
40
+ .b-accordion__list {
41
+ max-width: var(--max);
42
+ margin-inline: auto;
43
+ width: 100%;
44
+ display: flex;
45
+ flex-direction: column;
46
+ gap: 0.5rem;
47
+ }
48
+ .b-accordion__item {
49
+ border: var(--card-border);
50
+ border-radius: var(--radius-sm);
51
+ padding: 0.25rem 1rem;
52
+ background: var(--bg-surface);
53
+ }
54
+ .b-accordion__head {
55
+ cursor: pointer;
56
+ font-weight: 600;
57
+ color: var(--fg);
58
+ padding: 0.85rem 0;
59
+ list-style: none;
60
+ display: flex;
61
+ align-items: center;
62
+ justify-content: space-between;
63
+ gap: 0.75rem;
64
+ }
65
+ .b-accordion__head::-webkit-details-marker {
66
+ display: none;
67
+ }
68
+ .b-accordion__head::after {
69
+ content: '+';
70
+ color: var(--gold);
71
+ font-weight: 700;
72
+ font-size: 1.25rem;
73
+ line-height: 1;
74
+ transition: transform 0.2s ease;
75
+ }
76
+ .b-accordion__item[open] .b-accordion__head::after {
77
+ content: '\2013'; /* en dash */
78
+ }
79
+ .b-accordion__head:focus-visible {
80
+ outline: 2px solid var(--gold);
81
+ outline-offset: 2px;
82
+ }
83
+ .b-accordion__body {
84
+ padding: 0 0 0.9rem;
85
+ color: var(--muted);
86
+ line-height: 1.6;
87
+ }
88
+ .b-accordion__body :global(p) {
89
+ margin: 0;
90
+ }
91
+ </style>
@@ -23,12 +23,16 @@ interface Props {
23
23
  items?: Item[];
24
24
  }
25
25
  const { title, intro, items = [] } = Astro.props;
26
+ // Alignment overrides (BlockLayout): the title + intro default to centred; the block's
27
+ // own `align`, or `headingAlign` for the title alone, overrides it from the page data.
28
+ const { align: blockAlign, headingAlign } = Astro.props as { align?: 'start' | 'center' | 'end'; headingAlign?: 'start' | 'center' | 'end' };
29
+ const headAlign = headingAlign ?? blockAlign ?? 'center';
26
30
  ---
27
31
 
28
32
  <section class="b-bento">
29
33
  {(title || intro) && (
30
- <div class="b-bento__head">
31
- {title && <Heading text={title} level={2} align="center" />}
34
+ <div class="b-bento__head" data-recipe-head data-content-align={headAlign}>
35
+ {title && <Heading text={title} level={2} />}
32
36
  {intro && <p class="b-bento__intro">{intro}</p>}
33
37
  </div>
34
38
  )}
@@ -62,7 +66,6 @@ const { title, intro, items = [] } = Astro.props;
62
66
  .b-bento__head {
63
67
  max-width: var(--max);
64
68
  margin-inline: auto;
65
- text-align: center;
66
69
  display: flex;
67
70
  flex-direction: column;
68
71
  gap: 0.75rem;
@@ -91,6 +94,7 @@ const { title, intro, items = [] } = Astro.props;
91
94
  position: relative;
92
95
  display: flex;
93
96
  flex-direction: column;
97
+ align-items: var(--content-items, stretch); /* follows the page / section alignment */
94
98
  gap: 0.4rem;
95
99
  padding: 1.5rem;
96
100
  border-radius: var(--radius);
@@ -37,12 +37,16 @@ const {
37
37
  note,
38
38
  fields = [],
39
39
  } = Astro.props;
40
+ // Alignment overrides (BlockLayout): the title + intro default to centred; the block's
41
+ // own `align`, or `headingAlign` for the title alone, overrides it from the page data.
42
+ const { align: blockAlign, headingAlign } = Astro.props as { align?: 'start' | 'center' | 'end'; headingAlign?: 'start' | 'center' | 'end' };
43
+ const headAlign = headingAlign ?? blockAlign ?? 'center';
40
44
  ---
41
45
 
42
46
  <section class="b-contact-form">
43
47
  {(title || intro) && (
44
- <div class="b-contact-form__head">
45
- {title && <Heading text={title} level={2} align="center" />}
48
+ <div class="b-contact-form__head" data-recipe-head data-content-align={headAlign}>
49
+ {title && <Heading text={title} level={2} />}
46
50
  {intro && <p class="b-contact-form__intro">{intro}</p>}
47
51
  </div>
48
52
  )}
@@ -81,7 +85,6 @@ const {
81
85
  padding-inline: 1rem;
82
86
  }
83
87
  .b-contact-form__head {
84
- text-align: center;
85
88
  display: flex;
86
89
  flex-direction: column;
87
90
  gap: 0.75rem;
@@ -11,11 +11,15 @@ interface Props {
11
11
  background?: 'muted' | 'accent';
12
12
  }
13
13
  const { title, text, cta, background = 'accent' } = Astro.props;
14
+ // Alignment overrides (BlockLayout): the title + intro default to centred; the block's
15
+ // own `align`, or `headingAlign` for the title alone, overrides it from the page data.
16
+ const { align: blockAlign, headingAlign } = Astro.props as { align?: 'start' | 'center' | 'end'; headingAlign?: 'start' | 'center' | 'end' };
17
+ const headAlign = headingAlign ?? blockAlign ?? 'center';
14
18
  ---
15
19
 
16
20
  <section class="b-cta" data-bg={background}>
17
21
  <div class="b-cta__inner">
18
- <Heading text={title} level={2} align="center" />
22
+ <Heading text={title} level={2} align={headAlign} />
19
23
  {text && <p class="b-cta__text">{text}</p>}
20
24
  <div class="b-cta__action">
21
25
  <Button href={cta.href} variant="primary">{cta.label}</Button>
@@ -10,13 +10,17 @@ interface Props {
10
10
  items?: { question: string; answer: string }[];
11
11
  }
12
12
  const { title, items = [] } = Astro.props;
13
+ // Alignment overrides (BlockLayout): the title + intro default to centred; the block's
14
+ // own `align`, or `headingAlign` for the title alone, overrides it from the page data.
15
+ const { align: blockAlign, headingAlign } = Astro.props as { align?: 'start' | 'center' | 'end'; headingAlign?: 'start' | 'center' | 'end' };
16
+ const headAlign = headingAlign ?? blockAlign ?? 'center';
13
17
  // Answers may carry a link or bold via a tiny markdown subset (inlineMarkdown),
14
18
  // split into paragraphs on blank lines. Trusted editor/CMS text, sanitised.
15
19
  const paragraphs = (answer: string) => answer.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean);
16
20
  ---
17
21
 
18
22
  <section class="b-faq">
19
- {title && <Heading text={title} level={2} align="center" />}
23
+ {title && <Heading text={title} level={2} align={headAlign} />}
20
24
  <div class="b-faq__list">
21
25
  {items.map((item) => (
22
26
  <details class="b-faq__item">