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.
- package/bin/cms-config.mjs +25 -13
- package/components/NewsList.astro +52 -0
- package/components/PostsFilter.astro +73 -0
- package/integration.mjs +28 -8
- package/package.json +1 -1
- package/routes/posts/[...slug].astro +31 -0
- package/routes/posts/index.astro +31 -0
- package/routes/posts/tag/[...tag].astro +37 -0
package/bin/cms-config.mjs
CHANGED
|
@@ -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
|
|
65
|
-
* FERST_GIT_BASE env
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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
|
|
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) —
|
|
91
|
+
/* no ferst-site.json (e.g. the template repo itself) — derive from the branch */
|
|
80
92
|
}
|
|
81
|
-
return
|
|
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
|
-
//
|
|
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)
|
|
6
|
-
* calendar route are injected from ferst-core. A routing fix
|
|
7
|
-
* package bump + rebuild — the same channel component fixes travel
|
|
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
|
-
*
|
|
16
|
-
* listing
|
|
17
|
-
*
|
|
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.
|
|
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">← 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>
|