@sandboxsonny/ace-kit 0.6.0 → 0.7.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/CHANGELOG.md +13 -0
- package/README.md +1 -1
- package/dist/astro-integration.js +1 -1
- package/dist/{chunk-G5FGBOFT.js → chunk-2W2NWDKV.js} +12 -9
- package/dist/cli.js +2 -2
- package/dist/{environment-T5K5ZJ2M.js → environment-TGMGSRJ3.js} +2 -1
- package/dist/{main-YRQQWLHX.js → main-IAQ2IL3M.js} +497 -231
- package/dist/react-router-server.d.ts +1 -1
- package/dist/vite.js +1 -1
- package/docs/astro.md +6 -8
- package/docs/command-line.md +32 -10
- package/docs/dev-sites.md +1 -1
- package/docs/react-router.md +36 -9
- package/docs/react.md +5 -2
- package/docs/typed-content.md +1 -1
- package/package.json +1 -1
- package/templates/react-router/app/ace-kit.generated.d.ts +7 -1
- package/templates/react-router/app/app.css +85 -0
- package/templates/react-router/app/components/page-content.tsx +47 -24
- package/templates/react-router/app/components/sections/faq.schema.ts +17 -0
- package/templates/react-router/app/components/sections/faq.tsx +26 -0
- package/templates/react-router/app/components/sections/gallery.schema.ts +20 -0
- package/templates/react-router/app/components/sections/gallery.tsx +39 -0
- package/templates/react-router/app/components/sections/hero.schema.ts +14 -0
- package/templates/react-router/app/components/sections/hero.tsx +25 -0
- package/templates/react-router/app/routes/page.test.tsx +54 -0
- package/templates/react-router/app/routes.ts +7 -3
- package/templates/react-router.section-types.json +109 -0
|
@@ -142,7 +142,7 @@ declare const getMenuLinks: (handle: string, options?: {
|
|
|
142
142
|
fallback?: AceNavigationLink[];
|
|
143
143
|
}) => Promise<AceNavigationLink[]>;
|
|
144
144
|
/**
|
|
145
|
-
* The loader for the route that draws every page. The route is a catch-all (`route("*", "routes/page.tsx")`):
|
|
145
|
+
* The loader for the route that draws every page. The route is a catch-all, plus an index line because "*" does not match "/" (`index("routes/page.tsx", { id: "home" })` and `route("*", "routes/page.tsx", { id: "page" })`):
|
|
146
146
|
*
|
|
147
147
|
* export const loader = createPageLoader<PageValues>();
|
|
148
148
|
* export { headers } from "@sandboxsonny/ace-kit/react-router/server";
|
package/dist/vite.js
CHANGED
package/docs/astro.md
CHANGED
|
@@ -15,21 +15,19 @@ export default defineConfig({
|
|
|
15
15
|
});
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
The integration declares
|
|
18
|
+
The integration declares the site's settings, adds the middleware that lets the CMS's admin app (and only it) frame the site for [live preview](./live-preview.md), and makes Vite process the kit. The settings are read when the server starts, not baked into the build, so one build runs anywhere:
|
|
19
19
|
|
|
20
|
-
| Setting
|
|
21
|
-
|
|
|
22
|
-
| `
|
|
23
|
-
| `ACE_SITE_DOMAIN` | the host of Astro's `site` setting, else `localhost` | the domain this site has in the CMS (the port is ignored) |
|
|
24
|
-
| `ACE_PREVIEW_ADMIN_ORIGIN` | `https://app.ace-cms.co.uk` | where the CMS admin app runs |
|
|
20
|
+
| Setting | Default | Meaning |
|
|
21
|
+
| ----------------- | ---------------------------------------------------- | --------------------------------------------------------- |
|
|
22
|
+
| `ACE_SITE_DOMAIN` | the host of Astro's `site` setting, else `localhost` | the domain this site has in the CMS (the port is ignored) |
|
|
25
23
|
|
|
26
|
-
|
|
24
|
+
A site connects to CMS Ace by itself and only needs to say which site it is, which it does with Astro's own `site` setting, which Astro needs for canonical addresses anyway:
|
|
27
25
|
|
|
28
26
|
```js
|
|
29
27
|
export default defineConfig({ site: "https://www.example.com", integrations: [ace()] });
|
|
30
28
|
```
|
|
31
29
|
|
|
32
|
-
To try a site without editing the config, `npm run dev -- --site https://www.example.com` does the same. `ACE_SITE_DOMAIN`, when set, wins.
|
|
30
|
+
To try a site without editing the config, `npm run dev -- --site https://www.example.com` does the same. `ACE_SITE_DOMAIN`, when set, wins.
|
|
33
31
|
|
|
34
32
|
## Pages
|
|
35
33
|
|
package/docs/command-line.md
CHANGED
|
@@ -7,11 +7,12 @@ npx @sandboxsonny/ace-kit create my-site --site my-site.com # make a new site
|
|
|
7
7
|
npx ace-kit login # sign in
|
|
8
8
|
npx ace-kit whoami # who am I signed in as?
|
|
9
9
|
npx ace-kit logout # sign out
|
|
10
|
+
npx ace-kit doctor # check this site is set up properly (see Checking a site)
|
|
10
11
|
npx ace-kit generate # write TypeScript types (see Typed content)
|
|
11
12
|
npx ace-kit sync # send your section types to the CMS (see Sections)
|
|
12
13
|
```
|
|
13
14
|
|
|
14
|
-
Commands
|
|
15
|
+
Commands connect to CMS Ace for you, so `ace-kit login` and `ace-kit whoami` need no flags at all. Commands about a site (`sync`, `generate`, `doctor`) take `--site example.com`, or read `ACE_SITE_DOMAIN`.
|
|
15
16
|
|
|
16
17
|
## Making a site
|
|
17
18
|
|
|
@@ -23,24 +24,45 @@ One command makes everything a new site needs:
|
|
|
23
24
|
|
|
24
25
|
1. **Checks** the folder, the domain and the name, before anything is made.
|
|
25
26
|
2. **Signs you in** if you aren't already (your browser opens; type the code from the terminal, as in [Signing in](#signing-in)).
|
|
26
|
-
3. **Makes the site in CMS Ace** and gives it a starter: a `pages` content type, Home, About and Contact entries, a main menu and
|
|
27
|
+
3. **Makes the site in CMS Ace** and gives it a starter: a `pages` content type, Home, About and Contact entries, a main menu and the three example section types (hero, FAQ, gallery).
|
|
27
28
|
4. **Copies a starter site** into `my-site/` with its name, domain and package name filled in.
|
|
28
29
|
|
|
29
30
|
If a site with that domain already exists in CMS Ace, it is **left alone** (nothing is added to it, in case it has real content), a warning says so, and only the folder is made. If the CMS refuses to make the site (for instance, you have made as many as your account allows) nothing is made at all.
|
|
30
31
|
|
|
31
32
|
Your account must be a **partner or super admin** (only they can make sites). The login `create` uses can set up the site it just made, for an hour, and no other site: it can't write content anywhere else.
|
|
32
33
|
|
|
33
|
-
| Option | Meaning
|
|
34
|
-
| --------------------------------- |
|
|
35
|
-
| `<domain>` (or `--site <domain>`) | The domain the site has in CMS Ace. Required.
|
|
36
|
-
| `--framework astro\|react-router` | `astro` (the default) or `react-router`.
|
|
37
|
-
| `--name <name>` | The name shown to visitors. Default: made from the folder, so `my-site` is "My Site".
|
|
38
|
-
| `--api-url <address>` | Only for a CMS on your own machine (or set `ACE_API_URL`). |
|
|
39
|
-
|
|
40
|
-
Against a CMS on your own machine (`--api-url` or `ACE_API_URL` set to `http://localhost:8080`), it also writes a `.env` in the new folder with `ACE_API_URL` and `ACE_PREVIEW_ADMIN_ORIGIN=http://localhost:5173`, and sets the site's Preview address to the local dev server (`http://localhost:4321`, or 4322 for React Router), so the site and live preview work with no more setup. Against the hosted CMS Ace none of that is needed.
|
|
34
|
+
| Option | Meaning |
|
|
35
|
+
| --------------------------------- | ------------------------------------------------------------------------------------- |
|
|
36
|
+
| `<domain>` (or `--site <domain>`) | The domain the site has in CMS Ace. Required. |
|
|
37
|
+
| `--framework astro\|react-router` | `astro` (the default) or `react-router`. Both have the same three example sections. |
|
|
38
|
+
| `--name <name>` | The name shown to visitors. Default: made from the folder, so `my-site` is "My Site". |
|
|
41
39
|
|
|
42
40
|
The folder must not exist yet (or must be empty). The starter is a small server-rendered site with a header, footer, rich text, live preview and Prettier, ESLint, tests and a CI workflow. When it finishes: `cd my-site`, `npm install`, `npm run dev`.
|
|
43
41
|
|
|
42
|
+
## Checking a site
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
npx ace-kit doctor
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Run it in your site's folder when something isn't working. It changes nothing, never prints your login, and says what to do about each problem:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
✓ Node 24.14.0
|
|
52
|
+
✓ An Astro site
|
|
53
|
+
✓ Ace Kit 0.6.0 is installed
|
|
54
|
+
✓ CMS: https://api.ace-cms.co.uk (the hosted CMS Ace)
|
|
55
|
+
✓ Site: shop.example.com
|
|
56
|
+
✓ The CMS answers
|
|
57
|
+
✓ The CMS knows the site: "Shop"
|
|
58
|
+
✓ Pages has 3 published entries, including home
|
|
59
|
+
✓ Signed in as sam@example.com
|
|
60
|
+
✓ 3 section types in your code (faq, gallery, hero)
|
|
61
|
+
✓ The CMS has all of them
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
It checks your Node version, that Ace Kit is installed, which CMS and site your settings point at, that the CMS can be reached and knows the site, that the site has its Pages content, that you are signed in, the site's preview address, and that the section types in your code match the CMS. `✗` is a problem (the command exits with 1), `!` is a warning, `-` is a note. It takes `--site` and `--dir` like `sync`.
|
|
65
|
+
|
|
44
66
|
## Signing in
|
|
45
67
|
|
|
46
68
|
```sh
|
package/docs/dev-sites.md
CHANGED
|
@@ -34,7 +34,7 @@ A dev site is never merged into the live site. When your change is ready, deploy
|
|
|
34
34
|
|
|
35
35
|
## If something stops it
|
|
36
36
|
|
|
37
|
-
The dev server says so once, and carries on showing the **live site**, read-only, with section sync off: you aren't signed in (run `npx ace-kit login
|
|
37
|
+
The dev server says so once, and carries on showing the **live site**, read-only, with section sync off: you aren't signed in (run `npx ace-kit login`), your account can't have a dev site, the site isn't one you can see, or the CMS can't be reached.
|
|
38
38
|
|
|
39
39
|
## Housekeeping
|
|
40
40
|
|
package/docs/react-router.md
CHANGED
|
@@ -4,7 +4,7 @@ Ace Kit has a React Router adapter, so a site only writes its design. It needs `
|
|
|
4
4
|
|
|
5
5
|
```
|
|
6
6
|
app/
|
|
7
|
-
routes.ts route("*"
|
|
7
|
+
routes.ts index + route("*") for routes/page.tsx one route draws every page
|
|
8
8
|
entry.server.tsx one line
|
|
9
9
|
root.tsx createRootLoader + your layout and header
|
|
10
10
|
routes/page.tsx createPageLoader + your page
|
|
@@ -26,13 +26,11 @@ export default defineConfig({ plugins: [ace(), reactRouter()] });
|
|
|
26
26
|
|
|
27
27
|
The settings are environment variables, read when the server starts, so one build runs anywhere. Put them in `.env` for development and in the host's environment for production:
|
|
28
28
|
|
|
29
|
-
| Variable
|
|
30
|
-
|
|
|
31
|
-
| `
|
|
32
|
-
| `ACE_SITE_DOMAIN` | `localhost` | the domain this site has in the CMS (the port is ignored) |
|
|
33
|
-
| `ACE_PREVIEW_ADMIN_ORIGIN` | `https://app.ace-cms.co.uk` | where the CMS admin app runs |
|
|
29
|
+
| Variable | Default | Meaning |
|
|
30
|
+
| ----------------- | ----------- | --------------------------------------------------------- |
|
|
31
|
+
| `ACE_SITE_DOMAIN` | `localhost` | the domain this site has in the CMS (the port is ignored) |
|
|
34
32
|
|
|
35
|
-
|
|
33
|
+
A site connects to CMS Ace by itself and only needs to say which site it is, with `ACE_SITE_DOMAIN`.
|
|
36
34
|
|
|
37
35
|
**2. The server entry**, which renders pages and lets the CMS's admin app (and only it) frame the site for [live preview](./live-preview.md):
|
|
38
36
|
|
|
@@ -47,9 +45,13 @@ It waits until a page is completely ready before answering (every page is fully
|
|
|
47
45
|
|
|
48
46
|
```ts
|
|
49
47
|
// app/routes.ts
|
|
50
|
-
import { type RouteConfig, route } from "@react-router/dev/routes";
|
|
48
|
+
import { type RouteConfig, index, route } from "@react-router/dev/routes";
|
|
51
49
|
|
|
52
|
-
|
|
50
|
+
// "/" is the home entry. A "*" route doesn't match "/", so the home page needs its own `index` line.
|
|
51
|
+
export default [
|
|
52
|
+
index("routes/page.tsx", { id: "home" }),
|
|
53
|
+
route("*", "routes/page.tsx", { id: "page" }),
|
|
54
|
+
] satisfies RouteConfig;
|
|
53
55
|
```
|
|
54
56
|
|
|
55
57
|
```tsx
|
|
@@ -117,3 +119,28 @@ export async function loader() {
|
|
|
117
119
|
## Without the adapter
|
|
118
120
|
|
|
119
121
|
Everything the adapter does is built from the framework-free [core](./getting-started.md): `loadPage`, `loadMenuLinks`, `previewTokenFrom`, `addFramingPolicy`, the [React components](./react.md) and `usePreviewRefresh`. The adapter is about 150 lines; read it if you need to do something it doesn't.
|
|
122
|
+
|
|
123
|
+
## Sections
|
|
124
|
+
|
|
125
|
+
A sections field is drawn with `AceSections`, which gives each section to the component for its kind. Each kind is a component with a `*.schema.ts` file beside it saying what an editor can fill in (see [Sections](./sections.md)):
|
|
126
|
+
|
|
127
|
+
```tsx
|
|
128
|
+
// app/components/page-content.tsx
|
|
129
|
+
import { AceSections } from "@sandboxsonny/ace-kit/react-router";
|
|
130
|
+
|
|
131
|
+
import { Hero } from "~/components/sections/hero";
|
|
132
|
+
|
|
133
|
+
<AceSections sections={entry.values.content} components={{ hero: Hero }} />;
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
```tsx
|
|
137
|
+
// app/components/sections/hero.tsx, typed from hero.schema.ts
|
|
138
|
+
import type { SectionOf } from "@sandboxsonny/ace-kit";
|
|
139
|
+
import type hero from "./hero.schema";
|
|
140
|
+
|
|
141
|
+
export function Hero({ section }: { section: SectionOf<typeof hero> }) {
|
|
142
|
+
return <h2>{section.values.heading}</h2>;
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Schema files in `app/` (or `src/`) are found by `ace-kit sync`, and while `npm run dev` runs, saving one syncs it to the CMS. A new site from `npx @sandboxsonny/ace-kit create --framework react-router` comes with a hero, an FAQ and a gallery.
|
package/docs/react.md
CHANGED
|
@@ -74,8 +74,11 @@ Pass `adminOrigin` only for a page shown with a preview token (`previewTokenFrom
|
|
|
74
74
|
## A route in React Router 7
|
|
75
75
|
|
|
76
76
|
```tsx
|
|
77
|
-
// app/routes.ts: one route draws every page
|
|
78
|
-
export default [
|
|
77
|
+
// app/routes.ts: one route draws every page ("/" needs its own index line: a "*" route does not match it)
|
|
78
|
+
export default [
|
|
79
|
+
index("routes/page.tsx", { id: "home" }),
|
|
80
|
+
route("*", "routes/page.tsx", { id: "page" }),
|
|
81
|
+
] satisfies RouteConfig;
|
|
79
82
|
|
|
80
83
|
// app/routes/page.tsx
|
|
81
84
|
export async function loader({ request, params }: Route.LoaderArgs) {
|
package/docs/typed-content.md
CHANGED
|
@@ -12,7 +12,7 @@ or with the settings in the environment:
|
|
|
12
12
|
ACE_SITE_DOMAIN=www.example.com npx ace-kit generate
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
It reads `GET /public/content-types` (no login: it describes field names and kinds, never entries) and writes `src/ace-kit.generated.d.ts` (change it with `--out`). Make sure the file is inside your `tsconfig` `include`. Commit it, and run the command again whenever content types change (a good moment is a CI step, or a `generate` script next to `dev`).
|
|
16
16
|
|
|
17
17
|
```ts
|
|
18
18
|
// ace-kit.generated.d.ts (generated)
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Generated by ace-kit from localhost. Do not edit: run `ace-kit generate` to update it.
|
|
2
|
-
import type { AceImageData, AceRichTextDocument } from "@sandboxsonny/ace-kit";
|
|
2
|
+
import type { AceImageData, AceLinkData, AceRichTextDocument, AceSection } from "@sandboxsonny/ace-kit";
|
|
3
3
|
|
|
4
4
|
declare module "@sandboxsonny/ace-kit" {
|
|
5
5
|
interface AceContentTypes {
|
|
@@ -11,6 +11,12 @@ declare module "@sandboxsonny/ace-kit" {
|
|
|
11
11
|
"body"?: AceRichTextDocument;
|
|
12
12
|
/** Hero image */
|
|
13
13
|
"hero"?: AceImageData;
|
|
14
|
+
/** Published */
|
|
15
|
+
"published"?: string;
|
|
16
|
+
/** Button */
|
|
17
|
+
"button"?: AceLinkData;
|
|
18
|
+
/** Content */
|
|
19
|
+
"content"?: AceSection[];
|
|
14
20
|
};
|
|
15
21
|
}
|
|
16
22
|
}
|
|
@@ -170,3 +170,88 @@ a {
|
|
|
170
170
|
padding-left: 1rem;
|
|
171
171
|
font-style: italic;
|
|
172
172
|
}
|
|
173
|
+
.published {
|
|
174
|
+
color: var(--colour-muted);
|
|
175
|
+
font-size: 0.9rem;
|
|
176
|
+
}
|
|
177
|
+
.actions {
|
|
178
|
+
margin-top: 2rem;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/* Sections: a hero, an FAQ and a gallery (see components/sections) */
|
|
182
|
+
.hero {
|
|
183
|
+
padding-block: 3rem;
|
|
184
|
+
}
|
|
185
|
+
.hero__inner {
|
|
186
|
+
display: grid;
|
|
187
|
+
gap: 2rem;
|
|
188
|
+
align-items: center;
|
|
189
|
+
}
|
|
190
|
+
@media (min-width: 48rem) {
|
|
191
|
+
.hero__inner--with-picture {
|
|
192
|
+
grid-template-columns: 1fr 1fr;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
.hero h2 {
|
|
196
|
+
margin: 0 0 0.75rem;
|
|
197
|
+
font-size: 2.25rem;
|
|
198
|
+
line-height: 1.15;
|
|
199
|
+
}
|
|
200
|
+
.hero__subheading {
|
|
201
|
+
margin: 0 0 1.5rem;
|
|
202
|
+
color: var(--colour-muted);
|
|
203
|
+
font-size: 1.15rem;
|
|
204
|
+
}
|
|
205
|
+
.hero__picture img {
|
|
206
|
+
border-radius: 0.5rem;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.faq {
|
|
210
|
+
max-width: 42rem;
|
|
211
|
+
}
|
|
212
|
+
.faq h2 {
|
|
213
|
+
margin: 0 0 1rem;
|
|
214
|
+
font-size: 1.75rem;
|
|
215
|
+
}
|
|
216
|
+
.faq details {
|
|
217
|
+
border-bottom: 1px solid var(--colour-surface);
|
|
218
|
+
padding-block: 0.75rem;
|
|
219
|
+
}
|
|
220
|
+
.faq summary {
|
|
221
|
+
cursor: pointer;
|
|
222
|
+
font-weight: 600;
|
|
223
|
+
}
|
|
224
|
+
.faq__answer {
|
|
225
|
+
margin-top: 0.5rem;
|
|
226
|
+
color: var(--colour-muted);
|
|
227
|
+
}
|
|
228
|
+
.faq__answer > * + * {
|
|
229
|
+
margin-top: 0.75rem;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.gallery__note {
|
|
233
|
+
margin: 0 0 1rem;
|
|
234
|
+
color: var(--colour-muted);
|
|
235
|
+
font-size: 0.9rem;
|
|
236
|
+
}
|
|
237
|
+
.gallery__photos {
|
|
238
|
+
display: grid;
|
|
239
|
+
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
|
|
240
|
+
gap: 1rem;
|
|
241
|
+
margin: 0;
|
|
242
|
+
padding: 0;
|
|
243
|
+
list-style: none;
|
|
244
|
+
}
|
|
245
|
+
.gallery__photos figure {
|
|
246
|
+
margin: 0;
|
|
247
|
+
}
|
|
248
|
+
.gallery__photos img {
|
|
249
|
+
border-radius: 0.5rem;
|
|
250
|
+
width: 100%;
|
|
251
|
+
object-fit: cover;
|
|
252
|
+
}
|
|
253
|
+
.gallery__photos figcaption {
|
|
254
|
+
margin-top: 0.4rem;
|
|
255
|
+
color: var(--colour-muted);
|
|
256
|
+
font-size: 0.9rem;
|
|
257
|
+
}
|
|
@@ -1,32 +1,55 @@
|
|
|
1
|
+
import { formatAceDate } from "@sandboxsonny/ace-kit";
|
|
1
2
|
import type { AceEntry } from "@sandboxsonny/ace-kit";
|
|
2
|
-
import { AceImage, AceRichText, AceTextArea } from "@sandboxsonny/ace-kit/react-router";
|
|
3
|
+
import { AceImage, AceLink, AceRichText, AceSections, AceTextArea } from "@sandboxsonny/ace-kit/react-router";
|
|
3
4
|
|
|
5
|
+
import { Faq } from "~/components/sections/faq";
|
|
6
|
+
import { Gallery } from "~/components/sections/gallery";
|
|
7
|
+
import { Hero } from "~/components/sections/hero";
|
|
4
8
|
import type { PageValues } from "~/types/page";
|
|
5
9
|
|
|
6
|
-
/** An entry of the "Pages" content type: its title (or headline), description, hero image and
|
|
10
|
+
/** An entry of the "Pages" content type: its title (or headline), description, hero image, body, button and sections. */
|
|
7
11
|
export function PageContent({ entry }: { entry: AceEntry<PageValues> }) {
|
|
8
|
-
const { headline, body, hero } = entry.values;
|
|
12
|
+
const { headline, body, hero, published, button, content } = entry.values;
|
|
9
13
|
return (
|
|
10
|
-
|
|
11
|
-
<
|
|
12
|
-
<
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
<
|
|
25
|
-
<
|
|
26
|
-
</
|
|
27
|
-
) : null
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
14
|
+
<>
|
|
15
|
+
<section className="section">
|
|
16
|
+
<div className="container prose">
|
|
17
|
+
<h1>{headline || entry.title}</h1>
|
|
18
|
+
{entry.description ? <p className="intro">{entry.description}</p> : null}
|
|
19
|
+
{
|
|
20
|
+
// A date field is a day with no time of day, so it is the same day wherever the server runs.
|
|
21
|
+
published ? (
|
|
22
|
+
<p className="published">
|
|
23
|
+
Published <time dateTime={published}>{formatAceDate(published, { locale: "en-GB" }) ?? published}</time>
|
|
24
|
+
</p>
|
|
25
|
+
) : null
|
|
26
|
+
}
|
|
27
|
+
{hero ? (
|
|
28
|
+
<figure>
|
|
29
|
+
<AceImage image={hero} sizes="(min-width: 64rem) 64rem, 100vw" lazy={false} />
|
|
30
|
+
</figure>
|
|
31
|
+
) : null}
|
|
32
|
+
{
|
|
33
|
+
// A site made before its Body field was rich text still has plain text, with blank lines between paragraphs.
|
|
34
|
+
typeof body === "string" ? (
|
|
35
|
+
<AceTextArea value={body} />
|
|
36
|
+
) : body ? (
|
|
37
|
+
<div className="rich-text">
|
|
38
|
+
<AceRichText document={body} />
|
|
39
|
+
</div>
|
|
40
|
+
) : null
|
|
41
|
+
}
|
|
42
|
+
{
|
|
43
|
+
// A link field: the editor's words and where it goes, already worked out by the CMS.
|
|
44
|
+
button ? (
|
|
45
|
+
<p className="actions">
|
|
46
|
+
<AceLink link={button} className="button" />
|
|
47
|
+
</p>
|
|
48
|
+
) : null
|
|
49
|
+
}
|
|
50
|
+
</div>
|
|
51
|
+
</section>
|
|
52
|
+
<AceSections sections={content} components={{ hero: Hero, faq: Faq, gallery: Gallery }} />
|
|
53
|
+
</>
|
|
31
54
|
);
|
|
32
55
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineSection } from "@sandboxsonny/ace-kit";
|
|
2
|
+
|
|
3
|
+
// An FAQ is a heading and a list of questions. The questions are blocks: the editor adds as many as they like.
|
|
4
|
+
export default defineSection({
|
|
5
|
+
handle: "faq",
|
|
6
|
+
name: "FAQ",
|
|
7
|
+
fields: [{ handle: "heading", label: "Heading", type: "text" }],
|
|
8
|
+
blocks: {
|
|
9
|
+
question: {
|
|
10
|
+
name: "Question",
|
|
11
|
+
fields: [
|
|
12
|
+
{ handle: "question", label: "Question", type: "text", required: true },
|
|
13
|
+
{ handle: "answer", label: "Answer", type: "richtext" },
|
|
14
|
+
],
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { SectionOf } from "@sandboxsonny/ace-kit";
|
|
2
|
+
import { AceRichText } from "@sandboxsonny/ace-kit/react-router";
|
|
3
|
+
|
|
4
|
+
import type faq from "./faq.schema";
|
|
5
|
+
|
|
6
|
+
/** An FAQ: a heading and a list of questions, each a block. Its fields and blocks are in faq.schema.ts. */
|
|
7
|
+
export function Faq({ section }: { section: SectionOf<typeof faq> }) {
|
|
8
|
+
const { heading } = section.values;
|
|
9
|
+
return (
|
|
10
|
+
<section className="section" data-section={section.type}>
|
|
11
|
+
<div className="container faq">
|
|
12
|
+
{heading ? <h2>{heading}</h2> : null}
|
|
13
|
+
{section.blocks.map((block) => (
|
|
14
|
+
<details key={block.id}>
|
|
15
|
+
<summary>{block.values.question}</summary>
|
|
16
|
+
{block.values.answer ? (
|
|
17
|
+
<div className="faq__answer">
|
|
18
|
+
<AceRichText document={block.values.answer} />
|
|
19
|
+
</div>
|
|
20
|
+
) : null}
|
|
21
|
+
</details>
|
|
22
|
+
))}
|
|
23
|
+
</div>
|
|
24
|
+
</section>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { defineSection } from "@sandboxsonny/ace-kit";
|
|
2
|
+
|
|
3
|
+
// A gallery is a heading, a date and a list of photos. The photos are blocks.
|
|
4
|
+
export default defineSection({
|
|
5
|
+
handle: "gallery",
|
|
6
|
+
name: "Gallery",
|
|
7
|
+
fields: [
|
|
8
|
+
{ handle: "heading", label: "Heading", type: "text" },
|
|
9
|
+
{ handle: "published", label: "Published", type: "date" },
|
|
10
|
+
],
|
|
11
|
+
blocks: {
|
|
12
|
+
photo: {
|
|
13
|
+
name: "Photo",
|
|
14
|
+
fields: [
|
|
15
|
+
{ handle: "image", label: "Image", type: "image" },
|
|
16
|
+
{ handle: "caption", label: "Caption", type: "text" },
|
|
17
|
+
],
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { formatAceDate } from "@sandboxsonny/ace-kit";
|
|
2
|
+
import type { SectionOf } from "@sandboxsonny/ace-kit";
|
|
3
|
+
import { AceImage } from "@sandboxsonny/ace-kit/react-router";
|
|
4
|
+
|
|
5
|
+
import type gallery from "./gallery.schema";
|
|
6
|
+
|
|
7
|
+
/** A gallery: a heading, a date and a list of photos, each a block. Its fields and blocks are in gallery.schema.ts. */
|
|
8
|
+
export function Gallery({ section }: { section: SectionOf<typeof gallery> }) {
|
|
9
|
+
const { heading, published } = section.values;
|
|
10
|
+
const photos = section.blocks.filter((block) => block.values.image || block.values.caption);
|
|
11
|
+
return (
|
|
12
|
+
<section className="section" data-section={section.type}>
|
|
13
|
+
<div className="container">
|
|
14
|
+
{heading ? <h2>{heading}</h2> : null}
|
|
15
|
+
{published ? (
|
|
16
|
+
<p className="gallery__note">
|
|
17
|
+
<time dateTime={published}>{formatAceDate(published, { locale: "en-GB" }) ?? published}</time>
|
|
18
|
+
</p>
|
|
19
|
+
) : null}
|
|
20
|
+
{photos.length > 0 ? (
|
|
21
|
+
<ul className="gallery__photos">
|
|
22
|
+
{photos.map((photo) => (
|
|
23
|
+
<li key={photo.id}>
|
|
24
|
+
<figure>
|
|
25
|
+
{photo.values.image ? (
|
|
26
|
+
<AceImage image={photo.values.image} sizes="(min-width: 48rem) 20rem, 100vw" />
|
|
27
|
+
) : null}
|
|
28
|
+
{photo.values.caption ? <figcaption>{photo.values.caption}</figcaption> : null}
|
|
29
|
+
</figure>
|
|
30
|
+
</li>
|
|
31
|
+
))}
|
|
32
|
+
</ul>
|
|
33
|
+
) : (
|
|
34
|
+
<p className="gallery__note">No photos yet.</p>
|
|
35
|
+
)}
|
|
36
|
+
</div>
|
|
37
|
+
</section>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { defineSection } from "@sandboxsonny/ace-kit";
|
|
2
|
+
|
|
3
|
+
// What a hero can hold. The CMS learns it from this file (the dev server syncs it when it changes), and Hero.astro's
|
|
4
|
+
// props are typed from it.
|
|
5
|
+
export default defineSection({
|
|
6
|
+
handle: "hero",
|
|
7
|
+
name: "Hero",
|
|
8
|
+
fields: [
|
|
9
|
+
{ handle: "heading", label: "Heading", type: "text", required: true },
|
|
10
|
+
{ handle: "subheading", label: "Subheading", type: "textarea" },
|
|
11
|
+
{ handle: "picture", label: "Picture", type: "image" },
|
|
12
|
+
{ handle: "button", label: "Button", type: "link" },
|
|
13
|
+
],
|
|
14
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { SectionOf } from "@sandboxsonny/ace-kit";
|
|
2
|
+
import { AceImage, AceLink } from "@sandboxsonny/ace-kit/react-router";
|
|
3
|
+
|
|
4
|
+
import type hero from "./hero.schema";
|
|
5
|
+
|
|
6
|
+
/** A hero: a heading, an optional subheading and button, and an optional picture. Its fields are in hero.schema.ts. */
|
|
7
|
+
export function Hero({ section }: { section: SectionOf<typeof hero> }) {
|
|
8
|
+
const { heading, subheading, picture, button } = section.values;
|
|
9
|
+
return (
|
|
10
|
+
<section className="hero section--surface" data-section={section.type}>
|
|
11
|
+
<div className={picture ? "container hero__inner hero__inner--with-picture" : "container hero__inner"}>
|
|
12
|
+
<div>
|
|
13
|
+
<h2>{heading}</h2>
|
|
14
|
+
{subheading ? <p className="hero__subheading">{subheading}</p> : null}
|
|
15
|
+
{button ? <AceLink link={button} className="button" /> : null}
|
|
16
|
+
</div>
|
|
17
|
+
{picture ? (
|
|
18
|
+
<div className="hero__picture">
|
|
19
|
+
<AceImage image={picture} sizes="(min-width: 64rem) 32rem, 100vw" lazy={false} />
|
|
20
|
+
</div>
|
|
21
|
+
) : null}
|
|
22
|
+
</div>
|
|
23
|
+
</section>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -47,6 +47,60 @@ describe("the page", () => {
|
|
|
47
47
|
expect(screen.getByRole("link", { name: "more" }).getAttribute("href")).toBe("/pages/contact");
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
+
it("shows the published date and the button", () => {
|
|
51
|
+
show({
|
|
52
|
+
kind: "entry",
|
|
53
|
+
status: 200,
|
|
54
|
+
entry: {
|
|
55
|
+
...entry,
|
|
56
|
+
values: { ...entry.values, published: "2026-10-13", button: { href: "/pages/contact", label: "Get in touch" } },
|
|
57
|
+
},
|
|
58
|
+
headers: {},
|
|
59
|
+
});
|
|
60
|
+
expect(screen.getByText("13 October 2026")).toBeTruthy();
|
|
61
|
+
expect(screen.getByRole("link", { name: "Get in touch" }).getAttribute("href")).toBe("/pages/contact");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("draws each section with the component for its kind, in order, and leaves out a kind it has none for", () => {
|
|
65
|
+
const richText = (text: string) => ({
|
|
66
|
+
type: "doc",
|
|
67
|
+
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
|
|
68
|
+
});
|
|
69
|
+
show({
|
|
70
|
+
kind: "entry",
|
|
71
|
+
status: 200,
|
|
72
|
+
entry: {
|
|
73
|
+
...entry,
|
|
74
|
+
values: {
|
|
75
|
+
...entry.values,
|
|
76
|
+
content: [
|
|
77
|
+
{ id: "a", type: "hero", values: { heading: "Big news", subheading: "Read on" }, blocks: [] },
|
|
78
|
+
{
|
|
79
|
+
id: "b",
|
|
80
|
+
type: "faq",
|
|
81
|
+
values: { heading: "Questions" },
|
|
82
|
+
blocks: [
|
|
83
|
+
{ id: "b1", type: "question", values: { question: "Why?", answer: richText("Because.") } },
|
|
84
|
+
{ id: "b2", type: "question", values: { question: "How?" } },
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
{ id: "c", type: "gallery", values: { heading: "Photos" }, blocks: [] },
|
|
88
|
+
{ id: "d", type: "mystery", values: {}, blocks: [] },
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
headers: {},
|
|
93
|
+
});
|
|
94
|
+
expect(screen.getByRole("heading", { name: "Big news" })).toBeTruthy();
|
|
95
|
+
expect(screen.getByText("Read on")).toBeTruthy();
|
|
96
|
+
expect(screen.getByText("Why?")).toBeTruthy();
|
|
97
|
+
expect(screen.getByText("Because.")).toBeTruthy();
|
|
98
|
+
expect(screen.getByText("How?")).toBeTruthy();
|
|
99
|
+
expect(screen.getByText("No photos yet.")).toBeTruthy();
|
|
100
|
+
const kinds = [...document.querySelectorAll("[data-section]")].map((node) => node.getAttribute("data-section"));
|
|
101
|
+
expect(kinds).toEqual(["hero", "faq", "gallery"]);
|
|
102
|
+
});
|
|
103
|
+
|
|
50
104
|
it("shows plain text bodies, from before the field was rich text, as paragraphs", () => {
|
|
51
105
|
show({ kind: "entry", status: 200, entry: { ...entry, values: { body: "One\n\nTwo" } }, headers: {} });
|
|
52
106
|
expect(screen.getByText("One")).toBeTruthy();
|