ferst-core 0.9.2 → 0.9.4
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/components/Button.astro +8 -8
- package/components/CalendarEmbed.astro +1 -1
- package/components/CookieBanner.astro +1 -1
- package/components/PostsFilter.astro +1 -1
- package/components/SiteFooter.astro +1 -0
- package/components/SiteHeader.astro +1 -1
- package/components/StickyCallout.astro +1 -1
- package/components/recipes/Announcement.astro +1 -1
- package/components/recipes/Bento.astro +4 -2
- package/components/recipes/Hero.astro +533 -533
- package/components/recipes/LatestPosts.astro +1 -1
- package/components/recipes/LinkRow.astro +3 -1
- package/components/recipes/Locations.astro +1 -1
- package/components/recipes/MapEmbed.astro +1 -1
- package/components/recipes/Pricing.astro +1 -1
- package/components/recipes/Steps.astro +1 -1
- package/components/recipes/Tiles.astro +4 -2
- package/components/recipes/Video.astro +1 -1
- package/content/accessibility.ts +62 -0
- package/content/schemas.ts +2 -1
- package/integration.mjs +39 -3
- package/layouts/Base.astro +17 -2
- package/lib/contrast.ts +146 -0
- package/lib/pages.ts +18 -0
- package/lib/seo.mjs +16 -0
- package/lib/structured-data.mjs +37 -11
- package/lib/themeDefaults.ts +15 -0
- package/lib/themeTokens.ts +11 -9
- package/package.json +1 -1
- package/routes/accessibility.astro +44 -0
- package/styles/theme.css +2 -0
|
@@ -242,7 +242,7 @@ const isNew = (date: Date | string) => isRecent(date, newWithinDays, nowMs);
|
|
|
242
242
|
padding: 0.12rem 0.45rem;
|
|
243
243
|
border-radius: var(--radius-pill);
|
|
244
244
|
background: var(--gold);
|
|
245
|
-
color: var(--
|
|
245
|
+
color: var(--on-brand);
|
|
246
246
|
font-family: var(--font-heading);
|
|
247
247
|
font-size: 0.62rem;
|
|
248
248
|
font-weight: 700;
|
|
@@ -20,7 +20,7 @@ const external = (href: string) => /^https?:\/\//i.test(href);
|
|
|
20
20
|
---
|
|
21
21
|
|
|
22
22
|
<section class="b-linkrow">
|
|
23
|
-
{title && <Heading text={title} level={
|
|
23
|
+
{title && <Heading text={title} level={2} />}
|
|
24
24
|
{intro && <p class="b-linkrow__intro" set:html={inlineMarkdown(intro)} />}
|
|
25
25
|
<ul class="b-linkrow__links">
|
|
26
26
|
{links.map((l) => (
|
|
@@ -36,6 +36,8 @@ const external = (href: string) => /^https?:\/\//i.test(href);
|
|
|
36
36
|
</section>
|
|
37
37
|
|
|
38
38
|
<style>
|
|
39
|
+
/* Its title is the block's heading (h2, so headings stay in order) at the h3 size it always had. */
|
|
40
|
+
.b-linkrow > :global(h2) { font-size: clamp(1.375rem, 1.2rem + 0.55vw, 1.625rem); }
|
|
39
41
|
.b-linkrow {
|
|
40
42
|
display: flex;
|
|
41
43
|
flex-direction: column;
|
|
@@ -126,7 +126,7 @@ const cols = Math.min(columns, Math.max(items.length, 1)) as 1 | 2;
|
|
|
126
126
|
.b-loc-map__load {
|
|
127
127
|
font: inherit; font-family: var(--font-heading); font-weight: 600; font-size: 0.9rem;
|
|
128
128
|
padding: 0.5rem 1.1rem; border: none; border-radius: var(--radius-sm);
|
|
129
|
-
background: var(--gold); color: var(--
|
|
129
|
+
background: var(--gold); color: var(--on-brand); cursor: pointer;
|
|
130
130
|
transition: filter 0.15s ease;
|
|
131
131
|
}
|
|
132
132
|
.b-loc-map__load:hover { filter: brightness(0.94); }
|
|
@@ -111,7 +111,7 @@ const headAlign = headingAlign ?? blockAlign ?? 'center';
|
|
|
111
111
|
font-weight: 700;
|
|
112
112
|
letter-spacing: 0.08em;
|
|
113
113
|
text-transform: uppercase;
|
|
114
|
-
color: var(--
|
|
114
|
+
color: var(--on-brand);
|
|
115
115
|
background: var(--gold);
|
|
116
116
|
padding: 0.2rem 0.6rem;
|
|
117
117
|
border-radius: var(--radius-pill);
|
|
@@ -81,7 +81,7 @@ const headAlign = headingAlign ?? blockAlign ?? 'center';
|
|
|
81
81
|
justify-content: center;
|
|
82
82
|
border-radius: var(--radius-pill);
|
|
83
83
|
background: var(--gold);
|
|
84
|
-
color: var(--
|
|
84
|
+
color: var(--on-brand);
|
|
85
85
|
font-family: var(--font-heading);
|
|
86
86
|
font-weight: 700;
|
|
87
87
|
font-size: 1.05rem;
|
|
@@ -36,6 +36,8 @@ interface Props {
|
|
|
36
36
|
tiles?: Tile[];
|
|
37
37
|
}
|
|
38
38
|
const { title, intro, columns = 3, variant = 'elevated', spotlight = false, tiles = [] } = Astro.props;
|
|
39
|
+
// Headings stay in order: items sit one level under the block's title, or take its place.
|
|
40
|
+
const ItemHeading = title ? 'h3' : 'h2';
|
|
39
41
|
// Alignment overrides (BlockLayout): the title + intro default to centred; the block's
|
|
40
42
|
// own `align`, or `headingAlign` for the title alone, overrides it from the page data.
|
|
41
43
|
const { align: blockAlign, headingAlign } = Astro.props as { align?: 'start' | 'center' | 'end'; headingAlign?: 'start' | 'center' | 'end' };
|
|
@@ -73,7 +75,7 @@ const cols = Math.min(columns, Math.max(tiles.length, 1)) as 1 | 2 | 3 | 4;
|
|
|
73
75
|
<span class="b-tile__icon"><Icon name={tile.icon} size={26} /></span>
|
|
74
76
|
)}
|
|
75
77
|
{tile.eyebrow && <span class="b-tile__eyebrow">{tile.eyebrow}</span>}
|
|
76
|
-
{tile.title && <
|
|
78
|
+
{tile.title && <ItemHeading class="b-tile__title">{tile.title}</ItemHeading>}
|
|
77
79
|
{/* A linked tile is one big link: its text keeps breaks and emphasis, no inner links. */}
|
|
78
80
|
{tile.text && <p class="b-tile__text" set:html={tile.href ? inlineMarkdownNoLinks(tile.text) : inlineMarkdown(tile.text)} />}
|
|
79
81
|
{tile.href && !plain && <span class="b-tile__more" aria-hidden="true">{tile.linkLabel ?? 'Learn more'} →</span>}
|
|
@@ -151,7 +153,7 @@ const cols = Math.min(columns, Math.max(tiles.length, 1)) as 1 | 2 | 3 | 4;
|
|
|
151
153
|
}
|
|
152
154
|
.b-tiles__grid[data-variant='gradient'] .b-tile {
|
|
153
155
|
background: linear-gradient(135deg, var(--gold), var(--accent-2, var(--gold)));
|
|
154
|
-
color: var(--
|
|
156
|
+
color: var(--on-brand);
|
|
155
157
|
}
|
|
156
158
|
.b-tiles__grid[data-variant='image'] .b-tile {
|
|
157
159
|
color: #fff;
|
|
@@ -141,7 +141,7 @@ const safeLink = /^https:\/\//i.test(url) ? url : undefined;
|
|
|
141
141
|
border: none;
|
|
142
142
|
border-radius: var(--radius-sm);
|
|
143
143
|
background: var(--gold);
|
|
144
|
-
color: var(--
|
|
144
|
+
color: var(--on-brand);
|
|
145
145
|
cursor: pointer;
|
|
146
146
|
}
|
|
147
147
|
.b-video__load:hover {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The accessibility statement (`/accessibility`), generated for every site like the cookie
|
|
3
|
+
* policy: what the site does for people using keyboards, screen readers or magnification,
|
|
4
|
+
* where it falls short, and how to report a problem. Pure, so it's unit-testable; the route
|
|
5
|
+
* (routes/accessibility.astro) renders it with the site's name and contact details.
|
|
6
|
+
*/
|
|
7
|
+
export interface StatementSection {
|
|
8
|
+
heading: string;
|
|
9
|
+
paragraphs: string[];
|
|
10
|
+
items?: string[];
|
|
11
|
+
/** On the "reporting a problem" section, when the site has contact details. */
|
|
12
|
+
contact?: { email?: string; phone?: string };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AccessibilityStatement {
|
|
16
|
+
title: string;
|
|
17
|
+
intro: string;
|
|
18
|
+
sections: StatementSection[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function accessibilityStatement({ siteName, email, phone }: { siteName: string; email?: string; phone?: string }): AccessibilityStatement {
|
|
22
|
+
const contact = email || phone ? { ...(email ? { email } : {}), ...(phone ? { phone } : {}) } : undefined;
|
|
23
|
+
return {
|
|
24
|
+
title: 'Accessibility',
|
|
25
|
+
intro: `${siteName} wants everyone to be able to use this website, including people who use a keyboard instead of a mouse, a screen reader, or magnification.`,
|
|
26
|
+
sections: [
|
|
27
|
+
{
|
|
28
|
+
heading: 'What you can do on this site',
|
|
29
|
+
paragraphs: ['The site is built so that you can:'],
|
|
30
|
+
items: [
|
|
31
|
+
'move around it using only a keyboard, starting with a "Skip to content" link',
|
|
32
|
+
'use it with a screen reader: pages have headings in order, landmarks and descriptions for meaningful pictures',
|
|
33
|
+
'zoom in up to 400% without text spilling off the screen, and read it on a phone',
|
|
34
|
+
'read text that stands out clearly from its background, in light and dark mode',
|
|
35
|
+
'stop moving backgrounds; they stay still if your device asks for reduced motion',
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
heading: 'The standard we aim for',
|
|
40
|
+
paragraphs: [
|
|
41
|
+
'The site aims to meet the Web Content Accessibility Guidelines version 2.2 (WCAG 2.2) at level AA. Its layout, navigation and controls are tested for accessibility before every release of the software it runs on.',
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
heading: 'What may not be fully accessible',
|
|
46
|
+
paragraphs: ['We know of some things that may be harder to use:'],
|
|
47
|
+
items: [
|
|
48
|
+
'some documents, such as PDF newsletters and older uploads, were not written with accessibility in mind',
|
|
49
|
+
'videos shown from other sites, such as YouTube, rely on that site for their captions',
|
|
50
|
+
'some older pictures may not have a description yet',
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
heading: 'Reporting a problem',
|
|
55
|
+
paragraphs: contact
|
|
56
|
+
? ['If something on this site is hard to use, or you need information in another format, please tell us. We will do our best to help.']
|
|
57
|
+
: ['If something on this site is hard to use, or you need information in another format, please get in touch through the contact page. We will do our best to help.'],
|
|
58
|
+
...(contact ? { contact } : {}),
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
}
|
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
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
* @param {boolean} [options.calendar=true] Inject the `/calendar` route.
|
|
23
23
|
* @param {boolean} [options.blog=true] Inject the blog routes (`/posts`,
|
|
24
24
|
* `/posts/[...slug]`, `/posts/tag/[...tag]`).
|
|
25
|
+
* @param {boolean} [options.accessibility=true] Inject `/accessibility`, the accessibility statement
|
|
26
|
+
* every footer links to (content/accessibility.ts). Skipped when the site has its own
|
|
27
|
+
* `src/pages/accessibility.astro`.
|
|
25
28
|
* @param {boolean} [options.cookiePolicy=true] Inject `/cookie-policy` (the page the cookie
|
|
26
29
|
* banner links to), generated from the blocks the site uses. Skipped automatically when
|
|
27
30
|
* the site has its own `src/pages/cookie-policy.astro`.
|
|
@@ -35,16 +38,28 @@
|
|
|
35
38
|
* `public/` win.
|
|
36
39
|
* @param {boolean} [options.seo=true] Search signals (lib/seo.mjs): `/robots.txt`, and once
|
|
37
40
|
* `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`.
|
|
41
|
+
* structured data and link previews, and (with the blog) `/posts/rss.xml`. Every build's
|
|
42
|
+
* internal links to a page point at its own address (`/about-us/`), not at a redirect.
|
|
39
43
|
* A test copy (any branch but the production one) is marked noindex on every file. A
|
|
40
44
|
* site's own `public/robots.txt` or `public/sitemap.xml` wins.
|
|
41
45
|
* @returns {import('astro').AstroIntegration}
|
|
42
46
|
*/
|
|
43
47
|
import { copyFileSync, createReadStream, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
44
48
|
import { deploymentBranch } from './bin/cms-config.mjs';
|
|
45
|
-
import { seoContext, withNoindexHeader } from './lib/seo.mjs';
|
|
49
|
+
import { linksToPageAddresses, seoContext, withNoindexHeader } from './lib/seo.mjs';
|
|
46
50
|
import { writeSiteIcons } from './lib/site-icon.mjs';
|
|
47
51
|
|
|
52
|
+
/** Every page of a build (its .html files, at any depth), as file URLs. */
|
|
53
|
+
function htmlFiles(dir) {
|
|
54
|
+
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) =>
|
|
55
|
+
entry.isDirectory()
|
|
56
|
+
? htmlFiles(new URL(`${encodeURIComponent(entry.name)}/`, dir))
|
|
57
|
+
: entry.name.endsWith('.html')
|
|
58
|
+
? [new URL(encodeURIComponent(entry.name), dir)]
|
|
59
|
+
: [],
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
48
63
|
/** The self-hosted fonts the engine's CSS requests (/fonts/*.woff2), shipped in the package. */
|
|
49
64
|
const FONTS_DIR = new URL('./fonts/', import.meta.url);
|
|
50
65
|
const FONT_URL = /^\/fonts\/([a-z0-9-]+\.woff2)(?:\?.*)?$/;
|
|
@@ -97,7 +112,7 @@ function readSiteMeta(root) {
|
|
|
97
112
|
const hasPublicFile = (root, name) => Boolean(root) && existsSync(new URL(`public/${name}`, root));
|
|
98
113
|
|
|
99
114
|
export default function ferst(options = {}) {
|
|
100
|
-
const { calendar = true, blog = true, cookiePolicy = true, notFound = true, icons = true, seo = true } = options;
|
|
115
|
+
const { calendar = true, blog = true, cookiePolicy = true, accessibility = true, notFound = true, icons = true, seo = true } = options;
|
|
101
116
|
const fonts = ferstFonts().hooks;
|
|
102
117
|
/** This build's search context, decided at setup and used again when the build is done. */
|
|
103
118
|
let search = null;
|
|
@@ -150,6 +165,11 @@ export default function ferst(options = {}) {
|
|
|
150
165
|
entrypoint: 'ferst-core/routes/cookie-policy.astro',
|
|
151
166
|
});
|
|
152
167
|
}
|
|
168
|
+
// Every footer links to the accessibility statement. The site's own page wins.
|
|
169
|
+
const ownStatement = config?.root && existsSync(new URL('src/pages/accessibility.astro', config.root));
|
|
170
|
+
if (accessibility && !ownStatement) {
|
|
171
|
+
injectRoute({ pattern: '/accessibility', entrypoint: 'ferst-core/routes/accessibility.astro' });
|
|
172
|
+
}
|
|
153
173
|
// A missing address must answer 404, not the home page (Cloudflare Pages serves the
|
|
154
174
|
// home page with a 200 when a build has no 404.html). The site's own page wins.
|
|
155
175
|
const ownNotFound = config?.root && existsSync(new URL('src/pages/404.astro', config.root));
|
|
@@ -163,6 +183,7 @@ export default function ferst(options = {}) {
|
|
|
163
183
|
define: {
|
|
164
184
|
'import.meta.env.FERST_ROUTES_BLOG': JSON.stringify(blog),
|
|
165
185
|
'import.meta.env.FERST_ROUTES_CALENDAR': JSON.stringify(calendar),
|
|
186
|
+
'import.meta.env.FERST_ROUTES_ACCESSIBILITY': JSON.stringify(accessibility),
|
|
166
187
|
},
|
|
167
188
|
},
|
|
168
189
|
});
|
|
@@ -197,6 +218,21 @@ export default function ferst(options = {}) {
|
|
|
197
218
|
'astro:server:setup': fonts['astro:server:setup'],
|
|
198
219
|
'astro:build:done': async (ctx) => {
|
|
199
220
|
await fonts['astro:build:done'](ctx);
|
|
221
|
+
// Internal links point at each page's one address, never at a redirect (lib/seo.mjs).
|
|
222
|
+
if (seo) {
|
|
223
|
+
const isPage = (path) => {
|
|
224
|
+
try {
|
|
225
|
+
return existsSync(new URL(`.${path}index.html`, ctx.dir));
|
|
226
|
+
} catch {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
for (const file of htmlFiles(ctx.dir)) {
|
|
231
|
+
const html = readFileSync(file, 'utf8');
|
|
232
|
+
const linked = linksToPageAddresses(html, isPage);
|
|
233
|
+
if (linked !== html) writeFileSync(file, linked);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
200
236
|
// A test copy is noindex on every file: pages, and the PDFs and images beside them.
|
|
201
237
|
if (search && !search.production) {
|
|
202
238
|
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,
|
|
@@ -251,6 +256,8 @@ const hasFeed = Boolean(siteUrl) && import.meta.env.FERST_ROUTES_BLOG !== false;
|
|
|
251
256
|
</script>
|
|
252
257
|
</head>
|
|
253
258
|
<body>
|
|
259
|
+
<!-- The first thing a keyboard reaches: straight past the header to the page. -->
|
|
260
|
+
<a class="skip-link" href="#main">Skip to content</a>
|
|
254
261
|
<SiteHeader
|
|
255
262
|
path={path}
|
|
256
263
|
headerClass={headerClass}
|
|
@@ -262,7 +269,7 @@ const hasFeed = Boolean(siteUrl) && import.meta.env.FERST_ROUTES_BLOG !== false;
|
|
|
262
269
|
cta={navigation.cta}
|
|
263
270
|
/>
|
|
264
271
|
|
|
265
|
-
<main class:list={['site-main', mainClass]} data-content-align={align}>
|
|
272
|
+
<main id="main" tabindex="-1" class:list={['site-main', mainClass]} data-content-align={align}>
|
|
266
273
|
<slot name="hero" />
|
|
267
274
|
<slot />
|
|
268
275
|
{showSecondaryLogo && (secondaryLogoLight || secondaryLogoDark) && (
|
|
@@ -296,6 +303,14 @@ const hasFeed = Boolean(siteUrl) && import.meta.env.FERST_ROUTES_BLOG !== false;
|
|
|
296
303
|
all flow from the same variables — no per-mode hex overrides here.
|
|
297
304
|
*/
|
|
298
305
|
* { box-sizing: border-box; }
|
|
306
|
+
/* Skip link: hidden until a keyboard reaches it, then shown at the top left. */
|
|
307
|
+
.skip-link {
|
|
308
|
+
position: absolute; left: 0.75rem; top: -100%; z-index: 1000;
|
|
309
|
+
padding: 0.6rem 1rem; border-radius: 0.375rem;
|
|
310
|
+
background: var(--fg); color: var(--bg); font-weight: 600; text-decoration: none;
|
|
311
|
+
}
|
|
312
|
+
.skip-link:focus { top: 0.75rem; outline: 2px solid var(--bg); outline-offset: 2px; }
|
|
313
|
+
#main:focus { outline: none; }
|
|
299
314
|
html, body { margin: 0; padding: 0; }
|
|
300
315
|
html {
|
|
301
316
|
height: 100%;
|
package/lib/contrast.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Colour contrast for a theme (WCAG 2.2 AA): every text and brand colour the token builder
|
|
3
|
+
* (lib/themeTokens.ts) derives, measured against the ground it sits on, in light and dark
|
|
4
|
+
* mode. Theme Studio warns before saving a theme that fails; the tests hold the neutral
|
|
5
|
+
* defaults to it. Pure, so it's unit-testable.
|
|
6
|
+
*
|
|
7
|
+
* The derivations mirror themeTokens.ts: light mode mixes in sRGB (CSS `color-mix(in srgb)`),
|
|
8
|
+
* dark mode moves the same three colours in OKLCH. A colour that isn't hex (a CSS variable or
|
|
9
|
+
* function in an override) is skipped, never guessed.
|
|
10
|
+
*/
|
|
11
|
+
import type { ThemeSettings } from '../content/schemas';
|
|
12
|
+
import { DARK_DEFAULTS, NEUTRAL_SOURCE } from './themeDefaults';
|
|
13
|
+
|
|
14
|
+
type RGB = [number, number, number]; // 0..1, gamma-encoded sRGB
|
|
15
|
+
|
|
16
|
+
function parseHex(hex: string): RGB | null {
|
|
17
|
+
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(String(hex ?? '').trim());
|
|
18
|
+
if (!m) return null;
|
|
19
|
+
const h = m[1].length === 3 ? m[1].split('').map((c) => c + c).join('') : m[1];
|
|
20
|
+
return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255) as RGB;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
|
24
|
+
const toGamma = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
|
|
25
|
+
const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
|
|
26
|
+
|
|
27
|
+
function luminance([r, g, b]: RGB): number {
|
|
28
|
+
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function ratio(a: RGB, b: RGB): number {
|
|
32
|
+
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
|
33
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** WCAG contrast ratio between two hex colours (1 to 21). */
|
|
37
|
+
export function contrastRatio(a: string, b: string): number {
|
|
38
|
+
const ra = parseHex(a);
|
|
39
|
+
const rb = parseHex(b);
|
|
40
|
+
if (!ra || !rb) throw new Error(`not a hex colour: ${!ra ? a : b}`);
|
|
41
|
+
return ratio(ra, rb);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const rgbHex = (c: RGB) => '#' + c.map((v) => Math.round(v * 255).toString(16).padStart(2, '0')).join('');
|
|
45
|
+
|
|
46
|
+
/** CSS `color-mix(in srgb, a p, b)`. */
|
|
47
|
+
const mix = (a: RGB, b: RGB, p: number): RGB => a.map((v, i) => v * p + b[i] * (1 - p)) as RGB;
|
|
48
|
+
|
|
49
|
+
// OKLCH ⇄ sRGB (Björn Ottosson's OKLab).
|
|
50
|
+
function toOklch([r, g, b]: RGB): [number, number, number] {
|
|
51
|
+
const [lr, lg, lb] = [r, g, b].map(toLinear);
|
|
52
|
+
const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
|
|
53
|
+
const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
|
|
54
|
+
const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
|
|
55
|
+
const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
|
|
56
|
+
const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
|
|
57
|
+
const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
|
|
58
|
+
return [L, Math.hypot(A, B), Math.atan2(B, A)];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function fromOklch(L: number, C: number, h: number): RGB {
|
|
62
|
+
const A = C * Math.cos(h);
|
|
63
|
+
const B = C * Math.sin(h);
|
|
64
|
+
const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;
|
|
65
|
+
const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;
|
|
66
|
+
const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;
|
|
67
|
+
const lin = [
|
|
68
|
+
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
|
69
|
+
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
|
70
|
+
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
|
|
71
|
+
];
|
|
72
|
+
return lin.map((c) => clamp01(toGamma(clamp01(c)))) as RGB;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type ContrastPair = 'text' | 'secondary-text' | 'links' | 'button-text';
|
|
76
|
+
|
|
77
|
+
export interface ContrastIssue {
|
|
78
|
+
mode: 'light' | 'dark';
|
|
79
|
+
pair: ContrastPair;
|
|
80
|
+
/** What a person sees fail, in plain words. */
|
|
81
|
+
label: string;
|
|
82
|
+
ratio: number;
|
|
83
|
+
needs: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const LABELS: Record<ContrastPair, string> = {
|
|
87
|
+
text: 'Text on the page background',
|
|
88
|
+
'secondary-text': 'Secondary text (dates, captions) on the page background',
|
|
89
|
+
links: 'Links and brand-coloured text on the page background',
|
|
90
|
+
'button-text': 'Text on brand-coloured buttons',
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Text on brand fills in light mode (`--on-brand`): white, or the theme's ink when that reads
|
|
95
|
+
* better on the brand (a pale brand such as a gold). In dark mode it's the dark ground. Null
|
|
96
|
+
* when either colour isn't hex.
|
|
97
|
+
*/
|
|
98
|
+
export function onBrandColour(brand: string, ink: string): string | null {
|
|
99
|
+
const b = parseHex(brand);
|
|
100
|
+
const i = parseHex(ink);
|
|
101
|
+
if (!b || !i) return null;
|
|
102
|
+
return ratio(i, b) > ratio([1, 1, 1], b) ? ink.trim() : '#FFFFFF';
|
|
103
|
+
}
|
|
104
|
+
const AA = 4.5;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Every pair in `theme` that falls below WCAG AA (4.5:1), light mode then dark. Empty when the
|
|
108
|
+
* theme passes.
|
|
109
|
+
*/
|
|
110
|
+
export function contrastIssues(theme: ThemeSettings): ContrastIssue[] {
|
|
111
|
+
const brand = parseHex(theme.brand || NEUTRAL_SOURCE.brand) ?? parseHex(NEUTRAL_SOURCE.brand)!;
|
|
112
|
+
const ink = parseHex(theme.ink || NEUTRAL_SOURCE.ink) ?? parseHex(NEUTRAL_SOURCE.ink)!;
|
|
113
|
+
const surface = parseHex(theme.surface || NEUTRAL_SOURCE.surface) ?? parseHex(NEUTRAL_SOURCE.surface)!;
|
|
114
|
+
|
|
115
|
+
// Dark mode: the same three colours moved in OKLCH (themeTokens.ts DARK_DERIVATION), or the
|
|
116
|
+
// site's own overrides when they're hex.
|
|
117
|
+
const depth = typeof theme.darkDepth === 'number' ? theme.darkDepth : DARK_DEFAULTS.depth;
|
|
118
|
+
const cap = typeof theme.darkChroma === 'number' ? theme.darkChroma : DARK_DEFAULTS.chroma;
|
|
119
|
+
const [, sc, sh] = toOklch(surface);
|
|
120
|
+
const [, ic, ih] = toOklch(ink);
|
|
121
|
+
const [, bc, bh] = toOklch(brand);
|
|
122
|
+
const ov = theme.darkOverrides ?? { ground: '', text: '', accent: '' };
|
|
123
|
+
const override = (v: string | undefined) => (v ? parseHex(v) : undefined);
|
|
124
|
+
const ground = override(ov.ground) ?? (ov.ground ? null : fromOklch(depth, Math.min(Math.max(sc * 3.5, 0), cap), sh));
|
|
125
|
+
const text = override(ov.text) ?? (ov.text ? null : fromOklch(0.94, ic * 0.3, ih));
|
|
126
|
+
const accent = override(ov.accent) ?? (ov.accent ? null : fromOklch(0.78, bc * 0.9, bh));
|
|
127
|
+
const muted = text ? (() => { const [l, c, h] = toOklch(text); return fromOklch(l - 0.2, c, h); })() : null;
|
|
128
|
+
|
|
129
|
+
const checks: Array<[ContrastIssue['mode'], ContrastPair, RGB | null, RGB | null]> = [
|
|
130
|
+
['light', 'text', ink, surface],
|
|
131
|
+
['light', 'secondary-text', mix(ink, surface, 0.72), surface],
|
|
132
|
+
['light', 'links', brand, surface],
|
|
133
|
+
['light', 'button-text', parseHex(onBrandColour(rgbHex(brand), rgbHex(ink)) ?? '#FFFFFF'), brand],
|
|
134
|
+
['dark', 'text', text, ground],
|
|
135
|
+
['dark', 'secondary-text', muted, ground],
|
|
136
|
+
['dark', 'links', accent, ground],
|
|
137
|
+
['dark', 'button-text', ground, accent],
|
|
138
|
+
];
|
|
139
|
+
const issues: ContrastIssue[] = [];
|
|
140
|
+
for (const [mode, pair, fg, bg] of checks) {
|
|
141
|
+
if (!fg || !bg) continue; // a colour we can't read: skipped, never guessed
|
|
142
|
+
const r = ratio(fg, bg);
|
|
143
|
+
if (r < AA) issues.push({ mode, pair, label: LABELS[pair], ratio: Math.round(r * 100) / 100, needs: AA });
|
|
144
|
+
}
|
|
145
|
+
return issues;
|
|
146
|
+
}
|
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({
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The theme's fallbacks, shared by the token builder (themeTokens.ts) and the contrast check
|
|
3
|
+
* (contrast.ts) so both derive the same palette.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** The neutral source triple — used to fill any colour a client leaves blank so
|
|
7
|
+
* the derivation is always self-contained (mirrors `styles/theme.css`). */
|
|
8
|
+
export const NEUTRAL_SOURCE = {
|
|
9
|
+
brand: '#4F46E5',
|
|
10
|
+
ink: '#1F2937',
|
|
11
|
+
surface: '#F1F5F9',
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
14
|
+
/** Defaults for the dark-formula knobs (also the CMS/schema defaults). */
|
|
15
|
+
export const DARK_DEFAULTS = { depth: 0.21, chroma: 0.05 } as const;
|