smoodly 0.0.60 → 0.0.62

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +3 -1
  2. package/dist/admin/editor/CanvasOverlay.d.ts +4 -1
  3. package/dist/admin/editor/CanvasOverlay.js +3 -3
  4. package/dist/admin/editor/GlobalCanvas.d.ts +27 -0
  5. package/dist/admin/editor/GlobalCanvas.js +159 -0
  6. package/dist/admin/editor/InsertPicker.js +1 -1
  7. package/dist/admin/files.generated.js +1 -1
  8. package/dist/admin/serialize.d.ts +2 -0
  9. package/dist/admin/serialize.js +1 -0
  10. package/dist/admin/shell/AdminApp.js +5 -5
  11. package/dist/admin/shell/GlobalForm.js +80 -7
  12. package/dist/admin/shell/GlobalList.js +1 -1
  13. package/dist/admin/shell/{IconRail.d.ts → NavBar.d.ts} +3 -3
  14. package/dist/admin/shell/NavBar.js +120 -0
  15. package/dist/admin/shell/global-canvas.d.ts +40 -0
  16. package/dist/admin/shell/global-canvas.js +60 -0
  17. package/dist/admin/shell/health-list.js +1 -1
  18. package/dist/admin/ui/primitives.js +1 -1
  19. package/dist/admin/ui/theme.js +99 -91
  20. package/dist/api-client.js +5 -3
  21. package/dist/astro/index.js +3 -3
  22. package/dist/cli/push.js +2 -3
  23. package/dist/env.js +3 -2
  24. package/dist/push.d.ts +7 -0
  25. package/dist/push.js +8 -0
  26. package/dist/space-kind.d.ts +2 -0
  27. package/dist/space-kind.js +1 -0
  28. package/dist/version.d.ts +1 -1
  29. package/dist/version.js +1 -1
  30. package/docs/collections/defining.md +57 -0
  31. package/docs/collections/entry-pages.md +168 -0
  32. package/docs/collections/ordering.md +47 -0
  33. package/docs/collections/queries.md +173 -0
  34. package/docs/content-modeling/changing-schemas.md +79 -0
  35. package/docs/content-modeling/custom-section.md +94 -0
  36. package/docs/content-modeling/marks.md +97 -0
  37. package/docs/content-modeling/overview.md +55 -0
  38. package/docs/content-modeling/page-types.md +237 -0
  39. package/docs/content-modeling/sections.md +264 -0
  40. package/docs/content-modeling/styles-and-settings.md +184 -0
  41. package/docs/content-modeling/zones.md +80 -0
  42. package/docs/fields/choices.md +42 -0
  43. package/docs/fields/conditional.md +72 -0
  44. package/docs/fields/link.md +31 -0
  45. package/docs/fields/list-and-object.md +58 -0
  46. package/docs/fields/media.md +103 -0
  47. package/docs/fields/overview.md +56 -0
  48. package/docs/fields/reference-and-tags.md +62 -0
  49. package/docs/fields/richtext.md +168 -0
  50. package/docs/fields/text.md +152 -0
  51. package/docs/frameworks/astro.md +110 -0
  52. package/docs/frameworks/nextjs.md +147 -0
  53. package/docs/getting-started/content-from-code.md +263 -0
  54. package/docs/getting-started/deploying.md +131 -0
  55. package/docs/getting-started/export-and-import.md +73 -0
  56. package/docs/getting-started/first-page.md +98 -0
  57. package/docs/getting-started/install.md +108 -0
  58. package/docs/getting-started/keys.md +40 -0
  59. package/docs/getting-started/project-structure.md +142 -0
  60. package/docs/getting-started/working-together.md +42 -0
  61. package/docs/globals/defining.md +99 -0
  62. package/docs/globals/header-and-footer.md +117 -0
  63. package/docs/index.md +83 -0
  64. package/docs/languages/language-switcher.md +103 -0
  65. package/docs/languages/locales.md +119 -0
  66. package/docs/languages/localized-fields.md +61 -0
  67. package/docs/media/images-and-crops.md +131 -0
  68. package/docs/media/library.md +40 -0
  69. package/docs/pages/drafts-and-publishing.md +44 -0
  70. package/docs/pages/page-tree.md +51 -0
  71. package/docs/pages/paths-and-slugs.md +74 -0
  72. package/docs/pages/seo.md +126 -0
  73. package/docs/pages/version-history.md +23 -0
  74. package/docs/reference/upgrading.md +312 -0
  75. package/package.json +4 -3
  76. package/dist/admin/shell/IconRail.js +0 -109
