ferst-core 0.1.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.
- package/LICENSE +201 -0
- package/NOTICE +12 -0
- package/README.md +73 -0
- package/components/Button.astro +208 -0
- package/components/Card.astro +59 -0
- package/components/CookiePreferences.astro +232 -0
- package/components/DocLayout.astro +292 -0
- package/components/GroupCard.astro +164 -0
- package/components/LatestPostList.astro +64 -0
- package/components/PaginationNav.astro +81 -0
- package/components/PostCard.astro +262 -0
- package/components/PostsToolbar.astro +83 -0
- package/components/SecondaryLogo.astro +63 -0
- package/components/SiteFooter.astro +245 -0
- package/components/SiteHeader.astro +1130 -0
- package/content/collections.ts +40 -0
- package/content/filters.ts +107 -0
- package/content/schemas.ts +210 -0
- package/layouts/Base.astro +414 -0
- package/lib/mailLinks.ts +13 -0
- package/lib/mapsLink.ts +7 -0
- package/lib/siteSettings.ts +78 -0
- package/package.json +48 -0
- package/styles/fonts.css +76 -0
- package/styles/theme.css +90 -0
- package/utils/excerpt.ts +56 -0
- package/utils/formatDate.ts +22 -0
- package/utils/freshness.ts +5 -0
- package/utils/scrollReveal.ts +40 -0
package/styles/theme.css
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/* Theme tokens — the single home for every colour, surface, and breakpoint
|
|
2
|
+
width the rest of the core references via `var(--…)`. Lifted out of
|
|
3
|
+
`Base.astro` so the chrome components (`SiteHeader`, `SiteFooter`,
|
|
4
|
+
`CookiePreferences`) and any future "modify page elements" feature can
|
|
5
|
+
read the same palette without touching the layout file.
|
|
6
|
+
|
|
7
|
+
These are the core's NEUTRAL DEFAULTS — a deliberately unbranded slate +
|
|
8
|
+
indigo set. Every client overrides the ones it cares about via the
|
|
9
|
+
`themeSettings` collection (Base.astro emits those as a higher-specificity
|
|
10
|
+
<style> in the document head), so brand colours live in the client's repo,
|
|
11
|
+
never here. That's why the accent tokens keep their historic names
|
|
12
|
+
(`--gold`, `--brown`, `--gold-light`) even though the default values are no
|
|
13
|
+
longer gold: renaming them would churn ~60 call-sites across the chrome for
|
|
14
|
+
no functional gain, and clients set them by name regardless. Roles:
|
|
15
|
+
--gold interactive accent (links hover, buttons, focus, active)
|
|
16
|
+
--accent dark readable text accent (nav/footer link text)
|
|
17
|
+
--brown secondary muted text
|
|
18
|
+
--gold-light soft accent background (callouts, gradients)
|
|
19
|
+
--dark-bg dark-mode chrome background (header/footer/nav)
|
|
20
|
+
|
|
21
|
+
Breakpoints are em-based (MDN-recommended); duplicated as literals inside
|
|
22
|
+
@media rules elsewhere because media queries can't reference custom
|
|
23
|
+
properties. Read them in JS via getComputedStyle if you ever need the
|
|
24
|
+
numeric value at runtime. */
|
|
25
|
+
|
|
26
|
+
:root {
|
|
27
|
+
--bp-tablet: 48em; /* 768px @ default 16px root */
|
|
28
|
+
--bp-desktop: 64em; /* 1024px @ default 16px root */
|
|
29
|
+
|
|
30
|
+
--fg: #1F2937;
|
|
31
|
+
--muted: #4B5563;
|
|
32
|
+
--accent: #334155;
|
|
33
|
+
--bg: #F1F5F9;
|
|
34
|
+
--bg-surface: #FFFFFF;
|
|
35
|
+
--bg-section: #E2E8F0;
|
|
36
|
+
--bg-tag: #E2E8F0;
|
|
37
|
+
--border: #CBD5E1;
|
|
38
|
+
--max: 72ch;
|
|
39
|
+
--dark-bg: #0F172A;
|
|
40
|
+
--gold: #4F46E5;
|
|
41
|
+
--gold-light: #E0E7FF;
|
|
42
|
+
--brown: #4B5563;
|
|
43
|
+
--text-light: #FFFFFF;
|
|
44
|
+
--text-footer: #4B5563;
|
|
45
|
+
--text-footer-link: #4F46E5;
|
|
46
|
+
--text-copyright: #94A3B8;
|
|
47
|
+
--divider-dark: #94A3B8;
|
|
48
|
+
--text-subtle: #94A3B8;
|
|
49
|
+
--overlay-accent-08: rgba(51,65,85,0.08);
|
|
50
|
+
--overlay-accent-18: rgba(51,65,85,0.18);
|
|
51
|
+
--overlay-white-07: rgba(255,255,255,0.07);
|
|
52
|
+
--overlay-white-08: rgba(255,255,255,0.08);
|
|
53
|
+
--overlay-white-12: rgba(255,255,255,0.12);
|
|
54
|
+
/* Accent-tinted cards (e.g. the news carousel) — reuse the accent tokens */
|
|
55
|
+
--news-card-body: #1F2937;
|
|
56
|
+
--news-card-meta: #4B5563;
|
|
57
|
+
/* All `h3` / `.t-h3`: ~22px (1.375rem) floor on mobile → max 26px (1.625rem) */
|
|
58
|
+
--type-h3-size: clamp(1.375rem, 1.2rem + 0.55vw, 1.625rem);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* ── Dark mode ── */
|
|
62
|
+
[data-theme="dark"] {
|
|
63
|
+
--fg: #E2E8F0;
|
|
64
|
+
--muted: #94A3B8;
|
|
65
|
+
--accent: #CBD5E1;
|
|
66
|
+
--bg: #0F172A;
|
|
67
|
+
--bg-surface: #1E293B;
|
|
68
|
+
--bg-section: #1E293B;
|
|
69
|
+
--bg-tag: #1E293B;
|
|
70
|
+
--border: #334155;
|
|
71
|
+
--dark-bg: #0B1120;
|
|
72
|
+
--gold: #818CF8;
|
|
73
|
+
--gold-light: #312E81;
|
|
74
|
+
--brown: #94A3B8;
|
|
75
|
+
--text-light: #E2E8F0;
|
|
76
|
+
--text-footer: #94A3B8;
|
|
77
|
+
--text-footer-link: #818CF8;
|
|
78
|
+
--text-copyright: #64748B;
|
|
79
|
+
--divider-dark: #334155;
|
|
80
|
+
--text-subtle: #64748B;
|
|
81
|
+
/* Footer secondary-logo raster: darken toward paragraph copy (--muted) */
|
|
82
|
+
--secondary-logo-filter: brightness(0.68) contrast(1.12) saturate(0.82);
|
|
83
|
+
--overlay-accent-08: rgba(255,255,255,0.07);
|
|
84
|
+
--overlay-accent-18: rgba(255,255,255,0.12);
|
|
85
|
+
--overlay-white-07: rgba(255,255,255,0.07);
|
|
86
|
+
--overlay-white-08: rgba(255,255,255,0.08);
|
|
87
|
+
--overlay-white-12: rgba(255,255,255,0.12);
|
|
88
|
+
--news-card-body: var(--fg);
|
|
89
|
+
--news-card-meta: rgba(226, 232, 240, 0.78);
|
|
90
|
+
}
|
package/utils/excerpt.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cheap markdown → plaintext for listing cards and meta descriptions.
|
|
3
|
+
* Not a full parser; tuned for news-style Markdown (headings, links, emphasis).
|
|
4
|
+
*/
|
|
5
|
+
export function markdownToPlainText(markdown: string, maxInputChars = 8000): string {
|
|
6
|
+
let s = markdown.length > maxInputChars ? markdown.slice(0, maxInputChars) : markdown;
|
|
7
|
+
s = s.replace(/\r\n/g, '\n');
|
|
8
|
+
// Drop fenced code (and inline code ticks)
|
|
9
|
+
s = s.replace(/```[\s\S]*?```/g, ' ');
|
|
10
|
+
s = s.replace(/`[^`]*`/g, ' ');
|
|
11
|
+
// Images  → alt text
|
|
12
|
+
s = s.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1 ');
|
|
13
|
+
// Links [text](url) → text
|
|
14
|
+
s = s.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1 ');
|
|
15
|
+
s = s.replace(/\[([^\]]+)\]\[[^\]]*]/g, '$1 ');
|
|
16
|
+
// ATX headings
|
|
17
|
+
s = s.replace(/^#{1,6}\s+(.+)$/gm, '$1 ');
|
|
18
|
+
// Setext-style underline headings (rare)
|
|
19
|
+
s = s.replace(/^\s*[-=]{2,}\s*$/gm, ' ');
|
|
20
|
+
// Blockquotes, lists
|
|
21
|
+
s = s.replace(/^>\s?/gm, '');
|
|
22
|
+
s = s.replace(/^[\t ]*[-*+]\s+/gm, '');
|
|
23
|
+
s = s.replace(/^\d+\.\s+/gm, '');
|
|
24
|
+
// HR
|
|
25
|
+
s = s.replace(/^[\t ]*[-*_]{3,}[\t ]*$/gm, ' ');
|
|
26
|
+
// Bold / italic (nested patterns handled loosely)
|
|
27
|
+
s = s.replace(/\*\*([^*]+)\*\*/g, '$1');
|
|
28
|
+
s = s.replace(/\*([^*]+)\*/g, '$1');
|
|
29
|
+
s = s.replace(/__([^_]+)__/g, '$1');
|
|
30
|
+
s = s.replace(/_([^_]+)_/g, '$1');
|
|
31
|
+
// Strip simple HTML / MDX-looking tags
|
|
32
|
+
s = s.replace(/<[^>]+>/g, ' ');
|
|
33
|
+
s = s.replace(/\n+/g, ' ');
|
|
34
|
+
s = s.replace(/\s+/g, ' ').trim();
|
|
35
|
+
return s;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Plaintext suitable for cards (word-boundary trim + ellipsis).
|
|
40
|
+
*/
|
|
41
|
+
export function plainTextExcerpt(markdown: string, maxChars = 220): string {
|
|
42
|
+
const plain = markdownToPlainText(markdown);
|
|
43
|
+
if (!plain) return '';
|
|
44
|
+
if (plain.length <= maxChars) return plain;
|
|
45
|
+
const slice = plain.slice(0, maxChars);
|
|
46
|
+
const lastSpace = slice.lastIndexOf(' ');
|
|
47
|
+
const cut = lastSpace > maxChars * 0.55 ? slice.slice(0, lastSpace) : slice;
|
|
48
|
+
return `${cut.replace(/[.,;:!?…\s]+$/u, '').trim()}…`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Shorter blurb for `<meta name="description">` and Open Graph.
|
|
53
|
+
*/
|
|
54
|
+
export function metaDescriptionFromBody(markdown: string, maxChars = 155): string {
|
|
55
|
+
return plainTextExcerpt(markdown, maxChars);
|
|
56
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source for the "30 April 2026" date label used across post cards,
|
|
3
|
+
* the post detail page, and the newsletter listing. Pulling it out of four
|
|
4
|
+
* inline `toLocaleDateString(...)` calls means a future change to the
|
|
5
|
+
* format (e.g. add weekday, switch to a relative label) is one edit.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const DEFAULT_OPTIONS: Intl.DateTimeFormatOptions = {
|
|
9
|
+
day: 'numeric',
|
|
10
|
+
month: 'long',
|
|
11
|
+
year: 'numeric',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const DEFAULT_LOCALE = 'en-GB';
|
|
15
|
+
|
|
16
|
+
export function formatDate(
|
|
17
|
+
date: Date,
|
|
18
|
+
options: Intl.DateTimeFormatOptions = DEFAULT_OPTIONS,
|
|
19
|
+
locale: string = DEFAULT_LOCALE,
|
|
20
|
+
): string {
|
|
21
|
+
return date.toLocaleDateString(locale, options);
|
|
22
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progressive-enhancement reveal-on-scroll. Elements already on screen at
|
|
3
|
+
* arm-time are left alone (no hide, no fade, no JS involvement at all) —
|
|
4
|
+
* only elements below the fold get hidden and then faded/slid up once, the
|
|
5
|
+
* first time each individually scrolls into view. Pairs with the
|
|
6
|
+
* `.js-reveal` / `.is-visible` CSS in Base.astro.
|
|
7
|
+
*
|
|
8
|
+
* Skipping already-visible elements isn't just an optimisation: it avoids a
|
|
9
|
+
* flash where on-screen content would otherwise blink to invisible and back
|
|
10
|
+
* right as this runs — most noticeable right after an Astro View
|
|
11
|
+
* Transitions swap, where the new tiles are already painted by the time
|
|
12
|
+
* this executes.
|
|
13
|
+
*
|
|
14
|
+
* Callers should re-run this after any content swap — armed elements aren't
|
|
15
|
+
* tracked globally, so freshly rendered ones need to be passed in again to
|
|
16
|
+
* get the effect.
|
|
17
|
+
*/
|
|
18
|
+
export function armReveal(elements: HTMLElement[]): void {
|
|
19
|
+
const reducedMotionMq = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
20
|
+
if (!elements.length || reducedMotionMq.matches || !('IntersectionObserver' in window)) return;
|
|
21
|
+
|
|
22
|
+
const offscreen = elements.filter((el) => {
|
|
23
|
+
const rect = el.getBoundingClientRect();
|
|
24
|
+
return rect.bottom <= 0 || rect.top >= window.innerHeight;
|
|
25
|
+
});
|
|
26
|
+
if (!offscreen.length) return;
|
|
27
|
+
|
|
28
|
+
offscreen.forEach((el) => el.classList.add('js-reveal'));
|
|
29
|
+
requestAnimationFrame(() => {
|
|
30
|
+
const io = new IntersectionObserver((entries) => {
|
|
31
|
+
entries.forEach((entry) => {
|
|
32
|
+
if (entry.isIntersecting) {
|
|
33
|
+
entry.target.classList.add('is-visible');
|
|
34
|
+
io.unobserve(entry.target);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}, { threshold: 0.12 });
|
|
38
|
+
offscreen.forEach((el) => io.observe(el));
|
|
39
|
+
});
|
|
40
|
+
}
|