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.
- package/LICENSE +102 -201
- package/LICENSE-Apache-2.0 +201 -0
- package/NOTICE +24 -5
- package/README.md +89 -73
- package/bin/check-thin.mjs +74 -0
- package/components/BlockRenderer.astro +89 -0
- package/components/Button.astro +85 -33
- package/components/CalendarEmbed.astro +188 -0
- package/components/Card.astro +7 -17
- package/components/Icon.astro +35 -0
- package/components/PostArticle.astro +133 -0
- package/components/PostCard.astro +85 -225
- package/components/SiteFooter.astro +2 -2
- package/components/SiteHeader.astro +104 -51
- package/components/blocks/Badge.astro +29 -0
- package/components/blocks/ButtonBlock.astro +13 -0
- package/components/blocks/Divider.astro +12 -0
- package/components/blocks/Grid.astro +48 -0
- package/components/blocks/Heading.astro +42 -0
- package/components/blocks/ImageBlock.astro +80 -0
- package/components/blocks/List.astro +78 -0
- package/components/blocks/Prose.astro +29 -0
- package/components/blocks/Quote.astro +32 -0
- package/components/blocks/Section.astro +52 -0
- package/components/blocks/Spacer.astro +20 -0
- package/components/blocks/Stack.astro +30 -0
- package/components/blocks/Stat.astro +30 -0
- package/components/recipes/Accordion.astro +84 -0
- package/components/recipes/Announcement.astro +74 -0
- package/components/recipes/Banner.astro +134 -0
- package/components/recipes/Bento.astro +138 -0
- package/components/recipes/ContactForm.astro +172 -0
- package/components/recipes/Cta.astro +83 -0
- package/components/recipes/Faq.astro +80 -0
- package/components/recipes/FeatureCards.astro +105 -0
- package/components/recipes/Gallery.astro +55 -0
- package/components/recipes/Hero.astro +191 -0
- package/components/recipes/Logos.astro +77 -0
- package/components/recipes/Marquee.astro +75 -0
- package/components/recipes/Pricing.astro +149 -0
- package/components/recipes/Stats.astro +124 -0
- package/components/recipes/Steps.astro +120 -0
- package/components/recipes/Tabs.astro +133 -0
- package/components/recipes/Testimonial.astro +66 -0
- package/components/recipes/Tiles.astro +248 -0
- package/content/blocks.ts +534 -0
- package/content/calendar.ts +40 -0
- package/content/collections.ts +25 -14
- package/content/posts.ts +89 -0
- package/content/schemas.ts +58 -123
- package/layouts/Base.astro +30 -56
- package/lib/calendar.ts +12 -0
- package/lib/icons.ts +64 -0
- package/lib/pages.ts +42 -0
- package/lib/posts.ts +93 -0
- package/lib/siteSettings.ts +18 -36
- package/lib/themeTokens.ts +163 -0
- package/package.json +61 -48
- package/styles/theme.css +42 -0
- package/components/DocLayout.astro +0 -292
- package/components/GroupCard.astro +0 -164
- package/components/LatestPostList.astro +0 -64
- package/components/PaginationNav.astro +0 -81
- package/components/PostsToolbar.astro +0 -83
- package/content/filters.ts +0 -107
- package/lib/mailLinks.ts +0 -13
- package/lib/mapsLink.ts +0 -7
- package/utils/excerpt.ts +0 -56
- package/utils/formatDate.ts +0 -22
- package/utils/freshness.ts +0 -5
package/content/filters.ts
DELETED
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
// Named predicates for the content collection filters used across the
|
|
2
|
-
// /posts/* page routes.
|
|
3
|
-
//
|
|
4
|
-
// Why this lives here:
|
|
5
|
-
// - DRY: each rule is defined once instead of duplicated as an inline
|
|
6
|
-
// arrow at every call site.
|
|
7
|
-
// - Testable: pure functions over Post data, no Astro runtime needed.
|
|
8
|
-
// - Self-documenting: at the page, `getCollection('posts', forArchive)`
|
|
9
|
-
// reads better than the equivalent arrow.
|
|
10
|
-
//
|
|
11
|
-
// Visibility model encoded here:
|
|
12
|
-
// - `draft` and `scheduled` posts are never visible (no listing, no detail
|
|
13
|
-
// page). A `scheduled` post is content that has been written ahead of time:
|
|
14
|
-
// a cron worker flips it to `published` once its `date` has passed, which
|
|
15
|
-
// is the only thing that makes it appear. Until then it is hidden exactly
|
|
16
|
-
// like a draft.
|
|
17
|
-
// - `published`: appear on /posts/ (paginated index); anything beyond
|
|
18
|
-
// `LATEST_NEWS_MAX` newest also appears on /posts/archive/ alongside
|
|
19
|
-
// posts explicitly marked `archived`.
|
|
20
|
-
// - `archived` posts appear on /posts/archive/ (and still get
|
|
21
|
-
// /posts/<slug>/).
|
|
22
|
-
// - "Newsletter" is a tag, not a separate content type — a post tagged
|
|
23
|
-
// `newsletter` is otherwise an ordinary post (same schema, same
|
|
24
|
-
// `/posts/<slug>/` detail page, same listing/archive rules above) that
|
|
25
|
-
// just typically has no body, only an attachment. `/posts/tag/newsletter/`
|
|
26
|
-
// is the generic per-tag archive every tag gets for free, no bespoke
|
|
27
|
-
// route needed.
|
|
28
|
-
import type { CollectionEntry } from 'astro:content';
|
|
29
|
-
import type { Post } from './schemas';
|
|
30
|
-
|
|
31
|
-
type PostEntry = { data: Post };
|
|
32
|
-
|
|
33
|
-
/** How many published news posts headline /posts/ before they roll into /posts/archive/. */
|
|
34
|
-
export const LATEST_NEWS_MAX = 6;
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Statuses that are never shown on the public site — no listing, no detail
|
|
38
|
-
* page. `scheduled` joins `draft` here: it stays invisible until the cron
|
|
39
|
-
* worker promotes it to `published`.
|
|
40
|
-
*/
|
|
41
|
-
const isHidden = ({ data }: PostEntry): boolean =>
|
|
42
|
-
data.status === 'draft' || data.status === 'scheduled';
|
|
43
|
-
|
|
44
|
-
/** Listed on /posts/ (the news index). */
|
|
45
|
-
export function forLatestListing(entry: PostEntry): boolean {
|
|
46
|
-
return entry.data.status === 'published';
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** Has a generated /posts/<slug>/ detail page. Includes archived posts. */
|
|
50
|
-
export function forLatestDetail(entry: PostEntry): boolean {
|
|
51
|
-
return !isHidden(entry);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Single-entry archive predicate (tests, CMS docs). The live /posts/archive/
|
|
56
|
-
* list is built with `buildArchiveListing` (archived + published overflow).
|
|
57
|
-
*/
|
|
58
|
-
export function forArchive(entry: PostEntry): boolean {
|
|
59
|
-
return entry.data.status === 'archived';
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* /posts/archive/: all `archived` posts, plus `published` news that are not
|
|
64
|
-
* among the `LATEST_NEWS_MAX` newest (same date order as /posts/).
|
|
65
|
-
*/
|
|
66
|
-
export function buildArchiveListing(
|
|
67
|
-
publishedPosts: CollectionEntry<'posts'>[],
|
|
68
|
-
archivedPosts: CollectionEntry<'posts'>[]
|
|
69
|
-
): CollectionEntry<'posts'>[] {
|
|
70
|
-
const sorted = [...publishedPosts].sort(
|
|
71
|
-
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
|
|
72
|
-
);
|
|
73
|
-
const afterLatest = sorted.slice(LATEST_NEWS_MAX);
|
|
74
|
-
return [...archivedPosts, ...afterLatest].sort(
|
|
75
|
-
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
|
|
76
|
-
);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** Back link on a news article under /posts/ (archive vs main list; same cut-off as LATEST_NEWS_MAX). */
|
|
80
|
-
export function newsDetailBackLink(
|
|
81
|
-
post: CollectionEntry<'posts'>,
|
|
82
|
-
publishedPosts: CollectionEntry<'posts'>[]
|
|
83
|
-
): { href: string; label: string } {
|
|
84
|
-
if (post.data.status === 'archived') {
|
|
85
|
-
return { href: '/posts/archive/', label: '← Back to archive' };
|
|
86
|
-
}
|
|
87
|
-
const sorted = [...publishedPosts].sort(
|
|
88
|
-
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
|
|
89
|
-
);
|
|
90
|
-
const topIds = new Set(sorted.slice(0, LATEST_NEWS_MAX).map((p) => p.id));
|
|
91
|
-
if (topIds.has(post.id)) {
|
|
92
|
-
return { href: '/posts/', label: '← Back to all news' };
|
|
93
|
-
}
|
|
94
|
-
return { href: '/posts/archive/', label: '← Back to archive' };
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* A body-less post with an attachment (the typical newsletter shape) has
|
|
99
|
-
* nothing to show on a `/posts/<slug>/` detail page, so its card/row should
|
|
100
|
-
* open the attachment directly instead of linking internally. An ordinary
|
|
101
|
-
* post that happens to also carry an attachment still opens its detail page
|
|
102
|
-
* (where the attachment is offered as a separate link) since there's a body
|
|
103
|
-
* worth reading first.
|
|
104
|
-
*/
|
|
105
|
-
export function opensAttachmentDirectly(entry: PostEntry & { body?: string }): boolean {
|
|
106
|
-
return Boolean(entry.data.attachment) && !entry.body?.trim();
|
|
107
|
-
}
|
package/lib/mailLinks.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Build a single well-formed mailto URI for use in `<a href>`.
|
|
3
|
-
* Strips duplicated `mailto:` prefixes (CMS / paste errors) without
|
|
4
|
-
* rewriting valid `?subject=` / `&body=` query pairs on the remainder.
|
|
5
|
-
*/
|
|
6
|
-
export function normalizeMailtoHref(raw: string): string {
|
|
7
|
-
let s = raw.trim();
|
|
8
|
-
while (s.toLowerCase().startsWith('mailto:')) {
|
|
9
|
-
s = s.slice(7).trimStart();
|
|
10
|
-
}
|
|
11
|
-
if (!s) return '';
|
|
12
|
-
return `mailto:${s}`;
|
|
13
|
-
}
|
package/lib/mapsLink.ts
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Free-form maps query → real URL. encodeURIComponent for safety; the live
|
|
3
|
-
* site has historically used `+` for spaces — both are equivalent to Google.
|
|
4
|
-
*/
|
|
5
|
-
export function mapsLink(query: string): string {
|
|
6
|
-
return `https://maps.google.com/?q=${encodeURIComponent(query)}`;
|
|
7
|
-
}
|
package/utils/excerpt.ts
DELETED
|
@@ -1,56 +0,0 @@
|
|
|
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
|
-
}
|
package/utils/formatDate.ts
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
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
|
-
}
|