ferst-core 0.9.1 → 0.9.3

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.
@@ -108,18 +108,47 @@ export function deploymentBranch() {
108
108
  return 'main';
109
109
  }
110
110
 
111
+ /** Ferst's managed GitHub org: its sites are on the Basic plan. */
112
+ const MANAGED_OWNER = 'myai4webdev';
113
+
114
+ /** The Pages collection's description on each plan. */
115
+ const PAGES_DESCRIPTION = {
116
+ basic: "See each page and request a change — our team makes it and sends it back for you to check. You don't edit the page layout here.",
117
+ free: "You're on the free plan: you look after this site yourself and change its pages in your own repository. Asking the Ferst team to make changes for you comes with the Basic plan.",
118
+ };
119
+
120
+ /**
121
+ * The site's plan, for what its CMS offers. Requests (the Ferst team making a change for the
122
+ * site's people) come with the Basic plan: a managed site. A self-managed site, the client's
123
+ * own repo, is on the free plan and makes its own changes. The plan the platform records in
124
+ * `ferst-site.json` wins; otherwise the repo's owner decides. Unknown keeps the requests field:
125
+ * the platform refuses a free-plan site's request anyway.
126
+ * @param {string} repo owner/name
127
+ * @param {{ plan?: string }} [site] the parsed ferst-site.json
128
+ * @returns {'basic' | 'free'}
129
+ */
130
+ export function planFor(repo, site = {}) {
131
+ if (site && (site.plan === 'basic' || site.plan === 'free')) return site.plan;
132
+ const owner = String(repo ?? '').split('/')[0].trim().toLowerCase();
133
+ if (!owner) return 'basic';
134
+ return owner === MANAGED_OWNER ? 'basic' : 'free';
135
+ }
136
+
111
137
  /**
112
- * Fill the config template's placeholders. Pure — unit-testable.
138
+ * Fill the config template's placeholders. Pure — unit-testable. On the free plan the
139
+ * `# ferst:requests` … `# /ferst:requests` block (the pages' "Requested changes") is left out.
113
140
  * @param {string} tpl the template contents
114
- * @param {{ repo: string, base: string, branch: string }} vars
141
+ * @param {{ repo: string, base: string, branch: string, plan?: 'basic' | 'free' }} vars
115
142
  * @returns {string}
116
143
  */
