ferst-core 0.7.12 → 0.8.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.
package/lib/seo.mjs ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Search signals every Ferst site gives search engines: one canonical address per page, a
3
+ * sitemap, robots.txt, and test copies kept out of search results. Pure helpers, shared by
4
+ * the ferst() integration (build time, Node) and its injected routes.
5
+ *
6
+ * The site's public address comes from `ferst-site.json` `siteUrl` (the platform sets it
7
+ * at setup and when the domain changes), or the `FERST_SITE_URL` env var. Without one, no
8
+ * address is guessed: canonical links and the sitemap need absolute addresses, so they're
9
+ * left out rather than pointed at a wrong host.
10
+ *
11
+ * Live site vs test copy: the production branch (`main`, or `productionBranch`) is the live
12
+ * site; any other branch's deploy is a test copy, the same rule the CMS uses to pick its
13
+ * git-proxy. A test copy is marked noindex everywhere (an `X-Robots-Tag` header on every
14
+ * file, and a robots meta on every page) while crawling stays allowed: an engine has to
15
+ * fetch a page to see its noindex.
16
+ */
17
+
18
+ /**
19
+ * An address's origin (`https://host`), or null when it isn't a web address. A bare host
20
+ * gets https; any path or query is dropped, since it would make every canonical wrong.
21
+ * @param {unknown} value
22
+ * @returns {string | null}
23
+ */
24
+ export function normaliseSiteUrl(value) {
25
+ if (typeof value !== 'string') return null;
26
+ const raw = value.trim();
27
+ if (!raw) return null;
28
+ const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`;
29
+ let url;
30
+ try {
31
+ url = new URL(withScheme);
32
+ } catch {
33
+ return null;
34
+ }
35
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') return null;
36
+ if (!url.hostname.includes('.') && url.hostname !== 'localhost') return null;
37
+ return url.origin;
38
+ }
39
+
40
+ /**
41
+ * The site's address and whether this build is the live site.
42
+ * @param {{ siteMeta?: { siteUrl?: string, productionBranch?: string } | null, branch: string, env?: Record<string, string | undefined> }} p
43
+ * @returns {{ siteUrl: string | null, production: boolean }}
44
+ */
45
+ export function seoContext({ siteMeta, branch, env = {} }) {
46
+ const siteUrl = normaliseSiteUrl(env.FERST_SITE_URL || siteMeta?.siteUrl);
47
+ const prodFromMeta = typeof siteMeta?.productionBranch === 'string' ? siteMeta.productionBranch.trim() : '';
48
+ const productionBranch = (env.FERST_PROD_BRANCH || prodFromMeta || 'main').trim();
49
+ return { siteUrl, production: branch === productionBranch };
50
+ }
51
+
52
+ /**
53
+ * A page's canonical address: the site's origin plus the path it's served at. Pages are
54
+ * folders (`/clergy/` serves `clergy/index.html`), so a page path gets a trailing slash; a
55
+ * file path (`/uploads/x.pdf`) is left as it is. Any query string is dropped.
56
+ * @param {string} siteUrl
57
+ * @param {string} pathname
58
+ */
59
+ export function canonicalUrl(siteUrl, pathname) {
60
+ let path = String(pathname || '/').split(/[?#]/)[0] || '/';
61
+ if (!path.startsWith('/')) path = `/${path}`;
62
+ const last = path.split('/').pop() ?? '';
63
+ const isFile = /\.[a-z0-9]{1,5}$/i.test(last);
64
+ if (!isFile && !path.endsWith('/')) path = `${path}/`;
65
+ return `${siteUrl}${path}`;
66
+ }
67
+
68
+ /**
69
+ * robots.txt. The live site allows everything and names its sitemap. A test copy also
70
+ * allows crawling (its pages carry noindex, which engines only see by fetching them) and
71
+ * names no sitemap.
72
+ * @param {{ siteUrl: string | null, production: boolean }} p
73
+ */
74
+ export function robotsTxt({ siteUrl, production }) {
75
+ if (!production) {
76
+ return [
77
+ '# A test copy: every page and file is marked noindex, so search engines leave it out.',
78
+ '# Crawling stays allowed, or they could not see that noindex.',
79
+ 'User-agent: *',
80
+ 'Allow: /',
81
+ '',
82
+ ].join('\n');
83
+ }
84
+ const lines = ['User-agent: *', 'Allow: /'];
85
+ if (siteUrl) lines.push('', `Sitemap: ${siteUrl}/sitemap.xml`);
86
+ return `${lines.join('\n')}\n`;
87
+ }
88
+
89
+ const XML_ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' };
90
+ const xmlEscape = (s) => String(s).replace(/[&<>"']/g, (c) => XML_ESCAPES[c]);
91
+
92
+ /**
93
+ * A sitemap (sitemaps.org protocol): each address once, with a last-modified date where known.
94
+ * @param {Array<{ loc: string, lastmod?: Date | string }>} entries
95
+ */
96
+ export function sitemapXml(entries) {
97
+ const seen = new Set();
98
+ const urls = [];
99
+ for (const { loc, lastmod } of entries) {
100
+ if (!loc || seen.has(loc)) continue;
101
+ seen.add(loc);
102
+ let date = '';
103
+ if (lastmod) {
104
+ const d = lastmod instanceof Date ? lastmod : new Date(lastmod);
105
+ if (!Number.isNaN(d.getTime())) date = d.toISOString().slice(0, 10);
106
+ }
107
+ urls.push(` <url><loc>${xmlEscape(loc)}</loc>${date ? `<lastmod>${date}</lastmod>` : ''}</url>`);
108
+ }
109
+ return [
110
+ '<?xml version="1.0" encoding="UTF-8"?>',
111
+ '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
112
+ ...urls,
113
+ '</urlset>',
114
+ '',
115
+ ].join('\n');
116
+ }
117
+
118
+ const NOINDEX_RULE = '/*\n X-Robots-Tag: noindex\n';
119
+
120
+ /**
121
+ * A Cloudflare Pages `_headers` file that marks every path noindex (pages and files such as
122
+ * PDFs alike), keeping any headers the site already sets. Idempotent.
123
+ * @param {string} existing
124
+ */
125
+ export function withNoindexHeader(existing) {
126
+ const current = String(existing ?? '');
127
+ if (/X-Robots-Tag:\s*noindex/i.test(current)) return current;
128
+ if (!current.trim()) return NOINDEX_RULE;
129
+ return `${current.replace(/\s*$/, '')}\n\n${NOINDEX_RULE}`;
130
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "ferst-core",
3
- "version": "0.7.12",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
- "description": "Ferst Core \u2014 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.",
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",
7
7
  "author": "MyAI4 Ltd (https://ferst.co.uk)",
8
8
  "homepage": "https://ferst.co.uk",
@@ -0,0 +1,36 @@
1
+ ---
2
+ /**
3
+ * The "page not found" page (`/404` → `404.html`): INJECTED by the ferst() integration.
4
+ * Cloudflare Pages answers a missing address with this page and a real 404 status. Without
5
+ * a `404.html` it serves the HOME PAGE with a 200 instead (a "soft 404"): search engines
6
+ * would see the home page at every mistyped or retired address, and a broken link would
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).
9
+ */
10
+ import Base from '../layouts/Base.astro';
11
+ import BlockRenderer from '../components/BlockRenderer.astro';
12
+ import { pageSchema } from '../content/blocks';
13
+
14
+ const blog = import.meta.env.FERST_ROUTES_BLOG !== false;
15
+ const page = pageSchema.parse({
16
+ title: 'Page not found',
17
+ blocks: [
18
+ {
19
+ type: 'hero',
20
+ title: 'Page not found',
21
+ subtitle: "The page you were looking for isn't here. It may have moved, or the link may be out of date.",
22
+ },
23
+ {
24
+ type: 'prose',
25
+ variant: 'lead',
26
+ text: blog ? 'The home page, or the latest news, is a good place to start.' : 'The home page is a good place to start.',
27
+ },
28
+ { type: 'button', label: 'Go to the home page', href: '/' },
29
+ ...(blog ? [{ type: 'button', label: 'Latest news', href: '/posts/', variant: 'secondary' }] : []),
30
+ ],
31
+ });
32
+ ---
33
+
34
+ <Base title={page.title} description="This page isn't here." indexable={false}>
35
+ <BlockRenderer blocks={page.blocks} />
36
+ </Base>
@@ -0,0 +1,15 @@
1
+ // /robots.txt: injected by the ferst() integration (lib/seo.mjs explains the rules). The
2
+ // live site names its sitemap; a test copy stays crawlable so engines see its noindex.
3
+ import type { APIRoute } from 'astro';
4
+ import { robotsTxt } from '../lib/seo.mjs';
5
+
6
+ export const prerender = true;
7
+
8
+ export const GET: APIRoute = () =>
9
+ new Response(
10
+ robotsTxt({
11
+ siteUrl: import.meta.env.FERST_SITE_URL || null,
12
+ production: import.meta.env.FERST_SEO_PRODUCTION !== false,
13
+ }),
14
+ { headers: { 'Content-Type': 'text/plain; charset=utf-8' } },
15
+ );
@@ -0,0 +1,37 @@
1
+ // /sitemap.xml: injected by the ferst() integration once the site has an address
2
+ // (`ferst-site.json` `siteUrl`). It lists every published page, the posts index, each post
3
+ // and each tag page in use, and the calendar when it's set up. Drafts, hidden tags, later
4
+ // listing pages (/posts/page/2…) and the cookie policy stay out.
5
+ import type { APIRoute } from 'astro';
6
+ import { getPageEntries, pageRouteParams } from '../lib/pages';
7
+ import { canonicalUrl, sitemapXml } from '../lib/seo.mjs';
8
+
9
+ export const prerender = true;
10
+
11
+ export const GET: APIRoute = async () => {
12
+ const site = String(import.meta.env.FERST_SITE_URL || '');
13
+ const entries: Array<{ loc: string; lastmod?: Date }> = [];
14
+
15
+ for (const page of await getPageEntries()) {
16
+ const { slug } = pageRouteParams(page.id);
17
+ entries.push({ loc: canonicalUrl(site, slug ? `/${slug}` : '/') });
18
+ }
19
+
20
+ if (import.meta.env.FERST_ROUTES_BLOG !== false) {
21
+ const { getPublishedPosts, getUsedTags } = await import('../lib/posts');
22
+ const posts = await getPublishedPosts();
23
+ if (posts.length) entries.push({ loc: canonicalUrl(site, '/posts'), lastmod: posts[0].data.date });
24
+ for (const post of posts) entries.push({ loc: canonicalUrl(site, `/posts/${post.slug}`), lastmod: post.data.date });
25
+ for (const tag of await getUsedTags()) {
26
+ if (!tag.hidden && tag.count > 0) entries.push({ loc: canonicalUrl(site, `/posts/tag/${tag.slug}`) });
27
+ }
28
+ }
29
+
30
+ if (import.meta.env.FERST_ROUTES_CALENDAR !== false) {
31
+ const { getCalendarSettings } = await import('../lib/calendar');
32
+ const calendar = await getCalendarSettings();
33
+ if (calendar?.embedUrl) entries.push({ loc: canonicalUrl(site, '/calendar') });
34
+ }
35
+
36
+ return new Response(sitemapXml(entries), { headers: { 'Content-Type': 'application/xml; charset=utf-8' } });
37
+ };