smoodly 0.0.59 → 0.0.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +3 -1
  2. package/dist/admin/files.generated.js +1 -1
  3. package/dist/admin/ui/theme.js +1 -1
  4. package/dist/api-client.js +5 -3
  5. package/dist/astro/index.js +3 -3
  6. package/dist/cli/push.js +2 -3
  7. package/dist/env.js +3 -2
  8. package/dist/push.d.ts +7 -0
  9. package/dist/push.js +8 -0
  10. package/dist/space-kind.d.ts +2 -0
  11. package/dist/space-kind.js +1 -0
  12. package/dist/version.d.ts +1 -1
  13. package/dist/version.js +1 -1
  14. package/docs/collections/defining.md +57 -0
  15. package/docs/collections/entry-pages.md +168 -0
  16. package/docs/collections/ordering.md +47 -0
  17. package/docs/collections/queries.md +173 -0
  18. package/docs/content-modeling/changing-schemas.md +79 -0
  19. package/docs/content-modeling/custom-section.md +94 -0
  20. package/docs/content-modeling/marks.md +97 -0
  21. package/docs/content-modeling/overview.md +55 -0
  22. package/docs/content-modeling/page-types.md +237 -0
  23. package/docs/content-modeling/sections.md +264 -0
  24. package/docs/content-modeling/styles-and-settings.md +184 -0
  25. package/docs/content-modeling/zones.md +80 -0
  26. package/docs/fields/choices.md +42 -0
  27. package/docs/fields/conditional.md +72 -0
  28. package/docs/fields/link.md +31 -0
  29. package/docs/fields/list-and-object.md +58 -0
  30. package/docs/fields/media.md +103 -0
  31. package/docs/fields/overview.md +56 -0
  32. package/docs/fields/reference-and-tags.md +62 -0
  33. package/docs/fields/richtext.md +168 -0
  34. package/docs/fields/text.md +152 -0
  35. package/docs/frameworks/astro.md +110 -0
  36. package/docs/frameworks/nextjs.md +147 -0
  37. package/docs/getting-started/content-from-code.md +263 -0
  38. package/docs/getting-started/deploying.md +131 -0
  39. package/docs/getting-started/export-and-import.md +73 -0
  40. package/docs/getting-started/first-page.md +98 -0
  41. package/docs/getting-started/install.md +108 -0
  42. package/docs/getting-started/keys.md +40 -0
  43. package/docs/getting-started/project-structure.md +142 -0
  44. package/docs/getting-started/working-together.md +42 -0
  45. package/docs/globals/defining.md +99 -0
  46. package/docs/globals/header-and-footer.md +117 -0
  47. package/docs/index.md +83 -0
  48. package/docs/languages/language-switcher.md +103 -0
  49. package/docs/languages/locales.md +119 -0
  50. package/docs/languages/localized-fields.md +61 -0
  51. package/docs/media/images-and-crops.md +131 -0
  52. package/docs/media/library.md +40 -0
  53. package/docs/pages/drafts-and-publishing.md +44 -0
  54. package/docs/pages/page-tree.md +51 -0
  55. package/docs/pages/paths-and-slugs.md +74 -0
  56. package/docs/pages/seo.md +126 -0
  57. package/docs/pages/version-history.md +23 -0
  58. package/docs/reference/upgrading.md +312 -0
  59. package/package.json +4 -3
