ferst-core 0.5.2 → 0.6.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.
@@ -60,25 +60,37 @@ export function repoSlug() {
60
60
  return '';
61
61
  }
62
62
 
63
+ export const PROD_GIT_BASE = 'https://signin.ferst.co.uk';
64
+ export const DEV_GIT_BASE = 'https://signin-dev.ferst.co.uk';
65
+
63
66
  /**
64
- * The platform git-proxy origin (CMS `base_url` / `api_root`) for THIS site:
65
- * FERST_GIT_BASE env → committed `ferst-site.json` `gitBase` → prod default.
66
- * The seeder writes `ferst-site.json` with the origin of the worker that provisioned
67
- * the site, so a dev-provisioned site points its CMS backend at the dev worker and a
68
- * prod one at prod — the "site respects the portal it was generated from" binding,
69
- * restored at build time.
67
+ * The platform git-proxy origin (CMS `base_url` / `api_root`) for THIS build:
68
+ * 1. `FERST_GIT_BASE` env — explicit override, always wins.
69
+ * 2. committed `ferst-site.json` `gitBase` — an explicit per-site pin (e.g. a
70
+ * custom proxy); a BYO/seeded site can hard-bind its origin this way.
71
+ * 3. otherwise DERIVE from the deploy environment: the production branch talks to
72
+ * the prod git-proxy, every other (preview/dev) branch talks to the dev
73
+ * git-proxy. So a templated repo self-configures per environment on deploy — a
74
+ * dev-branch build points its CMS at the dev worker and main at prod — with NO
75
+ * per-client config. (The production branch defaults to `main`; override via
76
+ * `FERST_PROD_BRANCH` env or `ferst-site.json` `productionBranch`.)
77
+ * This restores the "site respects the environment it's deployed in" binding the old
78
+ * per-branch site config gave, without a per-client file to maintain.
70
79
  */
