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.
Files changed (58) hide show
  1. package/README.md +3 -1
  2. package/dist/admin/files.generated.js +1 -1
  3. package/dist/api-client.js +5 -3
  4. package/dist/astro/index.js +3 -3
  5. package/dist/cli/push.js +2 -3
  6. package/dist/env.js +3 -2
  7. package/dist/push.d.ts +7 -0
  8. package/dist/push.js +8 -0
  9. package/dist/space-kind.d.ts +2 -0
  10. package/dist/space-kind.js +1 -0
  11. package/dist/version.d.ts +1 -1
  12. package/dist/version.js +1 -1
  13. package/docs/collections/defining.md +57 -0
  14. package/docs/collections/entry-pages.md +168 -0
  15. package/docs/collections/ordering.md +47 -0
  16. package/docs/collections/queries.md +173 -0
  17. package/docs/content-modeling/changing-schemas.md +79 -0
  18. package/docs/content-modeling/custom-section.md +94 -0
  19. package/docs/content-modeling/marks.md +97 -0
  20. package/docs/content-modeling/overview.md +55 -0
  21. package/docs/content-modeling/page-types.md +237 -0
  22. package/docs/content-modeling/sections.md +264 -0
  23. package/docs/content-modeling/styles-and-settings.md +184 -0
  24. package/docs/content-modeling/zones.md +80 -0
  25. package/docs/fields/choices.md +42 -0
  26. package/docs/fields/conditional.md +72 -0
  27. package/docs/fields/link.md +31 -0
  28. package/docs/fields/list-and-object.md +58 -0
  29. package/docs/fields/media.md +103 -0
  30. package/docs/fields/overview.md +56 -0
  31. package/docs/fields/reference-and-tags.md +62 -0
  32. package/docs/fields/richtext.md +168 -0
  33. package/docs/fields/text.md +152 -0
  34. package/docs/frameworks/astro.md +110 -0
  35. package/docs/frameworks/nextjs.md +147 -0
  36. package/docs/getting-started/content-from-code.md +263 -0
  37. package/docs/getting-started/deploying.md +131 -0
  38. package/docs/getting-started/export-and-import.md +73 -0
  39. package/docs/getting-started/first-page.md +98 -0
  40. package/docs/getting-started/install.md +108 -0
  41. package/docs/getting-started/keys.md +40 -0
  42. package/docs/getting-started/project-structure.md +142 -0
  43. package/docs/getting-started/working-together.md +42 -0
  44. package/docs/globals/defining.md +99 -0
  45. package/docs/globals/header-and-footer.md +117 -0
  46. package/docs/index.md +83 -0
  47. package/docs/languages/language-switcher.md +103 -0
  48. package/docs/languages/locales.md +119 -0
  49. package/docs/languages/localized-fields.md +61 -0
  50. package/docs/media/images-and-crops.md +131 -0
  51. package/docs/media/library.md +40 -0
  52. package/docs/pages/drafts-and-publishing.md +44 -0
  53. package/docs/pages/page-tree.md +51 -0
  54. package/docs/pages/paths-and-slugs.md +74 -0
  55. package/docs/pages/seo.md +126 -0
  56. package/docs/pages/version-history.md +23 -0
  57. package/docs/reference/upgrading.md +312 -0
  58. package/package.json +4 -3
@@ -3,7 +3,7 @@
3
3
  // config exactly as the service's site computes them (site.ts siteStatics), so a cache key never disagrees across
4
4
  // the wire. A document's tags ride on the hit (SiteHit.tags), where the read layer takes them.
5
5
  import { countRead } from "./read-counter.js";
6
- import { pushBody, pushSchema } from "./push.js";
6
+ import { pushBody, pushLine, pushSchema } from "./push.js";
7
7
  import { siteOptions, siteStatics, } from "./site.js";
8
8
  /** The content API refused a read: its status, its code (`unauthorized`, `not_found`, `validation`, `internal`) and its message. */
