ferst-core 0.1.0 → 0.2.0

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 (70) hide show
  1. package/LICENSE +102 -201
  2. package/LICENSE-Apache-2.0 +201 -0
  3. package/NOTICE +24 -5
  4. package/README.md +89 -73
  5. package/bin/check-thin.mjs +74 -0
  6. package/components/BlockRenderer.astro +89 -0
  7. package/components/Button.astro +85 -33
  8. package/components/CalendarEmbed.astro +188 -0
  9. package/components/Card.astro +7 -17
  10. package/components/Icon.astro +35 -0
  11. package/components/PostArticle.astro +133 -0
  12. package/components/PostCard.astro +85 -225
  13. package/components/SiteFooter.astro +2 -2
  14. package/components/SiteHeader.astro +104 -51
  15. package/components/blocks/Badge.astro +29 -0
  16. package/components/blocks/ButtonBlock.astro +13 -0
  17. package/components/blocks/Divider.astro +12 -0
  18. package/components/blocks/Grid.astro +48 -0
  19. package/components/blocks/Heading.astro +42 -0
  20. package/components/blocks/ImageBlock.astro +80 -0
  21. package/components/blocks/List.astro +78 -0
  22. package/components/blocks/Prose.astro +29 -0
  23. package/components/blocks/Quote.astro +32 -0
  24. package/components/blocks/Section.astro +52 -0
  25. package/components/blocks/Spacer.astro +20 -0
  26. package/components/blocks/Stack.astro +30 -0
  27. package/components/blocks/Stat.astro +30 -0
  28. package/components/recipes/Accordion.astro +84 -0
  29. package/components/recipes/Announcement.astro +74 -0
  30. package/components/recipes/Banner.astro +134 -0
  31. package/components/recipes/Bento.astro +138 -0
  32. package/components/recipes/ContactForm.astro +172 -0
  33. package/components/recipes/Cta.astro +83 -0
  34. package/components/recipes/Faq.astro +80 -0
  35. package/components/recipes/FeatureCards.astro +105 -0
  36. package/components/recipes/Gallery.astro +55 -0
  37. package/components/recipes/Hero.astro +191 -0
  38. package/components/recipes/Logos.astro +77 -0
  39. package/components/recipes/Marquee.astro +75 -0
  40. package/components/recipes/Pricing.astro +149 -0
  41. package/components/recipes/Stats.astro +124 -0
  42. package/components/recipes/Steps.astro +120 -0
  43. package/components/recipes/Tabs.astro +133 -0
  44. package/components/recipes/Testimonial.astro +66 -0
  45. package/components/recipes/Tiles.astro +248 -0
  46. package/content/blocks.ts +534 -0
  47. package/content/calendar.ts +40 -0
  48. package/content/collections.ts +25 -14
  49. package/content/posts.ts +89 -0
  50. package/content/schemas.ts +58 -123
  51. package/layouts/Base.astro +30 -56
  52. package/lib/calendar.ts +12 -0
  53. package/lib/icons.ts +64 -0
  54. package/lib/pages.ts +42 -0
  55. package/lib/posts.ts +93 -0
  56. package/lib/siteSettings.ts +18 -36
  57. package/lib/themeTokens.ts +163 -0
  58. package/package.json +61 -48
  59. package/styles/theme.css +42 -0
  60. package/components/DocLayout.astro +0 -292
  61. package/components/GroupCard.astro +0 -164
  62. package/components/LatestPostList.astro +0 -64
  63. package/components/PaginationNav.astro +0 -81
  64. package/components/PostsToolbar.astro +0 -83
  65. package/content/filters.ts +0 -107
  66. package/lib/mailLinks.ts +0 -13
  67. package/lib/mapsLink.ts +0 -7
  68. package/utils/excerpt.ts +0 -56
  69. package/utils/formatDate.ts +0 -22
  70. package/utils/freshness.ts +0 -5
