ferst-core 0.7.2 → 0.7.5

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.
Files changed (47) hide show
  1. package/bin/ferst-update.mjs +33 -2
  2. package/cms/config.template.yml +214 -206
  3. package/components/BlockRenderer.astro +10 -0
  4. package/components/Pagination.astro +51 -0
  5. package/components/SiteFooter.astro +274 -270
  6. package/components/SiteHeader.astro +1243 -1183
  7. package/components/blocks/Heading.astro +51 -46
  8. package/components/blocks/Prose.astro +10 -2
  9. package/components/blocks/Quote.astro +40 -38
  10. package/components/recipes/Accordion.astro +91 -84
  11. package/components/recipes/Contacts.astro +139 -0
  12. package/components/recipes/Document.astro +157 -0
  13. package/components/recipes/FeatureCards.astro +112 -111
  14. package/components/recipes/Hero.astro +52 -4
  15. package/components/recipes/LinkRow.astro +91 -0
  16. package/components/recipes/MediaCards.astro +213 -212
  17. package/components/recipes/MediaText.astro +92 -0
  18. package/components/recipes/Notice.astro +90 -89
  19. package/components/recipes/Stats.astro +152 -145
  20. package/components/recipes/Table.astro +101 -97
  21. package/components/recipes/Tabs.astro +134 -133
  22. package/components/recipes/Tiles.astro +4 -2
  23. package/components/recipes/Timetable.astro +189 -0
  24. package/components/recipes/Video.astro +177 -157
  25. package/content/blocks.ts +113 -1
  26. package/content/cookies.ts +10 -0
  27. package/content/schemas.ts +167 -161
  28. package/examples/primitives.ts +2 -0
  29. package/examples/recipes.ts +7 -0
  30. package/integration.mjs +83 -64
  31. package/layouts/Base.astro +483 -475
  32. package/lib/documents.ts +68 -62
  33. package/lib/embed-consent.ts +78 -73
  34. package/lib/inline-markdown.ts +6 -0
  35. package/lib/links.ts +22 -0
  36. package/lib/paginate.ts +23 -0
  37. package/lib/posts.ts +20 -2
  38. package/lib/siteSettings.ts +61 -60
  39. package/lib/slug.ts +32 -0
  40. package/lib/table.ts +10 -0
  41. package/lib/timetable.ts +27 -0
  42. package/lib/video.ts +101 -75
  43. package/package.json +1 -1
  44. package/routes/cookie-policy.astro +27 -0
  45. package/routes/posts/index.astro +36 -31
  46. package/routes/posts/page/[page].astro +36 -0
  47. package/routes/posts/tag/[...tag].astro +41 -37
