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
@@ -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](url) → 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
- }
@@ -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
- }
@@ -1,5 +0,0 @@
1
- export const FRESH_WINDOW_MS = 3 * 24 * 60 * 60 * 1000;
2
-
3
- export function isFreshPost(date: Date, now: number = Date.now()): boolean {
4
- return now - date.valueOf() < FRESH_WINDOW_MS;
5
- }