smoodly 0.0.60 → 0.0.62

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.
Files changed (76) hide show
  1. package/README.md +3 -1
  2. package/dist/admin/editor/CanvasOverlay.d.ts +4 -1
  3. package/dist/admin/editor/CanvasOverlay.js +3 -3
  4. package/dist/admin/editor/GlobalCanvas.d.ts +27 -0
  5. package/dist/admin/editor/GlobalCanvas.js +159 -0
  6. package/dist/admin/editor/InsertPicker.js +1 -1
  7. package/dist/admin/files.generated.js +1 -1
  8. package/dist/admin/serialize.d.ts +2 -0
  9. package/dist/admin/serialize.js +1 -0
  10. package/dist/admin/shell/AdminApp.js +5 -5
  11. package/dist/admin/shell/GlobalForm.js +80 -7
  12. package/dist/admin/shell/GlobalList.js +1 -1
  13. package/dist/admin/shell/{IconRail.d.ts → NavBar.d.ts} +3 -3
  14. package/dist/admin/shell/NavBar.js +120 -0
  15. package/dist/admin/shell/global-canvas.d.ts +40 -0
  16. package/dist/admin/shell/global-canvas.js +60 -0
  17. package/dist/admin/shell/health-list.js +1 -1
  18. package/dist/admin/ui/primitives.js +1 -1
  19. package/dist/admin/ui/theme.js +99 -91
  20. package/dist/api-client.js +5 -3
  21. package/dist/astro/index.js +3 -3
  22. package/dist/cli/push.js +2 -3
  23. package/dist/env.js +3 -2
  24. package/dist/push.d.ts +7 -0
  25. package/dist/push.js +8 -0
  26. package/dist/space-kind.d.ts +2 -0
  27. package/dist/space-kind.js +1 -0
  28. package/dist/version.d.ts +1 -1
  29. package/dist/version.js +1 -1
  30. package/docs/collections/defining.md +57 -0
  31. package/docs/collections/entry-pages.md +168 -0
  32. package/docs/collections/ordering.md +47 -0
  33. package/docs/collections/queries.md +173 -0
  34. package/docs/content-modeling/changing-schemas.md +79 -0
  35. package/docs/content-modeling/custom-section.md +94 -0
  36. package/docs/content-modeling/marks.md +97 -0
  37. package/docs/content-modeling/overview.md +55 -0
  38. package/docs/content-modeling/page-types.md +237 -0
  39. package/docs/content-modeling/sections.md +264 -0
  40. package/docs/content-modeling/styles-and-settings.md +184 -0
  41. package/docs/content-modeling/zones.md +80 -0
  42. package/docs/fields/choices.md +42 -0
  43. package/docs/fields/conditional.md +72 -0
  44. package/docs/fields/link.md +31 -0
  45. package/docs/fields/list-and-object.md +58 -0
  46. package/docs/fields/media.md +103 -0
  47. package/docs/fields/overview.md +56 -0
  48. package/docs/fields/reference-and-tags.md +62 -0
  49. package/docs/fields/richtext.md +168 -0
  50. package/docs/fields/text.md +152 -0
  51. package/docs/frameworks/astro.md +110 -0
  52. package/docs/frameworks/nextjs.md +147 -0
  53. package/docs/getting-started/content-from-code.md +263 -0
  54. package/docs/getting-started/deploying.md +131 -0
  55. package/docs/getting-started/export-and-import.md +73 -0
  56. package/docs/getting-started/first-page.md +98 -0
  57. package/docs/getting-started/install.md +108 -0
  58. package/docs/getting-started/keys.md +40 -0
  59. package/docs/getting-started/project-structure.md +142 -0
  60. package/docs/getting-started/working-together.md +42 -0
  61. package/docs/globals/defining.md +99 -0
  62. package/docs/globals/header-and-footer.md +117 -0
  63. package/docs/index.md +83 -0
  64. package/docs/languages/language-switcher.md +103 -0
  65. package/docs/languages/locales.md +119 -0
  66. package/docs/languages/localized-fields.md +61 -0
  67. package/docs/media/images-and-crops.md +131 -0
  68. package/docs/media/library.md +40 -0
  69. package/docs/pages/drafts-and-publishing.md +44 -0
  70. package/docs/pages/page-tree.md +51 -0
  71. package/docs/pages/paths-and-slugs.md +74 -0
  72. package/docs/pages/seo.md +126 -0
  73. package/docs/pages/version-history.md +23 -0
  74. package/docs/reference/upgrading.md +312 -0
  75. package/package.json +4 -3
  76. package/dist/admin/shell/IconRail.js +0 -109
