ferst-core 0.8.1 → 0.9.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.
@@ -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
@@ -150,7 +150,11 @@ function otherMapUse(provider: string): CookieUse {
150
150
  export interface CookieScanInput {
151
151
  /** Every block on the site's pages. Container children (`section` / `grid` / `stack`)
152
152
  * are walked, so an embed nested in a grid counts. */
153
- blocks: Iterable<BlockLike>;
153
+ blocks?: Iterable<BlockLike>;
154
+ /** @deprecated Block types alone, as before 0.8.1: judged by type, not by source (any
155
+ * video block counts as YouTube and Vimeo). Kept so a site's own older policy page
156
+ * still builds; pass `blocks`. */
157
+ blockTypes?: Iterable<string>;
154
158
  /** True when the site has a Google Calendar configured (dedicated calendar page). */
155
159
  calendarConfigured?: boolean;
156
160
  }
@@ -171,9 +175,25 @@ function allBlocks(blocks: Iterable<BlockLike>, acc: BlockLike[] = []): BlockLik
171
175
  return acc;
172
176
  }
173
177
 
178
+ /** How a provider was detected before 0.8.1, from block types alone. */
179
+ const BY_TYPE: Record<string, string[]> = {
180
+ 'google-maps': ['locations', 'mapEmbed'],
181
+ 'google-calendar': ['calendar'],
182
+ youtube: ['video'],
183
+ vimeo: ['video'],
184
+ };
185
+
174
186
  /** Report the cookie uses actually active on a site, grouped by category. */
