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