@@ -1,46 +1,51 @@
1
- ---
2
- interface Props {
3
- text: string;
4
- level?: number;
5
- align?: 'start' | 'center' | 'end';
6
- gradient?: boolean;
7
- }
8
- const { text, level = 2, align, gradient = false } = Astro.props;
9
- const Tag = `h${level}` as unknown as keyof HTMLElementTagNameMap;
10
- ---
11
-
12
- <Tag class="b-heading" data-align={align} data-gradient={gradient ? 'true' : 'false'}>{text}</Tag>
13
-
14
- <style>
15
- /* By default a heading INHERITS the page/section content alignment (the
16
- `--content-align` cascade set on `.site-main` / a `section`), so a whole
17
- template stays coherent. An explicit `align` prop overrides it for this one
18
- heading. (`text-align` inherits, and inheritance carries the resolved
19
- alignment; the `[data-align]` rules win over it when set.) */
20
- .b-heading {
21
- color: var(--fg);
22
- margin: 0;
23
- text-wrap: balance;
24
- line-height: 1.2;
25
- text-align: inherit;
26
- }
27
- .b-heading[data-align='start'] {
28
- text-align: start;
29
- }
30
- .b-heading[data-align='center'] {
31
- text-align: center;
32
- }
33
- .b-heading[data-align='end'] {
34
- text-align: end;
35
- }
36
- /* Gradient text — only clip to text where supported, so it never renders as
37
- invisible/transparent on older browsers (it stays solid ink there). */
38
- @supports ((-webkit-background-clip: text) or (background-clip: text)) {
39
- .b-heading[data-gradient='true'] {
40
- background-image: linear-gradient(120deg, var(--gold), var(--accent-2, var(--gold)));
41
- -webkit-background-clip: text;
42
- background-clip: text;
43
- color: transparent;
44
- }
45
- }
46
- </style>
1
+ ---
2
+ import { pageSlugs, uniqueSlug } from '../../lib/slug';
3
+ interface Props {
4
+ text: string;
5
+ level?: number;
6
+ align?: 'start' | 'center' | 'end';
7
+ gradient?: boolean;
8
+ }
9
+ const { text, level = 2, align, gradient = false } = Astro.props;
10
+ const Tag = `h${level}` as unknown as keyof HTMLElementTagNameMap;
11
+ // A readable, page-unique anchor so any heading can be linked to (#mass-times).
12
+ const id = uniqueSlug(text, pageSlugs(Astro.locals));
13
+ ---
14
+
15
+ <Tag class="b-heading" id={id} data-align={align} data-gradient={gradient ? 'true' : 'false'}>{text}</Tag>
16
+
17
+ <style>
18
+ /* By default a heading INHERITS the page/section content alignment (the
19
+ `--content-align` cascade set on `.site-main` / a `section`), so a whole
20
+ template stays coherent. An explicit `align` prop overrides it for this one
21
+ heading. (`text-align` inherits, and inheritance carries the resolved
22
+ alignment; the `[data-align]` rules win over it when set.) */
23
+ .b-heading {
24
+ color: var(--fg);
25
+ margin: 0;
26
+ text-wrap: balance;
27
+ white-space: pre-line; /* a title typed over several lines keeps its breaks */
28
+ scroll-margin-top: 6rem; /* a linked heading stops clear of the sticky header */
29
+ line-height: 1.2;
30
+ text-align: inherit;
31
+ }
32
+ .b-heading[data-align='start'] {
33
+ text-align: start;
34
+ }
35
+ .b-heading[data-align='center'] {
36
+ text-align: center;
37
+ }
38
+ .b-heading[data-align='end'] {
39
+ text-align: end;
40
+ }
41
+ /* Gradient text — only clip to text where supported, so it never renders as
42
+ invisible/transparent on older browsers (it stays solid ink there). */
43
+ @supports ((-webkit-background-clip: text) or (background-clip: text)) {
44
+ .b-heading[data-gradient='true'] {
45
+ background-image: linear-gradient(120deg, var(--gold), var(--accent-2, var(--gold)));
46
+ -webkit-background-clip: text;
47
+ background-clip: text;
48
+ color: transparent;
49
+ }
50
+ }
51
+ </style>
@@ -2,14 +2,15 @@
2
2
  import { proseParts } from '../../lib/prose';
3
3
  interface Props {
4
4
  text: string;
5
+ variant?: 'body' | 'lead';
5
6
  }
6
- const { text } = Astro.props;
7
+ const { text, variant = 'body' } = Astro.props;
7
8
  // Blank lines separate paragraphs; `- ` / `1. ` lines make lists; links, bold and
8
9
  // italic are rendered safely (lib/prose.ts → inlineMarkdown).
9
10
  const parts = proseParts(text);
10
11
  ---
11
12
 
