smoodly 0.0.59 → 0.0.61
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/admin/files.generated.js +1 -1
- package/dist/admin/ui/theme.js +1 -1
- package/dist/api-client.js +5 -3
- package/dist/astro/index.js +3 -3
- package/dist/cli/push.js +2 -3
- package/dist/env.js +3 -2
- package/dist/push.d.ts +7 -0
- package/dist/push.js +8 -0
- package/dist/space-kind.d.ts +2 -0
- package/dist/space-kind.js +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/collections/defining.md +57 -0
- package/docs/collections/entry-pages.md +168 -0
- package/docs/collections/ordering.md +47 -0
- package/docs/collections/queries.md +173 -0
- package/docs/content-modeling/changing-schemas.md +79 -0
- package/docs/content-modeling/custom-section.md +94 -0
- package/docs/content-modeling/marks.md +97 -0
- package/docs/content-modeling/overview.md +55 -0
- package/docs/content-modeling/page-types.md +237 -0
- package/docs/content-modeling/sections.md +264 -0
- package/docs/content-modeling/styles-and-settings.md +184 -0
- package/docs/content-modeling/zones.md +80 -0
- package/docs/fields/choices.md +42 -0
- package/docs/fields/conditional.md +72 -0
- package/docs/fields/link.md +31 -0
- package/docs/fields/list-and-object.md +58 -0
- package/docs/fields/media.md +103 -0
- package/docs/fields/overview.md +56 -0
- package/docs/fields/reference-and-tags.md +62 -0
- package/docs/fields/richtext.md +168 -0
- package/docs/fields/text.md +152 -0
- package/docs/frameworks/astro.md +110 -0
- package/docs/frameworks/nextjs.md +147 -0
- package/docs/getting-started/content-from-code.md +263 -0
- package/docs/getting-started/deploying.md +131 -0
- package/docs/getting-started/export-and-import.md +73 -0
- package/docs/getting-started/first-page.md +98 -0
- package/docs/getting-started/install.md +108 -0
- package/docs/getting-started/keys.md +40 -0
- package/docs/getting-started/project-structure.md +142 -0
- package/docs/getting-started/working-together.md +42 -0
- package/docs/globals/defining.md +99 -0
- package/docs/globals/header-and-footer.md +117 -0
- package/docs/index.md +83 -0
- package/docs/languages/language-switcher.md +103 -0
- package/docs/languages/locales.md +119 -0
- package/docs/languages/localized-fields.md +61 -0
- package/docs/media/images-and-crops.md +131 -0
- package/docs/media/library.md +40 -0
- package/docs/pages/drafts-and-publishing.md +44 -0
- package/docs/pages/page-tree.md +51 -0
- package/docs/pages/paths-and-slugs.md +74 -0
- package/docs/pages/seo.md +126 -0
- package/docs/pages/version-history.md +23 -0
- package/docs/reference/upgrading.md +312 -0
- package/package.json +4 -3
|
@@ -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 rail 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).
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# SEO and social
|
|
2
|
+
|
|
3
|
+
A page type can give its pages an SEO & Social tab: a title, a description, a social image and a switch to hide the page from search engines. Smoodly turns them into the page's metadata.
|
|
4
|
+
|
|
5
|
+
## Turning it on
|
|
6
|
+
|
|
7
|
+
Add `seo: true` to a page type. Every page type in the starter has it:
|
|
8
|
+
|
|
9
|
+
File: `components/pages/Page.schema.ts`
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
export const pageSchema = smoodly.schema.page({
|
|
13
|
+
name: "page",
|
|
14
|
+
title: "Page",
|
|
15
|
+
// …
|
|
16
|
+
seo: true,
|
|
17
|
+
// …
|
|
18
|
+
});
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The tab appears in the inspector when nothing on the canvas is selected, before *Settings*.
|
|
22
|
+
|
|
23
|
+
## The four fields
|
|
24
|
+
|
|
25
|
+
| Field | Notes |
|
|
26
|
+
| --- | --- |
|
|
27
|
+
| `Title` | Used in search results and link previews. Empty means the page's own title. The counter turns amber past 60 characters and blocks nothing. |
|
|
28
|
+
| `Description` | The same, past 160 characters. |
|
|
29
|
+
| `Social image` | From the media library; 1200 × 630 is recommended. |
|
|
30
|
+
| `Hide from search engines` | A switch. On, the page asks search engines not to index or follow it. |
|
|
31
|
+
|
|
32
|
+
The values are content: they are saved as a draft, published with the page, and per language. A description edited on a draft does not reach the live page until the next publish, and an old version shows the description it was published with.
|
|
33
|
+
|
|
34
|
+
## What the site emits
|
|
35
|
+
|
|
36
|
+
**Astro**
|
|
37
|
+
|
|
38
|
+
Every page type and entry page view receives `head`, and the layout hands it to `Head` from `smoodly/astro/components` inside `<head>`:
|
|
39
|
+
|
|
40
|
+
File: `src/layouts/Layout.astro`
|
|
41
|
+
|
|
42
|
+
```astro
|
|
43
|
+
<head>
|
|
44
|
+
<meta charset="utf-8" />
|
|
45
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
46
|
+
<Head of={head} fallback="Lorem Co." />
|
|
47
|
+
</head>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
For a page it writes the document title, the description, the Open Graph title, description, image and URL, the canonical link to the page's own address, and `noindex` when the switch is on. `fallback` titles a document Smoodly has no title for. A page type without `seo: true` still gets its page title; `seo: true` adds the tab and the rest on top.
|
|
51
|
+
|
|
52
|
+
Every page and entry gets its canonical link. On a multilingual site it also gets the hreflang links to the same page in the other languages. [Language switcher and hreflang](../languages/language-switcher.md) covers where they come from.
|
|
53
|
+
|
|
54
|
+
The links are absolute on `site` in `astro.config.ts`, which the starter reads from `SITE_URL`. Without `site` they are built from each request's own address, and the build warns. Set `SITE_URL` to your production address where you deploy: [Deploying](../getting-started/deploying.md) lists it with the other variables.
|
|
55
|
+
|
|
56
|
+
An entry gets its metadata the same way when its collection turns SEO on, described below. Without it, an entry page gets its canonical link and the language alternates only.
|
|
57
|
+
|
|
58
|
+
**Next.js**
|
|
59
|
+
|
|
60
|
+
The route file hands metadata to Next through `smoodlyMetadata`, which the installer wires. The multilingual layout's catch-all route:
|
|
61
|
+
|
|
62
|
+
File: `app/[locale]/[[...path]]/page.tsx`
|
|
63
|
+
|
|
64
|
+
```tsx
|
|
65
|
+
export async function generateMetadata({ params }: { params: Params }) {
|
|
66
|
+
const { locale, path = [] } = await params;
|
|
67
|
+
return smoodlyMetadata(path, { locale });
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
For a page it sets the document title, the description, the Open Graph title, description, image and URL, the canonical link to the page's own address, and `robots` when the switch is on. The Open Graph image is served at 1200 pixels wide from the media bucket. What the tab leaves empty falls through to the defaults in your root layout. A page type without `seo: true` still gets its page title as the document title; `seo: true` adds the tab and the rest on top.
|
|
72
|
+
|
|
73
|
+
Every page and entry gets its canonical link. On a multilingual site it also gets `alternates.languages`, the hreflang links to the same page in the other languages. [Language switcher and hreflang](../languages/language-switcher.md) covers where they come from.
|
|
74
|
+
|
|
75
|
+
The links are absolute when your root layout sets `metadataBase`. Every starter sets it with `metadataBase: siteUrl()`, from `smoodly`: it reads `SITE_URL`, refuses a value without `https://` by name, and falls back to `http://localhost:3000` with a warning in production. Set `SITE_URL` to your production address when you deploy: [Deploying](../getting-started/deploying.md) lists it with the other variables.
|
|
76
|
+
|
|
77
|
+
An entry gets its metadata the same way when its collection turns SEO on, described below. Without it, an entry page gets its canonical link and the language alternates only.
|
|
78
|
+
|
|
79
|
+
## SEO for entries
|
|
80
|
+
|
|
81
|
+
A collection can give its entries the same SEO & Social panel. Add `seo` to the collection:
|
|
82
|
+
|
|
83
|
+
File: `smoodly/collections.ts`
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import { f, smoodly } from "smoodly";
|
|
87
|
+
|
|
88
|
+
export const notes = smoodly.collection({
|
|
89
|
+
name: "notes",
|
|
90
|
+
title: "Notes",
|
|
91
|
+
titleField: "title",
|
|
92
|
+
path: "notes",
|
|
93
|
+
seo: { description: "excerpt", image: "cover" },
|
|
94
|
+
fields: {
|
|
95
|
+
title: f.text().localized(),
|
|
96
|
+
slug: f.slug({ from: "title" }),
|
|
97
|
+
excerpt: f.text().localized(),
|
|
98
|
+
cover: f.image(),
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`seo: true` adds the panel and falls an empty title back on `titleField`. The object form also names the fields that fill an empty description and an empty social image, so articles carry real metadata before anyone touches the panel; an editor's value in the panel still wins, and the switch hides one entry from search engines. The panel's hints say what an empty value falls back on.
|
|
104
|
+
|
|
105
|
+
The values are saved per language, in the draft, like the entry's other fields. Reads return them as `seo`, the effective values with the fallbacks applied, and the page head uses them.
|
|
106
|
+
|
|
107
|
+
**Astro**
|
|
108
|
+
|
|
109
|
+
An entry read with `getLiveEntry("articles", { path, locale })` carries them as `data.seo`, and an entry page's `entry.seo`.
|
|
110
|
+
|
|
111
|
+
**Next.js**
|
|
112
|
+
|
|
113
|
+
File: `components/ArticleMeta.tsx`
|
|
114
|
+
|
|
115
|
+
```tsx
|
|
116
|
+
import { q } from "@/smoodly/server";
|
|
117
|
+
|
|
118
|
+
export async function articleDescription(id: string, locale: string) {
|
|
119
|
+
const article = await q().getEntry("articles", id, { locale });
|
|
120
|
+
return article?.seo?.description;
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`$seo` is reserved: a collection cannot declare a field of that name, and the panel's values never show up in `fields`.
|
|
125
|
+
|
|
126
|
+
Next, [Collections](../collections/defining.md).
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Version history
|
|
2
|
+
|
|
3
|
+
Every publish keeps a version. Editors look back at any of them and restore one as the new draft, without touching what is live.
|
|
4
|
+
|
|
5
|
+
## What a version is
|
|
6
|
+
|
|
7
|
+
A version is a snapshot of a page's content in one language. Smoodly keeps one for every publish, plus the current draft. Saving rewrites the draft rather than adding a version, so a page's history is a short list of the moments someone chose to go live, not a trail of keystrokes.
|
|
8
|
+
|
|
9
|
+
The same holds for entries and globals.
|
|
10
|
+
|
|
11
|
+
## Looking back
|
|
12
|
+
|
|
13
|
+
*History* is the first item in the ⋯ menu next to *Publish*, on the page editor, the entry form and a global's form. It opens a modal with the versions of the selected language on the left, newest first. The current draft is tagged *Draft* and the version the site serves is tagged *Live*.
|
|
14
|
+
|
|
15
|
+
Selecting a version shows it on the right: a page renders as the site would render it, an entry or a global appears as its form, read-only. Each version carries when it was saved. The page's *Updated* line, in the inspector, names the editor whose last change touched it.
|
|
16
|
+
|
|
17
|
+
## Restoring
|
|
18
|
+
|
|
19
|
+
*Restore* copies the selected version's content into the draft, after a confirm. The current draft is replaced; nothing goes live until an editor publishes, so a restore can be looked at and discarded like any other change. Restoring never removes a version: the history only grows.
|
|
20
|
+
|
|
21
|
+
A page that has never been published has nothing to restore, and *Discard changes* on a page with unpublished changes drops the abandoned draft rather than keeping it as a version.
|
|
22
|
+
|
|
23
|
+
Next, [SEO and social](../pages/seo.md).
|