@@ -0,0 +1,110 @@
1
+ # Astro
2
+
3
+ How Smoodly plugs into an Astro project: one integration in astro.config, views by file name, reads as live collections, pages cached whole and expired by tag, and what happens on a request. The installer writes all of it; this page is for reading it.
4
+
5
+ ## The integration
6
+
7
+ One line in `astro.config.ts` mounts Smoodly:
8
+
9
+ File: `astro.config.ts`
10
+
11
+ ```ts
12
+ import { defineConfig, memoryCache } from "astro/config";
13
+ import node from "@astrojs/node";
14
+ import smoodly from "smoodly/astro";
15
+ // …
16
+ cache: { provider: memoryCache() },
17
+ // …
18
+ site: process.env.SITE_URL,
19
+ integrations: [smoodly({ config: "src/smoodly/config.ts" })],
20
+ ```
21
+
22
+ `smoodly()` adds three routes and one middleware, so the project has no route file for any of them:
23
+
24
+ - **The admin**, at `/admin`. To move it, pass `adminPath: "/studio"` here and set `admin: { path: "/studio" }` in the config; the admin says so when the two disagree.
25
+ - **The preview address**, `/_smoodly/preview/<path>`, where the admin's canvas shows drafts.
26
+ - **The pages**, at every address a page file of your own does not take.
27
+
28
+ `config` names the Smoodly config file. `site` is the site's own address: canonical and hreflang links are absolute on it. Astro reads `astro.config.ts` before `.env`, so `process.env.SITE_URL` there sees only a value set in the shell or on the host; when it is empty, `smoodly()` sets `site` from `SITE_URL` in `.env`, and the build warns when nothing names one. The integration reads `.env` at `astro dev` and `astro build`, the `SMOODLY_` keys and `SITE_URL`; `npm start` reads it when it is there, and a deployed server takes the variables from its host.
29
+
30
+ ## Views by file name
31
+
32
+ A view is an `.astro` file named after its schema's `name`. There is no list of views to keep:
33
+
34
+ | Folder | What it renders, and its props |
35
+ | --- | --- |
36
+ | `src/page-types/<name>.astro` | A page type. It receives `page` (where the page sits: title, path, ancestors, children, locales), `zones` and `head`. |
37
+ | `src/sections/<name>.astro` | A section. Its fields arrive flat, with `settings` and `styles`. |
38
+ | `src/elements/<name>.astro` | An element inside richtext. Its fields and `settings`. |
39
+ | `src/entry-pages/<collection>.astro` | The entry page of a collection with a `path`. It receives `entry` and `head`. |
40
+
41
+ A schema with no view, and a view with no schema, are named once in the server log at the first request. A section is the same file in every framework apart from its view; [Sections](../content-modeling/sections.md) shows one.
42
+
43
+ ## Reads
44
+
45
+ Smoodly's content reaches your views as Astro live collections. `src/live.config.ts` names one loader per collection, global item, the pages and the page tree:
46
+
47
+ File: `src/live.config.ts`
48
+
49
+ ```ts
50
+ import { defineLiveCollection } from "astro:content";
51
+ import { entriesLoader, globalLoader, pageLoader, pageTreeLoader } from "smoodly/astro/content";
52
+ // …
53
+ export const collections = {
54
+ pages: defineLiveCollection({ loader: pageLoader() }),
55
+ pageTree: defineLiveCollection({ loader: pageTreeLoader() }),
56
+ articles: defineLiveCollection({ loader: entriesLoader(articles) }),
57
+ // …
58
+ header: defineLiveCollection({ loader: globalLoader(header) }),
59
+ // …
60
+ };
61
+ ```
62
+
63
+ A view reads with `getLiveCollection` and `getLiveEntry` from `astro:content`:
64
+
65
+ File: `src/page-types/articlesIndex.astro`
66
+
67
+ ```astro
68
+ ---
69
+ import { getLiveCollection } from "astro:content";
70
+ // …
71
+ const { entries = [] } = await getLiveCollection("articles", { locale: page.locale });
72
+ // …
73
+ ---
74
+ ```
75
+
76
+ A collection takes `locale`, `where`, `order` and `limit`; `getLiveEntry("footer", { locale })` reads a global item, and `getLiveEntry("pages", { path, locale })` one page. In a preview the same reads return drafts. A global flagged `layout: true` is answered from the page's own read, so a layout's `getLiveEntry` calls cost no call ([Defining a global](../globals/defining.md#layout-globals)).
77
+
78
+ ## The cache
79
+
80
+ Every read tags the response with what it read. Once the page is rendered, the response is stored whole, and a publish expires exactly the tags it touched. The first request after a publish reads from the service again; the rest are served from the cache. A 404, a draft and anything under the preview address are never stored.
81
+
82
+ `memoryCache()` keeps pages in the server process's memory: one process, one cache. On Vercel, `cacheVercel()` stores them on the CDN instead; [Deploying](../getting-started/deploying.md) shows the swap.
83
+
84
+ That is live mode, the default: pages render on demand and a publish is live at once. The other mode is static: with `output: "static"` in `astro.config`, every published page is built as a file, visitors need no function and no service, and editors press **Deploy** in the admin when their publishes should go live. Sections, schemas, the admin and the preview address are the same in both; [Deploying › Static](../getting-started/deploying.md#static) has the switch and the Deploy tile.
85
+
86
+ ## Preview
87
+
88
+ The admin's canvas loads a page at the preview address. A signed cookie, set for the admin's session, opens it; without the cookie the address is closed. Reads there return drafts, and the editor's bridge is added to the response. A live address never shows a draft and carries no Smoodly script. There is no pill: drafts are seen in the admin only.
89
+
90
+ ## Images
91
+
92
+ `Image` from `smoodly/astro/components` renders an `f.image()` value as plain HTML, an `<img>` or a `<picture>`:
93
+
94
+ File: `src/sections/hero.astro`
95
+
96
+ ```astro
97
+ {image && <Image image={image} class="hero__image" sizes="(min-width: 900px) 40vw, 100vw" priority {...m.field("image")} />}
98
+ ```
99
+
100
+ The box keeps the crop's aspect and the `srcset` lists the cut's widths on Smoodly's media host, in AVIF or WebP, so Astro's image service, `/_image` and `image.remotePatterns` are not involved and no image setting is needed. `Head` opens a connection to the media host when the page shows an image. In static output the build writes every image its pages show into the output instead, so a static page loads its images from your own host. [Images and crops](../media/images-and-crops.md) has named crops and the `media` prop.
101
+
102
+ ## Languages
103
+
104
+ `i18n` in `astro.config.ts` and `locales` in the config name the same set; the site refuses to start when they disagree, and the installer writes both. Smoodly follows Astro's rule for the default locale: no prefix unless `routing.prefixDefaultLocale` says so. `Head` writes one hreflang link per language the page is published in. [Locales and routing](../languages/locales.md) has the rest.
105
+
106
+ ## A page of your own
107
+
108
+ A file under `src/pages/`, such as `src/pages/pricing.astro`, wins its address. A page an editor creates at `/pricing` saves and publishes, but visitors see your file, and the page editor says so. `src/pages/404.astro` is the site's "not found" page.
109
+
110
+ To write content from a script, see [Content from code](../getting-started/content-from-code.md).
@@ -0,0 +1,147 @@
1
+ # Next.js
2
+
3
+ How Smoodly plugs into a Next.js app: the one route file, the server seam, the admin route group, and what happens on a request. The installer writes all of it; this page is for reading it.
4
+
5
+ ## The route file
6
+
7
+ One file under `app/(site)` serves every CMS URL, and it needs no editing:
8
+
9
+ File: `app/(site)/[[...path]]/page.tsx`
10
+
11
+ ```tsx
12
+ import { renderSmoodlyPath, smoodlyMetadata } from "@/smoodly/server";
13
+ // …
14
+ export function generateStaticParams() {
15
+ return [];
16
+ }
17
+ // …
18
+ export default async function Page({ params, searchParams }: { params: Params; searchParams: SearchParams }) {
19
+ return renderSmoodlyPath((await params).path ?? [], { searchParams });
20
+ }
21
+ ```
22
+
23
+ `renderSmoodlyPath` resolves the URL path to a page, a path in the tree or an entry, and renders it with its page type or entry page; no segments is the home. A page or entry that does not exist, or is not published, is a 404. A multilingual site has the same file under `app/[locale]` and passes the language along.
24
+
25
+ `generateStaticParams` returning nothing is what makes the page cacheable: Next builds no page ahead, renders each address on its first visit and keeps the result until a publish expires it ([What happens on a request](#what-happens-on-a-request)). Keep the export; without it Next renders the route on every request.
26
+
27
+ A route file of your own beside this one, `app/(site)/pricing/page.tsx`, wins for that exact URL by Next's precedence; deeper URLs still reach the catch-all. A page an editor creates at `/pricing` saves and publishes, but visitors see your route instead. The page editor says so: when its canvas cannot show the page, a warning names the cause, with a line starting "For your developer:" that says what to change. A home of your own is the same file: return your component when `path` is empty.
28
+
29
+ A page under this route may not read cookies, headers or search params, and neither may a section or the layout above it: Next decided the page is static, so a dynamic API inside the render answers a 500 in production, and `next dev` does not show it. A page that needs them is its own route file, which renders on every request. The same goes for `connection()`: a snippet from before 0.0.50 that awaits it in `smoodly/server.ts` turns every page into that 500. The file passes `searchParams` on to `renderSmoodlyPath`, which reads them inside draft mode only: the canvas asks for the page with a `smoodly-editor` parameter, and without it the page renders with no editor bridge, so nothing in the canvas can be selected. The page editor names that case too.
30
+
31
+ ## The server seam
32
+
33
+ `smoodly/server.ts` builds everything the routes and the admin share, once per server, and only when first used, so a build with no environment still succeeds:
34
+
35
+ File: `smoodly/server.ts`
36
+
37
+ ```ts
38
+ const site = createSmoodlyApi({ ...apiEnv(), config });
39
+ // …
40
+ const renderer = createPageRenderer({ site, ...views(), href });
41
+ // …
42
+ const q = createSmoodlyQueries({ site, collections, globals });
43
+ ```
44
+
45
+ `createSmoodlyApi` reads your content over Smoodly's API, one call per read, and computes everything else the renderer needs from the config, so the two cannot disagree. Your site opens no database. `views()` hands the renderer your components from `smoodly/views.ts`, checked against the config's schemas the first time the site renders.
46
+
47
+ The live site reads two variables: `SMOODLY_SPACE_ID`, the space your content lives in, and `SMOODLY_SECRET_KEY`. The admin needs the same two: it learns where editors sign in from Smoodly's API. `SITE_URL` is the address the build pushes to the space with the schema (`prebuild: "smoodly push"` in `package.json`). [Keys](../getting-started/keys.md) explains them. It exports the two functions the routes call, `q` for [queries](../collections/queries.md), and `href`, the one seam that turns a language-relative path into a site URL.
48
+
49
+ The file also builds `content()`, which scripts write content through ([Content from code](../getting-started/content-from-code.md)). It takes it from `smoodly/admin` and posts to the same API with the secret key:
50
+
51
+ File: `smoodly/server.ts`
52
+
53
+ ```ts
54
+ return createSmoodlyContent({ config, href, transport: apiTransport(apiEnv()) });
55
+ ```
56
+
57
+ ## The admin
58
+
59
+ The admin is one route. The `smoodly` package ships it prebuilt, so your app compiles none of it:
60
+
61
+ File: `app/(smoodly)/admin/[[...segments]]/route.ts`
62
+
63
+ ```ts
64
+ import { createAdminRoute } from "smoodly/next";
65
+ import { config } from "@/smoodly/config";
66
+ import { adminDeps, presence } from "@/smoodly/server";
67
+
68
+ export const { GET, POST } = createAdminRoute(() => ({ config, ...adminDeps(), presence }));
69
+ ```
70
+
71
+ `GET` serves the admin's page and the script and fonts it loads. The admin's operations run in Smoodly's API, which accepts them from the addresses your builds pushed and checks the editor's session against the space's members; the browser never holds a write key. `POST` takes two calls only: the API's call after a publish naming the cache tags to expire, which must be signed with the API's own key and less than a minute old, and the editor's browser turning on draft mode with a grant the API signed, accepted from the site's own address only.
72
+
73
+ `presence` is where the browser opens the editing-presence connection (Smoodly's API and your space), public values read from `smoodly/server.ts` per request; where editors sign in, the page asks Smoodly's API. Leave `presence` out and the admin works the same, minus [who is editing what](../getting-started/working-together.md).
74
+
75
+ The route folder under `app/(smoodly)` must match `admin.path` in the config; [The admin](https://smoodly.io/docs/getting-started/admin) covers moving it. The admin's page has no layout, so the site's layout never wraps it.
76
+
77
+ ## Content in the browser
78
+
79
+ The publishable key is public and reads published content only, so a component that runs in the browser could read with it. The starter's way is a route in your own app that reads through `q` on the server: the browser holds no key, and the route decides what leaves. A component that needs entries, a search box or a "load more" button, asks that route:
80
+
81
+ File: `app/api/articles/route.ts`
82
+
83
+ ```ts
84
+ import { q } from "@/smoodly/server";
85
+
86
+ export async function GET(request: Request) {
87
+ const locale = new URL(request.url).searchParams.get("locale") ?? undefined;
88
+ const items = await q().getEntries("articles", { locale, limit: 12 });
89
+ return Response.json(items.map((item) => ({ id: item.id, path: item.path, title: item.fields.title })));
90
+ }
91
+ ```
92
+
93
+ Return only what the browser should see: the route decides, not the service. Published content only leaves the route, because a request outside the editor's draft mode reads published rows.
94
+
95
+ ## What happens on a request
96
+
97
+ A CMS route is never built ahead: `next build` reads nothing from the service and needs no keys. Each address renders on its first visit, outside draft mode, and Next keeps the page in its route cache, which Vercel serves from its CDN without running your function. The render reads under tags that name the page or entry by id, the layout's global items by name, and, for a 404, a redirect or a code-owned page, one tag for every address. Publishing, unpublishing, renaming or deleting expires exactly those tags at once; the next visit renders fresh. Nothing expires on a timer. One consequence: a renamed page's old address answers its permanent redirect without the request's query string, since the cached answer cannot read it.
98
+
99
+ Draft mode is Next's way of rendering unpublished content for one browser. The editor turns it on for the editor's browser by posting a grant from Smoodly's API to the admin route, then loads the site's real URL in the canvas with `/~editor` after it, which mounts the bridge the canvas talks to. In draft mode every read returns the live draft rows, and *History* renders an old version at the page's URL followed by `/~version~` and the version's id. The flags ride the path, not the query, because Vercel passes a cached route only its own parameters, even in draft mode. The public site honors neither: outside draft mode such an address is not found.
100
+
101
+ ## Preview mode
102
+
103
+ Opening the page editor turns on draft mode for that browser. Every page Smoodly renders then shows a floating pill, outside the editor's canvas: *Preview · unpublished changes*, with *Exit preview*. Editors can drag it to the left, center or right, move it with the arrow keys, or hide it until the next reload. Outside draft mode the page's HTML has no trace of it, and no page loads Smoodly's editor code.
104
+
105
+ Exit goes to `/api/preview?disable`, the route every starter ships:
106
+
107
+ File: `app/api/preview/route.ts`
108
+
109
+ ```ts
110
+ export { previewRoute as GET } from "smoodly/next";
111
+ ```
112
+
113
+ If you moved that route, tell the renderer with the `previewRoute` option of `createPageRenderer`, like `previewRoute: "/my/preview"`.
114
+
115
+ A page you render yourself, without `renderSmoodlyPath`, can show the pill too:
116
+
117
+ File: `app/pricing/page.tsx`
118
+
119
+ ```tsx
120
+ import { draftMode } from "next/headers";
121
+ import { previewPill } from "smoodly/next";
122
+
123
+ export default async function Pricing() {
124
+ const { isEnabled } = await draftMode();
125
+ return <main>{isEnabled && previewPill()}</main>;
126
+ }
127
+ ```
128
+
129
+ The pill loads as a small script of its own, in preview only.
130
+
131
+ ## Cache Components
132
+
133
+ Leave `cacheComponents` off in `next.config.ts`. Next 16's Cache Components mode brings the `"use cache"` directive and partial prerendering for every route: a static shell goes out first and the rest streams in. Smoodly's catch-all serves paths that are unknown at build time, so under that mode it is always sent from a shell that has already committed status 200. A page that doesn't exist then answers 200 with a `noindex` tag instead of 404, and a redirect answers 200 instead of 308, both of which search engines treat worse. No route can opt out of the mode, so Smoodly does not support it.
134
+
135
+ You keep `unstable_cache` and `revalidateTag`, and every page and entry is already cached under its own tag. What you give up is `"use cache"` in your own code.
136
+
137
+ If the mode is on, the server log says so once, at the first page render:
138
+
139
+ ```text
140
+ smoodly: Next's cacheComponents is on, and Smoodly does not support it — …
141
+ ```
142
+
143
+ ## Images
144
+
145
+ `SmoodlyImage` from `smoodly/image` renders an `f.image()` value as a plain `<img>`, or a `<picture>` for named crops, over cuts from Smoodly's media host in AVIF or WebP. It does not use `next/image`, so `next.config.ts` holds no `images` setting and Vercel's image optimization is never billed. The component calls React's `preconnect` for the media host, which React places in the head. [Images and crops](../media/images-and-crops.md) has the rest.
146
+
147
+ Next, [Upgrading](../reference/upgrading.md) says what a new version asks of your code.
@@ -0,0 +1,263 @@
1
+ # Content from code
2
+
3
+ Write pages, entries, globals and images from a script instead of the admin: to seed a new site, or to move a client's old content in. At the end you have a script you can run again after it stops halfway.
4
+
5
+ ## Where it runs
6
+
7
+ **Astro**
8
+
9
+ `createContent(config)` from `smoodly/astro` builds the content API for a script. It reads `.env` and posts to Smoodly's API with the secret key, which runs the same operations as the admin, with no editor signed in. So every rule the admin enforces holds for a script too: required fields, slug rules, the checks before a publish. It needs the secret key, so it runs on the server only: in a script or an endpoint.
10
+
11
+ The starter's own content is written this way:
12
+
13
+ File: `scripts/seed.ts`
14
+
15
+ ```ts
16
+ const { config } = await import("../src/smoodly/config");
17
+ // …
18
+ const { node } = await import("smoodly");
19
+ const { createContent } = await import("smoodly/astro");
20
+ const { content, href } = createContent(config);
21
+ ```
22
+
23
+ `href` turns a path in one language into the site's URL, on the same rules as the site. When `astro.config.ts` sets `i18n`, pass the same object: `createContent(config, { i18n })`. The samples below use `c` for the content API; a script of yours starts like this:
24
+
25
+ ```ts
26
+ import { node } from "smoodly";
27
+ import { createContent } from "smoodly/astro";
28
+ import { config } from "@/smoodly/config";
29
+
30
+ const { content: c } = createContent(config);
31
+ ```
32
+
33
+ Add a line to `package.json` for it, and run it with `npm run`, from the project's folder:
34
+
35
+ File: `package.json`
36
+
37
+ ```json
38
+ {
39
+ "scripts": {
40
+ "services": "tsx scripts/services.ts"
41
+ }
42
+ }
43
+ ```
44
+
45
+ ```bash
46
+ npm run services
47
+ ```
48
+
49
+ The samples below import from `@/smoodly/server`, the Next.js file; in an Astro script those lines are the three above. Two things about such a script: `astro check` reads it, and a file with a top-level `await` must be a module, which any `import` line at the top makes it; and `@/` resolves from the project's folder, so run it through `npm run`, not `tsx` from elsewhere.
50
+
51
+ **Next.js**
52
+
53
+ `smoodly/server.ts` exports `content()`. It posts to Smoodly's API with the secret key, which runs the same operations as the admin, with no editor signed in, so every rule the admin enforces holds for a script too: required fields, slug rules, the checks before a publish. It needs the secret key, so it runs on the server only: in a script, a route handler or a server action. A script that uses a section, an element or a field you just added needs the schema pushed first: a dev server or a build pushes it when it starts, and `npx smoodly push` does it on its own; until then the service refuses the publish, naming the type the space does not know yet.
54
+
55
+ The starter's own content is written this way. `scripts/seed.ts` loads `.env.local`, then takes `content()`:
56
+
57
+ File: `scripts/seed.ts`
58
+
59
+ ```ts
60
+ import next from "@next/env";
61
+ // …
62
+ const { loadEnvConfig } = next;
63
+
64
+ loadEnvConfig(process.cwd());
65
+ // …
66
+ const { content, href, node } = await import("../smoodly/server");
67
+ // …
68
+ const c = content();
69
+ ```
70
+
71
+ `content()` reads the keys when it is called, not when `smoodly/server` is imported, so a script of your own can import it at the top and call `loadEnvConfig` first. Add a line to `package.json` for it, and run it with `npm run`, from the project's folder:
72
+
73
+ File: `package.json`
74
+
75
+ ```json
76
+ {
77
+ "scripts": {
78
+ "services": "tsx scripts/services.ts"
79
+ }
80
+ }
81
+ ```
82
+
83
+ ```bash
84
+ npm run services
85
+ ```
86
+
87
+ ## A page
88
+
89
+ A page is created in one language, given its sections, and published. `node()` builds a section for the tree: its fields are typed from the section's own definition, so a wrong field name fails in your editor. The function below finds the page before it creates it, so it can run again; the last section says why each step is safe to repeat.
90
+
91
+ File: `scripts/services.ts`
92
+
93
+ ```ts
94
+ import next from "@next/env";
95
+ import { SmoodlyContentError } from "smoodly/admin";
96
+ import { content, node } from "@/smoodly/server";
97
+ import { pageHeroSchema } from "@/components/sections/PageHero.schema";
98
+ import { pointsSchema } from "@/components/sections/Points.schema";
99
+
100
+ next.loadEnvConfig(process.cwd());
101
+ const c = content();
102
+
103
+ async function servicesPage() {
104
+ const page =
105
+ (await c.pages.find("en", "/services")) ??
106
+ (await c.pages.create({ parentId: null, locale: "en", slug: "services", template: "page", title: "Services" }));
107
+ await c.pages.setTree(page.id, "en", {
108
+ zones: {
109
+ hero: [node(pageHeroSchema, { heading: "Services", text: "What we do for our clients." })],
110
+ body: [node(pointsSchema, { heading: "Three things", points: [{ icon: "circle", heading: "Design", text: "Sites that read well." }] })],
111
+ },
112
+ });
113
+ await c.pages.publish(page.id, "en");
114
+ // The Finnish page starts as a copy of the English one, at its own slug.
115
+ if (!page.locales.fi) await c.pages.addLocale(page.id, "fi", { from: "en", slug: "palvelut", title: "Palvelut" });
116
+ await c.pages.publish(page.id, "fi");
117
+ return page;
118
+ }
119
+ ```
120
+
121
+ The example is the multilingual Lorem Co. starter's: on a one-language site leave out the Finnish lines, and on Blank the Page type has a `body` zone only. `hero` and `body` are the zones of the starter's Page type, and each takes the sections its zone lists. The Finnish page holds the English text until `setTree(page.id, "fi", …)` gives it its own. `template` is the page type's `name`, and `parentId` is another page's id, or `null` for the top level. `getTree(id, locale)` reads a page's draft back, for a script that changes a page instead of replacing it. `pages.list({ locale, template })` gives every page in a language, folders left out, for a script that changes many:
122
+
123
+ File: `scripts/rename.ts`
124
+
125
+ ```ts
126
+ import next from "@next/env";
127
+ import { content } from "@/smoodly/server";
128
+
129
+ next.loadEnvConfig(process.cwd());
130
+ const c = content();
131
+
132
+ for (const page of await c.pages.list({ template: "page" })) {
133
+ const tree = await c.pages.getTree(page.id, "en");
134
+ // …change the tree, then:
135
+ if (tree) await c.pages.setTree(page.id, "en", tree);
136
+ }
137
+ ```
138
+
139
+ Every call throws a `SmoodlyContentError` when the admin would have refused it. It comes from `smoodly/admin`, which is server code: a script may import it, the site's pages and sections never do. The error carries a `code` and, for a publish that failed validation, one line per field:
140
+
141
+ File: `scripts/services.ts`
142
+
143
+ ```ts
144
+ async function run(step: () => Promise<unknown>) {
145
+ try {
146
+ await step();
147
+ } catch (error) {
148
+ if (!(error instanceof SmoodlyContentError)) throw error;
149
+ console.error(error.code, error.message);
150
+ for (const field of error.fields) console.error(field.field, field.message);
151
+ process.exit(1);
152
+ }
153
+ }
154
+ ```
155
+
156
+ The draft stays as the script left it, so you can fix the content and run the publish again.
157
+
158
+ ## Entries, globals and images
159
+
160
+ An entry is created with its fields, then published. A localized field gets its other language with `addLocale`. A reference is stored as the other entry's id, so pass `person.id` where a field is an `f.ref()`. A slug that follows another field, `f.slug({ from: "title" })`, is filled from it when you leave it out, as the admin's form does; pass `slug` to choose your own.
161
+
162
+ An `f.richtext()` value is the editor's own document, TipTap JSON: a `doc` holding paragraphs, headings and lists, each holding text. A required richtext field left out is a *Required* error on publish:
163
+
164
+ File: `scripts/services.ts`
165
+
166
+ ```ts
167
+ const text = (t: string) => ({ type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: t }] }] });
168
+ await c.entries.create("recipes", { locale: "en", fields: { title: "Fish stew", body: text("Serves four.") } });
169
+ ```
170
+
171
+ File: `scripts/services.ts`
172
+
173
+ ```ts
174
+ async function person() {
175
+ const aino = await c.entries.create("people", { locale: "en", fields: { name: "Aino Virtanen", role: "Designer" } });
176
+ await c.entries.addLocale("people", aino.id, "fi", { from: "en", fields: { name: "Aino Virtanen", role: "Muotoilija" } });
177
+ await c.entries.publish("people", aino.id, "en");
178
+ await c.entries.publish("people", aino.id, "fi");
179
+ return aino;
180
+ }
181
+ ```
182
+
183
+ A global exists once, so there is nothing to create: `set` writes its fields and `publish` makes them live. Until the publish the change is a draft: the live site shows the old fields, draft mode shows the new ones, which is a quick way to try a preview link.
184
+
185
+ File: `scripts/services.ts`
186
+
187
+ ```ts
188
+ async function contactDetails() {
189
+ await c.globals.set("contact", "en", { email: "hello@example.com", address: "Main Street 1, Helsinki" });
190
+ await c.globals.publish("contact", "en");
191
+ }
192
+ ```
193
+
194
+ `media.upload()` takes a file path or bytes and puts the file in the media library. The admin's rules apply: an accepted file type and the size limit from `media` in the config. It returns the value an `f.image()` field stores, so it goes straight into a node or an entry:
195
+
196
+ File: `scripts/services.ts`
197
+
198
+ ```ts
199
+ async function teamPage() {
200
+ const photo = await c.media.upload("public/images/about_main.jpg", { alt: "The team at work" });
201
+ const page = await c.pages.create({ parentId: null, locale: "en", slug: "team", template: "page", title: "Team" });
202
+ await c.pages.setTree(page.id, "en", {
203
+ zones: { hero: [node(pageHeroSchema, { heading: "Our team", text: "The people you will work with.", image: photo })] },
204
+ });
205
+ await c.pages.publish(page.id, "en");
206
+ }
207
+ ```
208
+
209
+ ## Run it twice
210
+
211
+ A script that stops halfway, on a validation error or a dropped connection, gets run again. Each step above is safe to repeat, or guarded:
212
+
213
+ - `pages.find(locale, path)` returns the page at that URL, a draft included, or `null`. Finding before creating makes the page once.
214
+ - `setTree`, `globals.set` and `publish` replace what is there, so a second run leaves the same result.
215
+ - `addLocale` refuses a language the page already has, so check `page.locales` first.
216
+ - `entries.find(collection, { slug })` finds an entry by its slug, in the default language unless you pass a `locale`.
217
+ - `entries.list(collection, { where })` lists the entries whose fields match, for a collection without a slug field, such as the starter's people. A field holding a list, such as tags, matches when it holds the value:
218
+
219
+ File: `scripts/services.ts`
220
+
221
+ ```ts
222
+ const [existing] = await c.entries.list("people", { where: { name: "Aino Virtanen" } });
223
+ const aino = existing ?? (await person());
224
+ ```
225
+ - Every `media.upload` adds a file to the library. Upload where the thing that uses it is created, once.
226
+
227
+ File: `scripts/services.ts`
228
+
229
+ ```ts
230
+ await run(async () => {
231
+ const hadTeam = (await c.pages.find("en", "/team")) !== null;
232
+ await servicesPage();
233
+ await contactDetails();
234
+ // The team page, its photo and its person are made together, on the first run only.
235
+ if (!hadTeam) {
236
+ await person();
237
+ await teamPage();
238
+ }
239
+ });
240
+ ```
241
+
242
+ ## Code-owned pages
243
+
244
+ A fixed page type, such as Home or a collection's list page, has its URL in code. It is live as soon as the code is deployed, with empty zones, and you never create it. `find` returns its record, making it first when the space is new. Then you fill it like any page:
245
+
246
+ File: `scripts/services.ts`
247
+
248
+ ```ts
249
+ async function articlesList() {
250
+ const list = await c.pages.find("en", "/posts");
251
+ if (!list) return;
252
+ await c.pages.setTree(list.id, "en", { zones: { hero: [node(pageHeroSchema, { heading: "Articles", text: "What we have been writing." })] } });
253
+ await c.pages.publish(list.id, "en");
254
+ }
255
+ ```
256
+
257
+ Its title and slug stay what the page type says, in every language.
258
+
259
+ ## Members
260
+
261
+ Who can sign in to the admin is not set from code. A space's member list is kept at smoodly.io and arrives in the space whole; the admin's Settings › Members page shows it and links there.
262
+
263
+ Next, [the admin](https://smoodly.io/docs/getting-started/admin) is where editors take over from here.