9
9
  export class SmoodlyApiError extends Error {
@@ -24,9 +24,11 @@ export function createSmoodlyApi(options) {
24
24
  const localeOf = (o) => o?.locale ?? statics.locales.default;
25
25
  const env = typeof process === "undefined" ? {} : process.env;
26
26
  const push = options.push ?? (env.NODE_ENV === "development" ? pushBody(options.config, env, "live") : false);
27
- /** One push per client, awaited by every call until it settles; its failure is logged, never thrown. */
27
+ /** One push per client, awaited by every call until it settles; its failure is logged, never thrown. What landed is
28
+ * said once, with the kind of space it reached (spec 2026-11-13 production first and the preview copy §7): a dev
29
+ * server pushes its schema to the space it reads, so the developer must see when that space is production. */
28
30
  let pushed = null;
29
- const pushOnce = (body) => (pushed ?? (pushed = pushSchema({ apiUrl, spaceId: options.spaceId, key: options.key, fetch: doFetch }, body).then(() => undefined, (e) => console.error(`smoodly: the schema push failed — ${e instanceof Error ? e.message : String(e)}; the site runs against the schema the space has.`))));
31
+ const pushOnce = (body) => (pushed ?? (pushed = pushSchema({ apiUrl, spaceId: options.spaceId, key: options.key, fetch: doFetch }, body).then((result) => { const line = pushLine(options.spaceId, result); console.log(`smoodly: ${line[0].toLowerCase()}${line.slice(1)}`); }, (e) => console.error(`smoodly: the schema push failed — ${e instanceof Error ? e.message : String(e)}; the site runs against the schema the space has.`))));
30
32
  async function call(path, params) {
31
33
  if (push)
32
34
  await pushOnce(push);
@@ -11,7 +11,7 @@ import { apiTransport, createSmoodlyContent } from "../content/content-api.js";
11
11
  import { markerJson, MARKER_PATH } from "../deploy-state.js";
12
12
  import { DEFAULT_API_URL, apiEnv, siteUrl } from "../env.js";
13
13
  import { createSmoodlyApi } from "../api-client.js";
14
- import { pushBody, pushSchema } from "../push.js";
14
+ import { pushBody, pushLine, pushSchema } from "../push.js";
15
15
  import { loadSmoodlyEnv } from "./env-files.js";
16
16
  import { astroRouting, localeHref } from "./locale-paths.js";
17
17
  import { PREVIEW_BASE } from "./preview.js";
@@ -159,8 +159,8 @@ export default function smoodly(options = {}) {
159
159
  astroRouting(i18n, loaded.locales, loaded.routing);
160
160
  try {
161
161
  const env = apiEnv(process.env);
162
- const { version, changed } = await pushSchema(env, pushBody(loaded, process.env, output === "static" ? "static" : "live"));
163
- logger.info(changed ? `smoodly: pushed schema version ${version} to ${env.spaceId}.` : `smoodly: schema unchanged (version ${version}).`);
162
+ const line = pushLine(env.spaceId, await pushSchema(env, pushBody(loaded, process.env, output === "static" ? "static" : "live")));
163
+ logger.info(`smoodly: ${line[0].toLowerCase()}${line.slice(1)}`);
164
164
  }
165
165
  catch (e) {
166
166
  logger.warn(`smoodly: the schema push failed — ${e instanceof Error ? e.message : String(e)}; the site runs against the schema the space has.`);
package/dist/cli/push.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // except when npm runs the command as a build's `prebuild`: a push never fails a build (the spec's rule), so there it
5
5
  // says what failed and exits 0, and the site runs against the schema the space has.
6
6
  import { apiEnv } from "../env.js";
7
- import { pushBody, pushSchema } from "../push.js";
7
+ import { pushBody, pushLine, pushSchema } from "../push.js";
8
8
  import { loadAppEnv } from "./app-env.js";
9
9
  import { loadConfig } from "./load-config.js";
10
10
  export async function runPush(_args, out, deps = {}) {
@@ -15,8 +15,7 @@ export async function runPush(_args, out, deps = {}) {
15
15
  const config = await loadConfig(cwd);
16
16
  const { apiUrl, spaceId, key } = apiEnv(env);
17
17
  const body = pushBody(config, env, env.SMOODLY_STATIC === "1" ? "static" : "live");
18
- const { version, changed } = await pushSchema({ apiUrl, spaceId, key, fetch: deps.fetch }, body);
19
- out(changed ? `Pushed schema version ${version} to ${spaceId}.` : `Schema unchanged (version ${version}).`);
18
+ out(pushLine(spaceId, await pushSchema({ apiUrl, spaceId, key, fetch: deps.fetch }, body)));
20
19
  return 0;
21
20
  }
22
21
  catch (e) {
package/dist/env.js CHANGED
@@ -11,8 +11,9 @@ const ADMIN_NEEDS = SITE_NEEDS;
11
11
  const NEEDS = { site: SITE_NEEDS, admin: ADMIN_NEEDS };
12
12
  const isHttp = (v) => /^https?:\/\/[^/\s]+/.test(v);
13
13
  const DEPLOYING = "https://smoodly.io/docs/getting-started/deploying";
14
- /** A Smoodly key: its kind, the space it belongs to, the random part and the stamp (spec 2026-10-16 the control plane §1). */
15
- const SMOODLY_KEY = (kind) => new RegExp(`^${kind}_sp_(eu_)?[a-z2-7]{20}_[a-z2-7]{32}_[a-z2-7]{16}$`);
14
+ /** A Smoodly key: its kind, the space it belongs to (a preview space's id carries `preview_`, spec 2026-11-13 production
15
+ * first and the preview copy §8), the random part and the stamp (spec 2026-10-16 the control plane §1). */
16
+ const SMOODLY_KEY = (kind) => new RegExp(`^${kind}_sp_(eu_)?(preview_)?[a-z2-7]{20}_[a-z2-7]{32}_[a-z2-7]{16}$`);
16
17
  /** The live site and its admin read over the API with its address, the space and the secret key (spec 2026-10-16 the
17
18
  * control plane §5). A key in the wrong variable or of no known form is named here, not by a failed read later. */
18
19
  export function resolveSmoodlyEnv(env, need = "site") {
package/dist/push.d.ts CHANGED
@@ -23,6 +23,13 @@ export type PushApi = {
23
23
  key: string;
24
24
  fetch?: typeof fetch;
25
25
  };
26
+ /** What a push says when it landed: the version, and which kind of space it reached, read from the id (spec 2026-11-13
27
+ * production first and the preview copy §7), so a developer who meant the preview space sees at once that the machine
28
+ * reads production. */
29
+ export declare function pushLine(spaceId: string, result: {
30
+ version: number;
31
+ changed: boolean;
32
+ }): string;
26
33
  /** One push: the space's version of the registry, and whether this push changed it. A refusal throws `SmoodlyApiError`. */
27
34
  export declare function pushSchema(api: PushApi, body: PushBody): Promise<{
28
35
  version: number;
package/dist/push.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // delivery mode, and the package version, at `POST /v1/spaces/:space/schema` under the secret key. `smoodly push`, Astro's
3
3
  // build hook, the Next templates' prebuild and the API client in development all send this one body. The space keeps
4
4
  // the registry, versions it and purges its pages when it changed; a failed push never fails a build.
5
+ import { spaceKind } from "./space-kind.js";
5
6
  import { SmoodlyApiError } from "./api-client.js";
6
7
  import { serializeRegistry } from "./registry-json.js";
7
8
  import { SMOODLY_VERSION } from "./version.js";
@@ -29,6 +30,13 @@ export function siteInfo(config, env, mode) {
29
30
  export function pushBody(config, env, mode) {
30
31
  return { registry: serializeRegistry(config), site: siteInfo(config, env, mode), package: SMOODLY_VERSION };
31
32
  }
33
+ /** What a push says when it landed: the version, and which kind of space it reached, read from the id (spec 2026-11-13
34
+ * production first and the preview copy §7), so a developer who meant the preview space sees at once that the machine
35
+ * reads production. */
36
+ export function pushLine(spaceId, result) {
37
+ const where = `the ${spaceKind(spaceId)} space ${spaceId}`;
38
+ return result.changed ? `Pushed schema version ${result.version} to ${where}.` : `Schema unchanged (version ${result.version}) in ${where}.`;
39
+ }
32
40
  /** One push: the space's version of the registry, and whether this push changed it. A refusal throws `SmoodlyApiError`. */
33
41
  export async function pushSchema(api, body) {
34
42
  const apiUrl = api.apiUrl.replace(/\/+$/, "");
@@ -0,0 +1,2 @@
1
+ export type SpaceKind = "production" | "preview";
2
+ export declare const spaceKind: (spaceId: string) => SpaceKind;
@@ -0,0 +1 @@
1
+ export const spaceKind = (spaceId) => (/^sp_(eu_)?preview_/.test(spaceId) ? "preview" : "production");
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const SMOODLY_VERSION = "0.0.60";
1
+ export declare const SMOODLY_VERSION = "0.0.61";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/write-version.mjs from package.json — do not edit.
2
- export const SMOODLY_VERSION = "0.0.60";
2
+ export const SMOODLY_VERSION = "0.0.61";
@@ -0,0 +1,57 @@
1
+ # Defining a collection
2
+
3
+ A collection is a content type with a fixed shape and many entries: posts, people, products. You declare its fields once; editors get a list, a form and publishing for it.
4
+
5
+ ## The schema
6
+
7
+ `smoodly.collection()` takes a name, a title and the fields of one entry:
8
+
9
+ File: `smoodly/collections.ts`
10
+
11
+ ```ts
12
+ import { f, smoodly } from "smoodly";
13
+ // …
14
+ export const people = smoodly.collection({
15
+ name: "people",
16
+ title: "People",
17
+ titleField: "name",
18
+ order: "manual",
19
+ fields: {
20
+ name: f.text(),
21
+ role: f.text().localized(),
22
+ },
23
+ });
24
+ ```
25
+
26
+ | Name | Description |
27
+ | --- | --- |
28
+ | `name` | What references and queries use. Keep it stable once entries exist. global, asset and media are reserved. |
29
+ | `title` | The label in the admin's rail and list. |
30
+ | `titleField` | The field whose value names an entry in lists and pickers. Without one, or while it is empty, an entry reads Untitled. |
31
+ | `fields` | Any builders. A field named slug gives entries a URL part; .localized() gives a field a value per language. |
32
+ | `path` | Serves each entry at its own URL under this segment. See Entry pages. |
33
+ | `order` | How the list and queries sort. See Ordering. |
34
+ | `admin.showInNav` | true gives the collection its own item in the admin's main navigation, after Pages, instead of a line in the Collections menu. Default false. |
35
+ | `seo` | Adds the SEO & Social panel to the entry form; `{ description, image }` names the fields that fill an empty description and social image. See SEO. |
36
+
37
+ Register the export in the `collections` list of `smoodly/collections.ts`, which the config registers. Every collection stores its entries in the same tables, so the admin lists a new one at once. Smoodly's service indexes the new collection's lists itself: there is nothing to migrate.
38
+
39
+ ## Entries in the admin
40
+
41
+ The *Collections* item in the admin's rail opens a menu of every collection. A collection editors open all day can sit on the rail itself: add `admin: { showInNav: true }` to its schema and it appears after *Pages* under its title, and leaves the menu. A collection's list shows its entries with their title, status and when they changed, 20 to a page; *Previous* and *Next* under the rows move through a longer list, and *Per page* shows 100 or 500 instead, a choice the browser remembers. The box above the rows filters by the collection's `titleField`: it finds the entries whose title holds the text, in any language, and searches the whole collection, not the page on screen. A collection without a `titleField` has no filter. While a filter is on, a hand-ordered list does not drag. *+ New entry* creates an empty draft in the selected language and opens the form at once. The form autosaves, and carries the same *Publish* control as a page, so an entry is published per language like a page. *History* and *Discard changes* work the same way.
42
+
43
+ An entry cannot be deleted while a page, a section or another entry references it. The message says how many, and the media library shows the same for assets.
44
+
45
+ ## Shared and localized
46
+
47
+ A field's value is shared across languages unless it chains `.localized()`. In the starter's people, a person's name is the same everywhere and the role is translated. The form marks shared fields with a globe-off icon on a multilingual site, and the slug is always per language. [Localized fields](../languages/localized-fields.md) has the rules.
48
+
49
+ ## Where entries appear
50
+
51
+ Three ways, which the next pages cover:
52
+
53
+ - **Referenced** from a section or another entry with `f.ref()` or `f.refList()`: the starter's *People* section picks team members by hand. See [Reference and tags](../fields/reference-and-tags.md).
54
+ - **At their own URL**, when the collection has a `path` and an [entry page](../collections/entry-pages.md).
55
+ - **Queried** from your code with `q()`, for an index grid or a related list. See [Queries](../collections/queries.md).
56
+
57
+ Next, [Entry pages](../collections/entry-pages.md).
@@ -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).