ferst-core 0.5.2 → 0.6.1

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,330 @@
1
+ #!/usr/bin/env node
2
+ // ferst-update — bring a seeded thin-client repo up to date, from the repo alone:
3
+ //
4
+ // 1. latest template SCAFFOLD (via git: overwrite the template-owned paths)
5
+ // 2. latest ENGINE (ferst-core) (via npm: install @latest, align coreRange)
6
+ // 3. VERIFY (npm run build && npm run check:thin)
7
+ //
8
+ // Run it in a client repo root:
9
+ //
10
+ // npx --no-install ferst-update # sync scaffold + bump core + verify
11
+ // npx --no-install ferst-update --dry-run # fetch + show what WOULD change; no writes
12
+ // npx --no-install ferst-update --no-core # scaffold sync only; skip the npm bump
13
+ //
14
+ // The template-owned paths and the git upstream come from the repo's own
15
+ // `ferst-template.json` (the ownership manifest). Client paths are NEVER touched —
16
+ // the scaffold sync is a per-path OVERWRITE from upstream, conflict-free by
17
+ // construction (see docs/CLIENT-TEMPLATE-ARCHITECTURE.md, Workstream D). It never
18
+ // merges/rebases (the CMS bot commits to client repos continuously; a history merge
19
+ // would race live content).
20
+ //
21
+ // On a failed build/check after a `@latest` bump the tree is LEFT AS-IS and the exit
22
+ // is non-zero: a failed latest bump is a real incompatibility to surface for a
23
+ // human/agent to fix, not to hide behind a silent revert.
24
+ import { readFileSync, writeFileSync, realpathSync } from 'node:fs';
25
+ import { execSync } from 'node:child_process';
26
+ import { resolve } from 'node:path';
27
+ import { pathToFileURL } from 'node:url';
28
+
29
+ /**
30
+ * True when this file is being run as a CLI (not imported). Handles npm's
31
+ * `node_modules/.bin` **symlink**: there `process.argv[1]` is the symlink path but
32
+ * `import.meta.url` is the realpath, so a plain `===` check silently skips `main()`
33
+ * — the bug this guards against.
34
+ */
35
+ export function isDirectInvocation(argv1) {
36
+ if (!argv1) return false;
37
+ if (import.meta.url === pathToFileURL(argv1).href) return true;
38
+ try {
39
+ return import.meta.url === pathToFileURL(realpathSync(argv1)).href;
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ /** Fallback template upstream when the manifest doesn't pin one (the template is the SSOT). */
46
+ export const DEFAULT_UPSTREAM = 'https://github.com/MyAI4WebDev/ferst-client-template.git';
47
+ /** The ownership manifest a seeded client repo carries at its root. */
48
+ export const MANIFEST = 'ferst-template.json';
49
+
50
+ // ── pure helpers (unit-tested; no shell, no fs writes) ───────────────────────────
51
+
52
+ /** Parse the flags this bin understands. Pass `process.argv`. */
53
+ export function parseArgs(argv) {
54
+ const args = argv.slice(2);
55
+ return {
56
+ dryRun: args.includes('--dry-run'),
57
+ noCore: args.includes('--no-core'),
58
+ help: args.includes('--help') || args.includes('-h'),
59
+ };
60
+ }
61
+
62
+ /** Parse + shallow-validate the manifest JSON. Throws with a clear message. */
63
+ export function parseManifest(raw) {
64
+ let obj;
65
+ try {
66
+ obj = JSON.parse(raw);
67
+ } catch (e) {
68
+ throw new Error(`ferst-update: ${MANIFEST} is not valid JSON — ${e.message}`);
69
+ }
70
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
71
+ throw new Error(`ferst-update: ${MANIFEST} must be a JSON object.`);
72
+ }
73
+ return obj;
74
+ }
75
+
76
+ /** The template-owned paths to overwrite from upstream. Throws if missing/empty. */
77
+ export function resolveOwnedPaths(manifest) {
78
+ const owned = manifest.ownedPaths;
79
+ if (!Array.isArray(owned) || owned.length === 0) {
80
+ throw new Error(`ferst-update: ${MANIFEST} declares no "ownedPaths" — nothing to sync.`);
81
+ }
82
+ if (owned.some((p) => typeof p !== 'string' || !p.trim())) {
83
+ throw new Error(`ferst-update: ${MANIFEST} "ownedPaths" must all be non-empty strings.`);
84
+ }
85
+ return owned;
86
+ }
87
+
88
+ /**
89
+ * The template git URL to sync the scaffold from. Uses the manifest's `upstream`
90
+ * when present; otherwise falls back to the canonical template repo and flags it so
91
+ * the caller can warn (older templates predate the `upstream` field).
92
+ * @returns {{ url: string, defaulted: boolean }}
93
+ */
94
+ export function resolveUpstream(manifest) {
95
+ const u = manifest.upstream;
96
+ if (typeof u === 'string' && u.trim()) return { url: u.trim(), defaulted: false };
97
+ return { url: DEFAULT_UPSTREAM, defaulted: true };
98
+ }
99
+
100
+ /** The caret range that pins an installed version to its major.minor (e.g. 0.6.3 → ^0.6). */
101
+ export function coreRangeFor(version) {
102
+ const m = String(version).trim().replace(/^v/, '').match(/^(\d+)\.(\d+)/);
103
+ if (!m) throw new Error(`ferst-update: could not parse ferst-core version "${version}".`);
104
+ return `^${m[1]}.${m[2]}`;
105
+ }
106
+
107
+ /** Read the `version` out of a package.json's text. Throws if absent. */
108
+ export function extractVersion(pkgJsonText) {
109
+ let v;
110
+ try {
111
+ v = JSON.parse(pkgJsonText)?.version;
112
+ } catch (e) {
113
+ throw new Error(`ferst-update: could not parse installed package.json — ${e.message}`);
114
+ }
115
+ if (typeof v !== 'string' || !v.trim()) {
116
+ throw new Error('ferst-update: installed ferst-core has no version.');
117
+ }
118
+ return v.trim();
119
+ }
120
+
121
+ /**
122
+ * Decide whether/what the manifest's `coreRange` should become for a freshly
123
+ * installed version. Pure: the write is the caller's job.
124
+ * @returns {{ next: string, changed: boolean }}
125
+ */
126
+ export function computeCoreRangeUpdate(currentRange, installedVersion) {
127
+ const next = coreRangeFor(installedVersion);
128
+ return { next, changed: currentRange !== next };
129
+ }
130
+
131
+ /**
132
+ * Replace the manifest's `coreRange` value in place, preserving the file's exact
133
+ * formatting/comments (regex, like ferst-set-cms-branch stamps `branch:`). Returns
134
+ * the new text, or null when there is no `coreRange` field to replace.
135
+ */
136
+ export function replaceCoreRange(raw, next) {
137
+ const RANGE = /("coreRange"\s*:\s*")[^"]*(")/;
138
+ if (!RANGE.test(raw)) return null;
139
+ return raw.replace(RANGE, `$1${next}$2`);
140
+ }
141
+
142
+ /**
143
+ * The default branch name from `git ls-remote --symref <remote> HEAD` output
144
+ * (`ref: refs/heads/<branch>\tHEAD`). Null when it can't be read. Pure.
145
+ */
146
+ export function parseDefaultBranch(symrefOutput) {
147
+ if (!symrefOutput) return null;
148
+ const m = symrefOutput.match(/ref:\s+refs\/heads\/(\S+)\s+HEAD/);
149
+ return m ? m[1] : null;
150
+ }
151
+
152
+ /** The last `n` lines of a (possibly huge) command output, for a concise failure tail. */
153
+ export function tail(text, n = 40) {
154
+ const lines = String(text ?? '').replace(/\s+$/, '').split(/\r?\n/);
155
+ return lines.slice(-n).join('\n');
156
+ }
157
+
158
+ // ── thin side-effecting shell (kept out of the pure helpers above) ────────────────
159
+
160
+ const exec = (cmd, cwd) => execSync(cmd, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
161
+ const tryExec = (cmd, cwd) => { try { return exec(cmd, cwd); } catch { return null; } };
162
+ const git = (args, cwd) => exec(`git ${args}`, cwd);
163
+ const tryGit = (args, cwd) => tryExec(`git ${args}`, cwd);
164
+ const q = (s) => `"${s}"`;
165
+
166
+ /** Run a command capturing combined stdout+stderr and the exit code (never throws). */
167
+ function capture(cmd, cwd) {
168
+ try {
169
+ return { code: 0, out: exec(cmd, cwd) };
170
+ } catch (e) {
171
+ return { code: typeof e.status === 'number' ? e.status : 1, out: `${e.stdout ?? ''}${e.stderr ?? ''}` };
172
+ }
173
+ }
174
+
175
+ /** The ferst-core version currently installed in the client's node_modules, or null. */
176
+ function readInstalledCoreVersion(cwd) {
177
+ try {
178
+ return extractVersion(readFileSync(resolve(cwd, 'node_modules/ferst-core/package.json'), 'utf8'));
179
+ } catch {
180
+ return null;
181
+ }
182
+ }
183
+
184
+ /** Ensure an `upstream` remote points at `url` (add, or set-url if it already exists). */
185
+ function ensureUpstream(url, cwd) {
186
+ const remotes = (tryGit('remote', cwd) || '').split(/\r?\n/).map((s) => s.trim());
187
+ if (remotes.includes('upstream')) git(`remote set-url upstream ${q(url)}`, cwd);
188
+ else git(`remote add upstream ${q(url)}`, cwd);
189
+ }
190
+
191
+ function printHelp() {
192
+ console.log(`ferst-update — bring a seeded thin-client repo up to date (scaffold + ferst-core).
193
+
194
+ Usage (run in a client repo root):
195
+ ferst-update sync template scaffold, bump ferst-core@latest, then build + check:thin
196
+ ferst-update --dry-run fetch upstream and show what WOULD change; make no writes
197
+ ferst-update --no-core scaffold sync only; skip the ferst-core npm bump
198
+ ferst-update --help show this help
199
+
200
+ Reads ferst-template.json for the upstream template URL and the template-owned paths.
201
+ Client paths are never touched. On build/check failure the tree is left as-is (exit 1).`);
202
+ }
203
+
204
+ function main() {
205
+ const { dryRun, noCore, help } = parseArgs(process.argv);
206
+ if (help) { printHelp(); return; }
207
+ const cwd = process.cwd();
208
+
209
+ // 1. Read + validate the ownership manifest.
210
+ const manifestPath = resolve(cwd, MANIFEST);
211
+ let raw;
212
+ try {
213
+ raw = readFileSync(manifestPath, 'utf8');
214
+ } catch {
215
+ console.error(`ferst-update: no ${MANIFEST} in ${cwd} — run this in a seeded client repo root.`);
216
+ process.exit(1);
217
+ }
218
+ let manifest, ownedPaths, upstream;
219
+ try {
220
+ manifest = parseManifest(raw);
221
+ ownedPaths = resolveOwnedPaths(manifest);
222
+ upstream = resolveUpstream(manifest);
223
+ } catch (e) {
224
+ console.error(String(e.message ?? e));
225
+ process.exit(1);
226
+ }
227
+ if (upstream.defaulted) {
228
+ console.warn(`ferst-update: ${MANIFEST} has no "upstream" — defaulting to ${DEFAULT_UPSTREAM}`);
229
+ }
230
+
231
+ // 2. Git scaffold sync — overwrite the template-owned paths from upstream's default
232
+ // branch. Never a merge/rebase; path-checkout works across unrelated histories.
233
+ try {
234
+ ensureUpstream(upstream.url, cwd);
235
+ console.log(`ferst-update: fetching ${upstream.url} ...`);
236
+ git('fetch upstream', cwd);
237
+ } catch (e) {
238
+ console.error(`ferst-update: git fetch failed — ${tail(e.stderr || e.message, 10)}`);
239
+ process.exit(1);
240
+ }
241
+ const branch = parseDefaultBranch(tryGit('ls-remote --symref upstream HEAD', cwd)) || 'main';
242
+ console.log(`ferst-update: upstream default branch = ${branch}`);
243
+
244
+ let scaffoldCount = 0; // files the scaffold sync touched (counted BEFORE the npm churn)
245
+ if (dryRun) {
246
+ const spec = ownedPaths.map(q).join(' ');
247
+ const diff = (tryGit(`diff --stat upstream/${branch} -- ${spec}`, cwd) || '').trim();
248
+ console.log('ferst-update: --dry-run — scaffold changes that WOULD apply:');
249
+ console.log(diff ? diff : ' (no scaffold changes)');
250
+ } else {
251
+ for (const p of ownedPaths) {
252
+ // Per-path so one path that upstream no longer ships (pathspec no-match) can't
253
+ // abort the whole sync — warn and continue.
254
+ if (tryGit(`checkout upstream/${branch} -- ${q(p)}`, cwd) === null) {
255
+ console.warn(`ferst-update: could not sync "${p}" (not present upstream?) — skipped.`);
256
+ }
257
+ }
258
+ const status = (tryGit('status --short', cwd) || '').trim();
259
+ scaffoldCount = status ? status.split(/\r?\n/).filter(Boolean).length : 0;
260
+ console.log('ferst-update: scaffold synced. git status --short:');
261
+ console.log(status ? status : ' (working tree clean — scaffold already current)');
262
+ }
263
+
264
+ // 3. Engine (ferst-core) update.
265
+ let coreBump = null; // { before, after } once bumped
266
+ if (noCore) {
267
+ console.log('ferst-update: --no-core — skipping the ferst-core npm bump.');
268
+ } else if (dryRun) {
269
+ const latest = (tryExec('npm view ferst-core version', cwd) || '').trim();
270
+ const current = readInstalledCoreVersion(cwd);
271
+ console.log(`ferst-update: ferst-core installed=${current ?? '(none)'} latest=${latest || '(unknown)'}`);
272
+ } else {
273
+ const before = readInstalledCoreVersion(cwd);
274
+ console.log('ferst-update: npm install ferst-core@latest --save ...');
275
+ const r = capture('npm install ferst-core@latest --save', cwd);
276
+ if (r.code !== 0) {
277
+ console.error(`ferst-update: FAILED at "npm install ferst-core@latest" (exit ${r.code}).`);
278
+ console.error(tail(r.out, 40));
279
+ process.exit(1);
280
+ }
281
+ const after = readInstalledCoreVersion(cwd);
282
+ if (!after) {
283
+ console.error('ferst-update: ferst-core not found in node_modules after install.');
284
+ process.exit(1);
285
+ }
286
+ coreBump = { before, after };
287
+
288
+ // Align the manifest's coreRange to the installed major.minor.
289
+ const currentRange = typeof manifest.coreRange === 'string' ? manifest.coreRange : '';
290
+ const { next, changed } = computeCoreRangeUpdate(currentRange, after);
291
+ if (changed) {
292
+ const rewritten = replaceCoreRange(raw, next);
293
+ if (rewritten) {
294
+ writeFileSync(manifestPath, rewritten);
295
+ console.log(`ferst-update: coreRange ${currentRange || '(unset)'} → ${next}`);
296
+ } else {
297
+ console.warn(`ferst-update: no "coreRange" field in ${MANIFEST} to update (installed ${after}).`);
298
+ }
299
+ }
300
+ }
301
+
302
+ // 4. Verify — a failed @latest bump is a real incompatibility to surface, so on
303
+ // failure we print the tail and exit non-zero WITHOUT reverting.
304
+ if (dryRun) {
305
+ console.log('ferst-update: --dry-run complete (no writes, no verify).');
306
+ return;
307
+ }
308
+ for (const step of ['build', 'check:thin']) {
309
+ console.log(`ferst-update: npm run ${step} ...`);
310
+ const r = capture(`npm run ${step}`, cwd);
311
+ if (r.code !== 0) {
312
+ console.error(`ferst-update: FAILED at "npm run ${step}" (exit ${r.code}). Tree left as-is for a human/agent to fix.`);
313
+ console.error(tail(r.out, 40));
314
+ process.exit(1);
315
+ }
316
+ }
317
+
318
+ // 5. Summary.
319
+ const coreLine = coreBump
320
+ ? `ferst-core ${coreBump.before ?? '(none)'} → ${coreBump.after}`
321
+ : 'ferst-core unchanged (--no-core)';
322
+ console.log(
323
+ `ferst-update: DONE — scaffold files updated: ${scaffoldCount}; ${coreLine}; build + check:thin green.`,
324
+ );
325
+ }
326
+
327
+ // Run when invoked directly as a bin — including via npm's node_modules/.bin symlink.
328
+ if (isDirectInvocation(process.argv[1])) {
329
+ main();
330
+ }
@@ -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.1",
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",
@@ -50,7 +50,8 @@
50
50
  "bin": {
51
51
  "ferst-check-thin": "bin/check-thin.mjs",
52
52
  "ferst-set-cms-branch": "bin/set-cms-branch.mjs",
53
- "ferst-cms-config": "bin/cms-config.mjs"
53
+ "ferst-cms-config": "bin/cms-config.mjs",
54
+ "ferst-update": "bin/ferst-update.mjs"
54
55
  },
55
56
  "dependencies": {
56
57
  "zod": "^3.25.0"
@@ -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>