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.
- package/README.md +3 -1
- package/dist/admin/editor/CanvasOverlay.d.ts +4 -1
- package/dist/admin/editor/CanvasOverlay.js +3 -3
- package/dist/admin/editor/GlobalCanvas.d.ts +27 -0
- package/dist/admin/editor/GlobalCanvas.js +159 -0
- package/dist/admin/editor/InsertPicker.js +1 -1
- package/dist/admin/files.generated.js +1 -1
- package/dist/admin/serialize.d.ts +2 -0
- package/dist/admin/serialize.js +1 -0
- package/dist/admin/shell/AdminApp.js +5 -5
- package/dist/admin/shell/GlobalForm.js +80 -7
- package/dist/admin/shell/GlobalList.js +1 -1
- package/dist/admin/shell/{IconRail.d.ts → NavBar.d.ts} +3 -3
- package/dist/admin/shell/NavBar.js +120 -0
- package/dist/admin/shell/global-canvas.d.ts +40 -0
- package/dist/admin/shell/global-canvas.js +60 -0
- package/dist/admin/shell/health-list.js +1 -1
- package/dist/admin/ui/primitives.js +1 -1
- package/dist/admin/ui/theme.js +99 -91
- 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
- package/dist/admin/shell/IconRail.js +0 -109
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# Entry pages
|
|
2
|
+
|
|
3
|
+
Give a collection a path and each entry gets its own URL. The entry page is the component that renders one entry there.
|
|
4
|
+
|
|
5
|
+
## The path
|
|
6
|
+
|
|
7
|
+
`path` on a collection is the URL segment its entries live under. The starter's articles sit at `/posts/<slug>` in English and `/artikkelit/<slug>` in Finnish:
|
|
8
|
+
|
|
9
|
+
File: `smoodly/collections.ts`
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
export const articles = smoodly.collection({
|
|
13
|
+
name: "articles",
|
|
14
|
+
title: "Articles",
|
|
15
|
+
titleField: "title",
|
|
16
|
+
path: { en: "posts", fi: "artikkelit" },
|
|
17
|
+
order: { by: "date", direction: "desc" },
|
|
18
|
+
fields: {
|
|
19
|
+
title: f.text().localized(),
|
|
20
|
+
image: f.image({ aspect: "3:2" }),
|
|
21
|
+
date: f.date(),
|
|
22
|
+
author: f.ref(() => people),
|
|
23
|
+
slug: f.slug({ from: "title" }),
|
|
24
|
+
excerpt: f.text().localized(),
|
|
25
|
+
// …
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
A string applies to every language; an object names the segment per language. The collection needs a field named `slug` for the entry's own part. A collection without a `path` has no URLs: its entries appear only through references and queries.
|
|
31
|
+
|
|
32
|
+
The path shows in the page tree as a mount, so editors see where the entries live. Pages cannot be created under it.
|
|
33
|
+
|
|
34
|
+
## A nested path
|
|
35
|
+
|
|
36
|
+
A path may have several parts. The starter's news sits under the company, at `/company/news/<slug>` and `/yritys/uutiset/<slug>` in Finnish:
|
|
37
|
+
|
|
38
|
+
File: `smoodly/collections.ts`
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
export const news = smoodly.collection({
|
|
42
|
+
name: "news",
|
|
43
|
+
title: "News",
|
|
44
|
+
titleField: "title",
|
|
45
|
+
path: { en: "company/news", fi: "yritys/uutiset" },
|
|
46
|
+
order: { by: "date", direction: "desc" },
|
|
47
|
+
// …
|
|
48
|
+
});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Your code owns the whole address, not just its last part. The *Company* page at `/company` is an editor's page, or no page at all: renaming or moving it never moves the news, because published URLs should not change with an easy rename in the admin. The page tree lists the collection at the root with its full URL, and editors cannot put a page on `/company/news` or under it.
|
|
52
|
+
|
|
53
|
+
Each part follows the slug rule. A language the map leaves out takes the default language's address.
|
|
54
|
+
|
|
55
|
+
Change the path in code and the URLs of the index page and every entry change when the deploy goes live: Smoodly stores only each entry's own part, never the address. The old URLs stop working, so add a redirect in `next.config.ts` for them, as [Paths and slugs](../pages/paths-and-slugs.md#redirects) shows.
|
|
56
|
+
|
|
57
|
+
## The entry page
|
|
58
|
+
|
|
59
|
+
**Astro**
|
|
60
|
+
|
|
61
|
+
A collection with a `path` needs an entry page: `src/entry-pages/<collection>.astro`, named after the collection. It receives `entry` and `head`:
|
|
62
|
+
|
|
63
|
+
File: `src/entry-pages/articles.astro`
|
|
64
|
+
|
|
65
|
+
```astro
|
|
66
|
+
---
|
|
67
|
+
// …
|
|
68
|
+
const { entry, head } = Astro.props;
|
|
69
|
+
const { locale, locales } = entry;
|
|
70
|
+
// …
|
|
71
|
+
const a = entry.fields;
|
|
72
|
+
// …
|
|
73
|
+
const { entries: newest = [] } = await getLiveCollection("articles", { locale, limit: 4 });
|
|
74
|
+
// …
|
|
75
|
+
---
|
|
76
|
+
<Layout head={head} locale={locale} locales={locales}>
|
|
77
|
+
<article class="container article">
|
|
78
|
+
// …
|
|
79
|
+
<h1 class="article__title">{a.title}</h1>
|
|
80
|
+
// …
|
|
81
|
+
{a.image && <Image image={a.image} aspect="21:9" class="article__image" sizes="(min-width: 900px) 800px, 100vw" priority />}
|
|
82
|
+
<div class="article__body prose prose--lead"><RichText doc={a.body} sizes="(min-width: 900px) 720px, 100vw" /></div>
|
|
83
|
+
</article>
|
|
84
|
+
// …
|
|
85
|
+
</Layout>
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The entry's `fields` are resolved: images are ready for `Image`, and a reference such as `author` is `{ id, path, fields }`, the person's fields under `fields`. Beside `fields` the entry carries its `id`, `path`, `locale`, the language rendered, and `locales`, which maps every language to the entry's address there, for a language switcher, and `seo` when the collection turns [SEO](../pages/seo.md#seo-for-entries) on. `head` goes to the layout's `Head`.
|
|
89
|
+
|
|
90
|
+
There is nothing to register: the file is the entry page. A collection with a `path` and no file is named in the server log at the first request, and its entries answer 404.
|
|
91
|
+
|
|
92
|
+
**Next.js**
|
|
93
|
+
|
|
94
|
+
A collection with a `path` needs an entry page, or the site stops at the first render and names the collection. `smoodly.entryPage()` pairs the collection with a component. An entry page has no schema of its own, so it is listed in `pages` in `smoodly/views.ts`, not in the config:
|
|
95
|
+
|
|
96
|
+
File: `components/pages/Article.tsx`
|
|
97
|
+
|
|
98
|
+
```tsx
|
|
99
|
+
import { smoodly } from "smoodly/react";
|
|
100
|
+
import { RichText } from "@/smoodly/richtext";
|
|
101
|
+
import { SmoodlyImage } from "smoodly/image";
|
|
102
|
+
import { articles } from "@/smoodly/collections";
|
|
103
|
+
// …
|
|
104
|
+
export const ArticlePage = smoodly.entryPage(articles, async function ArticleView({ entry }) {
|
|
105
|
+
const { locale, locales } = entry;
|
|
106
|
+
// …
|
|
107
|
+
const a = entry.fields;
|
|
108
|
+
// …
|
|
109
|
+
return (
|
|
110
|
+
<PageFrame locale={locale} locales={locales}>
|
|
111
|
+
<article className="container article">
|
|
112
|
+
// …
|
|
113
|
+
<h1 className="article__title">{a.title}</h1>
|
|
114
|
+
// …
|
|
115
|
+
{a.image && <SmoodlyImage image={a.image} aspect="21:9" className="article__image" sizes="(min-width: 900px) 800px, 100vw" priority />}
|
|
116
|
+
<div className="article__body prose prose--lead"><RichText doc={a.body} /></div>
|
|
117
|
+
</article>
|
|
118
|
+
// …
|
|
119
|
+
</PageFrame>
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The view receives `entry`, whose `fields` are resolved: images are ready for `SmoodlyImage`, and a reference such as `author` is `{ id, path, fields }`, the person's fields under `fields`. Beside `fields` the entry carries its `id`, `path`, `locale`, the language rendered, and `locales`, which maps every language to the entry's address there, for a language switcher, and `seo` when the collection turns [SEO](../pages/seo.md#seo-for-entries) on. Register the export in the `pages` list of `smoodly/config.ts`, beside the page types.
|
|
125
|
+
|
|
126
|
+
An entry page has no zones. It is your own markup over the entry's fields, and editors change the entry in its form rather than in a canvas.
|
|
127
|
+
|
|
128
|
+
## The index page
|
|
129
|
+
|
|
130
|
+
The list at `/posts` itself is an ordinary fixed page whose slug matches the collection's path in every language:
|
|
131
|
+
|
|
132
|
+
File: `components/pages/Articles.schema.ts`
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
export const articlesSchema = smoodly.schema.page({
|
|
136
|
+
name: "articlesIndex",
|
|
137
|
+
title: "Articles",
|
|
138
|
+
slug: { en: "posts", fi: "artikkelit" },
|
|
139
|
+
zones: {
|
|
140
|
+
hero: z.locked(pageHeroSchema),
|
|
141
|
+
},
|
|
142
|
+
seo: true,
|
|
143
|
+
});
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Its view and its registration are any page type's (see [Page types](../content-modeling/page-types.md)), with the schema listed in `pages` in the config.
|
|
147
|
+
|
|
148
|
+
**Astro**
|
|
149
|
+
|
|
150
|
+
The view is `src/page-types/articlesIndex.astro`.
|
|
151
|
+
|
|
152
|
+
**Next.js**
|
|
153
|
+
|
|
154
|
+
The view is `export const ArticlesPage = smoodly.page(articlesSchema, ArticlesView)`, listed in `pages` in `smoodly/views.ts`.
|
|
155
|
+
|
|
156
|
+
The list page is live as soon as you deploy it, with an empty hero until an editor fills it. Its row appears in *Pages* the next time someone opens the admin.
|
|
157
|
+
|
|
158
|
+
Because the slugs match, that page is the mount: the row in the page tree opens its editor, editors own its hero, and its view queries the entries for the grid below. [Queries](../collections/queries.md) shows that part. A nested path works the same way: the News index page's slug is `{ en: "company/news", fi: "yritys/uutiset" }`.
|
|
159
|
+
|
|
160
|
+
**Astro**
|
|
161
|
+
|
|
162
|
+
Nothing changes in the routes: the integration's page shell already resolves a URL to a page, a path or an entry, and renders an entry with its collection's entry page.
|
|
163
|
+
|
|
164
|
+
**Next.js**
|
|
165
|
+
|
|
166
|
+
Nothing changes in the routes: the catch-all route already resolves a URL to a page, a path or an entry, and renders an entry with its collection's entry page.
|
|
167
|
+
|
|
168
|
+
Next, [Queries](../collections/queries.md).
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Ordering
|
|
2
|
+
|
|
3
|
+
A collection has one order, set in its schema, that the admin's list and your queries share.
|
|
4
|
+
|
|
5
|
+
## The order option
|
|
6
|
+
|
|
7
|
+
`order` takes one of two shapes:
|
|
8
|
+
|
|
9
|
+
| Name | Description |
|
|
10
|
+
| --- | --- |
|
|
11
|
+
| `{ by, direction }` | Sort by a field, or by createdAt or updatedAt, ascending or descending. Without an order, a collection is newest first. On the live site updatedAt is the time of the last publish, so a draft save moves nothing; in draft mode it is the last save. |
|
|
12
|
+
| `"manual"` | Editors drag entries into an order in the admin. Queries return that order. |
|
|
13
|
+
|
|
14
|
+
The starter uses both: articles by date, people by hand.
|
|
15
|
+
|
|
16
|
+
File: `smoodly/collections.ts`
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
export const people = smoodly.collection({
|
|
20
|
+
name: "people",
|
|
21
|
+
title: "People",
|
|
22
|
+
titleField: "name",
|
|
23
|
+
order: "manual",
|
|
24
|
+
// …
|
|
25
|
+
});
|
|
26
|
+
// …
|
|
27
|
+
export const articles = smoodly.collection({
|
|
28
|
+
name: "articles",
|
|
29
|
+
title: "Articles",
|
|
30
|
+
titleField: "title",
|
|
31
|
+
path: { en: "posts", fi: "artikkelit" },
|
|
32
|
+
order: { by: "date", direction: "desc" },
|
|
33
|
+
// …
|
|
34
|
+
});
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## By a field
|
|
38
|
+
|
|
39
|
+
The field must be shared across languages, because the order is one list for every language. A localized field or the slug is refused at startup, and so is a field the collection does not declare. Numbers order as numbers and dates as dates, since a date field stores an ISO string.
|
|
40
|
+
|
|
41
|
+
A query may override the order for one read with its own `order` option.
|
|
42
|
+
|
|
43
|
+
## By hand
|
|
44
|
+
|
|
45
|
+
With `"manual"`, the collection's list in the admin has a grip on every row, and dragging a row reorders the entries. The new order is immediate and not part of any draft: order belongs to the collection, not to an entry's version, so it needs no publish. On a multilingual site the order is shared by every language unless the config sets [`locales.order`](../languages/locales.md#one-order-or-one-per-language) to `"perLocale"`; then a drag reorders the language selected in the list, and a query returns its own language's order. Dragging works on flat collections; a nested collection, one with `tree`, lists its entries flat and cannot be reordered by drag.
|
|
46
|
+
|
|
47
|
+
Next, [Globals](../globals/defining.md).
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# Queries
|
|
2
|
+
|
|
3
|
+
Read entries, globals and the page tree from your own views, typed by your registry, cached, and aware of drafts.
|
|
4
|
+
|
|
5
|
+
## The read API
|
|
6
|
+
|
|
7
|
+
**Astro**
|
|
8
|
+
|
|
9
|
+
The reads are Astro live collections. `src/live.config.ts` names one loader per collection, global item, the pages and the page tree, from `smoodly/astro/content`:
|
|
10
|
+
|
|
11
|
+
File: `src/live.config.ts`
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
export const collections = {
|
|
15
|
+
pages: defineLiveCollection({ loader: pageLoader() }),
|
|
16
|
+
pageTree: defineLiveCollection({ loader: pageTreeLoader() }),
|
|
17
|
+
articles: defineLiveCollection({ loader: entriesLoader(articles) }),
|
|
18
|
+
// …
|
|
19
|
+
footer: defineLiveCollection({ loader: globalLoader(footer) }),
|
|
20
|
+
// …
|
|
21
|
+
};
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
A view reads with `getLiveCollection` and `getLiveEntry` from `astro:content`:
|
|
25
|
+
|
|
26
|
+
File: `src/page-types/articlesIndex.astro`
|
|
27
|
+
|
|
28
|
+
```astro
|
|
29
|
+
---
|
|
30
|
+
import { getLiveCollection } from "astro:content";
|
|
31
|
+
// …
|
|
32
|
+
const { entries = [] } = await getLiveCollection("articles", { locale: page.locale });
|
|
33
|
+
---
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
| Name | Description |
|
|
37
|
+
| --- | --- |
|
|
38
|
+
| `getLiveCollection("articles", options)` | Published entries of a collection, in the collection's order. Options: locale, where, order, limit, offset, parent. |
|
|
39
|
+
| `getLiveEntry("articles", { path, locale })` | One entry by its URL path, or by id, with its references resolved. |
|
|
40
|
+
| `getLiveEntry("footer", { locale })` | A global item's value in a language, for a layout. |
|
|
41
|
+
| `getLiveEntry("pages", { path, locale })` | A page by URL path or id. |
|
|
42
|
+
| `getLiveCollection("pageTree", { locale })` | The published page tree in a language, each item with its page fields, for a navigation. |
|
|
43
|
+
|
|
44
|
+
Each result's `data` is the read shape below. Every read tags the response, so a publish expires the page that made it.
|
|
45
|
+
|
|
46
|
+
**Next.js**
|
|
47
|
+
|
|
48
|
+
`smoodly/server.ts` exports `q`, a set of read functions typed by the collections and globals you registered:
|
|
49
|
+
|
|
50
|
+
File: `smoodly/server.ts`
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
export const q = (): Queries =>
|
|
54
|
+
new Proxy({} as Queries, {
|
|
55
|
+
get: (_, name) => async (...args: unknown[]) => {
|
|
56
|
+
await buildGate();
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Each call passes the build gate before it reads the keys: while `next build` prerenders it waits for a request, so the build needs no keys; at run time it does nothing, so a page that calls `q()` stays cacheable.
|
|
60
|
+
|
|
61
|
+
Call it in a server component, a page type's view or a route:
|
|
62
|
+
|
|
63
|
+
File: `components/pages/Articles.tsx`
|
|
64
|
+
|
|
65
|
+
```tsx
|
|
66
|
+
import { q } from "@/smoodly/server";
|
|
67
|
+
// …
|
|
68
|
+
const items = await q().getEntries("articles", { locale });
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
| Name | Description |
|
|
72
|
+
| --- | --- |
|
|
73
|
+
| `getEntries(collection, options)` | Published entries of a collection, in the collection's order. Options: locale, where, order, limit, offset, parent. |
|
|
74
|
+
| `getEntry(collection, idOrPath, options)` | One entry by id or by its URL path, with its references resolved. |
|
|
75
|
+
| `getGlobal(name, options)` | A global's value in a language, for a layout or a page frame. |
|
|
76
|
+
| `getPage(pathOrId, options)` | A page by URL path or id. |
|
|
77
|
+
| `getPageTree(options)` | The published page tree in a language, each item with its page fields, for a navigation. |
|
|
78
|
+
|
|
79
|
+
Every read takes `locale`; without it, the default language. Collection and global names are checked against the registry, so a typo is a type error.
|
|
80
|
+
|
|
81
|
+
## What comes back
|
|
82
|
+
|
|
83
|
+
Every read returns Smoodly's own keys at the top level and your content under `fields`, so a collection may have a field called `path` without a clash. Nothing else leaves the read: other languages' rows, draft pointers and editors' emails stay inside.
|
|
84
|
+
|
|
85
|
+
| Read | Shape |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| `An entry` | `{ id, path, locale, locales, updatedAt, fields, seo? }`. `path` is the entry's URL in that language, or `null` without a collection path; `locales` is its address in every language; `updatedAt` is the last publish on the live site and the last save in draft mode; `seo` is present when the collection turns SEO on. |
|
|
88
|
+
| `A page` | `{ id, title, path, locale, locales, template, fields, seo?, ancestors, children }`. `seo` is present when the page type has `seo: true`. |
|
|
89
|
+
| `A global` | `{ locale, fields, settings?, styles? }`. A visual global adds `settings` and `styles`; an object global has `fields` only. |
|
|
90
|
+
|
|
91
|
+
A collection read returns a list of these, `fields` typed from the collection, so an item needs no cast:
|
|
92
|
+
|
|
93
|
+
**Astro**
|
|
94
|
+
|
|
95
|
+
File: `src/page-types/articlesIndex.astro`
|
|
96
|
+
|
|
97
|
+
```astro
|
|
98
|
+
{entries.map(({ data: entry }) => <ArticleCard article={entry.fields} url={href(entry.path!, page.locale)} locale={page.locale} />)}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
**Next.js**
|
|
102
|
+
|
|
103
|
+
File: `components/pages/Articles.tsx`
|
|
104
|
+
|
|
105
|
+
```tsx
|
|
106
|
+
{items.map((entry) => (
|
|
107
|
+
<ArticleCard key={entry.id} article={entry.fields} url={url(entry.path!)} locale={locale} />
|
|
108
|
+
))}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
A reference arrives resolved, as deep as its own field says: a bare `f.ref` gives `{ id, path, fields }` and its target's references stay ids, `.resolve("author")` follows the named reference one level, and `.resolve()` follows the whole chain. [Reference and tags](../fields/reference-and-tags.md) has the table. In a list, images and references resolve in one batched pass per referenced collection, so a card has its author's fields and its image URL without a second read. A single entry resolves them the same way.
|
|
112
|
+
|
|
113
|
+
## Filtering and limiting
|
|
114
|
+
|
|
115
|
+
`where` matches fields by equality, or by any of several values:
|
|
116
|
+
|
|
117
|
+
**Astro**
|
|
118
|
+
|
|
119
|
+
File: `src/entry-pages/articles.astro`
|
|
120
|
+
|
|
121
|
+
```astro
|
|
122
|
+
---
|
|
123
|
+
// …
|
|
124
|
+
const { entries: byAuthor = [] } = authorId ? await getLiveCollection("articles", { locale, where: { author: authorId } }) : { entries: [] };
|
|
125
|
+
// …
|
|
126
|
+
---
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**Next.js**
|
|
130
|
+
|
|
131
|
+
File: `components/RecentNews.tsx`
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import { q } from "@/smoodly/server";
|
|
135
|
+
|
|
136
|
+
export async function recentNews(locale: string) {
|
|
137
|
+
const items = await q().getEntries("articles", { locale, where: { date: "2026-09-02" }, limit: 3 });
|
|
138
|
+
return items.map((item) => item.fields.title);
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`where` sees every field, localized ones included, and filters the version shown: on the public site the published version, in the editor's preview the draft, so an unpublished edit never touches the live list. `slug` is not filterable, so find an entry by its path instead. For an array field such as tags or checkboxes, one value matches entries that contain it. The match is exact, case included. A tag is stored in the spelling the collection already uses, so once one entry has `Dinner`, another that types `dinner` stores `Dinner` too: filter with that spelling. `limit` applies after ordering and after the published-only filter, so it counts entries that are really there, and `order` overrides the collection's default for one query.
|
|
143
|
+
|
|
144
|
+
The starter's article page reads the four newest articles and drops itself to show three related ones.
|
|
145
|
+
|
|
146
|
+
## Paging a list
|
|
147
|
+
|
|
148
|
+
`offset` skips that many entries after the order and the filter, before `limit` counts, so page N of a list is `limit` entries from `(N - 1) * limit`. The read returns the page's entries and no total: ask for one entry more than you show to know whether a next page exists.
|
|
149
|
+
|
|
150
|
+
**Astro**
|
|
151
|
+
|
|
152
|
+
Pass the same two options to `getLiveCollection`: `{ locale, limit: perPage + 1, offset: (page - 1) * perPage }`, show the first `perPage` entries, and link to the next page when the read returned one more.
|
|
153
|
+
|
|
154
|
+
**Next.js**
|
|
155
|
+
|
|
156
|
+
File: `components/ArticlePage.tsx`
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
import { q } from "@/smoodly/server";
|
|
160
|
+
|
|
161
|
+
export async function articlePage(locale: string, page: number, perPage = 10) {
|
|
162
|
+
const items = await q().getEntries("articles", { locale, limit: perPage + 1, offset: (page - 1) * perPage });
|
|
163
|
+
return { items: items.slice(0, perPage), hasNext: items.length > perPage };
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Each page is cached as its own read and expires with the collection, like any other list.
|
|
168
|
+
|
|
169
|
+
## Draft mode and caching
|
|
170
|
+
|
|
171
|
+
Inside the editor's canvas every query reads the draft rows: an unpublished article shows in the index grid as it will after publish. On the public site queries read published rows only and are cached; publishing an entry, a global or a page expires the queries that showed it, so the live site updates at once. A list is tagged by its collection and by every collection its references resolved into, so editing an author refreshes the article lists that show that author.
|
|
172
|
+
|
|
173
|
+
Next, [Ordering](../collections/ordering.md).
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# When the schema changes
|
|
2
|
+
|
|
3
|
+
Sites change after launch: a section goes, a field is renamed, a zone gets stricter. This page covers what stored content keeps when the code moves on, how the admin shows it, and what Publish refuses until an editor fixes it.
|
|
4
|
+
|
|
5
|
+
## What a change leaves behind
|
|
6
|
+
|
|
7
|
+
Smoodly never rewrites stored content when you deploy. A page, an entry or a global keeps what it held, and the admin compares it with the code you deployed. What it finds falls into two kinds: **broken** content blocks the next publish, **left over** content is only reported.
|
|
8
|
+
|
|
9
|
+
| Change | What stays stored | Publish |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| `Remove a section` | Every placed copy of it. The canvas shows a placeholder; the site renders nothing for it. | Refused |
|
|
12
|
+
| `Rename or remove a field` | The old key and its value. The admin lists it under No longer in the schema; the new field starts empty. | Allowed, unless the new field is required: then Required until it is filled |
|
|
13
|
+
| `Change a field's type` | The old value. When it doesn't fit the new type, such as text in f.number(), the field shows an error. | Refused when it doesn't fit |
|
|
14
|
+
| `Remove a select option` | The value. It no longer fits the field. | Refused |
|
|
15
|
+
| `Tighten a zone` | Sections the zone no longer allows, and sections over its .max(). | Refused |
|
|
16
|
+
| `Remove a zone from a page type` | The zone's sections, no longer shown. | Allowed |
|
|
17
|
+
| `Remove an element` | Every copy of it inside richtext. Its card in the editor says the code no longer registers it. | Refused |
|
|
18
|
+
| `Drop an element from a toolset` | Every copy of it. It still renders, because a toolset only decides what editors can insert. | Allowed |
|
|
19
|
+
| `Remove a page type` | Its pages. The canvas shows the site's 404 until the page gets another type. | Refused |
|
|
20
|
+
| `Remove a collection` | Its entries. The admin has no editor for them, so register it again or delete them. | No editor |
|
|
21
|
+
| `Toggle .localized()` | Every value. See Localized fields. | Allowed |
|
|
22
|
+
|
|
23
|
+
An empty value always fits: a new required field on old content is a *Required* error, as it is for new content.
|
|
24
|
+
|
|
25
|
+
## Content health
|
|
26
|
+
|
|
27
|
+
*Settings › Content health* lists every page, entry and global that doesn't match the code. Every member can open it, because editors do most of the fixing. It checks when it opens and when you press *Rescan*.
|
|
28
|
+
|
|
29
|
+
Findings come in two sections, *Blocks publishing* and *Left over*. Each group is one change you made, like "Field `title` no longer in section `hero`", with the documents it touches under it. A row names the document, its languages and where the problem sits:
|
|
30
|
+
|
|
31
|
+
- *Draft* only: the next publish will be refused until it is fixed.
|
|
32
|
+
- *Live* only: the site runs on content the code no longer describes, and the draft is already fixed. Publishing clears it.
|
|
33
|
+
- Both: fix it in the draft, then publish.
|
|
34
|
+
|
|
35
|
+
*Open* goes to the document's editor. The page only points; fixes happen in the editors.
|
|
36
|
+
|
|
37
|
+
## In the editor
|
|
38
|
+
|
|
39
|
+
A document with findings shows a notice under the top bar: "2 problems block publishing · 1 left over from an older schema". The page editor marks each affected section in the *Page Outline*, red for broken and grey for left over, and the fixes are where the problems are:
|
|
40
|
+
|
|
41
|
+
- **An unknown section** shows as a placeholder on the canvas. Select it to see its stored values, each with *Copy*, and *Remove section*.
|
|
42
|
+
- **A value that doesn't fit** is an error on its field. Typing a new value replaces it.
|
|
43
|
+
- **Left-over keys** sit in a collapsed *No longer in the schema* group at the foot of the form, each with *Copy* and *Clear*.
|
|
44
|
+
- **A removed zone** is listed after the real zones with *Clear zone*.
|
|
45
|
+
|
|
46
|
+
Every fix is an ordinary draft edit: it autosaves, and *Discard changes* or the history undoes it.
|
|
47
|
+
|
|
48
|
+
## What Publish refuses
|
|
49
|
+
|
|
50
|
+
Publish refuses broken content with the problems listed, the same way it refuses an empty required field. Left-over content never blocks it. The check runs on the server, so an editor can't publish around it.
|
|
51
|
+
|
|
52
|
+
Live pages are not rewritten. After a deploy the stored live version stays as it was, and a section the code no longer registers renders nothing on the site. The next publish takes the fixed draft live.
|
|
53
|
+
|
|
54
|
+
## Renaming a field
|
|
55
|
+
|
|
56
|
+
Rename a field in two steps, so no editor loses a value. First put the new field in the schema where the old one was, and deploy:
|
|
57
|
+
|
|
58
|
+
File: `components/elements/Callout.schema.ts`
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { f, smoodly } from "smoodly";
|
|
62
|
+
|
|
63
|
+
export const calloutSchema = smoodly.schema.section({
|
|
64
|
+
name: "callout",
|
|
65
|
+
title: "Callout",
|
|
66
|
+
fields: {
|
|
67
|
+
// was `title`: stored values stay under the old key until an editor moves them
|
|
68
|
+
heading: f.text(),
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Then, for each page *Content health* lists under "Field `title` no longer in section `callout`": copy the value from the *No longer in the schema* group into *Heading*, *Clear* the old key and publish.
|
|
74
|
+
|
|
75
|
+
There is no bulk rename in the admin yet. A tool that rewrote every page at once could break every live page with one wrong mapping, so its shape is still an open question. For many pages, a script can do it for your own site: walk `pages.list()`, read each page with `getTree`, move the value, write it back with `setTree` and publish, as [Content from code](../getting-started/content-from-code.md) shows. Until it runs, a required field under the new name renders empty on the live site; nothing warns a visitor.
|
|
76
|
+
|
|
77
|
+
A rename also fails the type check wherever code writes the old name (`npx tsc --noEmit`; `npx astro check` on Astro, whose `astro build` does not type-check; a dev server or a build pushes the renamed schema to the space when it starts), such as the starter's `scripts/seed.ts`: its `node()` calls are typed from the section. The seed has already run, so update the name there or delete the script.
|
|
78
|
+
|
|
79
|
+
Next, [Localized fields](../languages/localized-fields.md).
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# The custom section
|
|
2
|
+
|
|
3
|
+
The one section editors lay out themselves: rows of richtext columns on a 12-column grid, offered where a zone allows it. You turn it on, pick what its columns may hold, and style the result; Smoodly owns the grid.
|
|
4
|
+
|
|
5
|
+
To write a section of your own in code, see [Sections](../content-modeling/sections.md).
|
|
6
|
+
|
|
7
|
+
## Turning it on
|
|
8
|
+
|
|
9
|
+
A zone offers the custom section when its policy says so. The starter's body zones do:
|
|
10
|
+
|
|
11
|
+
File: `components/pages/Home.schema.ts`
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
zones: {
|
|
15
|
+
hero: z.locked(heroSchema),
|
|
16
|
+
body: z.freeform({ sections: [imageTextSchema, pointsSchema, favouriteArticlesSchema, peopleSchema, quoteSchema], custom: true }),
|
|
17
|
+
},
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Only `z.freeform()` takes `custom: true`. *Custom section* then appears in *Add section* after the listed sections.
|
|
21
|
+
|
|
22
|
+
What a column may hold comes from the config's toolset. The starter gives the columns its article toolset, so a column can hold headings, lists, images and the two elements:
|
|
23
|
+
|
|
24
|
+
File: `smoodly/config.ts`
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
export const config = defineConfig({
|
|
28
|
+
// …
|
|
29
|
+
custom: { toolset: article },
|
|
30
|
+
});
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Astro**
|
|
34
|
+
|
|
35
|
+
The columns render through the package's own richtext view, with images through Astro's image service and your elements by file name. There is nothing to bind.
|
|
36
|
+
|
|
37
|
+
**Next.js**
|
|
38
|
+
|
|
39
|
+
`smoodly/views.ts` binds the richtext renderer, so images render through the site's image component:
|
|
40
|
+
|
|
41
|
+
File: `smoodly/views.ts`
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
export const views = () =>
|
|
45
|
+
defineViews(config, {
|
|
46
|
+
// …
|
|
47
|
+
RichText,
|
|
48
|
+
});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Without `custom` in the config, the columns take the richtext default plus images. `breakpoint` sets where rows stack; the default is `48rem`.
|
|
52
|
+
|
|
53
|
+
## What editors get
|
|
54
|
+
|
|
55
|
+
A new custom section is one row with one column. Each row is a card in the inspector:
|
|
56
|
+
|
|
57
|
+
| Name | Description |
|
|
58
|
+
| --- | --- |
|
|
59
|
+
| `Columns` | A stepper from one to six. Removing a column folds its content into the last one, so nothing is lost. |
|
|
60
|
+
| `Widths` | A bar dragged in twelfths, with presets such as 1/2 · 1/2 and 1/3 · 2/3. Spans always add up to 12. |
|
|
61
|
+
| `Gap` | S, M or L between the columns. |
|
|
62
|
+
| `Vertical align` | Top, Center, Bottom or Stretch. |
|
|
63
|
+
| `Reverse on mobile` | Flips the column order when the row stacks, so a photo can sit right on desktop and first on a phone. |
|
|
64
|
+
| `Column 1, Column 2, …` | A richtext editor per column, with the toolset from the config. |
|
|
65
|
+
|
|
66
|
+
Rows move up and down, and a section keeps at least one. In the canvas the section is labeled by its first row, *Custom section · 2 columns*, and clicking a column reveals its editor in the inspector. Like any section it has *Spacing & layout* and, when the config allows, *Custom CSS* on its Settings tab.
|
|
67
|
+
|
|
68
|
+
## How it renders
|
|
69
|
+
|
|
70
|
+
The section renders through the same wrapper as yours, so spacing, width, selection and the CSS box come free. Smoodly adds one scoped style per custom section, computed from the stored numbers only:
|
|
71
|
+
|
|
72
|
+
- Each row is a grid, `.smd-row`, with its spans as `fr` tracks, its gap and its vertical alignment. Rows sit 2rem apart.
|
|
73
|
+
- A row's width is `var(--smd-width, var(--container, none))`, with `var(--smd-gutter, 1rem)` at the sides, so define `--container` and `--smd-gutter` once and rows line up with your other sections.
|
|
74
|
+
- Below the breakpoint every row stacks to one column, and a reversed row stacks bottom-up.
|
|
75
|
+
|
|
76
|
+
Your stylesheet styles the content. Each column is a `.smd-col`; the starter gives it the same rules as its prose:
|
|
77
|
+
|
|
78
|
+
File: `app/globals.css`
|
|
79
|
+
|
|
80
|
+
```css
|
|
81
|
+
/* The custom section's columns (.smd-col) read like prose. */
|
|
82
|
+
.prose, .smd-col { display: flex; flex-direction: column; gap: 20px; font-size: 17px; line-height: 1.65; color: var(--muted); }
|
|
83
|
+
.prose h2, .smd-col h2 { font-size: 32px; margin-top: 8px; }
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Nothing else is required: a site with no rules for `.smd-col` still gets working columns.
|
|
87
|
+
|
|
88
|
+
## What it cannot do
|
|
89
|
+
|
|
90
|
+
A column holds richtext: text, headings, lists, images and elements. It never holds a section or another row, so the nesting stays one level deep and the canvas, drag and drop and history keep working. A layout that needs more than columns of text is a section you write.
|
|
91
|
+
|
|
92
|
+
At publish, Smoodly checks that each row has one to six columns and that its spans add up to 12. An empty column is allowed; it is spacing.
|
|
93
|
+
|
|
94
|
+
Next, [Marks and inline editing](../content-modeling/marks.md) explains how the canvas knows what to make click-to-edit.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Marks and inline editing
|
|
2
|
+
|
|
3
|
+
Marks are the attributes a section spreads on the elements that render its fields. They tell the canvas which field an editor clicked, so text edits in place and everything else opens in the inspector.
|
|
4
|
+
|
|
5
|
+
## What a mark is
|
|
6
|
+
|
|
7
|
+
`smoodly.marks(heroSchema)` returns typed helpers for one schema. `m.field("heading")` is an object with one `data-` attribute naming the field; spread it on the element that renders the heading. The section root, rendered by Smoodly's section wrapper, carries the section's own id. From a click the canvas walks up from the marked element to that root and knows which field of which section was meant.
|
|
8
|
+
|
|
9
|
+
Outside the admin the marks are plain data attributes and do nothing. The typed helpers exist so a renamed field is a type error rather than a dead click.
|
|
10
|
+
|
|
11
|
+
## Marking a field
|
|
12
|
+
|
|
13
|
+
Put the mark on the element that shows the value as stored:
|
|
14
|
+
|
|
15
|
+
**Astro**
|
|
16
|
+
|
|
17
|
+
File: `src/sections/hero.astro`
|
|
18
|
+
|
|
19
|
+
```astro
|
|
20
|
+
---
|
|
21
|
+
// …
|
|
22
|
+
const m = smoodly.marks(heroSchema);
|
|
23
|
+
---
|
|
24
|
+
// …
|
|
25
|
+
{kicker && <p class="kicker" {...m.field("kicker")}>{kicker}</p>}
|
|
26
|
+
<h1 class="hero__heading" {...m.field("heading")}>{heading}</h1>
|
|
27
|
+
<p class="hero__text" {...m.field("text")}>{text}</p>
|
|
28
|
+
// …
|
|
29
|
+
{image && <Image image={image} class="hero__image" sizes="(min-width: 900px) 40vw, 100vw" priority {...m.field("image")} />}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**Next.js**
|
|
33
|
+
|
|
34
|
+
File: `components/sections/Hero.tsx`
|
|
35
|
+
|
|
36
|
+
```tsx
|
|
37
|
+
const m = smoodly.marks(heroSchema);
|
|
38
|
+
// …
|
|
39
|
+
{kicker && <p className="kicker" {...m.field("kicker")}>{kicker}</p>}
|
|
40
|
+
<h1 className="hero__heading" {...m.field("heading")}>{heading}</h1>
|
|
41
|
+
<p className="hero__text" {...m.field("text")}>{text}</p>
|
|
42
|
+
// …
|
|
43
|
+
{image && <SmoodlyImage image={image} className="hero__image" sizes="(min-width: 900px) 40vw, 100vw" priority {...m.field("image")} />}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Hovering a marked element outlines it in the canvas. What a click does depends on the field:
|
|
47
|
+
|
|
48
|
+
| Field | On click |
|
|
49
|
+
| --- | --- |
|
|
50
|
+
| `f.text()` | Edits in place. The element becomes editable, and each keystroke updates the draft. Escape or a click elsewhere ends it. |
|
|
51
|
+
| `f.richtext()` | Edits in place, with the richtext toolbar floating above the selection. |
|
|
52
|
+
| `Everything else` | Opens the inspector on that field: the media picker for an image, the repeater for a list, the pills for a choice. |
|
|
53
|
+
|
|
54
|
+
In-place text editing needs an element with nothing but text inside. A mark on an element with child elements, such as a paragraph holding a link, falls through to the inspector. So does a mark on an element that shows the value transformed, in effect: the canvas would write the stored value over the formatted one. Leave a formatted date unmarked, or mark its container.
|
|
55
|
+
|
|
56
|
+
The image component passes data attributes to its wrapper, so a mark on it selects the image box.
|
|
57
|
+
|
|
58
|
+
## Lists and objects
|
|
59
|
+
|
|
60
|
+
A list item or an object field is a scope of its own. `m.item("buttons", i)` returns a helper for one item: spread its `attrs` on the item's root element and use its `field()` for the fields inside. The starter's Hero buttons put both on the same anchor:
|
|
61
|
+
|
|
62
|
+
**Astro**
|
|
63
|
+
|
|
64
|
+
File: `src/sections/hero.astro`
|
|
65
|
+
|
|
66
|
+
```astro
|
|
67
|
+
{buttons.map((b, i) => {
|
|
68
|
+
const button = m.item("buttons", i);
|
|
69
|
+
return <a class={i === 0 ? "button" : "button button--outline"} href={safeHref(b.href)} {...button.attrs} {...button.field("label")}>{b.label}</a>;
|
|
70
|
+
})}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Next.js**
|
|
74
|
+
|
|
75
|
+
File: `components/sections/Hero.tsx`
|
|
76
|
+
|
|
77
|
+
```tsx
|
|
78
|
+
{buttons.map((b, i) => {
|
|
79
|
+
const button = m.item("buttons", i);
|
|
80
|
+
return (
|
|
81
|
+
<a key={i} className={i === 0 ? "button" : "button button--outline"} href={b.href}
|
|
82
|
+
{...button.attrs} {...button.field("label")}>{b.label}</a>
|
|
83
|
+
);
|
|
84
|
+
})}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`m.object("link")` works the same way for an object field. A scope nests: `m.item("points", i).object("author")` reaches a field inside an object inside a list item.
|
|
88
|
+
|
|
89
|
+
A mark for the whole list, such as `m.field("people")` on the grid that renders a reference list, opens the list in the inspector.
|
|
90
|
+
|
|
91
|
+
## What the canvas checks
|
|
92
|
+
|
|
93
|
+
A click on a mark that names a field the schema does not have logs a warning in development, naming the section and the path, and then does nothing. Check the `data-smd-field` and `data-smd-item` attributes it names.
|
|
94
|
+
|
|
95
|
+
Globals in the canvas, such as the starter's header, footer and quote, are marked the same way. Selecting one shows its own fields in the inspector, tagged *global*, with its own *Publish*, because publishing a footer to every page is a deliberate click. [Globals](../globals/defining.md) has the details.
|
|
96
|
+
|
|
97
|
+
Next, the [Fields](../fields/overview.md) group lists every field type and what editors see for each.
|