175
187
  export function scanCookieUses(input: CookieScanInput): CookiePolicyModel {
176
- const blocks = allBlocks(input.blocks);
188
+ const necessary = NECESSARY.map((u) => ({ ...u }));
189
+ if (!input.blocks && input.blockTypes) {
190
+ const types = new Set(input.blockTypes);
191
+ const additional = ADDITIONAL.filter(
192
+ (u) => (BY_TYPE[u.id] ?? []).some((t) => types.has(t)) || (u.calendar === true && !!input.calendarConfigured),
193
+ ).map(({ loads, calendar, ...use }) => use);
194
+ return { necessary, additional };
195
+ }
196
+ const blocks = allBlocks(input.blocks ?? []);
177
197
  const additional: CookieUse[] = ADDITIONAL.filter(
178
198
  (u) => (u.loads ? blocks.some(u.loads) : false) || (u.calendar === true && !!input.calendarConfigured),
179
199
  ).map(({ loads, calendar, ...use }) => use); // drop detection fields from the view model
@@ -181,7 +201,7 @@ export function scanCookieUses(input: CookieScanInput): CookiePolicyModel {
181
201
  const otherMaps = new Set(blocks.map(mapProviderOf).filter((p): p is string => p !== undefined && !isGoogleMaps(p)));
182
202
  const afterMaps = additional.findIndex((u) => u.id !== 'google-maps');
183
203
  additional.splice(afterMaps === -1 ? additional.length : afterMaps, 0, ...[...otherMaps].map(otherMapUse));
184
- return { necessary: NECESSARY.map((u) => ({ ...u })), additional };
204
+ return { necessary, additional };
185
205
  }
186
206
 
187
207
  /** Every block `type` used in a list of blocks, walking container children
@@ -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/pages.ts CHANGED
@@ -29,12 +29,21 @@ export async function getPageEntries() {
29
29
  return all.filter((entry) => !entry.data.draft);
30
30
  }
31
31
 
32
+ /** The reserved entry a site may design as its own "page not found" page. */
33
+ export const NOT_FOUND_ID = '404';
34
+
35
+ /** The site's own "page not found" page, if it has designed one (a `404` entry). The
36
+ * injected /404 route shows it at every missing address; it is never a page of its own. */
37
+ export async function getNotFoundEntry() {
38
+ return (await getPageEntries()).find((entry) => entry.id === NOT_FOUND_ID);
39
+ }
40
+
32
41
  /**
33
42
  * A drop-in `getStaticPaths` for a client's `[...slug].astro`: one route per
34
43
  * non-draft page, keyed by the file's slug, with the entry passed as a prop.
35
44
  */
36
45
  export async function getPageRoutes() {
37
- const entries = await getPageEntries();
46
+ const entries = (await getPageEntries()).filter((entry) => entry.id !== NOT_FOUND_ID);
38
47
  return entries.map((entry) => ({
39
48
  params: { slug: entry.id },
40
49
  props: { entry },
@@ -58,7 +67,9 @@ export function pageRouteParams(id: string): { slug: string | undefined } {
58
67
  * AND can pick its home layout in the builder like any other page.
59
68
  */
60
69
  export async function getSiteRoutes() {
61
- const entries = await getPageEntries();
70
+ // The `404` entry is the site's "page not found" page, shown by the /404 route at every
71
+ // missing address; it must not also be a page at /404/ (or listed in the sitemap).
72
+ const entries = (await getPageEntries()).filter((entry) => entry.id !== NOT_FOUND_ID);
62
73
  return entries.map((entry) => ({
63
74
  params: pageRouteParams(entry.id),
64
75
  props: { entry },
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.1",
3
+ "version": "0.9.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",
package/routes/404.astro CHANGED
@@ -5,11 +5,16 @@
5
5
  * a `404.html` it serves the HOME PAGE with a 200 instead (a "soft 404"): search engines
6
6
  * would see the home page at every mistyped or retired address, and a broken link would
7
7
  * look fine to visitors and to the migration's redirect check. A site with its own
8
- * `src/pages/404.astro` keeps it (the integration skips this route then).
8
+ * `src/pages/404.astro` keeps it (the integration skips this route then). A site that has
9
+ * designed its "page not found" page as content (a `404` entry in `pages`) gets that page.
9
10
  */
10
11
  import Base from '../layouts/Base.astro';
11
12
  import BlockRenderer from '../components/BlockRenderer.astro';
12
13
  import { pageSchema } from '../content/blocks';
14
+ import { getNotFoundEntry } from '../lib/pages';
15
+
16
+ const own = await getNotFoundEntry();
17
+ const ownPage = own ? pageSchema.parse(own.data) : null;
13
18
 
14
19
  const blog = import.meta.env.FERST_ROUTES_BLOG !== false;
15
20
  const page = pageSchema.parse({
@@ -31,6 +36,12 @@ const page = pageSchema.parse({
31
36
  });
32
37
  ---
33
38
 
34
- <Base title={page.title} description="This page isn't here." indexable={false}>
35
- <BlockRenderer blocks={page.blocks} />
36
- </Base>
39
+ {ownPage ? (
40
+ <Base title={ownPage.title} description={ownPage.description ?? "This page isn't here."} align={ownPage.align} indexable={false}>
41
+ <BlockRenderer blocks={ownPage.blocks} />
42
+ </Base>
43
+ ) : (
44
+ <Base title={page.title} description="This page isn't here." indexable={false}>
45
+ <BlockRenderer blocks={page.blocks} />
46
+ </Base>
47
+ )}
@@ -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
+ };
@@ -3,7 +3,7 @@
3
3
  // and each tag page in use, and the calendar when it's set up. Drafts, hidden tags, later
4
4
  // listing pages (/posts/page/2…) and the cookie policy stay out.
5
5
  import type { APIRoute } from 'astro';
6
- import { getPageEntries, pageRouteParams } from '../lib/pages';
6
+ import { getSiteRoutes } from '../lib/pages';
7
7
  import { canonicalUrl, sitemapXml } from '../lib/seo.mjs';
8
8
 
9
9
  export const prerender = true;
@@ -12,9 +12,9 @@ export const GET: APIRoute = async () => {
12
12
  const site = String(import.meta.env.FERST_SITE_URL || '');
13
13
  const entries: Array<{ loc: string; lastmod?: Date }> = [];
14
14
 
15
- for (const page of await getPageEntries()) {
16
- const { slug } = pageRouteParams(page.id);
17
- entries.push({ loc: canonicalUrl(site, slug ? `/${slug}` : '/') });
15
+ // Exactly the pages the site routes (so never its "page not found" page).
16
+ for (const { params } of await getSiteRoutes()) {
17
+ entries.push({ loc: canonicalUrl(site, params.slug ? `/${params.slug}` : '/') });
18
18
  }
19
19
 
20
20
  if (import.meta.env.FERST_ROUTES_BLOG !== false) {