ferst-core 0.7.13 → 0.8.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.
- package/cms/config.template.yml +1 -0
- package/components/SiteHeader.astro +4 -1
- package/components/recipes/Hero.astro +533 -530
- package/content/cookies.ts +104 -31
- package/content/schemas.ts +4 -0
- package/integration.mjs +208 -123
- package/layouts/Base.astro +515 -498
- package/lib/seo.mjs +130 -0
- package/lib/site-icon.mjs +145 -0
- package/package.json +1 -1
- package/routes/404.astro +36 -0
- package/routes/cookie-policy.astro +8 -7
- package/routes/robots.txt.ts +15 -0
- package/routes/sitemap.xml.ts +37 -0
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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
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
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The site's browser icon, written into every build by the ferst() integration:
|
|
3
|
+
* `/favicon.ico` (32 and 48 px, as PNGs inside the .ico, which every current browser
|
|
4
|
+
* reads) and `/apple-touch-icon.png` (180 px, opaque, for phones' home screens). Base links
|
|
5
|
+
* both. A migrated site's old `/favicon.ico` address keeps working this way too.
|
|
6
|
+
*
|
|
7
|
+
* Where the icon comes from, first match wins:
|
|
8
|
+
* 1. the browser icon set in the CMS (logoSettings `favicon`);
|
|
9
|
+
* 2. the site's header logo, when it's roughly square (a wide wordmark would shrink to an
|
|
10
|
+
* unreadable strip);
|
|
11
|
+
* 3. the site's initial, in white on its brand colour (or in ink on a light brand colour).
|
|
12
|
+
* A site's own `public/favicon.ico` or `public/apple-touch-icon.png` is never overwritten.
|
|
13
|
+
* Drawing needs `sharp` (installed with Astro). Without it the build goes on with a warning.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
16
|
+
|
|
17
|
+
/** A logo counts as square when its width over height is within these bounds. */
|
|
18
|
+
const SQUARE = [0.8, 1.25];
|
|
19
|
+
|
|
20
|
+
/** The first letter (or digit) of the site's name, upper case; '' when there's none. */
|
|
21
|
+
export function initialOf(name) {
|
|
22
|
+
const m = String(name ?? '').match(/[\p{L}\p{N}]/u);
|
|
23
|
+
return m ? m[0].toLocaleUpperCase('en-GB') : '';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Relative luminance of a #rrggbb colour (0 dark … 1 light); null when it isn't one. */
|
|
27
|
+
function luminance(hex) {
|
|
28
|
+
const m = /^#?([0-9a-f]{6})$/i.exec(String(hex ?? '').trim());
|
|
29
|
+
if (!m) return null;
|
|
30
|
+
const [r, g, b] = [0, 2, 4].map((i) => {
|
|
31
|
+
const c = parseInt(m[1].slice(i, i + 2), 16) / 255;
|
|
32
|
+
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
33
|
+
});
|
|
34
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The initial on a rounded tile of the brand colour, as SVG. */
|
|
38
|
+
export function monogramSvg({ letter, background, ink = '#1e2433' }) {
|
|
39
|
+
const bg = luminance(background) === null ? '#1e2433' : background;
|
|
40
|
+
const fg = (luminance(bg) ?? 0) > 0.5 ? ink : '#ffffff';
|
|
41
|
+
return (
|
|
42
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 64 64">' +
|
|
43
|
+
`<rect width="64" height="64" rx="14" fill="${bg}"/>` +
|
|
44
|
+
(letter
|
|
45
|
+
? `<text x="32" y="44.5" text-anchor="middle" font-family="Poppins, Inter, Helvetica, Arial, sans-serif" font-weight="700" font-size="36" fill="${fg}">${letter}</text>`
|
|
46
|
+
: '') +
|
|
47
|
+
'</svg>'
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** An .ico file holding each image as a PNG (Windows Vista+ and every current browser). */
|
|
52
|
+
export function icoFromPngs(images) {
|
|
53
|
+
const header = Buffer.alloc(6);
|
|
54
|
+
header.writeUInt16LE(0, 0); // reserved
|
|
55
|
+
header.writeUInt16LE(1, 2); // 1 = icon
|
|
56
|
+
header.writeUInt16LE(images.length, 4);
|
|
57
|
+
let offset = 6 + 16 * images.length;
|
|
58
|
+
const entries = images.map(({ size, png }) => {
|
|
59
|
+
const e = Buffer.alloc(16);
|
|
60
|
+
e.writeUInt8(size >= 256 ? 0 : size, 0); // width (0 means 256)
|
|
61
|
+
e.writeUInt8(size >= 256 ? 0 : size, 1); // height
|
|
62
|
+
e.writeUInt16LE(1, 4); // colour planes
|
|
63
|
+
e.writeUInt16LE(32, 6); // bits per pixel
|
|
64
|
+
e.writeUInt32LE(png.length, 8);
|
|
65
|
+
e.writeUInt32LE(offset, 12);
|
|
66
|
+
offset += png.length;
|
|
67
|
+
return e;
|
|
68
|
+
});
|
|
69
|
+
return Buffer.concat([header, ...entries, ...images.map((i) => i.png)]);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const readJson = (url) => {
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(readFileSync(url, 'utf8'));
|
|
75
|
+
} catch {
|
|
76
|
+
return {};
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** A site path such as `/uploads/crest.png` → its file in public/, if it's there. */
|
|
81
|
+
function publicFile(root, path) {
|
|
82
|
+
if (typeof path !== 'string' || !path.startsWith('/') || path.includes('..')) return null;
|
|
83
|
+
const file = new URL(`public${path}`, root);
|
|
84
|
+
return existsSync(file) ? file : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Write `favicon.ico` and `apple-touch-icon.png` into the build output. Never throws.
|
|
89
|
+
* @param {{ root: URL, outDir: URL, sharp: any, warn?: (message: string) => void }} p
|
|
90
|
+
* @returns {Promise<{ source: 'favicon' | 'logo' | 'initial' | null, wrote: string[] }>}
|
|
91
|
+
*/
|
|
92
|
+
export async function writeSiteIcons({ root, outDir, sharp, warn = (m) => console.warn(m) }) {
|
|
93
|
+
const wanted = ['favicon.ico', 'apple-touch-icon.png'].filter((name) => !existsSync(new URL(`public/${name}`, root)));
|
|
94
|
+
if (!wanted.length) return { source: null, wrote: [] };
|
|
95
|
+
if (!sharp) {
|
|
96
|
+
warn('[ferst] No browser icon written: the image library (sharp) is not installed.');
|
|
97
|
+
return { source: null, wrote: [] };
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const logos = readJson(new URL('src/content/logoSettings/index.json', root));
|
|
101
|
+
const settings = readJson(new URL('src/content/siteSettings/index.json', root));
|
|
102
|
+
const theme = readJson(new URL('src/content/themeSettings/index.json', root));
|
|
103
|
+
|
|
104
|
+
let source = 'initial';
|
|
105
|
+
let input = null;
|
|
106
|
+
const chosen = publicFile(root, logos.favicon);
|
|
107
|
+
if (chosen) {
|
|
108
|
+
source = 'favicon';
|
|
109
|
+
input = readFileSync(chosen);
|
|
110
|
+
} else {
|
|
111
|
+
const logo = publicFile(root, logos.primaryLogoLight);
|
|
112
|
+
if (logo) {
|
|
113
|
+
const { width, height } = await sharp(readFileSync(logo)).metadata();
|
|
114
|
+
if (width && height && width / height >= SQUARE[0] && width / height <= SQUARE[1]) {
|
|
115
|
+
source = 'logo';
|
|
116
|
+
input = readFileSync(logo);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (!input) {
|
|
121
|
+
const name = settings.identity?.shortName || settings.identity?.siteTitle || '';
|
|
122
|
+
input = Buffer.from(monogramSvg({ letter: initialOf(name), background: theme.brand, ink: theme.ink }));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const draw = (size) =>
|
|
126
|
+
sharp(input, { density: 384 })
|
|
127
|
+
.resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
|
128
|
+
.png();
|
|
129
|
+
const wrote = [];
|
|
130
|
+
if (wanted.includes('favicon.ico')) {
|
|
131
|
+
const images = await Promise.all([32, 48].map(async (size) => ({ size, png: await draw(size).toBuffer() })));
|
|
132
|
+
writeFileSync(new URL('favicon.ico', outDir), icoFromPngs(images));
|
|
133
|
+
wrote.push('favicon.ico');
|
|
134
|
+
}
|
|
135
|
+
if (wanted.includes('apple-touch-icon.png')) {
|
|
136
|
+
const ground = luminance(theme.surface) === null ? '#ffffff' : theme.surface;
|
|
137
|
+
writeFileSync(new URL('apple-touch-icon.png', outDir), await draw(180).flatten({ background: ground }).toBuffer());
|
|
138
|
+
wrote.push('apple-touch-icon.png');
|
|
139
|
+
}
|
|
140
|
+
return { source, wrote };
|
|
141
|
+
} catch (e) {
|
|
142
|
+
warn(`[ferst] No browser icon written: ${e?.message ?? e}`);
|
|
143
|
+
return { source: null, wrote: [] };
|
|
144
|
+
}
|
|
145
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ferst-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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",
|
package/routes/404.astro
ADDED
|
@@ -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>
|
|
@@ -1,25 +1,26 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
3
|
* The cookie policy (`/cookie-policy`) — INJECTED by the ferst() integration, so the
|
|
4
|
-
* cookie banner's link always resolves. Generated from what this site actually
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* cookie banner's link always resolves. Generated from what this site actually loads:
|
|
5
|
+
* the blocks on its pages and their sources (a YouTube video is listed; a video file the
|
|
6
|
+
* site hosts is not; a map with no address loads nothing) and whether a calendar is
|
|
7
|
+
* configured. A site with its own `src/pages/cookie-policy.astro` keeps it
|
|
7
8
|
* (the integration skips this route then).
|
|
8
9
|
*/
|
|
9
10
|
import Base from '../layouts/Base.astro';
|
|
10
11
|
import CookiePolicy from '../components/CookiePolicy.astro';
|
|
11
12
|
import { getPageEntries } from '../lib/pages';
|
|
12
13
|
import { getCalendarSettings } from '../lib/calendar';
|
|
13
|
-
import {
|
|
14
|
+
import { scanCookieUses, type BlockLike } from '../content/cookies';
|
|
14
15
|
import { pageSchema } from '../content/blocks';
|
|
15
16
|
|
|
16
|
-
const
|
|
17
|
+
const blocks: BlockLike[] = [];
|
|
17
18
|
for (const entry of await getPageEntries()) {
|
|
18
19
|
const page = pageSchema.safeParse(entry.data);
|
|
19
|
-
if (page.success)
|
|
20
|
+
if (page.success) blocks.push(...(page.data.blocks as BlockLike[]));
|
|
20
21
|
}
|
|
21
22
|
const cal = await getCalendarSettings();
|
|
22
|
-
const model = scanCookieUses({
|
|
23
|
+
const model = scanCookieUses({ blocks, calendarConfigured: Boolean(cal.embedUrl) });
|
|
23
24
|
---
|
|
24
25
|
|
|
25
26
|
<Base title="Cookie Policy" description="What this site stores on your device, and the third-party content it can show.">
|
|
@@ -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
|
+
};
|