71
- export function resolveGitBase(cwd = process.cwd()) {
80
+ export function resolveGitBase(cwd = process.cwd(), branch = deploymentBranch()) {
72
81
  const fromEnv = env('FERST_GIT_BASE');
73
82
  if (fromEnv) return fromEnv;
83
+ let prodBranch = env('FERST_PROD_BRANCH') || 'main';
74
84
  try {
75
- const raw = readFileSync(resolve(cwd, 'ferst-site.json'), 'utf8');
76
- const site = JSON.parse(raw);
85
+ const site = JSON.parse(readFileSync(resolve(cwd, 'ferst-site.json'), 'utf8'));
77
86
  if (site && typeof site.gitBase === 'string' && site.gitBase.trim()) return site.gitBase.trim();
87
+ if (!env('FERST_PROD_BRANCH') && site && typeof site.productionBranch === 'string' && site.productionBranch.trim()) {
88
+ prodBranch = site.productionBranch.trim();
89
+ }
78
90
  } catch {
79
- /* no ferst-site.json (e.g. the template repo itself) — fall through to the default */
91
+ /* no ferst-site.json (e.g. the template repo itself) — derive from the branch */
80
92
  }
81
- return 'https://signin.ferst.co.uk';
93
+ return branch === prodBranch ? PROD_GIT_BASE : DEV_GIT_BASE;
82
94
  }
83
95
 
84
96
  /** The deployment branch: CF Pages env → current git branch → main. */
@@ -123,9 +135,9 @@ function main() {
123
135
  console.error('ferst-cms-config: could not determine the repo — set GITHUB_REPOSITORY=owner/name.');
124
136
  process.exit(1);
125
137
  }
126
- // The platform git-proxy origin for this site (env → ferst-site.json → prod default).
127
- const base = resolveGitBase();
138
+ // Branch first, so the git-proxy origin can be derived from the deploy environment.
128
139
  const branch = deploymentBranch();
140
+ const base = resolveGitBase(process.cwd(), branch);
129
141
 
130
142
  let tpl;
131
143
  try {
@@ -0,0 +1,52 @@
1
+ ---
2
+ /**
3
+ * NewsList — a responsive grid of PostCards, shared by the posts index and tag
4
+ * pages. Promoted into core (from a client's src/custom/) so the ferst()
5
+ * integration can inject the blog routes for every client. Presentational: it
6
+ * renders whatever posts it is given (the route decides which — index, or a
7
+ * single tag). Core ships PostCard; this arranges them.
8
+ */
9
+ import PostCard from './PostCard.astro';
10
+ import { postLink } from '../content/posts';
11
+
12
+ interface PostItem {
13
+ slug: string;
14
+ body?: string | null;
15
+ data: { title: string; date: Date | string; summary?: string; heroImage?: string; tags?: string[]; attachment?: string };
16
+ }
17
+ interface Props {
18
+ posts: PostItem[];
19
+ empty?: string;
20
+ }
21
+ const { posts, empty = 'No posts yet.' } = Astro.props;
22
+ ---
23
+
24
+ {posts.length === 0 ? (
25
+ <p class="news-empty">{empty}</p>
26
+ ) : (
27
+ <div class="news-grid">
28
+ {posts.map((p) => {
29
+ // A post that is just an attached PDF (no body) links straight to the file.
30
+ const { href, newTab } = postLink(p);
31
+ return (
32
+ <PostCard
33
+ href={href}
34
+ newTab={newTab}
35
+ moreLabel={newTab ? 'Open PDF' : undefined}
36
+ title={p.data.title}
37
+ date={p.data.date}
38
+ summary={p.data.summary}
39
+ heroImage={p.data.heroImage}
40
+ tagLabels={p.data.tags}
41
+ />
42
+ );
43
+ })}
44
+ </div>
45
+ )}
46
+
47
+ <style>
48
+ .news-grid { display: grid; gap: 1.5rem; grid-template-columns: 1fr; }
49
+ @media (min-width: 40em) { .news-grid { grid-template-columns: repeat(2, 1fr); } }
50
+ @media (min-width: 60em) { .news-grid { grid-template-columns: repeat(3, 1fr); } }
51
+ .news-empty { color: var(--muted); }
52
+ </style>
@@ -0,0 +1,73 @@
1
+ ---
2
+ /**
3
+ * PostsFilter — a row of tag chips linking to each tag's page
4
+ * (/posts/tag/<slug>), with "All" back to /posts; the active tag is marked
5
+ * aria-current. No JS. Promoted into core (from a client's src/custom/) so the
6
+ * ferst() integration can inject the blog routes. Feed it core's getUsedTags
7
+ * (visible tags that actually have posts, with counts).
8
+ */
9
+ interface Tag {
10
+ slug: string;
11
+ label: string;
12
+ count: number;
13
+ }
14
+ interface Props {
15
+ tags: Tag[];
16
+ /** The active tag slug; omit on the main index ("All"). */
17
+ active?: string;
18
+ }
19
+ const { tags, active } = Astro.props;
20
+ ---
21
+
22
+ {tags.length > 0 && (
23
+ <nav class="posts-filter" aria-label="Filter posts by tag">
24
+ <a class:list={['posts-filter__chip', { 'is-active': !active }]} href="/posts" aria-current={!active ? 'page' : undefined}>
25
+ All
26
+ </a>
27
+ {tags.map((t) => (
28
+ <a
29
+ class:list={['posts-filter__chip', { 'is-active': active === t.slug }]}
30
+ href={`/posts/tag/${t.slug}`}
31
+ aria-current={active === t.slug ? 'page' : undefined}
32
+ >
33
+ {t.label}<span class="posts-filter__count">{t.count}</span>
34
+ </a>
35
+ ))}
36
+ </nav>
37
+ )}
38
+
39
+ <style>
40
+ .posts-filter {
41
+ display: flex;
42
+ flex-wrap: wrap;
43
+ gap: 0.5rem;
44
+ }
45
+ .posts-filter__chip {
46
+ display: inline-flex;
47
+ align-items: center;
48
+ gap: 0.4rem;
49
+ padding: 0.4rem 0.9rem;
50
+ border: var(--card-border);
51
+ border-radius: var(--radius-pill);
52
+ background: var(--bg-surface);
53
+ color: var(--accent);
54
+ font-family: var(--font-heading);
55
+ font-size: 0.85rem;
56
+ font-weight: 600;
57
+ text-decoration: none;
58
+ transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease;
59
+ }
60
+ .posts-filter__chip:hover {
61
+ border-color: var(--gold);
62
+ color: var(--gold);
63
+ }
64
+ .posts-filter__chip.is-active {
65
+ background: var(--gold);
66
+ border-color: var(--gold);
67
+ color: var(--text-light);
68
+ }
69
+ .posts-filter__count {
70
+ font-size: 0.75em;
71
+ opacity: 0.7;
72
+ }
73
+ </style>
package/integration.mjs CHANGED
@@ -2,26 +2,30 @@
2
2
  * ferst() — the Astro integration that makes a client site's ROUTING package-owned.
3
3
  *
4
4
  * A thin client adds `integrations: [ferst()]` to astro.config.mjs and carries
5
- * ZERO page files: the page catch-all (which also owns the home page) and the
6
- * calendar route are injected from ferst-core. A routing fix then ships as a
7
- * package bump + rebuild — the same channel component fixes travel through — instead
8
- * of being hand-copied into every client repo.
5
+ * ZERO page files: the page catch-all (which also owns the home page), the
6
+ * calendar route and the blog routes are injected from ferst-core. A routing fix
7
+ * then ships as a package bump + rebuild — the same channel component fixes travel
8
+ * through — instead of being hand-copied into every client repo.
9
9
  *
10
10
  * Irreducible caveat (Astro 5): content collections must be defined in the CONSUMER,
11
11
  * so the one-line `src/content/config.ts` re-export stays. "Zero page files" is
12
12
  * reachable; "zero files" is not. Explicit client `.astro` routes still win over
13
13
  * these injected ones, so a one-off hand-built page is always possible.
14
14
  *
15
- * Blog routes (news index / posts / tags) are deliberately NOT injected yet — the
16
- * listing component (NewsList) is not in the package. They join once it is promoted
17
- * to core; individual routes would otherwise exist with no listing to reach them.
15
+ * The blog routes (posts index / individual post / tag pages) inject as a set:
16
+ * the listing components (NewsList, PostsFilter) now live in core, so `/posts`,
17
+ * `/posts/[...slug]` and `/posts/tag/[...tag]` all ship from the package. Turn
18
+ * the whole capability off with `blog: false` (then a client wanting posts hand-
19
+ * builds its own routes over `ferst-core/lib/posts`).
18
20
  *
19
21
  * @param {object} [options]
20
22
  * @param {boolean} [options.calendar=true] Inject the `/calendar` route.
23
+ * @param {boolean} [options.blog=true] Inject the blog routes (`/posts`,
24
+ * `/posts/[...slug]`, `/posts/tag/[...tag]`).
21
25
  * @returns {import('astro').AstroIntegration}
22
26
  */
23
27
  export default function ferst(options = {}) {
24
- const { calendar = true } = options;
28
+ const { calendar = true, blog = true } = options;
25
29
  return {
26
30
  name: 'ferst',
27
31
  hooks: {
@@ -38,6 +42,22 @@ export default function ferst(options = {}) {
38
42
  entrypoint: 'ferst-core/routes/calendar/index.astro',
39
43
  });
40
44
  }
45
+ if (blog) {
46
+ // The posts capability: index, individual post, and tag pages. Reads
47
+ // the `posts`/`tags`/`postsSettings` collections (content/collections.ts).
48
+ injectRoute({
49
+ pattern: '/posts',
50
+ entrypoint: 'ferst-core/routes/posts/index.astro',
51
+ });
52
+ injectRoute({
53
+ pattern: '/posts/[...slug]',
54
+ entrypoint: 'ferst-core/routes/posts/[...slug].astro',
55
+ });
56
+ injectRoute({
57
+ pattern: '/posts/tag/[...tag]',
58
+ entrypoint: 'ferst-core/routes/posts/tag/[...tag].astro',
59
+ });
60
+ }
41
61
  },
42
62
  },
43
63
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ferst-core",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Ferst Core — the shared, brand-agnostic client-site system: Astro components, layouts, content schemas, the layered config resolver, and a neutral styling architecture that every client overrides.",
6
6
  "license": "BUSL-1.1",
@@ -0,0 +1,31 @@
1
+ ---
2
+ /**
3
+ * An individual post (`/posts/<slug>`) — INJECTED by the ferst() integration
4
+ * (blog option). Renders the markdown body via core's PostArticle; tags link to
5
+ * `/posts/tag/<slug>` to match the injected tag pages.
6
+ */
7
+ import Base from '../../layouts/Base.astro';
8
+ import PostArticle from '../../components/PostArticle.astro';
9
+ import { getPostRoutes, resolveTagLabels } from '../../lib/posts';
10
+
11
+ export const getStaticPaths = getPostRoutes;
12
+
13
+ const { entry } = Astro.props;
14
+ const { Content } = await entry.render();
15
+ const tags = await resolveTagLabels(entry.data.tags);
16
+ ---
17
+
18
+ <Base title={entry.data.title} description={entry.data.summary} heroImage={entry.data.heroImage}>
19
+ <PostArticle
20
+ title={entry.data.title}
21
+ date={entry.data.date}
22
+ heroImage={entry.data.heroImage}
23
+ attachment={entry.data.attachment}
24
+ tags={tags}
25
+ tagBase="/posts/tag"
26
+ backHref="/posts"
27
+ backLabel="All posts"
28
+ >
29
+ <Content />
30
+ </PostArticle>
31
+ </Base>
@@ -0,0 +1,31 @@
1
+ ---
2
+ /**
3
+ * Posts index (`/posts`) — INJECTED by the ferst() integration (blog option).
4
+ * The news / notices listing. Excludes any tag in postsSettings.excludeFromIndex
5
+ * (e.g. a newsletter series that has its own tag page). Content, not code — a
6
+ * client carries the post markdown + tag data; this route ships from core.
7
+ */
8
+ import Base from '../../layouts/Base.astro';
9
+ import NewsList from '../../components/NewsList.astro';
10
+ import PostsFilter from '../../components/PostsFilter.astro';
11
+ import { getIndexPosts, getPostsSettings, getUsedTags } from '../../lib/posts';
12
+
13
+ const [posts, settings, tags] = await Promise.all([getIndexPosts(), getPostsSettings(), getUsedTags()]);
14
+ ---
15
+
16
+ <Base title={settings.title} description={settings.intro || undefined}>
17
+ <section class="posts">
18
+ <header class="posts__head">
19
+ <h1>{settings.title}</h1>
20
+ {settings.intro && <p class="t-lead">{settings.intro}</p>}
21
+ </header>
22
+ <PostsFilter tags={tags} />
23
+ <NewsList posts={posts} empty="No posts yet." />
24
+ </section>
25
+ </Base>
26
+
27
+ <style>
28
+ .posts { display: flex; flex-direction: column; gap: 2rem; }
29
+ .posts__head { display: flex; flex-direction: column; gap: 0.5rem; }
30
+ .posts__head .t-lead { color: var(--muted); }
31
+ </style>
@@ -0,0 +1,37 @@
1
+ ---
2
+ /**
3
+ * A tag page (`/posts/tag/<slug>`) — INJECTED by the ferst() integration (blog
4
+ * option). All posts carrying one tag. This is also how an excluded-from-index
5
+ * series (e.g. a newsletter) gets its own page.
6
+ */
7
+ import Base from '../../../layouts/Base.astro';
8
+ import NewsList from '../../../components/NewsList.astro';
9
+ import PostsFilter from '../../../components/PostsFilter.astro';
10
+ import { getTagRoutes, getPostsByTag, getUsedTags } from '../../../lib/posts';
11
+
12
+ export const getStaticPaths = getTagRoutes;
13
+
14
+ const { tag } = Astro.props;
15
+ const [posts, tags] = await Promise.all([getPostsByTag(tag.slug), getUsedTags()]);
16
+ ---
17
+
18
+ <Base title={tag.label} description={tag.description || undefined}>
19
+ <section class="posts">
20
+ <header class="posts__head">
21
+ <p class="posts__eyebrow">Tag</p>
22
+ <h1>{tag.label}</h1>
23
+ {tag.description && <p class="t-lead">{tag.description}</p>}
24
+ </header>
25
+ <PostsFilter tags={tags} active={tag.slug} />
26
+ <NewsList posts={posts} />
27
+ <a class="posts__back" href="/posts">&larr; All posts</a>
28
+ </section>
29
+ </Base>
30
+
31
+ <style>
32
+ .posts { display: flex; flex-direction: column; gap: 2rem; }
33
+ .posts__head { display: flex; flex-direction: column; gap: 0.35rem; }
34
+ .posts__eyebrow { margin: 0; font-family: var(--font-heading); font-size: 0.75rem; font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase; color: var(--gold); }
35
+ .posts__head .t-lead { color: var(--muted); }
36
+ .posts__back { font-weight: 600; color: var(--gold); }
37
+ </style>