ferst-core 0.8.2 → 0.9.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.
@@ -142,6 +142,26 @@ export function coreRangeFor(version) {
142
142
  return `^${m[1]}.${m[2]}.0`;
143
143
  }
144
144
 
145
+ /** Whether `b` is a newer plain x.y.z version than `a`; anything unparsable is not newer. */
146
+ export function isNewerVersion(a, b) {
147
+ const parse = (v) => String(v ?? '').trim().replace(/^v/, '').match(/^(\d+)\.(\d+)\.(\d+)$/)?.slice(1).map(Number);
148
+ const [x, y] = [parse(a), parse(b)];
149
+ if (!x || !y) return false;
150
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return y[i] > x[i];
151
+ return false;
152
+ }
153
+
154
+ /**
155
+ * The dry run's line about the engine. A site's scheduled run decides from the dry run
156
+ * whether to update, so a newer ferst-core has to read as a change that WOULD apply, like
157
+ * the scaffold changes above it; otherwise an engine-only release never reaches the site.
158
+ */
159
+ export function coreUpdateNote(installed, latest) {
160
+ if (!latest) return `ferst-core is ${installed ?? '(not installed)'}; the latest version is unknown`;
161
+ if (!installed) return `ferst-core ${latest} WOULD be installed (none installed yet)`;
162
+ return isNewerVersion(installed, latest) ? `ferst-core ${installed} → ${latest} WOULD be installed` : `ferst-core is current (${installed})`;
163
+ }
164
+
145
165
  /** Read the `version` out of a package.json's text. Throws if absent. */