12
- <div class="b-prose">
13
+ <div class="b-prose" data-variant={variant === 'lead' ? 'lead' : undefined}>
13
14
  {parts.map((part) =>
14
15
  part.kind === 'p' ? <p set:html={part.html} />
15
16
  : part.kind === 'ul' ? <ul>{part.items.map((i) => <li set:html={i} />)}</ul>
@@ -38,6 +39,13 @@ const parts = proseParts(text);
38
39
  .b-prose > :last-child {
39
40
  margin-bottom: 0;
40
41
  }
42
+ /* The page's introduction: larger, quieter, a narrower measure. */
43
+ .b-prose[data-variant='lead'] p {
44
+ font-size: clamp(1.125rem, 1.6vw, 1.3125rem);
45
+ line-height: 1.6;
46
+ color: var(--muted);
47
+ max-width: 40em;
48
+ }
41
49
  .b-prose :global(a) {
42
50
  color: var(--accent);
43
51
  text-decoration: underline;
@@ -1,38 +1,40 @@
1
- ---
2
- interface Props {
3
- text: string;
4
- cite?: string;
5
- }
6
- const { text, cite } = Astro.props;
7
- ---
8
-
9
- <figure class="b-quote">
10
- <blockquote>{text}</blockquote>
11
- {cite && <figcaption>— {cite}</figcaption>}
12
- </figure>
13
-
14
- <style>
15
- .b-quote {
16
- margin: 0;
17
- border-inline-start: 3px solid var(--gold);
18
- padding-inline-start: 1rem;
19
- }
20
- /* On a centred page/section the left rule reads as off-axis — drop it so the
21
- quote centres cleanly. (Inherits text-align from the content-align cascade.) */
22
- :global([data-content-align='center']) .b-quote {
23
- border-inline-start: none;
24
- padding-inline-start: 0;
25
- }
26
- .b-quote blockquote {
27
- margin: 0;
28
- color: var(--fg);
29
- font-style: italic;
30
- font-size: 1.125rem;
31
- line-height: 1.6;
32
- }
33
- .b-quote figcaption {
34
- color: var(--muted);
35
- margin-top: 0.5rem;
36
- font-size: 0.9375rem;
37
- }
38
- </style>
1
+ ---
2
+ import { inlineMarkdown } from '../../lib/inline-markdown';
3
+ interface Props {
4
+ text: string;
5
+ cite?: string;
6
+ }
7
+ const { text, cite } = Astro.props;
8
+ ---
9
+
10
+ <figure class="b-quote">
11
+ <blockquote set:html={inlineMarkdown(text)} />
12
+ {cite && <figcaption>— {cite}</figcaption>}
13
+ </figure>
14
+
15
+ <style>
16
+ /* inline markdown already turns single newlines into <br>: a prayer or verse keeps its lines. */
17
+ .b-quote {
18
+ margin: 0;
19
+ border-inline-start: 3px solid var(--gold);
20
+ padding-inline-start: 1rem;
21
+ }
22
+ /* On a centred page/section the left rule reads as off-axis — drop it so the
23
+ quote centres cleanly. (Inherits text-align from the content-align cascade.) */
24
+ :global([data-content-align='center']) .b-quote {
25
+ border-inline-start: none;
26
+ padding-inline-start: 0;
27
+ }
28
+ .b-quote blockquote {
29
+ margin: 0;
30
+ color: var(--fg);
31
+ font-style: italic;
32
+ font-size: 1.125rem;
33
+ line-height: 1.6;
34
+ }
35
+ .b-quote figcaption {
36
+ color: var(--muted);
37
+ margin-top: 0.5rem;
38
+ font-size: 0.9375rem;
39
+ }
40
+ </style>
@@ -1,84 +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
- interface Props {
8
- title?: string;
9
- items?: { heading: string; body: string }[];
10
- }
11
- const { title, items = [] } = Astro.props;
12
- ---
13
-
14
- <section class="b-accordion">
15
- {title && <Heading text={title} level={2} align="center" />}
16
- <div class="b-accordion__list">
17
- {items.map((item) => (
18
- <details class="b-accordion__item">
19
- <summary class="b-accordion__head">{item.heading}</summary>
20
- <div class="b-accordion__body"><p>{item.body}</p></div>
21
- </details>
22
- ))}
23
- </div>
24
- </section>
25
-
26
- <style>
27
- .b-accordion {
28
- display: flex;
29
- flex-direction: column;
30
- gap: 1.5rem;
31
- padding-inline: 1rem;
32
- }
33
- .b-accordion__list {
34
- max-width: var(--max);
35
- margin-inline: auto;
36
- width: 100%;
37
- display: flex;
38
- flex-direction: column;
39
- gap: 0.5rem;
40
- }
41
- .b-accordion__item {
42
- border: var(--card-border);
43
- border-radius: var(--radius-sm);
44
- padding: 0.25rem 1rem;
45
- background: var(--bg-surface);
46
- }
47
- .b-accordion__head {
48
- cursor: pointer;
49
- font-weight: 600;
50
- color: var(--fg);
51
- padding: 0.85rem 0;
52
- list-style: none;
53
- display: flex;
54
- align-items: center;
55
- justify-content: space-between;
56
- gap: 0.75rem;
57
- }
58
- .b-accordion__head::-webkit-details-marker {
59
- display: none;
60
- }
61
- .b-accordion__head::after {
62
- content: '+';
63
- color: var(--gold);
64
- font-weight: 700;
65
- font-size: 1.25rem;
66
- line-height: 1;
67
- transition: transform 0.2s ease;
68
- }
69
- .b-accordion__item[open] .b-accordion__head::after {
70
- content: '\2013'; /* en dash */
71
- }
72
- .b-accordion__head:focus-visible {
73
- outline: 2px solid var(--gold);
74
- outline-offset: 2px;
75
- }
76
- .b-accordion__body {
77
- padding: 0 0 0.9rem;
78
- color: var(--muted);
79
- line-height: 1.6;
80
- }
81
- .b-accordion__body p {
82
- margin: 0;
83
- }
84
- </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 '../../lib/prose';
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(item.body).map((part) =>
23
+ part.kind === 'p' ? <p set:html={part.html} />
24
+ : part.kind === 'ul' ? <ul>{part.items.map((x) => <li set:html={x} />)}</ul>
25
+ : <ol>{part.items.map((x) => <li set:html={x} />)}</ol>
26
+ )}
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 p {
89
+ margin: 0;
90
+ }
91
+ </style>
@@ -0,0 +1,139 @@
1
+ ---
2
+ /**
3
+ * Contacts — a compact directory of people or offices. Each card: the role as its title,
4
+ * the name, phone and email as tap targets (email with an optional subject), address and
5
+ * hours. A group can carry one shared note (e.g. office hours) instead of repeating it on
6
+ * every card. For one contact as a branded band use `contactCard`; for one contact's
7
+ * links in a row, `linkRow`.
8
+ */
9
+ import Heading from '../blocks/Heading.astro';
10
+ import Icon from '../Icon.astro';
11
+ import { inlineMarkdown, textWithBreaks } from '../../lib/inline-markdown';
12
+ import { mailtoHref, telHref } from '../../lib/links';
13
+
14
+ interface Contact { role: string; name?: string; phone?: string; email?: string; emailSubject?: string; address?: string; hours?: string }
15
+ interface Props {
16
+ title?: string;
17
+ intro?: string;
18
+ groups: Array<{ heading?: string; note?: string; contacts: Contact[] }>;
19
+ }
20
+ const { title, intro, groups = [] } = Astro.props;
21
+ ---
22
+
23
+ <section class="b-contacts">
24
+ {(title || intro) && (
25
+ <div class="b-contacts__head">
26
+ {title && <Heading text={title} level={2} />}
27
+ {intro && <p class="b-contacts__intro" set:html={inlineMarkdown(intro)} />}
28
+ </div>
29
+ )}
30
+ {groups.map((g) => (
31
+ <div class="b-contacts__group">
32
+ {(g.heading || g.note) && (
33
+ <div class="b-contacts__group-head">
34
+ {g.heading && <h3 class="b-contacts__group-title">{g.heading}</h3>}
35
+ {g.note && <p class="b-contacts__note" set:html={inlineMarkdown(g.note)} />}
36
+ </div>
37
+ )}
38
+ <ul class="b-contacts__grid">
39
+ {g.contacts.map((c) => (
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>}
43
+ <div class="b-contacts__lines">
44
+ {c.phone && <a class="b-contacts__line" href={telHref(c.phone)}><Icon name="phone" size={16} /><span>{c.phone}</span></a>}
45
+ {c.email && <a class="b-contacts__line" href={mailtoHref(c.email, c.emailSubject)}><Icon name="email" size={16} /><span>{c.email}</span></a>}
46
+ {c.address && <p class="b-contacts__line"><Icon name="map-pin" size={16} /><span set:html={textWithBreaks(c.address)} /></p>}
47
+ {c.hours && <p class="b-contacts__line"><Icon name="calendar" size={16} /><span set:html={textWithBreaks(c.hours)} /></p>}
48
+ </div>
49
+ </li>
50
+ ))}
51
+ </ul>
52
+ </div>
53
+ ))}
54
+ </section>
55
+
56
+ <style>
57
+ .b-contacts {
58
+ display: flex;
59
+ flex-direction: column;
60
+ gap: 2rem;
61
+ width: 100%;
62
+ max-width: 72rem;
63
+ }
64
+ .b-contacts__head,
65
+ .b-contacts__group-head {
66
+ display: flex;
67
+ flex-direction: column;
68
+ gap: 0.4rem;
69
+ max-width: var(--max);
70
+ }
71
+ .b-contacts__intro,
72
+ .b-contacts__note {
73
+ margin: 0;
74
+ color: var(--muted);
75
+ line-height: 1.6;
76
+ }
77
+ .b-contacts__group {
78
+ display: flex;
79
+ flex-direction: column;
80
+ gap: 1rem;
81
+ }
82
+ .b-contacts__group-title {
83
+ margin: 0;
84
+ font-size: 1.15rem;
85
+ }
86
+ .b-contacts__grid {
87
+ list-style: none;
88
+ margin: 0;
89
+ padding: 0;
90
+ display: grid;
91
+ gap: 1rem;
92
+ grid-template-columns: repeat(auto-fill, minmax(min(100%, 16rem), 1fr));
93
+ }
94
+ .b-contacts__card {
95
+ display: flex;
96
+ flex-direction: column;
97
+ gap: 0.4rem;
98
+ padding: 1.1rem 1.25rem;
99
+ border: var(--card-border);
100
+ border-radius: var(--radius);
101
+ background: var(--bg-surface);
102
+ }
103
+ .b-contacts__role {
104
+ margin: 0;
105
+ font-size: 1.05rem;
106
+ }
107
+ .b-contacts__name {
108
+ margin: 0;
109
+ font-size: 0.9375rem;
110
+ color: var(--muted);
111
+ }
112
+ .b-contacts__lines {
113
+ display: grid;
114
+ gap: 0.35rem;
115
+ margin-top: 0.35rem;
116
+ }
117
+ .b-contacts__line {
118
+ display: flex;
119
+ align-items: flex-start;
120
+ gap: 0.5rem;
121
+ margin: 0;
122
+ color: var(--fg);
123
+ text-decoration: none;
124
+ font-size: 0.9375rem;
125
+ overflow-wrap: anywhere;
126
+ }
127
+ .b-contacts__line :global(svg) {
128
+ flex: none;
129
+ margin-top: 0.2em;
130
+ color: var(--gold);
131
+ }
132
+ a.b-contacts__line:hover span {
133
+ text-decoration: underline;
134
+ }
135
+ a.b-contacts__line:focus-visible {
136
+ outline: 2px solid var(--gold);
137
+ outline-offset: 2px;
138
+ }
139
+ </style>