@@ -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 *Sections*.
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 *Sections* 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.
@@ -0,0 +1,31 @@
1
+ # Link
2
+
3
+ A link is a text box that takes a full URL or a path on your site. Pair it with a label in an object to make a button.
4
+
5
+ ## The field
6
+
7
+ `f.link()` stores a string and shows a text box. Editors type a full address, `https://example.com/pricing`, or a path on the site, `/company/about-us`. The view renders it as an `href`:
8
+
9
+ File: `components/sections/Hero.schema.ts`
10
+
11
+ ```ts
12
+ buttons: f.list(f.object({ label: f.text(), href: f.link() })).max(2),
13
+ ```
14
+
15
+ File: `components/sections/Hero.tsx`
16
+
17
+ ```tsx
18
+ <a key={i} className={i === 0 ? "button" : "button button--outline"} href={b.href}
19
+ ```
20
+
21
+ The box takes only a target the site will render: `https://`, `http://`, `mailto:`, `tel:`, a path starting with `/`, `#` or `?`. Anything else, a `javascript:` address say, is marked under the box as the editor types, and Publish refuses it.
22
+
23
+ A path is stored as typed, not looked up: on a multilingual site each language's link holds that language's own URL, such as `/fi/yritys`, and a link to a page does not follow it when its slug changes.
24
+
25
+ A link with a label is an object of two fields, as above. A list of those objects is a row of buttons, capped with `.max()`.
26
+
27
+ ## Links inside richtext
28
+
29
+ Text inside a [richtext](../fields/richtext.md) field links through the `link` mark of its toolset, which editors apply from the toolbar. On the site, `RichText` renders only safe targets: `http`, `https`, `mailto`, `tel`, and paths on the site. Anything else renders as plain text.
30
+
31
+ Next, [Choices](../fields/choices.md).
@@ -0,0 +1,58 @@
1
+ # List and object
2
+
3
+ Group fields into one value with an object, or let editors repeat a value with a list. The two nest one level, which is as far as a sidebar form stays readable.
4
+
5
+ ## Object
6
+
7
+ `f.object()` groups fields into one value. Editors see the group under one label; the view gets one object:
8
+
9
+ File: `components/sections/ImageText.schema.ts`
10
+
11
+ ```ts
12
+ link: f.object({ label: f.text(), href: f.link() }).optional(),
13
+ ```
14
+
15
+ File: `components/sections/ImageText.tsx`
16
+
17
+ ```tsx
18
+ {link?.href && <a className="link-accent" href={link.href} {...linkMarks.attrs} {...linkMarks.field("label")}>{link.label}</a>}
19
+ ```
20
+
21
+ An optional object may be left empty as a whole. Without `.optional()`, its required fields are required.
22
+
23
+ ## List
24
+
25
+ `f.list(item)` repeats any builder. Editors add, reorder and remove items; `.min()` and `.max()` count them, the form shows a counter, and *Add* stops at the limit:
26
+
27
+ File: `components/sections/Points.schema.ts`
28
+
29
+ ```ts
30
+ points: f.list(f.object({
31
+ icon: f.select([{ value: "circle", label: "Circle" }, { value: "square", label: "Square" }, { value: "diamond", label: "Diamond" }]),
32
+ heading: f.text(),
33
+ text: f.text(),
34
+ })).min(1).max(6),
35
+ ```
36
+
37
+ A list of objects is the common shape: buttons, points, team members typed by hand. A list of one builder works too, `f.list(f.text())` for a plain list of lines.
38
+
39
+ ## The limits
40
+
41
+ - **A list never holds a list.** A list inside an object inside a list is still nested on screen, and a sidebar cannot show it legibly. A section that needs a grid of groups of lists is two sections, or a [collection](../collections/defining.md).
42
+ - **No references inside.** `f.ref()` and `f.refList()` live at the top level of a schema, where the reference index can address them.
43
+ - **Localize the whole value.** `.localized()` goes on the list or the object, not on a field inside it. The starter's footer localizes each link column as one value:
44
+
45
+ File: `components/sections/Footer.schema.ts`
46
+
47
+ ```ts
48
+ const column = () =>
49
+ f.object({ title: f.text(), links: f.list(f.object({ label: f.text(), href: f.link() })) }).localized();
50
+ ```
51
+
52
+ Each limit is an error at startup, with the path that broke it.
53
+
54
+ ## In the canvas
55
+
56
+ A list item and an object are scopes for [marks](../content-modeling/marks.md): `m.item("points", i)` and `m.object("link")` give you `attrs` for the item's root and `field()` for the fields inside, so each item's text edits in place. A mark for the whole list opens the repeater in the inspector.
57
+
58
+ Next, [Reference and tags](../fields/reference-and-tags.md).
@@ -0,0 +1,103 @@
1
+ # Image, video and file
2
+
3
+ The three media fields, the resolved values your view receives, and the components that render them.
4
+
5
+ ## Image
6
+
7
+ `f.image()` opens the media picker: drop a file to upload it, or pick one from the library. `aspect` names one ratio, or several the crop tool may lock to, so the component knows its box before the image loads:
8
+
9
+ File: `components/sections/Hero.schema.ts`
10
+
11
+ ```ts
12
+ image: f.image({ aspect: "4:5" }),
13
+ ```
14
+
15
+ For art direction, name a crop per shape the layout needs instead: `f.image({ crops: { desktop: "16:9", mobile: "4:5" } })`. Editors frame each named crop on its own, all sharing one focus point, and a crop nobody framed follows the focus point at its ratio, so most images need no second look. The first crop is the field's main crop: the resolved value's `crop` and `aspect` are the first one's, and `crops` holds every one by name with its `crop`, `aspect` and pixel `width` and `height`. `crops` and `aspect` are exclusive, a name is a lowercase identifier, and a name removed from the schema leaves its stored frame unread. `quality` and `maxWidth` set how this kind of image is served, such as `f.image({ quality: 95 })` for a portfolio's photos; [Quality and width](../media/images-and-crops.md#quality-and-width) has the rules.
16
+
17
+ **Astro**
18
+
19
+ File: `src/sections/hero.astro`
20
+
21
+ ```astro
22
+ {image && <Image image={image} class="hero__image" sizes="(min-width: 900px) 40vw, 100vw" priority {...m.field("image")} />}
23
+ ```
24
+
25
+ Editors pick a crop and a focus point per use under *Edit*, and can give this use its own alt text; otherwise the asset's own alt from the library applies. What the view receives is resolved: the URL, the dimensions, the alt and the crop. Render it with `Image` from `smoodly/astro/components`:
26
+
27
+ | Prop | Use |
28
+ | --- | --- |
29
+ | `image` | The resolved value. |
30
+ | `aspect` | Overrides the value's ratio for this placement, such as a 21:9 header over a 3:2 card. |
31
+ | `media` | Where each named crop applies, a media query per name in order: `{ mobile: "(max-width: 767px)" }`. |
32
+ | `sizes, priority, loading` | Set sizes to what the layout gives the image, and priority on the one above the fold. |
33
+ | `quality, maxWidth` | This placement's quality (50, 60, 75, 85, 95 or 100) and the widest cut its `srcset` lists, over the field's and the project's: [Quality and width](../media/images-and-crops.md#quality-and-width). |
34
+ | `class, style` | Land on the wrapper box, which holds the aspect ratio. |
35
+ | `data-*` | Land on the wrapper too, so a field mark selects the image in the canvas. |
36
+
37
+ The box keeps its ratio and nothing shifts as it loads. The image is plain HTML whose `srcset` lists the framed area cut at a fixed list of widths on Smoodly's media host, in AVIF or WebP; [Images and crops](../media/images-and-crops.md) has the details.
38
+
39
+ **Next.js**
40
+
41
+ File: `components/sections/Hero.tsx`
42
+
43
+ ```tsx
44
+ {image && <SmoodlyImage image={image} className="hero__image" sizes="(min-width: 900px) 40vw, 100vw" priority {...m.field("image")} />}
45
+ ```
46
+
47
+ Editors pick a crop and a focus point per use under *Edit*, and can give this use its own alt text; otherwise the asset's own alt from the library applies. What the view receives is resolved: the URL, the dimensions, the alt and the crop. Render it with `SmoodlyImage` from `smoodly/image`:
48
+
49
+ | Prop | Use |
50
+ | --- | --- |
51
+ | `image` | The resolved value. |
52
+ | `aspect` | Overrides the value's ratio for this placement, such as a 21:9 header over a 3:2 card. |
53
+ | `media` | Where each named crop applies, a media query per name in order: `{ mobile: "(max-width: 767px)" }`. |
54
+ | `sizes, priority, loading` | Set sizes to what the layout gives the image, and priority on the one above the fold. |
55
+ | `quality, maxWidth` | This placement's quality (50, 60, 75, 85, 95 or 100) and the widest cut its `srcset` lists, over the field's and the project's: [Quality and width](../media/images-and-crops.md#quality-and-width). |
56
+ | `className, style` | Land on the wrapper box, which holds the aspect ratio. |
57
+ | `data-*` | Land on the wrapper too, so a field mark selects the image in the canvas. |
58
+
59
+ The box keeps its ratio and nothing shifts as it loads. The image is a plain `<img>`, not `next/image`, whose `srcset` lists the framed area cut at a fixed list of widths on Smoodly's media host, in AVIF or WebP; [Images and crops](../media/images-and-crops.md) has the details.
60
+
61
+ ## Video and file
62
+
63
+ `f.video()` and `f.file()` use the same picker. A video resolves to its URL, dimensions and, when the library has one, a poster image.
64
+
65
+ **Astro**
66
+
67
+ `Video` from `smoodly/astro/components` renders it as a `video` element with the poster and size set, and passes every other attribute through: `<Video video={video} controls muted />`. A file is a link to its `url`.
68
+
69
+ **Next.js**
70
+
71
+ `SmoodlyVideo` from `smoodly/image` renders it as a `video` element with the poster and size set, and passes every other attribute through:
72
+
73
+ File: `components/sections/Reel.tsx`
74
+
75
+ ```tsx
76
+ import { f, smoodly, type Props } from "smoodly/react";
77
+ import { SmoodlyVideo } from "smoodly/image";
78
+
79
+ export const reelSchema = smoodly.schema.section({
80
+ name: "reel",
81
+ title: "Reel",
82
+ fields: { video: f.video(), brochure: f.file().optional() },
83
+ });
84
+
85
+ function ReelView({ video, brochure, styles }: Props<typeof reelSchema>) {
86
+ return (
87
+ <smoodly.Section styles={styles} className="reel">
88
+ <SmoodlyVideo video={video} controls muted />
89
+ {brochure && <a href={brochure.url} download={brochure.filename}>{brochure.title ?? brochure.filename}</a>}
90
+ </smoodly.Section>
91
+ );
92
+ }
93
+
94
+ export const Reel = smoodly.section(reelSchema, ReelView);
95
+ ```
96
+
97
+ A file resolves to its URL, filename, size and mime type, plus a title when the value carries one. Videos are served as uploaded, with no transcoding.
98
+
99
+ ## Where media lives
100
+
101
+ Uploads go to the media library, one place for the whole site, and a field stores a reference to an asset plus its own crop and alt. *Replace* on a field picks a different asset for that field alone, and the library shows where each asset is used. [The media library](../media/library.md) covers uploads, limits and usage.
102
+
103
+ Next, [List and object](../fields/list-and-object.md).
@@ -0,0 +1,56 @@
1
+ # All fields
2
+
3
+ Every field builder on f, what editors see for each, and the chain of modifiers they share. The pages that follow take them a few at a time.
4
+
5
+ ## The builders
6
+
7
+ | Field | Editor sees | Value in the view |
8
+ | --- | --- | --- |
9
+ | `f.text()` | A single-line text box. | string |
10
+ | `f.richtext({ toolset })` | A text editor with the toolset's formatting and elements. | A document for <RichText> |
11
+ | `f.link()` | A text box for a URL or a site path. | string |
12
+ | `f.slug()` | A text box with the URL prefix in front. Name the field slug. | string |
13
+ | `f.number()` | A number box. | number |
14
+ | `f.date()` | A calendar. Stored as 2026-09-02. | string |
15
+ | `f.datetime()` | A calendar with a time, in the site's zone. Stored as a UTC instant. | string |
16
+ | `f.boolean()` | A switch. | boolean |
17
+ | `f.select([…])` | A dropdown. | One of the option values |
18
+ | `f.radio([…])` | A row of pills. | One of the option values |
19
+ | `f.checkboxes([…])` | A checkbox list. | An array of option values |
20
+ | `f.tags()` | Chips, with suggestions from existing entries. | string[] |
21
+ | `f.image({ aspect })` | The media picker, with a crop tool. | A resolved image for the image component |
22
+ | `f.video()` | The media picker. | A resolved video for the video component |
23
+ | `f.file()` | The media picker. | A resolved file: url, filename, size, mime |
24
+ | `f.object({ … })` | A group of fields. | An object |
25
+ | `f.list(item)` | A repeater: add, reorder, remove. | An array of the item's value |
26
+ | `f.ref(() => coll)` | A search picker over one collection. | The entry's fields, or null |
27
+ | `f.refList(() => coll)` | The same picker, taking several. | An array of entries |
28
+
29
+ Every field is required unless it chains `.optional()`. A boolean is never required, and an object whose fields are all optional may be left empty. Publish reports what is missing; drafts save regardless.
30
+
31
+ ## The chain
32
+
33
+ Every builder shares these modifiers. Chaining never changes the builder in place, so a shared builder is safe to reuse.
34
+
35
+ | Name | Description |
36
+ | --- | --- |
37
+ | `.optional()` | The field may be empty. Its type in the view gains undefined. |
38
+ | `.default(value)` | The value a new section or entry starts with, and what the form shows while the field is empty. |
39
+ | `.min(n), .max(n)` | Counts items on a list, a reference list and tags. .max(n) also limits a text's length in characters. |
40
+ | `.localized()` | The field has its own value per language on an entry or a global. Page content is per language already. |
41
+ | `.hidden(f.when(path).is(value))` | Leaves the form while the condition holds. See Conditional fields. |
42
+ | `.resolve()` | On a ref: how far to follow the target's own references. See Reference and tags. |
43
+
44
+ ## Where a field may go
45
+
46
+ The same builders serve every schema, with a few limits that keep the admin simple:
47
+
48
+ | Place | Limits |
49
+ | --- | --- |
50
+ | `Section and element fields` | Any builder. A ref lives at the top level, never inside a list or an object. A list never holds a list. |
51
+ | `Settings` | Any builder, but never .localized(): a layout choice is the same in every language. |
52
+ | `Page fields` | No ref, list or richtext, and no .localized(), because page fields are per language already. |
53
+ | `Collection fields` | Any builder. Name a text field slug to give entries their own URL part. |
54
+ | `Elements in richtext` | Any builder except a ref. |
55
+
56
+ Next, [Text, number and date](../fields/text.md).