ferst-core 0.9.2 → 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.
- package/content/schemas.ts +2 -1
- package/integration.mjs +29 -2
- package/layouts/Base.astro +6 -1
- package/lib/pages.ts +18 -0
- package/lib/seo.mjs +16 -0
- package/lib/structured-data.mjs +37 -11
- package/package.json +1 -1
package/content/schemas.ts
CHANGED
|
@@ -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):
|
|
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);
|
package/layouts/Base.astro
CHANGED
|
@@ -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
|
package/lib/structured-data.mjs
CHANGED
|
@@ -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
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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 = {
|
|
17
|
-
/** The type of each of a site's places, when it
|
|
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 }
|
|
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
|
|
75
|
+
const listPlaces = filled.length > 1 || kind === 'church';
|
|
52
76
|
|
|
53
77
|
const organisation = clean({
|
|
54
|
-
'@type':
|
|
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:
|
|
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.
|
|
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",
|