ferst-core 0.7.6 → 0.7.8

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,107 @@
1
+ #!/usr/bin/env node
2
+ // ferst-modules — what this site has from its organisation's modules in the Ferst portal:
3
+ // the forms it can place and the calendar it shows. Run it in a client repo root:
4
+ //
5
+ // npx --no-install ferst-modules # the site named in ferst-site.json
6
+ // npx --no-install ferst-modules --site <address> # or ask by the site's address
7
+ // npx --no-install ferst-modules --json # the raw answer
8
+ //
9
+ // Forms and calendars live in the portal, never in the repo: place a form with a
10
+ // `{ "type": "form", "form": "<id>" }` block, and show the calendar by setting
11
+ // calendarSettings.embedUrl to the address printed here.
12
+ import { readFileSync, realpathSync } from 'node:fs';
13
+ import { resolve } from 'node:path';
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
+ }
31
+
32
+ /** The site to ask about: `--site <ref>`, else ferst-site.json's siteId, else null. */
33
+ export function moduleRef(argv, siteMeta) {
34
+ const i = argv.indexOf('--site');
35
+ if (i !== -1 && argv[i + 1]) return argv[i + 1];
36
+ return siteMeta && typeof siteMeta.siteId === 'string' && siteMeta.siteId ? siteMeta.siteId : null;
37
+ }
38
+
39
+ /** A readable summary of GET /sites/:ref/modules. Pure — unit-tested. */
40
+ export function formatModules(data) {
41
+ const out = [];
42
+ out.push(`Site: ${data.site.address ?? data.site.id} (${data.site.id})`, '');
43
+
44
+ out.push('Forms');
45
+ if (!data.modules.forms) out.push(' The Forms module is off for this site.');
46
+ else if (data.forms.length === 0) out.push(' No forms yet. They are created in the portal (Forms).');
47
+ for (const f of data.forms) {
48
+ out.push(` ${f.name}`);
49
+ out.push(` place it with: { "type": "form", "form": "${f.id}" }`);
50
+ out.push(` fields: ${f.fields.map((x) => `${x.label}${x.required ? ' *' : ''}`).join(', ') || '(none)'}`);
51
+ out.push(` button: ${f.submitLabel}`);
52
+ }
53
+ out.push('');
54
+
55
+ out.push('Events');
56
+ if (!data.events) out.push(' The Events module is off for this site.');
57
+ else {
58
+ out.push(` calendarSettings.embedUrl = ${data.events.embedUrl}`);
59
+ out.push(
60
+ data.events.calendars.length
61
+ ? ` shows: ${data.events.calendars.map((c) => c.name).join(', ')}`
62
+ : ' No calendars chosen yet. They are connected in the portal (Events).',
63
+ );
64
+ }
65
+ return out.join('\n');
66
+ }
67
+
68
+ function readSiteMeta(cwd) {
69
+ try {
70
+ return JSON.parse(readFileSync(resolve(cwd, 'ferst-site.json'), 'utf8'));
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ async function main() {
77
+ const argv = process.argv.slice(2);
78
+ const ref = moduleRef(argv, readSiteMeta(process.cwd()));
79
+ if (!ref) {
80
+ console.error('ferst-modules: no site to ask about. Pass --site <your site address> (e.g. --site example.pages.dev), or add "siteId" to ferst-site.json.');
81
+ process.exit(2);
82
+ }
83
+ const base = resolveGitBase();
84
+ const url = `${base}/sites/${encodeURIComponent(ref)}/modules`;
85
+ let res;
86
+ try {
87
+ res = await fetch(url, { headers: { Accept: 'application/json' } });
88
+ } catch (e) {
89
+ console.error(`ferst-modules: couldn't reach the Ferst platform at ${url}: ${e instanceof Error ? e.message : e}`);
90
+ process.exit(1);
91
+ }
92
+ if (res.status === 404) {
93
+ console.error(`ferst-modules: the platform doesn't know a site "${ref}". Check the address, or ask the core team (a core-request issue).`);
94
+ process.exit(1);
95
+ }
96
+ if (!res.ok) {
97
+ console.error(`ferst-modules: the platform answered ${res.status} for ${url}.`);
98
+ process.exit(1);
99
+ }
100
+ const data = await res.json();
101
+ console.log(argv.includes('--json') ? JSON.stringify(data, null, 2) : formatModules(data));
102
+ }
103
+
104
+ // Run when invoked directly as a bin — including via npm's node_modules/.bin symlink.
105
+ if (isDirectInvocation(process.argv[1])) {
106
+ main();
107
+ }
@@ -1,117 +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 Accordion from './recipes/Accordion.astro';
30
- import Tiles from './recipes/Tiles.astro';
31
- import Banner from './recipes/Banner.astro';
32
- import Stats from './recipes/Stats.astro';
33
- import Steps from './recipes/Steps.astro';
34
- import Pricing from './recipes/Pricing.astro';
35
- import Logos from './recipes/Logos.astro';
36
- import Tabs from './recipes/Tabs.astro';
37
- import Announcement from './recipes/Announcement.astro';
38
- import Bento from './recipes/Bento.astro';
39
- import Marquee from './recipes/Marquee.astro';
40
- import LatestPosts from './recipes/LatestPosts.astro';
41
- import MapEmbed from './recipes/MapEmbed.astro';
42
- import Table from './recipes/Table.astro';
43
- import Notice from './recipes/Notice.astro';
44
- import MediaCards from './recipes/MediaCards.astro';
45
- import ContactCard from './recipes/ContactCard.astro';
46
- import Locations from './recipes/Locations.astro';
47
- import Video from './recipes/Video.astro';
48
- import Documents from './recipes/Documents.astro';
49
- import LinkRow from './recipes/LinkRow.astro';
50
- import DocumentRecipe from './recipes/Document.astro';
51
- import Timetable from './recipes/Timetable.astro';
52
- import Contacts from './recipes/Contacts.astro';
53
- import MediaText from './recipes/MediaText.astro';
54
-
55
- interface Props {
56
- blocks: Block[];
57
- }
58
-
59
- const { blocks } = Astro.props;
60
-
61
- const registry: Record<BlockType, any> = {
62
- heading: Heading,
63
- prose: Prose,
64
- image: ImageBlock,
65
- button: ButtonBlock,
66
- quote: Quote,
67
- divider: Divider,
68
- spacer: Spacer,
69
- badge: Badge,
70
- stat: Stat,
71
- list: List,
72
- section: Section,
73
- grid: Grid,
74
- stack: Stack,
75
- hero: Hero,
76
- featureCards: FeatureCards,
77
- cta: Cta,
78
- faq: Faq,
79
- gallery: Gallery,
80
- testimonial: Testimonial,
81
- contactForm: ContactForm,
82
- accordion: Accordion,
83
- tiles: Tiles,
84
- banner: Banner,
85
- stats: Stats,
86
- steps: Steps,
87
- pricing: Pricing,
88
- logos: Logos,
89
- tabs: Tabs,
90
- announcement: Announcement,
91
- bento: Bento,
92
- marquee: Marquee,
93
- latestPosts: LatestPosts,
94
- mapEmbed: MapEmbed,
95
- table: Table,
96
- notice: Notice,
97
- mediaCards: MediaCards,
98
- contactCard: ContactCard,
99
- locations: Locations,
100
- video: Video,
101
- documents: Documents,
102
- linkRow: LinkRow,
103
- document: DocumentRecipe,
104
- timetable: Timetable,
105
- contacts: Contacts,
106
- mediaText: MediaText,
107
- };
108
- ---
109
-
110
- {
111
- blocks
112
- .filter((block) => block.enabled !== false)
113
- .map((block) => {
114
- const Component = registry[block.type];
115
- return <Component {...block} />;
116
- })
117
- }
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;
@@ -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 {
@@ -0,0 +1,26 @@
1
+ ---
2
+ /**
3
+ * ProseParts — renders prose text as paragraphs and lists (lib/prose: blank lines →
4
+ * paragraphs, `- ` / `1. ` lines → lists, inline links / bold / italic, all escaped).
5
+ * The one place this markup lives, shared by prose, document, accordion, mediaText and
6
+ * table intros, so a fix lands everywhere at once.
7
+ *
8
+ * Note for callers: these elements carry THIS component's style scope, so a parent styles
9
+ * them through its own wrapper with `:global(p)`, `:global(ul)` etc.
10
+ */
11
+ import { proseParts } from '../lib/prose';
12
+
13
+ interface Props {
14
+ text: string | undefined | null;
15
+ /** Optional class on each paragraph / list (e.g. a table intro's). */
16
+ class?: string;
17
+ }
18
+ const { text, class: cls } = Astro.props;
19
+ const parts = proseParts(text);
20
+ ---
21
+
22
+ {parts.map((part) =>
23
+ part.kind === 'p' ? <p class={cls} set:html={part.html} />
24
+ : part.kind === 'ul' ? <ul class={cls}>{part.items.map((x) => <li set:html={x} />)}</ul>
25
+ : <ol class={cls}>{part.items.map((x) => <li set:html={x} />)}</ol>
26
+ )}
@@ -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,5 +1,5 @@
1
1
  ---
2
- import { proseParts } from '../../lib/prose';
2
+ import ProseParts from '../ProseParts.astro';
3
3
  interface Props {
4
4
  text: string;
5
5
  variant?: 'body' | 'lead';
@@ -7,40 +7,35 @@ interface Props {
7
7
  const { text, variant = 'body' } = Astro.props;
8
8
  // Blank lines separate paragraphs; `- ` / `1. ` lines make lists; links, bold and
9
9
  // italic are rendered safely (lib/prose.ts → inlineMarkdown).
10
- const parts = proseParts(text);
11
10
  ---
12
11
 
13
12
  <div class="b-prose" data-variant={variant === 'lead' ? 'lead' : undefined}>
14
- {parts.map((part) =>
15
- part.kind === 'p' ? <p set:html={part.html} />
16
- : part.kind === 'ul' ? <ul>{part.items.map((i) => <li set:html={i} />)}</ul>
17
- : <ol>{part.items.map((i) => <li set:html={i} />)}</ol>
18
- )}
13
+ <ProseParts text={text} />
19
14
  </div>
20
15
 
21
16
  <style>
22
17
  .b-prose {
23
18
  color: var(--fg);
24
19
  }
25
- .b-prose p,
26
- .b-prose ul,
27
- .b-prose ol {
20
+ .b-prose :global(p),
21
+ .b-prose :global(ul),
22
+ .b-prose :global(ol) {
28
23
  margin: 0 0 1rem;
29
24
  line-height: 1.7;
30
25
  max-width: var(--max);
31
26
  }
32
- .b-prose ul,
33
- .b-prose ol {
27
+ .b-prose :global(ul),
28
+ .b-prose :global(ol) {
34
29
  padding-inline-start: 1.4em;
35
30
  }
36
- .b-prose li + li {
31
+ .b-prose :global(li + li) {
37
32
  margin-top: 0.25rem;
38
33
  }
39
- .b-prose > :last-child {
34
+ .b-prose > :global(:last-child) {
40
35
  margin-bottom: 0;
41
36
  }
42
37
  /* The page's introduction: larger, quieter, a narrower measure. */
43
- .b-prose[data-variant='lead'] p {
38
+ .b-prose[data-variant='lead'] :global(p) {
44
39
  font-size: clamp(1.125rem, 1.6vw, 1.3125rem);
45
40
  line-height: 1.6;
46
41
  color: var(--muted);
@@ -55,10 +50,10 @@ const parts = proseParts(text);
55
50
  section is centred (or end-aligned) re-anchor it so the text reads as
56
51
  deliberately centred, not centred inside a left column. Text-align itself
57
52
  is inherited from the content-align cascade. */
58
- :global([data-content-align='center']) .b-prose > * {
53
+ :global([data-content-align='center']) .b-prose > :global(*) {
59
54
  margin-inline: auto;
60
55
  }
61
- :global([data-content-align='end']) .b-prose > * {
56
+ :global([data-content-align='end']) .b-prose > :global(*) {
62
57
  margin-inline-start: auto;
63
58
  }
64
59
  </style>