117
- export function fillConfig(tpl, { repo, base, branch }) {
118
- const out = tpl
144
+ export function fillConfig(tpl, { repo, base, branch, plan = 'basic' }) {
145
+ const forPlan = plan === 'free' ? tpl.replace(/^[ \t]*# ferst:requests\b[\s\S]*?^[ \t]*# \/ferst:requests[^\n]*\n/m, '') : tpl;
146
+ const out = forPlan
119
147
  .split('__REPO__').join(repo)
120
148
  .split('__API_ROOT__').join(`${base}/git`)
121
149
  .split('__BASE_URL__').join(base)
122
- .split('__BRANCH__').join(branch);
150
+ .split('__BRANCH__').join(branch)
151
+ .split('__PAGES_DESCRIPTION__').join(PAGES_DESCRIPTION[plan] ?? PAGES_DESCRIPTION.basic);
123
152
  const leftover = out.match(/__[A-Z][A-Z_]*__/);
124
153
  if (leftover) {
125
154
  throw new Error(`ferst-cms-config: unfilled placeholder ${leftover[0]} — add it to the filler.`);
@@ -147,9 +176,17 @@ function main() {
147
176
  process.exit(1);
148
177
  }
149
178
 
179
+ let site = {};
180
+ try {
181
+ site = JSON.parse(readFileSync(resolve(process.cwd(), 'ferst-site.json'), 'utf8')) ?? {};
182
+ } catch {
183
+ /* no ferst-site.json: the repo's owner decides the plan */
184
+ }
185
+ const plan = planFor(repo, site);
186
+
150
187
  let out;
151
188
  try {
152
- out = fillConfig(tpl, { repo, base, branch });
189
+ out = fillConfig(tpl, { repo, base, branch, plan });
153
190
  } catch (e) {
154
191
  console.error(String(e.message ?? e));
155
192
  process.exit(1);
@@ -157,7 +194,7 @@ function main() {
157
194
 
158
195
  mkdirSync(dirname(outPath), { recursive: true });
159
196
  writeFileSync(outPath, out);
160
- console.log(`ferst-cms-config: wrote ${outPath} (repo=${repo}, branch="${branch}", base=${base})`);
197
+ console.log(`ferst-cms-config: wrote ${outPath} (repo=${repo}, branch="${branch}", base=${base}, plan=${plan})`);
161
198
  }
162
199
 
163
200
  // Run when invoked directly as a bin — including via npm's node_modules/.bin symlink.
@@ -234,7 +234,7 @@ collections:
234
234
  - name: pages
235
235
  label: Pages
236
236
  label_singular: Page
237
- description: "See each page and request a change — our team makes it and sends it back for you to check. You don't edit the page layout here."
237
+ description: "__PAGES_DESCRIPTION__"
238
238
  folder: src/content/pages
239
239
  extension: json
240
240
  format: json
@@ -244,6 +244,7 @@ collections:
244
244
  identifier_field: title
245
245
  fields:
246
246
  - { name: title, label: Page title, widget: string }
247
+ # ferst:requests (the Basic plan only: bin/cms-config.mjs leaves it out on the free plan)
247
248
  - name: requests
248
249
  label: Requested changes
249
250
  label_singular: Request
@@ -258,6 +259,7 @@ collections:
258
259
  - { name: issue, widget: hidden, required: false }
259
260
  - { name: by, widget: hidden, required: false }
260
261
  - { name: at, widget: hidden, required: false }
262
+ # /ferst:requests
261
263
  - { name: description, label: SEO description, widget: hidden, required: false }
262
264
  - { name: heroImage, widget: hidden, required: false }
263
265
  - { name: align, widget: hidden, required: false }
@@ -36,7 +36,8 @@ const siteIdentitySchema = z
36
36
  /** Town / city — appended to the brand in alt text. */
37
37
  location: z.string().default(''),
38
38
  /** What the site is for, which types its structured data for search engines
39
- * (lib/structured-data.mjs): Church, School, NGO, LocalBusiness or Organization. */
39
+ * (lib/structured-data.mjs): School, NGO, LocalBusiness or Organization. A church is an
40
+ * Organization (the parish), its churches its places. */
40
41
  kind: z.enum(['church', 'school', 'charity', 'business', 'organisation']).default('organisation'),
41
42
  })
42
43
  .default({});
package/integration.mjs CHANGED
@@ -35,16 +35,28 @@
35
35
  * `public/` win.
36
36
  * @param {boolean} [options.seo=true] Search signals (lib/seo.mjs): `/robots.txt`, and once
37
37
  * `ferst-site.json` has a `siteUrl`, `/sitemap.xml`, a canonical address on every page,
38
- * structured data and link previews, and (with the blog) `/posts/rss.xml`.
38
+ * structured data and link previews, and (with the blog) `/posts/rss.xml`. Every build's
39
+ * internal links to a page point at its own address (`/about-us/`), not at a redirect.
39
40
  * A test copy (any branch but the production one) is marked noindex on every file. A
40
41
  * site's own `public/robots.txt` or `public/sitemap.xml` wins.
41
42
  * @returns {import('astro').AstroIntegration}
42
43
  */
43
44
  import { copyFileSync, createReadStream, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
44
45
  import { deploymentBranch } from './bin/cms-config.mjs';
45
- import { seoContext, withNoindexHeader } from './lib/seo.mjs';
46
+ import { linksToPageAddresses, seoContext, withNoindexHeader } from './lib/seo.mjs';
46
47
  import { writeSiteIcons } from './lib/site-icon.mjs';
47
48
 
49
+ /** Every page of a build (its .html files, at any depth), as file URLs. */
50
+ function htmlFiles(dir) {
51
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) =>
52
+ entry.isDirectory()
53
+ ? htmlFiles(new URL(`${encodeURIComponent(entry.name)}/`, dir))
54
+ : entry.name.endsWith('.html')
55
+ ? [new URL(encodeURIComponent(entry.name), dir)]
56
+ : [],
57
+ );
58
+ }
59
+
48
60
  /** The self-hosted fonts the engine's CSS requests (/fonts/*.woff2), shipped in the package. */
49
61
  const FONTS_DIR = new URL('./fonts/', import.meta.url);
50
62
  const FONT_URL = /^\/fonts\/([a-z0-9-]+\.woff2)(?:\?.*)?$/;
@@ -197,6 +209,21 @@ export default function ferst(options = {}) {
197
209
  'astro:server:setup': fonts['astro:server:setup'],
198
210
  'astro:build:done': async (ctx) => {
199
211
  await fonts['astro:build:done'](ctx);
212
+ // Internal links point at each page's one address, never at a redirect (lib/seo.mjs).
213
+ if (seo) {
214
+ const isPage = (path) => {
215
+ try {
216
+ return existsSync(new URL(`.${path}index.html`, ctx.dir));
217
+ } catch {
218
+ return false;
219
+ }
220
+ };
221
+ for (const file of htmlFiles(ctx.dir)) {
222
+ const html = readFileSync(file, 'utf8');
223
+ const linked = linksToPageAddresses(html, isPage);
224
+ if (linked !== html) writeFileSync(file, linked);
225
+ }
226
+ }
200
227
  // A test copy is noindex on every file: pages, and the PDFs and images beside them.
201
228
  if (search && !search.production) {
202
229
  const file = new URL('_headers', ctx.dir);
@@ -32,6 +32,7 @@ import { getCalloutSettings } from '../lib/callout';
32
32
  import { buildThemeStyle } from '../lib/themeTokens';
33
33
  import { canonicalUrl, previewTags } from '../lib/seo.mjs';
34
34
  import { structuredData } from '../lib/structured-data.mjs';
35
+ import { titlesAbove } from '../lib/pages';
35
36
 
36
37
  interface Props {
37
38
  title: string;
@@ -101,9 +102,13 @@ const canonical = siteUrl && indexable ? canonicalUrl(siteUrl, path) : null;
101
102
  const noindex = !indexable || import.meta.env.FERST_SEO_PRODUCTION === false;
102
103
 
103
104
  // What the page tells search engines and link previews about the site (lib/structured-data,
104
- // lib/seo): from the site's own details, the same facts it shows visitors.
105
+ // lib/seo): from the site's own details, the same facts it shows visitors. A nested page also
106
+ // carries its breadcrumb trail, from the titles of the pages above it.
107
+ const nested = path.split('/').filter(Boolean).length >= 2;
108
+ const breadcrumbs = indexable && siteUrl && nested ? await titlesAbove(path) : {};
105
109
  const ld = indexable
106
110
  ? structuredData({
111
+ breadcrumbs,
107
112
  siteUrl: siteUrl || null,
108
113
  path,
109
114
  identity,
package/lib/pages.ts CHANGED
@@ -22,6 +22,24 @@
22
22
  * can still hand-build a one-off page beside the JSON-driven ones.
23
23
  */
24
24
  import { getCollection } from 'astro:content';
25
+ import { getPostsSettings } from './posts';
26
+
27
+ /**
28
+ * The titles of the pages above a nested address, for its breadcrumb trail
29
+ * (lib/structured-data): each address above it that is a page of its own, and the news page
30
+ * above a post. Keyed by address (`/about-us/`).
31
+ */
32
+ export async function titlesAbove(path: string): Promise<Record<string, string>> {
33
+ const segments = path.split('/').filter(Boolean);
34
+ const above = new Set(segments.slice(0, -1).map((_, i) => segments.slice(0, i + 1).join('/')));
35
+ const titles: Record<string, string> = {};
36
+ if (!above.size) return titles;
37
+ for (const entry of await getPageEntries()) {
38
+ if (above.has(entry.id) && entry.data.title) titles[`/${entry.id}/`] = entry.data.title;
39
+ }
40
+ if (above.has('posts') && !titles['/posts/']) titles['/posts/'] = (await getPostsSettings()).title;
41
+ return titles;
42
+ }
25
43
 
26
44
  /** All non-draft `pages` entries. */
27
45
  export async function getPageEntries() {
package/lib/seo.mjs CHANGED
@@ -129,6 +129,22 @@ export function withNoindexHeader(existing) {
129
129
  return `${current.replace(/\s*$/, '')}\n\n${NOINDEX_RULE}`;
130
130
  }
131
131
 
132
+ /**
133
+ * Internal links pointed at each page's one address. A page's address is its trailing-slash
134
+ * form (`/about-us/`), and Cloudflare Pages answers the slash-less form with a redirect: a link
135
+ * to `/about-us` costs every visit and crawl a hop, and search engines list the slash-less form
136
+ * as a page with a redirect. So `href="/about-us"` gains its slash, keeping any `?query` or
137
+ * `#section`, when `isPage('/about-us/')` says that page exists. Files, other sites and
138
+ * addresses that are already right are left alone.
139
+ * @param {string} html
140
+ * @param {(path: string) => boolean} isPage whether `/x/` is a page of the site
141
+ */
142
+ export function linksToPageAddresses(html, isPage) {
143
+ return String(html).replace(/(\shref=")(\/(?!\/)[^"?#]*[^"/?#])([?#][^"]*)?"/g, (match, attr, path, rest = '') =>
144
+ isPage(`${path}/`) ? `${attr}${path}/${rest}"` : match,
145
+ );
146
+ }
147
+
132
148
  /**
133
149
  * Link-preview tags (Open Graph and Twitter card) for one page: what a link shared on
134
150
  * Facebook, WhatsApp or LinkedIn shows. The picture is the page's own (its hero), else the
@@ -4,17 +4,18 @@
4
4
  * same facts the site shows visitors. Base renders it in each page's head once the site has
5
5
  * an address (its links must be absolute). Pure, so it's unit-testable.
6
6
  *
7
- * - The site is one entity (`#organization`), typed by its kind: a church with one place is
8
- * a `Church` at that address; with several places (a parish of several churches) it's an
9
- * `Organization` with each church as a `location`.
7
+ * - The site is one entity (`#organization`), typed by its kind. A church site is the parish,
8
+ * an `Organization` with each of its churches as a `location` (schema.org's `Church` is a
9
+ * building, and a building can't publish the website or write its posts); with one church,
10
+ * the parish is at that church's address. Other kinds list several places as `location`s.
10
11
  * - The website names that entity as its publisher.
11
12
  * - A post is an `Article`, dated, published by the site.
12
13
  */
13
14
  import { canonicalUrl } from './seo.mjs';
14
15
 
15
- /** The schema.org type for each kind of site (`identity.kind`). */
16
- const TYPE = { church: 'Church', school: 'School', charity: 'NGO', business: 'LocalBusiness', organisation: 'Organization' };
17
- /** The type of each of a site's places, when it has several. */
16
+ /** The schema.org type for each kind of site (`identity.kind`). A church site is an `Organization`. */
17
+ const TYPE = { school: 'School', charity: 'NGO', business: 'LocalBusiness', organisation: 'Organization' };
18
+ /** The type of each of a site's places, when it lists them. */
18
19
  const PLACE_TYPE = { church: 'Church', school: 'School', business: 'LocalBusiness' };
19
20
 
20
21
  /** Drop empty values (undefined, '', empty arrays and objects) so the data says only what's known. */
@@ -36,22 +37,45 @@ const isoDate = (d) => {
36
37
  return Number.isNaN(date.getTime()) ? undefined : date.toISOString().slice(0, 10);
37
38
  };
38
39
 
40
+ /**
41
+ * The trail to a nested page (the site › each page above it › the page), or undefined for a
42
+ * top-level page. `titles` maps each address above the page to its page title; an address
43
+ * that isn't a page of its own is left out, and a trail with nothing between the site and
44
+ * the page isn't worth showing.
45
+ */
46
+ function breadcrumbList(site, path, title, siteName, titles) {
47
+ const segments = String(path).split('/').filter(Boolean);
48
+ if (segments.length < 2 || !title) return undefined;
49
+ const items = [{ name: siteName, item: `${site}/` }];
50
+ for (let i = 1; i < segments.length; i++) {
51
+ const above = `/${segments.slice(0, i).join('/')}/`;
52
+ if (titles[above]) items.push({ name: titles[above], item: canonicalUrl(site, above) });
53
+ }
54
+ if (items.length < 2) return undefined;
55
+ items.push({ name: title, item: canonicalUrl(site, path) });
56
+ return {
57
+ '@type': 'BreadcrumbList',
58
+ itemListElement: items.map((it, i) => ({ '@type': 'ListItem', position: i + 1, name: it.name, item: it.item })),
59
+ };
60
+ }
61
+
39
62
  /**
40
63
  * The JSON-LD for one page, or null when the site has no address.
41
64
  * @param {{ siteUrl: string | null, path: string, identity: any, contact: any, places?: any[], logo?: string,
42
- * title?: string, description?: string, article?: { date: Date | string, image?: string } }} p
65
+ * title?: string, description?: string, article?: { date: Date | string, image?: string },
66
+ * breadcrumbs?: Record<string, string> }} p `breadcrumbs`: the titles of the pages above this one.
43
67
  */
44
- export function structuredData({ siteUrl, path, identity, contact = {}, places = [], logo, title, description, article }) {
68
+ export function structuredData({ siteUrl, path, identity, contact = {}, places = [], logo, title, description, article, breadcrumbs = {} }) {
45
69
  if (!siteUrl) return null;
46
70
  const site = siteUrl.replace(/\/+$/, '');
47
71
  const abs = (u) => (!u ? undefined : /^https?:\/\//i.test(u) ? u : `${site}${u.startsWith('/') ? '' : '/'}${u}`);
48
72
  const orgId = `${site}/#organization`;
49
73
  const kind = identity?.kind || 'organisation';
50
74
  const filled = places.filter((p) => p && (p.name || p.streetAddress || p.postalCode));
51
- const several = filled.length > 1;
75
+ const listPlaces = filled.length > 1 || kind === 'church';
52
76
 
53
77
  const organisation = clean({
54
- '@type': several && kind === 'church' ? 'Organization' : TYPE[kind] || 'Organization',
78
+ '@type': TYPE[kind] || 'Organization',
55
79
  '@id': orgId,
56
80
  name: identity?.siteTitle,
57
81
  alternateName: identity?.shortName && identity.shortName !== identity.siteTitle ? identity.shortName : undefined,
@@ -62,7 +86,7 @@ export function structuredData({ siteUrl, path, identity, contact = {}, places =
62
86
  email: contact.email,
63
87
  sameAs: [contact.facebookUrl],
64
88
  address: filled.length === 1 ? postal(filled[0]) : undefined,
65
- location: several
89
+ location: listPlaces
66
90
  ? filled.map((p) => ({ '@type': PLACE_TYPE[kind] || 'Place', name: p.name, address: postal(p), hasMap: p.mapUrl }))
67
91
  : undefined,
68
92
  });
@@ -70,6 +94,8 @@ export function structuredData({ siteUrl, path, identity, contact = {}, places =
70
94
  organisation,
71
95
  { '@type': 'WebSite', '@id': `${site}/#website`, url: `${site}/`, name: identity?.siteTitle, inLanguage: 'en-GB', publisher: { '@id': orgId } },
72
96
  ];
97
+ const trail = breadcrumbList(site, path, title, identity?.siteTitle, breadcrumbs);
98
+ if (trail) graph.push(trail);
73
99
  if (article) {
74
100
  graph.push(
75
101
  clean({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ferst-core",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
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",