@@ -0,0 +1,188 @@
1
+ ---
2
+ /**
3
+ * CalendarEmbed — a consent-gated external calendar embed (e.g. Google Calendar).
4
+ * Nothing loads from the provider until the visitor clicks "Load calendar", so no
5
+ * third-party request fires on page load (privacy-first, matching the site's
6
+ * cookie stance). Consent is remembered per visitor (localStorage), so it
7
+ * auto-loads on later visits. White-room, token-driven.
8
+ *
9
+ * The embed URL is required to be https (isSafeEmbedUrl) before it can reach the
10
+ * iframe; an unset or unsafe URL degrades to a friendly note + optional link.
11
+ */
12
+ import { isSafeEmbedUrl } from '../content/calendar';
13
+
14
+ interface Props {
15
+ title?: string;
16
+ intro?: string;
17
+ embedUrl?: string;
18
+ publicUrl?: string;
19
+ height?: number;
20
+ provider?: string;
21
+ }
22
+ const {
23
+ title = 'Calendar',
24
+ intro = '',
25
+ embedUrl = '',
26
+ publicUrl = '',
27
+ height = 600,
28
+ provider = 'Google Calendar',
29
+ } = Astro.props;
30
+
31
+ const safe = isSafeEmbedUrl(embedUrl);
32
+ ---
33
+
34
+ <section class="b-calendar">
35
+ {title && <h2 class="b-calendar__title">{title}</h2>}
36
+ {intro && <p class="b-calendar__intro">{intro}</p>}
37
+
38
+ {!embedUrl ? (
39
+ <p class="b-calendar__note">The calendar isn't set up yet.</p>
40
+ ) : !safe ? (
41
+ <p class="b-calendar__note">
42
+ This calendar can't be displayed here.
43
+ {publicUrl && (
44
+ <> <a href={publicUrl} target="_blank" rel="noopener">Open it in a new tab</a>.</>
45
+ )}
46
+ </p>
47
+ ) : (
48
+ <div
49
+ class="b-calendar__embed"
50
+ data-embed-url={embedUrl}
51
+ data-height={String(height)}
52
+ data-title={title}
53
+ >
54
+ <div class="b-calendar__consent">
55
+ <p class="b-calendar__consent-text">
56
+ This calendar is provided by <strong>{provider}</strong>. Loading it shares your
57
+ device's data (such as your IP address) with {provider}.
58
+ </p>
59
+ <div class="b-calendar__actions">
60
+ <button type="button" class="b-calendar__load">Load calendar</button>
61
+ {publicUrl && (
62
+ <a class="b-calendar__link" href={publicUrl} target="_blank" rel="noopener">
63
+ Open in a new tab instead
64
+ </a>
65
+ )}
66
+ </div>
67
+ </div>
68
+ <div class="b-calendar__frame"></div>
69
+ </div>
70
+ )}
71
+ </section>
72
+
73
+ <script>
74
+ // Bundled (not is:inline), so it runs once even with multiple calendars on a
75
+ // page. Wires each embed's consent gate; auto-loads where consent was given.
76
+ const CONSENT_KEY = 'ferst-calendar-consent';
77
+ document.querySelectorAll<HTMLElement>('.b-calendar__embed').forEach((el) => {
78
+ const url = el.dataset.embedUrl;
79
+ if (!url) return;
80
+ const height = el.dataset.height || '600';
81
+ const title = el.dataset.title || 'Calendar';
82
+ const frameSlot = el.querySelector<HTMLElement>('.b-calendar__frame');
83
+ const consent = el.querySelector<HTMLElement>('.b-calendar__consent');
84
+ const btn = el.querySelector<HTMLButtonElement>('.b-calendar__load');
85
+
86
+ function load() {
87
+ if (el.dataset.loaded === 'true' || !frameSlot) return;
88
+ const frame = document.createElement('iframe');
89
+ frame.src = url as string;
90
+ frame.title = title;
91
+ frame.loading = 'lazy';
92
+ frame.style.width = '100%';
93
+ frame.style.height = height + 'px';
94
+ frame.style.border = '0';
95
+ frameSlot.replaceChildren(frame);
96
+ el.dataset.loaded = 'true';
97
+ if (consent) consent.hidden = true;
98
+ }
99
+
100
+ try {
101
+ if (localStorage.getItem(CONSENT_KEY) === '1') load();
102
+ } catch (_) {}
103
+
104
+ btn?.addEventListener('click', () => {
105
+ try {
106
+ localStorage.setItem(CONSENT_KEY, '1');
107
+ } catch (_) {}
108
+ load();
109
+ });
110
+ });
111
+ </script>
112
+
113
+ <style>
114
+ .b-calendar {
115
+ display: flex;
116
+ flex-direction: column;
117
+ gap: 1rem;
118
+ }
119
+ .b-calendar__title {
120
+ margin: 0;
121
+ }
122
+ .b-calendar__intro,
123
+ .b-calendar__note {
124
+ margin: 0;
125
+ color: var(--muted);
126
+ }
127
+ .b-calendar__note a,
128
+ .b-calendar__link {
129
+ color: var(--gold);
130
+ font-weight: 600;
131
+ }
132
+ /* The frame slot is empty (0 height) until the iframe is injected on consent, so
133
+ the consent card below defines the height until then — no empty reserved box. */
134
+ .b-calendar__frame :global(iframe) {
135
+ display: block;
136
+ border: var(--card-border);
137
+ border-radius: var(--radius);
138
+ background: var(--bg-surface);
139
+ }
140
+ /* Consent card stands in for the calendar until the visitor opts in. */
141
+ .b-calendar__consent {
142
+ display: flex;
143
+ flex-direction: column;
144
+ gap: 1rem;
145
+ padding: 2.5rem 1.5rem;
146
+ border: var(--card-border);
147
+ border-radius: var(--radius);
148
+ background: var(--bg-section);
149
+ text-align: center;
150
+ align-items: center;
151
+ }
152
+ .b-calendar__consent-text {
153
+ margin: 0;
154
+ max-width: 46ch;
155
+ color: var(--muted);
156
+ line-height: 1.6;
157
+ }
158
+ .b-calendar__actions {
159
+ display: flex;
160
+ flex-wrap: wrap;
161
+ gap: 0.75rem 1.25rem;
162
+ align-items: center;
163
+ justify-content: center;
164
+ }
165
+ .b-calendar__load {
166
+ font: inherit;
167
+ font-family: var(--font-heading);
168
+ font-weight: 600;
169
+ padding: 0.6rem 1.4rem;
170
+ border: none;
171
+ border-radius: var(--radius-sm);
172
+ background: var(--gold);
173
+ color: var(--text-light);
174
+ cursor: pointer;
175
+ transition: filter 0.15s ease;
176
+ }
177
+ .b-calendar__load:hover {
178
+ filter: brightness(0.94);
179
+ }
180
+ .b-calendar__load:focus-visible {
181
+ outline: 2px solid var(--gold);
182
+ outline-offset: 2px;
183
+ }
184
+ /* When JS has loaded the frame, the consent card is hidden via [hidden]. */
185
+ .b-calendar__consent[hidden] {
186
+ display: none;
187
+ }
188
+ </style>
@@ -15,13 +15,14 @@ const { as: Tag = 'div', hover = true, padding = '1.25rem 1.5rem', overflow = 'h
15
15
  </Tag>
16
16
 
17
17
  <style>
18
+ /* Token-driven so the global surface "knobs" (--radius / --card-border /
19
+ --shadow-*) restyle every card at once, in both themes. */
18
20
  .card {
19
21
  position: relative;
20
- border-radius: 10px;
21
- background: rgba(200, 151, 58, 0.1);
22
- box-shadow:
23
- 0 6px 22px rgba(44, 59, 42, 0.09),
24
- 0 2px 8px rgba(160, 120, 50, 0.06);
22
+ border-radius: var(--radius);
23
+ background: var(--bg-surface);
24
+ border: var(--card-border);
25
+ box-shadow: var(--shadow-md);
25
26
  overflow: var(--card-overflow, hidden);
26
27
  padding: var(--card-padding);
27
28
  display: flex;
@@ -34,9 +35,7 @@ const { as: Tag = 'div', hover = true, padding = '1.25rem 1.5rem', overflow = 'h
34
35
 
35
36
  .card--hover:hover {
36
37
  transform: translateY(-4px);
37
- box-shadow:
38
- 0 12px 32px rgba(44, 59, 42, 0.15),
39
- 0 4px 12px rgba(160, 120, 50, 0.1);
38
+ box-shadow: var(--shadow-lg);
40
39
  }
41
40
 
42
41
  @media (prefers-reduced-motion: reduce) {
@@ -47,13 +46,4 @@ const { as: Tag = 'div', hover = true, padding = '1.25rem 1.5rem', overflow = 'h
47
46
  transform: none;
48
47
  }
49
48
  }
50
-
51
- :global([data-theme='dark']) .card {
52
- background: rgba(255, 255, 255, 0.04);
53
- box-shadow: none;
54
- }
55
-
56
- :global([data-theme='dark']) .card--hover:hover {
57
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
58
- }
59
49
  </style>
@@ -0,0 +1,35 @@
1
+ ---
2
+ /**
3
+ * Renders one shape from `lib/icons.ts` as an inline SVG. `stroke` defaults to
4
+ * `currentColor` so the icon's colour follows the caller's CSS `color` (and so
5
+ * switches with the site's light/dark theme automatically). The single intended
6
+ * consumer of the icon registry.
7
+ */
8
+ import { ICONS, type IconKey } from '../lib/icons';
9
+
10
+ interface Props {
11
+ name: IconKey;
12
+ size?: number;
13
+ strokeWidth?: number;
14
+ class?: string;
15
+ }
16
+
17
+ const { name, size = 24, strokeWidth = 1.6, class: className } = Astro.props;
18
+ const shape = ICONS[name];
19
+ ---
20
+
21
+ {shape && (
22
+ <svg
23
+ class={className}
24
+ width={size}
25
+ height={size}
26
+ viewBox="0 0 24 24"
27
+ fill="none"
28
+ stroke="currentColor"
29
+ stroke-width={strokeWidth}
30
+ stroke-linecap="round"
31
+ stroke-linejoin="round"
32
+ aria-hidden="true"
33
+ set:html={shape}
34
+ />
35
+ )}
@@ -0,0 +1,133 @@
1
+ ---
2
+ /**
3
+ * PostArticle — the individual post layout: a header (title, date, tags) + optional
4
+ * hero image, then a <slot /> for the rendered markdown body. The route calls
5
+ * `entry.render()` and drops `<Content />` into the slot. Body typography (measure,
6
+ * images, headings) is handled by Base's global `article` styles; this owns only
7
+ * the post header. White-room (no CTK code).
8
+ */
9
+ interface Props {
10
+ title: string;
11
+ date: Date | string;
12
+ heroImage?: string;
13
+ /** Optional downloadable file (e.g. a PDF newsletter) shown as a download link. */
14
+ attachment?: string;
15
+ /** Resolved tags for display + linking. */
16
+ tags?: Array<{ slug: string; label: string }>;
17
+ /** Base path for tag links (default `/tags`). */
18
+ tagBase?: string;
19
+ /** Optional "back to the index" link. */
20
+ backHref?: string;
21
+ backLabel?: string;
22
+ }
23
+ const { title, date, heroImage, attachment, tags = [], tagBase = '/tags', backHref, backLabel = 'Back to news' } = Astro.props;
24
+ // A human label for the download link, hinting the file type from its extension.
25
+ const attachmentExt = attachment ? attachment.split('.').pop()?.toUpperCase() : undefined;
26
+ const attachmentLabel = attachmentExt && attachmentExt.length <= 4 ? `Download (${attachmentExt})` : 'Download attachment';
27
+ const d = date instanceof Date ? date : new Date(date);
28
+ const iso = Number.isNaN(d.valueOf()) ? undefined : d.toISOString().slice(0, 10);
29
+ const display = Number.isNaN(d.valueOf())
30
+ ? ''
31
+ : d.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });
32
+ ---
33
+
34
+ <article class="b-post">
35
+ <header class="b-post__header">
36
+ {backHref && (
37
+ <a class="b-post__back" href={backHref}>&larr; {backLabel}</a>
38
+ )}
39
+ <h1 class="b-post__title">{title}</h1>
40
+ <div class="b-post__meta">
41
+ {display && <time datetime={iso}>{display}</time>}
42
+ {tags.length > 0 && (
43
+ <ul class="b-post__tags">
44
+ {tags.map((t) => (
45
+ <li><a href={`${tagBase}/${t.slug}`}>{t.label}</a></li>
46
+ ))}
47
+ </ul>
48
+ )}
49
+ </div>
50
+ {heroImage && <img class="b-post__hero" src={heroImage} alt="" />}
51
+ </header>
52
+ <div class="b-post__body">
53
+ <slot />
54
+ </div>
55
+ {attachment && (
56
+ <p class="b-post__attachment">
57
+ <a href={attachment} target="_blank" rel="noopener">{attachmentLabel}</a>
58
+ </p>
59
+ )}
60
+ </article>
61
+
62
+ <style>
63
+ .b-post {
64
+ display: flex;
65
+ flex-direction: column;
66
+ gap: 1.5rem;
67
+ width: 100%;
68
+ }
69
+ .b-post__header {
70
+ display: flex;
71
+ flex-direction: column;
72
+ gap: 0.75rem;
73
+ }
74
+ .b-post__back {
75
+ font-family: var(--font-heading);
76
+ font-size: 0.85rem;
77
+ font-weight: 600;
78
+ color: var(--gold);
79
+ }
80
+ .b-post__title {
81
+ max-width: 22ch;
82
+ }
83
+ .b-post__meta {
84
+ display: flex;
85
+ flex-wrap: wrap;
86
+ align-items: center;
87
+ gap: 0.5rem 1rem;
88
+ }
89
+ .b-post__tags {
90
+ display: flex;
91
+ flex-wrap: wrap;
92
+ gap: 0.4rem;
93
+ margin: 0;
94
+ padding: 0;
95
+ list-style: none;
96
+ }
97
+ .b-post__tags a {
98
+ display: inline-block;
99
+ padding: 0.15rem 0.6rem;
100
+ border: var(--card-border);
101
+ border-radius: var(--radius-pill);
102
+ background: var(--bg-tag);
103
+ color: var(--accent);
104
+ font-size: 0.78rem;
105
+ font-weight: 600;
106
+ }
107
+ .b-post__tags a:hover {
108
+ border-color: var(--gold);
109
+ color: var(--gold);
110
+ }
111
+ .b-post__hero {
112
+ width: 100%;
113
+ border-radius: var(--radius);
114
+ margin-top: 0.5rem;
115
+ }
116
+ .b-post__attachment {
117
+ margin: 0;
118
+ }
119
+ .b-post__attachment a {
120
+ display: inline-block;
121
+ padding: 0.55rem 1.1rem;
122
+ border: var(--card-border);
123
+ border-radius: var(--radius-sm);
124
+ background: var(--bg-section);
125
+ color: var(--accent);
126
+ font-family: var(--font-heading);
127
+ font-weight: 600;
128
+ }
129
+ .b-post__attachment a:hover {
130
+ border-color: var(--gold);
131
+ color: var(--gold);
132
+ }
133
+ </style>