ferst-core 0.9.9 → 0.10.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-modules.mjs +2 -2
- package/components/recipes/Form.astro +7 -6
- package/lib/forms.ts +29 -4
- package/lib/git-base.mjs +15 -0
- package/package.json +1 -1
package/bin/ferst-modules.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// npx --no-install ferst-modules --json # the raw answer
|
|
8
8
|
//
|
|
9
9
|
// Forms and calendars live in the portal, never in the repo: place a form with a
|
|
10
|
-
// `{ "type": "form", "form": "<
|
|
10
|
+
// `{ "type": "form", "form": "<its name on this site>" }` block, and show the calendar by setting
|
|
11
11
|
// calendarSettings.embedUrl to the address printed here.
|
|
12
12
|
import { readFileSync, realpathSync } from 'node:fs';
|
|
13
13
|
import { resolve } from 'node:path';
|
|
@@ -46,7 +46,7 @@ export function formatModules(data) {
|
|
|
46
46
|
else if (data.forms.length === 0) out.push(' No forms yet. They are created in the portal (Forms).');
|
|
47
47
|
for (const f of data.forms) {
|
|
48
48
|
out.push(` ${f.name}`);
|
|
49
|
-
out.push(` place it with: { "type": "form", "form": "${f.id}" }`);
|
|
49
|
+
out.push(` place it with: { "type": "form", "form": "${f.slot ?? f.id}" }`);
|
|
50
50
|
out.push(` fields: ${f.fields.map((x) => `${x.label}${x.required ? ' *' : ''}`).join(', ') || '(none)'}`);
|
|
51
51
|
out.push(` button: ${f.submitLabel}`);
|
|
52
52
|
}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
|
-
* Form — one of the organisation's forms from the Ferst portal, placed by
|
|
4
|
-
* form's fields, button and submit address from the
|
|
5
|
-
*
|
|
6
|
-
* change made in the portal reaches the site on its next
|
|
3
|
+
* Form — one of the organisation's forms from the Ferst portal, placed by its name on this site
|
|
4
|
+
* (its slot; older pages give its id). Reads the form's fields, button and submit address from the
|
|
5
|
+
* platform at build time, through the site (lib/forms), and renders it with the ContactForm recipe,
|
|
6
|
+
* so it looks like every other form and a change made in the portal reaches the site on its next
|
|
7
|
+
* build.
|
|
7
8
|
*/
|
|
8
9
|
import ContactForm from './ContactForm.astro';
|
|
9
10
|
import { loadFormDefinition } from '../../lib/forms';
|
|
10
|
-
import { resolveGitBase } from '../../lib/git-base.mjs';
|
|
11
|
+
import { resolveGitBase, resolveSiteId } from '../../lib/git-base.mjs';
|
|
11
12
|
|
|
12
13
|
interface Props {
|
|
13
14
|
form: string;
|
|
@@ -16,7 +17,7 @@ interface Props {
|
|
|
16
17
|
note?: string;
|
|
17
18
|
}
|
|
18
19
|
const { form, title, intro, note } = Astro.props;
|
|
19
|
-
const def = await loadFormDefinition(form, { base: resolveGitBase() });
|
|
20
|
+
const def = await loadFormDefinition(form, { base: resolveGitBase(), siteId: resolveSiteId() });
|
|
20
21
|
---
|
|
21
22
|
|
|
22
23
|
<ContactForm
|
package/lib/forms.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A form placed
|
|
2
|
+
* A form placed on a page (the `form` block) is read from the Ferst platform at build time, so
|
|
3
3
|
* a change made to it in the portal reaches the site on its next build — no pasted copy of
|
|
4
4
|
* its fields to keep in step. A missing form or an unreachable platform fails the build:
|
|
5
5
|
* better the last good deploy stays live than a form that can't be sent goes out.
|
|
6
|
+
*
|
|
7
|
+
* The block names the form by its name on this site — its slot — and the site says who it is
|
|
8
|
+
* (`siteId` in ferst-site.json): the platform answers with whichever of the organisation's forms
|
|
9
|
+
* fills that slot, and the form posts to the site's own address for it. So the organisation can
|
|
10
|
+
* swap, copy or move forms without anyone editing the site. Pages from before slots name a form
|
|
11
|
+
* by its id, which still answers; a platform that doesn't know the site (a site id from another
|
|
12
|
+
* environment) falls back to the form's id.
|
|
6
13
|
*/
|
|
7
14
|
export interface FormField {
|
|
8
15
|
name: string;
|
|
@@ -24,17 +31,35 @@ type FetchFn = (input: string, init?: RequestInit) => Promise<Response>;
|
|
|
24
31
|
|
|
25
32
|
const cache = new Map<string, Promise<FormDefinition>>();
|
|
26
33
|
|
|
27
|
-
export function loadFormDefinition(
|
|
28
|
-
const key = `${opts.base}|${
|
|
34
|
+
export function loadFormDefinition(ref: string, opts: { base: string; siteId?: string | null; fetch?: FetchFn }): Promise<FormDefinition> {
|
|
35
|
+
const key = `${opts.base}|${opts.siteId ?? ''}|${ref}`;
|
|
29
36
|
let hit = cache.get(key);
|
|
30
37
|
if (!hit) {
|
|
31
|
-
hit = fetchDefinition(
|
|
38
|
+
hit = opts.siteId ? fetchThroughSite(ref, opts.siteId, opts.base, opts.fetch ?? fetch) : fetchDefinition(ref, opts.base, opts.fetch ?? fetch);
|
|
32
39
|
cache.set(key, hit);
|
|
33
40
|
hit.catch(() => cache.delete(key)); // a failure is retried by the next caller
|
|
34
41
|
}
|
|
35
42
|
return hit;
|
|
36
43
|
}
|
|
37
44
|
|
|
45
|
+
async function fetchThroughSite(slot: string, siteId: string, base: string, fetchFn: FetchFn): Promise<FormDefinition> {
|
|
46
|
+
const url = `${base}/sites/${encodeURIComponent(siteId)}/forms/${encodeURIComponent(slot)}`;
|
|
47
|
+
let res: Response;
|
|
48
|
+
try {
|
|
49
|
+
res = await fetchFn(url, { headers: { Accept: 'application/json' } });
|
|
50
|
+
} catch (e) {
|
|
51
|
+
throw new Error(`Form block: couldn’t reach the Ferst platform for form "${slot}" (${url}): ${e instanceof Error ? e.message : String(e)}`);
|
|
52
|
+
}
|
|
53
|
+
if (res.status === 404) {
|
|
54
|
+
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
|
55
|
+
if (body.error === 'site_not_found') return fetchDefinition(slot, base, fetchFn);
|
|
56
|
+
throw new Error(`Form block: form "${slot}" isn’t on this site. Check its name on the site in the portal (Forms → Place on a site), or run \`npx ferst-modules\` to list this site's forms.`);
|
|
57
|
+
}
|
|
58
|
+
if (!res.ok) throw new Error(`Form block: the Ferst platform answered ${res.status} for form "${slot}" (${url}).`);
|
|
59
|
+
const def = (await res.json()) as { name: string; submit_label?: string; fields?: FormField[]; action: string };
|
|
60
|
+
return { name: def.name, submitLabel: def.submit_label || 'Send', fields: def.fields ?? [], action: def.action };
|
|
61
|
+
}
|
|
62
|
+
|
|
38
63
|
async function fetchDefinition(id: string, base: string, fetchFn: FetchFn): Promise<FormDefinition> {
|
|
39
64
|
const url = `${base}/forms/${encodeURIComponent(id)}`;
|
|
40
65
|
let res: Response;
|
package/lib/git-base.mjs
CHANGED
|
@@ -46,6 +46,21 @@ export function resolveGitBase(cwd = process.cwd(), branch = deploymentBranch())
|
|
|
46
46
|
return branch === prodBranch ? PROD_GIT_BASE : DEV_GIT_BASE;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Who this site is on the Ferst platform: `FERST_SITE_ID` env, else ferst-site.json's `siteId`
|
|
51
|
+
* (written when the site was set up), else null. The Form block asks for its forms through it.
|
|
52
|
+
*/
|
|
53
|
+
export function resolveSiteId(cwd = process.cwd()) {
|
|
54
|
+
const fromEnv = env('FERST_SITE_ID');
|
|
55
|
+
if (fromEnv) return fromEnv;
|
|
56
|
+
try {
|
|
57
|
+
const site = JSON.parse(readFileSync(resolve(cwd, 'ferst-site.json'), 'utf8'));
|
|
58
|
+
return site && typeof site.siteId === 'string' && site.siteId.trim() ? site.siteId.trim() : null;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
49
64
|
/** The deployment branch: CF Pages env → current git branch → main. */
|
|
50
65
|
export function deploymentBranch() {
|
|
51
66
|
const cf = env('CF_PAGES_BRANCH');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ferst-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
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",
|