smoodly 0.0.60 → 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/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,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).
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Reference and tags
|
|
2
|
+
|
|
3
|
+
A reference points at an entry in a collection and arrives in the view already loaded. Tags are short labels editors reuse across entries.
|
|
4
|
+
|
|
5
|
+
## Reference
|
|
6
|
+
|
|
7
|
+
`f.ref(() => collection)` picks one entry; `f.refList(() => collection)` picks several. The target is a function, so two collections can point at each other without an import cycle:
|
|
8
|
+
|
|
9
|
+
File: `components/sections/FavouriteArticles.schema.ts`
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { articles } from "@/smoodly/collections";
|
|
13
|
+
// …
|
|
14
|
+
fields: {
|
|
15
|
+
heading: f.text(),
|
|
16
|
+
items: f.refList(() => articles).max(6),
|
|
17
|
+
},
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Editors see a search picker over the collection's entries in the language they are editing, each with its status. A list takes each entry once and stops at `.max()`.
|
|
21
|
+
|
|
22
|
+
## What the view receives
|
|
23
|
+
|
|
24
|
+
A reference resolves before render, in one batched read per collection, so the view gets the entry as `{ id, path, fields }` rather than an id:
|
|
25
|
+
|
|
26
|
+
File: `components/sections/FavouriteArticles.tsx`
|
|
27
|
+
|
|
28
|
+
```tsx
|
|
29
|
+
{(items ?? []).filter((a) => a !== null).map((a, i) => (
|
|
30
|
+
<ArticleCard key={i} article={a.fields} url={url(a.path!)} locale={locale} />
|
|
31
|
+
))}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
An entry that is unpublished, or was deleted after the page went live, resolves to `null`, which is why the view filters before it maps. The value's type follows the target collection's fields.
|
|
35
|
+
|
|
36
|
+
The same rule holds in every read: sections, `q` lists and single reads, entry pages and globals. How deep a reference goes is the field's decision:
|
|
37
|
+
|
|
38
|
+
| Declaration | The view gets |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| `f.ref(() => authors)` | `{ id, path, fields }`; the author's own references stay id strings. |
|
|
41
|
+
| `.resolve("company")` | The named reference of the target resolved one level too. |
|
|
42
|
+
| `.resolve()` | The whole chain resolved. |
|
|
43
|
+
|
|
44
|
+
Types follow the depth, so `a.fields.company` is an id or a resolved entry as declared. One gap: where the chain meets a cycle it stops at ids, while the type says resolved.
|
|
45
|
+
|
|
46
|
+
## Where references may go
|
|
47
|
+
|
|
48
|
+
A reference lives at the top level of a section, element or collection schema. It cannot sit inside a list or an object, in a page field, or in an element that richtext embeds, because the reference index addresses fields by their top-level key. A schema that breaks the rule fails at startup with the path.
|
|
49
|
+
|
|
50
|
+
## Tags
|
|
51
|
+
|
|
52
|
+
`f.tags()` stores an array of short strings and shows them as chips. Editors type a tag and press Enter, or paste a comma-separated list, and pick from the tags other entries of the collection already use in that language:
|
|
53
|
+
|
|
54
|
+
File: `smoodly/collections.ts`
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
tags: f.tags().localized(),
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Tags are trimmed, and two that differ only in case are one tag: typing `design` when `Design` exists reuses it. `.min()` and `.max()` count tags. Each entry keeps its own list; there is no tag collection to manage. The starter shows an article's first tag on its card and all of them on the article page.
|
|
61
|
+
|
|
62
|
+
Next, [Conditional fields](../fields/conditional.md).
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# Richtext and elements
|
|
2
|
+
|
|
3
|
+
Formatted text stored as structured data, the toolset that decides what editors may put in it, and the elements they can insert between the paragraphs.
|
|
4
|
+
|
|
5
|
+
## The field
|
|
6
|
+
|
|
7
|
+
`f.richtext()` shows a text editor and stores a document, never HTML. Render it with `RichText`.
|
|
8
|
+
|
|
9
|
+
File: `components/sections/ImageText.schema.ts`
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
text: f.richtext({ toolset: smoodly.toolset({ marks: ["bold", "italic", "link"] }) }),
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
**Astro**
|
|
16
|
+
|
|
17
|
+
`RichText` comes from `smoodly/astro/components`:
|
|
18
|
+
|
|
19
|
+
File: `src/sections/imageText.astro`
|
|
20
|
+
|
|
21
|
+
```astro
|
|
22
|
+
---
|
|
23
|
+
import { Image, RichText, Section } from "smoodly/astro/components";
|
|
24
|
+
// …
|
|
25
|
+
---
|
|
26
|
+
// …
|
|
27
|
+
<div class="prose" {...m.field("text")}><RichText doc={text} /></div>
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**Next.js**
|
|
31
|
+
|
|
32
|
+
`RichText` comes from the site's bound renderer, below:
|
|
33
|
+
|
|
34
|
+
File: `components/sections/ImageText.tsx`
|
|
35
|
+
|
|
36
|
+
```tsx
|
|
37
|
+
import { RichText } from "@/smoodly/richtext";
|
|
38
|
+
// …
|
|
39
|
+
<div className="prose" {...m.field("text")}><RichText doc={text} /></div>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`RichText` renders a fragment with no wrapper, so the element and class around it are yours. An empty document counts as missing, so a required richtext field must hold some text before publish. In the canvas, a marked richtext field edits in place with a floating toolbar.
|
|
43
|
+
|
|
44
|
+
## Toolsets
|
|
45
|
+
|
|
46
|
+
A toolset is what a richtext field offers: which formatting, which block types, which elements. Without one, a field gets bold, italic and links, headings two to four, both lists and blockquotes. Heading one is left out because the section or page owns the page's first heading.
|
|
47
|
+
|
|
48
|
+
`smoodly.toolset()` builds one from three lists:
|
|
49
|
+
|
|
50
|
+
| Name | Description |
|
|
51
|
+
| --- | --- |
|
|
52
|
+
| `marks` | bold, italic, link. |
|
|
53
|
+
| `nodes` | h1 to h6, bulletList, orderedList, blockquote and image. image adds pictures from the media library, with a caption. |
|
|
54
|
+
| `elements` | Registered elements, by value. |
|
|
55
|
+
|
|
56
|
+
The starter keeps its toolsets in one file and imports them by value where a field needs them:
|
|
57
|
+
|
|
58
|
+
File: `smoodly/toolsets.ts`
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { smoodly } from "smoodly";
|
|
62
|
+
import { calloutSchema } from "../components/elements/Callout.schema";
|
|
63
|
+
import { ctaSchema } from "../components/elements/Cta.schema";
|
|
64
|
+
|
|
65
|
+
export const article = smoodly.toolset({
|
|
66
|
+
marks: ["bold", "italic", "link"],
|
|
67
|
+
nodes: ["h2", "h3", "h4", "bulletList", "orderedList", "blockquote", "image"],
|
|
68
|
+
elements: [calloutSchema, ctaSchema],
|
|
69
|
+
});
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
A toolset limits what editors can create, not what the document may hold. Tightening one later leaves existing content as it is, so nothing is rewritten behind an editor's back.
|
|
73
|
+
|
|
74
|
+
## Rendering with elements and images
|
|
75
|
+
|
|
76
|
+
**Astro**
|
|
77
|
+
|
|
78
|
+
The package's `RichText` knows your elements by file name and renders body images through Astro's image service, so there is nothing to bind. Links keep only safe targets: `http`, `https`, `mailto`, `tel` and site-relative paths.
|
|
79
|
+
|
|
80
|
+
**Next.js**
|
|
81
|
+
|
|
82
|
+
The `RichText` from `smoodly/react` renders text alone. A site that embeds elements or images binds its own renderer once, and imports it from there everywhere:
|
|
83
|
+
|
|
84
|
+
File: `smoodly/richtext.tsx`
|
|
85
|
+
|
|
86
|
+
```tsx
|
|
87
|
+
import { createRichText } from "smoodly/richtext";
|
|
88
|
+
import { Callout } from "@/components/elements/Callout";
|
|
89
|
+
import { Cta } from "@/components/elements/Cta";
|
|
90
|
+
|
|
91
|
+
export const RichText = createRichText({ elements: [Callout, Cta], sizes: "(min-width: 900px) 720px, 100vw" });
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The bound renderer knows the elements a document may name and renders body images through `SmoodlyImage`, with `sizes` set to the text column's width. Links keep only safe targets: `http`, `https`, `mailto`, `tel` and site-relative paths.
|
|
95
|
+
|
|
96
|
+
## Elements
|
|
97
|
+
|
|
98
|
+
An element is a small component editors insert between paragraphs, from the *+* button on the toolbar or by typing `/`. It is defined like a section, with `contexts` saying where it may go:
|
|
99
|
+
|
|
100
|
+
File: `components/elements/Callout.schema.ts`
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
export const calloutSchema = smoodly.schema.element({
|
|
104
|
+
name: "callout",
|
|
105
|
+
title: "Callout",
|
|
106
|
+
icon: "!",
|
|
107
|
+
description: "A highlighted note inside the text.",
|
|
108
|
+
contexts: ["richtext"],
|
|
109
|
+
fields: {
|
|
110
|
+
title: f.text().max(80),
|
|
111
|
+
body: f.richtext({ toolset: smoodly.toolset({ marks: ["bold", "italic", "link"] }) }),
|
|
112
|
+
},
|
|
113
|
+
settings: {
|
|
114
|
+
tone: f.select([{ value: "info", label: "Info" }, { value: "success", label: "Success" }, { value: "warning", label: "Warning" }]).default("info"),
|
|
115
|
+
},
|
|
116
|
+
// …
|
|
117
|
+
});
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
**Astro**
|
|
121
|
+
|
|
122
|
+
An element's view is `src/elements/<name>.astro`, named after the schema:
|
|
123
|
+
|
|
124
|
+
File: `src/elements/callout.astro`
|
|
125
|
+
|
|
126
|
+
```astro
|
|
127
|
+
---
|
|
128
|
+
// …
|
|
129
|
+
const { title, body, settings } = Astro.props;
|
|
130
|
+
---
|
|
131
|
+
<aside class="callout" data-tone={settings.tone}>
|
|
132
|
+
<strong class="callout__title">{title}</strong>
|
|
133
|
+
<div class="callout__body"><RichText doc={body} /></div>
|
|
134
|
+
</aside>
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
A link an editor types in an element goes through `safeHref` from `smoodly`, as in the starter's `cta.astro`.
|
|
138
|
+
|
|
139
|
+
**Next.js**
|
|
140
|
+
|
|
141
|
+
File: `components/elements/Callout.tsx`
|
|
142
|
+
|
|
143
|
+
```tsx
|
|
144
|
+
function CalloutView({ title, body, settings }: Props<typeof calloutSchema>) {
|
|
145
|
+
return (
|
|
146
|
+
<aside className="callout" data-tone={settings.tone}>
|
|
147
|
+
<strong className="callout__title">{title}</strong>
|
|
148
|
+
<div className="callout__body"><RichText doc={body} /></div>
|
|
149
|
+
</aside>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export const Callout = smoodly.element(calloutSchema, CalloutView);
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
An element renders with no wrapper of Smoodly's and has no styles or CSS box: spacing is the text column's job. It may not hold a reference. In the editor an inserted element is a card with its own fields, and its `sample` fills it on insert.
|
|
157
|
+
|
|
158
|
+
Register an element's schema in `smoodly/elements.ts` and list it in the toolsets that may insert it. A toolset that names an element the config does not register fails at startup.
|
|
159
|
+
|
|
160
|
+
**Astro**
|
|
161
|
+
|
|
162
|
+
The view is the file; there is nothing else to list.
|
|
163
|
+
|
|
164
|
+
**Next.js**
|
|
165
|
+
|
|
166
|
+
Add its component to `smoodly/views.ts` and `smoodly/richtext.tsx` as well.
|
|
167
|
+
|
|
168
|
+
Next, [Link](../fields/link.md).
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# Text, number, date and time
|
|
2
|
+
|
|
3
|
+
The plain values: a line of text, a number, a date, a date with a time, and the slug that becomes part of an entry's URL.
|
|
4
|
+
|
|
5
|
+
## Text
|
|
6
|
+
|
|
7
|
+
`f.text()` is a single line. `.max(n)` limits it in characters, and publish enforces the limit. There is no multi-line text field: paragraphs are [richtext](../fields/richtext.md), which keeps formatting as data rather than as line breaks.
|
|
8
|
+
|
|
9
|
+
File: `components/elements/Cta.schema.ts`
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
fields: {
|
|
13
|
+
heading: f.text().max(80),
|
|
14
|
+
text: f.text().max(200).optional(),
|
|
15
|
+
label: f.text().max(40),
|
|
16
|
+
href: f.link(),
|
|
17
|
+
},
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Text is the one field editors can change in place in the canvas, when its element carries a [mark](../content-modeling/marks.md).
|
|
21
|
+
|
|
22
|
+
## Number
|
|
23
|
+
|
|
24
|
+
`f.number()` shows a number box and stores a number. An empty box stores nothing, so an optional number is `undefined` in the view, not `0`.
|
|
25
|
+
|
|
26
|
+
## Date and time
|
|
27
|
+
|
|
28
|
+
`f.date()` is a calendar: a text box the editor may type into, and a calendar button that opens a month grid. It stores the day as `2026-09-02`, and publish refuses anything else — `2.9.2026` or a February 30th never reach the site. The view formats it for the reader; the starter formats in the page's language:
|
|
29
|
+
|
|
30
|
+
File: `components/articles.ts`
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
export const formatDate = (iso: string, locale: string) =>
|
|
34
|
+
new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeZone: "UTC" }).format(new Date(iso));
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`f.datetime()` is the same calendar with an hour and a minute beside it. It stores a moment in time as a UTC instant, `2026-09-02T14:30:00Z`, so an event start or a publish-at means the same thing in every zone and sorts as a string. The editor picks in the site's time zone, and a hint under the field names it.
|
|
38
|
+
|
|
39
|
+
The site's zone is one line of config, an IANA name:
|
|
40
|
+
|
|
41
|
+
File: `smoodly/config.ts`
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
export const config = defineConfig({
|
|
45
|
+
// …
|
|
46
|
+
locales: { default: routing.defaultLocale, supported: [...routing.locales] },
|
|
47
|
+
// Editors pick dates and times in this zone; the views format with it (an IANA name, never an offset).
|
|
48
|
+
timeZone: "Europe/Helsinki",
|
|
49
|
+
// …
|
|
50
|
+
});
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Name a city, never an offset. `"+3"` is wrong half the year; `Europe/Helsinki` carries the daylight-saving rules, and the browser and Node apply them from their own zone database. `defineConfig` refuses a name the runtime does not know. Without `timeZone`, editors pick in their own browser's zone, and the hint says so.
|
|
54
|
+
|
|
55
|
+
A view formats a datetime with the same zone:
|
|
56
|
+
|
|
57
|
+
File: `components/articles.ts`
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
export const formatDateTime = (iso: string, locale: string, timeZone: string) =>
|
|
61
|
+
new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short", timeZone }).format(new Date(iso));
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Pass `config.timeZone` from your config file. A collection can order its entries by either field, as the starter's articles do with `order: { by: "date", direction: "desc" }`.
|
|
65
|
+
|
|
66
|
+
### Time zones in Europe
|
|
67
|
+
|
|
68
|
+
The value to put in `timeZone`, country by country. A country with several zones has its capital first.
|
|
69
|
+
|
|
70
|
+
| Country | timeZone |
|
|
71
|
+
| --- | --- |
|
|
72
|
+
| `Albania` | Europe/Tirane |
|
|
73
|
+
| `Andorra` | Europe/Andorra |
|
|
74
|
+
| `Austria` | Europe/Vienna |
|
|
75
|
+
| `Belarus` | Europe/Minsk |
|
|
76
|
+
| `Belgium` | Europe/Brussels |
|
|
77
|
+
| `Bosnia and Herzegovina` | Europe/Sarajevo |
|
|
78
|
+
| `Bulgaria` | Europe/Sofia |
|
|
79
|
+
| `Croatia` | Europe/Zagreb |
|
|
80
|
+
| `Cyprus` | Asia/Nicosia |
|
|
81
|
+
| `Czechia` | Europe/Prague |
|
|
82
|
+
| `Denmark` | Europe/Copenhagen |
|
|
83
|
+
| `Estonia` | Europe/Tallinn |
|
|
84
|
+
| `Faroe Islands` | Atlantic/Faroe |
|
|
85
|
+
| `Finland` | Europe/Helsinki |
|
|
86
|
+
| `Åland` | Europe/Mariehamn |
|
|
87
|
+
| `France` | Europe/Paris |
|
|
88
|
+
| `Germany` | Europe/Berlin (Büsingen: Europe/Busingen) |
|
|
89
|
+
| `Gibraltar` | Europe/Gibraltar |
|
|
90
|
+
| `Greece` | Europe/Athens |
|
|
91
|
+
| `Guernsey` | Europe/Guernsey |
|
|
92
|
+
| `Hungary` | Europe/Budapest |
|
|
93
|
+
| `Iceland` | Atlantic/Reykjavik |
|
|
94
|
+
| `Ireland` | Europe/Dublin |
|
|
95
|
+
| `Isle of Man` | Europe/Isle_of_Man |
|
|
96
|
+
| `Italy` | Europe/Rome |
|
|
97
|
+
| `Jersey` | Europe/Jersey |
|
|
98
|
+
| `Kosovo` | Europe/Belgrade |
|
|
99
|
+
| `Latvia` | Europe/Riga |
|
|
100
|
+
| `Liechtenstein` | Europe/Vaduz |
|
|
101
|
+
| `Lithuania` | Europe/Vilnius |
|
|
102
|
+
| `Luxembourg` | Europe/Luxembourg |
|
|
103
|
+
| `Malta` | Europe/Malta |
|
|
104
|
+
| `Moldova` | Europe/Chisinau |
|
|
105
|
+
| `Monaco` | Europe/Monaco |
|
|
106
|
+
| `Montenegro` | Europe/Podgorica |
|
|
107
|
+
| `Netherlands` | Europe/Amsterdam |
|
|
108
|
+
| `North Macedonia` | Europe/Skopje |
|
|
109
|
+
| `Norway` | Europe/Oslo (Svalbard: Arctic/Longyearbyen) |
|
|
110
|
+
| `Poland` | Europe/Warsaw |
|
|
111
|
+
| `Portugal` | Europe/Lisbon (Azores: Atlantic/Azores, Madeira: Atlantic/Madeira) |
|
|
112
|
+
| `Romania` | Europe/Bucharest |
|
|
113
|
+
| `Russia` | Europe/Moscow (also Europe/Kaliningrad, Europe/Samara, Europe/Astrakhan, Europe/Saratov, Europe/Ulyanovsk, Europe/Volgograd, Europe/Kirov) |
|
|
114
|
+
| `San Marino` | Europe/San_Marino |
|
|
115
|
+
| `Serbia` | Europe/Belgrade |
|
|
116
|
+
| `Slovakia` | Europe/Bratislava |
|
|
117
|
+
| `Slovenia` | Europe/Ljubljana |
|
|
118
|
+
| `Spain` | Europe/Madrid (Canary Islands: Atlantic/Canary) |
|
|
119
|
+
| `Sweden` | Europe/Stockholm |
|
|
120
|
+
| `Switzerland` | Europe/Zurich |
|
|
121
|
+
| `Turkey` | Europe/Istanbul |
|
|
122
|
+
| `Ukraine` | Europe/Kyiv (Crimea: Europe/Simferopol) |
|
|
123
|
+
| `United Kingdom` | Europe/London |
|
|
124
|
+
| `Vatican City` | Europe/Vatican |
|
|
125
|
+
|
|
126
|
+
Outside Europe the same rule holds — a city name from the IANA database, such as `America/New_York` or `Asia/Tokyo`. The full list is `Intl.supportedValuesOf("timeZone")` in any modern runtime.
|
|
127
|
+
|
|
128
|
+
## Slug
|
|
129
|
+
|
|
130
|
+
`f.slug()` is a text box shown with the entry's URL prefix in front, such as `/posts/`. It belongs on a collection, and the field must be named `slug`: that is the name the URL is built from, so `collection()` refuses `f.slug()` under any other name.
|
|
131
|
+
|
|
132
|
+
File: `smoodly/collections.ts`
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
export const articles = smoodly.collection({
|
|
136
|
+
name: "articles",
|
|
137
|
+
title: "Articles",
|
|
138
|
+
titleField: "title",
|
|
139
|
+
path: { en: "posts", fi: "artikkelit" },
|
|
140
|
+
// …
|
|
141
|
+
fields: {
|
|
142
|
+
title: f.text().localized(),
|
|
143
|
+
// …
|
|
144
|
+
slug: f.slug({ from: "title" }),
|
|
145
|
+
// …
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The starter passes `from: "title"`: the admin fills the slug from the title as the editor types it, until they type a slug of their own. It stops at the first publish, so a live URL never changes by itself. A slug is tidied when the box loses focus, so `Hello World` becomes `hello-world`, and publish refuses anything that is still not a valid slug. A slug is per language without `.localized()`, because each language has its own URL. Two entries under the same parent cannot share one. Pages have no slug field: a page's slug is set in the admin when it is created and renamed under *Settings*.
|
|
151
|
+
|
|
152
|
+
Next, [Richtext and elements](../fields/richtext.md).
|