smoodly 0.0.59 → 0.0.61
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/README.md +3 -1
- package/dist/admin/files.generated.js +1 -1
- package/dist/admin/ui/theme.js +1 -1
- package/dist/api-client.js +5 -3
- package/dist/astro/index.js +3 -3
- package/dist/cli/push.js +2 -3
- package/dist/env.js +3 -2
- package/dist/push.d.ts +7 -0
- package/dist/push.js +8 -0
- package/dist/space-kind.d.ts +2 -0
- package/dist/space-kind.js +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/collections/defining.md +57 -0
- package/docs/collections/entry-pages.md +168 -0
- package/docs/collections/ordering.md +47 -0
- package/docs/collections/queries.md +173 -0
- package/docs/content-modeling/changing-schemas.md +79 -0
- package/docs/content-modeling/custom-section.md +94 -0
- package/docs/content-modeling/marks.md +97 -0
- package/docs/content-modeling/overview.md +55 -0
- package/docs/content-modeling/page-types.md +237 -0
- package/docs/content-modeling/sections.md +264 -0
- package/docs/content-modeling/styles-and-settings.md +184 -0
- package/docs/content-modeling/zones.md +80 -0
- package/docs/fields/choices.md +42 -0
- package/docs/fields/conditional.md +72 -0
- package/docs/fields/link.md +31 -0
- package/docs/fields/list-and-object.md +58 -0
- package/docs/fields/media.md +103 -0
- package/docs/fields/overview.md +56 -0
- package/docs/fields/reference-and-tags.md +62 -0
- package/docs/fields/richtext.md +168 -0
- package/docs/fields/text.md +152 -0
- package/docs/frameworks/astro.md +110 -0
- package/docs/frameworks/nextjs.md +147 -0
- package/docs/getting-started/content-from-code.md +263 -0
- package/docs/getting-started/deploying.md +131 -0
- package/docs/getting-started/export-and-import.md +73 -0
- package/docs/getting-started/first-page.md +98 -0
- package/docs/getting-started/install.md +108 -0
- package/docs/getting-started/keys.md +40 -0
- package/docs/getting-started/project-structure.md +142 -0
- package/docs/getting-started/working-together.md +42 -0
- package/docs/globals/defining.md +99 -0
- package/docs/globals/header-and-footer.md +117 -0
- package/docs/index.md +83 -0
- package/docs/languages/language-switcher.md +103 -0
- package/docs/languages/locales.md +119 -0
- package/docs/languages/localized-fields.md +61 -0
- package/docs/media/images-and-crops.md +131 -0
- package/docs/media/library.md +40 -0
- package/docs/pages/drafts-and-publishing.md +44 -0
- package/docs/pages/page-tree.md +51 -0
- package/docs/pages/paths-and-slugs.md +74 -0
- package/docs/pages/seo.md +126 -0
- package/docs/pages/version-history.md +23 -0
- package/docs/reference/upgrading.md +312 -0
- package/package.json +4 -3
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# Defining a section
|
|
2
|
+
|
|
3
|
+
A section is a full-width component editors place in a zone. You describe its fields once, in a schema file, and Smoodly derives the editor form, the Add section entry and the TypeScript props from that one definition. This page walks through the Lorem Co. starter's Hero.
|
|
4
|
+
|
|
5
|
+
## The schema
|
|
6
|
+
|
|
7
|
+
A section is two files. `Hero.schema.ts` holds the schema: `smoodly.schema.section()` with a name, the title editors see, and a `fields` map built from the `f` helpers. The view, below, renders it. The schema is the same file in every framework; in an Astro project it lives under `src/components/sections/`.
|
|
8
|
+
|
|
9
|
+
File: `components/sections/Hero.schema.ts`
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { f, smoodly } from "smoodly";
|
|
13
|
+
|
|
14
|
+
export const heroSchema = smoodly.schema.section({
|
|
15
|
+
name: "hero",
|
|
16
|
+
title: "Hero",
|
|
17
|
+
description: "The home page opener: heading, text, buttons and a photo.",
|
|
18
|
+
fields: {
|
|
19
|
+
kicker: f.text().optional(),
|
|
20
|
+
heading: f.text(),
|
|
21
|
+
text: f.text(),
|
|
22
|
+
buttons: f.list(f.object({ label: f.text(), href: f.link() })).max(2),
|
|
23
|
+
image: f.image({ aspect: "4:5" }),
|
|
24
|
+
},
|
|
25
|
+
sample: {
|
|
26
|
+
// …
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
| Name | Description |
|
|
32
|
+
| --- | --- |
|
|
33
|
+
| `name` | What the content stores for every placed Hero, so keep it stable once pages exist. |
|
|
34
|
+
| `title` | The label in Add section and in the inspector. |
|
|
35
|
+
| `description` | One line under the title in Add section. Optional, like icon. |
|
|
36
|
+
| `fields` | What editors fill. Field order is form order: editors see Kicker, Heading, Text, Buttons, Image, top to bottom. |
|
|
37
|
+
| `sample` | What a new Hero starts with. See Sample content below. |
|
|
38
|
+
|
|
39
|
+
Every field is required unless it chains `.optional()`. `.max()` and `.min()` limit a length or a count, `.default()` sets a starting value, and `.localized()` gives a field its own value per language. The full list is on [All fields](../fields/overview.md).
|
|
40
|
+
|
|
41
|
+
## The view
|
|
42
|
+
|
|
43
|
+
**Astro**
|
|
44
|
+
|
|
45
|
+
The view is an `.astro` file named after the schema: `src/sections/hero.astro` renders the section named `hero`. It imports the schema, and `Props<typeof heroSchema>` types `Astro.props`: the fields flat, plus `settings` and `styles`.
|
|
46
|
+
|
|
47
|
+
File: `src/sections/hero.astro`
|
|
48
|
+
|
|
49
|
+
```astro
|
|
50
|
+
---
|
|
51
|
+
import { safeHref, smoodly, type Props as SmoodlyProps } from "smoodly";
|
|
52
|
+
import { Image, Section } from "smoodly/astro/components";
|
|
53
|
+
import { heroSchema } from "@/components/sections/Hero.schema";
|
|
54
|
+
|
|
55
|
+
type Props = SmoodlyProps<typeof heroSchema>;
|
|
56
|
+
const { kicker, heading, text, buttons, image, styles } = Astro.props;
|
|
57
|
+
const m = smoodly.marks(heroSchema);
|
|
58
|
+
---
|
|
59
|
+
<Section styles={styles} class="hero">
|
|
60
|
+
// …
|
|
61
|
+
<h1 class="hero__heading" {...m.field("heading")}>{heading}</h1>
|
|
62
|
+
// …
|
|
63
|
+
{buttons.map((b, i) => {
|
|
64
|
+
const button = m.item("buttons", i);
|
|
65
|
+
return <a class={i === 0 ? "button" : "button button--outline"} href={safeHref(b.href)} {...button.attrs} {...button.field("label")}>{b.label}</a>;
|
|
66
|
+
})}
|
|
67
|
+
// …
|
|
68
|
+
{image && <Image image={image} class="hero__image" sizes="(min-width: 900px) 40vw, 100vw" priority {...m.field("image")} />}
|
|
69
|
+
// …
|
|
70
|
+
</Section>
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Three things make this a section rather than a plain component:
|
|
74
|
+
|
|
75
|
+
- **`Section` is the root element.** It comes from `smoodly/astro/components`. Pass it `styles`, and it renders the spacing and width steps an editor picks, plus the attribute the canvas uses to select the section. `as="header"` changes the tag; `class`, `id` and `data-` attributes pass through. The section's own CSS is yours, in `src/styles/global.css` like the starter's.
|
|
76
|
+
- **Marks make fields click-to-edit.** `smoodly.marks(heroSchema)` returns typed helpers. Spread `m.field("heading")` on the element that renders the heading, and clicking it in the canvas opens that field. For a list, `m.item("buttons", i)` gives a scope: spread its `attrs` on the item's root and mark the fields inside with `field()`. `m.object()` does the same for an object field. Outside the admin the marks are plain `data-` attributes. [Marks and inline editing](../content-modeling/marks.md) has the details.
|
|
77
|
+
- **The file name pairs the two.** There is no pairing line and no list of views. The server names a schema with no file, and a file with no schema, at the first request.
|
|
78
|
+
|
|
79
|
+
A link an editor typed goes through `safeHref` from `smoodly`: Astro writes an attribute as given, so a `javascript:` URL would reach the page. `safeHref` passes http(s), mailto, tel and site paths, and gives `null` for anything else, so the link renders with no `href`.
|
|
80
|
+
|
|
81
|
+
**Next.js**
|
|
82
|
+
|
|
83
|
+
The view is an ordinary React component in `Hero.tsx`, beside the schema. It imports the schema, and `Props<typeof heroSchema>` types it: the fields flat, plus `settings` and `styles`.
|
|
84
|
+
|
|
85
|
+
File: `components/sections/Hero.tsx`
|
|
86
|
+
|
|
87
|
+
```tsx
|
|
88
|
+
const m = smoodly.marks(heroSchema);
|
|
89
|
+
|
|
90
|
+
function HeroView({ kicker, heading, text, buttons, image, styles }: Props<typeof heroSchema>) {
|
|
91
|
+
return (
|
|
92
|
+
<smoodly.Section styles={styles} className="hero">
|
|
93
|
+
<div className="container hero__grid">
|
|
94
|
+
<div className="hero__copy">
|
|
95
|
+
{kicker && <p className="kicker" {...m.field("kicker")}>{kicker}</p>}
|
|
96
|
+
<h1 className="hero__heading" {...m.field("heading")}>{heading}</h1>
|
|
97
|
+
<p className="hero__text" {...m.field("text")}>{text}</p>
|
|
98
|
+
{buttons?.length > 0 && (
|
|
99
|
+
<div className="hero__buttons">
|
|
100
|
+
{buttons.map((b, i) => {
|
|
101
|
+
const button = m.item("buttons", i);
|
|
102
|
+
return (
|
|
103
|
+
<a key={i} className={i === 0 ? "button" : "button button--outline"} href={b.href}
|
|
104
|
+
{...button.attrs} {...button.field("label")}>{b.label}</a>
|
|
105
|
+
);
|
|
106
|
+
})}
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
{image && <SmoodlyImage image={image} className="hero__image" sizes="(min-width: 900px) 40vw, 100vw" priority {...m.field("image")} />}
|
|
111
|
+
</div>
|
|
112
|
+
</smoodly.Section>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const Hero = smoodly.section(heroSchema, HeroView);
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Three things make this a section rather than a plain component:
|
|
120
|
+
|
|
121
|
+
- **`smoodly.Section` is the root element.** Pass it `styles`, and it renders the spacing and width steps an editor picks, plus the attribute the canvas uses to select the section. `as="header"` changes the tag; `className`, `id` and `data-` attributes pass through. The section's own CSS is yours, in `app/globals.css` like the starter's.
|
|
122
|
+
- **Marks make fields click-to-edit.** `smoodly.marks(heroSchema)` returns typed helpers. Spread `m.field("heading")` on the element that renders the heading, and clicking it in the canvas opens that field. For a list, `m.item("buttons", i)` gives a scope: spread its `attrs` on the item's root and mark the fields inside with `field()`. `m.object()` does the same for an object field. Outside the admin the marks are plain `data-` attributes. [Marks and inline editing](../content-modeling/marks.md) has the details.
|
|
123
|
+
- **`smoodly.section()` pairs the two.** Rename a field in the schema and this line fails to compile until the view follows. The export is the component you list in `smoodly/views.ts`. It renders as a normal component too, so you can place a section in your own JSX with its fields as props.
|
|
124
|
+
|
|
125
|
+
### When a section throws
|
|
126
|
+
|
|
127
|
+
If a section's own code throws, for example on data it didn't expect, the page still renders and that section is left out. The error goes to the server log, and the editor's canvas shows a placeholder in its place. The editor names it too, and Publish asks before a page with a broken section goes live.
|
|
128
|
+
|
|
129
|
+
**Astro**
|
|
130
|
+
|
|
131
|
+
The zone renders each section through `Astro.slots.render` inside a try/catch, so a throw anywhere inside the section, a component it renders included, leaves the section out and the page renders.
|
|
132
|
+
|
|
133
|
+
**Next.js**
|
|
134
|
+
|
|
135
|
+
`next dev` shows the placeholder too. A throw inside a component the section renders, or in a `'use client'` section, still fails the live page, but in the canvas it is caught and named the same way, so it shows up before anyone publishes.
|
|
136
|
+
|
|
137
|
+
## Registering it
|
|
138
|
+
|
|
139
|
+
**Astro**
|
|
140
|
+
|
|
141
|
+
Add the schema to `src/smoodly/sections.ts`, which the config registers. The view is the file; there is nothing else to list. Editors can place the section only once a zone lists it, which is the next step:
|
|
142
|
+
|
|
143
|
+
File: `src/smoodly/sections.ts`
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
import { heroSchema } from "@/components/sections/Hero.schema";
|
|
147
|
+
// …
|
|
148
|
+
export const sections = [heroSchema, pageHeroSchema, imageTextSchema, pointsSchema, favouriteArticlesSchema, peopleSchema, quoteSchema, headerSchema, footerSchema];
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
**Next.js**
|
|
152
|
+
|
|
153
|
+
A section is registered in two lists. Add its schema to `smoodly/sections.ts`, which the config registers, and its component to `sections` in `smoodly/views.ts`. The config imports no component, so the CLI, a seed script and an agent can read your content model without loading React. Editors can place the section only once a zone lists it, which is the next step:
|
|
154
|
+
|
|
155
|
+
File: `smoodly/sections.ts`
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
import { heroSchema } from "@/components/sections/Hero.schema";
|
|
159
|
+
// …
|
|
160
|
+
export const sections = [heroSchema, pageHeroSchema, imageTextSchema, pointsSchema, favouriteArticlesSchema, peopleSchema, quoteSchema, headerSchema, footerSchema];
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
File: `smoodly/views.ts`
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
import { Hero } from "@/components/sections/Hero";
|
|
167
|
+
// …
|
|
168
|
+
export const views = () =>
|
|
169
|
+
defineViews(config, {
|
|
170
|
+
sections: [Hero, PageHero, ImageText, Points, FavouriteArticles, People, Quote, Header, Footer],
|
|
171
|
+
// …
|
|
172
|
+
});
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
A schema with no component in `smoodly/views.ts` stops the first render with a named error: `smoodly: section "hero" is registered in the config but has no view — add its component to defineViews({ sections }).`
|
|
176
|
+
|
|
177
|
+
Registering makes the section known; a page type's zones decide where editors may use it. Lorem Co.'s home page locks its hero and lets the body take five sections:
|
|
178
|
+
|
|
179
|
+
File: `components/pages/Home.schema.ts`
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
export const homeSchema = smoodly.schema.page({
|
|
183
|
+
name: "home",
|
|
184
|
+
title: "Home",
|
|
185
|
+
slug: "home",
|
|
186
|
+
zones: {
|
|
187
|
+
hero: z.locked(heroSchema),
|
|
188
|
+
body: z.freeform({ sections: [imageTextSchema, pointsSchema, favouriteArticlesSchema, peopleSchema, quoteSchema], custom: true }),
|
|
189
|
+
},
|
|
190
|
+
// …
|
|
191
|
+
});
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
A registered section that no zone lists never appears in *Add section*. Lorem Co.'s Header and Footer are registered that way, because the layout renders them as [globals](../globals/header-and-footer.md). The three policies are on [Zones and freedom](../content-modeling/zones.md).
|
|
195
|
+
|
|
196
|
+
## Sample content
|
|
197
|
+
|
|
198
|
+
`sample` is what a section starts with, so a page never shows an empty one. Smoodly uses it twice:
|
|
199
|
+
|
|
200
|
+
- When an editor picks the section in *Add section*, the new section carries the sample.
|
|
201
|
+
- When a page opens with an empty locked zone, Smoodly fills the zone from the locked section's sample.
|
|
202
|
+
|
|
203
|
+
Fields the sample leaves out start from their `.default()`, or empty. A missing image or number stays unset rather than showing a broken picture or a stray zero. Make the sample a complete value in plain copy: it is what editors see first, and what they overwrite.
|
|
204
|
+
|
|
205
|
+
The sample may set settings too, under their own keys.
|
|
206
|
+
|
|
207
|
+
## Settings
|
|
208
|
+
|
|
209
|
+
Fields are content. Settings are your own options for how the section looks, which the view alone interprets. Lorem Co.'s *Image and text* lets editors put the photo on either side:
|
|
210
|
+
|
|
211
|
+
File: `components/sections/ImageText.schema.ts`
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
export const imageTextSchema = smoodly.schema.section({
|
|
215
|
+
name: "imageText",
|
|
216
|
+
title: "Image and text",
|
|
217
|
+
// …
|
|
218
|
+
settings: {
|
|
219
|
+
imageSide: f.radio([{ value: "left", label: "Image left" }, { value: "right", label: "Image right" }]).default("left"),
|
|
220
|
+
},
|
|
221
|
+
// …
|
|
222
|
+
});
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
**Astro**
|
|
226
|
+
|
|
227
|
+
File: `src/sections/imageText.astro`
|
|
228
|
+
|
|
229
|
+
```astro
|
|
230
|
+
---
|
|
231
|
+
// …
|
|
232
|
+
const { image, heading, text, link, settings, styles } = Astro.props;
|
|
233
|
+
// …
|
|
234
|
+
---
|
|
235
|
+
<Section styles={styles} class="image-text" data-image-side={settings.imageSide}>
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
**Next.js**
|
|
239
|
+
|
|
240
|
+
File: `components/sections/ImageText.tsx`
|
|
241
|
+
|
|
242
|
+
```tsx
|
|
243
|
+
function ImageTextView({ image, heading, text, link, settings, styles }: Props<typeof imageTextSchema>) {
|
|
244
|
+
// …
|
|
245
|
+
<smoodly.Section styles={styles} className="image-text" data-image-side={settings.imageSide}>
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
The view receives `settings` with defaults filled, so `settings.imageSide` is always a value. Settings use the same `f` builders as fields, with one rule: they are never localized, because a layout choice is the same in every language.
|
|
249
|
+
|
|
250
|
+
In the inspector, fields are on the *Content* tab and settings on the *Settings* tab, above Smoodly's own *Spacing & layout* group. [Settings, styles and CSS](../content-modeling/styles-and-settings.md) covers that group, `styles: false` for a section that should not offer it, and the custom CSS box.
|
|
251
|
+
|
|
252
|
+
## The fields used here
|
|
253
|
+
|
|
254
|
+
| Field | Editor sees | Notes |
|
|
255
|
+
| --- | --- | --- |
|
|
256
|
+
| `f.text()` | Single line | .max(n) limits the length in characters. |
|
|
257
|
+
| `f.link()` | Text box | A full URL or a site path such as /company/about-us. |
|
|
258
|
+
| `f.image({ aspect })` | Media picker | Upload or pick from the library, then crop to the aspect for this use. |
|
|
259
|
+
| `f.list(item)` | Repeater | Editors add, reorder and remove items. .min(n) and .max(n) count items; lists cannot nest. |
|
|
260
|
+
| `f.object({ … })` | A group of fields | One value with named parts, such as a button's label and href. |
|
|
261
|
+
| `f.richtext({ toolset })` | Text editor | Formatted text; the toolset decides which formatting and elements it offers. |
|
|
262
|
+
| `f.radio([…])` | Pills | One value from a closed set. f.select([…]) shows the same set as a dropdown. |
|
|
263
|
+
|
|
264
|
+
Next, [Page types](../content-modeling/page-types.md) shows where zones come from, and [Zones and freedom](../content-modeling/zones.md) how to set each zone's policy.
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# Settings, styles and CSS
|
|
2
|
+
|
|
3
|
+
Beyond its fields, a placed section carries three more things editors can change: the settings you declare, Smoodly's own spacing and width steps, and a custom CSS box you can turn on. All three sit on the Settings tab.
|
|
4
|
+
|
|
5
|
+
## What a section carries
|
|
6
|
+
|
|
7
|
+
Every section on a page stores four groups of values:
|
|
8
|
+
|
|
9
|
+
| Group | Who defines it | Where editors find it |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| `fields` | You, in the schema. The content. | The Content tab. |
|
|
12
|
+
| `settings` | You, in the schema. Options for how the section looks. | The Settings tab, at the top. |
|
|
13
|
+
| `styles` | Smoodly. Spacing, width and alignment steps. | The Settings tab, under Spacing & layout. |
|
|
14
|
+
| `css` | Smoodly, when the config turns it on. A CSS snippet for this one section. | The Settings tab, under Advanced. |
|
|
15
|
+
|
|
16
|
+
The view receives the first three as props: the fields flat, then `settings` and `styles`. The CSS never reaches the view; the section wrapper renders it.
|
|
17
|
+
|
|
18
|
+
## Settings
|
|
19
|
+
|
|
20
|
+
Settings use the same `f` builders as fields. The starter's Quote offers two backgrounds:
|
|
21
|
+
|
|
22
|
+
File: `components/sections/Quote.schema.ts`
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
export const quoteSchema = smoodly.schema.section({
|
|
26
|
+
name: "quote",
|
|
27
|
+
title: "Quote",
|
|
28
|
+
// …
|
|
29
|
+
settings: {
|
|
30
|
+
background: f.radio([{ value: "paper", label: "Paper" }, { value: "ink", label: "Ink" }]).default("paper"),
|
|
31
|
+
},
|
|
32
|
+
// …
|
|
33
|
+
});
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Astro**
|
|
37
|
+
|
|
38
|
+
File: `src/sections/quote.astro`
|
|
39
|
+
|
|
40
|
+
```astro
|
|
41
|
+
---
|
|
42
|
+
// …
|
|
43
|
+
const { text, attribution, settings, styles } = Astro.props;
|
|
44
|
+
// …
|
|
45
|
+
---
|
|
46
|
+
<Section styles={styles} class="quote" data-background={settings.background}>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**Next.js**
|
|
50
|
+
|
|
51
|
+
File: `components/sections/Quote.tsx`
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
function QuoteView({ text, attribution, settings, styles }: Props<typeof quoteSchema>) {
|
|
55
|
+
return (
|
|
56
|
+
<smoodly.Section styles={styles} className="quote" data-background={settings.background}>
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The view alone interprets a setting. Here it becomes a data attribute that the stylesheet reads; it could as well pick a class or a layout branch. `settings` arrives with defaults filled, so `settings.background` is always one of the two values.
|
|
60
|
+
|
|
61
|
+
Two rules set settings apart from fields. They are never localized, because a layout choice is the same in every language, and `.localized()` on a setting throws at startup. And a sample may set them under their own keys, next to the fields.
|
|
62
|
+
|
|
63
|
+
## Hiding a field on a condition
|
|
64
|
+
|
|
65
|
+
A field or setting can leave the form while a condition holds. A media section that shows either a photo or a video needs only one of its two fields at a time:
|
|
66
|
+
|
|
67
|
+
**Astro**
|
|
68
|
+
|
|
69
|
+
The schema goes in `src/components/sections/Media.schema.ts`, written as in the Next.js tab: a schema is the same in every framework. Its view, `src/sections/media.astro`, renders `<Section styles={styles} class="media" data-kind={settings.kind}>` and shows the video or the image by `settings.kind`.
|
|
70
|
+
|
|
71
|
+
**Next.js**
|
|
72
|
+
|
|
73
|
+
File: `components/sections/Media.tsx`
|
|
74
|
+
|
|
75
|
+
```tsx
|
|
76
|
+
import { f, smoodly, type Props } from "smoodly/react";
|
|
77
|
+
|
|
78
|
+
export const mediaSchema = smoodly.schema.section({
|
|
79
|
+
name: "media",
|
|
80
|
+
title: "Media",
|
|
81
|
+
fields: {
|
|
82
|
+
image: f.image({ aspect: "16:9" }).hidden(f.when("settings.kind").is("video")),
|
|
83
|
+
video: f.video().hidden(f.when("settings.kind").is("image")),
|
|
84
|
+
caption: f.text().optional(),
|
|
85
|
+
},
|
|
86
|
+
settings: {
|
|
87
|
+
kind: f.radio([{ value: "image", label: "Photo" }, { value: "video", label: "Video" }]).default("image"),
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
function MediaView({ image, video, caption, settings, styles }: Props<typeof mediaSchema>) {
|
|
92
|
+
return (
|
|
93
|
+
<smoodly.Section styles={styles} className="media" data-kind={settings.kind}>
|
|
94
|
+
{settings.kind === "video" ? <video src={video?.url} controls /> : <img src={image?.url} alt={image?.alt ?? ""} />}
|
|
95
|
+
{caption && <p className="media__caption">{caption}</p>}
|
|
96
|
+
</smoodly.Section>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const Media = smoodly.section(mediaSchema, MediaView);
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`f.when(path)` reads a value and offers `.is()`, `.isNot()`, `.in([…])`, `.isEmpty()` and `.isNotEmpty()`. A bare path names a sibling at the same level, so inside an object or a list item it reads that item. A path prefixed `fields.` or `settings.` reads a top-level key of that group. A hidden field is skipped at publish, and its stored value stays, so switching back restores it. A condition that names a field that does not exist fails at startup, not in the form.
|
|
104
|
+
|
|
105
|
+
## Smoodly's styles
|
|
106
|
+
|
|
107
|
+
Every section gets four steps under *Spacing & layout*, whatever its schema says:
|
|
108
|
+
|
|
109
|
+
| Step | Options | Renders as |
|
|
110
|
+
| --- | --- | --- |
|
|
111
|
+
| `Spacing top, Spacing bottom` | Default · 0 · S · M · L | padding-top and padding-bottom, inline on the section root. |
|
|
112
|
+
| `Width` | Default · Narrow · Wide · Full | The --smd-width variable on the section root; your container reads it. |
|
|
113
|
+
| `Align` | Default · Left · Center · Right | text-align on the section root. |
|
|
114
|
+
|
|
115
|
+
*Default* stores and renders nothing, so a section looks exactly as you wrote it until an editor reaches for a step. *0* is a real step, because two dark sections meeting need your padding removed, not added. Editors never see pixels or breakpoints.
|
|
116
|
+
|
|
117
|
+
The config supplies the CSS behind each step. The starter points them at variables its stylesheet defines per breakpoint:
|
|
118
|
+
|
|
119
|
+
File: `smoodly/config.ts`
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
export const config = defineConfig({
|
|
123
|
+
// …
|
|
124
|
+
styles: {
|
|
125
|
+
spacing: { sm: "var(--space-sm)", md: "var(--space-md)", lg: "var(--space-lg)" },
|
|
126
|
+
width: { narrow: "var(--width-narrow)", wide: "var(--width-wide)", full: "none" },
|
|
127
|
+
},
|
|
128
|
+
// …
|
|
129
|
+
});
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
File: `app/globals.css`
|
|
133
|
+
|
|
134
|
+
```css
|
|
135
|
+
:root {
|
|
136
|
+
--space-sm: 24px; --space-md: 48px; --space-lg: 72px;
|
|
137
|
+
--width-narrow: 640px; --width-wide: 1440px;
|
|
138
|
+
--container: 1200px;
|
|
139
|
+
}
|
|
140
|
+
@media (min-width: 768px) { :root { --space-sm: 32px; --space-md: 64px; --space-lg: 96px; } }
|
|
141
|
+
|
|
142
|
+
/* The container reads the editor's width step; without one, the design's width. */
|
|
143
|
+
.container { max-width: var(--smd-width, var(--container)); width: 100%; margin: 0 auto; padding-inline: 32px; }
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Width is a variable rather than a `max-width` on the section, so a section with a background stays full-bleed while its content narrows. Only your container knows it is the container, so give it the rule above. Without a `styles` entry in the config the package's own fallbacks apply.
|
|
147
|
+
|
|
148
|
+
Two things make the steps work in a view: the root element is Smoodly's section wrapper, and it receives the `styles` prop. A section that renders its own root gets no steps.
|
|
149
|
+
|
|
150
|
+
**Astro**
|
|
151
|
+
|
|
152
|
+
The wrapper is `<Section styles={styles}>` from `smoodly/astro/components`.
|
|
153
|
+
|
|
154
|
+
**Next.js**
|
|
155
|
+
|
|
156
|
+
The wrapper is `<smoodly.Section styles={styles}>` from `smoodly/react`.
|
|
157
|
+
|
|
158
|
+
To take the group away from a section, set `styles: false` in its schema, as the starter's header and footer do, or `styles: { omit: ["align"] }` to drop some steps. Elements never have styles: spacing is their container's job.
|
|
159
|
+
|
|
160
|
+
## Custom CSS
|
|
161
|
+
|
|
162
|
+
Turn the box on for the whole site in the config:
|
|
163
|
+
|
|
164
|
+
File: `smoodly/config.ts`
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
export const config = defineConfig({
|
|
168
|
+
// …
|
|
169
|
+
css: true,
|
|
170
|
+
// …
|
|
171
|
+
});
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Every section then has a *Custom CSS* box under *Advanced* on its Settings tab, and `css: false` in a schema opts one section out. What an editor types is nested inside the section's own selector, so declarations apply to the section root and nested selectors reach inside it:
|
|
175
|
+
|
|
176
|
+
```css
|
|
177
|
+
background: var(--ink);
|
|
178
|
+
color: var(--paper);
|
|
179
|
+
h2 { font-size: 40px; }
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
The snippet is scoped, so it cannot restyle another section or the page. It is also checked as it is typed and again at publish, and refused when it uses `@import`, `url()`, `expression()` or `behavior:`, contains `</`, leaves a brace unclosed, or runs past 4000 characters. The layout of the [custom section](../content-modeling/custom-section.md) is emitted the same way, before the editor's own snippet, so the box still wins.
|
|
183
|
+
|
|
184
|
+
Next, [The custom section](../content-modeling/custom-section.md) covers the one layout editors build themselves.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Zones and freedom
|
|
2
|
+
|
|
3
|
+
A zone is a place in a page type where editors add sections. Its policy sets how much they may change there, from filling one section's fields to placing any section in any order.
|
|
4
|
+
|
|
5
|
+
## The three policies
|
|
6
|
+
|
|
7
|
+
Every zone in `smoodly.schema.page()` takes one policy from `z`:
|
|
8
|
+
|
|
9
|
+
| Name | Description |
|
|
10
|
+
| --- | --- |
|
|
11
|
+
| `z.locked(heroSchema)` | Exactly one Hero, always. Editors fill its fields but cannot add, move or remove it. |
|
|
12
|
+
| `z.sections([heroSchema, pageHeroSchema])` | Any number of the listed sections, in any order. .max(n) caps how many. |
|
|
13
|
+
| `z.freeform({ sections: [...] })` | The same, plus custom: true to offer the custom section. sections: "*" allows every registered section. |
|
|
14
|
+
|
|
15
|
+
Policies name components, not strings, so a typo is a type error. One page type can mix all three:
|
|
16
|
+
|
|
17
|
+
**Astro**
|
|
18
|
+
|
|
19
|
+
The schema is the same file as in any framework. Its view, `src/page-types/landing.astro`, renders each zone with `<Zone of={zones.hero} />` from `smoodly/astro/components`, as on [Page types](../content-modeling/page-types.md); a zone locked to a global renders the global's section.
|
|
20
|
+
|
|
21
|
+
**Next.js**
|
|
22
|
+
|
|
23
|
+
File: `components/pages/Landing.tsx`
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
import { smoodly, z, Zone, type Props } from "smoodly/react";
|
|
27
|
+
import { heroSchema } from "@/components/sections/Hero.schema";
|
|
28
|
+
import { pageHeroSchema } from "@/components/sections/PageHero.schema";
|
|
29
|
+
import { quote } from "@/smoodly/globals";
|
|
30
|
+
|
|
31
|
+
export const landingSchema = smoodly.schema.page({
|
|
32
|
+
name: "landing",
|
|
33
|
+
title: "Landing",
|
|
34
|
+
zones: {
|
|
35
|
+
hero: z.sections([heroSchema, pageHeroSchema]).max(1),
|
|
36
|
+
body: z.freeform({ sections: "*", custom: true }),
|
|
37
|
+
closing: z.locked(quote),
|
|
38
|
+
},
|
|
39
|
+
seo: true,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function LandingView({ zones }: Props<typeof landingSchema>) {
|
|
43
|
+
return (
|
|
44
|
+
<>
|
|
45
|
+
<Zone of={zones.hero} />
|
|
46
|
+
<Zone of={zones.body} />
|
|
47
|
+
<Zone of={zones.closing} />
|
|
48
|
+
</>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const LandingPage = smoodly.page(landingSchema, LandingView);
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The hero zone offers a choice of two openers and takes one of them. The body takes anything. The closing zone always shows the site's one quote, edited under *Site*.
|
|
56
|
+
|
|
57
|
+
> **Note:** `sections: "*"` includes every registered section, the header and footer too. List the sections instead when some are only meant for the layout.
|
|
58
|
+
|
|
59
|
+
## What editors see
|
|
60
|
+
|
|
61
|
+
In the canvas, *Add section* appears between the sections of a zone and at its end. It opens a picker with the sections the policy allows, then *Custom section* where `custom: true`, then a *Global* group with the globals whose section is allowed there. A zone capped with `.max(n)` loses its *Add section* once it is full.
|
|
62
|
+
|
|
63
|
+
A section's toolbar moves it up or down, duplicates it or removes it. In a locked zone the section is tagged *locked* and the toolbar says *fields only*. Smoodly fills the zone from the section's `sample` the first time the page opens.
|
|
64
|
+
|
|
65
|
+
An empty zone shows as a box in the canvas so it can take a section. On the site it renders nothing.
|
|
66
|
+
|
|
67
|
+
## Locking a zone to a global
|
|
68
|
+
|
|
69
|
+
`z.locked()` also takes a global with a section, as `closing` does above. The zone then shows that one global on every page of the type. Editors change it once, under *Site* or right in any canvas that shows it, and every page follows. The starter's header and footer take a different route: the layout renders them directly, so they need no zone.
|
|
70
|
+
|
|
71
|
+
A global can also be placed by hand. The starter lists Quote in its body zones, so the picker's *Global* group offers the site quote there, and an editor may still add a one-off Quote from the sections list.
|
|
72
|
+
|
|
73
|
+
## Choosing a policy
|
|
74
|
+
|
|
75
|
+
- **Lock** what the design needs exactly once, in one place: the opener of every page type in the starter is a locked hero.
|
|
76
|
+
- **Sections with a list** for a slot with a few right answers: a hero zone that takes one of two openers.
|
|
77
|
+
- **Freeform** for the body. Most sites need it in one zone, with the sections you have built and `custom: true` for the layouts you have not.
|
|
78
|
+
- **Your own JSX** for what editors should never touch. It renders in the canvas but cannot be selected.
|
|
79
|
+
|
|
80
|
+
Next, [Settings, styles and CSS](../content-modeling/styles-and-settings.md) covers what editors can change on a placed section beyond its fields.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Choices
|
|
2
|
+
|
|
3
|
+
A switch, a dropdown, a row of pills or a checkbox list: the fields that pick from a closed set you declare.
|
|
4
|
+
|
|
5
|
+
## Options
|
|
6
|
+
|
|
7
|
+
`f.select()`, `f.radio()` and `f.checkboxes()` take a list of options. Each option is a `value`, which is stored and what the view compares against, and a `label`, which editors read:
|
|
8
|
+
|
|
9
|
+
File: `components/sections/Points.schema.ts`
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
icon: f.select([{ value: "circle", label: "Circle" }, { value: "square", label: "Square" }, { value: "diamond", label: "Diamond" }]),
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
A bare string is shorthand for an option whose label is its value: `f.radio(["left", "center"])`. The value's type in the view is the union of the option values, so a `switch` over it is exhaustive. An empty option list is an error at startup, and publish refuses a stored value that is not in the list.
|
|
16
|
+
|
|
17
|
+
## The four fields
|
|
18
|
+
|
|
19
|
+
| Field | Editor sees | Value |
|
|
20
|
+
| --- | --- | --- |
|
|
21
|
+
| `f.boolean()` | A switch. | true or false. Never required: an untouched switch is false. |
|
|
22
|
+
| `f.select([…])` | A dropdown. .optional() adds an empty choice. | One option value. |
|
|
23
|
+
| `f.radio([…])` | Pills in a row. .optional() adds a None pill that stores nothing. | One option value. |
|
|
24
|
+
| `f.checkboxes([…])` | A checkbox per option. | An array of option values, in option order whatever order they were ticked. |
|
|
25
|
+
|
|
26
|
+
Choose `f.radio()` for two to four options that fit on a row, and `f.select()` for longer lists. Smoodly's own styles use pills, so a radio in your settings looks the same.
|
|
27
|
+
|
|
28
|
+
## Defaults
|
|
29
|
+
|
|
30
|
+
A choice usually has a right answer to start from. `.default()` sets it, and the form shows it while the field is untouched:
|
|
31
|
+
|
|
32
|
+
File: `components/sections/ImageText.schema.ts`
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
settings: {
|
|
36
|
+
imageSide: f.radio([{ value: "left", label: "Image left" }, { value: "right", label: "Image right" }]).default("left"),
|
|
37
|
+
},
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
A boolean page field follows the same rule; the starter's `showInNavigation` defaults to `true`, so a new page appears in its parent's sub-navigation until an editor turns it off.
|
|
41
|
+
|
|
42
|
+
Next, [Image, video and file](../fields/media.md).
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Conditional fields
|
|
2
|
+
|
|
3
|
+
Hide a field while another field has a certain value, so editors see only the fields that apply.
|
|
4
|
+
|
|
5
|
+
## The condition
|
|
6
|
+
|
|
7
|
+
Every field and setting takes `.hidden(condition)`, and `f.when(path)` builds the condition:
|
|
8
|
+
|
|
9
|
+
| Name | Description |
|
|
10
|
+
| --- | --- |
|
|
11
|
+
| `f.when(path).is(value)` | Hidden while the field at path equals value. |
|
|
12
|
+
| `f.when(path).isNot(value)` | Hidden while it differs. |
|
|
13
|
+
| `f.when(path).in([a, b])` | Hidden while it is one of the listed values. |
|
|
14
|
+
| `f.when(path).isEmpty()` | Hidden while it is empty: unset, an empty string or list, or an image with no asset. |
|
|
15
|
+
| `f.when(path).isNotEmpty()` | Hidden while it has a value. |
|
|
16
|
+
|
|
17
|
+
A hidden field leaves the form and is skipped at publish, so a required field that is hidden is not required. Its stored value stays, and comes back when the condition stops holding.
|
|
18
|
+
|
|
19
|
+
## Paths
|
|
20
|
+
|
|
21
|
+
A bare path names a sibling: a field at the same level as the one being hidden. Inside an object or a list item, that is the item's own fields. A path prefixed with `fields.` or `settings.` names a top-level key of that group from anywhere.
|
|
22
|
+
|
|
23
|
+
A link that is either a page on the site or an external address:
|
|
24
|
+
|
|
25
|
+
**Astro**
|
|
26
|
+
|
|
27
|
+
The schema goes in `src/components/sections/Banner.schema.ts`, written as in the Next.js tab. Its view, `src/sections/banner.astro`, picks the link the same way, through `safeHref` since an editor typed it: `href={safeHref(cta.kind === "external" ? cta.url : cta.path)}`.
|
|
28
|
+
|
|
29
|
+
**Next.js**
|
|
30
|
+
|
|
31
|
+
File: `components/sections/Banner.tsx`
|
|
32
|
+
|
|
33
|
+
```tsx
|
|
34
|
+
import { f, smoodly, type Props } from "smoodly/react";
|
|
35
|
+
|
|
36
|
+
export const bannerSchema = smoodly.schema.section({
|
|
37
|
+
name: "banner",
|
|
38
|
+
title: "Banner",
|
|
39
|
+
fields: {
|
|
40
|
+
heading: f.text(),
|
|
41
|
+
cta: f.object({
|
|
42
|
+
label: f.text(),
|
|
43
|
+
kind: f.radio([{ value: "internal", label: "Page" }, { value: "external", label: "Address" }]).default("internal"),
|
|
44
|
+
path: f.link().hidden(f.when("kind").is("external")),
|
|
45
|
+
url: f.link().hidden(f.when("kind").is("internal")),
|
|
46
|
+
newTab: f.boolean().hidden(f.when("kind").is("internal")),
|
|
47
|
+
}),
|
|
48
|
+
},
|
|
49
|
+
settings: {
|
|
50
|
+
tone: f.radio([{ value: "light", label: "Light" }, { value: "dark", label: "Dark" }]).default("light"),
|
|
51
|
+
background: f.image().optional().hidden(f.when("settings.tone").is("light")),
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
function BannerView({ heading, cta, settings, styles }: Props<typeof bannerSchema>) {
|
|
56
|
+
const href = cta.kind === "external" ? cta.url : cta.path;
|
|
57
|
+
return (
|
|
58
|
+
<smoodly.Section styles={styles} className="banner" data-tone={settings.tone}>
|
|
59
|
+
<h2>{heading}</h2>
|
|
60
|
+
<a href={href} target={cta.newTab ? "_blank" : undefined} rel={cta.newTab ? "noopener" : undefined}>{cta.label}</a>
|
|
61
|
+
</smoodly.Section>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const Banner = smoodly.section(bannerSchema, BannerView);
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Inside `cta`, `kind` is a sibling of `path` and `url`, so the bare path reads it. The background setting reads `settings.tone` with the prefix. A condition that names a field the schema does not have fails at startup, not in the form.
|
|
69
|
+
|
|
70
|
+
The view still receives hidden fields as typed, so the sample above branches on `kind` itself rather than trusting that one of the two links is empty.
|
|
71
|
+
|
|
72
|
+
Next, the [Pages](../pages/page-tree.md) group covers what editors do with pages once the shapes exist.
|