@@ -0,0 +1,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.
@@ -0,0 +1,131 @@
1
+ # Deploying
2
+
3
+ Put your Smoodly site on the internet: give it the production space's values and let its build push the schema; then, once it is live, add a preview space to develop against.
4
+
5
+ ## What a deploy needs
6
+
7
+ **Astro**
8
+
9
+ A Smoodly site on Astro is a server-rendered Astro site. The starter's `@astrojs/node` adapter deploys to any host that runs `astro build` and starts `dist/server/entry.mjs`. Pages are cached in that process's memory and expire when an editor publishes.
10
+
11
+ On Vercel, swap the adapter for Vercel's, and the cache for its CDN:
12
+
13
+ File: `astro.config.ts`
14
+
15
+ ```ts
16
+ import { defineConfig } from "astro/config";
17
+ import vercel from "@astrojs/vercel";
18
+ import { cacheVercel } from "@astrojs/vercel/cache";
19
+ import smoodly from "smoodly/astro";
20
+
21
+ export default defineConfig({
22
+ output: "server",
23
+ adapter: vercel(),
24
+ // Pages are cached on Vercel's CDN and expired by tag when an editor publishes.
25
+ cache: { provider: cacheVercel() },
26
+ site: process.env.SITE_URL,
27
+ integrations: [smoodly({ config: "src/smoodly/config.ts" })],
28
+ });
29
+ ```
30
+
31
+ Install it with `npm install @astrojs/vercel`. The steps below name Vercel's settings; other hosts have the same ones under other names.
32
+
33
+ Run the function near your visitors. A page that is not in the CDN yet makes one call to Smoodly's API, answered from the edge when cached and otherwise by your space, which lives near where you made the project: Stockholm for a project made from Europe. On Vercel, `vercel.json` sets the function's region with `{ "regions": ["arn1"] }` (Stockholm; the [region list](https://vercel.com/docs/regions) names the rest). Vercel's Speed Insights (`@vercel/speed-insights`, mounted as its Astro or Next component in your layout) records real visitors' Web Vitals; it is your site's script, not Smoodly's, and optional.
34
+
35
+ ### Static
36
+
37
+ Set Astro's output to static and every published page is built as a file: visitors need no function, and the site stays up if Smoodly's API is down.
38
+
39
+ File: `astro.config.ts (static)`
40
+
41
+ ```ts
42
+ import { defineConfig } from "astro/config";
43
+ import vercel from "@astrojs/vercel";
44
+ import smoodly from "smoodly/astro";
45
+
46
+ export default defineConfig({
47
+ output: "static",
48
+ adapter: vercel(),
49
+ site: process.env.SITE_URL,
50
+ integrations: [smoodly({ config: "src/smoodly/config.ts" })],
51
+ });
52
+ ```
53
+
54
+ Publishing does not change the live site. Editors press **Deploy** in the admin's top bar, next to their avatar, when they are done, and the host builds. Give the site the host's deploy hook as `SMOODLY_DEPLOY_HOOK` where `astro build` runs (on Vercel: Settings › Git › Deploy Hooks): the build hands it to Smoodly's API with the site's address; without it the tile shows what is waiting and Deploy stays disabled. `SITE_URL` must be the site's own address, and the build refuses static output without it: the tile reads the build's marker, `/_smoodly/build.json`, from there. It says *Building…* until the new build answers, *N changes* while publishes wait (an unpublish or a delete counts as one too), and *The build has not landed* after ten minutes with no new build, in which case look at the host's log. A host that strips query strings from a cached file can hold the old marker for its max-age.
55
+
56
+ The admin, its revalidate and preview routes and the preview address still run on demand, so the deployment is files plus one function; a host with no function at all cannot run the admin. A static page has no lists by query string and no draft at its real address: drafts are edited through the preview address, as in live mode. A moved page's old address becomes one of the host's redirects at build, so it is answered without a function too. The cache provider in `astro.config` is unused in static output and may stay.
57
+
58
+ **Next.js**
59
+
60
+ A Smoodly site on Next.js deploys like any other Next.js app: to Vercel, or to any host that runs `next build` and `next start`. Pages are served from the host's cache (on Vercel, the CDN: `x-vercel-cache: HIT`) and expire when an editor publishes; the function runs for the first visit of an address and for the editor's preview. The steps below name Vercel's settings; other hosts have the same ones under other names.
61
+
62
+ ## Environment variables
63
+
64
+ Set these in your host's project settings. Production gets the production space's values. Your host's Preview environment gets the preview space's once the project has one ([A preview space](#a-preview-space)), and the production space's until then. A missing or wrong key answers every page with a 500 and names the variable in the server log, your host's function logs:
65
+
66
+ | Variable | What it is |
67
+ | --- | --- |
68
+ | `SMOODLY_SPACE_ID` | The space your content lives in, `sp_…`. A key from another space is refused by name. |
69
+ | `SMOODLY_SECRET_KEY` | The space's secret key (`sk_…`). Server-only: never expose it to the browser. A wrong key fails every page with one of these: the publishable key `holds the publishable key`; a string that is no Smoodly key `is not a Smoodly secret key`; a key the space does not hold `is not one of the space's live keys`. Every read sends the key again, so a corrected key works on the next request. |
70
+ | `SMOODLY_PUBLISHABLE_KEY` | The space's publishable key (`pk_…`): it reads published content only, so a browser may hold it. |
71
+ | `SITE_URL` | The site's own address with its scheme, like `https://www.example.com`. The build pushes it to the space: the admin's operations are accepted from it, and a publish calls it back. Your root layout builds `metadataBase` from it, so the canonical link, hreflang links (on a multilingual site) and social image URLs are absolute. Unset, it falls back to `http://localhost:3000` and the server warns once per process (a build prints it from each of its workers); a value without `http://` or `https://` stops every page with an error that names it. It is read when the server starts, so restart or redeploy after changing it. |
72
+ | `SMOODLY_PREVIEW_SECRET` | Next.js only; an Astro site has no such route and ignores the variable, since its drafts live at the preview address inside the admin. Optional. Lets a link turn draft mode on outside the admin: `/api/preview?secret=…&path=/` sets the draft cookie and redirects to `path`; a wrong or missing secret gets a 401. `/api/preview?disable` turns it off and needs no secret. Leave the variable unset and only the admin can turn draft mode on. |
73
+
74
+ On Astro the same variables are read at build and at run time. `SITE_URL` becomes `site`, fixed into the build, so set it where `astro build` runs (in the host's settings, or in `.env` for a build on your machine); without it, the absolute links in the page head are built from each request's own address, and the build warns.
75
+
76
+ [Keys](../getting-started/keys.md) says what each key can reach, and how to rotate one.
77
+
78
+ ### Seeing what a page costs
79
+
80
+ Set `SMOODLY_DEBUG_READS=1` on the server and every page answers with two headers: `x-smoodly-reads`, the number of calls to Smoodly's API the response made (a page is one: the lookup brings the layout globals along, so the layout's reads cost nothing; a list on the page is one more, and so is a global without `layout: true` that a view reads), and `Server-Timing`, each call by its key with when it started and how long it took, then the render. `curl -sD - -o /dev/null https://your-site/?v=1` shows them on a cache miss. Leave the variable unset for visitors.
81
+
82
+ ## The push at build
83
+
84
+ Your content model lives in your code, and each build pushes it to the space with the site's address. A Next site's `prebuild` script runs `smoodly push`; an Astro build does it itself. The build log shows `Pushed schema version 1 to the production space sp_…`, or `Schema unchanged (version 1) in the production space sp_…` when nothing changed (`the preview space sp_preview_…` for a preview space), so the log says which space the build reached. The dev server says the same line once when it starts. A push that fails is logged and never fails the build. There is no migration to run and no document to rebuild: a package upgrade or a content-model change reaches the space with the next deploy.
85
+
86
+ In live mode every page renders on request, so the framework's build reads no content: a build without the keys still succeeds and only skips the push. A static Astro build reads every published page, so it needs the keys.
87
+
88
+ If your site takes the package from a packed tarball in the repository rather than from npm, replacing the file is not enough: a host that restores `node_modules` from its build cache (Vercel does) sees the same path and version and keeps the old package. Deploy once without the cache (`vercel --prod --force`), or give the tarball a new name.
89
+
90
+ ## The starter's content
91
+
92
+ The installer already filled the space with the starter's content. On a new site that is the production space, so the content you build on is where the live site reads it and a first deploy has nothing to copy. If the installer's seed failed, or you chose to run it later, run it yourself; it fills the space your env file names:
93
+
94
+ ```bash
95
+ npm run seed
96
+ ```
97
+
98
+ A space without a schema refuses the seed with `no_schema`: start the dev server or build first, which pushes the schema.
99
+
100
+ ## A preview space
101
+
102
+ A preview space is a full copy of production to develop against: its pages, entries, history, redirects and media. A project has none until you add one, and many sites never need one before they go live.
103
+
104
+ Add it once the site is live. A dev server and every build push the site's schema to the space they read. Before launch that is what you want. After it, a schema change you try on your machine, or in a preview deployment, would change what the live site serves if they still read production.
105
+
106
+ 1. Open the project at smoodly.io, go to *Spaces & keys* and choose **Add a preview space**. Its two keys are shown once; its id stays on the page.
107
+ 2. Put the three values in your env file, and in your host's Preview environment.
108
+ 3. Wait for the copy; the page shows its progress. While it runs the preview space refuses edits, uploads and schema pushes, its admin says a copy is in progress, and a site reading it may show missing pages.
109
+ 4. When the copy is ready, restart the dev server and redeploy the preview environment. Their push was refused if they started during the copy, and a site signs in to its admin only from an address its push gave the space.
110
+
111
+ If a copy fails, the page says why, and the preview space stays locked: its admin and a push say the copy failed. Choose **Try again** there to continue where it stopped, or **Refresh from production** to start over.
112
+
113
+ A preview space's values say what they are: its id is `sp_preview_…` and both keys carry it. The build log names the space a push reached, so you can see which one a build used.
114
+
115
+ **Refresh from production**, on the same page, replaces everything in the preview space with a new copy: preview's own content changes are lost, and its keys and its schema stay the same. After a refresh, restart or redeploy the site that reads preview: it may hold pages it cached before. Nothing travels the other way. Content is edited in production; preview is where code and schema changes are tried against a copy of it.
116
+
117
+ ## Sign-in on the real address
118
+
119
+ Editors sign in at `/admin` on the deployed site as they do locally: the admin sends them to smoodly.io and back. The deploy's push gives the space the site's address, and smoodly.io sends an editor back only to an admin address the space recorded that way (a preview deploy's and `localhost` included), so there is nothing to set.
120
+
121
+ ## Preview and drafts
122
+
123
+ **Astro**
124
+
125
+ Editors see drafts at the preview address, `/_smoodly/preview/<path>`, inside the admin only: a signed cookie opens it, and nothing there is cached. A live address never shows a draft, and there is no pill.
126
+
127
+ **Next.js**
128
+
129
+ The editor turns on draft mode (Next's way of rendering unpublished content for one browser). While it is on, a small pill at the bottom of each page says *Preview · unpublished changes*. *Exit preview* turns draft mode off through `/api/preview`, which every starter includes. Visitors never get the pill or any of its code.
130
+
131
+ Next, [Astro](../frameworks/astro.md) and [Next.js](../frameworks/nextjs.md) cover what each framework adds.
@@ -0,0 +1,73 @@
1
+ # Export and import
2
+
3
+ Copy a space's content into a folder, and from the folder into a new space: every page, entry, version and media file. It is how you move a site or keep a copy.
4
+
5
+ ## What travels
6
+
7
+ `npx smoodly export` writes your site's content into a folder: every page and entry with its whole history, the redirects, and the media library with its files. `npx smoodly import` writes that folder into another space. Every id stays the same, so the site renders exactly as before.
8
+
9
+ To copy production into a project's preview space, use **Add a preview space** or **Refresh from production** at smoodly.io ([Deploying](../getting-started/deploying.md#a-preview-space)): the service copies it for you. These two commands are for moving a site to another project and for keeping a copy.
10
+
11
+ Members do not travel: a space's members are set in the project at smoodly.io. The import lists the source's members, so you can add them to the new space there.
12
+
13
+ Both commands run in your app's folder and read `SMOODLY_SPACE_ID` and `SMOODLY_SECRET_KEY` from `.env.local` (Astro: `.env`). The secret key decides whose content they reach, so there is nothing else to pass. The publishable key is refused, because it reads no drafts and writes nothing:
14
+
15
+ ```
16
+ smoodly: SMOODLY_SECRET_KEY holds the publishable key (pk_…) — copy the secret key (sk_…) from the space page.
17
+ ```
18
+
19
+ ## Export
20
+
21
+ ```bash
22
+ npx smoodly export
23
+ ```
24
+
25
+ ```
26
+ Exported space sp_…: 14 pages (41 versions), 32 entries (118 versions), 51 assets (38.2 MB), 9 redirects
27
+ → smoodly-export-2026-10-16/
28
+ ```
29
+
30
+ The folder lands in the folder the command runs in. It holds `manifest.json`, one JSON file per table under `rows/` and the media files under `media/`. Zip it to hand it over. `--out <dir>` names another folder. An existing folder is never overwritten. The starters' `.gitignore` lists `smoodly-export-*`, so the folder stays out of your repository.
31
+
32
+ An editor who saves while the export runs can stop it with `Content changed during export — run it again.` Run it again.
33
+
34
+ ## Import into a new space
35
+
36
+ A space that already has content refuses an import, so import into a new one:
37
+
38
+ 1. Export, with the env file still naming the space you are leaving.
39
+ 2. Create a new project at smoodly.io and copy its production space's three values.
40
+ 3. Point the env file at the new space: its `SMOODLY_SPACE_ID`, `SMOODLY_SECRET_KEY` and `SMOODLY_PUBLISHABLE_KEY`.
41
+ 4. Push the schema, so the space can serve the site (a build or the dev server would push it too):
42
+
43
+ ```bash
44
+ npx smoodly push
45
+ ```
46
+
47
+ 5. Import:
48
+
49
+ ```bash
50
+ npx smoodly import smoodly-export-2026-10-16
51
+ ```
52
+
53
+ The path is the export folder's, relative or absolute, so it can stay in the old site's folder. A live page reads what landed straight away: there is nothing to rebuild.
54
+
55
+ ```
56
+ Imported 14 pages, 32 entries, 51 assets and 9 redirects into space sp_….
57
+ Members in the source — add them to the space's members in the project at smoodly.io:
58
+ anna@example.com (admin), mikko@example.com (editor)
59
+ Restart or redeploy the app: it may have cached pages from while the space was empty.
60
+ ```
61
+
62
+ 6. Add the members to the new space at smoodly.io: nobody can sign in to its admin until its member list names them. Then restart or redeploy the app.
63
+
64
+ ## When import refuses
65
+
66
+ Import checks everything before it writes anything.
67
+
68
+ - **The space already has content.** Import writes only into an empty space, and it never deletes. Use a new project's space.
69
+ - **The space is being copied into.** A preview space refuses an import while a copy from production runs, and after one failed. Wait for the copy, or retry it at smoodly.io.
70
+ - **The service versions differ.** The export and the target space must come from the same version of Smoodly's service. The message says which side is newer.
71
+ - **It stopped midway**, on a dropped connection for example. Run the same command again: rows and files that already landed are skipped. A run that finds everything already there says `Nothing new` and writes no content.
72
+
73
+ Next, [Upgrading](../reference/upgrading.md).
@@ -0,0 +1,98 @@
1
+ # Your first page
2
+
3
+ Create a page in the admin, fill it with sections and publish it, as an editor would. Then look at the code that decided what the editor was allowed to do.
4
+
5
+ ## Create the page
6
+
7
+ This page assumes the Lorem Co. starter from [Install](../getting-started/install.md), running on your machine. The steps in the admin are the same for every framework.
8
+
9
+ 1. Open `/admin` on your running site and sign in.
10
+ 2. On *Pages*, click *+ New page*.
11
+ 3. Enter `services` as the slug, the last part of the page's URL.
12
+
13
+ Lorem Co. has one page type that editors can create, so the admin skips that question and opens the new page. On a site with several page types, it asks which one to use.
14
+
15
+ ## Edit it in the canvas
16
+
17
+ The editor shows the page as the site renders it (the canvas) on the left, and the fields of whatever you select (the inspector) on the right.
18
+
19
+ 1. Click the hero at the top. It is marked *locked*: editors fill its fields but cannot move or remove it.
20
+ 2. In the inspector, change *Heading* to `Services`. The canvas updates as you type, and *Saved* appears at the top. Every change saves as a draft on its own; nothing is live yet.
21
+ 3. Click *Add section* under the hero and pick *Points*. It arrives filled with sample content, so the page never shows an empty section.
22
+ 4. Change the section's *Heading* to `What we do`. The toolbar on the selected section moves it up or down, duplicates it or removes it.
23
+
24
+ The switch at the top right (next to the eye button) turns the canvas into the structure view: every section is a card with its title and a line of its content. Drag a card by its grip to reorder the page, click a card to edit its fields in the inspector, and switch back to see the page as the site renders it. As with every edit, the live site changes nothing until you publish.
25
+
26
+ Every change can be taken back: Cmd+Z (Ctrl+Z on Windows and Linux), or the left arrow at the top right, undoes the last edit step by step, and Shift+Cmd+Z or the right arrow brings it back. A burst of typing is one step.
27
+
28
+ To reuse a section on another page, pick *Copy* in the section's ⋯ menu (on its card in the structure view, or on its row in the Page Outline), and on the other page pick *Paste before* or *Paste after* in a card's or row's ⋯ menu, or *Paste* on a zone's heading in the structure view. The entry is greyed out when that page's zone does not allow the section.
29
+
30
+ ## Publish
31
+
32
+ Click *Publish* at the top right. The status next to it changes from *Draft* to *Published*, and the eye button (*View live*) opens the page at `/services` on your site.
33
+
34
+ A new page's title starts as its slug, so the browser tab says "services". To change it, go back to *Pages*, open the ⋯ menu at the end of the page's row and pick *Rename…*. The admin asks for the title and then the slug; enter `Services` and keep the slug. The title changes on the live page at once.
35
+
36
+ ## Why the editor could do that
37
+
38
+ Everything the editor was allowed to do comes from the page type's schema. It is the same file in every framework; in an Astro project it lives under `src/components/pages/`.
39
+
40
+ File: `components/pages/Page.schema.ts`
41
+
42
+ ```ts
43
+ export const pageSchema = smoodly.schema.page({
44
+ name: "page",
45
+ title: "Page",
46
+ zones: {
47
+ hero: z.locked(pageHeroSchema),
48
+ body: z.freeform({ sections: [imageTextSchema, pointsSchema, favouriteArticlesSchema, peopleSchema, quoteSchema], custom: true }),
49
+ },
50
+ // …
51
+ });
52
+ ```
53
+
54
+ The page type's view decides where each zone renders:
55
+
56
+ **Astro**
57
+
58
+ File: `src/page-types/page.astro`
59
+
60
+ ```astro
61
+ ---
62
+ import { Zone } from "smoodly/astro/components";
63
+ // …
64
+ ---
65
+ <Layout head={head} locale={page.locale} locales={page.locales}>
66
+ // …
67
+ <Zone of={zones.hero} />
68
+ <Zone of={zones.body} />
69
+ </Layout>
70
+ ```
71
+
72
+ The file's name pairs it with the schema named `page`.
73
+
74
+ **Next.js**
75
+
76
+ File: `components/pages/Page.tsx`
77
+
78
+ ```tsx
79
+ import { smoodly, Zone, type Props } from "smoodly/react";
80
+ // …
81
+ <Zone of={zones.hero} />
82
+ <Zone of={zones.body} />
83
+ // …
84
+ export const StandardPage = smoodly.page(pageSchema, PageView);
85
+ ```
86
+
87
+ `smoodly.page()` pairs it with the schema.
88
+
89
+ Each zone is a place on the page where editors work, and its policy sets how much they may change:
90
+
91
+ | Name | Description |
92
+ | --- | --- |
93
+ | `z.locked(PageHero)` | Exactly one page hero. Editors fill its fields but cannot add, move or remove it. |
94
+ | `z.freeform({ … })` | Any number of the listed sections, in any order. custom: true also offers the custom section, which editors lay out themselves. |
95
+
96
+ The form in the inspector came from the Points section's own definition: one field for the heading and a list of one to six points. Change a field there and the form changes with it.
97
+
98
+ Next, [Sections](../content-modeling/sections.md) shows how to write a section of your own, and [Zones and freedom](../content-modeling/zones.md) covers the three zone policies.