146
166
  export function extractVersion(pkgJsonText) {
147
167
  let v;
@@ -451,8 +471,7 @@ function main() {
451
471
  console.log('ferst-update: --no-core — skipping the ferst-core npm bump.');
452
472
  } else if (dryRun) {
453
473
  dryLatest = (tryExec('npm view ferst-core version', cwd) || '').trim() || null;
454
- const current = readInstalledCoreVersion(cwd);
455
- console.log(`ferst-update: ferst-core installed=${current ?? '(none)'} latest=${dryLatest || '(unknown)'}`);
474
+ console.log(`ferst-update: --dry-run — ${coreUpdateNote(readInstalledCoreVersion(cwd), dryLatest)}`);
456
475
  } else {
457
476
  const before = readInstalledCoreVersion(cwd);
458
477
  console.log('ferst-update: npm install ferst-core@latest --save ...');
@@ -76,6 +76,7 @@ collections:
76
76
  - { name: shortName, label: Short name, widget: string }
77
77
  - { name: description, label: Description, widget: text }
78
78
  - { name: location, label: Location, widget: string, required: false }
79
+ - { name: kind, label: Kind of organisation, widget: select, required: false, default: organisation, options: [{ label: Church, value: church }, { label: School, value: school }, { label: Charity, value: charity }, { label: Business, value: business }, { label: Other organisation, value: organisation }], hint: "Tells search engines what the site is for." }
79
80
  - name: contact
80
81
  label: Contact
81
82
  widget: object
@@ -84,6 +85,28 @@ collections:
84
85
  - { name: phoneTel, label: 'Phone (tel: digits)', widget: string, required: false }
85
86
  - { name: email, label: Email, widget: string, required: false }
86
87
  - { name: facebookUrl, label: Facebook URL, widget: string, required: false }
88
+ - name: places
89
+ label: Places
90
+ label_singular: Place
91
+ widget: list
92
+ required: false
93
+ hint: "Each place the site is for, with its address (a parish may have several churches). Search engines read these."
94
+ fields:
95
+ - { name: name, label: Name, widget: string }
96
+ - { name: streetAddress, label: Street address, widget: string, required: false }
97
+ - { name: locality, label: Town, widget: string, required: false }
98
+ - { name: postalCode, label: Postcode, widget: string, required: false }
99
+ - { name: country, label: Country code, widget: string, required: false, default: GB }
100
+ - { name: mapUrl, label: Map link, widget: string, required: false }
101
+ - name: verification
102
+ label: Search engine verification
103
+ widget: object
104
+ required: false
105
+ collapsed: true
106
+ hint: "Codes from Google Search Console and Bing Webmaster Tools, when they ask for an HTML tag."
107
+ fields:
108
+ - { name: google, label: Google code, widget: string, required: false }
109
+ - { name: bing, label: Bing code, widget: string, required: false }
87
110
 
88
111
  - name: logos
89
112
  label: Logos
@@ -154,6 +177,16 @@ collections:
154
177
  - { name: provider, label: Provider name, widget: string, default: Google Calendar }
155
178
  - { name: weekStart, label: Week starts on, widget: select, default: monday, options: [{ label: "Monday", value: monday }, { label: "Sunday", value: sunday }] }
156
179
 
180
+ - name: posts-settings
181
+ label: News
182
+ file: src/content/postsSettings/index.json
183
+ icon: newspaper
184
+ fields:
185
+ - { name: title, label: Heading, widget: string, default: News }
186
+ - { name: intro, label: Intro, widget: text, required: false }
187
+ - { name: perPage, label: Posts per page, widget: number, default: 12, value_type: int, min: 1, max: 48, hint: "How many posts each news page shows. 12 fills rows of one, two, three or four evenly." }
188
+ - { name: excludeFromIndex, label: Tags kept off the main news page, widget: list, required: false, hint: "Tag names (for example newsletter) whose posts appear only on their own tag page." }
189
+
157
190
  - name: posts
158
191
  label: Posts
159
192
  label_singular: Post
package/content/posts.ts CHANGED
@@ -43,8 +43,8 @@ export const postsSettingsSchema = z.object({
43
43
  title: z.string().default('News'),
44
44
  /** Optional intro copy for the index. */
45
45
  intro: z.string().default(''),
46
- /** Posts per page in the paginated index. */
47
- perPage: z.number().int().min(1).default(9),
46
+ /** Posts per page in the paginated index. 12 fills rows of 1, 2, 3 or 4 tiles evenly. */
47
+ perPage: z.number().int().min(1).default(12),
48
48
  /** Tag slugs excluded from the MAIN index (e.g. a `newsletter` tag that has its
49
49
  * own view). Kept generic — no capability knows what "newsletter" means. */
50
50
  excludeFromIndex: z.array(z.string()).default([]),
@@ -35,6 +35,30 @@ const siteIdentitySchema = z
35
35
  description: z.string().default('A website built with Ferst.'),
36
36
  /** Town / city — appended to the brand in alt text. */
37
37
  location: z.string().default(''),
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. */
40
+ kind: z.enum(['church', 'school', 'charity', 'business', 'organisation']).default('organisation'),
41
+ })
42
+ .default({});
43
+
44
+ /** One of the places the site is for (a parish may have several churches). Search engines
45
+ * read each address from here (structured data); blocks can show the same facts. */
46
+ const placeSchema = z.object({
47
+ name: z.string().default(''),
48
+ streetAddress: z.string().default(''),
49
+ locality: z.string().default(''),
50
+ postalCode: z.string().default(''),
51
+ country: z.string().default('GB'),
52
+ /** A link to the place on a map (e.g. Google Maps). */
53
+ mapUrl: z.string().default(''),
54
+ });
55
+
56
+ /** Search-engine verification codes (Search Console, Bing Webmaster Tools), rendered as
57
+ * meta tags, for sites whose DNS we don't run. */
58
+ const verificationSchema = z
59
+ .object({
60
+ google: z.string().default(''),
61
+ bing: z.string().default(''),
38
62
  })
39
63
  .default({});
40
64
 
@@ -54,6 +78,8 @@ const siteContactSchema = z
54
78
  export const siteSettingsSchema = z.object({
55
79
  identity: siteIdentitySchema,
56
80
  contact: siteContactSchema,
81
+ places: z.array(placeSchema).default([]),
82
+ verification: verificationSchema,
57
83
  });
58
84
 
59
85
  export const logoSettingsSchema = z.object({
package/integration.mjs CHANGED
@@ -34,7 +34,8 @@
34
34
  * square logo, else the site's initial in its brand colour. A site's own files in
35
35
  * `public/` win.
36
36
  * @param {boolean} [options.seo=true] Search signals (lib/seo.mjs): `/robots.txt`, and once
37
- * `ferst-site.json` has a `siteUrl`, `/sitemap.xml` and a canonical address on every page.
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
39
  * A test copy (any branch but the production one) is marked noindex on every file. A
39
40
  * site's own `public/robots.txt` or `public/sitemap.xml` wins.
40
41
  * @returns {import('astro').AstroIntegration}
@@ -186,6 +187,10 @@ export default function ferst(options = {}) {
186
187
  if (search.siteUrl && !hasPublicFile(root, 'sitemap.xml')) {
187
188
  injectRoute({ pattern: '/sitemap.xml', entrypoint: 'ferst-core/routes/sitemap.xml.ts' });
188
189
  }
190
+ // The news feed lists absolute addresses too.
191
+ if (search.siteUrl && blog) {
192
+ injectRoute({ pattern: '/posts/rss.xml', entrypoint: 'ferst-core/routes/posts/rss.xml.ts' });
193
+ }
189
194
  }
190
195
  },
191
196
  // The engine's self-hosted fonts: served in dev, copied into every build.
@@ -30,7 +30,8 @@ import SecondaryLogo from '../components/SecondaryLogo.astro';
30
30
  import { loadSiteSettings } from '../lib/siteSettings';
31
31
  import { getCalloutSettings } from '../lib/callout';
32
32
  import { buildThemeStyle } from '../lib/themeTokens';
33
- import { canonicalUrl } from '../lib/seo.mjs';
33
+ import { canonicalUrl, previewTags } from '../lib/seo.mjs';
34
+ import { structuredData } from '../lib/structured-data.mjs';
34
35
 
35
36
  interface Props {
36
37
  title: string;
@@ -47,6 +48,8 @@ interface Props {
47
48
  /** False for a page with no address of its own (the "page not found" page): it gets no
48
49
  * canonical address and asks search engines not to index it. */
49
50
  indexable?: boolean;
51
+ /** A post: shared and described to search engines as an article with this date. */
52
+ article?: { date: Date | string; image?: string };
50
53
  }
51
54
 
52
55
  const {
@@ -59,6 +62,7 @@ const {
59
62
  showSecondaryLogo = false,
60
63
  align = 'start',
61
64
  indexable = true,
65
+ article,
62
66
  } = Astro.props;
63
67
 
64
68
  const settings = await loadSiteSettings();
@@ -95,6 +99,36 @@ const metaDescription = description ?? identity.description;
95
99
  const siteUrl = import.meta.env.FERST_SITE_URL || '';
96
100
  const canonical = siteUrl && indexable ? canonicalUrl(siteUrl, path) : null;
97
101
  const noindex = !indexable || import.meta.env.FERST_SEO_PRODUCTION === false;
102
+
103
+ // 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
+ const ld = indexable
106
+ ? structuredData({
107
+ siteUrl: siteUrl || null,
108
+ path,
109
+ identity,
110
+ contact,
111
+ places: settings.places,
112
+ logo: settings.primaryLogoLight,
113
+ title,
114
+ description: metaDescription,
115
+ article,
116
+ })
117
+ : null;
118
+ // `<` escaped so the JSON can't close its script tag.
119
+ const ldJson = ld ? JSON.stringify(ld).replace(/</g, '\\u003c') : '';
120
+ const preview = previewTags({
121
+ siteUrl: siteUrl || null,
122
+ path,
123
+ title,
124
+ siteName: identity.siteTitle,
125
+ description: metaDescription,
126
+ image: heroImage,
127
+ logo: settings.primaryLogoLight,
128
+ article,
129
+ });
130
+ const { verification } = settings;
131
+ const hasFeed = Boolean(siteUrl) && import.meta.env.FERST_ROUTES_BLOG !== false;
98
132
  ---
99
133
 
100
134
  <!doctype html>
@@ -106,6 +140,11 @@ const noindex = !indexable || import.meta.env.FERST_SEO_PRODUCTION === false;
106
140
  <meta name="description" content={metaDescription} />
107
141
  {canonical && <link rel="canonical" href={canonical} />}
108
142
  {noindex && <meta name="robots" content="noindex" />}
143
+ {preview.map((t) => (t.property ? <meta property={t.property} content={t.content} /> : <meta name={t.name} content={t.content} />))}
144
+ {verification.google && <meta name="google-site-verification" content={verification.google} />}
145
+ {verification.bing && <meta name="msvalidate.01" content={verification.bing} />}
146
+ {hasFeed && <link rel="alternate" type="application/rss+xml" title={`${identity.siteTitle}: news`} href="/posts/rss.xml" />}
147
+ {ldJson && <script type="application/ld+json" is:inline set:html={ldJson} />}
109
148
  <title>{title} — {identity.siteTitle}</title>
110
149
  <!-- The browser icon, written into every build by the ferst() integration (lib/site-icon). -->
111
150
  <link rel="icon" href="/favicon.ico" sizes="32x32 48x48" />
package/lib/seo.mjs CHANGED
@@ -128,3 +128,71 @@ export function withNoindexHeader(existing) {
128
128
  if (!current.trim()) return NOINDEX_RULE;
129
129
  return `${current.replace(/\s*$/, '')}\n\n${NOINDEX_RULE}`;
130
130
  }
131
+
132
+ /**
133
+ * Link-preview tags (Open Graph and Twitter card) for one page: what a link shared on
134
+ * Facebook, WhatsApp or LinkedIn shows. The picture is the page's own (its hero), else the
135
+ * site's logo, else its browser icon. Addresses must be absolute, so without the site's
136
+ * address only the title, description and type are given.
137
+ * @param {{ siteUrl: string | null, path: string, title: string, siteName: string, description?: string,
138
+ * image?: string, logo?: string, article?: { date: Date | string } }} p
139
+ * @returns {Array<{ property?: string, name?: string, content: string }>}
140
+ */
141
+ export function previewTags({ siteUrl, path, title, siteName, description, image, logo, article }) {
142
+ const site = siteUrl ? siteUrl.replace(/\/+$/, '') : null;
143
+ const abs = (u) => (/^https?:\/\//i.test(u) ? u : `${site}${u.startsWith('/') ? '' : '/'}${u}`);
144
+ const tags = [
145
+ { property: 'og:type', content: article ? 'article' : 'website' },
146
+ { property: 'og:title', content: title },
147
+ ...(description ? [{ property: 'og:description', content: description }] : []),
148
+ ...(siteName ? [{ property: 'og:site_name', content: siteName }] : []),
149
+ { property: 'og:locale', content: 'en_GB' },
150
+ ];
151
+ if (site) {
152
+ tags.push({ property: 'og:url', content: canonicalUrl(site, path) });
153
+ tags.push({ property: 'og:image', content: abs(image || logo || '/apple-touch-icon.png') });
154
+ }
155
+ tags.push({ name: 'twitter:card', content: image && site ? 'summary_large_image' : 'summary' });
156
+ if (article) {
157
+ const d = article.date instanceof Date ? article.date : new Date(article.date);
158
+ if (!Number.isNaN(d.getTime())) tags.push({ property: 'article:published_time', content: d.toISOString().slice(0, 10) });
159
+ }
160
+ return tags;
161
+ }
162
+
163
+ /**
164
+ * The site's news feed (RSS 2.0) at /posts/rss.xml: its posts in the order given (newest
165
+ * first), each with its address, date and summary.
166
+ * @param {{ siteUrl: string, title: string, description?: string,
167
+ * items: Array<{ title: string, path: string, date: Date | string, summary?: string }> }} p
168
+ */
169
+ export function rssXml({ siteUrl, title, description = '', items }) {
170
+ const site = siteUrl.replace(/\/+$/, '');
171
+ const entries = items.map((item) => {
172
+ const link = canonicalUrl(site, item.path);
173
+ const d = item.date instanceof Date ? item.date : new Date(item.date);
174
+ return [
175
+ ' <item>',
176
+ ` <title>${xmlEscape(item.title)}</title>`,
177
+ ` <link>${xmlEscape(link)}</link>`,
178
+ ` <guid isPermaLink="true">${xmlEscape(link)}</guid>`,
179
+ ...(Number.isNaN(d.getTime()) ? [] : [` <pubDate>${d.toUTCString()}</pubDate>`]),
180
+ ...(item.summary ? [` <description>${xmlEscape(item.summary)}</description>`] : []),
181
+ ' </item>',
182
+ ].join('\n');
183
+ });
184
+ return [
185
+ '<?xml version="1.0" encoding="UTF-8"?>',
186
+ '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">',
187
+ ' <channel>',
188
+ ` <title>${xmlEscape(title)}</title>`,
189
+ ` <link>${xmlEscape(`${site}/`)}</link>`,
190
+ ` <description>${xmlEscape(description)}</description>`,
191
+ ' <language>en-gb</language>',
192
+ ` <atom:link href="${xmlEscape(`${site}/posts/rss.xml`)}" rel="self" type="application/rss+xml"/>`,
193
+ ...entries,
194
+ ' </channel>',
195
+ '</rss>',
196
+ '',
197
+ ].join('\n');
198
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * What every page tells search engines about the site: schema.org structured data (JSON-LD),
3
+ * built from the site's own details in `siteSettings` (its kind, places and contact), the
4
+ * same facts the site shows visitors. Base renders it in each page's head once the site has
5
+ * an address (its links must be absolute). Pure, so it's unit-testable.
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`.
10
+ * - The website names that entity as its publisher.
11
+ * - A post is an `Article`, dated, published by the site.
12
+ */
13
+ import { canonicalUrl } from './seo.mjs';
14
+
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. */
18
+ const PLACE_TYPE = { church: 'Church', school: 'School', business: 'LocalBusiness' };
19
+
20
+ /** Drop empty values (undefined, '', empty arrays and objects) so the data says only what's known. */
21
+ function clean(value) {
22
+ if (Array.isArray(value)) {
23
+ const items = value.map(clean).filter((v) => v !== undefined);
24
+ return items.length ? items : undefined;
25
+ }
26
+ if (value && typeof value === 'object' && !(value instanceof Date)) {
27
+ const entries = Object.entries(value).map(([k, v]) => [k, clean(v)]).filter(([, v]) => v !== undefined);
28
+ return entries.length ? Object.fromEntries(entries) : undefined;
29
+ }
30
+ return value === '' || value === null ? undefined : value;
31
+ }
32
+
33
+ const postal = (p) => ({ '@type': 'PostalAddress', streetAddress: p.streetAddress, addressLocality: p.locality, postalCode: p.postalCode, addressCountry: p.country || 'GB' });
34
+ const isoDate = (d) => {
35
+ const date = d instanceof Date ? d : new Date(d);
36
+ return Number.isNaN(date.getTime()) ? undefined : date.toISOString().slice(0, 10);
37
+ };
38
+
39
+ /**
40
+ * The JSON-LD for one page, or null when the site has no address.
41
+ * @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
43
+ */
44
+ export function structuredData({ siteUrl, path, identity, contact = {}, places = [], logo, title, description, article }) {
45
+ if (!siteUrl) return null;
46
+ const site = siteUrl.replace(/\/+$/, '');
47
+ const abs = (u) => (!u ? undefined : /^https?:\/\//i.test(u) ? u : `${site}${u.startsWith('/') ? '' : '/'}${u}`);
48
+ const orgId = `${site}/#organization`;
49
+ const kind = identity?.kind || 'organisation';
50
+ const filled = places.filter((p) => p && (p.name || p.streetAddress || p.postalCode));
51
+ const several = filled.length > 1;
52
+
53
+ const organisation = clean({
54
+ '@type': several && kind === 'church' ? 'Organization' : TYPE[kind] || 'Organization',
55
+ '@id': orgId,
56
+ name: identity?.siteTitle,
57
+ alternateName: identity?.shortName && identity.shortName !== identity.siteTitle ? identity.shortName : undefined,
58
+ description: identity?.description,
59
+ url: `${site}/`,
60
+ logo: abs(logo),
61
+ telephone: contact.phoneDisplay || contact.phoneTel,
62
+ email: contact.email,
63
+ sameAs: [contact.facebookUrl],
64
+ address: filled.length === 1 ? postal(filled[0]) : undefined,
65
+ location: several
66
+ ? filled.map((p) => ({ '@type': PLACE_TYPE[kind] || 'Place', name: p.name, address: postal(p), hasMap: p.mapUrl }))
67
+ : undefined,
68
+ });
69
+ const graph = [
70
+ organisation,
71
+ { '@type': 'WebSite', '@id': `${site}/#website`, url: `${site}/`, name: identity?.siteTitle, inLanguage: 'en-GB', publisher: { '@id': orgId } },
72
+ ];
73
+ if (article) {
74
+ graph.push(
75
+ clean({
76
+ '@type': 'Article',
77
+ headline: title,
78
+ description,
79
+ datePublished: isoDate(article.date),
80
+ image: abs(article.image),
81
+ mainEntityOfPage: canonicalUrl(site, path),
82
+ author: { '@id': orgId },
83
+ publisher: { '@id': orgId },
84
+ }),
85
+ );
86
+ }
87
+ return { '@context': 'https://schema.org', '@graph': graph };
88
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ferst-core",
3
- "version": "0.8.2",
3
+ "version": "0.9.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",
@@ -15,7 +15,7 @@ const { Content } = await entry.render();
15
15
  const tags = await resolveTagLabels(entry.data.tags);
16
16
  ---
17
17
 
18
- <Base title={entry.data.title} description={entry.data.summary} heroImage={entry.data.heroImage}>
18
+ <Base title={entry.data.title} description={entry.data.summary} heroImage={entry.data.heroImage} article={{ date: entry.data.date, image: entry.data.heroImage }}>
19
19
  <PostArticle
20
20
  title={entry.data.title}
21
21
  date={entry.data.date}
@@ -0,0 +1,21 @@
1
+ // /posts/rss.xml: the site's news feed, injected by the ferst() integration once the site
2
+ // has an address (`ferst-site.json` `siteUrl`) and its blog is on. Its newest 50 posts, each
3
+ // with its address, date and summary, for feed readers and search engines.
4
+ import type { APIRoute } from 'astro';
5
+ import { getPublishedPosts } from '../../lib/posts';
6
+ import { loadSiteSettings } from '../../lib/siteSettings';
7
+ import { rssXml } from '../../lib/seo.mjs';
8
+
9
+ export const prerender = true;
10
+
11
+ export const GET: APIRoute = async () => {
12
+ const { identity } = await loadSiteSettings();
13
+ const posts = (await getPublishedPosts()).slice(0, 50);
14
+ const xml = rssXml({
15
+ siteUrl: String(import.meta.env.FERST_SITE_URL || ''),
16
+ title: `${identity.siteTitle}: news`,
17
+ description: identity.description,
18
+ items: posts.map((post) => ({ title: post.data.title, path: `/posts/${post.slug}`, date: post.data.date, summary: post.data.summary })),
19
+ });
20
+ return new Response(xml, { headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' } });
21
+ };