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,103 @@
1
+ # Language switcher and hreflang
2
+
3
+ Every page and entry knows its own address in every language. A language switcher links from that map, and the site's hreflang tags come from the same one.
4
+
5
+ ## The language map
6
+
7
+ A page type's view gets `page.locales`, and an entry page gets `locales`: one key per supported language. The value is the page's `slug`, `path` and `url` in that language, or `null` where the page does not exist or is not published there. In the editor's canvas an unpublished translation still counts; on the public site it never links.
8
+
9
+ A switcher links to the same page where it exists, and to that language's home otherwise. The blank starter does it in one component:
10
+
11
+ **Astro**
12
+
13
+ File: `src/components/LocaleNav.astro`
14
+
15
+ ```astro
16
+ ---
17
+ import { href } from "smoodly/astro/runtime";
18
+ // …
19
+ const codes = Object.keys(locales);
20
+ ---
21
+ {codes.length > 1 && (
22
+ <nav class="container locales" aria-label="Language">
23
+ {codes.map((code) => <a href={href(locales[code]?.path ?? "/", code)} aria-current={code === locale ? "page" : undefined}>{code}</a>)}
24
+ </nav>
25
+ )}
26
+ ```
27
+
28
+ `href` from `smoodly/astro/runtime` adds the language prefix the site uses. With one language the map has one key and the component renders nothing, which is why the single-language scaffold shares the file.
29
+
30
+ **Next.js**
31
+
32
+ File: `components/LocaleNav.tsx`
33
+
34
+ ```tsx
35
+ export function LocaleNav({ locale, locales }: { locale: string; locales: Record<string, LocaleAlternate | null> }) {
36
+ const codes = Object.keys(locales);
37
+ if (codes.length < 2) return null;
38
+ return (
39
+ <nav className="container locales" aria-label="Language">
40
+ {codes.map((code) => (
41
+ <a key={code} href={href(locales[code]?.path ?? "/", code)} aria-current={code === locale ? "page" : undefined}>{code}</a>
42
+ ))}
43
+ </nav>
44
+ );
45
+ }
46
+ ```
47
+
48
+ `href` from `smoodly/server.ts` adds the language prefix the site uses. With one language the map has one key and the component renders nothing, which is why the single-language scaffold shares the file.
49
+
50
+ ## Why the page decides it
51
+
52
+ The links must land on the same page in the other language, so they come from the page on screen.
53
+
54
+ **Astro**
55
+
56
+ Every page view passes its own `locales` map to the layout, and the layout renders the header with the links. Astro renders the page before its layout, so the layout knows which page is on screen. [Header and footer](../globals/header-and-footer.md) has the file.
57
+
58
+ **Next.js**
59
+
60
+ The switcher lives with the page, not in the layout, because a layout cannot know which page is on screen and the links must land on the same page in the other language. The Lorem Co. starter renders its header, links included, from every page type through `PageFrame`:
61
+
62
+ File: `components/PageFrame.tsx`
63
+
64
+ ```tsx
65
+ const header = await q().getGlobal("header", { locale });
66
+ const codes = Object.keys(locales);
67
+ const links: LocaleLink[] | undefined = codes.length > 1
68
+ ? codes.map((code) => ({ code, url: href(locales[code]?.path ?? "/", code), current: code === locale }))
69
+ : undefined;
70
+ ```
71
+
72
+ [Header and footer](../globals/header-and-footer.md) has the whole file.
73
+
74
+ ## hreflang
75
+
76
+ **Astro**
77
+
78
+ `Head` from `smoodly/astro/components` writes one hreflang link per language where the page is published, from the same map the switcher reads. A single-language site gets none, only the page's canonical link. The links are absolute on `site` in `astro.config.ts`, which the starter reads from `SITE_URL`: set it to the site's real address, as [SEO](../pages/seo.md) explains.
79
+
80
+ File: `src/layouts/Layout.astro`
81
+
82
+ ```astro
83
+ <Head of={head} fallback="Lorem Co." />
84
+ ```
85
+
86
+ The visitor picks a language through the switcher; nothing redirects by browser language.
87
+
88
+ **Next.js**
89
+
90
+ `smoodlyMetadata` in the route files emits `alternates.languages` for every page and entry, from the same map the switcher reads: only the languages where the page has a URL, each through `href`. A single-language site emits none, only the page's canonical link. The links are absolute on the host in `SITE_URL`, which the installer sets to `http://localhost:3000`: set it to the site's real address, as [SEO](../pages/seo.md) explains.
91
+
92
+ next-intl could derive hreflang and redirect visitors by browser language on its own, and the scaffold turns both off:
93
+
94
+ File: `i18n/routing.ts`
95
+
96
+ ```ts
97
+ alternateLinks: false,
98
+ localeDetection: false,
99
+ ```
100
+
101
+ Both assume a page has the same slug in every language. Once slugs translate, a derived hreflang points at a URL that does not exist, and a detection redirect keeps the English segments under `/fi/` and lands on a 404. So the visitor picks a language through the switcher, and the tags come from the content.
102
+
103
+ Next, [Frameworks](../frameworks/astro.md).
@@ -0,0 +1,119 @@
1
+ # Locales and routing
2
+
3
+ A site publishes in one language or several. This page covers declaring the languages, how each gets its own URLs, and what editors see when a page exists in some languages and not others.
4
+
5
+ ## Declaring the languages
6
+
7
+ The config names the default language and the supported set. A single-language site declares one:
8
+
9
+ File: `smoodly/config.ts`
10
+
11
+ ```ts
12
+ locales: { default: "en", supported: ["en"] },
13
+ ```
14
+
15
+ **Astro**
16
+
17
+ A multilingual site, which the installer scaffolds when you answer yes to *multilingual*, names the set twice: in `i18n` in `astro.config.ts`, for Astro's routing, and in the config. The installer writes both, and the site refuses to start when they disagree:
18
+
19
+ File: `astro.config.ts`
20
+
21
+ ```ts
22
+ i18n: { locales: ["en", "fi"], defaultLocale: "en" },
23
+ ```
24
+
25
+ File: `src/smoodly/config.ts`
26
+
27
+ ```ts
28
+ locales: { default: "en", supported: ["en", "fi"] },
29
+ ```
30
+
31
+ **Next.js**
32
+
33
+ A multilingual site, which the installer scaffolds when you answer yes to *multilingual*, keeps the set in one place for [next-intl](https://next-intl.dev) and imports it into the config:
34
+
35
+ File: `i18n/routing.ts`
36
+
37
+ ```ts
38
+ export const routing = defineRouting({
39
+ locales: ["en", "fi"],
40
+ defaultLocale: "en",
41
+ localePrefix: "as-needed",
42
+ alternateLinks: false,
43
+ localeDetection: false,
44
+ });
45
+ ```
46
+
47
+ File: `smoodly/config.ts`
48
+
49
+ ```ts
50
+ locales: { default: routing.defaultLocale, supported: [...routing.locales] },
51
+ ```
52
+
53
+ The config cannot go the other way round: the proxy that routes languages runs in the edge runtime and cannot load the registry.
54
+
55
+ A locale code is a plain string such as `en`, `fi`, `en-US` or `zh-Hant`. Adding a language later is a config change; every fixed page and global gets a row in it on the next admin load.
56
+
57
+ ## One URL per language
58
+
59
+ Every page and entry has its own slug in each language it exists in, so URLs translate whole: `/company/about-us` in English can be `/fi/yritys/tietoa` in Finnish. Fixed pages and collection paths take a per-language map in code; the starter's Articles index is `posts` in English and `artikkelit` in Finnish.
60
+
61
+ **Astro**
62
+
63
+ The language prefix is Astro's: the default language has none, unless `routing: { prefixDefaultLocale: true }` in `i18n` says so, and the others get theirs. Smoodly follows the same rule. `href` from `smoodly/astro/runtime` turns a language-relative path into a site URL, and everything that hands out a URL goes through it:
64
+
65
+ File: `src/layouts/Layout.astro`
66
+
67
+ ```astro
68
+ ---
69
+ // …
70
+ ? codes.map((code) => ({ code, url: href(locales[code]?.path ?? "/", code), current: code === locale }))
71
+ // …
72
+ ---
73
+ ```
74
+
75
+ On a single-language site it returns the path unchanged. The admin sits outside the language prefix. A site that prefixes the default language says so in the config too, with `routing: { prefixDefaultLocale: true }`, so the links Smoodly builds for editors carry the prefix; the site refuses to start when the two disagree.
76
+
77
+ **Next.js**
78
+
79
+ The language prefix is next-intl's: with `as-needed`, the default language has none and the others get theirs. Smoodly never adds a prefix itself. One function in `smoodly/server.ts` turns a language-relative path into a site URL, and everything that hands out a URL goes through it:
80
+
81
+ File: `smoodly/server.ts`
82
+
83
+ ```ts
84
+ export const href = (path: string, locale: string) => getPathname({ locale, href: path });
85
+ ```
86
+
87
+ On a single-language site the same function returns the path unchanged. The proxy skips `/admin`, which sits outside the language segment and has no prefix. A site whose `localePrefix` is `always` says so in the config too, with `routing: { prefixDefaultLocale: true }`, so the links Smoodly builds for editors carry the prefix.
88
+
89
+ ## Pages that exist in some languages
90
+
91
+ A page or an entry exists in the languages it has been added to, and nothing falls back: a page that exists only in Finnish is a 404 in English. The pages list and every collection list have a language picker, and a page missing from the selected language shows dimmed, with *Add en* in its menu.
92
+
93
+ Adding a language copies the content on screen as the first draft in the new language, for the editor to translate. A page can be added to a language only when its parent exists there, and removed from one only while no child page has it. Fixed pages, collection mounts and globals exist in every language from the start and never lose one.
94
+
95
+ Publishing is per language. A page published in English and edited in Finnish is *Published* in one and *Unpublished changes* in the other, and each language has its own history.
96
+
97
+ ## One order, or one per language
98
+
99
+ Editors drag pages into an order in the page tree, and entries in a collection ordered `"manual"`. By default that order is shared: a drag made in any language reorders every language.
100
+
101
+ Add `order: "perLocale"` to `locales` in the config, next to `default` and `supported`, and each language keeps its own. A drag then reorders only the language selected in the list, and the page tree, a page's `children` and a manual collection's queries return the order of the language they are read in. Until an editor drags, every language has the order it had before.
102
+
103
+ Only the order differs. A page still sits under the same parent in every language; moved under a new parent, it lands where it was dropped in the selected language and last in the others. A page added to a language starts where the language it was copied from has it.
104
+
105
+ Setting `order` back to `"shared"` (or removing it) keeps each language's order as it is until the next drag among the same siblings, which every language then takes.
106
+
107
+ ## The site's own words
108
+
109
+ Labels that are not content, such as *All articles* or *min read*, are yours to translate.
110
+
111
+ **Astro**
112
+
113
+ The starter keeps them in `src/messages/<locale>.json` and reads them through `src/strings.ts`, with no library behind it. A language without its own file shows the English words.
114
+
115
+ **Next.js**
116
+
117
+ The multilingual starter keeps them in `messages/<locale>.json` and reads them through next-intl; the single-language starter ships the English map with no library behind it.
118
+
119
+ Next, [Localized fields](../languages/localized-fields.md).
@@ -0,0 +1,61 @@
1
+ # Localized fields
2
+
3
+ On an entry or a global, a field is shared across languages unless it chains .localized(). This page covers the rule, where it applies and what it means for queries.
4
+
5
+ ## The rule
6
+
7
+ `.localized()` gives a field its own value per language. Without it, one value serves every language:
8
+
9
+ File: `smoodly/collections.ts`
10
+
11
+ ```ts
12
+ export const people = smoodly.collection({
13
+ name: "people",
14
+ title: "People",
15
+ titleField: "name",
16
+ order: "manual",
17
+ fields: {
18
+ name: f.text(),
19
+ role: f.text().localized(),
20
+ },
21
+ });
22
+ ```
23
+
24
+ A person's name is the same in Finnish and English; the role is translated. Shared is the default because most of an entry is: the photo, the date, the author, the price. The starter's articles localize the title, the excerpt, the body and the tags, and share the image, the date and the author.
25
+
26
+ A global follows the same rule. The contact details share the email and localize the address; the header shares the brand and localizes the navigation.
27
+
28
+ ## Where it applies
29
+
30
+ | Place | Effect of .localized() |
31
+ | --- | --- |
32
+ | `Collection and global fields` | Decides storage: the field gets a value per language. |
33
+ | `slug` | Always per language, with or without .localized(): each language has its own URL. |
34
+ | `Lists and objects` | Localizes the whole value. Inside an item or a subfield it is a type error; localize the top-level field that holds it, for a list of objects the list. |
35
+ | `Settings` | Never. A layout choice is the same in every language, and .localized() on a setting throws. |
36
+ | `Page fields` | Not needed: page fields are stored per language already. |
37
+ | `Section fields on a page` | No effect on storage. A page's content is per language as a whole. |
38
+
39
+ A page's whole tree is per language: adding a language copies the tree, and from then on the two are edited apart. That is why a page's sections need no `.localized()`, and why a change made in English is not mirrored in Finnish.
40
+
41
+ ## In the form
42
+
43
+ On a multilingual site the entry and Globals forms mark every shared field with a globe-off icon, so an editor translating knows which values will change in every language at once. Adding a language copies every field as the first draft, shared and localized alike; the shared ones then stay in step by themselves.
44
+
45
+ ## Changing it later
46
+
47
+ Adding or removing `.localized()` on a field that already holds content moves nothing in storage:
48
+
49
+ - **Turning it on**: the value saved while the field was shared stays as every language's fallback. Each language shows it until an editor saves that language, which gives the language its own value. Until then, the form's notice says the value is still shared.
50
+ - **Turning it off**: each language keeps showing its own value until it is saved. The first save in a language makes that language's value the one every language shows, and the form warns about it before the save.
51
+
52
+ [When the schema changes](../content-modeling/changing-schemas.md) covers the other changes and the *Content health* page that lists them.
53
+
54
+ ## In code
55
+
56
+ Reads merge the shared and the localized values for the language asked, so a view sees one flat set of fields. Two things follow from the split:
57
+
58
+ - A `where` filter in a [query](../collections/queries.md) matches shared fields only. Filtering on a localized field or on the slug is refused, because those live per language.
59
+ - A collection's `order.by` must be a shared field, because the order is one list for every language.
60
+
61
+ Next, [Language switcher and hreflang](../languages/language-switcher.md).
@@ -0,0 +1,131 @@
1
+ # Images and crops
2
+
3
+ An image field stores which asset to show and how to frame it. Smoodly's media host cuts the framed area at the width the layout needs, in a modern format, and the image component renders it without layout shift.
4
+
5
+ ## Aspect ratios
6
+
7
+ An image field declares the ratio of the box it renders into, or several:
8
+
9
+ File: `components/sections/Hero.schema.ts`
10
+
11
+ ```ts
12
+ image: f.image({ aspect: "4:5" }),
13
+ ```
14
+
15
+ The crop tool locks to the declared ratio, and with several, `["3:2", "1:1"]`, a switch lets the editor pick one per use. Without an aspect the crop is free and the box takes the crop's own ratio. Declare one where the layout fixes the shape, such as a card or a hero, so the box is known before the image loads. A crop counts only at the ratio the code declares now: change a field's ratio, or a named crop's, and the crops saved at the old one give way to the focus point at the new ratio until an editor frames them again.
16
+
17
+ ## Named crops
18
+
19
+ Where the layout needs a different shape per screen, name a crop for each instead of one aspect:
20
+
21
+ File: `components/sections/PageHero.schema.ts`
22
+
23
+ ```ts
24
+ image: f.image({ crops: { wide: "21:9", tall: "4:5" } }).optional(),
25
+ ```
26
+
27
+ The editor gets a tab per crop in the crop tool, all sharing one focus point. A crop nobody framed follows the focus point at its ratio, so most images need no second look; a framed one keeps its rectangle. The first crop is the main one: the resolved value's `crop` and `aspect` are its, and `crops` holds every one by name.
28
+
29
+ The view says where each crop applies, with a media query per name. Breakpoints live here, in code, never in content:
30
+
31
+ **Astro**
32
+
33
+ File: `src/sections/pageHero.astro`
34
+
35
+ ```astro
36
+ {image && <Image image={image} media={{ tall: "(max-width: 767px)" }} class="page-hero__image" sizes="100vw" priority {...m.field("image")} />}
37
+ ```
38
+
39
+ **Next.js**
40
+
41
+ File: `components/sections/PageHero.tsx`
42
+
43
+ ```tsx
44
+ {image && <SmoodlyImage image={image} media={{ tall: "(max-width: 767px)" }} className="page-hero__image" sizes="100vw" priority {...m.field("image")} />}
45
+ ```
46
+
47
+ The component renders a `<picture>` with one `<source>` per named crop in `media`, in the order given, and the main crop as the `<img>`; each has its own widths and its own `width` and `height`, so nothing shifts on either screen.
48
+
49
+ ## The crop tool
50
+
51
+ *Edit* on a filled image field opens the tool: the full image with a draggable crop rectangle, a draggable focus point, an alt text box for this use and a live preview at the chosen ratio.
52
+
53
+ - The **crop** picks the part of the image to show.
54
+ - The **focus point** decides what a crop nobody framed shows, and what stays visible when a view places the image in a box of another shape.
55
+ - The **alt** for this use overrides the asset's own alt from the library when set.
56
+
57
+ The value the field stores is the asset's id plus this framing. The same photo can be cropped differently in a card and in a hero, and a change in one place leaves the other alone.
58
+
59
+ ## Rendering
60
+
61
+ The image component renders a box at the crop's ratio and a plain `<img>`, with the dimensions set so nothing shifts as it loads:
62
+
63
+ **Astro**
64
+
65
+ File: `src/entry-pages/articles.astro`
66
+
67
+ ```astro
68
+ {a.image && <Image image={a.image} aspect="21:9" class="article__image" sizes="(min-width: 900px) 800px, 100vw" priority />}
69
+ ```
70
+
71
+ **Next.js**
72
+
73
+ File: `components/pages/Article.tsx`
74
+
75
+ ```tsx
76
+ {a.image && <SmoodlyImage image={a.image} aspect="21:9" className="article__image" sizes="(min-width: 900px) 800px, 100vw" priority />}
77
+ ```
78
+
79
+ Here an article's photo, cropped at 3:2 for its card, renders at 21:9 on the article page: the `aspect` prop overrides the ratio for this placement and the focus point keeps the subject in view. [Image, video and file](../fields/media.md) lists the other props.
80
+
81
+ The bytes come from Smoodly's media host, `media.smoodly.io`. It cuts each image to the area its stored value gives (the editor's crop, or the ratio around the focus point, or the whole image) and serves the cut at one width from a fixed list (320, 640, 750, 828, 1080, 1280, 1536, 1920, 2560 and the cut's own width; from 640 to 1080 the steps `next/image` uses), at quality 75 unless you choose another, in AVIF where the browser takes it and WebP otherwise. The `srcset` lists those widths and `sizes` tells the browser which one the layout needs, so a visitor downloads only the cropped area at about the width it is shown. The page opens a connection to the media host while its HTML is parsed. There is no image config in your project and no image optimizer on your host: neither `next/image` nor Astro's `/_image` is involved. An SVG is served as uploaded.
82
+
83
+ A static Astro build writes every image its pages show into its own output, under `/_smoodly/media/`: each listed width in AVIF and WebP, offered by type in a `<picture>`, since a static host cannot choose a format per browser. The pages then need nothing from Smoodly at run time and their images come from your own host; a publish reaches them with the next build. Videos and files keep their media address. In local development the service serves the originals and the component crops them in CSS.
84
+
85
+ ## Quality and width
86
+
87
+ Every image is served at quality 75 with a `srcset` up to the cut's own width unless you say otherwise, in three places; the nearest wins. The whole site, in its config:
88
+
89
+ File: `smoodly/config.ts`
90
+
91
+ ```ts
92
+ import { defineConfig } from "smoodly";
93
+
94
+ export const config = defineConfig({
95
+ registry: {},
96
+ locales: { default: "en", supported: ["en"] },
97
+ media: { quality: 85, maxWidth: 1920 },
98
+ });
99
+ ```
100
+
101
+ A kind of image, on its field:
102
+
103
+ File: `components/sections/Gallery.schema.ts`
104
+
105
+ ```ts
106
+ import { f } from "smoodly";
107
+
108
+ export const photos = f.list(f.image({ quality: 95 }));
109
+ ```
110
+
111
+ One placement, with the same two props on Astro's `Image` and Next's `SmoodlyImage`:
112
+
113
+ File: `components/Card.tsx`
114
+
115
+ ```tsx
116
+ import { SmoodlyImage, type ResolvedImage } from "smoodly/image";
117
+
118
+ export function CardImage({ image }: { image: ResolvedImage }) {
119
+ return <SmoodlyImage image={image} quality={60} maxWidth={828} sizes="(min-width: 900px) 25vw, 50vw" />;
120
+ }
121
+ ```
122
+
123
+ `quality` is one of 50, 60, 75, 85, 95 or 100; any other number is refused when the config loads or the page renders, since each quality is a separate image the media host makes and keeps. 50 and 60 suit thumbnails and grids, 75 matches what `next/image` serves, 85 and above suit photography. 100 is heavy: a wide image at 100 weighs several times the same one at 85 for a difference few eyes see.
124
+
125
+ `maxWidth` caps the `srcset` at the listed widths under it and adds no width: `maxWidth: 1000` lists 320, 640, 750 and 828. Use it where the layout never shows an image wide, so a sharp screen does not fetch more than the layout needs. Editors see neither setting. In local development the originals are served, so both apply only on the media host.
126
+
127
+ ## Images inside richtext
128
+
129
+ A body image, inserted from a toolset with `image` in its nodes, stores the asset, an alt and a caption, and no crop: it renders at its natural ratio through `RichText`, with the text column's `sizes` and the project's `media.quality` and `media.maxWidth`. [Richtext and elements](../fields/richtext.md) covers the toolset.
130
+
131
+ Next, [Languages](../languages/locales.md).
@@ -0,0 +1,40 @@
1
+ # The media library
2
+
3
+ Every image, video and file editors upload lives in one library. A field stores a reference to an asset, so one photo can appear in many places and be found from each of them.
4
+
5
+ ## Uploading
6
+
7
+ Editors upload from two places. A media field takes a dropped file straight away, or opens the library to pick one. *Media* in the top bar shows the whole library, with an upload button and a filter for images, video and files.
8
+
9
+ The browser measures an image's dimensions on upload, and captures a frame of a video as its poster, stored as an image asset of its own. What the site can accept is config:
10
+
11
+ | Name | Description |
12
+ | --- | --- |
13
+ | `media.maxSize` | The largest file, in bytes. Default 25 MB. |
14
+ | `media.accept` | The mime types offered in the file dialog and accepted on upload. Default: JPEG, PNG, WebP, AVIF, GIF, SVG, MP4, WebM and PDF. |
15
+
16
+ A field narrows the list to its kind: an image field offers image types only.
17
+
18
+ ## The library
19
+
20
+ Selecting an asset shows its details: the filename, its size and dimensions, and four editable texts:
21
+
22
+ | Name | Description |
23
+ | --- | --- |
24
+ | `Title` | A name for the asset in the library, searched with the filename. |
25
+ | `Alt` | The alt text an image carries wherever it is used, unless a field sets its own. |
26
+ | `Caption, Credit` | Kept with the asset for your code to render. |
27
+
28
+ Below them, *Used in N places* lists every page, entry and global that references the asset, from the same index that tracks references between entries.
29
+
30
+ ## Deleting and replacing
31
+
32
+ An asset can be deleted only while nothing uses it; the message says how many places still do. Removing the field's value, or picking another asset, clears the use.
33
+
34
+ Uploaded objects never change. *Replace* on a field picks a different asset for that field; the library keeps the old one until it is deleted. This is what lets the site cache media for a year: a URL always serves the same bytes.
35
+
36
+ ## Where files live
37
+
38
+ Uploads go to Smoodly's storage, served with a year-long cache header; until media moves fully to Smoodly the files live in a bucket Smoodly runs. The browser uploads directly to storage with a signed ticket from the admin, so a large file never passes through your app.
39
+
40
+ Next, [Images and crops](../media/images-and-crops.md).
@@ -0,0 +1,44 @@
1
+ # Drafts and publishing
2
+
3
+ Every change saves as a draft on its own. Publish makes the draft live, after Smoodly checks it. This page covers the three states, what publish checks, and the two ways back.
4
+
5
+ ## Autosave
6
+
7
+ There is no Save button. Each change in the editor saves the draft after a moment, and the indicator in the top bar says *Saved* when it has. Nothing reaches the site until an editor publishes; a draft can stay unpublished for as long as it likes. Entries and globals autosave the same way.
8
+
9
+ Saving is lenient: a draft may be incomplete, with required fields still empty.
10
+
11
+ ## The three states
12
+
13
+ The badge next to *Publish* shows the page's state in the selected language:
14
+
15
+ | State | Meaning |
16
+ | --- | --- |
17
+ | `Draft` | Nothing is live. The page has never been published in this language. |
18
+ | `Published` | The live page matches the draft. |
19
+ | `Unpublished changes` | The page is live, and the draft has moved on. This is the state that asks for a click. |
20
+
21
+ Publishing is per language. A page published in English and edited in Finnish shows *Unpublished changes* in Finnish only.
22
+
23
+ ## What publish checks
24
+
25
+ *Publish* validates the whole draft first. A required field left empty, a list past its `.max()`, a choice outside its options or a refused CSS snippet stops the publish, and the editor lists the fields to fix. Hidden fields are skipped.
26
+
27
+ Publish also checks references. A page cannot go live in a language while it shows an entry or a global that is unpublished, or does not exist, in that language, and an entry or a global cannot go live while a reference field of its points at such an entry. The message names the offenders; publish those first.
28
+
29
+ On a passing publish the live page updates at once, and so do the pages that list it.
30
+
31
+ ## Two ways back
32
+
33
+ The ⋯ menu next to *Publish* offers two more actions, disabled when they do not apply:
34
+
35
+ - **Discard changes** throws the draft away and restores the published version, so the page shows *Published* again.
36
+ - **Unpublish** takes the page off the site. Its URL returns a 404 until it is published again. A page whose child is still published in that language cannot be unpublished, so that no page serves under a parent the site no longer shows: unpublish the children first. The home page and every fixed page stay published.
37
+
38
+ *History*, the first item in the same menu, is on the [next page](../pages/version-history.md).
39
+
40
+ ## Entries and globals
41
+
42
+ The entry form and a global's form carry the same badge, the same *Publish* and the same menu, and follow the same rules. Publishing a page never publishes a global it shows: a footer going live on every page is a click of its own, in the global's card in the canvas or on its own page under *Site*.
43
+
44
+ Next, [Version history](../pages/version-history.md).
@@ -0,0 +1,51 @@
1
+ # The page tree
2
+
3
+ How editors create, arrange and remove pages in the admin, and what your configuration allows them to do.
4
+
5
+ ## The list
6
+
7
+ *Pages* shows every page as a tree: a row per page, indented under its parent, with its title, its URL, its page type and its status. A multilingual site has a language picker at the top; the tree's shape is shared across languages, and a page that does not exist in the selected language is dimmed. [Languages](../languages/locales.md) covers that side.
8
+
9
+ Clicking a page opens the editor. Clicking a path expands it.
10
+
11
+ ## Creating pages
12
+
13
+ *+ New page* asks for a slug, then for the page type when more than one open type is registered, and opens the new page in the editor. Its title starts as the slug until an editor renames it.
14
+
15
+ *+ New path* creates a path: an item that groups pages under a shared part of their URL and has no page of its own. A row's ⋯ menu offers *New subpage* and *New subpath* under that row, and *Add page here* turns a path into a page by giving it a page type.
16
+
17
+ How deep the tree may go is config. Without a setting, pages sit side by side at the root. The starter allows three levels:
18
+
19
+ File: `smoodly/config.ts`
20
+
21
+ ```ts
22
+ export const config = defineConfig({
23
+ // …
24
+ pages: { tree: { depth: 3 } },
25
+ // …
26
+ });
27
+ ```
28
+
29
+ The home page never has children, so a site's pages start at the root beside it.
30
+
31
+ ## Moving pages
32
+
33
+ Editors drag a row by its grip to reorder it among its siblings, or to move it under another parent. The page's URL follows, because a URL is derived from the page's place in the tree. On a multilingual site a page moves only under a parent that exists in every language the page exists in, and the order is the same in every language unless the config sets [`locales.order`](../languages/locales.md#one-order-or-one-per-language) to `"perLocale"`.
34
+
35
+ ## Pages you own
36
+
37
+ Two kinds of rows are code-owned and cannot be renamed, moved or deleted: fixed pages, from page types with a `slug`, and collection mounts, from collections with a `path`. Smoodly creates both on the first admin load, so they are there before an editor arrives.
38
+
39
+ A mount is the collection's place in the tree. When a fixed page claims the same slug, as the starter's Articles index does, the mount row is that page and opens its editor. Without one, the row opens the collection's list.
40
+
41
+ Code-owned rows always sit at the root, even when their URL has several parts: the starter's News row shows `/company/news` next to the editors' *Company* page. A row marked *No longer in code* is a page whose registration was removed; delete it. A notice under a page's title means visitors get your code's page at its address instead; rename or move it. [Paths and slugs](../pages/paths-and-slugs.md#urls-your-code-owns) covers both.
42
+
43
+ ## Renaming, duplicating and deleting
44
+
45
+ *Rename…* in the ⋯ menu asks for the title and then the slug, in the selected language. The home page takes a title only; its slug is the config's `homePageSlug`.
46
+
47
+ *Duplicate…* asks for a title and a slug and makes a new draft page beside the original, with a copy of its sections, page fields and SEO in the selected language. It appears on pages whose page type editors can create; a code-owned page has one address and cannot be duplicated.
48
+
49
+ *Delete* removes the page in every language, after a confirm. It appears only on a page with no children: move or delete the children first.
50
+
51
+ Next, [Paths and slugs](../pages/paths-and-slugs.md) explains what makes a valid URL.
@@ -0,0 +1,74 @@
1
+ # Paths and slugs
2
+
3
+ A page's URL is derived from its place in the page tree: each item contributes one slug. This page covers what a slug may be, which URLs your code reserves, how a URL changes and how old addresses redirect.
4
+
5
+ ## Three words
6
+
7
+ - The **URL** is a page's full address within the site, `/company/about-us`. The pages list shows it in the *URL* column.
8
+ - A **path** is an item in the tree that groups pages under a shared part of their URL, like `company`. It has no page of its own, so `/company` is a 404 until an editor gives it a page type with *Add page here*.
9
+ - The **slug** is one item's own part, `about-us`. Editors set it when they create a page and change it with *Rename…*.
10
+
11
+ In code, parameters named `path` such as the one `renderSmoodlyPath()` takes hold the URL path, and the `page` prop offers both `page.path` and `page.url`. [Page types](../content-modeling/page-types.md) explains the difference.
12
+
13
+ ## What a slug may be
14
+
15
+ Lowercase letters, digits and single hyphens: `about-us`, `2026-report`. Anything else is refused with a message, in the admin and in code. Two siblings cannot share a slug in the same language, and the admin says so when a second one is tried. The admin makes the slug from the title: `ä` becomes `a`, `ø` becomes `o` and `ß` becomes `ss`. A title in a script without Latin letters, such as `Новости`, makes no slug, and the admin asks you to type one.
16
+
17
+ A slug is per language. On a multilingual site each language has its own, so `/company/about-us` and `/yritys/tietoa` can be the same page. [Locales and routing](../languages/locales.md) covers translated URLs.
18
+
19
+ ## When a URL changes
20
+
21
+ A URL is never stored; it is derived from the slugs of the page and its ancestors. Renaming a slug or moving a page changes the URL of the page and of everything below it, at once and everywhere the site links to it through Smoodly.
22
+
23
+ When the page is published, the admin asks before the change and offers **Redirect the old address**, ticked by default. Leave it ticked and every old address that was live, the page's and its published children's, keeps working: a visitor or a search engine who opens it is sent to the new address with a permanent redirect, so search ranking moves with the page. Untick it and the old addresses stop working. A draft's rename asks nothing and leaves nothing behind.
24
+
25
+ A redirect points at the page, not at its new address. Rename the page again and both old addresses go straight to the current one. Rename it back and the old address is simply live again. Create a new page at an address that redirects, and the new page wins: the redirect is replaced without a question.
26
+
27
+ The page's *Settings* tab lists the addresses that redirect to it under **Redirects from**.
28
+
29
+ ## Redirects
30
+
31
+ *Settings › Redirects* lists every redirect in the current language, with the address it sends visitors to, and lets any member remove one. **Add redirect** takes an old address by hand, for example the URLs of a site you replaced with Smoodly, and the page or entry it should go to. The target is always Smoodly content, picked from a list.
32
+
33
+ The old address is a site path in the current language, `/old-products/widget`. It needs a leading slash and no trailing slash, query string or hash. Its parts do not have to be slugs: capitals, dots and underscores are fine, because old sites had them, and matching is exact. Letters like ä and ö work too, typed or pasted from a browser's address bar as `/s%C3%A4%C3%A4`: both are the same address, and the list shows it the way you read it. The admin refuses an address a live page already owns, the admin's own address, and on a multilingual site an address with a file extension, because the language proxy never hands those to Smoodly.
34
+
35
+ Two things to know:
36
+
37
+ - **Browsers remember a permanent redirect.** After you remove one, a visitor who followed it before may keep landing on the new address until their browser forgets it. Search engines pick up the removal on their next crawl.
38
+ - **Destinations outside Smoodly** and addresses with a file extension or a query string belong in your app's own redirects, which run before any read of Smoodly:
39
+
40
+ ```ts
41
+ // next.config.ts
42
+ export default {
43
+ async redirects() {
44
+ return [
45
+ { source: "/about.html", destination: "/about", permanent: true },
46
+ { source: "/shop/:path*", destination: "https://shop.example.com/:path*", permanent: true },
47
+ ];
48
+ },
49
+ };
50
+ ```
51
+
52
+ A collection's `path` or a fixed page's slug that changes in code leaves no redirect either. The URLs move to the new address when the deploy goes live; add a line like the one above for the old ones.
53
+
54
+ ## URLs your code owns
55
+
56
+ Some segments are decided in code and checked when the config loads:
57
+
58
+ | Name | Description |
59
+ | --- | --- |
60
+ | `A fixed page's slug` | smoodly.schema.page({ slug }): one page at that URL, in every language. Editors cannot rename it. It may have several parts, like about/team. |
61
+ | `A collection's path` | smoodly.collection({ path }): its entries live under it, /posts/<slug>. Pages cannot be created under it. It may have several parts, like company/news. |
62
+ | `homePageSlug` | defineConfig({ homePageSlug }), default home: the page with that slug is served at /. |
63
+ | `The admin's path` | defineConfig({ admin: { path } }), default /admin: reserved in every language, so no page can take it. |
64
+
65
+ Two of these claiming the same address is an error at startup, not a surprise in production.
66
+
67
+ Your code owns each of these addresses whole. An editor cannot create, rename or move a page onto one or below one, except as a subpage of the fixed page itself; the admin refuses and names the owner: `"/company/news" belongs to the News collection.` The parts in between are not code's. An editor's `/company` page is an ordinary page, and when it is published its rename confirm says that `/company/news` stays where it is.
68
+
69
+ Nothing an editor made is ever taken over:
70
+
71
+ - **An editor's page already on the address** stays in the pages list, but visitors get the code's page there, as an `app/` route folder would beat it. The list shows a notice on it: rename or move it to give it a URL of its own. Its sections are never moved into the code's page.
72
+ - **A registration removed from code** leaves its page behind, marked *No longer in code* in the pages list. It has no URL any more and can only be deleted.
73
+
74
+ Next, [Drafts and publishing](../pages/drafts-and-publishing.md).