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,99 @@
|
|
|
1
|
+
# Defining a global
|
|
2
|
+
|
|
3
|
+
A global is content that exists once and appears on many pages: the header, the footer, the company's contact details. You declare it in code; editors edit it in one place under Globals.
|
|
4
|
+
|
|
5
|
+
## Two kinds
|
|
6
|
+
|
|
7
|
+
`smoodly.global()` takes a name, a title, and exactly one of `section` or `fields`:
|
|
8
|
+
|
|
9
|
+
File: `smoodly/globals.ts`
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { f, smoodly } from "smoodly";
|
|
13
|
+
import { headerSchema } from "@/components/sections/Header.schema";
|
|
14
|
+
import { footerSchema } from "@/components/sections/Footer.schema";
|
|
15
|
+
import { quoteSchema } from "@/components/sections/Quote.schema";
|
|
16
|
+
|
|
17
|
+
export const header = smoodly.global({ name: "header", title: "Header", section: headerSchema, layout: true });
|
|
18
|
+
|
|
19
|
+
export const footer = smoodly.global({ name: "footer", title: "Footer", section: footerSchema, layout: true });
|
|
20
|
+
|
|
21
|
+
export const contact = smoodly.global({
|
|
22
|
+
name: "contact",
|
|
23
|
+
title: "Contact details",
|
|
24
|
+
fields: {
|
|
25
|
+
email: f.text(),
|
|
26
|
+
phone: f.text().optional(),
|
|
27
|
+
address: f.text().localized(),
|
|
28
|
+
},
|
|
29
|
+
layout: true,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export const quote = smoodly.global({ name: "quote", title: "Quote", section: quoteSchema });
|
|
33
|
+
|
|
34
|
+
export const globals = [header, footer, contact, quote];
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
- A **visual global** takes a registered section's schema. Its fields, settings and styles are the section's, and it renders through the section's view. The starter's header, footer and quote are visual.
|
|
38
|
+
- An **object global** takes fields of its own and renders nowhere by itself; your code reads it. The starter's contact details are one, read by the footer.
|
|
39
|
+
|
|
40
|
+
Register the list in the config's `globals`. A visual global's section must be in the `sections` list too, which is why the starter's header and footer sit in `smoodly/sections.ts` although no zone lists them.
|
|
41
|
+
|
|
42
|
+
## Layout globals
|
|
43
|
+
|
|
44
|
+
`layout: true` says a global is part of every page: the header, the footer, the contact details the footer shows. On Astro the page's own read brings every layout global along, so the layout's `getLiveEntry` calls cost nothing; a page that hides the header simply does not read it. Leave the flag off a global that editors place in zones or that one view reads, such as the starter's quote or a form section used on a few pages: it is resolved into each page that places it, and a view that reads it pays one call. In development, a page render that reads an unflagged global prints a hint naming it, once. On Next the flag changes nothing: its data cache keeps every global until that global is published again.
|
|
45
|
+
|
|
46
|
+
## In the admin
|
|
47
|
+
|
|
48
|
+
*Site* in the rail lists every global with its kind, *Visual* or *Fields*, and its status in the selected language. Each opens a form like an entry's: the fields in schema order, and for a visual global the *Settings* tab's groups after them. The form autosaves, and carries the same *Publish* control, *History* and *Discard changes* as a page. A visual global's page also says on how many pages it is used.
|
|
49
|
+
|
|
50
|
+
A global exists in every language from the start, so there is nothing to add or delete. Publishing is per language, as for pages. A field's value is shared across languages unless it chains `.localized()`: the starter's contact email is one value, its address is translated.
|
|
51
|
+
|
|
52
|
+
## Reading a global
|
|
53
|
+
|
|
54
|
+
Your code reads a global by name and language, through the [reads](../collections/queries.md). It is `null` until the global is published in that language, so the caller renders nothing rather than an empty header.
|
|
55
|
+
|
|
56
|
+
**Astro**
|
|
57
|
+
|
|
58
|
+
A global item is its own live collection, `globalLoader(footer)` in `src/live.config.ts`, read with `getLiveEntry("footer", { locale })`; the value is the entry's `data`:
|
|
59
|
+
|
|
60
|
+
File: `src/layouts/Layout.astro`
|
|
61
|
+
|
|
62
|
+
```astro
|
|
63
|
+
---
|
|
64
|
+
// …
|
|
65
|
+
const [{ entry: header }, { entry: footer }, { entry: contact }] = await Promise.all([
|
|
66
|
+
getLiveEntry("header", { locale }),
|
|
67
|
+
getLiveEntry("footer", { locale }),
|
|
68
|
+
getLiveEntry("contact", { locale }),
|
|
69
|
+
]);
|
|
70
|
+
// …
|
|
71
|
+
---
|
|
72
|
+
// …
|
|
73
|
+
{footer && <Footer {...footer.data.fields} settings={footer.data.settings} styles={footer.data.styles} contact={contact?.data.fields} />}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**Next.js**
|
|
77
|
+
|
|
78
|
+
`getGlobal` reads it:
|
|
79
|
+
|
|
80
|
+
File: `app/[locale]/layout.tsx`
|
|
81
|
+
|
|
82
|
+
```tsx
|
|
83
|
+
const [footer, contact] = await Promise.all([
|
|
84
|
+
q().getGlobal("footer", { locale }),
|
|
85
|
+
q().getGlobal("contact", { locale }),
|
|
86
|
+
]);
|
|
87
|
+
// …
|
|
88
|
+
{footer && <FooterView {...footer.fields} settings={footer.settings} styles={footer.styles} contact={contact?.fields} />}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
A global resolves to `{ locale, fields }`, and a visual global adds `settings` with defaults filled, and `styles`. Spread `fields` onto the section's view, with `settings` and `styles` beside it, and the section renders with the spacing steps an editor chose and the id the canvas uses to select it.
|
|
92
|
+
|
|
93
|
+
## Placing a global on a page
|
|
94
|
+
|
|
95
|
+
A visual global can also be placed in a zone. `z.locked(quote)` binds a zone to it, so every page of that type shows the one quote; in a freeform or sections zone that allows the global's section, the *Add section* picker offers it under *Global*. A placed global is a link, not a copy: editing it anywhere changes it everywhere, and the canvas tags it *global*.
|
|
96
|
+
|
|
97
|
+
A page cannot be published in a language while a global it shows is unpublished there. Publishing the page does not publish the global; that is a click of its own, in the global's canvas card or on its page.
|
|
98
|
+
|
|
99
|
+
Next, [Header and footer](../globals/header-and-footer.md) walks through the starter's layout.
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Header and footer
|
|
2
|
+
|
|
3
|
+
The starter's header and footer are visual globals rendered by your layout, not sections placed on pages. This page walks through how they are defined, rendered and edited.
|
|
4
|
+
|
|
5
|
+
## The sections
|
|
6
|
+
|
|
7
|
+
The header is a section like any other, with one difference in its schema:
|
|
8
|
+
|
|
9
|
+
File: `components/sections/Header.schema.ts`
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
export const headerSchema = smoodly.schema.section({
|
|
13
|
+
name: "header",
|
|
14
|
+
title: "Header",
|
|
15
|
+
description: "The site header: brand and navigation.",
|
|
16
|
+
fields: {
|
|
17
|
+
brand: f.text(),
|
|
18
|
+
nav: f.list(f.object({ label: f.text(), href: f.link() })).localized(),
|
|
19
|
+
},
|
|
20
|
+
sample: { brand: "Lorem Co.", nav: [{ label: "Home", href: "/" }] },
|
|
21
|
+
// Nobody pads the header from the editor: no Spacing & layout group.
|
|
22
|
+
styles: false,
|
|
23
|
+
});
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`styles: false` takes away the *Spacing & layout* group, because the header's spacing is the design's. The brand is shared across languages and the navigation is localized as a whole, so each language has its own labels and links. The footer follows the same pattern with a tagline, three link columns and a copyright line.
|
|
27
|
+
|
|
28
|
+
Both are registered in `smoodly/sections.ts` and named with `smoodly.global()` in `smoodly/globals.ts`, which gives each a row under *Site* and its `sample` as starting content. No zone lists them, so the *Add section* picker never offers them. Both are `layout: true` ([Defining a global](../globals/defining.md#layout-globals)): on Astro they arrive with the page's own read, so rendering them costs no extra call.
|
|
29
|
+
|
|
30
|
+
## Rendering them
|
|
31
|
+
|
|
32
|
+
**Astro**
|
|
33
|
+
|
|
34
|
+
The layout reads the header, the footer and the contact details as live entries, and renders the sections' views with the values spread:
|
|
35
|
+
|
|
36
|
+
File: `src/layouts/Layout.astro`
|
|
37
|
+
|
|
38
|
+
```astro
|
|
39
|
+
---
|
|
40
|
+
// …
|
|
41
|
+
const [{ entry: header }, { entry: footer }, { entry: contact }] = await Promise.all([
|
|
42
|
+
getLiveEntry("header", { locale }),
|
|
43
|
+
getLiveEntry("footer", { locale }),
|
|
44
|
+
getLiveEntry("contact", { locale }),
|
|
45
|
+
]);
|
|
46
|
+
const codes = Object.keys(locales);
|
|
47
|
+
const links = codes.length > 1
|
|
48
|
+
? codes.map((code) => ({ code, url: href(locales[code]?.path ?? "/", code), current: code === locale }))
|
|
49
|
+
: undefined;
|
|
50
|
+
---
|
|
51
|
+
// …
|
|
52
|
+
{header && <Header {...header.data.fields} settings={header.data.settings} styles={header.data.styles} home={href("/", locale)} locales={links} />}
|
|
53
|
+
<main><slot /></main>
|
|
54
|
+
{footer && <Footer {...footer.data.fields} settings={footer.data.settings} styles={footer.data.styles} contact={contact?.data.fields} />}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The footer's view takes one prop its schema does not have, the contact object; a view is a plain `.astro` file and accepts anything. The header's language links need the page on screen, and in Astro the layout knows it: every page view passes its own `locales` map to the layout, which Astro renders after the page.
|
|
58
|
+
|
|
59
|
+
**Next.js**
|
|
60
|
+
|
|
61
|
+
The root layout reads the footer and the contact details and renders the footer's view:
|
|
62
|
+
|
|
63
|
+
File: `app/[locale]/layout.tsx`
|
|
64
|
+
|
|
65
|
+
```tsx
|
|
66
|
+
import { FooterView } from "@/components/sections/Footer";
|
|
67
|
+
import { q } from "@/smoodly/server";
|
|
68
|
+
// …
|
|
69
|
+
const [footer, contact] = await Promise.all([
|
|
70
|
+
q().getGlobal("footer", { locale }),
|
|
71
|
+
q().getGlobal("contact", { locale }),
|
|
72
|
+
]);
|
|
73
|
+
return (
|
|
74
|
+
<html lang={locale} className={`${serif.variable} ${sans.variable}`}>
|
|
75
|
+
<body>
|
|
76
|
+
<NextIntlClientProvider>
|
|
77
|
+
{children}
|
|
78
|
+
{footer && <FooterView {...footer.fields} settings={footer.settings} styles={footer.styles} contact={contact?.fields} />}
|
|
79
|
+
</NextIntlClientProvider>
|
|
80
|
+
</body>
|
|
81
|
+
</html>
|
|
82
|
+
);
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The layout renders `FooterView`, the view, rather than `Footer`, the paired component, because it passes one prop the footer's schema does not have: the contact object. A paired component accepts only its schema's fields; a view is a plain component and accepts anything. The footer exports both.
|
|
86
|
+
|
|
87
|
+
The header is not in the layout. A layout cannot know which page is on screen, and the language links in the header must land on the same page in the other language. So every page type renders the header itself, through the starter's `PageFrame`, from the page's own language map:
|
|
88
|
+
|
|
89
|
+
File: `components/PageFrame.tsx`
|
|
90
|
+
|
|
91
|
+
```tsx
|
|
92
|
+
export async function PageFrame({ locale, locales, children }: {
|
|
93
|
+
locale: string;
|
|
94
|
+
locales: Record<string, LocaleAlternate | null>;
|
|
95
|
+
children: ReactNode;
|
|
96
|
+
}) {
|
|
97
|
+
const header = await q().getGlobal("header", { locale });
|
|
98
|
+
const codes = Object.keys(locales);
|
|
99
|
+
const links: LocaleLink[] | undefined = codes.length > 1
|
|
100
|
+
? codes.map((code) => ({ code, url: href(locales[code]?.path ?? "/", code), current: code === locale }))
|
|
101
|
+
: undefined;
|
|
102
|
+
return (
|
|
103
|
+
<>
|
|
104
|
+
{header && <HeaderView {...header.fields} settings={header.settings} styles={header.styles} home={href("/", locale)} locales={links} />}
|
|
105
|
+
<main>{children}</main>
|
|
106
|
+
</>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
On a single-language site there are no links and the header shows the brand and navigation alone. [Language switcher and hreflang](../languages/language-switcher.md) covers the `locales` map.
|
|
112
|
+
|
|
113
|
+
## Editing them
|
|
114
|
+
|
|
115
|
+
Editors change the header and footer under *Site*, or in any page's canvas: both are selectable there, tagged *global*, with their own form and their own *Publish* in the inspector. The header's navigation edits in place like any list with [marks](../content-modeling/marks.md). Publishing a page never publishes the footer, because a footer going live on every page is a deliberate click.
|
|
116
|
+
|
|
117
|
+
Next, [The media library](../media/library.md).
|
package/docs/index.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Smoodly
|
|
2
|
+
|
|
3
|
+
> Smoodly is a code-first CMS for Astro and Next.js. A developer registers sections, collections and page types in code, editors build pages in a visual admin mounted in the site at /admin, and the content lives in Smoodly Cloud, read by the site over the API.
|
|
4
|
+
|
|
5
|
+
Start a site with the installer. It creates a fresh app; it does not add Smoodly to an existing one. With these flags it asks nothing:
|
|
6
|
+
|
|
7
|
+
npx create-smoodly-app <folder> --yes --framework astro|next --starter blank|lorem [--locales en,fi] --space <sp_…> --secret-key <sk_…> --publishable-key <pk_…>
|
|
8
|
+
|
|
9
|
+
The three space values are on the project's production space page at smoodly.io. In an installed site, the guides for that version are in node_modules/smoodly/docs/ and the project's AGENTS.md holds the working rules.
|
|
10
|
+
|
|
11
|
+
## Getting started
|
|
12
|
+
|
|
13
|
+
- [Install with create-smoodly-app](getting-started/install.md): One command creates a website with the Smoodly admin mounted, connected to a space that holds its content. At the end you have the Lorem Co. starter running on your machine.
|
|
14
|
+
- [Project structure](getting-started/project-structure.md): What create-smoodly-app puts in your project, and which files you change as you build the site. Everything is ordinary code in your repository; Smoodly adds a folder of setup and mounts the admin.
|
|
15
|
+
- [Your first page](getting-started/first-page.md): Create a page in the admin, fill it with sections and publish it, as an editor would. Then look at the code that decided what the editor was allowed to do.
|
|
16
|
+
- [Content from code](getting-started/content-from-code.md): Write pages, entries, globals and images from a script instead of the admin: to seed a new site, or to move a client's old content in. At the end you have a script you can run again after it stops halfway.
|
|
17
|
+
- [Working together](getting-started/working-together.md): Who can open the admin, and what happens when two people open the same page: the admin shows who is where, and never lets one person's save silently overwrite another's.
|
|
18
|
+
- [Keys](getting-started/keys.md): The space id and the two keys a Smoodly site uses, where each one may live, what the public one can reach, and how to rotate or revoke a key.
|
|
19
|
+
- [Deploying](getting-started/deploying.md): Put your Smoodly site on the internet: give it the production space's values and let its build push the schema; then, once it is live, add a preview space to develop against.
|
|
20
|
+
- [Export and import](getting-started/export-and-import.md): Copy a space's content into a folder, and from the folder into a new space: every page, entry, version and media file. It is how you move a site or keep a copy.
|
|
21
|
+
|
|
22
|
+
## Content modeling
|
|
23
|
+
|
|
24
|
+
- [Modeling content](content-modeling/overview.md): The five things you register, what each one holds, and where it lives in your project. Read this once; the rest of the group takes them one at a time.
|
|
25
|
+
- [Defining a section](content-modeling/sections.md): A section is a full-width component editors place in a zone. You describe its fields once, in a schema file, and Smoodly derives the editor form, the Add section entry and the TypeScript props from that one definition. This page walks through the Lorem Co. starter's Hero.
|
|
26
|
+
- [Defining a page type](content-modeling/page-types.md): A page type is a page component with zones: the places where editors add sections. This page walks through the Lorem Co. starter's Home and Page types, and what a page type learns about its place in the site.
|
|
27
|
+
- [Zones and freedom](content-modeling/zones.md): A zone is a place in a page type where editors add sections. Its policy sets how much they may change there, from filling one section's fields to placing any section in any order.
|
|
28
|
+
- [Settings, styles and CSS](content-modeling/styles-and-settings.md): Beyond its fields, a placed section carries three more things editors can change: the settings you declare, Smoodly's own spacing and width steps, and a custom CSS box you can turn on. All three sit on the Settings tab.
|
|
29
|
+
- [The custom section](content-modeling/custom-section.md): The one section editors lay out themselves: rows of richtext columns on a 12-column grid, offered where a zone allows it. You turn it on, pick what its columns may hold, and style the result; Smoodly owns the grid.
|
|
30
|
+
- [Marks and inline editing](content-modeling/marks.md): Marks are the attributes a section spreads on the elements that render its fields. They tell the canvas which field an editor clicked, so text edits in place and everything else opens in the inspector.
|
|
31
|
+
- [When the schema changes](content-modeling/changing-schemas.md): Sites change after launch: a section goes, a field is renamed, a zone gets stricter. This page covers what stored content keeps when the code moves on, how the admin shows it, and what Publish refuses until an editor fixes it.
|
|
32
|
+
|
|
33
|
+
## Fields
|
|
34
|
+
|
|
35
|
+
- [All fields](fields/overview.md): Every field builder on f, what editors see for each, and the chain of modifiers they share. The pages that follow take them a few at a time.
|
|
36
|
+
- [Text, number, date and time](fields/text.md): 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.
|
|
37
|
+
- [Richtext and elements](fields/richtext.md): 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.
|
|
38
|
+
- [Link](fields/link.md): A link is a text box that takes a full URL or a path on your site. Pair it with a label in an object to make a button.
|
|
39
|
+
- [Choices](fields/choices.md): A switch, a dropdown, a row of pills or a checkbox list: the fields that pick from a closed set you declare.
|
|
40
|
+
- [Image, video and file](fields/media.md): The three media fields, the resolved values your view receives, and the components that render them.
|
|
41
|
+
- [List and object](fields/list-and-object.md): Group fields into one value with an object, or let editors repeat a value with a list. The two nest one level, which is as far as a sidebar form stays readable.
|
|
42
|
+
- [Reference and tags](fields/reference-and-tags.md): A reference points at an entry in a collection and arrives in the view already loaded. Tags are short labels editors reuse across entries.
|
|
43
|
+
- [Conditional fields](fields/conditional.md): Hide a field while another field has a certain value, so editors see only the fields that apply.
|
|
44
|
+
|
|
45
|
+
## Pages
|
|
46
|
+
|
|
47
|
+
- [The page tree](pages/page-tree.md): How editors create, arrange and remove pages in the admin, and what your configuration allows them to do.
|
|
48
|
+
- [Paths and slugs](pages/paths-and-slugs.md): 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.
|
|
49
|
+
- [Drafts and publishing](pages/drafts-and-publishing.md): 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.
|
|
50
|
+
- [Version history](pages/version-history.md): Every publish keeps a version. Editors look back at any of them and restore one as the new draft, without touching what is live.
|
|
51
|
+
- [SEO and social](pages/seo.md): 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.
|
|
52
|
+
|
|
53
|
+
## Collections
|
|
54
|
+
|
|
55
|
+
- [Defining a collection](collections/defining.md): A collection is a content type with a fixed shape and many entries: posts, people, products. You declare its fields once; editors get a list, a form and publishing for it.
|
|
56
|
+
- [Entry pages](collections/entry-pages.md): Give a collection a path and each entry gets its own URL. The entry page is the component that renders one entry there.
|
|
57
|
+
- [Queries](collections/queries.md): Read entries, globals and the page tree from your own views, typed by your registry, cached, and aware of drafts.
|
|
58
|
+
- [Ordering](collections/ordering.md): A collection has one order, set in its schema, that the admin's list and your queries share.
|
|
59
|
+
|
|
60
|
+
## Globals
|
|
61
|
+
|
|
62
|
+
- [Defining a global](globals/defining.md): A global is content that exists once and appears on many pages: the header, the footer, the company's contact details. You declare it in code; editors edit it in one place under Globals.
|
|
63
|
+
- [Header and footer](globals/header-and-footer.md): The starter's header and footer are visual globals rendered by your layout, not sections placed on pages. This page walks through how they are defined, rendered and edited.
|
|
64
|
+
|
|
65
|
+
## Media
|
|
66
|
+
|
|
67
|
+
- [The media library](media/library.md): 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.
|
|
68
|
+
- [Images and crops](media/images-and-crops.md): 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.
|
|
69
|
+
|
|
70
|
+
## Languages
|
|
71
|
+
|
|
72
|
+
- [Locales and routing](languages/locales.md): 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.
|
|
73
|
+
- [Localized fields](languages/localized-fields.md): 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.
|
|
74
|
+
- [Language switcher and hreflang](languages/language-switcher.md): 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.
|
|
75
|
+
|
|
76
|
+
## Frameworks
|
|
77
|
+
|
|
78
|
+
- [Astro](frameworks/astro.md): 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.
|
|
79
|
+
- [Next.js](frameworks/nextjs.md): 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.
|
|
80
|
+
|
|
81
|
+
## Reference
|
|
82
|
+
|
|
83
|
+
- [Upgrading](reference/upgrading.md): Update the package and deploy. Your content model lives in your code and every build pushes it to your space: there is no migration to run.
|
|
@@ -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).
|