@areca-cms/skills 0.4.0 → 0.5.0

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.
@@ -0,0 +1,177 @@
1
+ # Multilingual sites (SDK)
2
+
3
+ Read this when the CMS site has content languages. `get_site_info` in the MCP server, or `content.languages` in the SDK, tells you: `content.languages` is `null` on a single-language site, and then the `locale` options, `entry.locale` and the `Locale` type do not apply.
4
+
5
+ ## Configure the languages
6
+
7
+ ```ts
8
+ // areca.config.ts
9
+ import { defineSiteContent } from "@areca-cms/site-sdk";
10
+
11
+ export default defineSiteContent({
12
+ siteKey: "acme",
13
+ languages: { main: "en", all: ["en", "nl"] },
14
+ i18n: { paraglide: "./src/paraglide/runtime.js" },
15
+ collections: {
16
+ articles: {
17
+ route: "/journal/$slug",
18
+ routes: { nl: "/journaal/$slug" },
19
+ fields: [
20
+ { key: "title", label: "Title", type: "text", required: true },
21
+ { key: "slug", label: "Slug", type: "slug", required: true },
22
+ { key: "price", label: "Price", type: "number", translatable: false },
23
+ ],
24
+ },
25
+ },
26
+ globals: {
27
+ homePage: { label: "Home page", requiredLanguages: ["nl"], fields: [/* ... */] },
28
+ },
29
+ });
30
+ ```
31
+
32
+ - `languages.main` is the fallback and the language of shared values. `languages.all` is the switcher order and includes `main`. Validation fails on duplicates, a `main` that is not in `all`, or an empty `all`.
33
+ - `routes` maps a language to its route pattern. Keys must be in `languages.all`, and each pattern must contain `$slug`. Languages not listed use `route`. The pattern has no language prefix; the site adds it.
34
+ - `requiredLanguages` on a collection or global lists the languages (other than main) that must be complete before an entry is marked ready. Every key must be in `languages.all`.
35
+ - `translatable` on a field overrides the default. Text, textarea, richtext and url are per language by default; every other type is shared across languages.
36
+ - `i18n.paraglide` is the Paraglide runtime path. When it is set, `areca pull` wires its `getLocale` into the generated content object.
37
+ - `languages` and the per-language settings are sent with the schema by `manifest push`. `i18n` and `assets` stay local.
38
+ - `manifest pull` lists language changes before it writes. Removing a language, or changing `main`, needs confirmation (`--yes` without a TTY). Adding one only appears in the diff. Ask the user before confirming: a removed language stops being served.
39
+ - `areca pull` warns when `project.inlang/settings.json` disagrees with `languages`.
40
+
41
+ The generated `areca.gen.ts` exports `languages` (`{ main, all }`) and the `Locale` union, which is registered for the typed API. Commit it like any other generated file.
42
+
43
+ ## Quick start with Paraglide
44
+
45
+ 1. `pnpm add @inlang/paraglide-js`, with `project.inlang/settings.json` listing the same `locales` as `languages.all`.
46
+ 2. Add the Vite plugin with `strategy: ['url', 'baseLocale']`, so the locale comes from the URL prefix only.
47
+ 3. Compile before anything imports the generated content: `paraglide-js compile --project ./project.inlang --outdir ./src/paraglide`. Then run `areca pull`.
48
+ 4. Wrap the server entry in `paraglideMiddleware`, so server rendering and prerendering see the URL's language.
49
+ 5. Localize the router's URLs with `deLocalizeUrl` and `localizeUrl` in `createRouter({ rewrite })`. Routes are written once in the main language.
50
+ 6. `content.articles.all()` now returns the language of the current URL. Use `getLocale()` for `Intl` formatting.
51
+
52
+ Prerender every language's tree. Each entry's `alternates` gives its path in each language.
53
+
54
+ ## Other i18n libraries
55
+
56
+ `areca pull` wires only Paraglide. For anything else, build your own content object from the generated schema and pass a resolver:
57
+
58
+ ```ts
59
+ // src/content.ts
60
+ import { createContent } from "@areca-cms/site-sdk/content";
61
+ import { schema, globalSchema } from "./generated/content/areca.gen.ts";
62
+ import i18next from "i18next";
63
+
64
+ export const content = createContent({
65
+ schema,
66
+ globalSchema,
67
+ dir: "src/generated/content",
68
+ locale: () => i18next.resolvedLanguage, // called on every read; undefined = main language
69
+ });
70
+ ```
71
+
72
+ `createContent({ locale })` takes a string or a function. A function is called on each read, so it follows the library's current language. Use `i18n.global.locale.value` for vue-i18n, or `i18n.locale` from `@lingui/core` for Lingui.
73
+
74
+ On a server with a per-request language (for example next-intl), do not rely on a global resolver. Pass the request's language per call: `content.in(locale).articles.all()` or `content.articles.all({ locale })`.
75
+
76
+ ## How the language is chosen
77
+
78
+ For each read, the first of these that is set wins:
79
+
80
+ 1. the `locale` in the call: `all({ locale })`, `get(slug, { locale })`, `byId(id, { locale })`;
81
+ 2. the scope from `content.in(locale)`;
82
+ 3. the resolver from `createContent({ locale })`;
83
+ 4. the main language.
84
+
85
+ `content.in(locale)` returns a new content object that reads in that language. A per-call `locale` still overrides the scope.
86
+
87
+ Types follow the same scope. `Href` (and `href()` on a collection or `content.href(ref)`) is the union of every language's route, such as `` `/journal/${string}` | `/journaal/${string}` ``. `content.in("nl").articles.href(entry)` narrows it to the Dutch route `` `/journaal/${string}` ``.
88
+
89
+ Media URLs are rewritten for every language view, so the main language and the other languages all serve the local files from `areca pull`, not CMS URLs.
90
+
91
+ ## Reading in a language
92
+
93
+ Every entry carries:
94
+
95
+ | Field | Meaning |
96
+ | --- | --- |
97
+ | `entry.locale` | The language of `data`, `slug` and `seo`. |
98
+ | `entry.isFallback` | `true` when it is served in a language other than the one asked for. |
99
+ | `entry.locales` | The languages with a complete translation of this entry. |
100
+ | `entry.alternates` | The entry's URL in each of `locales`, from its per-language slug and route, for example `{ en: "/journal/backend", nl: "/journaal/backend" }`. |
101
+
102
+ `content.languages` is `{ main, all }` or `null`. Section values (`content.globals`) carry `$locale` and `$isFallback` instead, and their other values stay flat.
103
+
104
+ ```ts
105
+ const dutch = content.in("nl");
106
+ const posts = dutch.articles.all({ orderBy: { title: "asc" } });
107
+
108
+ // Only entries that really exist in Dutch
109
+ const onlyDutch = content.articles.all({ locale: "nl", fallback: false });
110
+
111
+ const post = content.articles.get("backend-ontwikkelaar", { locale: "nl" });
112
+ if (post?.isFallback) {
113
+ // show the main-language text with a note, or link to the English page
114
+ }
115
+ ```
116
+
117
+ - `fallback` defaults to `true`. With `false`, `all` leaves out entries with no complete translation in the requested language, and `get` returns `undefined` for them.
118
+ - `where` and `orderBy` run on the language's own values, so a Dutch title sorts by the Dutch title.
119
+ - For a requested language, the entry is served from the first complete language of this chain: the exact tag (case-insensitive), its language subtag (`nl-BE` gives `nl`), the main language, then the first complete language in site order.
120
+
121
+ Use `LocaleOptions` (`{ locale?: Locale; fallback?: boolean }`) to type your own wrappers around these calls.
122
+
123
+ ## Match the visitor's language
124
+
125
+ ```ts
126
+ content.matchLocale("nl-BE"); // "nl"
127
+ content.matchLocale("de;q=0.9, en-GB;q=0.8"); // "en"
128
+ content.matchLocale(request.headers.get("accept-language")); // main language when nothing matches
129
+ ```
130
+
131
+ `content.matchLocale(input)` tries each tag in preference order, first as an exact match and then by its subtag. `Accept-Language` headers are sorted by quality. It returns the main language when nothing matches, and `undefined` on a single-language site. The pure function `matchLocale(input, languages)` from `@areca-cms/site-sdk` takes the languages explicitly, which is useful in a redirect or in a resolver.
132
+
133
+ ## Alternate links and hreflang
134
+
135
+ Pass the entry's alternates and language to `seoHead` (or `seoHeadHtml`):
136
+
137
+ ```ts
138
+ import { seoHead } from "@areca-cms/site-sdk";
139
+
140
+ export const head = ({ entry }) =>
141
+ seoHead(entry.seo, {
142
+ locale: entry.locale,
143
+ alternates: entry.alternates,
144
+ main: content.languages?.main,
145
+ });
146
+ ```
147
+
148
+ - `locale` sets `og:locale`, using `ogLocale(tag)` from `@areca-cms/site-sdk/seo`: `nl` stays `nl`, `nl-BE` becomes `nl_BE`.
149
+ - Each alternate becomes a `hreflang` link and an `og:locale:alternate`. The page's own locale is included as a link but not as an `og:locale:alternate`.
150
+ - `main` makes the main language's alternate also the `x-default` link.
151
+ - Relative alternate paths are made absolute from the site origin when the entry has a canonical URL.
152
+ - For pages that aren't entries, build the same alternates from the path in each language.
153
+ - Set the document's `lang` attribute to the page's language.
154
+
155
+ ## Preview in a language
156
+
157
+ The CMS opens preview links with `&locale=<code>`. Pass it through:
158
+
159
+ ```ts
160
+ const result = await content.preview(token, { locale: url.searchParams.get("locale") ?? undefined });
161
+ // or without the content object:
162
+ const raw = await getPreviewEntry({ token, locale: "nl" });
163
+ ```
164
+
165
+ - `content.previewGlobals(source, { locale })` takes the same option for sections.
166
+ - `client.fetchPreview(token, { locale })` sends the same query parameter.
167
+ - A preview draft carries `localeStatus`: a map from each site language to `PreviewLocaleStatus` (`{ complete, missing }`, where `missing` lists the labels of the missing required fields). `PreviewSnapshotEntry` and `PreviewSnapshotGlobal` are the preview types that include it. Use it to show an incomplete draft in the language it was written in.
168
+ - Preview responses are draft content. Send `previewHeaders()` (`X-Robots-Tag: noindex, nofollow`, `Cache-Control: no-store`) and keep them out of caches and search.
169
+
170
+ ## Checklist
171
+
172
+ - `languages` in `areca.config.ts` matches the CMS site and `project.inlang/settings.json`.
173
+ - `routes` for each language use the same segments as the router's localized URLs.
174
+ - Shared values (prices, contact details, product names) are `translatable: false`.
175
+ - Pages prerender in every language and link to their alternates.
176
+ - Untranslated entries either fall back with a note or are left out with `fallback: false`.
177
+ - Preview routes read `locale` from the query string and send `previewHeaders()`.
@@ -86,6 +86,7 @@ await pullContent({ outDir: "src/generated/content", client });
86
86
  | `404` / no publication on pull | Nothing is live yet. Go live in the CMS, then pull again. |
87
87
  | `429` | 300 requests/min per token. Wait for `Retry-After`. |
88
88
  | `400` / `413` on push | Invalid schema, or the manifest is over 256 KiB |
89
+ | `Removing <key.field> deletes content` / `FieldDropsRequiredError` | Nothing was pushed. Ask the user before confirming, then re-run with `--confirm-drop <key.field>` (or `--yes`), or pass `confirmDropFields` to `syncManifest`. |
89
90
  | `No content file for collection` | Run `areca pull`, then check the collection key |
90
91
  | `Unsafe collection or field identifier` | Rename the key to match `^[A-Za-z][A-Za-z0-9_-]{0,63}$` |
91
92
  | `Collection key "<key>" is reserved` / `resolve`, `href`, `preview`, `globals`, `previewGlobals` | Collections cannot use these names, because the content object uses them. Rename the collection. Field keys may keep them. |