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/bin/ferst-update.mjs +72 -17
- package/components/recipes/Hero.astro +533 -530
- package/integration.mjs +195 -123
- package/layouts/Base.astro +512 -498
- package/lib/seo.mjs +130 -0
- package/package.json +2 -2
- package/routes/404.astro +36 -0
- package/routes/robots.txt.ts +15 -0
- package/routes/sitemap.xml.ts +37 -0
package/integration.mjs
CHANGED
|
@@ -1,123 +1,195 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ferst() — the Astro integration that makes a client site's ROUTING package-owned.
|
|
3
|
-
*
|
|
4
|
-
* A thin client adds `integrations: [ferst()]` to astro.config.mjs and carries
|
|
5
|
-
* ZERO page files: the page catch-all (which also owns the home page), the
|
|
6
|
-
* calendar route and the blog routes are injected from ferst-core. A routing fix
|
|
7
|
-
* then ships as a package bump + rebuild — the same channel component fixes travel
|
|
8
|
-
* through — instead of being hand-copied into every client repo.
|
|
9
|
-
*
|
|
10
|
-
* Irreducible caveat (Astro 5): content collections must be defined in the CONSUMER,
|
|
11
|
-
* so the one-line `src/content/config.ts` re-export stays. "Zero page files" is
|
|
12
|
-
* reachable; "zero files" is not. Explicit client `.astro` routes still win over
|
|
13
|
-
* these injected ones, so a one-off hand-built page is always possible.
|
|
14
|
-
*
|
|
15
|
-
* The blog routes (posts index / individual post / tag pages) inject as a set:
|
|
16
|
-
* the listing components (NewsList, PostsFilter) now live in core, so `/posts`,
|
|
17
|
-
* `/posts/[...slug]` and `/posts/tag/[...tag]` all ship from the package. Turn
|
|
18
|
-
* the whole capability off with `blog: false` (then a client wanting posts hand-
|
|
19
|
-
* builds its own routes over `ferst-core/lib/posts`).
|
|
20
|
-
*
|
|
21
|
-
* @param {object} [options]
|
|
22
|
-
* @param {boolean} [options.calendar=true] Inject the `/calendar` route.
|
|
23
|
-
* @param {boolean} [options.blog=true] Inject the blog routes (`/posts`,
|
|
24
|
-
* `/posts/[...slug]`, `/posts/tag/[...tag]`).
|
|
25
|
-
* @param {boolean} [options.cookiePolicy=true] Inject `/cookie-policy` (the page the cookie
|
|
26
|
-
* banner links to), generated from the blocks the site uses. Skipped automatically when
|
|
27
|
-
* the site has its own `src/pages/cookie-policy.astro`.
|
|
28
|
-
* @
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (!
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
1
|
+
/**
|
|
2
|
+
* ferst() — the Astro integration that makes a client site's ROUTING package-owned.
|
|
3
|
+
*
|
|
4
|
+
* A thin client adds `integrations: [ferst()]` to astro.config.mjs and carries
|
|
5
|
+
* ZERO page files: the page catch-all (which also owns the home page), the
|
|
6
|
+
* calendar route and the blog routes are injected from ferst-core. A routing fix
|
|
7
|
+
* then ships as a package bump + rebuild — the same channel component fixes travel
|
|
8
|
+
* through — instead of being hand-copied into every client repo.
|
|
9
|
+
*
|
|
10
|
+
* Irreducible caveat (Astro 5): content collections must be defined in the CONSUMER,
|
|
11
|
+
* so the one-line `src/content/config.ts` re-export stays. "Zero page files" is
|
|
12
|
+
* reachable; "zero files" is not. Explicit client `.astro` routes still win over
|
|
13
|
+
* these injected ones, so a one-off hand-built page is always possible.
|
|
14
|
+
*
|
|
15
|
+
* The blog routes (posts index / individual post / tag pages) inject as a set:
|
|
16
|
+
* the listing components (NewsList, PostsFilter) now live in core, so `/posts`,
|
|
17
|
+
* `/posts/[...slug]` and `/posts/tag/[...tag]` all ship from the package. Turn
|
|
18
|
+
* the whole capability off with `blog: false` (then a client wanting posts hand-
|
|
19
|
+
* builds its own routes over `ferst-core/lib/posts`).
|
|
20
|
+
*
|
|
21
|
+
* @param {object} [options]
|
|
22
|
+
* @param {boolean} [options.calendar=true] Inject the `/calendar` route.
|
|
23
|
+
* @param {boolean} [options.blog=true] Inject the blog routes (`/posts`,
|
|
24
|
+
* `/posts/[...slug]`, `/posts/tag/[...tag]`).
|
|
25
|
+
* @param {boolean} [options.cookiePolicy=true] Inject `/cookie-policy` (the page the cookie
|
|
26
|
+
* banner links to), generated from the blocks the site uses. Skipped automatically when
|
|
27
|
+
* the site has its own `src/pages/cookie-policy.astro`.
|
|
28
|
+
* @param {boolean} [options.notFound=true] Inject the "page not found" page (`/404` →
|
|
29
|
+
* `404.html`). Cloudflare Pages answers a missing address with it and a real 404 status;
|
|
30
|
+
* without one it serves the home page with a 200 (a "soft 404"). Skipped automatically
|
|
31
|
+
* when the site has its own `src/pages/404.astro` or `public/404.html`.
|
|
32
|
+
* @param {boolean} [options.seo=true] Search signals (lib/seo.mjs): `/robots.txt`, and once
|
|
33
|
+
* `ferst-site.json` has a `siteUrl`, `/sitemap.xml` and a canonical address on every page.
|
|
34
|
+
* A test copy (any branch but the production one) is marked noindex on every file. A
|
|
35
|
+
* site's own `public/robots.txt` or `public/sitemap.xml` wins.
|
|
36
|
+
* @returns {import('astro').AstroIntegration}
|
|
37
|
+
*/
|
|
38
|
+
import { copyFileSync, createReadStream, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
39
|
+
import { deploymentBranch } from './bin/cms-config.mjs';
|
|
40
|
+
import { seoContext, withNoindexHeader } from './lib/seo.mjs';
|
|
41
|
+
|
|
42
|
+
/** The self-hosted fonts the engine's CSS requests (/fonts/*.woff2), shipped in the package. */
|
|
43
|
+
const FONTS_DIR = new URL('./fonts/', import.meta.url);
|
|
44
|
+
const FONT_URL = /^\/fonts\/([a-z0-9-]+\.woff2)(?:\?.*)?$/;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* ferstFonts() — serves the engine's self-hosted fonts in `astro dev` and copies them into
|
|
48
|
+
* every build's `/fonts/`. Included in ferst(); usable on its own (the showcase). A file
|
|
49
|
+
* the site already has in its own `public/fonts/` is never overwritten.
|
|
50
|
+
* @returns {import('astro').AstroIntegration}
|
|
51
|
+
*/
|
|
52
|
+
export function ferstFonts() {
|
|
53
|
+
return {
|
|
54
|
+
name: 'ferst-fonts',
|
|
55
|
+
hooks: {
|
|
56
|
+
'astro:server:setup': ({ server }) => {
|
|
57
|
+
server.middlewares.use((req, res, next) => {
|
|
58
|
+
const m = req.url && req.url.match(FONT_URL);
|
|
59
|
+
if (!m) return next();
|
|
60
|
+
const file = new URL(m[1], FONTS_DIR);
|
|
61
|
+
if (!existsSync(file)) return next();
|
|
62
|
+
res.setHeader('Content-Type', 'font/woff2');
|
|
63
|
+
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
|
64
|
+
createReadStream(file).pipe(res);
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
'astro:build:done': async ({ dir }) => {
|
|
68
|
+
const out = new URL('fonts/', dir);
|
|
69
|
+
mkdirSync(out, { recursive: true });
|
|
70
|
+
for (const name of readdirSync(FONTS_DIR)) {
|
|
71
|
+
if (!name.endsWith('.woff2')) continue;
|
|
72
|
+
const dest = new URL(name, out);
|
|
73
|
+
if (!existsSync(dest)) copyFileSync(new URL(name, FONTS_DIR), dest);
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The site's `ferst-site.json`, or null (no file, or not JSON). */
|
|
81
|
+
function readSiteMeta(root) {
|
|
82
|
+
if (!root) return null;
|
|
83
|
+
try {
|
|
84
|
+
return JSON.parse(readFileSync(new URL('ferst-site.json', root), 'utf8'));
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Does the site ship this file in its own `public/`? (Its own file wins over ours.) */
|
|
91
|
+
const hasPublicFile = (root, name) => Boolean(root) && existsSync(new URL(`public/${name}`, root));
|
|
92
|
+
|
|
93
|
+
export default function ferst(options = {}) {
|
|
94
|
+
const { calendar = true, blog = true, cookiePolicy = true, notFound = true, seo = true } = options;
|
|
95
|
+
const fonts = ferstFonts().hooks;
|
|
96
|
+
/** This build's search context, decided at setup and used again when the build is done. */
|
|
97
|
+
let search = null;
|
|
98
|
+
return {
|
|
99
|
+
name: 'ferst',
|
|
100
|
+
hooks: {
|
|
101
|
+
'astro:config:setup': ({ injectRoute, updateConfig, config }) => {
|
|
102
|
+
// Pages + home: the reserved `index` entry renders at `/`, every other
|
|
103
|
+
// `pages` entry at its own slug (see lib/pages.ts `getSiteRoutes`).
|
|
104
|
+
injectRoute({
|
|
105
|
+
pattern: '/[...slug]',
|
|
106
|
+
entrypoint: 'ferst-core/routes/[...slug].astro',
|
|
107
|
+
});
|
|
108
|
+
if (calendar) {
|
|
109
|
+
injectRoute({
|
|
110
|
+
pattern: '/calendar',
|
|
111
|
+
entrypoint: 'ferst-core/routes/calendar/index.astro',
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
if (blog) {
|
|
115
|
+
// The posts capability: index, individual post, and tag pages. Reads
|
|
116
|
+
// the `posts`/`tags`/`postsSettings` collections (content/collections.ts).
|
|
117
|
+
injectRoute({
|
|
118
|
+
pattern: '/posts',
|
|
119
|
+
entrypoint: 'ferst-core/routes/posts/index.astro',
|
|
120
|
+
});
|
|
121
|
+
injectRoute({
|
|
122
|
+
pattern: '/posts/[...slug]',
|
|
123
|
+
entrypoint: 'ferst-core/routes/posts/[...slug].astro',
|
|
124
|
+
});
|
|
125
|
+
injectRoute({
|
|
126
|
+
pattern: '/posts/tag/[...tag]',
|
|
127
|
+
entrypoint: 'ferst-core/routes/posts/tag/[...tag].astro',
|
|
128
|
+
});
|
|
129
|
+
// Index pages 2+ (page 1 is /posts; tag pages paginate inside their own route).
|
|
130
|
+
injectRoute({
|
|
131
|
+
pattern: '/posts/page/[page]',
|
|
132
|
+
entrypoint: 'ferst-core/routes/posts/page/[page].astro',
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
// The cookie banner links here on every page, so it must always resolve. A site's
|
|
136
|
+
// own hand-built page wins (Astro would otherwise see two routes for one path).
|
|
137
|
+
const bespoke = config?.root && existsSync(new URL('src/pages/cookie-policy.astro', config.root));
|
|
138
|
+
if (cookiePolicy && !bespoke) {
|
|
139
|
+
injectRoute({
|
|
140
|
+
pattern: '/cookie-policy',
|
|
141
|
+
entrypoint: 'ferst-core/routes/cookie-policy.astro',
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
// A missing address must answer 404, not the home page (Cloudflare Pages serves the
|
|
145
|
+
// home page with a 200 when a build has no 404.html). The site's own page wins.
|
|
146
|
+
const ownNotFound = config?.root && existsSync(new URL('src/pages/404.astro', config.root));
|
|
147
|
+
if (notFound && !ownNotFound && !hasPublicFile(config?.root, '404.html')) {
|
|
148
|
+
injectRoute({ pattern: '/404', entrypoint: 'ferst-core/routes/404.astro' });
|
|
149
|
+
}
|
|
150
|
+
// Which optional routes this site has, for the pages that link to them (the sitemap,
|
|
151
|
+
// the "page not found" page).
|
|
152
|
+
updateConfig?.({
|
|
153
|
+
vite: {
|
|
154
|
+
define: {
|
|
155
|
+
'import.meta.env.FERST_ROUTES_BLOG': JSON.stringify(blog),
|
|
156
|
+
'import.meta.env.FERST_ROUTES_CALENDAR': JSON.stringify(calendar),
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
// Search signals: the site's address and whether this deploy is the live site (the
|
|
161
|
+
// production branch) or a test copy, the same rule the CMS uses for its git-proxy.
|
|
162
|
+
if (seo) {
|
|
163
|
+
const root = config?.root;
|
|
164
|
+
search = seoContext({ siteMeta: readSiteMeta(root), branch: deploymentBranch(), env: process.env });
|
|
165
|
+
updateConfig?.({
|
|
166
|
+
...(search.siteUrl && !config?.site ? { site: search.siteUrl } : {}),
|
|
167
|
+
vite: {
|
|
168
|
+
define: {
|
|
169
|
+
'import.meta.env.FERST_SITE_URL': JSON.stringify(search.siteUrl ?? ''),
|
|
170
|
+
'import.meta.env.FERST_SEO_PRODUCTION': JSON.stringify(search.production),
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
if (!hasPublicFile(root, 'robots.txt')) {
|
|
175
|
+
injectRoute({ pattern: '/robots.txt', entrypoint: 'ferst-core/routes/robots.txt.ts' });
|
|
176
|
+
}
|
|
177
|
+
// A sitemap lists absolute addresses, so it needs the site's address.
|
|
178
|
+
if (search.siteUrl && !hasPublicFile(root, 'sitemap.xml')) {
|
|
179
|
+
injectRoute({ pattern: '/sitemap.xml', entrypoint: 'ferst-core/routes/sitemap.xml.ts' });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
// The engine's self-hosted fonts: served in dev, copied into every build.
|
|
184
|
+
'astro:server:setup': fonts['astro:server:setup'],
|
|
185
|
+
'astro:build:done': async (ctx) => {
|
|
186
|
+
await fonts['astro:build:done'](ctx);
|
|
187
|
+
// A test copy is noindex on every file: pages, and the PDFs and images beside them.
|
|
188
|
+
if (search && !search.production) {
|
|
189
|
+
const file = new URL('_headers', ctx.dir);
|
|
190
|
+
writeFileSync(file, withNoindexHeader(existsSync(file) ? readFileSync(file, 'utf8') : ''));
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|